11 Commits
Author SHA1 Message Date
Philipp 77357f0c3f refactor(action): replace bash blocks with zero-dependency Node.js modules
CI Tests / JS Unit Tests (pull_request) Successful in 46s
Philipp/Global-Workflows: Pre-commit Lint (Scoped) / pre-commit (pull_request) Successful in 1m54s
CI Tests / cross-check-mixed-sbom (sbom=false) (pull_request) Failing after 3m22s
CI Tests / cross-check-mixed-sbom (sbom=true) (pull_request) Failing after 2m38s
CI Tests / with-inbuild-sbom (sbom=false) (pull_request) Failing after 4m21s
CI Tests / without-inbuild-sbom (sbom=true) (pull_request) Failing after 5m36s
CI Tests / without-inbuild-sbom (sbom=false) (pull_request) Failing after 6m47s
CI Tests / with-inbuild-sbom (sbom=true) (pull_request) Failing after 2m59s
Extract ~450 lines of complex bash from action.yaml into modular,
unit-tested Node.js scripts in lib/. All logic is covered by 26 native
node:test unit tests (node --test, no external dependencies).

Modules added:
- lib/platforms.js   - platform parsing and OCI manifest auto-discovery
- lib/distro.js      - SPDX PURL distro detection (os_name/os_version)
- lib/verifier.js    - Trivy arch mapping and SHA256 verification
- lib/diagnostics.js - CI terminal diagnostic formatters
- lib/install-trivy.js  - Trivy install/verify entrypoint (replaces bash step)
- lib/extract-sboms.js  - SBOM extraction entrypoint (replaces bash step)
- lib/scan-sboms.js     - SBOM scan + diff entrypoint (replaces bash step)

Tests added (all pass, auto-discovered by
ode --test):
- test/platforms.test.js
- test/distro.test.js
- test/verifier.test.js
- test/diagnostics.test.js
- test/orchestration.test.js

action.yaml shrinks from 554 to 107 lines. Inputs are bridged to Node
scripts via INPUT_* env vars so modules are fully decoupled from the
Actions runner and testable in isolation.
2026-09-15 00:39:13 +02:00
Philipp 0ccfd5f512 Merge pull request 'ci: add matrix test workflow for debian slim images in .gitea' (#15) from feat-test-matrix-workflow into main
CI Tests / JS Unit Tests (push) Successful in 9s
CI Tests / cross-check-mixed-sbom (sbom=false) (push) Successful in 2m46s
CI Tests / cross-check-mixed-sbom (sbom=true) (push) Successful in 3m3s
CI Tests / without-inbuild-sbom (sbom=true) (push) Successful in 2m57s
CI Tests / without-inbuild-sbom (sbom=false) (push) Successful in 3m3s
CI Tests / with-inbuild-sbom (sbom=false) (push) Successful in 2m53s
CI Tests / with-inbuild-sbom (sbom=true) (push) Successful in 3m15s
Reviewed-on: #15
2026-09-15 00:26:57 +02:00
Philipp c98b31c23a Merge branch 'main' into feat-test-matrix-workflow
CI Tests / JS Unit Tests (pull_request) Successful in 17s
Philipp/Global-Workflows: Pre-commit Lint (Scoped) / pre-commit (pull_request) Successful in 1m49s
CI Tests / cross-check-mixed-sbom (sbom=false) (pull_request) Successful in 1m58s
CI Tests / without-inbuild-sbom (sbom=true) (pull_request) Successful in 3m1s
CI Tests / without-inbuild-sbom (sbom=false) (pull_request) Successful in 3m8s
CI Tests / cross-check-mixed-sbom (sbom=true) (pull_request) Successful in 3m35s
CI Tests / with-inbuild-sbom (sbom=false) (pull_request) Successful in 2m59s
CI Tests / with-inbuild-sbom (sbom=true) (pull_request) Successful in 3m6s
2026-09-15 00:17:06 +02:00
Philipp def42c37a8 docs: document trade-offs and differences between in-build and direct scanning
CI Tests / JS Unit Tests (pull_request) Successful in 8s
Philipp/Global-Workflows: Pre-commit Lint (Scoped) / pre-commit (pull_request) Successful in 1m34s
CI Tests / cross-check-mixed-sbom (sbom=false) (pull_request) Successful in 2m35s
CI Tests / cross-check-mixed-sbom (sbom=true) (pull_request) Successful in 2m42s
CI Tests / without-inbuild-sbom (sbom=false) (pull_request) Successful in 7m5s
CI Tests / without-inbuild-sbom (sbom=true) (pull_request) Successful in 5m52s
CI Tests / with-inbuild-sbom (sbom=false) (pull_request) Successful in 5m46s
CI Tests / with-inbuild-sbom (sbom=true) (pull_request) Successful in 2m22s
2026-09-15 00:12:42 +02:00
Philipp 64747ec466 fix: robustly parse distro from in-build SBOM package PURLs
CI Tests / JS Unit Tests (pull_request) Successful in 8s
Philipp/Global-Workflows: Pre-commit Lint (Scoped) / pre-commit (pull_request) Successful in 1m35s
CI Tests / cross-check-mixed-sbom (sbom=false) (pull_request) Successful in 3m3s
CI Tests / cross-check-mixed-sbom (sbom=true) (pull_request) Successful in 3m11s
CI Tests / without-inbuild-sbom (sbom=true) (pull_request) Successful in 5m32s
CI Tests / with-inbuild-sbom (sbom=false) (pull_request) Successful in 5m32s
CI Tests / with-inbuild-sbom (sbom=true) (pull_request) Successful in 1m49s
CI Tests / without-inbuild-sbom (sbom=false) (pull_request) Successful in 7m2s
2026-09-14 23:37:07 +02:00
Philipp 3b9043a746 feat: detect distro from in-build SBOM PURLs and pass --distro to trivy
CI Tests / JS Unit Tests (pull_request) Successful in 8s
Philipp/Global-Workflows: Pre-commit Lint (Scoped) / pre-commit (pull_request) Successful in 1m35s
CI Tests / cross-check-mixed-sbom (sbom=false) (pull_request) Successful in 2m57s
CI Tests / cross-check-mixed-sbom (sbom=true) (pull_request) Successful in 3m5s
CI Tests / without-inbuild-sbom (sbom=true) (pull_request) Successful in 3m59s
CI Tests / without-inbuild-sbom (sbom=false) (pull_request) Successful in 5m34s
CI Tests / with-inbuild-sbom (sbom=false) (pull_request) Successful in 3m52s
CI Tests / with-inbuild-sbom (sbom=true) (pull_request) Successful in 1m36s
2026-09-14 23:25:09 +02:00
Philipp 790c1aa3de docs: update README with in-build fallback and unit test details
CI Tests / JS Unit Tests (pull_request) Successful in 12s
Philipp/Global-Workflows: Pre-commit Lint (Scoped) / pre-commit (pull_request) Successful in 1m16s
CI Tests / cross-check-mixed-sbom (sbom=false) (pull_request) Successful in 3m45s
CI Tests / cross-check-mixed-sbom (sbom=true) (pull_request) Successful in 3m40s
CI Tests / without-inbuild-sbom (sbom=false) (pull_request) Successful in 7m8s
CI Tests / with-inbuild-sbom (sbom=false) (pull_request) Successful in 4m24s
CI Tests / without-inbuild-sbom (sbom=true) (pull_request) Successful in 4m24s
CI Tests / with-inbuild-sbom (sbom=true) (pull_request) Successful in 1m56s
2026-09-14 22:34:12 +02:00
Philipp f31be5eef6 ci: move unit tests into dedicated unit-test job
CI Tests / JS Unit Tests (pull_request) Successful in 11s
Philipp/Global-Workflows: Pre-commit Lint (Scoped) / pre-commit (pull_request) Successful in 1m16s
CI Tests / cross-check-mixed-sbom (sbom=false) (pull_request) Successful in 5m57s
CI Tests / without-inbuild-sbom (sbom=false) (pull_request) Successful in 5m39s
CI Tests / cross-check-mixed-sbom (sbom=true) (pull_request) Successful in 5m58s
CI Tests / without-inbuild-sbom (sbom=true) (pull_request) Successful in 5m34s
CI Tests / with-inbuild-sbom (sbom=false) (pull_request) Successful in 5m38s
CI Tests / with-inbuild-sbom (sbom=true) (pull_request) Successful in 5m57s
2026-09-14 22:29:21 +02:00
Philipp d89fb25d7b test: add unit tests for cve-diff and fallback for in-build sbom scans
CI Tests / cross-check-mixed-sbom (sbom=true) (pull_request) Successful in 3m0s
CI Tests / cross-check-mixed-sbom (sbom=false) (pull_request) Successful in 3m0s
CI Tests / without-inbuild-sbom (sbom=false) (pull_request) Successful in 3m1s
CI Tests / without-inbuild-sbom (sbom=true) (pull_request) Successful in 7m14s
CI Tests / with-inbuild-sbom (sbom=false) (pull_request) Successful in 7m14s
CI Tests / with-inbuild-sbom (sbom=true) (pull_request) Successful in 7m32s
Philipp/Global-Workflows: Pre-commit Lint (Scoped) / pre-commit (pull_request) Successful in 1m29s
2026-09-14 22:27:49 +02:00
Philipp 40209bbeed style: format test workflow to comply with pretty-format-yaml
CI Tests / cross-check-mixed-sbom (sbom=true) (pull_request) Failing after 4m58s
CI Tests / cross-check-mixed-sbom (sbom=false) (pull_request) Successful in 5m2s
CI Tests / without-inbuild-sbom (sbom=false) (pull_request) Successful in 4m8s
CI Tests / without-inbuild-sbom (sbom=true) (pull_request) Successful in 4m52s
CI Tests / with-inbuild-sbom (sbom=true) (pull_request) Failing after 3m57s
CI Tests / with-inbuild-sbom (sbom=false) (pull_request) Successful in 4m55s
Philipp/Global-Workflows: Pre-commit Lint (Scoped) / pre-commit (pull_request) Successful in 1m40s
2026-09-14 03:06:49 +02:00
Philipp 44c4b5a304 ci: add matrix test workflow for debian slim images in .gitea
CI Tests / cross-check-mixed-sbom (sbom=true) (pull_request) Failing after 9m33s
CI Tests / cross-check-mixed-sbom (sbom=false) (pull_request) Successful in 10m38s
CI Tests / without-inbuild-sbom (sbom=false) (pull_request) Successful in 10m32s
CI Tests / without-inbuild-sbom (sbom=true) (pull_request) Successful in 3m9s
CI Tests / with-inbuild-sbom (sbom=true) (pull_request) Failing after 5m21s
CI Tests / with-inbuild-sbom (sbom=false) (pull_request) Successful in 5m25s
Philipp/Global-Workflows: Pre-commit Lint (Scoped) / pre-commit (pull_request) Failing after 5m8s
2026-09-14 03:03:32 +02:00
23 changed files with 1518 additions and 364 deletions

No files matched your search

+111
View File
@@ -0,0 +1,111 @@
name: CI Tests
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
jobs:
unit-test:
name: JS Unit Tests
runs-on: k8s
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run JS Unit Tests
shell: bash
run: node --test
test:
name: ${{ matrix.image_set.name }} (sbom=${{ matrix.use_inbuild_sbom }})
needs: unit-test
runs-on: k8s
strategy:
fail-fast: false
matrix:
image_set:
# Test 1: Slim images with built-in SBOMs (2 architectures, 15-40 CVEs each)
- name: with-inbuild-sbom
head_image: debian:bookworm-slim
target_image: debian:bullseye-slim
architectures: amd64,arm64
expect_arch_col: true
# Test 2: Slim images without built-in SBOMs (1 architecture, 70-120 CVEs each)
- name: without-inbuild-sbom
head_image: debian:buster-slim
target_image: debian:stretch-slim
architectures: amd64
expect_arch_col: false
# Test 3: Cross-check (Head has built-in SBOM, Target does not, 15-70 CVEs each)
- name: cross-check-mixed-sbom
head_image: debian:bookworm-slim
target_image: debian:buster-slim
architectures: amd64
expect_arch_col: false
use_inbuild_sbom:
- 'true'
- 'false'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Test CVE Differential Action
uses: ./
with:
head-image: ${{ matrix.image_set.head_image }}
target-image: ${{ matrix.image_set.target_image }}
target-branch: main
architectures: ${{ matrix.image_set.architectures }}
use-inbuild-sbom: ${{ matrix.use_inbuild_sbom }}
output-delta: cve-delta.md
output-summary: cve-summary.md
- name: Verify Report Generation & Content
shell: bash
run: |
set -euo pipefail
test -s cve-delta.md || (echo "::error::cve-delta.md is empty or missing" && exit 1)
test -s cve-summary.md || (echo "::error::cve-summary.md is empty or missing" && exit 1)
# Verify that each scan produced at least 3 CVE findings
cve_count=$(grep -c "CVE-" cve-summary.md || echo 0)
echo "Total CVE entries found in summary report: $cve_count"
if [ "$cve_count" -lt 3 ]; then
echo "::error::Expected at least 3 CVEs in cve-summary.md, found only $cve_count"
exit 1
fi
echo "=== Delta Report Preview ==="
head -n 25 cve-delta.md
# Verify architectural column rendering behavior
if [ "${{ matrix.image_set.expect_arch_col }}" = "true" ]; then
grep -q "| Arch |" cve-delta.md || (echo "::error::Expected Arch column in multi-arch report" && exit 1)
else
if grep -q "| Arch |" cve-delta.md; then
echo "::error::Did not expect Arch column in single-arch report" && exit 1
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
+1
View File
@@ -13,3 +13,4 @@ cve-*.json
cve-delta.md
cve-summary.md
node_modules/
!test/fixtures/**
+27 -13
View File
@@ -7,13 +7,12 @@ A self-contained Gitea and GitHub Actions composite action that extracts multi-p
## ✨ Features
- **Dynamic Multi-Platform Support**: Automatically discovers and scans all architectures present in container images, or iterates over an explicit list of target architectures.
- **Fast Attestation Extraction**: Uses `docker buildx imagetools inspect` to extract platform-specific SBOMs directly from registry manifest indices in seconds without downloading layer blobs.
- **Graceful Fallbacks**: If SBOM attestations are missing for any platform, automatically falls back to generating SBOMs directly with Trivy per architecture.
- **Fast Attestation Extraction with Automatic Fallback**: Uses `docker buildx imagetools inspect` to extract platform-specific SBOMs directly from registry manifest indices. If attestations are missing or produce 0 CVE findings (common when third-party generators omit OS qualifiers in SPDX), it automatically falls back to generating SBOMs directly with Trivy per architecture.
- **Set-Based Differential Analysis**: Categorizes vulnerabilities into:
- 🔴 **Introduced Vulnerabilities** (newly added in this branch / commit)
- 🟢 **Fixed Vulnerabilities** (present in target baseline, resolved in this branch)
- ⚪ **Unchanged Vulnerabilities** (pre-existing in target baseline)
- **Secure by Default**: Verified Trivy binary downloads against static SHA256 checksum lockfiles and single-step unified caching (binary + vulnerability database).
- **Secure & Unit Tested**: Verified Trivy binary downloads against static SHA256 checksum lockfiles, single-step unified caching, and comprehensive native unit tests (`node:test`).
---
@@ -28,7 +27,7 @@ A self-contained Gitea and GitHub Actions composite action that extracts multi-p
| `output-delta` | Output path for the CVE delta Markdown report | No | `cve-delta.md` |
| `output-summary` | Output path for the full vulnerability Markdown summary | No | `cve-summary.md` |
| `write-summary` | Whether to append the generated reports to the job summary (`$GITHUB_STEP_SUMMARY`) | No | `true` |
| `use-inbuild-sbom` | Whether to inspect and use built-in image SBOM attestations. If false, generates SBOMs directly with Trivy | No | `false` |
| `use-inbuild-sbom` | Whether to inspect and use built-in image SBOM attestations. If false (recommended default), generates SBOMs directly with Trivy for maximum vulnerability fidelity. If true, extracts attestations without pulling layers (faster, but findings reflect binary-package SBOMs without rootfs source-mapping; falls back to Trivy image scan if 0 CVEs detected). | No | `false` |
---
@@ -48,21 +47,36 @@ This action supports two modes for obtaining SBOMs:
### 1. Direct Image Scanning with Trivy (`use-inbuild-sbom: false` — Default & Recommended)
By default, the action uses `trivy image --format spdx-json` to generate the SBOM directly from the container images:
- **Full Filesystem Fidelity**: Inspects the entire flattened root filesystem, reading `/etc/os-release` and native package databases (e.g. `/var/lib/dpkg/status`, RPM databases, apk).
- **Accurate OS & Language Package Detection**: Successfully catalogs both OS packages (Debian, Alpine, RHEL, etc.) and application dependencies (Python, Node.js, Go, etc.).
- **Accurate OS & Language Package Detection**: Catalogs both OS packages (Debian, Alpine, RHEL, etc.) and application dependencies (Python, Node.js, Go, etc.).
- **Symmetric, Apples-to-Apples Comparisons**: Both `head` and `target` images are analyzed using the identical Trivy engine and vulnerability database, preventing skewed diffs.
### 2. Built-in Attestation Extraction (`use-inbuild-sbom: true`)
### 2. Built-in Attestation Extraction with Resilient Fallback (`use-inbuild-sbom: true`)
When enabled, the action extracts SBOM attestations attached to the container image using `docker buildx imagetools inspect`:
- **Speed**: Extracts the SBOM JSON metadata directly from the registry manifest index in seconds without pulling image layer blobs.
- **Underlying Generator (Syft)**: Docker Buildx (`sbom: true`) uses BuildKit's embedded Syft scanner (`moby/buildkit-syft-scanner`).
- **Known Limitations with Trivy**:
- **Testing/Rolling Distributions**: Embedded Syft versions may not recognize newer or unreleased distributions (such as Debian 13 "Trixie"), recording the OS as `family="none"` and skipping OS package vulnerability matching.
- **Multi-Stage Builds**: Analyzing layer diffs rather than the flattened container rootfs can cause package database paths to be omitted or detached from the OS manifest.
- **Schema/PURL Discrepancies**: Syft and Trivy express certain package qualifiers and namespaces differently in SPDX. When Trivy scans a Syft SPDX file that lacks explicit distro qualifiers, Trivy will silently skip OS vulnerability checks.
- **Differential Skew**: If a `head` image uses an incomplete Syft SBOM (detecting only language packages) while `target` is scanned directly with Trivy (detecting 400+ OS packages), the differential report will erroneously show hundreds of "resolved" CVEs.
- **Underlying Generator (Syft / Docker Scout)**: Docker Buildx (`sbom: true`) uses BuildKit's embedded Syft scanner (`moby/buildkit-syft-scanner`).
- **Distro Detection**: Automatically extracts OS distribution information from package PURLs (e.g. `debian/12`) and queries Trivy with `--distro` to accurately identify vulnerabilities on the extracted SBOM.
- **Resilient Fallback**: If an extracted in-build SBOM scan produces 0 CVE findings (a common issue when third-party generators omit OS distribution qualifiers from SPDX packages) or if scanning fails, the action **automatically falls back** to running `trivy image --platform "$platform" --format spdx-json` directly on the image. This guarantees that vulnerability scans never fail silently or report false zeros.
- **Audit Copies**: The action preserves copies of both the raw extracted in-build SBOM (`inbuild-sbom-*.spdx.json`) and the final scanned SBOM (`sbom-*.spdx.json`) for auditing and debugging.
### ⚖️ Trade-offs: Why do the results differ between strategies?
The vulnerability results between Strategy 1 (`sbom: false`) and Strategy 2 (`sbom: true`) are **not identical**:
| Feature / Metric | Direct Trivy Scan (`use-inbuild-sbom: false`) | Built-in Attestation (`use-inbuild-sbom: true`) |
| :--- | :--- | :--- |
| **Generator** | Trivy directly on container rootfs | Docker Scout / BuildKit (Syft) |
| **Execution Speed** | Slower (pulls image layer blobs) | **Very Fast** (~5x faster, 0 layer downloads) |
| **Debian Slim Findings Example** | **~225 CVEs** (`debian:bookworm-slim`) | **~104 CVEs** (`debian:bookworm-slim`) |
| **Package Resolution Model** | Source & binary packages (`/var/lib/dpkg/status`) | Binary packages cataloged in SPDX PURLs |
| **Filesystem Inspection** | Full filesystem (static binaries, libraries) | Manifest-only (packages cataloged at build time) |
| **Primary Use Case** | **High-fidelity security audits & release gates** | **High-speed PR checks & developer feedback loops** |
#### Why the vulnerability counts differ:
1. **Debian Source vs. Binary Packages**: Debian security advisories (DSAs / DLAs) are tracked and published per **source package** (e.g., the source package `glibc` produces multiple binary packages like `libc6`, `libc-bin`, `locales`). Trivy's filesystem scanner reads `/var/lib/dpkg/status` to map installed binary packages directly to Debian source package advisories. Syft's default SPDX output catalogs installed binary packages as PURLs without source-package relations, so Trivy's SBOM scan queries only the exact binary package entries that match Debian database schemas without the source-mapping context.
2. **Filesystem vs. Manifest Scope**: Direct image scanning unpacks and inspects the entire container image filesystem (including file hashes, crypto libraries, and static binaries). An in-build SBOM scan is strictly limited to the package inventory written by the build-time generator.
> [!TIP]
> If your builds run on stable base images where BuildKit's Syft scanner accurately captures all OS packages, you can set `use-inbuild-sbom: 'true'` to speed up CI runs. Otherwise, leave it as `false` to ensure consistent and comprehensive scans.
> Setting `use-inbuild-sbom: 'false'` remains the recommended default for maximum accuracy and cross-platform consistency. If you choose to enable `use-inbuild-sbom: 'true'`, the built-in fallback ensures full coverage even when third-party attestations lack OS package metadata.
---
+22 -347
View File
@@ -29,7 +29,7 @@ inputs:
required: false
default: 'true'
use-inbuild-sbom:
description: Whether to inspect and use built-in image SBOM attestations. If false, generates SBOM directly from images with Trivy.
description: Whether to inspect and use built-in image SBOM attestations. If false (recommended default), generates SBOMs directly with Trivy for maximum vulnerability detection fidelity. If true, extracts attestations without pulling layers (faster, but findings may differ from direct scans due to binary vs source package tracking; falls back to Trivy image scan if 0 CVEs detected).
required: false
default: 'false'
@@ -65,358 +65,33 @@ runs:
- name: Install and Verify Trivy
shell: bash
run: |
set -euo pipefail
mkdir -p "$HOME/.local/bin" "$HOME/.cache/trivy-bin"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
ARCH="$(uname -m)"
case "${ARCH}" in
x86_64) T_ARCH="64bit" ;;
aarch64|arm64) T_ARCH="ARM64" ;;
*) echo "::error::Unsupported architecture: ${ARCH}"; exit 1 ;;
esac
checksums_file="${{ github.action_path }}/checksums.txt"
tarball="trivy_${{ steps.vars.outputs.version }}_Linux-${T_ARCH}.tar.gz"
if [ -f "$HOME/.cache/trivy-bin/${tarball}" ] && (cd "$HOME/.cache/trivy-bin" && grep " ${tarball}$" "$checksums_file" | sha256sum -c -); then
echo "Cached Trivy archive verified against lockfile."
else
echo "Archive missing or checksum mismatch; downloading..."
curl -sfLO "https://github.com/aquasecurity/trivy/releases/download/v${{ steps.vars.outputs.version }}/${tarball}" --output-dir "$HOME/.cache/trivy-bin"
(cd "$HOME/.cache/trivy-bin" && grep " ${tarball}$" "$checksums_file" | sha256sum -c -)
fi
tar -xzf "$HOME/.cache/trivy-bin/${tarball}" -C "$HOME/.local/bin" trivy
chmod +x "$HOME/.local/bin/trivy"
env:
TRIVY_VERSION: ${{ steps.vars.outputs.version }}
run: node "${{ github.action_path }}/lib/install-trivy.js"
- name: Extract Multi-Platform SBOMs
shell: bash
run: |
export PATH="$HOME/.local/bin:$PATH"
export TRIVY_SKIP_VERSION_CHECK="true"
mkdir -p sboms/head sboms/target
cleanup_file() {
local f="$1"
if [ -f "$f" ]; then
if [ ! -s "$f" ] || [ "$(cat "$f" 2>/dev/null)" = "null" ]; then
rm -f "$f"
fi
fi
}
head_sbom_err=""
target_sbom_err=""
use_inbuild="${{ inputs.use-inbuild-sbom }}"
# Determine target platforms / architectures
PLATFORMS=()
if [ -n "${{ inputs.architectures }}" ]; then
IFS=',' read -ra RAW_ARCHS <<< "${{ inputs.architectures }}"
for a in "${RAW_ARCHS[@]}"; do
clean_a=$(echo "$a" | xargs)
[ -z "$clean_a" ] && continue
if [[ "$clean_a" != *"/"* ]]; then
PLATFORMS+=("linux/${clean_a}")
else
PLATFORMS+=("$clean_a")
fi
done
else
echo "Auto-discovering architectures from images..."
raw_head=$(docker buildx imagetools inspect --raw "${{ inputs.head-image }}" 2>/dev/null || true)
raw_target=$(docker buildx imagetools inspect --raw "${{ inputs.target-image }}" 2>/dev/null || true)
discovered=$(node -e '
const raw1 = process.argv[1];
const raw2 = process.argv[2];
const set = new Set();
for (const raw of [raw1, raw2]) {
if (!raw) continue;
try {
const data = JSON.parse(raw);
const manifests = data.manifests || [];
for (const m of manifests) {
if (m.platform && m.platform.architecture && m.platform.architecture !== "unknown") {
const os = m.platform.os || "linux";
const variant = m.platform.variant ? "/" + m.platform.variant : "";
set.add(`${os}/${m.platform.architecture}${variant}`);
}
}
} catch (e) {}
}
console.log(Array.from(set).sort().join("\n"));
' "$raw_head" "$raw_target")
if [ -n "$discovered" ]; then
while IFS= read -r line; do
[ -n "$line" ] && PLATFORMS+=("$line")
done <<< "$discovered"
fi
fi
echo "Configured platforms: ${PLATFORMS[*]:-(single platform fallback)}"
for platform in "${PLATFORMS[@]}"; do
arch_tag=$(echo "$platform" | sed -e 's#^[a-zA-Z0-9_-]*/##' -e 's#/#-#g')
# Head image extraction & fallback
if [ "$use_inbuild" = "true" ]; then
echo "Attempting extraction of built-in SBOM for head image ($platform)..."
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"
fi
if [ ! -f "sboms/head/sbom-${arch_tag}.spdx.json" ]; then
echo "Generating SBOM for head image ($platform) with Trivy..."
if ! trivy image --skip-version-check --platform "$platform" --format spdx-json --output "sboms/head/sbom-${arch_tag}.spdx.json" "${{ inputs.head-image }}"; then
echo "::warning::Trivy image scan failed for head image ($platform)"
fi
cleanup_file "sboms/head/sbom-${arch_tag}.spdx.json"
fi
# Target image extraction & fallback
if [ "$use_inbuild" = "true" ]; then
echo "Attempting extraction of built-in SBOM for target image ($platform)..."
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"
fi
if [ ! -f "sboms/target/sbom-${arch_tag}.spdx.json" ]; then
echo "Generating SBOM for target image ($platform) with Trivy..."
if ! trivy image --skip-version-check --platform "$platform" --format spdx-json --output "sboms/target/sbom-${arch_tag}.spdx.json" "${{ inputs.target-image }}"; then
echo "::warning::Trivy image scan failed for target image ($platform)"
fi
cleanup_file "sboms/target/sbom-${arch_tag}.spdx.json"
fi
done
# Fallbacks if no platform-specific SBOMs were extracted (e.g. single-arch image without index)
if [ -z "$(find sboms/head -type f -name 'sbom-*.spdx.json' 2>/dev/null)" ]; then
if [ "$use_inbuild" = "true" ]; then
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
fi
if [ ! -f sboms/head/sbom-default.spdx.json ]; then
echo "Generating default head SBOM directly with Trivy..."
if ! trivy image --skip-version-check --format spdx-json --output sboms/head/sbom-default.spdx.json "${{ inputs.head-image }}"; then
head_sbom_err="Failed to generate SBOM with Trivy for head image"
fi
cleanup_file sboms/head/sbom-default.spdx.json
fi
fi
if [ -z "$(find sboms/target -type f -name 'sbom-*.spdx.json' 2>/dev/null)" ]; then
if [ "$use_inbuild" = "true" ]; then
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
fi
if [ ! -f sboms/target/sbom-default.spdx.json ]; then
echo "Generating default target SBOM directly with Trivy..."
if ! trivy image --skip-version-check --format spdx-json --output sboms/target/sbom-default.spdx.json "${{ inputs.target-image }}"; then
target_sbom_err="Failed to generate SBOM with Trivy for target image"
fi
cleanup_file sboms/target/sbom-default.spdx.json
fi
fi
shopt -s nullglob
head_files=(sboms/head/sbom-*.spdx.json)
target_files=(sboms/target/sbom-*.spdx.json)
if [ ${#head_files[@]} -eq 0 ] && [ -z "$head_sbom_err" ]; then
head_sbom_err="No SBOM generated and head image could not be inspected"
fi
if [ ${#target_files[@]} -eq 0 ] && [ -z "$target_sbom_err" ]; then
target_sbom_err="No SBOM generated and target baseline image could not be inspected"
fi
# Diagnostic overview
echo ""
echo "================================================================================"
echo " SBOM EXTRACTION DIAGNOSTIC OVERVIEW "
echo "================================================================================"
if [ -n "$head_sbom_err" ]; then
echo "HEAD Image (${{ inputs.head-image }}): ❌ FAILED - $head_sbom_err"
else
echo "HEAD Image (${{ inputs.head-image }}): ✅ SUCCESS"
for f in "${head_files[@]}"; do
echo " - $(basename "$f") ($(wc -c < "$f") bytes)"
done
fi
if [ -n "$target_sbom_err" ]; then
echo "TARGET Baseline Image (${{ inputs.target-image }}): ❌ FAILED - $target_sbom_err"
else
echo "TARGET Baseline Image (${{ inputs.target-image }}): ✅ SUCCESS"
for f in "${target_files[@]}"; do
echo " - $(basename "$f") ($(wc -c < "$f") bytes)"
done
fi
echo "================================================================================"
echo ""
has_sbom_err=false
if [ -n "$head_sbom_err" ]; then
echo "::error::Head SBOM extraction failed: $head_sbom_err"
has_sbom_err=true
fi
if [ -n "$target_sbom_err" ]; then
echo "::error::Target baseline SBOM extraction failed: $target_sbom_err"
has_sbom_err=true
fi
if [ "$has_sbom_err" = "true" ]; then
echo "::error::Both head and target images must have valid SBOMs to proceed with differential scanning."
exit 1
fi
# Expose extracted head SBOMs to workspace root for workflow artifact uploads
for f in sboms/head/sbom-*.spdx.json; do
[ -f "$f" ] || continue
cp "$f" "$(basename "$f")" || true
done
env:
PATH: ${{ env.HOME }}/.local/bin:${{ env.PATH }}
TRIVY_SKIP_VERSION_CHECK: 'true'
INPUT_HEAD_IMAGE: ${{ inputs.head-image }}
INPUT_TARGET_IMAGE: ${{ inputs.target-image }}
INPUT_ARCHITECTURES: ${{ inputs.architectures }}
INPUT_USE_INBUILD_SBOM: ${{ inputs.use-inbuild-sbom }}
run: node "${{ github.action_path }}/lib/extract-sboms.js"
- name: Scan Multi-Platform SBOMs and Generate Delta
shell: bash
run: |
export PATH="$HOME/.local/bin:$PATH"
export TRIVY_SKIP_VERSION_CHECK="true"
mkdir -p cve-reports/head cve-reports/target
shopt -s nullglob
head_sboms=(sboms/head/sbom-*.spdx.json)
target_sboms=(sboms/target/sbom-*.spdx.json)
head_ok=true
target_ok=true
head_report_count=0
target_report_count=0
head_summary=()
target_summary=()
# 1. Scan Head SBOMs
if [ ${#head_sboms[@]} -eq 0 ]; then
head_ok=false
head_summary+=(" - ❌ No SBOM files found in sboms/head/")
else
for f in "${head_sboms[@]}"; do
arch=$(basename "$f" .spdx.json | sed 's/sbom-//')
out_json="cve-reports/head/cve-${arch}.json"
err_log="cve-reports/head/cve-${arch}.err"
echo "Scanning head SBOM for architecture: $arch"
if trivy sbom --skip-version-check --format json --output "$out_json" "$f" 2>"$err_log"; 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)")
else
head_summary+=(" - ${arch}: ✅ OK (${vuln_cnt} vulnerability findings)")
fi
head_report_count=$((head_report_count + 1))
else
head_ok=false
head_summary+=(" - ${arch}: ❌ FAILED (Output report was empty / 0 bytes)")
fi
else
head_ok=false
err_msg=$(tail -n 2 "$err_log" | tr '\n' ' ' | sed 's/ */ /g')
[ -z "$err_msg" ] && err_msg="Trivy scan exited with non-zero status"
head_summary+=(" - ${arch}: ❌ FAILED (${err_msg})")
fi
done
fi
# 2. Scan Target SBOMs
if [ ${#target_sboms[@]} -eq 0 ]; then
target_ok=false
target_summary+=(" - ❌ No SBOM files found in sboms/target/")
else
for f in "${target_sboms[@]}"; do
arch=$(basename "$f" .spdx.json | sed 's/sbom-//')
out_json="cve-reports/target/cve-${arch}.json"
err_log="cve-reports/target/cve-${arch}.err"
echo "Scanning target SBOM for architecture: $arch"
if trivy sbom --skip-version-check --format json --output "$out_json" "$f" 2>"$err_log"; 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)")
else
target_summary+=(" - ${arch}: ✅ OK (${vuln_cnt} vulnerability findings)")
fi
target_report_count=$((target_report_count + 1))
else
target_ok=false
target_summary+=(" - ${arch}: ❌ FAILED (Output report was empty / 0 bytes)")
fi
else
target_ok=false
err_msg=$(tail -n 2 "$err_log" | tr '\n' ' ' | sed 's/ */ /g')
[ -z "$err_msg" ] && err_msg="Trivy scan exited with non-zero status"
target_summary+=(" - ${arch}: ❌ FAILED (${err_msg})")
fi
done
fi
# 3. Print Comprehensive Diagnostic Overview
echo ""
echo "================================================================================"
echo " CVE VULNERABILITY SCAN DIAGNOSTIC OVERVIEW "
echo "================================================================================"
echo "HEAD Image: ${{ inputs.head-image }}"
for line in "${head_summary[@]}"; do
echo "$line"
done
echo ""
echo "TARGET Baseline Image: ${{ inputs.target-image }} (branch: '${{ inputs.target-branch }}')"
for line in "${target_summary[@]}"; do
echo "$line"
done
echo "================================================================================"
echo ""
# 4. Enforce Gating: Both must have succeeded with valid reports
has_error=false
if [ "$head_ok" != "true" ] || [ "$head_report_count" -eq 0 ]; then
echo "::error::Head image CVE analysis failed or produced no reports."
has_error=true
fi
if [ "$target_ok" != "true" ] || [ "$target_report_count" -eq 0 ]; then
echo "::error::Target baseline CVE analysis failed or produced no reports."
has_error=true
fi
if [ "$has_error" = "true" ]; then
echo "::error::Aborting differential analysis: both head and target scans must succeed before diffing."
exit 1
fi
# 5. Run multi-arch set-based diff and generate consolidated full summary report
echo "Both head and target scans succeeded. Computing set-based vulnerability delta..."
if ! node "${{ github.action_path }}/cve-diff.js" \
"cve-reports/target" \
"cve-reports/head" \
"${{ inputs.target-branch }}" \
"${{ inputs.output-summary }}" > "${{ inputs.output-delta }}"; then
echo "::error::Failed to generate CVE differential report"
exit 1
fi
env:
PATH: ${{ env.HOME }}/.local/bin:${{ env.PATH }}
TRIVY_SKIP_VERSION_CHECK: 'true'
INPUT_HEAD_IMAGE: ${{ inputs.head-image }}
INPUT_TARGET_IMAGE: ${{ inputs.target-image }}
INPUT_TARGET_BRANCH: ${{ inputs.target-branch }}
INPUT_USE_INBUILD_SBOM: ${{ inputs.use-inbuild-sbom }}
INPUT_OUTPUT_DELTA: ${{ inputs.output-delta }}
INPUT_OUTPUT_SUMMARY: ${{ inputs.output-summary }}
run: node "${{ github.action_path }}/lib/scan-sboms.js"
- name: Publish Reports to Step Summary
shell: bash
+5 -4
View File
@@ -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) + '...';
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Diagnostic formatting and reporting utilities.
*/
function formatSbomOverview({
headImage,
targetImage,
headFiles = [],
targetFiles = [],
headError = null,
targetError = null,
}) {
const lines = [
'',
'================================================================================',
' SBOM EXTRACTION DIAGNOSTIC OVERVIEW ',
'================================================================================',
];
if (headError) {
lines.push(`HEAD Image (${headImage}): ❌ FAILED - ${headError}`);
} else {
lines.push(`HEAD Image (${headImage}): ✅ SUCCESS`);
for (const f of headFiles) {
lines.push(` - ${f.name} (${f.size} bytes)`);
}
}
if (targetError) {
lines.push(`TARGET Baseline Image (${targetImage}): ❌ FAILED - ${targetError}`);
} else {
lines.push(`TARGET Baseline Image (${targetImage}): ✅ SUCCESS`);
for (const f of targetFiles) {
lines.push(` - ${f.name} (${f.size} bytes)`);
}
}
lines.push('================================================================================');
lines.push('');
return lines.join('\n');
}
function formatScanOverview({
headImage,
targetImage,
targetBranch = 'main',
strategy = null,
headSummary = [],
targetSummary = [],
}) {
const lines = [
'',
'================================================================================',
' CVE VULNERABILITY SCAN DIAGNOSTIC OVERVIEW ',
'================================================================================',
];
if (strategy) {
lines.push(`Scan Strategy: ${strategy}`);
lines.push('--------------------------------------------------------------------------------');
}
lines.push(`HEAD Image: ${headImage}`);
for (const line of headSummary) {
lines.push(line);
}
lines.push('');
lines.push(`TARGET Baseline Image: ${targetImage} (branch: '${targetBranch}')`);
for (const line of targetSummary) {
lines.push(line);
}
lines.push('================================================================================');
lines.push('');
return lines.join('\n');
}
function countVulnerabilities(report) {
if (!report) return 0;
let data = report;
if (typeof report === 'string' || Buffer.isBuffer(report)) {
try {
data = JSON.parse(report.toString('utf8'));
} catch {
return 0;
}
}
if (!data || typeof data !== 'object') return 0;
let count = 0;
if (Array.isArray(data.Results)) {
for (const res of data.Results) {
if (res && Array.isArray(res.Vulnerabilities)) {
count += res.Vulnerabilities.length;
}
}
}
return count;
}
module.exports = {
formatSbomOverview,
formatScanOverview,
countVulnerabilities,
};
+42
View File
@@ -0,0 +1,42 @@
/**
* Distribution detection from SPDX SBOM package PURLs.
*/
function detectDistro(spdxInput) {
if (!spdxInput) return null;
let data = spdxInput;
if (typeof spdxInput === 'string' || Buffer.isBuffer(spdxInput)) {
try {
data = JSON.parse(spdxInput.toString('utf8'));
} catch {
return null;
}
}
if (!data || typeof data !== 'object' || !Array.isArray(data.packages)) {
return null;
}
for (const pkg of data.packages) {
if (!pkg || !Array.isArray(pkg.externalRefs)) continue;
for (const ref of pkg.externalRefs) {
if (ref && ref.referenceType === 'purl' && typeof ref.referenceLocator === 'string') {
const purl = ref.referenceLocator;
const nameMatch = purl.match(/[?&]os_name=([^&]+)/);
const verMatch = purl.match(/[?&]os_version=([^&]+)/);
if (nameMatch && verMatch) {
const osName = decodeURIComponent(nameMatch[1]);
const osVer = decodeURIComponent(verMatch[1]);
return `${osName}/${osVer}`;
}
}
}
}
return null;
}
module.exports = {
detectDistro,
};
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env node
/**
* Extracts or generates multi-platform SBOMs for head and target images.
*/
const fs = require('node:fs');
const path = require('node:path');
const cp = require('node:child_process');
const { parsePlatforms, discoverPlatforms, toArchTag } = require('./platforms.js');
const { formatSbomOverview } = require('./diagnostics.js');
function parseArgs(argv) {
const args = {
headImage: process.env.INPUT_HEAD_IMAGE || '',
targetImage: process.env.INPUT_TARGET_IMAGE || '',
architectures: process.env.INPUT_ARCHITECTURES || '',
useInbuildSbom: process.env.INPUT_USE_INBUILD_SBOM || 'false',
};
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--head-image' && i + 1 < argv.length) args.headImage = argv[++i];
else if (arg === '--target-image' && i + 1 < argv.length) args.targetImage = argv[++i];
else if (arg === '--architectures' && i + 1 < argv.length) args.architectures = argv[++i];
else if (arg === '--use-inbuild-sbom' && i + 1 < argv.length) args.useInbuildSbom = argv[++i];
}
return args;
}
function cleanupInvalidFile(filePath) {
if (!fs.existsSync(filePath)) return;
try {
const stat = fs.statSync(filePath);
if (stat.size === 0) {
fs.unlinkSync(filePath);
return;
}
const content = fs.readFileSync(filePath, 'utf8').trim();
if (content === 'null' || content === '') {
fs.unlinkSync(filePath);
}
} catch {
// Ignore cleanup errors
}
}
function runCommandSafe(cmd, args) {
try {
return cp.execFileSync(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }).toString('utf8');
} catch {
return null;
}
}
function extractOrGenerateSbom({ image, platform, archTag, outputDir, useInbuild }) {
const outputFile = path.join(outputDir, `sbom-${archTag}.spdx.json`);
const inbuildFile = path.join(outputDir, `inbuild-sbom-${archTag}.spdx.json`);
// 1. Attempt in-build SBOM extraction if requested
if (useInbuild) {
console.log(`Attempting extraction of built-in SBOM for image (${platform})...`);
const formatStr = `{{ json (index .SBOM "${platform}").SPDX }}`;
const rawSbom = runCommandSafe('docker', ['buildx', 'imagetools', 'inspect', image, '--format', formatStr]);
if (rawSbom) {
fs.writeFileSync(outputFile, rawSbom);
cleanupInvalidFile(outputFile);
if (fs.existsSync(outputFile)) {
fs.copyFileSync(outputFile, inbuildFile);
}
}
}
// 2. Fallback to direct Trivy image generation if SBOM not present
if (!fs.existsSync(outputFile)) {
console.log(`Generating SBOM for image (${platform}) with Trivy...`);
const platArgs = platform ? ['--platform', platform] : [];
try {
cp.execFileSync('trivy', [
'image',
'--skip-version-check',
...platArgs,
'--format', 'spdx-json',
'--output', outputFile,
image,
], { stdio: 'inherit' });
} catch (e) {
console.warn(`::warning::Trivy image scan failed for image (${platform}): ${e.message}`);
}
cleanupInvalidFile(outputFile);
}
}
function main() {
const args = parseArgs(process.argv);
const { headImage, targetImage, architectures, useInbuildSbom } = args;
const useInbuild = useInbuildSbom === 'true';
if (!headImage || !targetImage) {
console.error('::error::Both --head-image and --target-image must be provided.');
process.exit(1);
}
const headDir = path.join('sboms', 'head');
const targetDir = path.join('sboms', 'target');
fs.mkdirSync(headDir, { recursive: true });
fs.mkdirSync(targetDir, { recursive: true });
// Determine platforms
let platforms = parsePlatforms(architectures);
if (platforms.length === 0) {
console.log('Auto-discovering architectures from images...');
const rawHead = runCommandSafe('docker', ['buildx', 'imagetools', 'inspect', '--raw', headImage]);
const rawTarget = runCommandSafe('docker', ['buildx', 'imagetools', 'inspect', '--raw', targetImage]);
platforms = discoverPlatforms(rawHead, rawTarget);
}
console.log(`Configured platforms: ${platforms.length > 0 ? platforms.join(', ') : '(single platform fallback)'}`);
if (useInbuild) {
console.log('ℹ️ Strategy: Built-in SBOM attestation extraction enabled (Docker Scout / Syft). Skips container layer downloads for maximum speed.');
console.log('ℹ️ Note: Built-in SBOM vulnerability counts may differ from direct filesystem scans (\'trivy image\') due to binary vs. source package tracking.');
} else {
console.log('ℹ️ Strategy: Direct Trivy image scanning enabled (default). Inspects full container rootfs and package databases for maximum fidelity.');
}
const targetPlatforms = platforms.length > 0 ? platforms : [''];
for (const platform of targetPlatforms) {
const archTag = toArchTag(platform);
// Head extraction
extractOrGenerateSbom({
image: headImage,
platform,
archTag,
outputDir: headDir,
useInbuild,
});
// Target extraction
extractOrGenerateSbom({
image: targetImage,
platform,
archTag,
outputDir: targetDir,
useInbuild,
});
}
// Collect generated files
const getSbomFiles = (dir) => {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir)
.filter((f) => f.startsWith('sbom-') && f.endsWith('.spdx.json'))
.map((f) => {
const fullPath = path.join(dir, f);
return { name: f, size: fs.statSync(fullPath).size };
});
};
const headFiles = getSbomFiles(headDir);
const targetFiles = getSbomFiles(targetDir);
let headError = null;
let targetError = null;
if (headFiles.length === 0) headError = 'No SBOM generated and head image could not be inspected';
if (targetFiles.length === 0) targetError = 'No SBOM generated and target baseline image could not be inspected';
const overview = formatSbomOverview({
headImage,
targetImage,
headFiles,
targetFiles,
headError,
targetError,
});
console.log(overview);
if (headError || targetError) {
console.error('::error::Both head and target images must have valid SBOMs to proceed with differential scanning.');
process.exit(1);
}
// Expose extracted head SBOMs to workspace root for workflow artifact uploads
for (const file of headFiles) {
try {
fs.copyFileSync(path.join(headDir, file.name), file.name);
} catch {
// Ignore copy error
}
}
}
if (require.main === module) {
main();
}
module.exports = {
parseArgs,
cleanupInvalidFile,
extractOrGenerateSbom,
main,
};
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env node
/**
* Installs and verifies Trivy binary against checksums lockfile.
*/
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const cp = require('node:child_process');
const { mapArch, parseChecksums, verifyFileSha256 } = require('./verifier.js');
function main() {
const version = process.env.TRIVY_VERSION || '0.74.0';
const homeDir = os.homedir();
const binDir = path.join(homeDir, '.local', 'bin');
const cacheDir = path.join(homeDir, '.cache', 'trivy-bin');
fs.mkdirSync(binDir, { recursive: true });
fs.mkdirSync(cacheDir, { recursive: true });
// Expose bin directory to GITHUB_PATH if running in Actions runner
if (process.env.GITHUB_PATH) {
fs.appendFileSync(process.env.GITHUB_PATH, `${binDir}\n`);
}
const unameArch = os.arch() === 'x64' ? 'x86_64' : (os.arch() === 'arm64' ? 'ARM64' : os.arch());
const tArch = mapArch(unameArch);
const tarball = `trivy_${version}_Linux-${tArch}.tar.gz`;
const tarballPath = path.join(cacheDir, tarball);
const checksumsFile = path.resolve(__dirname, '..', 'checksums.txt');
if (!fs.existsSync(checksumsFile)) {
console.error(`::error::Checksums lockfile not found at ${checksumsFile}`);
process.exit(1);
}
const checksums = parseChecksums(fs.readFileSync(checksumsFile, 'utf8'));
const expectedHash = checksums.get(tarball);
if (!expectedHash) {
console.error(`::error::Tarball ${tarball} not found in checksums.txt`);
process.exit(1);
}
if (fs.existsSync(tarballPath) && verifyFileSha256(tarballPath, expectedHash)) {
console.log('Cached Trivy archive verified against lockfile.');
} else {
console.log('Archive missing or checksum mismatch; downloading...');
const downloadUrl = `https://github.com/aquasecurity/trivy/releases/download/v${version}/${tarball}`;
cp.execFileSync('curl', ['-sfLO', downloadUrl, '--output-dir', cacheDir], { stdio: 'inherit' });
if (!verifyFileSha256(tarballPath, expectedHash)) {
console.error(`::error::Downloaded archive ${tarball} failed SHA256 checksum verification!`);
process.exit(1);
}
console.log('Downloaded Trivy archive verified successfully.');
}
// Extract binary
cp.execFileSync('tar', ['-xzf', tarballPath, '-C', binDir, 'trivy'], { stdio: 'inherit' });
const trivyBin = path.join(binDir, 'trivy');
if (fs.existsSync(trivyBin)) {
fs.chmodSync(trivyBin, 0o755);
console.log(`Trivy ${version} installed and verified successfully at ${trivyBin}`);
} else {
console.error('::error::Failed to extract trivy binary from tarball.');
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = { main };
+54
View File
@@ -0,0 +1,54 @@
/**
* Platform and architecture resolution utilities.
*/
function parsePlatforms(input) {
if (!input || typeof input !== 'string') return [];
const parts = input.split(',');
const platforms = [];
for (const part of parts) {
const clean = part.trim();
if (!clean) continue;
if (!clean.includes('/')) {
platforms.push(`linux/${clean}`);
} else {
platforms.push(clean);
}
}
return platforms;
}
function discoverPlatforms(headRawJson, targetRawJson) {
const set = new Set();
for (const raw of [headRawJson, targetRawJson]) {
if (!raw) continue;
try {
const data = JSON.parse(raw);
const manifests = data.manifests || [];
for (const m of manifests) {
if (m.platform && m.platform.architecture && m.platform.architecture !== 'unknown') {
const os = m.platform.os || 'linux';
const variant = m.platform.variant ? `/${m.platform.variant}` : '';
set.add(`${os}/${m.platform.architecture}${variant}`);
}
}
} catch {
// Ignore JSON parse errors from invalid manifests
}
}
return Array.from(set).sort();
}
function toArchTag(platform) {
if (!platform) return 'default';
// Strip optional leading os/ (e.g. linux/)
const withoutOs = platform.replace(/^[a-zA-Z0-9_-]+\//, '');
// Replace slashes with dashes (e.g. arm/v7 -> arm-v7)
return withoutOs.replace(/\//g, '-');
}
module.exports = {
parsePlatforms,
discoverPlatforms,
toArchTag,
};
+280
View File
@@ -0,0 +1,280 @@
#!/usr/bin/env node
/**
* Scans multi-platform SBOMs with Trivy, handles distro detection and fallbacks,
* prints diagnostic overview, and invokes diff engine.
*/
const fs = require('node:fs');
const path = require('node:path');
const cp = require('node:child_process');
const { detectDistro } = require('./distro.js');
const { formatScanOverview, countVulnerabilities } = require('./diagnostics.js');
function parseArgs(argv) {
const args = {
headImage: process.env.INPUT_HEAD_IMAGE || '',
targetImage: process.env.INPUT_TARGET_IMAGE || '',
targetBranch: process.env.INPUT_TARGET_BRANCH || 'main',
useInbuildSbom: process.env.INPUT_USE_INBUILD_SBOM || 'false',
outputDelta: process.env.INPUT_OUTPUT_DELTA || 'cve-delta.md',
outputSummary: process.env.INPUT_OUTPUT_SUMMARY || 'cve-summary.md',
};
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--head-image' && i + 1 < argv.length) args.headImage = argv[++i];
else if (arg === '--target-image' && i + 1 < argv.length) args.targetImage = argv[++i];
else if (arg === '--target-branch' && i + 1 < argv.length) args.targetBranch = argv[++i];
else if (arg === '--use-inbuild-sbom' && i + 1 < argv.length) args.useInbuildSbom = argv[++i];
else if (arg === '--output-delta' && i + 1 < argv.length) args.outputDelta = argv[++i];
else if (arg === '--output-summary' && i + 1 < argv.length) args.outputSummary = argv[++i];
}
return args;
}
function scanSbomFile({ sbomPath, image, outJson, errLog, useInbuild }) {
const archMatch = path.basename(sbomPath, '.spdx.json').match(/^sbom-(.+)$/);
const arch = archMatch ? archMatch[1] : 'default';
console.log(`Scanning SBOM for architecture: ${arch}`);
let distroArg = [];
if (useInbuild) {
try {
const content = fs.readFileSync(sbomPath, 'utf8');
const detected = detectDistro(content);
if (detected) {
console.log(`Detected distribution '${detected}' from in-build SBOM PURLs. Scanning with --distro.`);
distroArg = ['--distro', detected];
}
} catch {
// Ignore read error
}
}
const runTrivySbom = () => {
try {
const errFd = fs.openSync(errLog, 'w');
try {
cp.execFileSync('trivy', [
'sbom',
...distroArg,
'--skip-version-check',
'--format', 'json',
'--output', outJson,
sbomPath,
], { stdio: ['ignore', 'ignore', errFd] });
return true;
} finally {
fs.closeSync(errFd);
}
} catch {
return false;
}
};
let scanSuccess = runTrivySbom();
let vulnCount = 0;
if (fs.existsSync(outJson) && fs.statSync(outJson).size > 0) {
vulnCount = countVulnerabilities(fs.readFileSync(outJson, 'utf8'));
}
// Fallback if in-build scan produced 0 findings or failed
if (useInbuild && (!scanSuccess || vulnCount === 0)) {
console.warn(`::warning::In-build SBOM for image (${arch}) yielded ${vulnCount} CVE findings. Falling back to direct Trivy image scan...`);
const platArgs = arch !== 'default' ? ['--platform', `linux/${arch}`] : [];
try {
cp.execFileSync('trivy', [
'image',
'--skip-version-check',
...platArgs,
'--format', 'spdx-json',
'--output', sbomPath,
image,
], { stdio: 'inherit' });
console.log('Re-scanning SBOM generated directly by Trivy...');
// Re-scan without external distroArg (Trivy's generated SBOM has OS declarations)
distroArg = [];
scanSuccess = runTrivySbom();
if (fs.existsSync(outJson) && fs.statSync(outJson).size > 0) {
vulnCount = countVulnerabilities(fs.readFileSync(outJson, 'utf8'));
}
} catch (e) {
console.warn(`::warning::Direct Trivy scan fallback failed for ${arch}: ${e.message}`);
}
}
return { arch, scanSuccess, vulnCount, outJson, errLog };
}
function main() {
const args = parseArgs(process.argv);
const {
headImage,
targetImage,
targetBranch,
useInbuildSbom,
outputDelta,
outputSummary,
} = args;
const useInbuild = useInbuildSbom === 'true';
fs.mkdirSync(path.join('cve-reports', 'head'), { recursive: true });
fs.mkdirSync(path.join('cve-reports', 'target'), { recursive: true });
const getSboms = (dir) => {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir)
.filter((f) => f.startsWith('sbom-') && f.endsWith('.spdx.json'))
.sort()
.map((f) => path.join(dir, f));
};
const headSboms = getSboms(path.join('sboms', 'head'));
const targetSboms = getSboms(path.join('sboms', 'target'));
let headOk = true;
let targetOk = true;
let headReportCount = 0;
let targetReportCount = 0;
const headSummary = [];
const targetSummary = [];
// 1. Scan Head SBOMs
if (headSboms.length === 0) {
headOk = false;
headSummary.push(' - ❌ No SBOM files found in sboms/head/');
} else {
for (const f of headSboms) {
const arch = path.basename(f, '.spdx.json').replace(/^sbom-/, '');
const outJson = path.join('cve-reports', 'head', `cve-${arch}.json`);
const errLog = path.join('cve-reports', 'head', `cve-${arch}.err`);
const res = scanSbomFile({
sbomPath: f,
image: headImage,
outJson,
errLog,
useInbuild,
});
if (res.scanSuccess && fs.existsSync(outJson) && fs.statSync(outJson).size > 0) {
if (res.vulnCount === 0) {
console.warn(`::warning::Head image architecture '${arch}' has 0 CVE findings. Verify that OS/packages were recognized.`);
headSummary.push(` - ${arch}: ⚠️ WARNING (0 vulnerability findings detected)`);
} else {
headSummary.push(` - ${arch}: ✅ OK (${res.vulnCount} vulnerability findings)`);
}
headReportCount++;
} else {
headOk = false;
let errMsg = 'Trivy scan exited with non-zero status';
if (fs.existsSync(errLog)) {
const errLines = fs.readFileSync(errLog, 'utf8').trim().split('\n');
errMsg = errLines.slice(-2).join(' ').replace(/\s+/g, ' ') || errMsg;
}
headSummary.push(` - ${arch}: ❌ FAILED (${errMsg})`);
}
}
}
// 2. Scan Target SBOMs
if (targetSboms.length === 0) {
targetOk = false;
targetSummary.push(' - ❌ No SBOM files found in sboms/target/');
} else {
for (const f of targetSboms) {
const arch = path.basename(f, '.spdx.json').replace(/^sbom-/, '');
const outJson = path.join('cve-reports', 'target', `cve-${arch}.json`);
const errLog = path.join('cve-reports', 'target', `cve-${arch}.err`);
const res = scanSbomFile({
sbomPath: f,
image: targetImage,
outJson,
errLog,
useInbuild,
});
if (res.scanSuccess && fs.existsSync(outJson) && fs.statSync(outJson).size > 0) {
if (res.vulnCount === 0) {
console.warn(`::warning::Target baseline image architecture '${arch}' has 0 CVE findings. Verify that OS/packages were recognized.`);
targetSummary.push(` - ${arch}: ⚠️ WARNING (0 vulnerability findings detected)`);
} else {
targetSummary.push(` - ${arch}: ✅ OK (${res.vulnCount} vulnerability findings)`);
}
targetReportCount++;
} else {
targetOk = false;
let errMsg = 'Trivy scan exited with non-zero status';
if (fs.existsSync(errLog)) {
const errLines = fs.readFileSync(errLog, 'utf8').trim().split('\n');
errMsg = errLines.slice(-2).join(' ').replace(/\s+/g, ' ') || errMsg;
}
targetSummary.push(` - ${arch}: ❌ FAILED (${errMsg})`);
}
}
}
// 3. Print Comprehensive Diagnostic Overview
const strategy = useInbuild
? 'Built-in OCI Attestation (Syft/Docker Scout) with distro detection'
: 'Direct Trivy Image Scan (Full rootfs & source package mapping)';
const overview = formatScanOverview({
headImage,
targetImage,
targetBranch,
strategy,
headSummary,
targetSummary,
});
console.log(overview);
// 4. Enforce Gating
let hasError = false;
if (!headOk || headReportCount === 0) {
console.error('::error::Head image CVE analysis failed or produced no reports.');
hasError = true;
}
if (!targetOk || targetReportCount === 0) {
console.error('::error::Target baseline CVE analysis failed or produced no reports.');
hasError = true;
}
if (hasError) {
console.error('::error::Aborting differential analysis: both head and target scans must succeed before diffing.');
process.exit(1);
}
// 5. Run multi-arch set-based diff and generate reports
console.log('Both head and target scans succeeded. Computing set-based vulnerability delta...');
const cveDiffScript = path.resolve(__dirname, '..', 'cve-diff.js');
try {
const deltaOutput = cp.execFileSync(process.execPath, [
cveDiffScript,
path.join('cve-reports', 'target'),
path.join('cve-reports', 'head'),
targetBranch,
outputSummary,
], { encoding: 'utf8' });
fs.writeFileSync(outputDelta, deltaOutput, 'utf8');
console.log(`CVE delta written to ${outputDelta} and full summary written to ${outputSummary}`);
} catch (e) {
console.error(`::error::Failed to generate CVE differential report: ${e.message}`);
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = {
parseArgs,
scanSbomFile,
main,
};
+56
View File
@@ -0,0 +1,56 @@
/**
* Architecture mapping and cryptographic checksum verification.
*/
const fs = require('node:fs');
const crypto = require('node:crypto');
function mapArch(unameArch) {
const normalized = (unameArch || '').trim().toLowerCase();
switch (normalized) {
case 'x86_64':
case 'amd64':
return '64bit';
case 'aarch64':
case 'arm64':
return 'ARM64';
default:
throw new Error(`Unsupported architecture: ${unameArch}`);
}
}
function parseChecksums(checksumsContent) {
const map = new Map();
if (!checksumsContent) return map;
const lines = checksumsContent.split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
// Format: <hash> <filename>
const match = trimmed.match(/^([a-fA-F0-9]{64})\s+(.+)$/);
if (match) {
const hash = match[1].toLowerCase();
const filename = match[2].trim();
map.set(filename, hash);
}
}
return map;
}
function verifyFileSha256(filePath, expectedHash) {
if (!fs.existsSync(filePath) || !expectedHash) return false;
try {
const fileBuffer = fs.readFileSync(filePath);
const actualHash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
return actualHash.toLowerCase() === expectedHash.toLowerCase();
} catch {
return false;
}
}
module.exports = {
mapArch,
parseChecksums,
verifyFileSha256,
};
+111
View File
@@ -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`'));
});
+76
View File
@@ -0,0 +1,76 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
formatSbomOverview,
formatScanOverview,
countVulnerabilities,
} = require('../lib/diagnostics.js');
test('formatSbomOverview formats successful extraction banner', () => {
const overview = formatSbomOverview({
headImage: 'my-head:latest',
targetImage: 'my-target:latest',
headFiles: [{ name: 'sbom-amd64.spdx.json', size: 1024 }],
targetFiles: [{ name: 'sbom-amd64.spdx.json', size: 2048 }],
headError: null,
targetError: null,
});
assert.ok(overview.includes('SBOM EXTRACTION DIAGNOSTIC OVERVIEW'));
assert.ok(overview.includes('HEAD Image (my-head:latest): ✅ SUCCESS'));
assert.ok(overview.includes('TARGET Baseline Image (my-target:latest): ✅ SUCCESS'));
assert.ok(overview.includes('sbom-amd64.spdx.json (1024 bytes)'));
});
test('formatSbomOverview formats failure banner when error present', () => {
const overview = formatSbomOverview({
headImage: 'my-head:latest',
targetImage: 'my-target:latest',
headFiles: [],
targetFiles: [],
headError: 'Image not found',
targetError: null,
});
assert.ok(overview.includes('HEAD Image (my-head:latest): ❌ FAILED - Image not found'));
});
test('formatScanOverview formats scan summary with strategy', () => {
const overview = formatScanOverview({
headImage: 'debian:bookworm-slim',
targetImage: 'debian:bullseye-slim',
targetBranch: 'main',
strategy: 'Direct Trivy Image Scan',
headSummary: [' - amd64: ✅ OK (104 vulnerability findings)'],
targetSummary: [' - amd64: ✅ OK (130 vulnerability findings)'],
});
assert.ok(overview.includes('CVE VULNERABILITY SCAN DIAGNOSTIC OVERVIEW'));
assert.ok(overview.includes('Scan Strategy: Direct Trivy Image Scan'));
assert.ok(overview.includes('HEAD Image: debian:bookworm-slim'));
assert.ok(overview.includes('TARGET Baseline Image: debian:bullseye-slim (branch: \'main\')'));
assert.ok(overview.includes('104 vulnerability findings'));
});
test('countVulnerabilities counts all vulnerabilities in a Trivy JSON report', () => {
const report = {
Results: [
{
Vulnerabilities: [
{ VulnerabilityID: 'CVE-2023-0001' },
{ VulnerabilityID: 'CVE-2023-0002' },
],
},
{
Vulnerabilities: [
{ VulnerabilityID: 'CVE-2023-0003' },
],
},
],
};
assert.equal(countVulnerabilities(report), 3);
assert.equal(countVulnerabilities(JSON.stringify(report)), 3);
assert.equal(countVulnerabilities({}), 0);
assert.equal(countVulnerabilities(null), 0);
});
+81
View File
@@ -0,0 +1,81 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { detectDistro } = require('../lib/distro.js');
test('detectDistro returns null for null, undefined, or empty input', () => {
assert.equal(detectDistro(null), null);
assert.equal(detectDistro(undefined), null);
assert.equal(detectDistro(''), null);
assert.equal(detectDistro('{}'), null);
});
test('detectDistro extracts distro from parsed SPDX object with package PURLs', () => {
const spdx = {
packages: [
{
name: 'acl',
externalRefs: [
{
referenceType: 'purl',
referenceLocator: 'pkg:deb/debian/acl@2.3.1-3?os_distro=bookworm&os_name=debian&os_version=12',
},
],
},
],
};
assert.equal(detectDistro(spdx), 'debian/12');
});
test('detectDistro extracts distro from JSON string containing escaped unicode \\u0026', () => {
const jsonStr = JSON.stringify({
packages: [
{
name: 'adduser',
externalRefs: [
{
referenceType: 'purl',
referenceLocator: 'pkg:deb/debian/adduser@3.134?os_distro=bullseye&os_name=debian&os_version=11',
},
],
},
],
}).replace(/&/g, '\\u0026');
assert.ok(jsonStr.includes('\\u0026'));
assert.equal(detectDistro(jsonStr), 'debian/11');
});
test('detectDistro handles non-debian distributions (e.g. ubuntu, alpine)', () => {
const spdx = {
packages: [
{
externalRefs: [
{
referenceType: 'purl',
referenceLocator: 'pkg:apk/alpine/alpine-baselayout@3.4.3-r2?os_name=alpine&os_version=3.20',
},
],
},
],
};
assert.equal(detectDistro(spdx), 'alpine/3.20');
});
test('detectDistro returns null when PURLs lack os_name or os_version', () => {
const spdx = {
packages: [
{
externalRefs: [
{
referenceType: 'purl',
referenceLocator: 'pkg:generic/foo@1.0.0',
},
],
},
],
};
assert.equal(detectDistro(spdx), null);
});
+8
View File
@@ -0,0 +1,8 @@
{
"SchemaVersion": 2,
"ArtifactName": "debian:bullseye-slim",
"ArtifactType": "spdx",
"Metadata": {
"OS": null
}
}
+30
View File
@@ -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"
}
]
}
]
}
+22
View File
@@ -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"
}
]
}
]
}
+36
View File
@@ -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"
}
]
}
]
}
+22
View File
@@ -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"
}
]
}
]
}
+65
View File
@@ -0,0 +1,65 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { parseArgs: parseExtractArgs, cleanupInvalidFile } = require('../lib/extract-sboms.js');
const { parseArgs: parseScanArgs } = require('../lib/scan-sboms.js');
test('extract-sboms parseArgs parses command line arguments and defaults', () => {
const argv = [
'node',
'extract-sboms.js',
'--head-image', 'my-repo/head:1.0',
'--target-image', 'my-repo/target:1.0',
'--architectures', 'amd64,arm64',
'--use-inbuild-sbom', 'true',
];
const args = parseExtractArgs(argv);
assert.equal(args.headImage, 'my-repo/head:1.0');
assert.equal(args.targetImage, 'my-repo/target:1.0');
assert.equal(args.architectures, 'amd64,arm64');
assert.equal(args.useInbuildSbom, 'true');
});
test('extract-sboms cleanupInvalidFile removes empty files and files containing null', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sbom-clean-'));
const emptyFile = path.join(tmpDir, 'empty.json');
const nullFile = path.join(tmpDir, 'null.json');
const validFile = path.join(tmpDir, 'valid.json');
fs.writeFileSync(emptyFile, '');
fs.writeFileSync(nullFile, 'null\n');
fs.writeFileSync(validFile, '{"spdxVersion": "SPDX-2.3"}');
cleanupInvalidFile(emptyFile);
cleanupInvalidFile(nullFile);
cleanupInvalidFile(validFile);
assert.equal(fs.existsSync(emptyFile), false);
assert.equal(fs.existsSync(nullFile), false);
assert.equal(fs.existsSync(validFile), true);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('scan-sboms parseArgs parses scan arguments and defaults', () => {
const argv = [
'node',
'scan-sboms.js',
'--head-image', 'my-head:latest',
'--target-image', 'my-target:latest',
'--target-branch', 'feature',
'--output-delta', 'custom-delta.md',
'--output-summary', 'custom-summary.md',
];
const args = parseScanArgs(argv);
assert.equal(args.headImage, 'my-head:latest');
assert.equal(args.targetImage, 'my-target:latest');
assert.equal(args.targetBranch, 'feature');
assert.equal(args.outputDelta, 'custom-delta.md');
assert.equal(args.outputSummary, 'custom-summary.md');
assert.equal(args.useInbuildSbom, 'false');
});
+47
View File
@@ -0,0 +1,47 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { parsePlatforms, discoverPlatforms, toArchTag } = require('../lib/platforms.js');
test('parsePlatforms handles empty and undefined input', () => {
assert.deepEqual(parsePlatforms(''), []);
assert.deepEqual(parsePlatforms(null), []);
assert.deepEqual(parsePlatforms(undefined), []);
assert.deepEqual(parsePlatforms(' '), []);
});
test('parsePlatforms parses comma-separated architectures and adds linux/ prefix if omitted', () => {
const result = parsePlatforms('amd64, arm64, linux/arm/v7');
assert.deepEqual(result, ['linux/amd64', 'linux/arm64', 'linux/arm/v7']);
});
test('discoverPlatforms extracts unique architectures from OCI image manifests', () => {
const headManifest = JSON.stringify({
manifests: [
{ platform: { architecture: 'amd64', os: 'linux' } },
{ platform: { architecture: 'arm64', os: 'linux' } },
{ platform: { architecture: 'unknown', os: 'unknown' } },
],
});
const targetManifest = JSON.stringify({
manifests: [
{ platform: { architecture: 'amd64', os: 'linux' } },
{ platform: { architecture: 'arm', os: 'linux', variant: 'v7' } },
],
});
const result = discoverPlatforms(headManifest, targetManifest);
assert.deepEqual(result, ['linux/amd64', 'linux/arm/v7', 'linux/arm64']);
});
test('discoverPlatforms safely handles invalid or missing manifest JSON', () => {
assert.deepEqual(discoverPlatforms('invalid json', null), []);
assert.deepEqual(discoverPlatforms('', '{}'), []);
});
test('toArchTag converts platform strings to filesystem-safe arch tags', () => {
assert.equal(toArchTag('linux/amd64'), 'amd64');
assert.equal(toArchTag('linux/arm64'), 'arm64');
assert.equal(toArchTag('linux/arm/v7'), 'arm-v7');
assert.equal(toArchTag('amd64'), 'amd64');
});
+39
View File
@@ -0,0 +1,39 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const crypto = require('node:crypto');
const { mapArch, parseChecksums, verifyFileSha256 } = require('../lib/verifier.js');
test('mapArch maps uname architecture to Trivy release arch', () => {
assert.equal(mapArch('x86_64'), '64bit');
assert.equal(mapArch('aarch64'), 'ARM64');
assert.equal(mapArch('arm64'), 'ARM64');
assert.throws(() => mapArch('mips'), /Unsupported architecture/);
});
test('parseChecksums parses sha256 checksum lockfile into filename map', () => {
const content = `
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 trivy_0.74.0_Linux-64bit.tar.gz
a1b2c3d4e5f60000000000000000000000000000000000000000000000000000 trivy_0.74.0_Linux-ARM64.tar.gz
`;
const map = parseChecksums(content);
assert.equal(map.get('trivy_0.74.0_Linux-64bit.tar.gz'), 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
assert.equal(map.get('trivy_0.74.0_Linux-ARM64.tar.gz'), 'a1b2c3d4e5f60000000000000000000000000000000000000000000000000000');
assert.equal(map.has('nonexistent'), false);
});
test('verifyFileSha256 verifies actual sha256 hash of a file', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cve-test-'));
const testFile = path.join(tmpDir, 'test.txt');
const fileContent = 'hello world\n';
fs.writeFileSync(testFile, fileContent);
const expectedHash = crypto.createHash('sha256').update(fileContent).digest('hex');
assert.equal(verifyFileSha256(testFile, expectedHash), true);
assert.equal(verifyFileSha256(testFile, 'wronghash'), false);
fs.rmSync(tmpDir, { recursive: true, force: true });
});