diff --git a/.gitea/workflows/test.yaml b/.gitea/workflows/test.yaml index 60cda89..0d30e82 100644 --- a/.gitea/workflows/test.yaml +++ b/.gitea/workflows/test.yaml @@ -46,6 +46,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Run JS Unit Tests + shell: bash + run: node --test + - name: Test CVE Differential Action uses: ./ with: @@ -85,3 +89,15 @@ jobs: fi fi echo "✅ All assertions passed (including >= 3 CVEs verification)." + + - name: Archive SBOMs and CVE Reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: sboms-and-reports-${{ matrix.image_set.name }}-inbuild-${{ matrix.use_inbuild_sbom }} + path: | + sboms/ + cve-reports/ + cve-delta.md + cve-summary.md + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 363f8b8..e8a1dc6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ cve-*.json cve-delta.md cve-summary.md node_modules/ +!test/fixtures/** diff --git a/action.yaml b/action.yaml index 2257cd4..83df1e9 100644 --- a/action.yaml +++ b/action.yaml @@ -168,6 +168,7 @@ runs: docker buildx imagetools inspect "${{ inputs.head-image }}" \ --format "{{ json (index .SBOM \"${platform}\").SPDX }}" > "sboms/head/sbom-${arch_tag}.spdx.json" 2>/dev/null || true cleanup_file "sboms/head/sbom-${arch_tag}.spdx.json" + [ -f "sboms/head/sbom-${arch_tag}.spdx.json" ] && cp "sboms/head/sbom-${arch_tag}.spdx.json" "sboms/head/inbuild-sbom-${arch_tag}.spdx.json" 2>/dev/null || true fi if [ ! -f "sboms/head/sbom-${arch_tag}.spdx.json" ]; then @@ -184,6 +185,7 @@ runs: docker buildx imagetools inspect "${{ inputs.target-image }}" \ --format "{{ json (index .SBOM \"${platform}\").SPDX }}" > "sboms/target/sbom-${arch_tag}.spdx.json" 2>/dev/null || true cleanup_file "sboms/target/sbom-${arch_tag}.spdx.json" + [ -f "sboms/target/sbom-${arch_tag}.spdx.json" ] && cp "sboms/target/sbom-${arch_tag}.spdx.json" "sboms/target/inbuild-sbom-${arch_tag}.spdx.json" 2>/dev/null || true fi if [ ! -f "sboms/target/sbom-${arch_tag}.spdx.json" ]; then @@ -201,6 +203,7 @@ runs: docker buildx imagetools inspect "${{ inputs.head-image }}" \ --format '{{ json .SBOM.SPDX }}' > sboms/head/sbom-default.spdx.json 2>/dev/null || true cleanup_file sboms/head/sbom-default.spdx.json + [ -f sboms/head/sbom-default.spdx.json ] && cp sboms/head/sbom-default.spdx.json sboms/head/inbuild-sbom-default.spdx.json 2>/dev/null || true fi if [ ! -f sboms/head/sbom-default.spdx.json ]; then @@ -217,6 +220,7 @@ runs: docker buildx imagetools inspect "${{ inputs.target-image }}" \ --format '{{ json .SBOM.SPDX }}' > sboms/target/sbom-default.spdx.json 2>/dev/null || true cleanup_file sboms/target/sbom-default.spdx.json + [ -f sboms/target/sbom-default.spdx.json ] && cp sboms/target/sbom-default.spdx.json sboms/target/inbuild-sbom-default.spdx.json 2>/dev/null || true fi if [ ! -f sboms/target/sbom-default.spdx.json ]; then @@ -304,6 +308,7 @@ runs: head_summary=() target_summary=() + use_inbuild="${{ inputs.use-inbuild-sbom }}" # 1. Scan Head SBOMs if [ ${#head_sboms[@]} -eq 0 ]; then @@ -316,9 +321,40 @@ runs: err_log="cve-reports/head/cve-${arch}.err" echo "Scanning head SBOM for architecture: $arch" + scan_success=false if trivy sbom --skip-version-check --format json --output "$out_json" "$f" 2>"$err_log"; then + scan_success=true + fi + + vuln_cnt=0 + if [ -s "$out_json" ]; then + cnt=$(grep -c '"VulnerabilityID"' "$out_json" 2>/dev/null || true) + cnt=$(echo "$cnt" | tr -d '[:space:]') + [ -n "$cnt" ] && vuln_cnt="$cnt" + fi + + # Fallback if in-build SBOM scan produced 0 CVEs (e.g. incompatible third-party format) or scan failed + if [ "$use_inbuild" = "true" ] && { [ "$scan_success" != "true" ] || [ "$vuln_cnt" -eq 0 ]; }; then + echo "::warning::In-build SBOM for head image (${arch}) yielded ${vuln_cnt} CVE findings. Falling back to direct Trivy image scan..." + t_plat="linux/${arch}" + [ "$arch" = "default" ] && t_plat="" + plat_arg="" + [ -n "$t_plat" ] && plat_arg="--platform $t_plat" + if trivy image --skip-version-check $plat_arg --format spdx-json --output "$f" "${{ inputs.head-image }}"; then + echo "Re-scanning head SBOM generated directly by Trivy..." + if trivy sbom --skip-version-check --format json --output "$out_json" "$f" 2>"$err_log"; then + scan_success=true + if [ -s "$out_json" ]; then + cnt=$(grep -c '"VulnerabilityID"' "$out_json" 2>/dev/null || true) + cnt=$(echo "$cnt" | tr -d '[:space:]') + [ -n "$cnt" ] && vuln_cnt="$cnt" + fi + fi + fi + fi + + if [ "$scan_success" = "true" ]; then if [ -s "$out_json" ]; then - vuln_cnt=$(grep -o '"VulnerabilityID"' "$out_json" 2>/dev/null | wc -l || echo 0) if [ "$vuln_cnt" -eq 0 ]; then echo "::warning::Head image architecture '${arch}' has 0 CVE findings. Verify that OS/packages were recognized." head_summary+=(" - ${arch}: ⚠️ WARNING (0 vulnerability findings detected)") @@ -350,9 +386,40 @@ runs: err_log="cve-reports/target/cve-${arch}.err" echo "Scanning target SBOM for architecture: $arch" + scan_success=false if trivy sbom --skip-version-check --format json --output "$out_json" "$f" 2>"$err_log"; then + scan_success=true + fi + + vuln_cnt=0 + if [ -s "$out_json" ]; then + cnt=$(grep -c '"VulnerabilityID"' "$out_json" 2>/dev/null || true) + cnt=$(echo "$cnt" | tr -d '[:space:]') + [ -n "$cnt" ] && vuln_cnt="$cnt" + fi + + # Fallback if in-build SBOM scan produced 0 CVEs (e.g. incompatible third-party format) or scan failed + if [ "$use_inbuild" = "true" ] && { [ "$scan_success" != "true" ] || [ "$vuln_cnt" -eq 0 ]; }; then + echo "::warning::In-build SBOM for target image (${arch}) yielded ${vuln_cnt} CVE findings. Falling back to direct Trivy image scan..." + t_plat="linux/${arch}" + [ "$arch" = "default" ] && t_plat="" + plat_arg="" + [ -n "$t_plat" ] && plat_arg="--platform $t_plat" + if trivy image --skip-version-check $plat_arg --format spdx-json --output "$f" "${{ inputs.target-image }}"; then + echo "Re-scanning target SBOM generated directly by Trivy..." + if trivy sbom --skip-version-check --format json --output "$out_json" "$f" 2>"$err_log"; then + scan_success=true + if [ -s "$out_json" ]; then + cnt=$(grep -c '"VulnerabilityID"' "$out_json" 2>/dev/null || true) + cnt=$(echo "$cnt" | tr -d '[:space:]') + [ -n "$cnt" ] && vuln_cnt="$cnt" + fi + fi + fi + fi + + if [ "$scan_success" = "true" ]; then if [ -s "$out_json" ]; then - vuln_cnt=$(grep -o '"VulnerabilityID"' "$out_json" 2>/dev/null | wc -l || echo 0) if [ "$vuln_cnt" -eq 0 ]; then echo "::warning::Target baseline image architecture '${arch}' has 0 CVE findings. Verify that OS/packages were recognized." target_summary+=(" - ${arch}: ⚠️ WARNING (0 vulnerability findings detected)") diff --git a/cve-diff.js b/cve-diff.js index f0de854..b0cac43 100755 --- a/cve-diff.js +++ b/cve-diff.js @@ -84,14 +84,15 @@ function loadReportsForArg(arg) { process.exit(1); } - if (!data || typeof data !== 'object' || !Array.isArray(data.Results)) { - console.error(`::error::Malformed Trivy report structure (missing Results array) in: ${filePath}`); + if (!data || typeof data !== 'object') { + console.error(`::error::Malformed Trivy report structure in: ${filePath}`); process.exit(1); } + const results = Array.isArray(data.Results) ? data.Results : []; loadedFiles++; - for (const result of data.Results) { + for (const result of results) { const target = result.Target || result.Type || 'App'; const vulns = Array.isArray(result.Vulnerabilities) ? result.Vulnerabilities : []; for (const v of vulns) { @@ -135,7 +136,7 @@ function formatVulnRow(v, allScannedArchs = new Set(), showArch = true) { const installed = v.InstalledVersion || '-'; const fixed = v.FixedVersion || '-'; - let title = (v.Title || v.Description || '').replace(/\n/g, ' ').trim(); + let title = (v.Title || v.Description || '').replace(/\n/g, ' ').replace(/\|/g, '\\|').trim(); if (title.length > 80) { title = title.slice(0, 77) + '...'; } diff --git a/test/cve-diff.test.js b/test/cve-diff.test.js new file mode 100644 index 0000000..0b07766 --- /dev/null +++ b/test/cve-diff.test.js @@ -0,0 +1,111 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { + loadReportsForArg, + formatVulnRow, + renderMarkdownDiff, + renderFullSummary, +} = require('../cve-diff.js'); + +const FIXTURES_DIR = path.join(__dirname, 'fixtures'); + +test('loadReportsForArg loads valid reports and extracts multi-arch metadata', () => { + const targetDir = path.join(FIXTURES_DIR, 'target'); + const cves = loadReportsForArg(targetDir); + + assert.equal(cves.size, 2); + assert.ok(cves.has('CVE-2023-0001:::glibc')); + assert.ok(cves.has('CVE-2023-0002:::openssl')); + + const glibc = cves.get('CVE-2023-0001:::glibc'); + assert.equal(glibc.Severity, 'CRITICAL'); + assert.deepEqual(Array.from(glibc._archs).sort(), ['amd64', 'arm64']); + assert.deepEqual(Array.from(cves.scannedArchs).sort(), ['amd64', 'arm64']); + + const openssl = cves.get('CVE-2023-0002:::openssl'); + assert.equal(openssl.Severity, 'HIGH'); + assert.deepEqual(Array.from(openssl._archs), ['amd64']); +}); + +test('loadReportsForArg handles reports with missing or null Results array without crashing', () => { + const emptyDir = path.join(FIXTURES_DIR, 'empty'); + const cves = loadReportsForArg(emptyDir); + + assert.equal(cves.size, 0); +}); + +test('renderMarkdownDiff produces correct introduced, resolved, and unchanged sets', () => { + const targetDir = path.join(FIXTURES_DIR, 'target'); + const headDir = path.join(FIXTURES_DIR, 'head'); + + const baseCves = loadReportsForArg(targetDir); + const branchCves = loadReportsForArg(headDir); + + const diff = renderMarkdownDiff(baseCves, branchCves, 'main'); + + // Introduced: CVE-2023-0003 (curl) + assert.ok(diff.includes('New Vulnerabilities Introduced (1)')); + assert.ok(diff.includes('CVE-2023-0003')); + assert.ok(diff.includes('`curl`')); + + // Resolved: CVE-2023-0001 (glibc) + assert.ok(diff.includes('Resolved / Removed Vulnerabilities (1)')); + assert.ok(diff.includes('CVE-2023-0001')); + assert.ok(diff.includes('`glibc`')); + + // Unchanged count + assert.ok(diff.includes('| ⚪ **Unchanged Vulnerabilities** | 1 |')); + + // Multi-arch is present in fixtures (amd64, arm64), so Arch column should be shown + assert.ok(diff.includes('| Arch |')); +}); + +test('renderMarkdownDiff hides Arch column when single architecture is scanned', () => { + const targetSingle = path.join(FIXTURES_DIR, 'target', 'cve-amd64.json'); + const headSingle = path.join(FIXTURES_DIR, 'head', 'cve-amd64.json'); + + const baseCves = loadReportsForArg(targetSingle); + const branchCves = loadReportsForArg(headSingle); + + const diff = renderMarkdownDiff(baseCves, branchCves, 'main'); + + assert.ok(!diff.includes('| Arch |')); + assert.ok(diff.includes('New Vulnerabilities Introduced (1)')); + assert.ok(diff.includes('Resolved / Removed Vulnerabilities (1)')); +}); + +test('renderFullSummary outputs summary table and lists all vulnerabilities', () => { + const headDir = path.join(FIXTURES_DIR, 'head'); + const headCves = loadReportsForArg(headDir); + + const summary = renderFullSummary(headCves); + + assert.ok(summary.includes('### 🛡️ Security Scan Report')); + assert.ok(summary.includes('| Total Vulnerabilities | **2** |')); + assert.ok(summary.includes('CVE-2023-0002')); + assert.ok(summary.includes('CVE-2023-0003')); +}); + +test('formatVulnRow properly formats badge, architecture tag, and escape pipes', () => { + const vuln = { + Severity: 'CRITICAL', + VulnerabilityID: 'CVE-2023-0001', + PrimaryURL: 'https://avd.aquasec.com/nvd/cve-2023-0001', + PkgName: 'glibc', + InstalledVersion: '2.31', + FixedVersion: '2.32', + Title: 'A vulnerability with | pipe symbol', + _archs: new Set(['amd64']), + }; + + const allArchs = new Set(['amd64', 'arm64']); + const rowWithArch = formatVulnRow(vuln, allArchs, true); + + assert.ok(rowWithArch.includes('🔴 CRITICAL')); + assert.ok(rowWithArch.includes('`amd64`')); + assert.ok(rowWithArch.includes('\\|')); // Pipe escaped + + const rowWithoutArch = formatVulnRow(vuln, allArchs, false); + assert.ok(!rowWithoutArch.includes('`amd64`')); +}); diff --git a/test/fixtures/empty/cve-amd64.json b/test/fixtures/empty/cve-amd64.json new file mode 100644 index 0000000..8573fa6 --- /dev/null +++ b/test/fixtures/empty/cve-amd64.json @@ -0,0 +1,8 @@ +{ + "SchemaVersion": 2, + "ArtifactName": "debian:bullseye-slim", + "ArtifactType": "spdx", + "Metadata": { + "OS": null + } +} diff --git a/test/fixtures/head/cve-amd64.json b/test/fixtures/head/cve-amd64.json new file mode 100644 index 0000000..caf2619 --- /dev/null +++ b/test/fixtures/head/cve-amd64.json @@ -0,0 +1,30 @@ +{ + "SchemaVersion": 2, + "ArtifactName": "debian:bookworm-slim", + "ArtifactType": "container_image", + "Results": [ + { + "Target": "debian:bookworm-slim (debian 12)", + "Class": "os-pkgs", + "Type": "debian", + "Vulnerabilities": [ + { + "VulnerabilityID": "CVE-2023-0002", + "PkgName": "openssl", + "InstalledVersion": "1.1.1n-0+deb11u4", + "FixedVersion": "1.1.1n-0+deb11u5", + "Severity": "HIGH", + "Title": "Timing attack in OpenSSL" + }, + { + "VulnerabilityID": "CVE-2023-0003", + "PkgName": "curl", + "InstalledVersion": "7.88.1-10", + "FixedVersion": "7.88.1-10+deb12u1", + "Severity": "MEDIUM", + "Title": "Buffer overflow in curl" + } + ] + } + ] +} diff --git a/test/fixtures/head/cve-arm64.json b/test/fixtures/head/cve-arm64.json new file mode 100644 index 0000000..7ef472c --- /dev/null +++ b/test/fixtures/head/cve-arm64.json @@ -0,0 +1,22 @@ +{ + "SchemaVersion": 2, + "ArtifactName": "debian:bookworm-slim", + "ArtifactType": "container_image", + "Results": [ + { + "Target": "debian:bookworm-slim (debian 12)", + "Class": "os-pkgs", + "Type": "debian", + "Vulnerabilities": [ + { + "VulnerabilityID": "CVE-2023-0003", + "PkgName": "curl", + "InstalledVersion": "7.88.1-10", + "FixedVersion": "7.88.1-10+deb12u1", + "Severity": "MEDIUM", + "Title": "Buffer overflow in curl" + } + ] + } + ] +} diff --git a/test/fixtures/target/cve-amd64.json b/test/fixtures/target/cve-amd64.json new file mode 100644 index 0000000..6b65a9c --- /dev/null +++ b/test/fixtures/target/cve-amd64.json @@ -0,0 +1,36 @@ +{ + "SchemaVersion": 2, + "ArtifactName": "debian:bullseye-slim", + "ArtifactType": "container_image", + "Metadata": { + "OS": { + "Family": "debian", + "Name": "11" + } + }, + "Results": [ + { + "Target": "debian:bullseye-slim (debian 11)", + "Class": "os-pkgs", + "Type": "debian", + "Vulnerabilities": [ + { + "VulnerabilityID": "CVE-2023-0001", + "PkgName": "glibc", + "InstalledVersion": "2.31-13+deb11u5", + "FixedVersion": "2.31-13+deb11u6", + "Severity": "CRITICAL", + "Title": "Memory corruption in glibc" + }, + { + "VulnerabilityID": "CVE-2023-0002", + "PkgName": "openssl", + "InstalledVersion": "1.1.1n-0+deb11u4", + "FixedVersion": "1.1.1n-0+deb11u5", + "Severity": "HIGH", + "Title": "Timing attack in OpenSSL" + } + ] + } + ] +} diff --git a/test/fixtures/target/cve-arm64.json b/test/fixtures/target/cve-arm64.json new file mode 100644 index 0000000..cf6373f --- /dev/null +++ b/test/fixtures/target/cve-arm64.json @@ -0,0 +1,22 @@ +{ + "SchemaVersion": 2, + "ArtifactName": "debian:bullseye-slim", + "ArtifactType": "container_image", + "Results": [ + { + "Target": "debian:bullseye-slim (debian 11)", + "Class": "os-pkgs", + "Type": "debian", + "Vulnerabilities": [ + { + "VulnerabilityID": "CVE-2023-0001", + "PkgName": "glibc", + "InstalledVersion": "2.31-13+deb11u5", + "FixedVersion": "2.31-13+deb11u6", + "Severity": "CRITICAL", + "Title": "Memory corruption in glibc" + } + ] + } + ] +}