Public Access
generated from Philipp/template-git
Compare commits
1
Commits
0.0.2
...
599b74ef49
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
599b74ef49
|
No files matched your search
+22
-347
@@ -29,7 +29,7 @@ inputs:
|
|||||||
required: false
|
required: false
|
||||||
default: 'true'
|
default: 'true'
|
||||||
use-inbuild-sbom:
|
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
|
required: false
|
||||||
default: 'false'
|
default: 'false'
|
||||||
|
|
||||||
@@ -65,358 +65,33 @@ runs:
|
|||||||
|
|
||||||
- name: Install and Verify Trivy
|
- name: Install and Verify Trivy
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
env:
|
||||||
set -euo pipefail
|
TRIVY_VERSION: ${{ steps.vars.outputs.version }}
|
||||||
|
run: node "${{ github.action_path }}/lib/install-trivy.js"
|
||||||
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"
|
|
||||||
|
|
||||||
- name: Extract Multi-Platform SBOMs
|
- name: Extract Multi-Platform SBOMs
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
env:
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
PATH: ${{ env.HOME }}/.local/bin:${{ env.PATH }}
|
||||||
export TRIVY_SKIP_VERSION_CHECK="true"
|
TRIVY_SKIP_VERSION_CHECK: 'true'
|
||||||
mkdir -p sboms/head sboms/target
|
INPUT_HEAD_IMAGE: ${{ inputs.head-image }}
|
||||||
|
INPUT_TARGET_IMAGE: ${{ inputs.target-image }}
|
||||||
cleanup_file() {
|
INPUT_ARCHITECTURES: ${{ inputs.architectures }}
|
||||||
local f="$1"
|
INPUT_USE_INBUILD_SBOM: ${{ inputs.use-inbuild-sbom }}
|
||||||
if [ -f "$f" ]; then
|
run: node "${{ github.action_path }}/lib/extract-sboms.js"
|
||||||
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
|
|
||||||
|
|
||||||
- name: Scan Multi-Platform SBOMs and Generate Delta
|
- name: Scan Multi-Platform SBOMs and Generate Delta
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
env:
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
PATH: ${{ env.HOME }}/.local/bin:${{ env.PATH }}
|
||||||
export TRIVY_SKIP_VERSION_CHECK="true"
|
TRIVY_SKIP_VERSION_CHECK: 'true'
|
||||||
mkdir -p cve-reports/head cve-reports/target
|
INPUT_HEAD_IMAGE: ${{ inputs.head-image }}
|
||||||
|
INPUT_TARGET_IMAGE: ${{ inputs.target-image }}
|
||||||
shopt -s nullglob
|
INPUT_TARGET_BRANCH: ${{ inputs.target-branch }}
|
||||||
head_sboms=(sboms/head/sbom-*.spdx.json)
|
INPUT_USE_INBUILD_SBOM: ${{ inputs.use-inbuild-sbom }}
|
||||||
target_sboms=(sboms/target/sbom-*.spdx.json)
|
INPUT_OUTPUT_DELTA: ${{ inputs.output-delta }}
|
||||||
|
INPUT_OUTPUT_SUMMARY: ${{ inputs.output-summary }}
|
||||||
head_ok=true
|
run: node "${{ github.action_path }}/lib/scan-sboms.js"
|
||||||
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
|
|
||||||
|
|
||||||
- name: Publish Reports to Step Summary
|
- name: Publish Reports to Step Summary
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
Reference in new issue
Block a user