mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
137 lines
5.0 KiB
TypeScript
137 lines
5.0 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|||
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||
|
|
import { tmpdir } from "node:os";
|
||
|
|
import { dirname, join, resolve } from "node:path";
|
||
|
|
import { fileURLToPath } from "node:url";
|
||
|
|
import { afterAll, describe, expect, it } from "vitest";
|
||
|
|
|
||
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
||
|
|
const root = resolve(here, "../../..");
|
||
|
|
const gate = resolve(root, "scripts/trivy-unfixed-gate.mjs");
|
||
|
|
const allowFile = resolve(root, ".trivy-unfixed-allow");
|
||
|
|
const workspace = mkdtempSync(join(tmpdir(), "trivy-gate-"));
|
||
|
|
|
||
|
|
afterAll(() => rmSync(workspace, { recursive: true, force: true }));
|
||
|
|
|
||
|
|
function vuln(overrides: Record<string, unknown>) {
|
||
|
|
return {
|
||
|
|
VulnerabilityID: "CVE-0000-0000",
|
||
|
|
PkgName: "somepkg",
|
||
|
|
InstalledVersion: "1.0",
|
||
|
|
Severity: "CRITICAL",
|
||
|
|
Title: "example",
|
||
|
|
...overrides,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function report(vulnerabilities: Record<string, unknown>[]): string {
|
||
|
|
const path = join(workspace, `report-${Math.random().toString(36).slice(2)}.json`);
|
||
|
|
writeFileSync(path, JSON.stringify({ Results: [{ Vulnerabilities: vulnerabilities }] }));
|
||
|
|
return path;
|
||
|
|
}
|
||
|
|
|
||
|
|
function allow(body: string): string {
|
||
|
|
const path = join(workspace, `allow-${Math.random().toString(36).slice(2)}`);
|
||
|
|
writeFileSync(path, body);
|
||
|
|
return path;
|
||
|
|
}
|
||
|
|
|
||
|
|
function run(reportPath: string, allowPath: string, extra: string[] = []) {
|
||
|
|
return spawnSync(process.execPath, [gate, reportPath, "--allow", allowPath, ...extra], {
|
||
|
|
encoding: "utf8",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
describe("trivy unfixed gate", () => {
|
||
|
|
it("fails on an unfixed CRITICAL that nobody has written down", () => {
|
||
|
|
const result = run(
|
||
|
|
report([vuln({ VulnerabilityID: "CVE-2026-99999" })]),
|
||
|
|
allow("# nothing allowed\n"),
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(result.status).toBe(1);
|
||
|
|
expect(result.stdout).toContain("::error::CVE-2026-99999");
|
||
|
|
expect(result.stdout).toContain("**no**");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("passes an unfixed CRITICAL that is on the allowlist, and still reports it", () => {
|
||
|
|
const result = run(
|
||
|
|
report([vuln({ VulnerabilityID: "CVE-2026-99999" })]),
|
||
|
|
allow("# justified elsewhere\nCVE-2026-99999\n"),
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(result.status).toBe(0);
|
||
|
|
// Allowed is not the same as hidden: the whole point is that it stays visible.
|
||
|
|
expect(result.stdout).toContain("CVE-2026-99999");
|
||
|
|
expect(result.stdout).toContain("1 finding with no fix available upstream");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("leaves findings with a fix to the blocking scan", () => {
|
||
|
|
const result = run(
|
||
|
|
report([vuln({ VulnerabilityID: "CVE-2026-99999", FixedVersion: "1.1" })]),
|
||
|
|
allow(""),
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(result.status).toBe(0);
|
||
|
|
expect(result.stdout).toContain("None.");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("ignores severities outside the requested set", () => {
|
||
|
|
const path = report([
|
||
|
|
vuln({ VulnerabilityID: "CVE-2026-11111", Severity: "HIGH" }),
|
||
|
|
vuln({ VulnerabilityID: "CVE-2026-22222", Severity: "CRITICAL" }),
|
||
|
|
]);
|
||
|
|
|
||
|
|
expect(run(path, allow("CVE-2026-22222\n")).status).toBe(0);
|
||
|
|
expect(run(path, allow("CVE-2026-22222\n"), ["--severity", "CRITICAL,HIGH"]).status).toBe(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("warns but does not fail when an allowlist entry is absent", () => {
|
||
|
|
// The release matrix scans one architecture per job, so an entry that is
|
||
|
|
// live on arm64 is legitimately missing from the amd64 report.
|
||
|
|
const result = run(report([]), allow("CVE-2026-99999\n"));
|
||
|
|
|
||
|
|
expect(result.status).toBe(0);
|
||
|
|
expect(result.stdout).toContain("::warning::1 allowlist entry is absent");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("rejects an allowlist line that is not a vulnerability ID", () => {
|
||
|
|
const result = run(report([]), allow("please ignore this one\n"));
|
||
|
|
|
||
|
|
expect(result.status).not.toBe(0);
|
||
|
|
expect(result.stderr).toContain("Not a vulnerability ID");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("writes the report to the GitHub step summary when one is configured", () => {
|
||
|
|
const summary = join(workspace, "summary.md");
|
||
|
|
writeFileSync(summary, "");
|
||
|
|
run(report([vuln({ VulnerabilityID: "CVE-2026-99999" })]), allow("CVE-2026-99999\n"), [
|
||
|
|
"--summary",
|
||
|
|
summary,
|
||
|
|
]);
|
||
|
|
|
||
|
|
expect(readFileSync(summary, "utf8")).toContain("CVE-2026-99999");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("keeps the committed allowlist parseable and free of duplicates", () => {
|
||
|
|
const ids = readFileSync(allowFile, "utf8")
|
||
|
|
.split("\n")
|
||
|
|
.map((line) => line.replace(/#.*$/, "").trim())
|
||
|
|
.filter(Boolean);
|
||
|
|
|
||
|
|
expect(ids.length).toBeGreaterThan(0);
|
||
|
|
expect(new Set(ids).size).toBe(ids.length);
|
||
|
|
for (const id of ids) expect(id).toMatch(/^(?:CVE|GHSA|PYSEC|DLA|DSA|TEMP|OSV)-[\w.-]+$/);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("is wired into both release scan jobs", () => {
|
||
|
|
const release = readFileSync(resolve(root, ".github/workflows/release.yml"), "utf8");
|
||
|
|
const gateSteps = release.match(/node scripts\/trivy-unfixed-gate\.mjs/g) ?? [];
|
||
|
|
|
||
|
|
expect(gateSteps).toHaveLength(2);
|
||
|
|
// The reports the gate reads must not be pre-filtered, or it sees nothing.
|
||
|
|
expect(release).not.toMatch(/format: "json"[\s\S]{0,200}?ignore-unfixed/);
|
||
|
|
expect(release).not.toMatch(/format: json[\s\S]{0,200}?ignore-unfixed/);
|
||
|
|
});
|
||
|
|
});
|