diff --git a/.circleci/config.yml b/.circleci/config.yml index cd6d7ca4..284f463d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,7 +1,7 @@ version: 2.1 orbs: - snyk: snyk/snyk@2.3.3 # https://github.com/snyk/snyk-orb/ + snyk: snyk/snyk@2.3.0 # https://github.com/snyk/snyk-orb/ jobs: build: @@ -68,7 +68,43 @@ jobs: else echo "Skipping monitor for non-main branch - no data sent to dashboard" fi - - run: snyk test --severity-threshold=high --all-projects --detection-depth=10 --scan-all-unmanaged || true # will NOT fail step if any results are found + - run: snyk test --severity-threshold=high --all-projects --detection-depth=10 --scan-all-unmanaged --json > snyk-results.json || true # will NOT fail step if any results are found + - run: + name: CVE Summary + command: | + if [ -f snyk-results.json ]; then + # Check if scan failed (error field present) + if jq -e '.error' snyk-results.json > /dev/null 2>&1; then + echo "Snyk scan failed - see snyk-results.json for details" + exit 0 + fi + cve_count=$(jq '[.[] | select(type == "object") | select(.vulnerabilities != null) | .vulnerabilities[] | select( (.identifiers.CVE | length > 0 ) or (.identifiers.GHSA | length > 0) or (.identifiers.CWE | length > 0 ) )] | length' snyk-results.json) + if [ "$cve_count" -gt 0 ]; then + echo "=== CVEs Found ===" + jq -r '.[] | select(type == "object") | select(.vulnerabilities != null) | .vulnerabilities[] | select( (.identifiers.CVE | length > 0) or (.identifiers.GHSA | length > 0) or (.identifiers.CWE | length > 0 ) ) | [( [.identifiers.CVE,.identifiers.GHSA,.identifiers.CWE] | map(select(.!=null and .!=[])) | flatten | join(",")), .severity, .moduleName, .title] | @csv' snyk-results.json | sort -u | tee snyk-summary.csv + echo "==================" + else + echo "No CVEs found - clean scan!" + fi + else + echo "No snyk-results.json found — skipping CVE summary" + fi + - run: + name: Convert Snyk results to JUnit XML + when: always + command: | + if [ -f snyk-results.json ]; then + mkdir -p test-results/junit + bash scripts/convert_snyk_junit.sh snyk-results.json > test-results/junit/snyk-junit.xml + fi + - store_test_results: + path: test-results + - store_artifacts: + path: snyk-results.json + destination: snyk-results.json + - store_artifacts: + path: snyk-summary.csv + destination: snyk-summary.csv # will NOT fail step if any results are found workflows: version: 2 @@ -78,4 +114,4 @@ workflows: context: - PagerDuty - Snyk - - build \ No newline at end of file + - build diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0932bd95..0bce0a7a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ shadow = "8.1.1" ospackage = "11.11.2" buildInfo = "0.9" buildConfig = "3.1.0" -jacksonDatabind = "2.21.1" +jacksonDatabind = "2.21.2" picocli = "4.7.7" snakeYaml = "2.0" #used for authz lib integration diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 072d09cf..d35678dd 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -172,6 +172,16 @@ + + + + + + + + + + @@ -182,6 +192,11 @@ + + + + + @@ -190,6 +205,17 @@ + + + + + + + + + + + @@ -198,6 +224,17 @@ + + + + + + + + + + + @@ -206,6 +243,17 @@ + + + + + + + + + + + diff --git a/scripts/convert_snyk_junit.sh b/scripts/convert_snyk_junit.sh new file mode 100755 index 00000000..d558ec46 --- /dev/null +++ b/scripts/convert_snyk_junit.sh @@ -0,0 +1,177 @@ +#!/bin/bash +set -euo pipefail + +# Converts Snyk JSON output (from `snyk test --all-projects --json`) to JUnit XML +# for native display in CircleCI's Tests tab. +# +# Key behaviors: +# - Deduplicates findings by moduleName (package) across all scanned projects +# - Filters out license issues (snyk:lic: prefix), keeping only security vulnerabilities +# - Uses moduleName as classname for consistent CircleCI test tracking across runs +# - Aggregates all CVEs/CWEs/Snyk IDs for a package into one test case +# - Reports highest severity and highest CVSS score per package + +readonly IN=${1:-/dev/stdin} + +xml_escape() { + local s="$1" + s="${s//&/&}" + s="${s///>}" + s="${s//\"/"}" + s="${s//\'/'}" + echo "$s" +} + +emit_empty() { + echo '' +} + +convert_snyk_junit() { + local inputfile="$IN" + + # If reading from stdin, buffer to a temp file so we can read multiple times + if [ "$inputfile" = "/dev/stdin" ]; then + inputfile=$(mktemp) + cat > "$inputfile" + fi + + echo "input file: $IN" >&2 + + if ! jq empty < "$inputfile" 2>/dev/null; then + echo "ERROR: Invalid JSON in $IN" >&2 + emit_empty + return 0 + fi + + if jq -e 'type == "object" and has("error")' < "$inputfile" > /dev/null 2>&1; then + echo "Snyk scan had errors - generating empty JUnit output" >&2 + emit_empty + return 0 + fi + + local tmpfile + tmpfile=$(mktemp) + trap "rm -f '$tmpfile'" EXIT + + # Flatten all vulnerabilities across projects, filter out license issues, + # deduplicate by moduleName, and aggregate findings per package. + jq -c ' + def sev_rank: + if . == "critical" then 0 + elif . == "high" then 1 + elif . == "medium" then 2 + elif . == "low" then 3 + else 4 end; + + [ .[] | select(type == "object") | .vulnerabilities[]? ] + | map(select(.id | startswith("snyk:lic:") | not)) + | group_by(.moduleName) + | map({ + moduleName: .[0].moduleName, + versions: ([.[].version] | unique | sort), + snykIds: ([.[].id] | unique), + cves: [.[].identifiers.CVE[]?] | unique | sort, + cwes: [.[].identifiers.CWE[]?] | unique | sort, + titles: ([.[].title] | unique), + highestSeverity: ([ .[].severity ] | map({sev: ., rank: (. | sev_rank)}) | sort_by(.rank) | .[0].sev), + maxCvssScore: ([.[].cvssScore // 0] | max), + fixedIn: [.[].fixedIn[]?] | unique | sort, + isUpgradable: ([.[].isUpgradable] | any), + totalOccurrences: length + }) + | sort_by((.highestSeverity | sev_rank), .moduleName) + ' < "$inputfile" > "$tmpfile" + + local uniquePackages + uniquePackages=$(jq 'length // 0' < "$tmpfile") + uniquePackages=${uniquePackages:-0} + local totalOccurrences + totalOccurrences=$(jq '[.[].totalOccurrences] | add // 0' < "$tmpfile") + totalOccurrences=${totalOccurrences:-0} + + echo "uniquePackages: $uniquePackages (from $totalOccurrences total occurrences across projects)" >&2 + + echo '' + echo "" + + for sev in critical high medium low; do + local sevPkgs + sevPkgs=$(jq -c "[.[] | select(.highestSeverity == \"$sev\")]" < "$tmpfile") + local sevCount + sevCount=$(echo "$sevPkgs" | jq 'length') + sevCount=${sevCount:-0} + + if [ "$sevCount" -eq 0 ]; then + continue + fi + + echo " " + + echo "$sevPkgs" | jq -c '.[]' | while IFS= read -r pkg; do + local moduleName highestSev maxCvss isUpgradable totalOcc + moduleName=$(echo "$pkg" | jq -r '.moduleName') + highestSev=$(echo "$pkg" | jq -r '.highestSeverity') + maxCvss=$(echo "$pkg" | jq -r '.maxCvssScore') + isUpgradable=$(echo "$pkg" | jq -r '.isUpgradable') + totalOcc=$(echo "$pkg" | jq -r '.totalOccurrences') + + local versions snykIds cves cwes titles fixedIn + versions=$(echo "$pkg" | jq -r '.versions | join(", ")') + snykIds=$(echo "$pkg" | jq -r '.snykIds | join(", ")') + cves=$(echo "$pkg" | jq -r '.cves | join(", ")') + cwes=$(echo "$pkg" | jq -r '.cwes | join(", ")') + titles=$(echo "$pkg" | jq -r '.titles | join("; ")') + fixedIn=$(echo "$pkg" | jq -r '.fixedIn | join(", ")') + + local idLabel="" + if [ -n "$cves" ]; then + idLabel="$cves" + elif [ -n "$cwes" ]; then + idLabel="$cwes" + else + idLabel="$snykIds" + fi + + local sevUpper + sevUpper=$(echo "$highestSev" | tr '[:lower:]' '[:upper:]') + + local escapedName escapedClass escapedTitle + escapedName=$(xml_escape "${sevUpper} ${moduleName} (${totalOcc} findings) ${idLabel}") + escapedClass=$(xml_escape "$moduleName") + escapedTitle=$(xml_escape "$titles") + + cat < + + + + +END + done + + echo " " + done + + if [ "$uniquePackages" -eq 0 ]; then + echo " " + echo " " + echo " " + fi + + echo "" +} + +convert_snyk_junit