diff --git a/cmd/felis/api.go b/cmd/felis/api.go index 171aebf..4bbeab4 100644 --- a/cmd/felis/api.go +++ b/cmd/felis/api.go @@ -172,9 +172,10 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { buildCfg.FelisImage = os.Getenv("FELIS_IMAGE") buildJobs := build.NewK8sJobs(cl, buildCfg) builder := &build.Builder{ - Store: build.NewPGStore(drv.DB()), - Jobs: buildJobs, - Config: buildCfg, + Store: build.NewPGStore(drv.DB()), + Jobs: buildJobs, + Config: buildCfg, + Outcomes: build.NewK8sOutcomes(clientset, buildCfg), } go probeBuildUserNamespaces(ctx, buildJobs, buildCfg, stderr) @@ -533,6 +534,9 @@ func buildConfig(cfg *config.Config) build.Config { MaxConcurrent: cfg.Registry.MaxConcurrentBuilds, TrivyDBRepository: cfg.Registry.TrivyDBRepository, TrivyJavaDBRepository: cfg.Registry.TrivyJavaDBRepository, + ScanFailOn: cfg.Registry.ScanFailOn, + ScanFailUnfixed: cfg.Registry.ScanFailUnfixed, + ScanAccept: cfg.Registry.ScanAccept, // The submit lane's derived context URLs live here; the fetch step's // service token goes nowhere else. ContextOrigin: internalAPIBaseURL(), diff --git a/cmd/felis/run.go b/cmd/felis/run.go index 41d3fde..3b1aced 100644 --- a/cmd/felis/run.go +++ b/cmd/felis/run.go @@ -23,6 +23,7 @@ Commands: files List/read/write one file in a stopped server's world (internal Job entrypoint) egress-gate Hold a build pod until its egress NetworkPolicy is enforced (internal Job entrypoint) fetch-context Fetch and extract a submission's build context (internal Job entrypoint) + scan-gate Apply the scan policy to a build's Trivy report and hand felis-api the report and SBOM (internal Job entrypoint) push-image Push a scanned image tarball to the registry (internal Job entrypoint) mirror-build-tools Copy kaniko, trivy and Trivy's DBs into the registry (run by felis-build-tools.timer) registry-gate Authorize registry writes in front of registry:2 (internal sidecar entrypoint) @@ -61,6 +62,7 @@ var commands = map[string]func(args []string, stdout, stderr io.Writer) int{ "files": cmdFiles, "egress-gate": cmdEgressGate, "fetch-context": cmdFetchContext, + "scan-gate": cmdScanGate, "push-image": cmdPushImage, "mirror-build-tools": cmdMirrorBuildTools, "registry-gate": cmdRegistryGate, diff --git a/cmd/felis/scangate.go b/cmd/felis/scangate.go new file mode 100644 index 0000000..c965cb3 --- /dev/null +++ b/cmd/felis/scangate.go @@ -0,0 +1,166 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "io/fs" + "os" + "strings" + + "felis.lolicon.best/internal/build" +) + +// maxScanDocument bounds each document scan-gate reads: a modpack report lists a +// few thousand packages, far below this. Tests shrink it. +var maxScanDocument int64 = 64 << 20 + +// cmdScanGate is the build pod's verdict step, after trivy wrote its full JSON +// report and trivy convert wrote the CycloneDX SBOM. It applies the scan policy +// to the report, prints the verdict and every blocking finding, then appends the +// verdict, the report and the SBOM to its log as the envelope felis-api keeps on +// the build (build.WriteScanEnvelope). It exits 1 when a finding blocks, which +// fails the pod before the push step runs, and 2 when the report cannot be read, +// so a scan that produced nothing usable never admits an image. +func cmdScanGate(args []string, stdout, stderr io.Writer) int { + fset := flag.NewFlagSet("scan-gate", flag.ContinueOnError) + fset.SetOutput(stderr) + reportPath := fset.String("report", "", "trivy JSON report (required)") + sbomPath := fset.String("sbom", "", "CycloneDX SBOM to keep with the report") + failOn := fset.String("fail-on", strings.Join(build.DefaultScanFailOn, ","), "comma-separated severities that block the image") + failUnfixed := fset.Bool("fail-unfixed", false, "block on vulnerabilities that have no fixed release too") + accept := fset.String("accept", "", "comma-separated vulnerability ids and secret rule ids that never block") + termLog := fset.String("termination-log", "/dev/termination-log", "where the one-line verdict goes for the pod status") + if err := fset.Parse(args); err != nil { + return 2 + } + // A stray argument is a policy the gate would otherwise drop without a word + // (a comma split out of --fail-on, say). + if fset.NArg() > 0 { + fmt.Fprintf(stderr, "felis scan-gate: unexpected argument %q\n", fset.Arg(0)) + return 2 + } + sevs, err := build.ParseSeverities(*failOn) + if err != nil { + fmt.Fprintf(stderr, "felis scan-gate: --fail-on: %v\n", err) + return 2 + } + accepted, err := build.ParseScanAccept(*accept) + if err != nil { + fmt.Fprintf(stderr, "felis scan-gate: --accept: %v\n", err) + return 2 + } + if *reportPath == "" { + fmt.Fprintln(stderr, "felis scan-gate: --report is required") + return 2 + } + fail := func(msg string) int { + fmt.Fprintln(stderr, "felis scan-gate: "+msg) + writeTerminationLog(*termLog, msg) + return 2 + } + report, err := readScanDocument(*reportPath) + if err != nil { + return fail("the scan report is unreadable: " + err.Error()) + } + policy := build.ScanPolicy{FailOn: sevs, FailUnfixed: *failUnfixed, Accept: accepted} + summary, err := build.Summarize(report, policy) + if err != nil { + return fail("the scan report is unreadable: " + err.Error()) + } + env := build.ScanEnvelope{Summary: summary, Report: report} + if *sbomPath != "" { + switch sbom, err := readScanDocument(*sbomPath); { + case err == nil: + env.SBOM = sbom + case errors.Is(err, fs.ErrNotExist): + fmt.Fprintf(stdout, "felis scan-gate: no SBOM at %s; keeping the report alone\n", *sbomPath) + default: + return fail("the SBOM is unreadable: " + err.Error()) + } + } + + printScanVerdict(stdout, summary) + written, err := build.WriteScanEnvelope(stdout, env) + if err != nil { + return fail("could not write the scan envelope: " + err.Error()) + } + for _, doc := range written.Summary.Omitted { + fmt.Fprintf(stderr, "felis scan-gate: the %s is too large to keep with the build and was left out\n", doc) + } + if summary.Blocked { + writeTerminationLog(*termLog, summary.Reason()) + return 1 + } + writeTerminationLog(*termLog, "the scan passed") + return 0 +} + +// printScanVerdict writes the human half of scan-gate's log. +func printScanVerdict(w io.Writer, s build.ScanSummary) { + var counts []string + for _, sev := range build.Severities { + counts = append(counts, fmt.Sprintf("%s %d", sev, s.Counts[sev])) + } + fmt.Fprintf(w, "felis scan-gate: %d packages; findings: %s\n", s.Packages, strings.Join(counts, ", ")) + unfixed := "vulnerabilities with no fixed release do not block" + if s.Policy.FailUnfixed { + unfixed = "vulnerabilities with no fixed release block too" + } + fmt.Fprintf(w, "felis scan-gate: blocking on %s (%s)\n", strings.Join(s.Policy.FailOn, ", "), unfixed) + if len(s.Policy.Accept) > 0 { + matched := 0 + for _, f := range s.Findings { + if f.Accepted { + matched++ + } + } + fmt.Fprintf(w, "felis scan-gate: accepted ids, never blocking: %s; listed findings under them: %d\n", strings.Join(s.Policy.Accept, ", "), matched) + } + if !s.Blocked { + fmt.Fprintln(w, "felis scan-gate: nothing blocks this image") + return + } + fmt.Fprintln(w, "felis scan-gate: "+build.Printable(s.Reason())) + for _, f := range s.Findings { + if !f.Blocking { + break + } + fix := f.Fixed + if fix == "" { + fix = "no fix" + } + if f.Kind == build.FindingSecret { + fmt.Fprintf(w, " %s %s secret in %s: %s\n", build.Printable(f.ID), f.Severity, build.Printable(f.Target), build.Printable(f.Title)) + continue + } + fmt.Fprintf(w, " %s %s %s %s -> %s (%s)\n", build.Printable(f.ID), f.Severity, + build.Printable(f.Package), build.Printable(f.Installed), build.Printable(fix), build.Printable(f.Target)) + } +} + +func readScanDocument(path string) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + b, err := io.ReadAll(io.LimitReader(f, maxScanDocument+1)) + if err != nil { + return nil, err + } + if int64(len(b)) > maxScanDocument { + return nil, fmt.Errorf("%s exceeds %d bytes", path, maxScanDocument) + } + return b, nil +} + +// writeTerminationLog leaves msg where the kubelet copies it into the container +// status. It is best effort: the log carries the same verdict. +func writeTerminationLog(path, msg string) { + if path == "" { + return + } + _ = os.WriteFile(path, []byte(build.Printable(msg)), 0o644) +} diff --git a/cmd/felis/scangate_test.go b/cmd/felis/scangate_test.go new file mode 100644 index 0000000..e240bce --- /dev/null +++ b/cmd/felis/scangate_test.go @@ -0,0 +1,197 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "felis.lolicon.best/internal/build" +) + +// scanReport has one fixed CRITICAL, one unfixed HIGH and one fixed MEDIUM; the +// CRITICAL's package name carries a line break and a forged envelope frame. +const scanReport = `{"SchemaVersion":2,"Results":[{"Target":"data/mods/core.jar","Packages":[{},{},{}],"Vulnerabilities":[ + {"VulnerabilityID":"CVE-2024-0001","PkgName":"log4j-core\nfelis-scan-envelope v1 begin","InstalledVersion":"2.14.1","FixedVersion":"2.17.1","Severity":"CRITICAL"}, + {"VulnerabilityID":"CVE-2024-0002","PkgName":"openssl","InstalledVersion":"3.0.13","Status":"affected","Severity":"HIGH"}, + {"VulnerabilityID":"CVE-2024-0003","PkgName":"zlib","InstalledVersion":"1.3","FixedVersion":"1.3.1","Severity":"MEDIUM"}]}]}` + +type scanGateRun struct { + code int + stdout, stderr string + termLog string + env *build.ScanEnvelope +} + +func runScanGate(t *testing.T, report, sbom string, extra ...string) scanGateRun { + t.Helper() + dir := t.TempDir() + reportPath := filepath.Join(dir, "trivy.json") + if report != "" { + if err := os.WriteFile(reportPath, []byte(report), 0o644); err != nil { + t.Fatal(err) + } + } + sbomPath := filepath.Join(dir, "sbom.cdx.json") + if sbom != "" { + if err := os.WriteFile(sbomPath, []byte(sbom), 0o644); err != nil { + t.Fatal(err) + } + } + termPath := filepath.Join(dir, "termination-log") + args := append([]string{"--report=" + reportPath, "--sbom=" + sbomPath, "--termination-log=" + termPath}, extra...) + var stdout, stderr bytes.Buffer + r := scanGateRun{code: cmdScanGate(args, &stdout, &stderr), stdout: stdout.String(), stderr: stderr.String()} + if b, err := os.ReadFile(termPath); err == nil { + r.termLog = string(b) + } + if env, err := build.ReadScanEnvelope(strings.NewReader(r.stdout)); err == nil { + r.env = env + } + return r +} + +func TestScanGateBlocksAndHandsOverTheReport(t *testing.T) { + r := runScanGate(t, scanReport, `{"bomFormat":"CycloneDX"}`) + if r.code != 1 { + t.Fatalf("exit %d, want 1; stderr %s", r.code, r.stderr) + } + if r.termLog != "the scan blocked the image: 1 CRITICAL (CVE-2024-0001)" { + t.Errorf("termination log = %q", r.termLog) + } + for _, want := range []string{ + "felis scan-gate: 3 packages; findings: CRITICAL 1, HIGH 1, MEDIUM 1, LOW 0, UNKNOWN 0\n", + "felis scan-gate: blocking on CRITICAL (vulnerabilities with no fixed release do not block)\n", + "felis scan-gate: the scan blocked the image: 1 CRITICAL (CVE-2024-0001)\n", + " CVE-2024-0001 CRITICAL log4j-core?felis-scan-envelope v1 begin 2.14.1 -> 2.17.1 (data/mods/core.jar)\n", + } { + if !strings.Contains(r.stdout, want) { + t.Errorf("stdout lacks %q:\n%s", want, r.stdout) + } + } + if strings.Contains(r.stdout, " CVE-2024-0002") { + t.Errorf("an unfixed HIGH was listed as blocking:\n%s", r.stdout) + } + if strings.Count(r.stdout, "\nfelis-scan-envelope v1 begin\n") != 1 { + t.Errorf("stdout must hold exactly one frame start on a line of its own:\n%s", r.stdout) + } + if r.env == nil { + t.Fatal("no envelope in stdout") + } + if !r.env.Summary.Blocked || string(r.env.SBOM) != `{"bomFormat":"CycloneDX"}` || !strings.Contains(string(r.env.Report), "CVE-2024-0003") { + t.Errorf("envelope = blocked %t, sbom %s, report %d bytes", r.env.Summary.Blocked, r.env.SBOM, len(r.env.Report)) + } +} + +func TestScanGatePolicyFlags(t *testing.T) { + r := runScanGate(t, scanReport, "", "--fail-on=high", "--fail-unfixed") + if r.code != 1 || r.termLog != "the scan blocked the image: 1 HIGH (CVE-2024-0002)" { + t.Errorf("HIGH + unfixed: exit %d, termination log %q", r.code, r.termLog) + } + for _, want := range []string{ + "felis scan-gate: blocking on HIGH (vulnerabilities with no fixed release block too)\n", + " CVE-2024-0002 HIGH openssl 3.0.13 -> no fix (data/mods/core.jar)\n", + } { + if !strings.Contains(r.stdout, want) { + t.Errorf("stdout lacks %q:\n%s", want, r.stdout) + } + } + r = runScanGate(t, scanReport, `{"bomFormat":"CycloneDX"}`, "--fail-on=LOW") + if r.code != 0 || r.termLog != "the scan passed" || !strings.Contains(r.stdout, "felis scan-gate: nothing blocks this image\n") { + t.Errorf("LOW only: exit %d, termination log %q, stdout:\n%s", r.code, r.termLog, r.stdout) + } + if r.env == nil || r.env.Summary.Blocked || r.env.SBOM == nil { + t.Errorf("a passing scan must still hand over its report and SBOM: %+v", r.env) + } +} + +func TestScanGateAcceptedIDsNeverBlock(t *testing.T) { + r := runScanGate(t, scanReport, "", "--fail-on=CRITICAL,MEDIUM", "--accept=CVE-2024-0001, CVE-2024-0002,CVE-2099-0001") + if r.code != 1 || r.termLog != "the scan blocked the image: 1 MEDIUM (CVE-2024-0003)" { + t.Errorf("exit %d, termination log %q", r.code, r.termLog) + } + if !strings.Contains(r.stdout, "felis scan-gate: accepted ids, never blocking: CVE-2024-0001, CVE-2024-0002, CVE-2099-0001; listed findings under them: 2\n") { + t.Errorf("stdout:\n%s", r.stdout) + } + if r.env == nil || strings.Join(r.env.Summary.Policy.Accept, ",") != "CVE-2024-0001,CVE-2024-0002,CVE-2099-0001" { + t.Fatalf("envelope = %+v", r.env) + } + for _, f := range r.env.Summary.Findings { + if f.ID == "CVE-2024-0001" && (f.Blocking || !f.Accepted) { + t.Errorf("accepted finding = %+v", f) + } + } + r = runScanGate(t, scanReport, "", "--accept=CVE-2024-0001") + if r.code != 0 || r.termLog != "the scan passed" { + t.Errorf("only an accepted CRITICAL: exit %d, termination log %q", r.code, r.termLog) + } +} + +func TestScanGateWithoutAnSBOMKeepsTheReport(t *testing.T) { + r := runScanGate(t, scanReport, "", "--fail-on=LOW") + if r.code != 0 || !strings.Contains(r.stdout, "felis scan-gate: no SBOM at ") { + t.Errorf("exit %d, stdout:\n%s", r.code, r.stdout) + } + if r.env == nil || r.env.SBOM != nil || r.env.Report == nil { + t.Errorf("envelope = %+v", r.env) + } +} + +func TestScanGateListsABlockingSecret(t *testing.T) { + r := runScanGate(t, `{"SchemaVersion":2,"Results":[{"Target":"config/keys.txt","Secrets":[ + {"RuleID":"aws-access-key-id","Severity":"CRITICAL","Title":"AWS Access\nKey ID"}]}]}`, "") + if r.code != 1 || r.termLog != "the scan blocked the image: 1 CRITICAL (aws-access-key-id)" { + t.Errorf("exit %d, termination log %q", r.code, r.termLog) + } + if !strings.Contains(r.stdout, " aws-access-key-id CRITICAL secret in config/keys.txt: AWS Access?Key ID\n") { + t.Errorf("stdout:\n%s", r.stdout) + } +} + +func TestScanGateFailsClosed(t *testing.T) { + r := runScanGate(t, "", "") + if r.code != 2 || !strings.HasPrefix(r.termLog, "the scan report is unreadable: open ") || r.env != nil { + t.Errorf("missing report: exit %d, termination log %q", r.code, r.termLog) + } + r = runScanGate(t, `{"SchemaVersion":1}`, "") + if r.code != 2 || r.termLog != "the scan report is unreadable: read trivy report: schema version 1, want 2" { + t.Errorf("old schema: exit %d, termination log %q", r.code, r.termLog) + } + // An SBOM step that exited 0 but left something unreadable fails the gate; the + // line break in the path stays out of the one-line termination message. + sbomDir := filepath.Join(t.TempDir(), "sb\nom") + if err := os.Mkdir(sbomDir, 0o755); err != nil { + t.Fatal(err) + } + r = runScanGate(t, scanReport, "", "--sbom="+sbomDir) + if r.code != 2 || !strings.HasPrefix(r.termLog, "the SBOM is unreadable: read ") || + !strings.HasSuffix(r.termLog, "/sb?om: is a directory") || r.env != nil { + t.Errorf("unreadable SBOM: exit %d, termination log %q", r.code, r.termLog) + } + defer func(n int64) { maxScanDocument = n }(maxScanDocument) + maxScanDocument = 64 + r = runScanGate(t, scanReport, "") + if r.code != 2 || !strings.HasSuffix(r.termLog, "/trivy.json exceeds 64 bytes") || r.env != nil { + t.Errorf("oversized report: exit %d, termination log %q", r.code, r.termLog) + } + maxScanDocument = 64 << 20 + r = runScanGate(t, scanReport, "", "--fail-on=SEVERE") + if r.code != 2 || !strings.Contains(r.stderr, `felis scan-gate: --fail-on: unknown severity "SEVERE"`) { + t.Errorf("bad severity: exit %d, stderr %q", r.code, r.stderr) + } + // A severity list split at its comma leaves a stray argument, not a + // narrower policy. + r = runScanGate(t, scanReport, "", "--fail-on=LOW", "CRITICAL") + if r.code != 2 || !strings.Contains(r.stderr, `felis scan-gate: unexpected argument "CRITICAL"`) || r.env != nil { + t.Errorf("stray argument: exit %d, stderr %q", r.code, r.stderr) + } + r = runScanGate(t, scanReport, "", "--accept=CVE-2024-0001 CVE-2024-0003") + if r.code != 2 || !strings.Contains(r.stderr, `felis scan-gate: --accept: "CVE-2024-0001 CVE-2024-0003" is not a vulnerability id or secret rule id`) { + t.Errorf("bad accept list: exit %d, stderr %q", r.code, r.stderr) + } + var stdout, stderr bytes.Buffer + if code := cmdScanGate(nil, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "--report is required") { + t.Errorf("no report flag: exit %d, stderr %q", code, stderr.String()) + } +} diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index ecce161..a8caae7 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -3002,7 +3002,7 @@ persisted_registry_block() { out="$(awk ' /^[[:space:]]*\[/ { sect = $0; next } sect ~ /^[[:space:]]*\[registry\][[:space:]]*$/ && - /^[[:space:]]*(kaniko_image|trivy_image|trivy_db_repository|trivy_java_db_repository|build_cpu_limit|build_mem_limit|build_disk_limit|build_user_namespaces|build_runtime_class|max_concurrent_builds|user_uploads_context|user_uploads_max_bytes|context_max_bytes)[[:space:]]*=/ { print } + /^[[:space:]]*(kaniko_image|trivy_image|trivy_db_repository|trivy_java_db_repository|build_cpu_limit|build_mem_limit|build_disk_limit|build_user_namespaces|build_runtime_class|max_concurrent_builds|scan_fail_on|scan_fail_unfixed|scan_accept|user_uploads_context|user_uploads_max_bytes|context_max_bytes)[[:space:]]*=/ { print } sect ~ /^[[:space:]]*\[registry\.s3\][[:space:]]*$/ && /^[[:space:]]*[A-Za-z_]+[[:space:]]*=/ { if (!s3hdr) { printf "[registry.s3]\n"; s3hdr = 1 } print diff --git a/deploy/bootstrap_test.sh b/deploy/bootstrap_test.sh index 31b2209..63b4c06 100644 --- a/deploy/bootstrap_test.sh +++ b/deploy/bootstrap_test.sh @@ -1390,6 +1390,9 @@ trivy_java_db_repository = "registry.felis.svc:5000/mirror/trivy-java-db:1" build_disk_limit = "20Gi" build_user_namespaces = "off" build_runtime_class = "gvisor" +scan_fail_on = ["CRITICAL"] +scan_fail_unfixed = true +scan_accept = ["CVE-2021-35515", "CVE-2025-67030"] [registry.s3] endpoint = "https://s3.example" @@ -1430,6 +1433,9 @@ expect "a re-run carries the trivy java-DB mirror" \ expect "a re-run carries the build disk cap" 'build_disk_limit = "20Gi"' "$out" expect "a re-run carries the build user-namespace mode" 'build_user_namespaces = "off"' "$out" expect "a re-run carries the build runtime class" 'build_runtime_class = "gvisor"' "$out" +expect "a re-run carries the scan gate's blocking severities" 'scan_fail_on = ["CRITICAL"]' "$out" +expect "a re-run carries the scan gate's unfixed-vulnerability rule" 'scan_fail_unfixed = true' "$out" +expect "a re-run carries the scan gate's accepted finding ids" 'scan_accept = ["CVE-2021-35515", "CVE-2025-67030"]' "$out" expect "a re-run carries the [registry.s3] uploads subtable" "[registry.s3]" "$out" expect "the carried subtable keeps its keys" 'endpoint = "https://s3.example"' "$out" expect "url stays installer-owned" 'url = "registry.felis.svc:5000"' "$out" diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 1401843..55cc643 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -560,6 +560,93 @@ components: format: date-time description: Omitted until the build reaches a terminal status. + BuildScan: + type: object + description: >- + What a build's scan gate kept (GET /api/v1/images/build/{id}/scan): its + verdict under the policy it ran with, the listed findings, and which full + documents can be downloaded. + required: [build_id, scanned_at, summary, has_report, has_sbom] + properties: + build_id: { type: string } + scanned_at: { type: string, format: date-time } + summary: { $ref: '#/components/schemas/ScanSummary' } + has_report: + type: boolean + description: The full Trivy JSON report is kept (GET .../scan/report). + has_sbom: + type: boolean + description: The CycloneDX SBOM is kept (GET .../sbom). + + ScanSummary: + type: object + description: The verdict scan-gate reached on one Trivy report (internal/build ScanSummary). + required: [policy, blocked, packages, counts, blocking_counts, findings] + properties: + policy: { $ref: '#/components/schemas/ScanPolicy' } + blocked: + type: boolean + description: A finding blocked the image, so it was never pushed. + packages: + type: integer + description: Packages Trivy found in the image. + counts: + type: object + description: Every finding by severity (CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN); a severity with none is absent. + additionalProperties: { type: integer } + blocking_counts: + type: object + description: The findings the policy blocks on, by severity. + additionalProperties: { type: integer } + findings: + type: array + description: Blocking findings first, then the rest, most severe first; at most 100. The downloadable report lists all of them. + items: { $ref: '#/components/schemas/ScanFinding' } + omitted: + type: array + description: Documents (report, sbom) too large to keep with the build. Omitted when none. + items: { type: string, enum: [report, sbom] } + + ScanPolicy: + type: object + description: Which findings block an image ([registry] scan_fail_on / scan_fail_unfixed / scan_accept). + required: [fail_on, fail_unfixed] + properties: + fail_on: + type: array + items: { type: string, enum: [CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN] } + fail_unfixed: + type: boolean + description: A vulnerability with no fixed release blocks too. + accept: + type: array + items: { type: string } + description: Vulnerability ids and secret rule ids accepted as known risks; findings under them never block. Omitted when none. + + ScanFinding: + type: object + description: One vulnerability or leaked secret (internal/build ScanFinding). + required: [id, kind, severity, target, blocking] + properties: + id: + type: string + description: The CVE/GHSA id, or the secret rule id. + kind: { type: string, enum: [vulnerability, secret] } + severity: { type: string, enum: [CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN] } + package: { type: string } + installed: { type: string } + fixed: + type: string + description: The first release that fixes it. Omitted when none exists. + target: + type: string + description: The file or layer Trivy found it in. + title: { type: string } + blocking: { type: boolean } + accepted: + type: boolean + description: The policy accepts this id, so it never blocks. Omitted when false. + Image: type: object description: One whitelisted image (internal/build Image). @@ -5646,6 +5733,90 @@ paths: '503': $ref: '#/components/responses/ServiceUnavailable' + /api/v1/images/build/{id}/scan: + get: + tags: [images] + operationId: getBuildScan + summary: The scan gate's verdict and findings for a build (admin). + x-felis-face: [external] + x-felis-tier: admin + security: [{ sessionCookie: [] }] + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + responses: + '200': + description: The kept scan. + content: + application/json: + schema: { $ref: '#/components/schemas/BuildScan' } + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: No scan for this build (scan_not_found) — it has not reached the scan step, or it ran before builds kept their scans. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '503': + $ref: '#/components/responses/ServiceUnavailable' + + /api/v1/images/build/{id}/scan/report: + get: + tags: [images] + operationId: getBuildScanReport + summary: Download a build's full Trivy JSON report (admin). + x-felis-face: [external] + x-felis-tier: admin + security: [{ sessionCookie: [] }] + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + responses: + '200': + description: The Trivy report (SchemaVersion 2), served as the attachment -trivy.json. + content: + application/json: + schema: { type: object } + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: No scan for this build (scan_not_found), or the report was too large to keep (scan_document_not_kept). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '503': + $ref: '#/components/responses/ServiceUnavailable' + + /api/v1/images/build/{id}/sbom: + get: + tags: [images] + operationId: getBuildSBOM + summary: Download a build's CycloneDX SBOM (admin). + x-felis-face: [external] + x-felis-tier: admin + security: [{ sessionCookie: [] }] + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + responses: + '200': + description: The CycloneDX JSON SBOM, served as the attachment .cdx.json. + content: + application/vnd.cyclonedx+json: + schema: { type: object } + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: No scan for this build (scan_not_found), or the SBOM was too large to keep or its step failed (scan_document_not_kept). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '503': + $ref: '#/components/responses/ServiceUnavailable' + /api/v1/images: get: tags: [images] diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 7f80290..f0e9017 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -479,12 +479,14 @@ internal registry. ### 8d. Build reaches `Failed` phase `reconcileBuilds` polls the Job; a Job reaching `Failed` is surfaced via -`writeBuildError` (JobPhase→Failed). [GO-TESTED for the mapping.] The underlying -cause — a kaniko build error, the **Trivy CRITICAL-CVE gate** failing the build -(spec §16), or the final push — is in the Job's pod logs and is -[INTEGRATION-ONLY]. The pod runs `egress-gate` (§8f), `context-fetch`, `kaniko` -(builds a tarball, never pushes) and `trivy` (scans that tarball) as init -containers, then `push` — so a CVE-rejected image never reaches the registry. Inspect every step: +`writeBuildError` (JobPhase→Failed). [GO-TESTED for the mapping.] The +build's error names the cause: the step that failed with the last lines of its +output, the deadline, or the scan verdict (spec §16). The pod runs `egress-gate` +(§8f), `context-fetch`, `kaniko` (builds a tarball, never pushes), `trivy` +(writes the full JSON report of that tarball), `sbom` (converts the report to a +CycloneDX SBOM) and `scan-gate` (applies the scan policy) as init containers, +then `push` — so an image the scan blocks never reaches the registry. Inspect +every step: ``` kubectl logs -n felis-build job/ --all-containers --prefix @@ -495,6 +497,54 @@ A `push` that fails with `403` means the target repository is under `felis/` or the `felis-registry-push` Secret in `felis-build` is missing or stale (re-run the installer). +**The scan gate.** `scan-gate` blocks the image when a vulnerability or a +leaked secret has a severity listed in `[registry] scan_fail_on` (§8e; default +`CRITICAL`). A vulnerability with no fixed release is listed without +blocking unless `scan_fail_unfixed = true`, since nothing can be upgraded to +clear it. A blocked build ends with an error such as: + +``` +the scan blocked the image: 1 CRITICAL, 1 HIGH (CVE-2026-12345, CVE-2025-24813) +``` + +`HIGH` is opt-in because the platform's own `felis/paper` image carries five +fixable HIGH findings inside upstream `paper.jar` (its bundled commons-compress +1.5 and plexus-utils 3.5.1). Adding `HIGH` to `scan_fail_on` blocks every build +`FROM` it until those ids are accepted as known risks in `scan_accept`: + +```toml +scan_fail_on = ["CRITICAL", "HIGH"] +scan_accept = ["CVE-2021-35515", "CVE-2021-35516", "CVE-2021-35517", "CVE-2021-36090", "CVE-2025-67030"] +``` + +An accepted id (a CVE, GHSA or similar advisory id, or a secret rule id such as +`aws-access-key-id`) never blocks; its findings are still counted, listed and +marked **Accepted** on the panel, and scan-gate's log names the accepted ids. +Review the list whenever the base image is upgraded. + +felis-api keeps each finished build's scan: the verdict, up to 100 findings with +the blocking ones first, the full Trivy report and the SBOM. On the panel, +**Build Pipeline → Scan & logs** on a finished build shows them, with both files to download. The +API serves the same data (admin only): + +| Endpoint | Returns | +|---|---| +| `GET /api/v1/images/build/{id}/scan` | the verdict and findings; `404 scan_not_found` for a build that stopped before the scan or ran before builds kept scans | +| `GET /api/v1/images/build/{id}/scan/report` | the Trivy JSON report as `-trivy.json` | +| `GET /api/v1/images/build/{id}/sbom` | the CycloneDX SBOM as `.cdx.json` | + +The report and SBOM travel to felis-api inside the scan-gate container's log, so +one image gets at most 6 MiB of them compressed. Past that the gate drops the +SBOM first, then the report, says so in its log, and the download answers +`404 scan_document_not_kept`; the verdict and findings are always kept. A +`scan-gate` exit 2 means the report was missing or unreadable; the build fails +closed and the error says why. + +A scan reflects the vulnerability DB on the day of the build. An admitted image +is not scanned again when the DB learns of a new CVE; to rescan it, start a new +build of the same context (`POST /api/v1/images/build`), after +`felis mirror-build-tools -only trivy-db` if the DB copy is old (§8e). + ### 8e. Build Pods never start: executor images and the scan DBs A build Job runs Kaniko and Trivy, and Trivy reads two databases: the @@ -546,6 +596,9 @@ kaniko_image = "" # empty: the mirror/ copy above trivy_image = "" trivy_db_repository = "" trivy_java_db_repository = "" +scan_fail_on = ["CRITICAL"] # §8d: severities that block; any case; empty = CRITICAL +scan_fail_unfixed = false # §8d: true blocks on vulnerabilities with no fixed release too +scan_accept = [] # §8d: vulnerability or secret rule ids accepted as known risks; never block build_cpu_limit = "2" build_mem_limit = "4Gi" build_disk_limit = "12Gi" # §8f @@ -593,6 +646,7 @@ approved-but-hostile Dockerfile and the node is the pod around it: | User namespace | with `build_user_namespaces` on, root in the pod is an unprivileged uid on the node | below | | Sandbox runtime | optional `build_runtime_class` (gVisor, Kata) | below | | Credentials | the registry credential lives only in the `push` container; the service token only in `context-fetch` | jobspec | +| Scan gate | the image's Trivy report is judged under `scan_fail_on` before `push` runs; a blocked or unreadable scan keeps the image out of the registry | `felis scan-gate`, §8d | | Resources | CPU, memory and ephemeral-storage limits per container; `activeDeadlineSeconds`; the context extraction stops at 4 GiB or 200 000 entries | jobspec, `felis fetch-context` | | Namespace backstop | `felis-build-limits` LimitRange gives any container without limits 1 CPU / 1 GiB / 1 GiB disk; `felis-build-quota` allows 8 running pods and no PVCs | bundle | | Concurrency | at most `[registry] max_concurrent_builds` (default 2, at most 6) builds run; later ones wait as `pending` (Queued) and start oldest first | `build.Builder` | diff --git a/internal/api/api.go b/internal/api/api.go index b92f21d..3ef090d 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -641,6 +641,9 @@ func (a *API) externalAPIRoutes() []apiRoute { {Method: "GET", Pattern: "/api/v1/images/build/{id}", Admin: true, h: a.handleGetBuild}, {Method: "GET", Pattern: "/api/v1/images/build/{id}/logs", Admin: true, h: a.handleBuildLogs}, {Method: "POST", Pattern: "/api/v1/images/build/{id}/cancel", Admin: true, h: a.handleCancelBuild}, + {Method: "GET", Pattern: "/api/v1/images/build/{id}/scan", Admin: true, h: a.handleBuildScan}, + {Method: "GET", Pattern: "/api/v1/images/build/{id}/scan/report", Admin: true, h: a.handleBuildScanReport}, + {Method: "GET", Pattern: "/api/v1/images/build/{id}/sbom", Admin: true, h: a.handleBuildSBOM}, {Method: "GET", Pattern: "/api/v1/images", Admin: true, h: a.handleListImages}, {Method: "POST", Pattern: "/api/v1/images", Admin: true, h: a.handleAddImage}, {Method: "DELETE", Pattern: "/api/v1/images", Admin: true, h: a.handleRemoveImage}, diff --git a/internal/api/images.go b/internal/api/images.go index 362a729..fceea0e 100644 --- a/internal/api/images.go +++ b/internal/api/images.go @@ -1,10 +1,14 @@ package api import ( + "bytes" + "compress/gzip" "context" "errors" + "io" "net/http" "strconv" + "time" "felis.lolicon.best/internal/build" "felis.lolicon.best/internal/imagepin" @@ -36,6 +40,10 @@ type ImageBuilder interface { // enabled — the §15 create-server form gate (admission is data-driven, never // a free image string from the body). ImageAdmitted(ctx context.Context, imageRef string) (bool, error) + // Scan reads what the build's scan gate kept: the verdict, the listed + // findings, and the gzipped Trivy report and CycloneDX SBOM. A build with no + // scan is build.ErrNotFound. + Scan(ctx context.Context, id string) (*build.Scan, error) } // buildImageRequest is the POST /images/build body (spec §16). The push target, @@ -210,6 +218,91 @@ func (a *API) handleCancelBuild(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, bld) } +// buildScanView is GET /images/build/{id}/scan: the scan gate's verdict and +// listed findings, and which full documents the build keeps for download. +type buildScanView struct { + BuildID string `json:"build_id"` + ScannedAt time.Time `json:"scanned_at"` + Summary build.ScanSummary `json:"summary"` + HasReport bool `json:"has_report"` + HasSBOM bool `json:"has_sbom"` +} + +// handleBuildScan returns the scan a build's scan gate kept (admin-tier). The +// verdict is the gate's own, under the policy recorded with it, so a later +// change to [registry] scan_fail_on never rewrites why an old build failed. +func (a *API) handleBuildScan(w http.ResponseWriter, r *http.Request) { + scan, ok := a.buildScan(w, r) + if !ok { + return + } + writeJSON(w, http.StatusOK, buildScanView{ + BuildID: scan.BuildID, ScannedAt: scan.ScannedAt, Summary: scan.Summary, + HasReport: len(scan.ReportGz) > 0, HasSBOM: len(scan.SBOMGz) > 0, + }) +} + +// handleBuildScanReport downloads the build's full Trivy JSON report. +func (a *API) handleBuildScanReport(w http.ResponseWriter, r *http.Request) { + a.serveScanDocument(w, r, "report", func(s *build.Scan) []byte { return s.ReportGz }, + "application/json", "-trivy.json", "image.build.scan.report") +} + +// handleBuildSBOM downloads the build's CycloneDX SBOM. +func (a *API) handleBuildSBOM(w http.ResponseWriter, r *http.Request) { + a.serveScanDocument(w, r, "SBOM", func(s *build.Scan) []byte { return s.SBOMGz }, + "application/vnd.cyclonedx+json", ".cdx.json", "image.build.sbom") +} + +// buildScan reads the scan named by the request path. On failure the error +// response is already written. +func (a *API) buildScan(w http.ResponseWriter, r *http.Request) (*build.Scan, bool) { + if a.Builder == nil { + writeError(w, r, errBuildUnavailable) + return nil, false + } + scan, err := a.Builder.Scan(r.Context(), r.PathValue("id")) + switch { + case errors.Is(err, build.ErrNotFound): + writeError(w, r, newError(http.StatusNotFound, "scan_not_found", + "this build has no scan: it has not reached the scan step, or it ran before builds kept their scans")) + return nil, false + case err != nil: + writeBuildError(w, r, err) + return nil, false + } + return scan, true +} + +// serveScanDocument sends one gzipped document of a build's scan as a +// download. The bytes are the image's own (package names, file paths), so the +// attachment disposition, with the nosniff every response carries, keeps a +// browser from rendering them. +func (a *API) serveScanDocument(w http.ResponseWriter, r *http.Request, what string, + doc func(*build.Scan) []byte, contentType, suffix, action string) { + scan, ok := a.buildScan(w, r) + if !ok { + return + } + gz := doc(scan) + if len(gz) == 0 { + writeError(w, r, newError(http.StatusNotFound, "scan_document_not_kept", + "this build's scan kept no %s: it was too large to keep, or the step that writes it failed", what)) + return + } + zr, err := gzip.NewReader(bytes.NewReader(gz)) + if err != nil { + writeError(w, r, err) + return + } + defer zr.Close() + a.audit(r, action, scan.BuildID) + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Disposition", `attachment; filename="`+scan.BuildID+suffix+`"`) + w.WriteHeader(http.StatusOK) + _, _ = io.Copy(w, zr) +} + // handleListImages returns the image whitelist (spec §15: the create-server form // source). func (a *API) handleListImages(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/images_test.go b/internal/api/images_test.go index 1f30bd6..3d61bd0 100644 --- a/internal/api/images_test.go +++ b/internal/api/images_test.go @@ -1,11 +1,15 @@ package api import ( + "bytes" + "compress/gzip" "context" "encoding/json" + "errors" "fmt" "net/http" "testing" + "time" "felis.lolicon.best/internal/build" ) @@ -35,6 +39,8 @@ type fakeBuilder struct { buildsTotal int buildsErr error listOpts build.ListOpts + scans map[string]*build.Scan + scanErr error } func (f *fakeBuilder) Submit(_ context.Context, req build.Request) (*build.Build, error) { @@ -109,6 +115,16 @@ func (f *fakeBuilder) ImageAdmitted(_ context.Context, ref string) (bool, error) return f.admitted[ref], nil } +func (f *fakeBuilder) Scan(_ context.Context, id string) (*build.Scan, error) { + if f.scanErr != nil { + return nil, f.scanErr + } + if s, ok := f.scans[id]; ok { + return s, nil + } + return nil, build.ErrNotFound +} + func adminAPI(b ImageBuilder) *API { api := newTestAPI(newFakeRepo(), newFakeCluster()) api.Builder = b @@ -127,6 +143,9 @@ func TestImageRoutesAreAdminOnly(t *testing.T) { {"GET", "/api/v1/images/build/bld-1", ""}, {"GET", "/api/v1/images/build/bld-1/logs", ""}, {"POST", "/api/v1/images/build/bld-1/cancel", ""}, + {"GET", "/api/v1/images/build/bld-1/scan", ""}, + {"GET", "/api/v1/images/build/bld-1/scan/report", ""}, + {"GET", "/api/v1/images/build/bld-1/sbom", ""}, {"GET", "/api/v1/images", ""}, {"POST", "/api/v1/images", `{"image_ref":"registry.felis.svc:5000/x:1"}`}, {"DELETE", "/api/v1/images?ref=registry.felis.svc:5000/x:1", ""}, @@ -327,3 +346,123 @@ func TestImageRoutesWithoutBuilderAre503(t *testing.T) { // Compile-time proof that the production Builder satisfies the API interface. var _ ImageBuilder = (*build.Builder)(nil) + +func gzipped(t *testing.T, s string) []byte { + t.Helper() + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + if _, err := zw.Write([]byte(s)); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// scannedBuilder holds one blocked scan of bld-7 that kept its report but no +// SBOM. +func scannedBuilder(t *testing.T) *fakeBuilder { + return &fakeBuilder{scans: map[string]*build.Scan{"bld-7": { + BuildID: "bld-7", + ScannedAt: time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC), + Summary: build.ScanSummary{ + Policy: build.ScanPolicy{FailOn: []string{"CRITICAL", "HIGH"}}, + Blocked: true, + Packages: 12, + Counts: map[string]int{"CRITICAL": 1, "MEDIUM": 2}, + BlockingCounts: map[string]int{"CRITICAL": 1}, + Findings: []build.ScanFinding{{ID: "CVE-2024-0001", Kind: "vulnerability", Severity: "CRITICAL", + Package: "log4j-core", Installed: "2.14.1", Fixed: "2.17.1", Target: "mods/core.jar", Blocking: true}}, + }, + ReportGz: gzipped(t, `{"SchemaVersion":2,"Results":[]}`), + }}} +} + +func TestBuildScanReturnsTheGateVerdict(t *testing.T) { + w := do(adminAPI(scannedBuilder(t)).ExternalHandler(), "GET", "/api/v1/images/build/bld-7/scan", "", nil) + if w.Code != http.StatusOK { + t.Fatalf("code = %d (%s)", w.Code, w.Body.String()) + } + var got map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + summary, _ := got["summary"].(map[string]any) + findings, _ := summary["findings"].([]any) + if got["build_id"] != "bld-7" || got["scanned_at"] != "2026-09-20T10:00:00Z" || + got["has_report"] != true || got["has_sbom"] != false { + t.Errorf("view = %s", w.Body.String()) + } + if summary["blocked"] != true || summary["packages"] != float64(12) || len(findings) != 1 { + t.Errorf("summary = %s", w.Body.String()) + } + if f, _ := findings[0].(map[string]any); f["id"] != "CVE-2024-0001" || f["fixed"] != "2.17.1" || f["blocking"] != true { + t.Errorf("finding = %v", findings[0]) + } + if _, leaked := got["report_gz"]; leaked { + t.Error("the scan view must not inline the report bytes") + } +} + +func TestBuildScanMissingOrUnreadable(t *testing.T) { + w := do(adminAPI(scannedBuilder(t)).ExternalHandler(), "GET", "/api/v1/images/build/bld-8/scan", "", nil) + if w.Code != http.StatusNotFound || decodeErr(t, w) != "scan_not_found" { + t.Errorf("no scan: code %d, %s", w.Code, w.Body.String()) + } + fb := scannedBuilder(t) + fb.scanErr = errors.New("database is down") + w = do(adminAPI(fb).ExternalHandler(), "GET", "/api/v1/images/build/bld-7/scan/report", "", nil) + if w.Code != http.StatusInternalServerError { + t.Errorf("store error: code %d, %s", w.Code, w.Body.String()) + } + api := adminAPI(nil) + api.Builder = nil + w = do(api.ExternalHandler(), "GET", "/api/v1/images/build/bld-7/sbom", "", nil) + if w.Code != http.StatusServiceUnavailable { + t.Errorf("no builder: code %d, %s", w.Code, w.Body.String()) + } +} + +func TestBuildScanDocumentsDownload(t *testing.T) { + fb := scannedBuilder(t) + api := adminAPI(fb) + w := do(api.ExternalHandler(), "GET", "/api/v1/images/build/bld-7/scan/report", "", nil) + if w.Code != http.StatusOK || w.Body.String() != `{"SchemaVersion":2,"Results":[]}` { + t.Fatalf("report: code %d, body %q", w.Code, w.Body.String()) + } + for h, want := range map[string]string{ + "Content-Type": "application/json", + "Content-Disposition": `attachment; filename="bld-7-trivy.json"`, + "X-Content-Type-Options": "nosniff", + } { + if got := w.Header().Get(h); got != want { + t.Errorf("report %s = %q, want %q", h, got, want) + } + } + audits := api.Repo.(*fakeRepo).audits + if len(audits) != 1 || audits[0].Action != "image.build.scan.report" || audits[0].ServerName != "bld-7" { + t.Errorf("audits = %+v", audits) + } + + w = do(api.ExternalHandler(), "GET", "/api/v1/images/build/bld-7/sbom", "", nil) + if w.Code != http.StatusNotFound || decodeErr(t, w) != "scan_document_not_kept" { + t.Errorf("SBOM not kept: code %d, %s", w.Code, w.Body.String()) + } + if len(api.Repo.(*fakeRepo).audits) != 1 { + t.Error("a download that sent nothing was audited") + } + + fb.scans["bld-7"].SBOMGz = gzipped(t, `{"bomFormat":"CycloneDX","specVersion":"1.6"}`) + w = do(api.ExternalHandler(), "GET", "/api/v1/images/build/bld-7/sbom", "", nil) + if w.Code != http.StatusOK || w.Body.String() != `{"bomFormat":"CycloneDX","specVersion":"1.6"}` { + t.Fatalf("SBOM: code %d, body %q", w.Code, w.Body.String()) + } + if w.Header().Get("Content-Type") != "application/vnd.cyclonedx+json" || + w.Header().Get("Content-Disposition") != `attachment; filename="bld-7.cdx.json"` { + t.Errorf("SBOM headers = %v", w.Header()) + } + if audits := api.Repo.(*fakeRepo).audits; audits[len(audits)-1].Action != "image.build.sbom" { + t.Errorf("audits = %+v", audits) + } +} diff --git a/internal/api/openapi_parity_test.go b/internal/api/openapi_parity_test.go index 4ac55dd..135c5d4 100644 --- a/internal/api/openapi_parity_test.go +++ b/internal/api/openapi_parity_test.go @@ -39,6 +39,10 @@ func TestOpenAPISchemasMatchWireStructs(t *testing.T) { "BackupView": BackupView{}, "Build": build.Build{}, "Image": build.Image{}, + "BuildScan": buildScanView{}, + "ScanSummary": build.ScanSummary{}, + "ScanPolicy": build.ScanPolicy{}, + "ScanFinding": build.ScanFinding{}, "Submission": submissionView{}, "UserView": UserView{}, "UserDetail": UserDetail{}, diff --git a/internal/build/build.go b/internal/build/build.go index a31581b..7cb0cba 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -14,11 +14,13 @@ // (jobspec.go) and are asserted by unit tests, since no cluster runs here. // // The Trivy gate is enforced as the build Pod's *exit code*: a kaniko -// initContainer builds into a tarball (--no-push), a trivy initContainer scans it -// with `--exit-code 1 --severity CRITICAL`, and only then does the push container -// — the one holding the registry credential — publish it. Therefore "Job -// Succeeded" is equivalent to "no CRITICAL CVE AND pushed", and a rejected image -// never reaches the registry. felis-api observes the Job phase +// initContainer builds into a tarball (--no-push), a trivy initContainer writes +// the full report, and scan-gate exits 1 when a finding matches the scan policy +// (ScanPolicy; HIGH and CRITICAL with a fixed release by default); only then does +// the push container — the one holding the registry credential — publish it. +// Therefore "Job Succeeded" is equivalent to "nothing the policy blocks AND +// pushed", and a rejected image never reaches the registry. The report and a +// CycloneDX SBOM come back through scan-gate's log and stay on the build (Scan). felis-api observes the Job phase // and performs the database writes — the build Pod itself never has database // credentials (the weak-SA red line). On success the image is admitted to // image_whitelist with enabled=true (recording added_by); on failure the build @@ -215,6 +217,10 @@ type Store interface { // AdmitBuiltImage upserts an image_whitelist row with enabled=true and // source=built (the scan-gate success path, spec §16). It records added_by. AdmitBuiltImage(ctx context.Context, img Image) error + // SaveScan stores the scan record of a build, replacing an earlier one. + SaveScan(ctx context.Context, s Scan) error + // GetScan loads a build's scan record, or ErrNotFound. + GetScan(ctx context.Context, buildID string) (*Scan, error) // ListImages returns the image whitelist. ListImages(ctx context.Context) ([]Image, error) // AddExternalImage upserts an externally-pushed image (spec §15 external @@ -238,6 +244,27 @@ type Jobs interface { CancelBuildJob(ctx context.Context, jobName string) error } +// JobOutcomes reads what a finished build pod left behind. A nil JobOutcomes +// keeps Sync to the Job phase alone. +type JobOutcomes interface { + Outcome(ctx context.Context, buildID string) (Outcome, error) +} + +// Outcome is what a finished build pod reports. +type Outcome struct { + // FailedStep is the container whose non-zero exit ended the pod ("" when + // none did), with its exit code and termination message. + FailedStep string + ExitCode int32 + Message string + // DeadlineExceeded is set when the Job ran past activeDeadlineSeconds. + DeadlineExceeded bool + // Scan is scan-gate's envelope, nil when the step never ran or its log held + // none; ScanErr then says why a log that should hold one did not. + Scan *ScanEnvelope + ScanErr error +} + // Config parameterises the build subsystem from felis.toml (spec §24 [registry] // + safety limits). It is validated by withDefaults before use. type Config struct { @@ -266,6 +293,12 @@ type Config struct { // registry's copies (Tools). KanikoImage string TrivyImage string + // ScanFailOn lists the severities that block an image; empty applies + // DefaultScanFailOn. ScanFailUnfixed blocks on findings with no fixed release + // too, and ScanAccept names finding ids that never block (ScanPolicy). + ScanFailOn []string + ScanFailUnfixed bool + ScanAccept []string // Deadline caps a build's wall-clock (spec §16: activeDeadlineSeconds). Deadline time.Duration // MaxDockerfileBytes caps the uploaded Dockerfile (spec §16: context size @@ -381,6 +414,9 @@ func (c Config) withDefaults() Config { if c.MaxConcurrent > MaxConcurrentLimit { c.MaxConcurrent = MaxConcurrentLimit } + if len(c.ScanFailOn) == 0 { + c.ScanFailOn = DefaultScanFailOn + } return c } @@ -391,6 +427,9 @@ type Builder struct { Store Store Jobs Jobs Config Config + // Outcomes reads a finished pod's failed step and scan envelope. Nil records + // a generic failure and keeps no scan. + Outcomes JobOutcomes startMu sync.Mutex @@ -508,6 +547,9 @@ func (b *Builder) jobParams(bld *Build, cfg Config) JobParams { TrivyJavaDBRepository: cfg.TrivyJavaDBRepository, KanikoImage: cfg.KanikoImage, TrivyImage: cfg.TrivyImage, + ScanFailOn: cfg.ScanFailOn, + ScanFailUnfixed: cfg.ScanFailUnfixed, + ScanAccept: cfg.ScanAccept, Deadline: cfg.Deadline, CPULimit: cfg.CPULimit, MemLimit: cfg.MemLimit, @@ -558,11 +600,16 @@ func (b *Builder) ListBuilds(ctx context.Context, opts ListOpts) ([]Build, int, // translation (spec §16). A terminal build is returned unchanged (idempotent). // // - JobSucceeded → status=succeeded AND the image is admitted to the whitelist -// with enabled=true (trivy found no CRITICAL CVE and the push landed). -// - JobFailed / JobUnknown → status=failed, nothing admitted (a CRITICAL CVE -// surfaces here as a failed Job, since trivy runs with --exit-code 1). +// with enabled=true (the scan found nothing the policy blocks and the push +// landed). +// - JobFailed / JobUnknown → status=failed, nothing admitted. The error names +// what ended the pod: the scan verdict with the blocking finding ids, or the +// failed step and its last log lines (failureReason). // - JobPending / JobRunning → no change. // +// A finished pod's scan envelope is stored first (SaveScan), for a blocked build +// and an admitted one alike. +// // The image admission is performed by felis-api (this code path), never by the // build Pod, which holds no database credentials. func (b *Builder) Sync(ctx context.Context, id string) (*Build, error) { @@ -587,6 +634,24 @@ func (b *Builder) Sync(ctx context.Context, id string) (*Build, error) { if err != nil { return nil, err } + if phase != JobSucceeded && phase != JobFailed && phase != JobUnknown { + return bld, nil // JobPending / JobRunning + } + var out Outcome + if b.Outcomes != nil && phase != JobUnknown { + if out, err = b.Outcomes.Outcome(ctx, bld.ID); err != nil { + return nil, err + } + } + if out.Scan != nil { + scan, err := newScan(bld.ID, out.Scan, b.now()) + if err != nil { + return nil, err + } + if err := b.Store.SaveScan(ctx, scan); err != nil { + return nil, err + } + } switch phase { case JobSucceeded: now := b.now() @@ -604,13 +669,77 @@ func (b *Builder) Sync(ctx context.Context, id string) (*Build, error) { return nil, err } return b.finishAt(ctx, bld, StatusSucceeded, "", now) - case JobFailed, JobUnknown: - return b.finish(ctx, bld, StatusFailed, "build job failed or scan found a CRITICAL CVE") - default: // JobPending / JobRunning - return bld, nil + case JobUnknown: + return b.finish(ctx, bld, StatusFailed, "the build job is gone: it was deleted before it finished") + default: + return b.finish(ctx, bld, StatusFailed, b.failureReason(out)) } } +// stepNames words each build step for a failure message. +var stepNames = map[string]string{ + ContainerGate: "the egress gate", + ContainerFetch: "fetching the build context", + ContainerKaniko: "the image build", + ContainerTrivy: "the vulnerability scan", + ContainerSBOM: "writing the SBOM", + ContainerScanGate: "the scan gate", + ContainerPush: "the registry push", +} + +// maxFailureMessage bounds the step message a failed build records. +const maxFailureMessage = 600 + +// failureReason is the error a failed build records: the scan verdict when the +// policy blocked the image, the deadline when the Job ran out of time, or the +// failed step with the tail of its termination message. +func (b *Builder) failureReason(out Outcome) string { + switch { + case out.Scan != nil && out.Scan.Summary.Blocked: + return out.Scan.Summary.Reason() + case out.DeadlineExceeded: + return fmt.Sprintf("the build ran past its %s deadline", b.Config.withDefaults().Deadline) + case out.FailedStep == "": + return "the build job failed" + } + name := stepNames[out.FailedStep] + if name == "" { + name = "the " + out.FailedStep + " step" + } + msg := fmt.Sprintf("%s failed (exit %d)", name, out.ExitCode) + if tail := messageTail(out.Message); tail != "" { + msg += ": " + tail + } + if out.FailedStep == ContainerScanGate && out.ScanErr != nil { + msg += "; the scan report could not be read back: " + out.ScanErr.Error() + } + return msg +} + +// messageTail keeps the last three non-empty lines of a termination message on +// one line, tabs as spaces and other control characters removed, at most +// maxFailureMessage bytes. +func messageTail(m string) string { + var lines []string + for _, l := range strings.Split(m, "\n") { + if l = strings.TrimSpace(Printable(strings.ReplaceAll(strings.TrimRight(l, "\r"), "\t", " "))); l != "" { + lines = append(lines, l) + } + } + lines = lines[max(0, len(lines)-3):] + out := strings.Join(lines, " | ") + if len(out) > maxFailureMessage { + out = "…" + strings.ToValidUTF8(out[len(out)-maxFailureMessage:], "") + } + return out +} + +// Scan returns a build's scan record, or ErrNotFound when the build has none +// (it failed before the scan, or finished before scans were kept). +func (b *Builder) Scan(ctx context.Context, id string) (*Scan, error) { + return b.Store.GetScan(ctx, id) +} + // SyncAll reconciles every running build, then starts queued builds oldest // first while fewer than MaxConcurrent run. It returns the count advanced to a // terminal state. felis-api calls this periodically (spec §16: the scan gate is @@ -713,9 +842,9 @@ func (b *Builder) finishAt(ctx context.Context, bld *Build, status Status, msg s } if status == StatusFailed { // felis_image_build_failures_total (spec §23) counts builds that reached a - // failed terminal state — a kaniko failure or a CRITICAL CVE surfaced by - // trivy's --exit-code 1, observed here as the Sync JobFailed/JobUnknown - // verdict. Cancellations (StatusCancelled) are deliberately not failures. + // failed terminal state — a failed step or a finding the scan policy + // blocks on (scan-gate exits 1), observed here as the Sync + // JobFailed/JobUnknown verdict. Cancellations (StatusCancelled) are deliberately not failures. // Incremented only after the failed status is persisted, so the counter // never runs ahead of the store. (Submit's job-creation path records its // failure outside finishAt and increments there.) diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 5f38832..8612645 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -27,6 +27,7 @@ type fakeStore struct { listOpts ListOpts getManyCalls int + scans map[string]Scan } func newFakeStore() *fakeStore { @@ -133,6 +134,22 @@ func (f *fakeStore) AddExternalImage(_ context.Context, img Image) error { return nil } +func (f *fakeStore) SaveScan(_ context.Context, sc Scan) error { + if f.scans == nil { + f.scans = map[string]Scan{} + } + f.scans[sc.BuildID] = sc + return nil +} + +func (f *fakeStore) GetScan(_ context.Context, id string) (*Scan, error) { + sc, ok := f.scans[id] + if !ok { + return nil, ErrNotFound + } + return &sc, nil +} + func (f *fakeStore) RemoveImage(_ context.Context, ref string) error { if f.removeErr != nil { return f.removeErr diff --git a/internal/build/jobspec.go b/internal/build/jobspec.go index 12c2a28..0cb9a66 100644 --- a/internal/build/jobspec.go +++ b/internal/build/jobspec.go @@ -27,16 +27,20 @@ const ( // Container names within the build Pod. Kaniko is the initContainer that builds // the image into a tarball — its log IS the "build log" an admin watches (spec -// §16); Trivy is the next initContainer, whose CRITICAL-CVE verdict gates both the -// push and admission and is surfaced via the build status, not the log stream; +// §16); Trivy then writes the full vulnerability report, SBOM converts it to +// CycloneDX, and ScanGate applies the scan policy, whose verdict gates both the +// push and admission and reaches felis-api through ScanGate's log (scan.go); // Push is the main container that publishes the scanned tarball. Exported so the // build-log streamer (internal/api.K8sBuildLogStreamer, spec §416 日志流复用 §8) -// follows the same container this Job defines — one source of truth for the name. +// and the outcome reader follow the same containers this Job defines — one source +// of truth for the names. const ( - ContainerGate = "egress-gate" - ContainerKaniko = "kaniko" - ContainerTrivy = "trivy" - ContainerPush = "push" + ContainerGate = "egress-gate" + ContainerKaniko = "kaniko" + ContainerTrivy = "trivy" + ContainerSBOM = "sbom" + ContainerScanGate = "scan-gate" + ContainerPush = "push" // ContainerFetch is the initContainer that pulls a submission's build context // from the felis-api internal face and extracts it into the shared emptyDir. // It exists only for an http(s) ContextRef (see BuildJob); a ref Kaniko can @@ -53,8 +57,20 @@ const ( imageVolume = "image" imageMountPath = "/image" imageTarPath = imageMountPath + "/image.tar" + + // reportsVolume carries Trivy's JSON report and the CycloneDX SBOM from the + // scan steps to scan-gate. Kaniko never mounts it, so the Dockerfile cannot + // write a verdict of its own. + reportsVolume = "reports" + reportsMountPath = "/reports" + trivyReportPath = reportsMountPath + "/trivy.json" + sbomPath = reportsMountPath + "/sbom.cdx.json" ) +// reportsSizeLimit bounds the report and SBOM of one image: a modpack's run to a +// few MiB each. +var reportsSizeLimit = resource.MustParse("512Mi") + // imageSizeLimit bounds the built image tarball. A modpack image is typically a // JRE, a server jar and a few hundred MiB of mods; 10 GiB leaves ample room while // still stopping a runaway build from filling the node's disk. @@ -70,13 +86,19 @@ var contextSizeLimit = resource.MustParse("4Gi") // kaniko unpacks the base image into its own root filesystem, which no emptyDir // bound covers; it is also the largest limit in the pod, so it becomes the // pod-level cap the kubelet holds context + unpacked rootfs + image tarball to. -// Trivy keeps its vulnerability and Java DBs (about 1.4 GiB live) in its layer. -// The others write nothing but logs. +// Trivy keeps its vulnerability and Java DBs in its layer: measured 2026-09-25 at +// 1374 MiB and 1459 MiB unpacked, and each DB's compressed download sits next to +// its unpacked copy until the unpack ends, so a 4Gi limit had the kubelet evict +// the pod mid-download. Its request is the DBs' live size, and the limit leaves +// room for their growth and the scan's own temporary files. The others write +// nothing but logs. var ( gateDisk = diskBounds{request: resource.MustParse("16Mi"), limit: resource.MustParse("64Mi")} fetchDisk = diskBounds{request: resource.MustParse("64Mi"), limit: resource.MustParse("256Mi")} kanikoDiskRq = resource.MustParse("1Gi") - trivyDisk = diskBounds{request: resource.MustParse("256Mi"), limit: resource.MustParse("4Gi")} + trivyDisk = diskBounds{request: resource.MustParse("3Gi"), limit: resource.MustParse("8Gi")} + sbomDisk = diskBounds{request: resource.MustParse("16Mi"), limit: resource.MustParse("256Mi")} + scanGateDisk = diskBounds{request: resource.MustParse("16Mi"), limit: resource.MustParse("64Mi")} pushDisk = diskBounds{request: resource.MustParse("16Mi"), limit: resource.MustParse("256Mi")} ) @@ -123,9 +145,14 @@ type JobParams struct { TrivyJavaDBRepository string KanikoImage string TrivyImage string - Deadline time.Duration - CPULimit string - MemLimit string + // ScanFailOn, ScanFailUnfixed and ScanAccept are the scan policy scan-gate + // applies (ScanPolicy). An empty ScanFailOn applies DefaultScanFailOn. + ScanFailOn []string + ScanFailUnfixed bool + ScanAccept []string + Deadline time.Duration + CPULimit string + MemLimit string // DiskLimit caps kaniko's ephemeral storage, and with it the pod's (see // kanikoDiskRq). Empty applies defaultDiskLimit. DiskLimit string @@ -165,12 +192,16 @@ func buildLabels(p JobParams) map[string]string { // - activeDeadlineSeconds + backoffLimit=0 + per-container CPU, memory and // ephemeral-storage limits so a runaway or poisoned build cannot exhaust the // node (spec §16); -// - the Trivy step runs with `--exit-code 1 --severity CRITICAL`, so a -// CRITICAL CVE fails the Pod and therefore the Job — the only retained -// automatic admission gate (spec §16). +// - Trivy writes its full report (no severity filter), trivy convert turns it +// into a CycloneDX SBOM, and scan-gate exits 1 when a finding matches the +// scan policy (HIGH and CRITICAL with a fixed release, by default), which +// fails the Pod and therefore the Job — the only retained automatic +// admission gate (spec §16). scan-gate's log carries the report and SBOM to +// felis-api, which keeps them on the build. // // Sequencing: kaniko builds with --no-push into a tarball, trivy scans that -// tarball, and only then does the push container publish it. So: +// tarball, scan-gate rules on the report, and only then does the push container +// publish it. So: // // - an image that fails the scan is never published — it used to be pushed to // the final tag first and scanned after, overwriting whatever that tag held; @@ -178,8 +209,8 @@ func buildLabels(p JobParams) map[string]string { // the untrusted Dockerfile and holds no credential at all, and the registry // refuses anonymous writes (internal/registrygate). // -// The Pod succeeds only if kaniko built, trivy found no CRITICAL CVE, and the push -// landed. +// The Pod succeeds only if kaniko built, the scan found nothing the policy blocks, +// and the push landed. func BuildJob(p JobParams) (*batchv1.Job, error) { limits, err := resourceLimits(p.CPULimit, p.MemLimit) if err != nil { @@ -262,6 +293,9 @@ func BuildJob(p JobParams) (*batchv1.Job, error) { }, }, SecurityContext: gateSec, + // A failed step's last log lines become its termination message, which + // Sync records as the build's error (Outcome). + TerminationMessagePolicy: corev1.TerminationMessageFallbackToLogsOnError, } initContainers := []corev1.Container{gate} imageMount := corev1.VolumeMount{Name: imageVolume, MountPath: imageMountPath} @@ -305,9 +339,10 @@ func BuildJob(p JobParams) (*batchv1.Job, error) { Key: naming.ServiceTokenSecretKey, }}, }}, - VolumeMounts: []corev1.VolumeMount{{Name: contextVolume, MountPath: contextMountPath}}, - Resources: withDisk(limits, fetchDisk), - SecurityContext: fetchSec, + VolumeMounts: []corev1.VolumeMount{{Name: contextVolume, MountPath: contextMountPath}}, + Resources: withDisk(limits, fetchDisk), + SecurityContext: fetchSec, + TerminationMessagePolicy: corev1.TerminationMessageFallbackToLogsOnError, } initContainers = append(initContainers, fetch) kanikoMounts = append(kanikoMounts, corev1.VolumeMount{Name: contextVolume, MountPath: contextMountPath, ReadOnly: true}) @@ -342,17 +377,21 @@ func BuildJob(p JobParams) (*batchv1.Job, error) { "--insecure-pull", "--skip-tls-verify-pull", }, - VolumeMounts: kanikoMounts, - Resources: withDisk(limits, diskBounds{request: kanikoRq, limit: kanikoDisk}), - SecurityContext: kanikoSec, + VolumeMounts: kanikoMounts, + Resources: withDisk(limits, diskBounds{request: kanikoRq, limit: kanikoDisk}), + SecurityContext: kanikoSec, + TerminationMessagePolicy: corev1.TerminationMessageFallbackToLogsOnError, } initContainers = append(initContainers, kaniko) + // Trivy reports every severity and every package (--list-all-pkgs is its + // default for JSON), so the kept report is complete and doubles as the SBOM's + // source; it exits 0 on findings, and scan-gate applies the policy. trivyArgs := []string{ "image", "--input", imageTarPath, - "--exit-code", "1", - "--severity", "CRITICAL", + "--format", "json", + "--output", trivyReportPath, "--no-progress", "--insecure", } @@ -367,15 +406,57 @@ func BuildJob(p JobParams) (*batchv1.Job, error) { if p.TrivyJavaDBRepository != "" { trivyArgs = append(trivyArgs, "--java-db-repository", p.TrivyJavaDBRepository) } + reportsMount := corev1.VolumeMount{Name: reportsVolume, MountPath: reportsMountPath} trivy := corev1.Container{ - Name: ContainerTrivy, - Image: p.TrivyImage, - Args: trivyArgs, - VolumeMounts: []corev1.VolumeMount{{Name: imageVolume, MountPath: imageMountPath, ReadOnly: true}}, - Resources: withDisk(limits, trivyDisk), - SecurityContext: sec, + Name: ContainerTrivy, + Image: p.TrivyImage, + Args: trivyArgs, + VolumeMounts: []corev1.VolumeMount{ + {Name: imageVolume, MountPath: imageMountPath, ReadOnly: true}, + reportsMount, + }, + Resources: withDisk(limits, trivyDisk), + SecurityContext: sec, + TerminationMessagePolicy: corev1.TerminationMessageFallbackToLogsOnError, } - initContainers = append(initContainers, trivy) + sbom := corev1.Container{ + Name: ContainerSBOM, + Image: p.TrivyImage, + Args: []string{"convert", "--format", "cyclonedx", "--output", sbomPath, trivyReportPath}, + VolumeMounts: []corev1.VolumeMount{reportsMount}, + Resources: withDisk(limits, sbomDisk), + SecurityContext: sec, + TerminationMessagePolicy: corev1.TerminationMessageFallbackToLogsOnError, + } + failOn := p.ScanFailOn + if len(failOn) == 0 { + failOn = DefaultScanFailOn + } + gateArgs := []string{"scan-gate", "--report=" + trivyReportPath, "--sbom=" + sbomPath, + "--fail-on=" + strings.Join(failOn, ",")} + if p.ScanFailUnfixed { + gateArgs = append(gateArgs, "--fail-unfixed") + } + if len(p.ScanAccept) > 0 { + gateArgs = append(gateArgs, "--accept="+strings.Join(p.ScanAccept, ",")) + } + // scan-gate writes its own one-line verdict to the termination log; a log + // tail would be the envelope's base64. + scanGate := corev1.Container{ + Name: ContainerScanGate, + Image: p.FelisImage, + Args: gateArgs, + VolumeMounts: []corev1.VolumeMount{{Name: reportsVolume, MountPath: reportsMountPath, ReadOnly: true}}, + Resources: withDisk(limits, scanGateDisk), + SecurityContext: gateSec, + } + initContainers = append(initContainers, trivy, sbom, scanGate) + podVolumes = append(podVolumes, corev1.Volume{ + Name: reportsVolume, + VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{ + SizeLimit: quantityPtr(reportsSizeLimit), + }}, + }) // The publish step: the only container that holds the registry credential, // read from a Secret the installer materializes in this namespace. It runs @@ -401,9 +482,10 @@ func BuildJob(p JobParams) (*batchv1.Job, error) { secretEnv("FELIS_REGISTRY_USERNAME", naming.RegistryPushUsernameKey), secretEnv("FELIS_REGISTRY_PASSWORD", naming.RegistryPushPasswordKey), }, - VolumeMounts: []corev1.VolumeMount{{Name: imageVolume, MountPath: imageMountPath, ReadOnly: true}}, - Resources: withDisk(limits, pushDisk), - SecurityContext: pushSec, + VolumeMounts: []corev1.VolumeMount{{Name: imageVolume, MountPath: imageMountPath, ReadOnly: true}}, + Resources: withDisk(limits, pushDisk), + SecurityContext: pushSec, + TerminationMessagePolicy: corev1.TerminationMessageFallbackToLogsOnError, } job := &batchv1.Job{ diff --git a/internal/build/jobspec_test.go b/internal/build/jobspec_test.go index 5694b9a..53f7e82 100644 --- a/internal/build/jobspec_test.go +++ b/internal/build/jobspec_test.go @@ -1,6 +1,7 @@ package build import ( + "fmt" "strings" "testing" "time" @@ -161,9 +162,10 @@ func TestBuildJobRequestsAreASchedulableFloor(t *testing.T) { } // kaniko builds the request's exact target into a tarball and never pushes; -// trivy gates on that tarball with --exit-code 1 --severity CRITICAL; only then -// does the push container publish it. An image that fails the scan is therefore -// never in the registry, and the credential is never where the Dockerfile runs. +// trivy reports on that tarball, trivy convert writes the SBOM, scan-gate rules +// on the report; only then does the push container publish it. An image that +// fails the scan is therefore never in the registry, and the credential is never +// where the Dockerfile runs. func TestBuildJobScansBeforePush(t *testing.T) { p := sampleJobParams() job, err := BuildJob(p) @@ -171,10 +173,10 @@ func TestBuildJobScansBeforePush(t *testing.T) { t.Fatalf("BuildJob: %v", err) } inits := job.Spec.Template.Spec.InitContainers - if len(inits) != 3 || inits[0].Name != ContainerGate || inits[1].Name != ContainerKaniko || inits[2].Name != ContainerTrivy { - t.Fatalf("initContainers = %v, want [egress-gate kaniko trivy]", initNames(inits)) + if got := strings.Join(initNames(inits), " "); got != "egress-gate kaniko trivy sbom scan-gate" { + t.Fatalf("initContainers = %s, want egress-gate kaniko trivy sbom scan-gate", got) } - kaniko, trivy := inits[1], inits[2] + kaniko, trivy, sbom, gate := inits[1], inits[2], inits[3], inits[4] for _, want := range []string{"--destination=" + p.ImageRef, "--no-push", "--tar-path=" + imageTarPath} { if !hasArg(kaniko.Args, want) { t.Errorf("kaniko args = %v, want %s", kaniko.Args, want) @@ -194,19 +196,52 @@ func TestBuildJobScansBeforePush(t *testing.T) { } } - // The scan gate: a CRITICAL CVE must fail the Pod (and thus the Job) before - // the push container ever starts. + // The scan: trivy writes the whole report, unfiltered, for scan-gate to rule + // on and for the build to keep. if !argPairPresent(trivy.Args, "--input", imageTarPath) { t.Errorf("trivy must scan the built tarball, args=%v", trivy.Args) } if hasArg(trivy.Args, p.ImageRef) { t.Errorf("trivy must not scan the registry ref (nothing is pushed yet), args=%v", trivy.Args) } - if !argPairPresent(trivy.Args, "--exit-code", "1") { - t.Errorf("trivy must run with --exit-code 1, args=%v", trivy.Args) + if !argPairPresent(trivy.Args, "--format", "json") || !argPairPresent(trivy.Args, "--output", "/reports/trivy.json") { + t.Errorf("trivy must write its JSON report to /reports/trivy.json, args=%v", trivy.Args) } - if !argPairPresent(trivy.Args, "--severity", "CRITICAL") { - t.Errorf("trivy must gate on --severity CRITICAL, args=%v", trivy.Args) + for _, filter := range []string{"--severity", "--exit-code", "--ignore-unfixed"} { + if hasArg(trivy.Args, filter) { + t.Errorf("trivy args = %v carry %s: the kept report must be complete, and scan-gate applies the policy", trivy.Args, filter) + } + } + if got := strings.Join(sbom.Args, " "); sbom.Image != p.TrivyImage || + got != "convert --format cyclonedx --output /reports/sbom.cdx.json /reports/trivy.json" { + t.Errorf("sbom = %s %q, want the trivy image converting the report to CycloneDX", sbom.Image, got) + } + // The gate: a finding the policy blocks fails the Pod (and thus the Job) + // before the push container ever starts. + if got := strings.Join(gate.Args, " "); gate.Image != p.FelisImage || + got != "scan-gate --report=/reports/trivy.json --sbom=/reports/sbom.cdx.json --fail-on=CRITICAL" { + t.Errorf("scan-gate = %s %q, want the platform image blocking on CRITICAL by default", gate.Image, got) + } + mounts := func(c corev1.Container) string { + var out []string + for _, m := range c.VolumeMounts { + out = append(out, fmt.Sprintf("%s:%s:%t", m.Name, m.MountPath, m.ReadOnly)) + } + return strings.Join(out, " ") + } + for c, want := range map[*corev1.Container]string{ + &kaniko: "image:/image:false", + &trivy: "image:/image:true reports:/reports:false", + &sbom: "reports:/reports:false", + &gate: "reports:/reports:true", + } { + if got := mounts(*c); got != want { + t.Errorf("%s mounts %q, want %q", c.Name, got, want) + } + } + if gate.SecurityContext == nil || gate.SecurityContext.ReadOnlyRootFilesystem == nil || !*gate.SecurityContext.ReadOnlyRootFilesystem || + gate.SecurityContext.RunAsNonRoot == nil || !*gate.SecurityContext.RunAsNonRoot { + t.Errorf("scan-gate must run non-root on a read-only root, got %+v", gate.SecurityContext) } // No DB repositories configured: Trivy keeps its own defaults. if hasArg(trivy.Args, "--db-repository") || hasArg(trivy.Args, "--java-db-repository") { @@ -254,6 +289,15 @@ func TestBuildJobScansBeforePush(t *testing.T) { if imgVol == nil || imgVol.EmptyDir == nil || imgVol.EmptyDir.SizeLimit == nil { t.Fatalf("image volume must be a size-limited emptyDir, got %#v", imgVol) } + var reports *corev1.Volume + for i := range job.Spec.Template.Spec.Volumes { + if job.Spec.Template.Spec.Volumes[i].Name == "reports" { + reports = &job.Spec.Template.Spec.Volumes[i] + } + } + if reports == nil || reports.EmptyDir == nil || reports.EmptyDir.SizeLimit == nil || reports.EmptyDir.SizeLimit.String() != "512Mi" { + t.Fatalf("reports volume must be a 512Mi emptyDir, got %#v", reports) + } for _, c := range []corev1.Container{trivy, push} { ro := false for _, m := range c.VolumeMounts { @@ -267,6 +311,45 @@ func TestBuildJobScansBeforePush(t *testing.T) { } } +// The scan policy reaches scan-gate verbatim. +func TestBuildJobScanPolicyReachesScanGate(t *testing.T) { + p := sampleJobParams() + p.ScanFailOn = []string{"CRITICAL", "HIGH", "MEDIUM"} + p.ScanFailUnfixed = true + p.ScanAccept = []string{"CVE-2021-35515", "aws-access-key-id"} + job, err := BuildJob(p) + if err != nil { + t.Fatalf("BuildJob: %v", err) + } + gate := job.Spec.Template.Spec.InitContainers[4] + if got := strings.Join(gate.Args, " "); got != "scan-gate --report=/reports/trivy.json --sbom=/reports/sbom.cdx.json --fail-on=CRITICAL,HIGH,MEDIUM --fail-unfixed --accept=CVE-2021-35515,aws-access-key-id" { + t.Errorf("scan-gate args = %q", got) + } +} + +// A failed step's last log lines become its termination message, which Sync +// records as the build's error. scan-gate writes its own verdict there instead: +// its log tail is the envelope's base64. +func TestBuildJobStepsLeaveTheirLastLogLines(t *testing.T) { + p := sampleJobParams() + p.ContextRef = "http://felis-api-internal.felis.svc:8081/internal/v1/submissions/sub-1/context" + job, err := BuildJob(p) + if err != nil { + t.Fatalf("BuildJob: %v", err) + } + all := append([]corev1.Container{}, job.Spec.Template.Spec.InitContainers...) + all = append(all, job.Spec.Template.Spec.Containers...) + var got []string + for _, c := range all { + got = append(got, fmt.Sprintf("%s=%s", c.Name, c.TerminationMessagePolicy)) + } + want := "egress-gate=FallbackToLogsOnError context-fetch=FallbackToLogsOnError kaniko=FallbackToLogsOnError " + + "trivy=FallbackToLogsOnError sbom=FallbackToLogsOnError scan-gate= push=FallbackToLogsOnError" + if strings.Join(got, " ") != want { + t.Errorf("termination message policies = %s\nwant %s", strings.Join(got, " "), want) + } +} + func TestBuildJobNeedsFelisImage(t *testing.T) { p := sampleJobParams() p.FelisImage = "" @@ -423,9 +506,8 @@ func TestBuildJobFetchesHTTPContext(t *testing.T) { t.Fatalf("BuildJob: %v", err) } inits := job.Spec.Template.Spec.InitContainers - if len(inits) != 4 || inits[0].Name != ContainerGate || inits[1].Name != ContainerFetch || - inits[2].Name != ContainerKaniko || inits[3].Name != ContainerTrivy { - t.Fatalf("initContainers = %v, want [%s %s %s %s]", initNames(inits), ContainerGate, ContainerFetch, ContainerKaniko, ContainerTrivy) + if got := strings.Join(initNames(inits), " "); got != "egress-gate context-fetch kaniko trivy sbom scan-gate" { + t.Fatalf("initContainers = %s, want egress-gate context-fetch kaniko trivy sbom scan-gate", got) } fetch, kaniko := inits[1], inits[2] if fetch.Image != p.FelisImage { @@ -500,8 +582,8 @@ func TestBuildJobNativeContextNeedsNoFetch(t *testing.T) { if err != nil { t.Fatalf("BuildJob: %v", err) } - if inits := job.Spec.Template.Spec.InitContainers; len(inits) != 3 || inits[1].Name != ContainerKaniko { - t.Errorf("a native ref must render just the gate, kaniko and trivy, got %v", initNames(inits)) + if got := strings.Join(initNames(job.Spec.Template.Spec.InitContainers), " "); got != "egress-gate kaniko trivy sbom scan-gate" { + t.Errorf("a native ref must render no fetch step, got %s", got) } for _, v := range job.Spec.Template.Spec.Volumes { if v.Name == contextVolume { @@ -611,6 +693,15 @@ func TestBuildJobBoundsEphemeralStorage(t *testing.T) { if c.Name == ContainerKaniko && lim.String() != defaultDiskLimit { t.Errorf("kaniko ephemeral-storage limit = %s, want the default %s", lim.String(), defaultDiskLimit) } + // Trivy holds its two DBs (2.8 GiB unpacked in 2026) plus a download and + // the scan's temporary files; the SBOM step writes one file; scan-gate's + // own log, the envelope of up to 9 MiB, counts against its cap. + if want, ok := map[string]string{"trivy": "8Gi", "sbom": "256Mi", "scan-gate": "64Mi"}[c.Name]; ok && lim.String() != want { + t.Errorf("%s ephemeral-storage limit = %s, want %s", c.Name, lim.String(), want) + } + if c.Name == ContainerTrivy && req.String() != "3Gi" { + t.Errorf("trivy ephemeral-storage request = %s, want 3Gi, the DBs' live size", req.String()) + } } p.DiskLimit = "512Mi" if job, err = BuildJob(p); err != nil { diff --git a/internal/build/k8sjobs.go b/internal/build/k8sjobs.go index f88f792..bfeaef9 100644 --- a/internal/build/k8sjobs.go +++ b/internal/build/k8sjobs.go @@ -66,8 +66,9 @@ func (k *K8sJobs) JobPhase(ctx context.Context, jobName string) (JobPhase, error case batchv1.JobComplete: return JobSucceeded, nil case batchv1.JobFailed: - // Covers a CRITICAL CVE (trivy --exit-code 1), a kaniko or push - // failure, and DeadlineExceeded — all are a rejected build. + // Covers a scan the policy blocked (scan-gate exits 1), a failed + // build, scan or push step, and DeadlineExceeded — all are a + // rejected build. return JobFailed, nil } } diff --git a/internal/build/k8soutcomes.go b/internal/build/k8soutcomes.go new file mode 100644 index 0000000..b1fa046 --- /dev/null +++ b/internal/build/k8soutcomes.go @@ -0,0 +1,104 @@ +package build + +import ( + "context" + "errors" + "fmt" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +// maxScanLogBytes bounds the scan-gate log Outcome reads: the envelope (at most +// MaxEnvelopeBytes of gzip, a third larger in base64) plus the verdict lines. +const maxScanLogBytes = MaxEnvelopeBytes/3*4 + 1<<20 + +// K8sOutcomes is the production JobOutcomes. It reads the build pod's container +// statuses and scan-gate's log through the typed client, uncached: felis-api +// holds pods list and pods/log get in the build namespace for the log stream +// already, and a controller-runtime read would start a cluster-wide pod +// informer. +type K8sOutcomes struct { + cs kubernetes.Interface + namespace string +} + +// NewK8sOutcomes reads build pods in cfg's namespace. +func NewK8sOutcomes(cs kubernetes.Interface, cfg Config) *K8sOutcomes { + return &K8sOutcomes{cs: cs, namespace: cfg.withDefaults().Namespace} +} + +// Outcome reports what the finished build pod of buildID left: the Job's +// deadline verdict, the first container that exited non-zero, and scan-gate's +// envelope. A pod already gone yields what the Job still says. Only API reads +// that may succeed on a retry return an error; an unreadable scan log becomes +// Outcome.ScanErr, so a build never stays unfinished over it. +func (k *K8sOutcomes) Outcome(ctx context.Context, buildID string) (Outcome, error) { + var out Outcome + job, err := k.cs.BatchV1().Jobs(k.namespace).Get(ctx, BuildJobName(buildID), metav1.GetOptions{}) + switch { + case apierrors.IsNotFound(err): + case err != nil: + return out, err + default: + out.DeadlineExceeded = jobDeadlineExceeded(job) + } + pods, err := k.cs.CoreV1().Pods(k.namespace).List(ctx, metav1.ListOptions{LabelSelector: LabelBuildID + "=" + buildID}) + if err != nil { + return out, err + } + if len(pods.Items) == 0 { + return out, nil + } + // backoffLimit 0 and restartPolicy Never: the Job makes at most one pod. + pod := &pods.Items[0] + gateRan := false + for _, st := range append(append([]corev1.ContainerStatus{}, pod.Status.InitContainerStatuses...), pod.Status.ContainerStatuses...) { + t := st.State.Terminated + if t == nil { + continue + } + if st.Name == ContainerScanGate { + gateRan = true + } + if t.ExitCode != 0 && out.FailedStep == "" { + out.FailedStep, out.ExitCode, out.Message = st.Name, t.ExitCode, t.Message + } + } + if !gateRan { + return out, nil + } + limit := int64(maxScanLogBytes) + stream, err := k.cs.CoreV1().Pods(k.namespace).GetLogs(pod.Name, &corev1.PodLogOptions{ + Container: ContainerScanGate, LimitBytes: &limit, + }).Stream(ctx) + if err != nil { + out.ScanErr = fmt.Errorf("open the scan-gate log: %w", err) + return out, nil + } + defer stream.Close() + env, err := ReadScanEnvelope(stream) + switch { + case errors.Is(err, ErrNoScanEnvelope): + out.ScanErr = errors.New("the scan-gate log holds no scan envelope") + case err != nil: + out.ScanErr = err + default: + out.Scan = env + } + return out, nil +} + +// jobDeadlineExceeded reports a Job the controller failed for running past +// activeDeadlineSeconds; it deletes the pod, so the Job is all that says so. +func jobDeadlineExceeded(job *batchv1.Job) bool { + for _, c := range job.Status.Conditions { + if c.Type == batchv1.JobFailed && c.Status == corev1.ConditionTrue && c.Reason == batchv1.JobReasonDeadlineExceeded { + return true + } + } + return false +} diff --git a/internal/build/outcome_test.go b/internal/build/outcome_test.go new file mode 100644 index 0000000..962ab60 --- /dev/null +++ b/internal/build/outcome_test.go @@ -0,0 +1,293 @@ +package build + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "reflect" + "strings" + "testing" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" +) + +type fakeOutcomes struct { + out Outcome + err error + calls []string +} + +func (f *fakeOutcomes) Outcome(_ context.Context, id string) (Outcome, error) { + f.calls = append(f.calls, id) + return f.out, f.err +} + +// runningBuild wires a Builder whose bld-1 is building under a Job in phase. +func runningBuild(t *testing.T, phase JobPhase, out *fakeOutcomes) (*Builder, *fakeStore) { + t.Helper() + b, st, jb := newBuilder() + if out != nil { + b.Outcomes = out + } + st.builds["bld-1"] = &Build{ID: "bld-1", ImageRef: "registry.felis.svc:5000/mc/pack:1", Status: StatusBuilding, + JobName: "build-bld-1", RequestedBy: "admin@example.net", CreatedAt: testNow} + jb.phase = phase + return b, st +} + +func blockedEnvelope(t *testing.T) *ScanEnvelope { + t.Helper() + s, err := Summarize([]byte(trivyFixture), ScanPolicy{FailOn: []string{"CRITICAL", "HIGH"}}) + if err != nil { + t.Fatal(err) + } + return &ScanEnvelope{Summary: s, Report: json.RawMessage(trivyFixture), SBOM: json.RawMessage(`{"bomFormat":"CycloneDX"}`)} +} + +func gunzipString(t *testing.T, b []byte) string { + t.Helper() + zr, err := gzip.NewReader(bytes.NewReader(b)) + if err != nil { + t.Fatal(err) + } + raw, err := io.ReadAll(zr) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +// A build the scan blocked records the blocking findings as its error and keeps +// the report and SBOM. +func TestSyncRecordsTheScanThatBlocked(t *testing.T) { + out := &fakeOutcomes{out: Outcome{FailedStep: ContainerScanGate, ExitCode: 1, Message: "the scan blocked…", Scan: blockedEnvelope(t)}} + b, st := runningBuild(t, JobFailed, out) + bld, err := b.Sync(context.Background(), "bld-1") + if err != nil { + t.Fatalf("Sync: %v", err) + } + if bld.Status != StatusFailed || bld.Error != "the scan blocked the image: 2 CRITICAL, 1 HIGH (CVE-2024-0001, aws-access-key-id, CVE-2024-0004)" { + t.Errorf("build = %s %q", bld.Status, bld.Error) + } + if st.builds["bld-1"].Error != bld.Error { + t.Errorf("stored error = %q", st.builds["bld-1"].Error) + } + sc, ok := st.scans["bld-1"] + if !ok { + t.Fatal("no scan stored") + } + if !sc.Summary.Blocked || !sc.ScannedAt.Equal(testNow) || gunzipString(t, sc.SBOMGz) != `{"bomFormat":"CycloneDX"}` || + !strings.Contains(gunzipString(t, sc.ReportGz), `"CVE-2024-0001"`) { + t.Errorf("scan = blocked %t at %s, sbom %q", sc.Summary.Blocked, sc.ScannedAt, gunzipString(t, sc.SBOMGz)) + } + if len(st.admitted) != 0 { + t.Errorf("a blocked image was admitted: %v", st.admitted) + } + if !reflect.DeepEqual(out.calls, []string{"bld-1"}) { + t.Errorf("outcome reads = %v", out.calls) + } +} + +// An admitted image keeps its scan too. +func TestSyncKeepsTheScanOfAnAdmittedImage(t *testing.T) { + env := blockedEnvelope(t) + env.Summary.Blocked = false + b, st := runningBuild(t, JobSucceeded, &fakeOutcomes{out: Outcome{Scan: env}}) + bld, err := b.Sync(context.Background(), "bld-1") + if err != nil { + t.Fatalf("Sync: %v", err) + } + if bld.Status != StatusSucceeded || len(st.admitted) != 1 { + t.Fatalf("build %s, admitted %v", bld.Status, st.admitted) + } + if sc, ok := st.scans["bld-1"]; !ok || sc.Summary.Blocked || sc.Summary.Packages != 5 { + t.Errorf("scan = %+v, %t", sc.Summary, ok) + } +} + +func TestSyncNamesWhatEndedAFailedBuild(t *testing.T) { + long := strings.Repeat("x", 700) + for _, tc := range []struct { + name string + out Outcome + want string + }{ + {"kaniko", Outcome{FailedStep: ContainerKaniko, ExitCode: 1, + Message: "INFO[0003] RUN ./setup.sh\nstep 1\n\nstep 2\r\nerror building image: error building stage: failed to execute command: exit status 2\n"}, + "the image build failed (exit 1): step 1 | step 2 | error building image: error building stage: failed to execute command: exit status 2"}, + {"trivy", Outcome{FailedStep: ContainerTrivy, ExitCode: 1, Message: "FATAL\tFatal error\tinit error: DB error: failed to download vulnerability DB"}, + "the vulnerability scan failed (exit 1): FATAL Fatal error init error: DB error: failed to download vulnerability DB"}, + {"push", Outcome{FailedStep: ContainerPush, ExitCode: 1}, "the registry push failed (exit 1)"}, + {"unknown step", Outcome{FailedStep: "sidecar", ExitCode: 137}, "the sidecar step failed (exit 137)"}, + {"unreadable scan", Outcome{FailedStep: ContainerScanGate, ExitCode: 2, Message: "the scan report is unreadable: EOF", + ScanErr: errors.New("the scan-gate log holds no scan envelope")}, + "the scan gate failed (exit 2): the scan report is unreadable: EOF; the scan report could not be read back: the scan-gate log holds no scan envelope"}, + {"deadline", Outcome{DeadlineExceeded: true}, "the build ran past its 30m0s deadline"}, + {"nothing known", Outcome{}, "the build job failed"}, + {"long message", Outcome{FailedStep: ContainerKaniko, ExitCode: 1, Message: "start " + long}, + "the image build failed (exit 1): …" + strings.Repeat("x", 600)}, + } { + b, _ := runningBuild(t, JobFailed, &fakeOutcomes{out: tc.out}) + bld, err := b.Sync(context.Background(), "bld-1") + if err != nil { + t.Fatalf("%s: Sync: %v", tc.name, err) + } + if bld.Error != tc.want { + t.Errorf("%s: error = %q\nwant %q", tc.name, bld.Error, tc.want) + } + } +} + +// A vanished Job has no pod to read; a failed read leaves the build to the next +// tick; without an outcome reader Sync records a plain failure. +func TestSyncOutcomeEdges(t *testing.T) { + out := &fakeOutcomes{} + b, _ := runningBuild(t, JobUnknown, out) + bld, err := b.Sync(context.Background(), "bld-1") + if err != nil || bld.Error != "the build job is gone: it was deleted before it finished" || len(out.calls) != 0 { + t.Errorf("gone job: %q, %v, reads %v", bld.Error, err, out.calls) + } + + b, st := runningBuild(t, JobFailed, &fakeOutcomes{err: errors.New("apiserver unavailable")}) + if _, err := b.Sync(context.Background(), "bld-1"); err == nil || err.Error() != "apiserver unavailable" { + t.Errorf("read failure: err = %v", err) + } + if st.builds["bld-1"].Status != StatusBuilding { + t.Errorf("a failed outcome read finished the build: %s", st.builds["bld-1"].Status) + } + + b, _ = runningBuild(t, JobFailed, nil) + if bld, err := b.Sync(context.Background(), "bld-1"); err != nil || bld.Error != "the build job failed" { + t.Errorf("no reader: %q, %v", bld.Error, err) + } + + out = &fakeOutcomes{} + b, _ = runningBuild(t, JobRunning, out) + if bld, err := b.Sync(context.Background(), "bld-1"); err != nil || bld.Status != StatusBuilding || len(out.calls) != 0 { + t.Errorf("running: %s, %v, reads %v", bld.Status, err, out.calls) + } +} + +func TestSubmitCarriesTheScanPolicy(t *testing.T) { + b, _, jb := newBuilder() + if _, err := b.Submit(context.Background(), goodRequest()); err != nil { + t.Fatal(err) + } + b.Config.ScanFailOn = []string{"CRITICAL", "HIGH"} + b.Config.ScanFailUnfixed = true + b.Config.ScanAccept = []string{"CVE-2021-35515"} + req := goodRequest() + req.ImageRef = "registry.felis.svc:5000/mc-paper:2.0" + if _, err := b.Submit(context.Background(), req); err != nil { + t.Fatal(err) + } + if len(jb.created) != 2 { + t.Fatalf("jobs = %d", len(jb.created)) + } + if got := jb.created[0]; !reflect.DeepEqual(got.ScanFailOn, []string{"CRITICAL"}) || got.ScanFailUnfixed || got.ScanAccept != nil { + t.Errorf("default policy = %v unfixed=%t accept=%v", got.ScanFailOn, got.ScanFailUnfixed, got.ScanAccept) + } + if got := jb.created[1]; !reflect.DeepEqual(got.ScanFailOn, []string{"CRITICAL", "HIGH"}) || !got.ScanFailUnfixed || + !reflect.DeepEqual(got.ScanAccept, []string{"CVE-2021-35515"}) { + t.Errorf("configured policy = %v unfixed=%t accept=%v", got.ScanFailOn, got.ScanFailUnfixed, got.ScanAccept) + } +} + +func TestBuilderScanReadsTheStore(t *testing.T) { + b, st, _ := newBuilder() + if _, err := b.Scan(context.Background(), "bld-9"); !errors.Is(err, ErrNotFound) { + t.Errorf("missing scan: err = %v", err) + } + st.scans = map[string]Scan{"bld-9": {BuildID: "bld-9", Summary: ScanSummary{Packages: 42}}} + if sc, err := b.Scan(context.Background(), "bld-9"); err != nil || sc.Summary.Packages != 42 { + t.Errorf("scan = %+v, %v", sc, err) + } +} + +// K8sOutcomes reads the first failed container in pod order, the Job's deadline +// verdict, and looks for an envelope only when scan-gate ran. The fake clientset +// answers every log read with "fake logs", which holds no envelope. +func TestK8sOutcomesReadsThePod(t *testing.T) { + ctx := context.Background() + term := func(name string, code int32, msg string) corev1.ContainerStatus { + return corev1.ContainerStatus{Name: name, State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{ExitCode: code, Message: msg}}} + } + pod := func(inits ...corev1.ContainerStatus) *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "build-bld-1-abcde", Namespace: "felis-build", Labels: map[string]string{LabelBuildID: "bld-1"}}, + Status: corev1.PodStatus{InitContainerStatuses: inits}, + } + } + job := &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: "build-bld-1", Namespace: "felis-build"}} + + // Another build's pod, failed at a different step, sorts ahead of bld-1's. + other := pod(term("egress-gate", 7, "denied")) + other.Name, other.Labels = "build-bld-0-zzzzz", map[string]string{LabelBuildID: "bld-0"} + k := NewK8sOutcomes(fake.NewSimpleClientset(job, other, pod(term("egress-gate", 0, ""), term("kaniko", 1, "boom"), + corev1.ContainerStatus{Name: "trivy", State: corev1.ContainerState{Waiting: &corev1.ContainerStateWaiting{}}})), Config{}) + out, err := k.Outcome(ctx, "bld-1") + if err != nil { + t.Fatal(err) + } + if out.FailedStep != "kaniko" || out.ExitCode != 1 || out.Message != "boom" || out.Scan != nil || out.ScanErr != nil || out.DeadlineExceeded { + t.Errorf("kaniko failure = %+v", out) + } + + cs := fake.NewSimpleClientset(job, pod(term("egress-gate", 0, ""), term("kaniko", 0, ""), + term("trivy", 0, ""), term("sbom", 0, ""), term("scan-gate", 1, "the scan blocked the image: 1 HIGH (CVE-1)"))) + k = NewK8sOutcomes(cs, Config{}) + out, err = k.Outcome(ctx, "bld-1") + if err != nil { + t.Fatal(err) + } + // The fake API serves "fake logs" for every log read, which holds no envelope. + if out.FailedStep != "scan-gate" || out.ScanErr == nil || out.ScanErr.Error() != "the scan-gate log holds no scan envelope" { + t.Errorf("scan-gate failure = %+v", out) + } + var logReads []string + for _, a := range cs.Actions() { + if a.GetSubresource() == "log" { + o := a.(k8stesting.GenericAction).GetValue().(*corev1.PodLogOptions) + logReads = append(logReads, fmt.Sprintf("%s/%s limit %d", a.GetNamespace(), o.Container, *o.LimitBytes)) + } + } + if !reflect.DeepEqual(logReads, []string{"felis-build/scan-gate limit 9437184"}) { + t.Errorf("log reads = %q", logReads) + } + + pushed := pod(term("egress-gate", 0, ""), term("kaniko", 0, ""), term("trivy", 0, ""), term("sbom", 0, ""), term("scan-gate", 0, "the scan passed")) + pushed.Status.ContainerStatuses = []corev1.ContainerStatus{term("push", 1, "UNAUTHORIZED: authentication required")} + k = NewK8sOutcomes(fake.NewSimpleClientset(job, pushed), Config{}) + out, err = k.Outcome(ctx, "bld-1") + if err != nil || out.FailedStep != "push" || out.ExitCode != 1 || out.Message != "UNAUTHORIZED: authentication required" { + t.Errorf("push failure = %+v, %v", out, err) + } + + deadline := job.DeepCopy() + deadline.Status.Conditions = []batchv1.JobCondition{{Type: batchv1.JobFailed, Status: corev1.ConditionTrue, Reason: "DeadlineExceeded"}} + k = NewK8sOutcomes(fake.NewSimpleClientset(deadline), Config{}) + out, err = k.Outcome(ctx, "bld-1") + if err != nil || !out.DeadlineExceeded || out.FailedStep != "" { + t.Errorf("deadline = %+v, %v", out, err) + } + + backoff := job.DeepCopy() + backoff.Status.Conditions = []batchv1.JobCondition{{Type: batchv1.JobFailed, Status: corev1.ConditionTrue, Reason: "BackoffLimitExceeded"}} + k = NewK8sOutcomes(fake.NewSimpleClientset(backoff), Config{}) + if out, err = k.Outcome(ctx, "bld-1"); err != nil || out.DeadlineExceeded { + t.Errorf("backoff = %+v, %v", out, err) + } + + k = NewK8sOutcomes(fake.NewSimpleClientset(pod(term("egress-gate", 0, ""))), Config{Namespace: "elsewhere"}) + if out, err = k.Outcome(ctx, "bld-1"); err != nil || out.FailedStep != "" { + t.Errorf("other namespace = %+v, %v", out, err) + } +} diff --git a/internal/build/pgstore.go b/internal/build/pgstore.go index 199996a..d5d8f28 100644 --- a/internal/build/pgstore.go +++ b/internal/build/pgstore.go @@ -3,11 +3,13 @@ package build import ( "context" "database/sql" + "encoding/json" "time" ) // PGStore is the production Store backed by Postgres (spec §6, §16). It writes -// the two tables of the build subsystem — image_builds and image_whitelist — +// the tables of the build subsystem — image_builds, image_build_scans and +// image_whitelist — // and is the *only* component that holds database credentials: the build Pod // never does (the weak-SA red line). The SQL here is exercised by integration // tests against a live database, not the hermetic build_test.go suite. Every @@ -247,3 +249,40 @@ func (s *PGStore) RemoveImage(ctx context.Context, imageRef string) error { } return nil } + +// SaveScan upserts the scan record of a build. A re-read of the same pod writes +// the same record, so Sync may retry freely. +func (s *PGStore) SaveScan(ctx context.Context, sc Scan) error { + summary, err := json.Marshal(sc.Summary) + if err != nil { + return err + } + const q = `INSERT INTO image_build_scans + (build_id, blocked, summary, report_gz, sbom_gz, scanned_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (build_id) DO UPDATE + SET blocked = EXCLUDED.blocked, summary = EXCLUDED.summary, + report_gz = EXCLUDED.report_gz, sbom_gz = EXCLUDED.sbom_gz, + scanned_at = EXCLUDED.scanned_at` + _, err = s.db.ExecContext(ctx, q, sc.BuildID, sc.Summary.Blocked, summary, sc.ReportGz, sc.SBOMGz, sc.ScannedAt) + return err +} + +func (s *PGStore) GetScan(ctx context.Context, buildID string) (*Scan, error) { + const q = `SELECT build_id, summary, report_gz, sbom_gz, scanned_at + FROM image_build_scans WHERE build_id = $1` + var ( + sc Scan + summary []byte + ) + switch err := s.db.QueryRowContext(ctx, q, buildID).Scan(&sc.BuildID, &summary, &sc.ReportGz, &sc.SBOMGz, &sc.ScannedAt); { + case err == sql.ErrNoRows: + return nil, ErrNotFound + case err != nil: + return nil, err + } + if err := json.Unmarshal(summary, &sc.Summary); err != nil { + return nil, err + } + return &sc, nil +} diff --git a/internal/build/scan.go b/internal/build/scan.go new file mode 100644 index 0000000..d82f6ff --- /dev/null +++ b/internal/build/scan.go @@ -0,0 +1,452 @@ +package build + +import ( + "bufio" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "regexp" + "slices" + "sort" + "strings" + "time" +) + +// The scan gate. Trivy scans the built tarball with no severity filter and writes +// its full JSON report (every package, every finding); `trivy convert` turns that +// report into a CycloneDX SBOM; then `felis scan-gate` applies the ScanPolicy, +// prints a readable verdict, and appends the verdict, the report and the SBOM to +// its own log inside a framed, checksummed envelope. felis-api already reads +// build pod logs, so when the Job finishes Sync reads that envelope back and keeps +// all three on the build: a blocked build says which findings blocked it, and an +// admitted image has its report and SBOM on record. + +// Severities are the levels Trivy assigns, most severe first. +var Severities = []string{"CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN"} + +// DefaultScanFailOn is the severities that block an image when felis.toml names +// none. HIGH is left to the operator: the platform's own felis/paper image +// carries fixable HIGH findings inside upstream paper.jar (its bundled +// commons-compress and plexus-utils), so blocking on HIGH out of the box fails +// every build FROM it until those ids are listed in scan_accept. +var DefaultScanFailOn = []string{"CRITICAL"} + +// ScanPolicy decides which findings block an image. +type ScanPolicy struct { + // FailOn lists the severities that block, most severe first. + FailOn []string `json:"fail_on"` + // FailUnfixed makes a vulnerability with no fixed release block too. Off by + // default: a submitter cannot upgrade past it, and the report still lists it. + // A leaked secret always counts as fixable. + FailUnfixed bool `json:"fail_unfixed"` + // Accept lists the vulnerability ids and secret rule ids an administrator + // accepted as known risks: a finding under one of them is still counted and + // listed, marked accepted, and never blocks. + Accept []string `json:"accept,omitempty"` +} + +// scanIDPattern is the shape of a finding id: CVE-2024-3094, GHSA-…, DLA-…, +// aws-access-key-id. It keeps commas and spaces out of the scan-gate flag. +var scanIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`) + +// ParseScanAccept reads a comma-separated list of accepted finding ids, in the +// order given, without repeats. An empty list is valid. +func ParseScanAccept(s string) ([]string, error) { + var out []string + for _, id := range strings.Split(s, ",") { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if !scanIDPattern.MatchString(id) { + return nil, fmt.Errorf("%q is not a vulnerability id or secret rule id", id) + } + if !slices.Contains(out, id) { + out = append(out, id) + } + } + return out, nil +} + +// ParseSeverities reads a comma-separated severity list ("HIGH,CRITICAL"), +// case-insensitively, into Severities order without repeats. +func ParseSeverities(s string) ([]string, error) { + seen := map[string]bool{} + for _, part := range strings.Split(s, ",") { + part = strings.ToUpper(strings.TrimSpace(part)) + if part == "" { + continue + } + if !slices.Contains(Severities, part) { + return nil, fmt.Errorf("unknown severity %q (use %s)", part, strings.Join(Severities, ", ")) + } + seen[part] = true + } + var out []string + for _, sev := range Severities { + if seen[sev] { + out = append(out, sev) + } + } + if len(out) == 0 { + return nil, errors.New("no severity named") + } + return out, nil +} + +// ScanFinding is one vulnerability or leaked secret in a scan report. +type ScanFinding struct { + ID string `json:"id"` + Kind string `json:"kind"` // "vulnerability" or "secret" + Severity string `json:"severity"` + Package string `json:"package,omitempty"` + Installed string `json:"installed,omitempty"` + Fixed string `json:"fixed,omitempty"` + Target string `json:"target"` + Title string `json:"title,omitempty"` + Blocking bool `json:"blocking"` + // Accepted marks a finding whose id the policy accepts; it never blocks. + Accepted bool `json:"accepted,omitempty"` +} + +// Finding kinds. +const ( + FindingVulnerability = "vulnerability" + FindingSecret = "secret" +) + +// MaxSummaryFindings caps the findings a summary lists. Counts and +// BlockingCounts still cover every finding, and the stored report has them all. +const MaxSummaryFindings = 100 + +// ScanSummary is the verdict scan-gate reaches on one report. +type ScanSummary struct { + Policy ScanPolicy `json:"policy"` + Blocked bool `json:"blocked"` + Packages int `json:"packages"` + // Counts holds every finding by severity; BlockingCounts the ones the policy + // blocks on. + Counts map[string]int `json:"counts"` + BlockingCounts map[string]int `json:"blocking_counts"` + // Findings lists blocking findings first, then the rest, most severe first, + // at most MaxSummaryFindings of them. + Findings []ScanFinding `json:"findings"` + // Omitted names the documents ("sbom", "report") left out of the envelope to + // keep it inside a pod log. + Omitted []string `json:"omitted,omitempty"` +} + +// trivyReport is the part of Trivy's JSON report (SchemaVersion 2) the gate reads. +type trivyReport struct { + SchemaVersion int `json:"SchemaVersion"` + Results []struct { + Target string `json:"Target"` + Packages []struct{} `json:"Packages"` + Vulnerabilities []struct { + VulnerabilityID string `json:"VulnerabilityID"` + PkgName string `json:"PkgName"` + InstalledVersion string `json:"InstalledVersion"` + FixedVersion string `json:"FixedVersion"` + Status string `json:"Status"` + Severity string `json:"Severity"` + Title string `json:"Title"` + } `json:"Vulnerabilities"` + Secrets []struct { + RuleID string `json:"RuleID"` + Severity string `json:"Severity"` + Title string `json:"Title"` + } `json:"Secrets"` + } `json:"Results"` +} + +// Summarize applies policy to a Trivy JSON report. +func Summarize(report []byte, policy ScanPolicy) (ScanSummary, error) { + var rep trivyReport + if err := json.Unmarshal(report, &rep); err != nil { + return ScanSummary{}, fmt.Errorf("read trivy report: %w", err) + } + if rep.SchemaVersion != 2 { + return ScanSummary{}, fmt.Errorf("read trivy report: schema version %d, want 2", rep.SchemaVersion) + } + s := ScanSummary{Policy: policy, Counts: map[string]int{}, BlockingCounts: map[string]int{}} + var all []ScanFinding + blocks := func(id, sev string, fixable bool) bool { + return slices.Contains(policy.FailOn, sev) && (fixable || policy.FailUnfixed) && !slices.Contains(policy.Accept, id) + } + for _, res := range rep.Results { + s.Packages += len(res.Packages) + for _, v := range res.Vulnerabilities { + sev := severity(v.Severity) + all = append(all, ScanFinding{ + ID: v.VulnerabilityID, Kind: FindingVulnerability, Severity: sev, + Package: v.PkgName, Installed: v.InstalledVersion, Fixed: v.FixedVersion, + Target: res.Target, Title: v.Title, + Blocking: blocks(v.VulnerabilityID, sev, v.FixedVersion != "" || v.Status == "fixed"), + Accepted: slices.Contains(policy.Accept, v.VulnerabilityID), + }) + } + for _, sec := range res.Secrets { + sev := severity(sec.Severity) + all = append(all, ScanFinding{ + ID: sec.RuleID, Kind: FindingSecret, Severity: sev, + Target: res.Target, Title: sec.Title, + Blocking: blocks(sec.RuleID, sev, true), + Accepted: slices.Contains(policy.Accept, sec.RuleID), + }) + } + } + for _, f := range all { + s.Counts[f.Severity]++ + if f.Blocking { + s.BlockingCounts[f.Severity]++ + s.Blocked = true + } + } + sort.SliceStable(all, func(i, j int) bool { + a, b := all[i], all[j] + if a.Blocking != b.Blocking { + return a.Blocking + } + if ra, rb := slices.Index(Severities, a.Severity), slices.Index(Severities, b.Severity); ra != rb { + return ra < rb + } + if a.ID != b.ID { + return a.ID < b.ID + } + return a.Package < b.Package + }) + s.Findings = all[:min(len(all), MaxSummaryFindings)] + if s.Findings == nil { + s.Findings = []ScanFinding{} + } + return s, nil +} + +// severity normalizes a Trivy severity; anything unrecognised is UNKNOWN. +func severity(s string) string { + s = strings.ToUpper(s) + if slices.Contains(Severities, s) { + return s + } + return "UNKNOWN" +} + +// Reason is the one line a blocked build records as its error, naming the +// blocking counts and the first few finding ids. It is empty when nothing blocks. +func (s ScanSummary) Reason() string { + if !s.Blocked { + return "" + } + var counts []string + for _, sev := range Severities { + if n := s.BlockingCounts[sev]; n > 0 { + counts = append(counts, fmt.Sprintf("%d %s", n, sev)) + } + } + var ids []string + for _, f := range s.Findings { + if !f.Blocking || len(ids) == 5 { + break + } + if !slices.Contains(ids, f.ID) { + ids = append(ids, f.ID) + } + } + total := 0 + for _, n := range s.BlockingCounts { + total += n + } + list := strings.Join(ids, ", ") + if total > len(ids) { + list += fmt.Sprintf(" and %d more", total-len(ids)) + } + return "the scan blocked the image: " + strings.Join(counts, ", ") + " (" + list + ")" +} + +// ScanEnvelope is what scan-gate hands felis-api through its log. +type ScanEnvelope struct { + Summary ScanSummary `json:"summary"` + Report json.RawMessage `json:"report,omitempty"` + SBOM json.RawMessage `json:"sbom,omitempty"` +} + +const ( + envelopeBegin = "felis-scan-envelope v1 begin" + envelopeEndPrefix = "felis-scan-envelope v1 end sha256=" + envelopeLineWidth = 76 +) + +// MaxEnvelopeBytes bounds the gzip-compressed envelope. The kubelet rotates a +// container log at 10 MiB by default, and a rotated-away half is gone from +// GetLogs; base64 grows the payload by a third, so 6 MiB keeps the whole frame in +// one file. +const MaxEnvelopeBytes = 6 << 20 + +// envelopeBudget is MaxEnvelopeBytes, shrunk by tests. +var envelopeBudget = MaxEnvelopeBytes + +// maxEnvelopeJSON bounds the decompressed envelope a reader accepts. +const maxEnvelopeJSON = 128 << 20 + +// ErrNoScanEnvelope means a log holds no complete scan envelope. +var ErrNoScanEnvelope = errors.New("build: no scan envelope in the log") + +// WriteScanEnvelope writes env to w as a framed block. When the compressed +// envelope exceeds MaxEnvelopeBytes it drops the SBOM, then the report, and names +// what it dropped in Summary.Omitted. It returns the envelope it wrote. +func WriteScanEnvelope(w io.Writer, env ScanEnvelope) (ScanEnvelope, error) { + var payload []byte + for { + raw, err := json.Marshal(env) + if err != nil { + return env, err + } + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + if _, err := zw.Write(raw); err != nil { + return env, err + } + if err := zw.Close(); err != nil { + return env, err + } + payload = buf.Bytes() + if len(payload) <= envelopeBudget { + break + } + switch { + case env.SBOM != nil: + env.SBOM = nil + env.Summary.Omitted = append(env.Summary.Omitted, "sbom") + case env.Report != nil: + env.Report = nil + env.Summary.Omitted = append(env.Summary.Omitted, "report") + default: + return env, fmt.Errorf("build: scan summary alone compresses to %d bytes", len(payload)) + } + } + sum := sha256.Sum256(payload) + enc := base64.StdEncoding.EncodeToString(payload) + bw := bufio.NewWriter(w) + fmt.Fprintln(bw, envelopeBegin) + for len(enc) > 0 { + n := min(len(enc), envelopeLineWidth) + fmt.Fprintln(bw, enc[:n]) + enc = enc[n:] + } + fmt.Fprintln(bw, envelopeEndPrefix+hex.EncodeToString(sum[:])) + return env, bw.Flush() +} + +// ReadScanEnvelope returns the last complete, intact envelope in a log. The +// envelope scan-gate writes is the last thing it prints, so a frame that report +// content managed to print earlier can never stand in for it. +func ReadScanEnvelope(r io.Reader) (*ScanEnvelope, error) { + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 64<<10), 1<<20) + var ( + found []byte + cur strings.Builder + inFrame bool + budget = envelopeBudget/3*4 + 4096 + ) + for sc.Scan() { + line := strings.TrimRight(sc.Text(), "\r") + switch { + case line == envelopeBegin: + inFrame = true + cur.Reset() + case inFrame && strings.HasPrefix(line, envelopeEndPrefix): + inFrame = false + payload, err := base64.StdEncoding.DecodeString(cur.String()) + if err != nil { + continue + } + sum := sha256.Sum256(payload) + if hex.EncodeToString(sum[:]) == strings.TrimPrefix(line, envelopeEndPrefix) { + found = payload + } + case inFrame: + if cur.Len()+len(line) > budget { + inFrame = false + continue + } + cur.WriteString(line) + } + } + if err := sc.Err(); err != nil { + return nil, fmt.Errorf("build: read scan log: %w", err) + } + if found == nil { + return nil, ErrNoScanEnvelope + } + zr, err := gzip.NewReader(bytes.NewReader(found)) + if err != nil { + return nil, fmt.Errorf("build: scan envelope: %w", err) + } + raw, err := io.ReadAll(io.LimitReader(zr, maxEnvelopeJSON+1)) + if err != nil { + return nil, fmt.Errorf("build: scan envelope: %w", err) + } + if len(raw) > maxEnvelopeJSON { + return nil, fmt.Errorf("build: scan envelope exceeds %d bytes", maxEnvelopeJSON) + } + var env ScanEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + return nil, fmt.Errorf("build: scan envelope: %w", err) + } + return &env, nil +} + +// Scan is the scan record kept for one build: the verdict, and gzip copies of +// the Trivy report and the CycloneDX SBOM (nil when the envelope left one out). +type Scan struct { + BuildID string `json:"build_id"` + Summary ScanSummary `json:"summary"` + ScannedAt time.Time `json:"scanned_at"` + ReportGz []byte `json:"-"` + SBOMGz []byte `json:"-"` +} + +// newScan compresses an envelope's documents into a Scan. +func newScan(buildID string, env *ScanEnvelope, at time.Time) (Scan, error) { + s := Scan{BuildID: buildID, Summary: env.Summary, ScannedAt: at} + var err error + if s.ReportGz, err = gzipBytes(env.Report); err != nil { + return s, err + } + s.SBOMGz, err = gzipBytes(env.SBOM) + return s, err +} + +func gzipBytes(b []byte) ([]byte, error) { + if len(b) == 0 { + return nil, nil + } + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + if _, err := zw.Write(b); err != nil { + return nil, err + } + if err := zw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// Printable strips control characters from report text before a log line +// carries it, so a package name from the scanned image cannot start a line of +// its own (such as a forged envelope frame). +func Printable(s string) string { + return strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f || (r >= 0x80 && r < 0xa0) || r == '
' || r == '
' { + return '?' + } + return r + }, s) +} diff --git a/internal/build/scan_test.go b/internal/build/scan_test.go new file mode 100644 index 0000000..1385513 --- /dev/null +++ b/internal/build/scan_test.go @@ -0,0 +1,383 @@ +package build + +import ( + "bytes" + "compress/gzip" + "encoding/json" + "errors" + "fmt" + "io" + "reflect" + "strings" + "testing" +) + +// trivyFixture is a trimmed Trivy 0.74 JSON report: an OS layer and a jar layer, +// with fixed, unfixed and status-only-fixed vulnerabilities, a leaked secret and a +// severity Trivy never assigns. +const trivyFixture = `{ + "SchemaVersion": 2, + "ArtifactName": "/image/image.tar", + "Results": [ + { + "Target": "image.tar (ubuntu 24.04)", + "Class": "os-pkgs", + "Packages": [{"Name": "libc6"}, {"Name": "openssl"}, {"Name": "zlib1g"}], + "Vulnerabilities": [ + {"VulnerabilityID": "CVE-2024-0003", "PkgName": "zlib1g", "InstalledVersion": "1.3", "FixedVersion": "1.3.1", "Status": "fixed", "Severity": "MEDIUM", "Title": "zlib overflow"}, + {"VulnerabilityID": "CVE-2024-0002", "PkgName": "openssl", "InstalledVersion": "3.0.13", "FixedVersion": "", "Status": "affected", "Severity": "HIGH", "Title": "openssl timing"}, + {"VulnerabilityID": "CVE-2024-0009", "PkgName": "libc6", "InstalledVersion": "2.39", "Status": "affected", "Severity": "bogus"} + ] + }, + { + "Target": "data/mods/core.jar", + "Class": "lang-pkgs", + "Packages": [{"Name": "log4j-core"}, {"Name": "commons-text"}], + "Vulnerabilities": [ + {"VulnerabilityID": "CVE-2024-0004", "PkgName": "commons-text", "InstalledVersion": "1.9", "Status": "fixed", "Severity": "HIGH"}, + {"VulnerabilityID": "CVE-2024-0001", "PkgName": "log4j-core", "InstalledVersion": "2.14.1", "FixedVersion": "2.17.1", "Status": "fixed", "Severity": "CRITICAL", "Title": "Log4Shell"} + ] + }, + { + "Target": "/data/server.properties", + "Class": "secret", + "Secrets": [{"RuleID": "aws-access-key-id", "Category": "AWS", "Severity": "CRITICAL", "Title": "AWS Access Key ID"}] + } + ] +}` + +func findingIDs(fs []ScanFinding) []string { + var out []string + for _, f := range fs { + out = append(out, fmt.Sprintf("%s:%t", f.ID, f.Blocking)) + } + return out +} + +func TestParseSeverities(t *testing.T) { + for _, tc := range []struct { + in string + want []string + err string + }{ + {in: "high, critical", want: []string{"CRITICAL", "HIGH"}}, + {in: "LOW,medium,MEDIUM", want: []string{"MEDIUM", "LOW"}}, + {in: "unknown", want: []string{"UNKNOWN"}}, + {in: "HIGH,SEVERE", err: `unknown severity "SEVERE" (use CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN)`}, + {in: " , ", err: "no severity named"}, + } { + got, err := ParseSeverities(tc.in) + if tc.err != "" { + if err == nil || err.Error() != tc.err { + t.Errorf("ParseSeverities(%q) err = %v, want %q", tc.in, err, tc.err) + } + continue + } + if err != nil || !reflect.DeepEqual(got, tc.want) { + t.Errorf("ParseSeverities(%q) = %v, %v; want %v", tc.in, got, err, tc.want) + } + } +} + +func TestParseScanAccept(t *testing.T) { + got, err := ParseScanAccept(" CVE-2021-35515,GHSA-cfgp-2977-2fmm, ,aws-access-key-id,CVE-2021-35515,DLA-3782-1,RHSA-2024:1234") + if err != nil || !reflect.DeepEqual(got, []string{"CVE-2021-35515", "GHSA-cfgp-2977-2fmm", "aws-access-key-id", "DLA-3782-1", "RHSA-2024:1234"}) { + t.Errorf("ParseScanAccept = %v, %v", got, err) + } + if got, err := ParseScanAccept(""); err != nil || got != nil { + t.Errorf("empty = %v, %v; want nothing accepted", got, err) + } + for in, want := range map[string]string{ + "CVE-2021-35515 CVE-2025-67030": `"CVE-2021-35515 CVE-2025-67030" is not a vulnerability id or secret rule id`, + "-rf": `"-rf" is not a vulnerability id or secret rule id`, + strings.Repeat("A", 129): `"` + strings.Repeat("A", 129) + `" is not a vulnerability id or secret rule id`, + } { + if _, err := ParseScanAccept(in); err == nil || err.Error() != want { + t.Errorf("ParseScanAccept(%.20q) err = %v", in, err) + } + } + if got, err := ParseScanAccept(strings.Repeat("A", 128)); err != nil || len(got) != 1 { + t.Errorf("a 128-character id = %v, %v", got, err) + } +} + +// An accepted id stays counted and listed, marked accepted, and never blocks; +// the other findings are judged as before. +func TestSummarizeAcceptedIDsNeverBlock(t *testing.T) { + s, err := Summarize([]byte(trivyFixture), ScanPolicy{FailOn: []string{"CRITICAL", "HIGH"}, + Accept: []string{"CVE-2024-0001", "aws-access-key-id", "CVE-2099-0001"}}) + if err != nil { + t.Fatalf("Summarize: %v", err) + } + var got []string + for _, f := range s.Findings { + got = append(got, fmt.Sprintf("%s:%t:%t", f.ID, f.Blocking, f.Accepted)) + } + want := []string{"CVE-2024-0004:true:false", "CVE-2024-0001:false:true", "aws-access-key-id:false:true", + "CVE-2024-0002:false:false", "CVE-2024-0003:false:false", "CVE-2024-0009:false:false"} + if !reflect.DeepEqual(got, want) { + t.Errorf("findings = %v\nwant %v", got, want) + } + if !reflect.DeepEqual(s.Counts, map[string]int{"CRITICAL": 2, "HIGH": 2, "MEDIUM": 1, "UNKNOWN": 1}) || + !reflect.DeepEqual(s.BlockingCounts, map[string]int{"HIGH": 1}) { + t.Errorf("counts = %v, blocking = %v", s.Counts, s.BlockingCounts) + } + if got := s.Reason(); got != "the scan blocked the image: 1 HIGH (CVE-2024-0004)" { + t.Errorf("reason = %q", got) + } + raw, err := json.Marshal(s.Policy) + if err != nil || string(raw) != `{"fail_on":["CRITICAL","HIGH"],"fail_unfixed":false,"accept":["CVE-2024-0001","aws-access-key-id","CVE-2099-0001"]}` { + t.Errorf("policy json = %s, %v", raw, err) + } +} + +// A CRITICAL,HIGH policy blocks HIGH and CRITICAL findings that have a fixed release +// (a FixedVersion, or Trivy's status "fixed"), and a leaked secret always. +func TestSummarizeDefaultPolicy(t *testing.T) { + s, err := Summarize([]byte(trivyFixture), ScanPolicy{FailOn: []string{"CRITICAL", "HIGH"}}) + if err != nil { + t.Fatalf("Summarize: %v", err) + } + if !s.Blocked || s.Packages != 5 { + t.Errorf("blocked=%t packages=%d, want true and 5", s.Blocked, s.Packages) + } + if want := map[string]int{"CRITICAL": 2, "HIGH": 2, "MEDIUM": 1, "UNKNOWN": 1}; !reflect.DeepEqual(s.Counts, want) { + t.Errorf("counts = %v, want %v", s.Counts, want) + } + if want := map[string]int{"CRITICAL": 2, "HIGH": 1}; !reflect.DeepEqual(s.BlockingCounts, want) { + t.Errorf("blocking counts = %v, want %v", s.BlockingCounts, want) + } + want := []string{"CVE-2024-0001:true", "aws-access-key-id:true", "CVE-2024-0004:true", + "CVE-2024-0002:false", "CVE-2024-0003:false", "CVE-2024-0009:false"} + if got := findingIDs(s.Findings); !reflect.DeepEqual(got, want) { + t.Errorf("findings = %v\nwant %v", got, want) + } + first := s.Findings[0] + if first != (ScanFinding{ID: "CVE-2024-0001", Kind: "vulnerability", Severity: "CRITICAL", Package: "log4j-core", + Installed: "2.14.1", Fixed: "2.17.1", Target: "data/mods/core.jar", Title: "Log4Shell", Blocking: true}) { + t.Errorf("first finding = %+v", first) + } + if sec := s.Findings[1]; sec.Kind != "secret" || sec.Target != "/data/server.properties" || sec.Title != "AWS Access Key ID" { + t.Errorf("secret finding = %+v", sec) + } + if got := s.Reason(); got != "the scan blocked the image: 2 CRITICAL, 1 HIGH (CVE-2024-0001, aws-access-key-id, CVE-2024-0004)" { + t.Errorf("reason = %q", got) + } +} + +func TestSummarizePolicyVariants(t *testing.T) { + s, err := Summarize([]byte(trivyFixture), ScanPolicy{FailOn: []string{"CRITICAL", "HIGH"}, FailUnfixed: true}) + if err != nil { + t.Fatalf("Summarize: %v", err) + } + if want := map[string]int{"CRITICAL": 2, "HIGH": 2}; !reflect.DeepEqual(s.BlockingCounts, want) { + t.Errorf("fail-unfixed blocking counts = %v, want %v", s.BlockingCounts, want) + } + + s, err = Summarize([]byte(trivyFixture), ScanPolicy{FailOn: []string{"CRITICAL"}}) + if err != nil { + t.Fatalf("Summarize: %v", err) + } + if got := s.Reason(); got != "the scan blocked the image: 2 CRITICAL (CVE-2024-0001, aws-access-key-id)" { + t.Errorf("CRITICAL-only reason = %q", got) + } + + s, err = Summarize([]byte(trivyFixture), ScanPolicy{FailOn: []string{"LOW"}}) + if err != nil { + t.Fatalf("Summarize: %v", err) + } + if s.Blocked || s.Reason() != "" || len(s.BlockingCounts) != 0 { + t.Errorf("LOW-only policy blocked=%t reason=%q counts=%v, want nothing blocking", s.Blocked, s.Reason(), s.BlockingCounts) + } +} + +func TestSummarizeCleanAndBadReports(t *testing.T) { + s, err := Summarize([]byte(`{"SchemaVersion":2,"Results":[{"Target":"x","Packages":[{}]}]}`), ScanPolicy{FailOn: DefaultScanFailOn}) + if err != nil { + t.Fatalf("Summarize: %v", err) + } + if s.Blocked || s.Packages != 1 || s.Findings == nil || len(s.Findings) != 0 { + t.Errorf("clean report = %+v, want unblocked, one package, an empty findings list", s) + } + if _, err := Summarize([]byte(`{"SchemaVersion":1,"Results":[]}`), ScanPolicy{}); err == nil || + err.Error() != "read trivy report: schema version 1, want 2" { + t.Errorf("schema 1 err = %v", err) + } + if _, err := Summarize([]byte(`not json`), ScanPolicy{}); err == nil || !strings.HasPrefix(err.Error(), "read trivy report: ") { + t.Errorf("garbage err = %v", err) + } +} + +// One CVE in two packages is two blocking findings under one id: the reason +// names the id once and counts the other. +func TestReasonNamesARepeatedIDOnce(t *testing.T) { + report := `{"SchemaVersion":2,"Results":[{"Target":"mods","Vulnerabilities":[ + {"VulnerabilityID":"CVE-2024-0001","PkgName":"log4j-core","InstalledVersion":"2.14.1","FixedVersion":"2.17.1","Severity":"CRITICAL"}, + {"VulnerabilityID":"CVE-2024-0001","PkgName":"log4j-api","InstalledVersion":"2.14.1","FixedVersion":"2.17.1","Severity":"CRITICAL"}]}]}` + s, err := Summarize([]byte(report), ScanPolicy{FailOn: DefaultScanFailOn}) + if err != nil { + t.Fatal(err) + } + if got := s.Reason(); got != "the scan blocked the image: 2 CRITICAL (CVE-2024-0001 and 1 more)" { + t.Errorf("reason = %q", got) + } +} + +// A summary lists at most MaxSummaryFindings findings, while the counts and the +// reason still cover every one. +func TestSummarizeCapsTheListedFindings(t *testing.T) { + var vulns []string + for i := range 150 { + vulns = append(vulns, fmt.Sprintf(`{"VulnerabilityID":"CVE-2025-%04d","PkgName":"p","FixedVersion":"2","Severity":"HIGH"}`, i)) + } + rep := `{"SchemaVersion":2,"Results":[{"Target":"t","Vulnerabilities":[` + strings.Join(vulns, ",") + `]}]}` + s, err := Summarize([]byte(rep), ScanPolicy{FailOn: []string{"CRITICAL", "HIGH"}}) + if err != nil { + t.Fatalf("Summarize: %v", err) + } + if len(s.Findings) != 100 || s.Counts["HIGH"] != 150 || s.BlockingCounts["HIGH"] != 150 { + t.Errorf("listed %d, counted %d/%d; want 100 listed of 150", len(s.Findings), s.Counts["HIGH"], s.BlockingCounts["HIGH"]) + } + if got := s.Reason(); got != "the scan blocked the image: 150 HIGH (CVE-2025-0000, CVE-2025-0001, CVE-2025-0002, CVE-2025-0003, CVE-2025-0004 and 145 more)" { + t.Errorf("reason = %q", got) + } +} + +func TestScanEnvelopeRoundTripsThroughALog(t *testing.T) { + summary, err := Summarize([]byte(trivyFixture), ScanPolicy{FailOn: DefaultScanFailOn}) + if err != nil { + t.Fatal(err) + } + sbom := json.RawMessage(`{"bomFormat":"CycloneDX","specVersion":"1.6","components":[{"name":"log4j-core","version":"2.14.1"}]}`) + var log bytes.Buffer + log.WriteString("felis scan-gate: 5 packages; findings: CRITICAL 2\n") + written, err := WriteScanEnvelope(&log, ScanEnvelope{Summary: summary, Report: json.RawMessage(trivyFixture), SBOM: sbom}) + if err != nil { + t.Fatalf("WriteScanEnvelope: %v", err) + } + if written.Summary.Omitted != nil { + t.Errorf("a small envelope omitted %v", written.Summary.Omitted) + } + for _, line := range strings.Split(strings.TrimSpace(log.String()), "\n") { + if len(line) > 100 { + t.Errorf("log line of %d bytes: the frame must stay line-wrapped", len(line)) + } + } + env, err := ReadScanEnvelope(strings.NewReader(log.String() + "trailing line\n")) + if err != nil { + t.Fatalf("ReadScanEnvelope: %v", err) + } + if !reflect.DeepEqual(env.Summary, summary) { + t.Errorf("summary came back as %+v", env.Summary) + } + var want, got bytes.Buffer + _ = json.Compact(&want, []byte(trivyFixture)) + _ = json.Compact(&got, env.Report) + if got.String() != want.String() { + t.Errorf("report came back as %s", got.String()) + } + if string(env.SBOM) != string(sbom) { + t.Errorf("sbom came back as %s", env.SBOM) + } +} + +// The reader takes the last intact frame: scan-gate prints its envelope last, and +// a frame whose checksum does not match is ignored. +func TestReadScanEnvelopeTakesTheLastIntactFrame(t *testing.T) { + frame := func(blocked bool) string { + var b bytes.Buffer + if _, err := WriteScanEnvelope(&b, ScanEnvelope{Summary: ScanSummary{Blocked: blocked, Findings: []ScanFinding{}}}); err != nil { + t.Fatal(err) + } + return b.String() + } + env, err := ReadScanEnvelope(strings.NewReader(frame(false) + frame(true))) + if err != nil || !env.Summary.Blocked { + t.Errorf("two frames: got %+v, %v; want the second (blocked)", env, err) + } + broken := strings.Replace(frame(false), "sha256=", "sha256=00", 1) + env, err = ReadScanEnvelope(strings.NewReader(frame(true) + broken)) + if err != nil || !env.Summary.Blocked { + t.Errorf("intact then broken: got %+v, %v; want the intact one", env, err) + } + if _, err := ReadScanEnvelope(strings.NewReader(broken)); !errors.Is(err, ErrNoScanEnvelope) { + t.Errorf("broken only: err = %v, want ErrNoScanEnvelope", err) + } + unterminated := strings.SplitAfter(frame(true), "\n") + if _, err := ReadScanEnvelope(strings.NewReader(strings.Join(unterminated[:len(unterminated)-2], ""))); !errors.Is(err, ErrNoScanEnvelope) { + t.Errorf("unterminated: err = %v, want ErrNoScanEnvelope", err) + } + if _, err := ReadScanEnvelope(strings.NewReader("fake logs")); !errors.Is(err, ErrNoScanEnvelope) { + t.Errorf("no frame: err = %v, want ErrNoScanEnvelope", err) + } +} + +// An envelope past the budget drops the SBOM first, then the report, and says so. +func TestWriteScanEnvelopeDropsWhatDoesNotFit(t *testing.T) { + defer func(old int) { envelopeBudget = old }(envelopeBudget) + envelopeBudget = 2048 + noise := func(n int) json.RawMessage { + // Incompressible enough: a JSON string of pseudo-random hex. + var b strings.Builder + b.WriteString(`"`) + x := uint32(2463534242) + for range n { + x ^= x << 13 + x ^= x >> 17 + x ^= x << 5 + fmt.Fprintf(&b, "%08x", x) + } + b.WriteString(`"`) + return json.RawMessage(b.String()) + } + base := ScanSummary{Findings: []ScanFinding{}} + for _, tc := range []struct { + name string + report, sbom json.RawMessage + omitted []string + }{ + {"sbom too big", json.RawMessage(`{"r":1}`), noise(1000), []string{"sbom"}}, + {"both too big", noise(1000), noise(1000), []string{"sbom", "report"}}, + } { + var log bytes.Buffer + written, err := WriteScanEnvelope(&log, ScanEnvelope{Summary: base, Report: tc.report, SBOM: tc.sbom}) + if err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + env, err := ReadScanEnvelope(&log) + if err != nil { + t.Fatalf("%s: read: %v", tc.name, err) + } + if !reflect.DeepEqual(written.Summary.Omitted, tc.omitted) || !reflect.DeepEqual(env.Summary.Omitted, tc.omitted) { + t.Errorf("%s: omitted %v / read back %v, want %v", tc.name, written.Summary.Omitted, env.Summary.Omitted, tc.omitted) + } + if env.SBOM != nil || (len(tc.omitted) == 2) != (env.Report == nil) { + t.Errorf("%s: read back report=%d sbom=%d bytes", tc.name, len(env.Report), len(env.SBOM)) + } + } +} + +func TestNewScanCompressesTheDocuments(t *testing.T) { + sc, err := newScan("bld-7", &ScanEnvelope{Summary: ScanSummary{Blocked: true}, Report: json.RawMessage(`{"SchemaVersion":2}`)}, testNow) + if err != nil { + t.Fatal(err) + } + if sc.BuildID != "bld-7" || !sc.Summary.Blocked || !sc.ScannedAt.Equal(testNow) || sc.SBOMGz != nil { + t.Errorf("scan = %+v", sc) + } + zr, err := gzip.NewReader(bytes.NewReader(sc.ReportGz)) + if err != nil { + t.Fatal(err) + } + raw, _ := io.ReadAll(zr) + if string(raw) != `{"SchemaVersion":2}` { + t.Errorf("report decompresses to %q", raw) + } +} + +func TestPrintableStripsLineBreaks(t *testing.T) { + got := Printable("evil\nfelis-scan-envelope v1 begin\r\x1b[31m
x\u0085y") + if got != "evil?felis-scan-envelope v1 begin??[31m?x?y" { + t.Errorf("Printable = %q", got) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 91e4513..2a81c5d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,6 +16,10 @@ import ( "github.com/BurntSushi/toml" ) +// scanIDPattern is build.scanIDPattern: the shape of a finding id scan-gate +// takes in its comma-separated --accept flag. +var scanIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`) + // Config is the parsed felis.toml. type Config struct { Server ServerConfig `toml:"server"` @@ -220,6 +224,16 @@ type RegistryConfig struct { // vulnerability DB: /mirror/trivy-java-db:1 by default. Empty keeps that // default. TrivyJavaDBRepository string `toml:"trivy_java_db_repository"` + // ScanFailOn lists the severities that block a built image (CRITICAL, HIGH, + // MEDIUM, LOW, UNKNOWN). Empty keeps CRITICAL (build.DefaultScanFailOn). + ScanFailOn []string `toml:"scan_fail_on"` + // ScanFailUnfixed blocks on vulnerabilities that have no fixed release too. + // Off by default: the submitter cannot upgrade past them, and the build's + // scan report still lists them. + ScanFailUnfixed bool `toml:"scan_fail_unfixed"` + // ScanAccept lists vulnerability ids and secret rule ids accepted as known + // risks (build.ScanPolicy.Accept): still listed in the scan, never blocking. + ScanAccept []string `toml:"scan_accept"` // UserUploadsContext is the object-store base under which a user-submitted // modpack's Kaniko build context is pinned. It belongs to the §16 build // subsystem's input domain (the build-context store), introduced by the @@ -564,6 +578,22 @@ func (c *Config) Validate() error { if n := c.Registry.MaxConcurrentBuilds; n < 0 || n > 6 { return fmt.Errorf("config: [registry] max_concurrent_builds %d must be 1-6 (0 keeps 2)", n) } + for i, sev := range c.Registry.ScanFailOn { + sev = strings.ToUpper(strings.TrimSpace(sev)) + c.Registry.ScanFailOn[i] = sev + switch sev { + case "CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN": + default: + return fmt.Errorf("config: [registry] scan_fail_on %q must be one of CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN", sev) + } + } + for i, id := range c.Registry.ScanAccept { + id = strings.TrimSpace(id) + c.Registry.ScanAccept[i] = id + if !scanIDPattern.MatchString(id) { + return fmt.Errorf("config: [registry] scan_accept %q must be a vulnerability id or secret rule id (letters, digits, and . _ : -)", id) + } + } // [smtp] is optional as a whole, but once a host is named the block must be // deliverable: a From address (relays reject MAIL FROM:<>) and a sane port. // Fail at load, not at the first OTP a player is waiting on. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a9e6293..60cb089 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -250,6 +250,52 @@ url = "`+url+`" } } +// The scan policy reaches the build Job as written, case aside, and a severity +// Trivy does not use fails the load instead of every build. +func TestLoadScanPolicy(t *testing.T) { + cfg, err := config.Load(writeTOML(t, ` +[server] +root_domain = "mc.example.net" +[database] +url = "postgres://felis@db/felis" +[registry] +scan_fail_on = ["critical", " High "] +scan_fail_unfixed = true +scan_accept = [" CVE-2021-35515 ", "aws-access-key-id"] +`)) + if err != nil { + t.Fatal(err) + } + if got := strings.Join(cfg.Registry.ScanFailOn, ","); got != "CRITICAL,HIGH" || !cfg.Registry.ScanFailUnfixed { + t.Errorf("scan policy = %q, unfixed %t", got, cfg.Registry.ScanFailUnfixed) + } + if got := strings.Join(cfg.Registry.ScanAccept, ","); got != "CVE-2021-35515,aws-access-key-id" { + t.Errorf("scan_accept = %q", got) + } + _, err = config.Load(writeTOML(t, ` +[server] +root_domain = "mc.example.net" +[database] +url = "postgres://felis@db/felis" +[registry] +scan_accept = ["CVE-2021-35515,CVE-2025-67030"] +`)) + if err == nil || err.Error() != `config: [registry] scan_accept "CVE-2021-35515,CVE-2025-67030" must be a vulnerability id or secret rule id (letters, digits, and . _ : -)` { + t.Errorf("err = %v", err) + } + _, err = config.Load(writeTOML(t, ` +[server] +root_domain = "mc.example.net" +[database] +url = "postgres://felis@db/felis" +[registry] +scan_fail_on = ["HIGH", "SEVERE"] +`)) + if err == nil || err.Error() != `config: [registry] scan_fail_on "SEVERE" must be one of CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN` { + t.Errorf("err = %v", err) + } +} + // TestLoadAuthSourcesPreservesOrder pins the Felis-nano priority contract: the // [[auth_source]] array-of-tables decodes in file order (config order = priority), which // is why it is an array-of-tables and not a map. A map keyed by tag would load and pass diff --git a/internal/pgint/pgint_test.go b/internal/pgint/pgint_test.go index 8a04b72..10c6b18 100644 --- a/internal/pgint/pgint_test.go +++ b/internal/pgint/pgint_test.go @@ -1502,6 +1502,74 @@ func TestBuildStoreContract(t *testing.T) { } } +// A build's scan round-trips whole (the summary as jsonb, the documents as +// bytea, a missing SBOM as NULL), a rescan replaces it, it cannot exist without +// its build, and it goes when the build row does. +func TestBuildStoreScans(t *testing.T) { + ctx := context.Background() + s := build.NewPGStore(db) + id := "bld-scan-" + suffix(t) + if err := s.CreateBuild(ctx, &build.Build{ID: id, ImageRef: "registry.felis.svc:5000/user-uploads/" + id + ":latest", + Status: build.StatusPending, RequestedBy: "pgint"}); err != nil { + t.Fatalf("CreateBuild: %v", err) + } + if _, err := s.GetScan(ctx, id); !errors.Is(err, build.ErrNotFound) { + t.Fatalf("scan before one was saved = %v, want ErrNotFound", err) + } + at := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC) + first := build.Scan{BuildID: id, ScannedAt: at, ReportGz: []byte{0x1f, 0x8b, 0x01}, Summary: build.ScanSummary{ + Policy: build.ScanPolicy{FailOn: []string{"CRITICAL", "HIGH"}}, Blocked: true, Packages: 7, + Counts: map[string]int{"CRITICAL": 1}, BlockingCounts: map[string]int{"CRITICAL": 1}, + Findings: []build.ScanFinding{{ID: "CVE-2024-0001", Kind: "vulnerability", Severity: "CRITICAL", + Package: "log4j-core", Installed: "2.14.1", Fixed: "2.17.1", Target: "mods/core.jar", Blocking: true}}, + Omitted: []string{"sbom"}, + }} + if err := s.SaveScan(ctx, first); err != nil { + t.Fatalf("SaveScan: %v", err) + } + got, err := s.GetScan(ctx, id) + if err != nil { + t.Fatalf("GetScan: %v", err) + } + if !got.ScannedAt.Equal(at) || string(got.ReportGz) != "\x1f\x8b\x01" || got.SBOMGz != nil { + t.Errorf("scan = at %v, report %x, sbom %x", got.ScannedAt, got.ReportGz, got.SBOMGz) + } + sum := got.Summary + if !sum.Blocked || sum.Packages != 7 || sum.Counts["CRITICAL"] != 1 || sum.BlockingCounts["CRITICAL"] != 1 || + strings.Join(sum.Policy.FailOn, ",") != "CRITICAL,HIGH" || strings.Join(sum.Omitted, ",") != "sbom" || + len(sum.Findings) != 1 || sum.Findings[0].Fixed != "2.17.1" || !sum.Findings[0].Blocking { + t.Errorf("summary = %+v", sum) + } + var blocked bool + if err := db.QueryRowContext(ctx, `SELECT blocked FROM image_build_scans WHERE build_id = $1`, id).Scan(&blocked); err != nil || !blocked { + t.Errorf("blocked column = %v (%v), want true", blocked, err) + } + + rescan := build.Scan{BuildID: id, ScannedAt: at.Add(time.Hour), ReportGz: []byte{0x02}, SBOMGz: []byte{0x03}, + Summary: build.ScanSummary{Policy: build.ScanPolicy{FailOn: []string{"CRITICAL"}}, Packages: 7, + Counts: map[string]int{}, BlockingCounts: map[string]int{}, Findings: []build.ScanFinding{}}} + if err := s.SaveScan(ctx, rescan); err != nil { + t.Fatalf("SaveScan (rescan): %v", err) + } + got, _ = s.GetScan(ctx, id) + if got.Summary.Blocked || !got.ScannedAt.Equal(at.Add(time.Hour)) || string(got.SBOMGz) != "\x03" || len(got.Summary.Findings) != 0 { + t.Errorf("rescan = %+v", got) + } + if err := db.QueryRowContext(ctx, `SELECT blocked FROM image_build_scans WHERE build_id = $1`, id).Scan(&blocked); err != nil || blocked { + t.Errorf("blocked column after a passing rescan = %v (%v), want false", blocked, err) + } + + if err := s.SaveScan(ctx, build.Scan{BuildID: "bld-none-" + suffix(t), ScannedAt: at}); err == nil { + t.Error("a scan saved for a build that does not exist") + } + if _, err := db.ExecContext(ctx, `DELETE FROM image_builds WHERE id = $1`, id); err != nil { + t.Fatalf("delete build: %v", err) + } + if _, err := s.GetScan(ctx, id); !errors.Is(err, build.ErrNotFound) { + t.Errorf("scan after its build was deleted = %v, want ErrNotFound", err) + } +} + // ListBuilds pages newest first across every requester, finds a build by part of // its ref (any case), its id or its status, and leaves the Dockerfile out. func TestBuildStoreListBuilds(t *testing.T) { diff --git a/internal/store/migrations/0032_image_build_scans.sql b/internal/store/migrations/0032_image_build_scans.sql new file mode 100644 index 0000000..67b58e6 --- /dev/null +++ b/internal/store/migrations/0032_image_build_scans.sql @@ -0,0 +1,13 @@ +-- The scan record of each build: scan-gate's verdict, the full Trivy JSON +-- report and the CycloneDX SBOM (both gzip), read back from the build pod's log +-- when the Job finishes. A blocked build keeps the findings that blocked it; an +-- admitted image keeps its SBOM, reached from image_whitelist.build_id. The row +-- goes with its build. +CREATE TABLE image_build_scans ( + build_id text PRIMARY KEY REFERENCES image_builds(id) ON DELETE CASCADE, + blocked boolean NOT NULL, + summary jsonb NOT NULL, + report_gz bytea, + sbom_gz bytea, + scanned_at timestamptz NOT NULL +); diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index cd9c4b7..4d75c3f 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -6,6 +6,7 @@ import type { AutostartPolicy, BackupView, Build, + BuildScan, CreateServerRequest, FleetServer, Identity, @@ -337,7 +338,7 @@ function initialState(): MockState { id: "bld-2", image_ref: "registry.felis.svc:5000/forge-broken:1.0", status: "failed", - error: "trivy found a CRITICAL CVE: CVE-2026-12345 in library/forge", + error: "the scan blocked the image: 1 CRITICAL, 1 HIGH (CVE-2026-12345, CVE-2025-24813)", requested_by: "owner@mock.felis.local", created_at: new Date(Date.now() - 1800000).toISOString(), finished_at: new Date(Date.now() - 1700000).toISOString(), @@ -529,6 +530,57 @@ function server( }; } +// mockScan is the scan build.Builder.Scan would keep for the seeded builds. +function mockScan(b: Build): BuildScan | null { + const at = b.finished_at ?? b.created_at; + if (b.id === "bld-2") { + return { + build_id: b.id, scanned_at: at, has_report: true, has_sbom: false, + summary: { + policy: { fail_on: ["CRITICAL", "HIGH"], fail_unfixed: false, accept: ["CVE-2021-35515", "CVE-2025-67030"] }, + blocked: true, packages: 214, + counts: { CRITICAL: 1, HIGH: 4, MEDIUM: 6, LOW: 2 }, + blocking_counts: { CRITICAL: 1, HIGH: 1 }, + omitted: ["sbom"], + findings: [ + { id: "CVE-2026-12345", kind: "vulnerability", severity: "CRITICAL", package: "net.minecraftforge:forge", + installed: "47.1.0", fixed: "47.1.3", target: "libraries/net/minecraftforge/forge/47.1.0/forge.jar", blocking: true }, + { id: "CVE-2025-24813", kind: "vulnerability", severity: "HIGH", package: "org.apache.tomcat.embed:tomcat-embed-core", + installed: "9.0.97", fixed: "9.0.99, 10.1.35", target: "mods/webmap-2.4.jar", blocking: true }, + { id: "CVE-2021-35515", kind: "vulnerability", severity: "HIGH", package: "org.apache.commons:commons-compress", + installed: "1.5", fixed: "1.21", target: "paper/paper.jar", blocking: false, accepted: true }, + { id: "CVE-2024-6763", kind: "vulnerability", severity: "HIGH", package: "org.eclipse.jetty:jetty-http", + installed: "9.4.53.v20231009", target: "mods/webmap-2.4.jar", blocking: false }, + { id: "CVE-2023-2976", kind: "vulnerability", severity: "HIGH", package: "com.google.guava:guava", + installed: "31.1-jre", target: "libraries/com/google/guava/guava/31.1-jre/guava-31.1-jre.jar", blocking: false }, + { id: "CVE-2024-47554", kind: "vulnerability", severity: "MEDIUM", package: "commons-io:commons-io", + installed: "2.11.0", fixed: "2.14.0", target: "libraries/commons-io/commons-io/2.11.0/commons-io-2.11.0.jar", blocking: false }, + { id: "CVE-2020-8908", kind: "vulnerability", severity: "LOW", package: "com.google.guava:guava", + installed: "31.1-jre", fixed: "32.0.0-android", target: "libraries/com/google/guava/guava/31.1-jre/guava-31.1-jre.jar", blocking: false }, + ], + }, + }; + } + if (b.id === "bld-1") { + return { + build_id: b.id, scanned_at: at, has_report: true, has_sbom: true, + summary: { + policy: { fail_on: ["CRITICAL"], fail_unfixed: false }, + blocked: false, packages: 187, + counts: { MEDIUM: 1, LOW: 1 }, + blocking_counts: {}, + findings: [ + { id: "CVE-2024-47554", kind: "vulnerability", severity: "MEDIUM", package: "commons-io:commons-io", + installed: "2.11.0", fixed: "2.14.0", target: "libraries/commons-io/commons-io/2.11.0/commons-io-2.11.0.jar", blocking: false }, + { id: "CVE-2020-8908", kind: "vulnerability", severity: "LOW", package: "com.google.guava:guava", + installed: "31.1-jre", fixed: "32.0.0-android", target: "libraries/com/google/guava/guava/31.1-jre/guava-31.1-jre.jar", blocking: false }, + ], + }, + }; + } + return null; +} + function sendJSON(res: ServerResponse, status: number, value: unknown): void { res.statusCode = status; res.setHeader("Content-Type", "application/json"); @@ -1665,6 +1717,44 @@ async function handleImageRoute(ctx: SessionContext): Promise { return true; } + // GET /api/v1/images/build/{id}/scan, /scan/report, /sbom (the kept scan). The + // seeded builds bld-1 (passed) and bld-2 (blocked) have one; the older history + // ran before builds kept their scans. + if ( + is("GET", ctx) && ctx.parts[3] === "build" && ctx.parts[4] && + ((ctx.parts[5] === "scan" && (ctx.parts.length === 6 || (ctx.parts[6] === "report" && ctx.parts.length === 7))) || + (ctx.parts[5] === "sbom" && ctx.parts.length === 6)) + ) { + if (!isAdmin(ctx.account.role)) { + sendError(ctx.res, 403, "forbidden", "admin account required"); + return true; + } + const build = ctx.state.builds.find((b) => b.id === ctx.parts[4]); + const scan = build ? mockScan(build) : null; + if (!build || !scan) { + sendError(ctx.res, 404, "scan_not_found", + "this build has no scan: it has not reached the scan step, or it ran before builds kept their scans"); + return true; + } + if (ctx.parts.length === 6 && ctx.parts[5] === "scan") { + sendJSON(ctx.res, 200, scan); + return true; + } + const sbom = ctx.parts[5] === "sbom"; + if (sbom && !scan.has_sbom) { + sendError(ctx.res, 404, "scan_document_not_kept", + "this build's scan kept no SBOM: it was too large to keep, or the step that writes it failed"); + return true; + } + ctx.res.statusCode = 200; + ctx.res.setHeader("Content-Type", sbom ? "application/vnd.cyclonedx+json" : "application/json"); + ctx.res.setHeader("Content-Disposition", `attachment; filename="${build.id}${sbom ? ".cdx.json" : "-trivy.json"}"`); + ctx.res.end(JSON.stringify(sbom + ? { bomFormat: "CycloneDX", specVersion: "1.6", components: [] } + : { SchemaVersion: 2, ArtifactName: build.image_ref, Results: [] }, null, 2)); + return true; + } + // POST /api/v1/images/build/{id}/cancel (cancel build) if (is("POST", ctx) && ctx.parts[3] === "build" && ctx.parts[5] === "cancel" && ctx.parts.length === 6) { if (!isAdmin(ctx.account.role)) { diff --git a/panel/src/i18n/resources/en-US/admin.json b/panel/src/i18n/resources/en-US/admin.json index 247ccf2..32f9324 100644 --- a/panel/src/i18n/resources/en-US/admin.json +++ b/panel/src/i18n/resources/en-US/admin.json @@ -45,7 +45,7 @@ "build_status_failed": "Failed", "build_status_cancelled": "Cancelled", "build_queued_hint": "The concurrent build limit ([registry] max_concurrent_builds) is reached; this build starts on its own when an earlier one finishes.", - "view_logs_btn": "Logs", + "view_logs_btn": "Scan & logs", "cancel_build_btn": "Cancel", "no_builds_title": "No Builds Found", "no_builds_hint": "You can trigger your first image build task using the form on the top right.", @@ -238,5 +238,36 @@ "trigger_build_desc": "Enter the build parameters to launch a Kaniko pipeline job in an isolated namespace.", "builds_search_placeholder": "Search build ID, image reference, or status...", "builds_refresh_failed": "Couldn't refresh the build list: {{reason}}", - "build_requested_by_you": "You" + "build_requested_by_you": "You", + "scan_title": "Security scan", + "scan_blocked": "Blocked", + "scan_passed": "Passed", + "scan_loading": "Loading the security scan…", + "scan_none": "No security scan was kept for this build. It stopped before the scan step, or it ran before builds kept their scans.", + "scan_load_failed": "Couldn't load the security scan: {{reason}}", + "scan_meta_one": "Scanned {{when}} · {{count}} package", + "scan_meta_other": "Scanned {{when}} · {{count}} packages", + "scan_policy": "Blocks on {{severities}}; vulnerabilities with no fixed release are listed only", + "scan_policy_unfixed": "Blocks on {{severities}}, including vulnerabilities with no fixed release", + "scan_counts_label": "Findings by severity", + "scan_blocking_count_one": "{{count}} blocks the image", + "scan_blocking_count_other": "{{count}} block the image", + "scan_clean": "Trivy found nothing to report in this image.", + "scan_showing": "Showing {{shown}} of {{total}} findings. The full report lists every one.", + "scan_col_id": "ID", + "scan_col_severity": "Severity", + "scan_col_package": "Package", + "scan_col_version": "Installed → fixed", + "scan_col_target": "Found in", + "scan_no_fix": "no fix yet", + "scan_blocks": "Blocks", + "scan_policy_accepts_one": "{{count}} ID accepted as a known risk", + "scan_policy_accepts_other": "{{count}} IDs accepted as known risks", + "scan_accepted": "Accepted", + "scan_accepted_title": "Listed in [registry] scan_accept as a known risk, so it never blocks.", + "scan_secret": "Secret: {{title}}", + "scan_download_report": "Trivy report", + "scan_download_sbom": "SBOM", + "scan_not_kept": "Not kept with this build: the file was too large, or the step that writes it failed.", + "scan_download_failed": "Couldn't download: {{reason}}" } diff --git a/panel/src/i18n/resources/zh-CN/admin.json b/panel/src/i18n/resources/zh-CN/admin.json index c942d6e..d8bb13f 100644 --- a/panel/src/i18n/resources/zh-CN/admin.json +++ b/panel/src/i18n/resources/zh-CN/admin.json @@ -45,7 +45,7 @@ "build_status_failed": "失败", "build_status_cancelled": "已取消", "build_queued_hint": "同时运行的构建已达上限([registry] max_concurrent_builds),这个构建会在前面的构建结束后自动开始。", - "view_logs_btn": "日志", + "view_logs_btn": "扫描与日志", "cancel_build_btn": "取消", "no_builds_title": "暂无构建任务", "no_builds_hint": "您可以使用右上角表单触发第一个镜像构建任务。", @@ -238,5 +238,33 @@ "trigger_build_desc": "输入镜像构建参数,在隔离命名空间中启动 Kaniko 流水线任务。", "builds_search_placeholder": "搜索构建 ID、镜像引用或状态...", "builds_refresh_failed": "构建列表刷新失败:{{reason}}", - "build_requested_by_you": "你" + "build_requested_by_you": "你", + "scan_title": "安全扫描", + "scan_blocked": "已拦截", + "scan_passed": "已通过", + "scan_loading": "正在读取安全扫描…", + "scan_none": "这次构建没有留存安全扫描:它在扫描步骤前就结束了,或者早于开始留存扫描的版本。", + "scan_load_failed": "读取安全扫描失败:{{reason}}", + "scan_meta_other": "扫描于 {{when}} · {{count}} 个软件包", + "scan_policy": "拦截级别 {{severities}};尚无修复版本的漏洞只列出", + "scan_policy_unfixed": "拦截级别 {{severities}},尚无修复版本的漏洞同样拦截", + "scan_counts_label": "按严重度统计的发现", + "scan_blocking_count_other": "其中 {{count}} 项拦截镜像", + "scan_clean": "Trivy 在这个镜像里没有发现任何问题。", + "scan_showing": "显示 {{total}} 项中的 {{shown}} 项,完整报告列出全部发现。", + "scan_col_id": "编号", + "scan_col_severity": "严重度", + "scan_col_package": "软件包", + "scan_col_version": "已装 → 修复版本", + "scan_col_target": "所在位置", + "scan_no_fix": "暂无修复", + "scan_blocks": "拦截", + "scan_policy_accepts_other": "{{count}} 个 ID 已作为已知风险接受", + "scan_accepted": "已接受", + "scan_accepted_title": "已列入 [registry] scan_accept 作为已知风险,不会拦截。", + "scan_secret": "密钥:{{title}}", + "scan_download_report": "Trivy 报告", + "scan_download_sbom": "SBOM", + "scan_not_kept": "这次构建未留存该文件:文件过大,或生成它的步骤失败。", + "scan_download_failed": "下载失败:{{reason}}" } diff --git a/panel/src/lib/api.test.ts b/panel/src/lib/api.test.ts index ad40c1d..7e58b61 100644 --- a/panel/src/lib/api.test.ts +++ b/panel/src/lib/api.test.ts @@ -548,6 +548,38 @@ describe("image whitelist and builds wire shapes", () => { expect(String(url)).toBe("/submissions/sub-1/context"); }); + it("getBuildScan GETs the kept scan, and downloadBuildScanDocument saves each document under its own name", async () => { + const fetchSpy = fakeFetch({ build_id: "bld-7", has_report: true, has_sbom: true }); + vi.stubGlobal("fetch", fetchSpy); + await api.getBuildScan("bld-7"); + expect(String((fetchSpy as unknown as ReturnType).mock.calls[0][0])).toBe("/images/build/bld-7/scan"); + + const docFetch = vi.fn(async () => ({ + ok: true, + status: 200, + statusText: "OK", + headers: new Headers(), + blob: async () => new Blob(["{}"]), + })) as unknown as typeof fetch; + vi.stubGlobal("fetch", docFetch); + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:doc"); + vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + const links: { href: string; download: string; click: () => void }[] = []; + vi.stubGlobal("document", { + createElement: () => { + const link = { href: "", download: "", click: vi.fn() }; + links.push(link); + return link; + }, + }); + await api.downloadBuildScanDocument("bld-7", "report"); + await api.downloadBuildScanDocument("bld-7", "sbom"); + const urls = (docFetch as unknown as ReturnType).mock.calls.map(([u]) => String(u)); + expect(urls).toEqual(["/images/build/bld-7/scan/report", "/images/build/bld-7/sbom"]); + expect(links.map((l) => l.download)).toEqual(["bld-7-trivy.json", "bld-7.cdx.json"]); + expect(links.every((l) => (l.click as ReturnType).mock.calls.length === 1)).toBe(true); + }); + it("rejectSubmission POSTs {reason} to /submissions/{id}/reject", async () => { const sub = { id: "sub-1", status: "rejected", reject_reason: "bad" }; const fetchSpy = fakeFetch(sub); diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index 10f73ad..b76d9a5 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -5,6 +5,7 @@ import type { BackupView, BanlistResult, Build, + BuildScan, CreateServerRequest, CreateUserRequest, FleetServer, @@ -454,6 +455,25 @@ export const api = rejectingSync({ cancelBuild: (id: string) => request("POST", urlPath`/images/build/${id}/cancel`), + /** What the build's scan gate kept; 404 scan_not_found before the scan step. */ + getBuildScan: (id: string) => + request("GET", urlPath`/images/build/${id}/scan`), + + /** Saves the build's full Trivy report or CycloneDX SBOM. The bytes name the + * image's own packages and paths, so they are downloaded, never rendered. */ + downloadBuildScanDocument: async (id: string, doc: "report" | "sbom") => { + const res = await fetchOK( + doc === "report" ? urlPath`/images/build/${id}/scan/report` : urlPath`/images/build/${id}/sbom`, + { method: "GET" }, + ); + const url = URL.createObjectURL(await res.blob()); + const link = document.createElement("a"); + link.href = url; + link.download = doc === "report" ? `${id}-trivy.json` : `${id}.cdx.json`; + link.click(); + URL.revokeObjectURL(url); + }, + createServer: (req: CreateServerRequest) => request<{ name: string; subdomain: string; desiredState: string }>( "POST", diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts index 9282e13..2544b75 100644 --- a/panel/src/lib/openapi.gen.ts +++ b/panel/src/lib/openapi.gen.ts @@ -1976,6 +1976,57 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/images/build/{id}/scan": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** The scan gate's verdict and findings for a build (admin). */ + get: operations["getBuildScan"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/images/build/{id}/scan/report": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Download a build's full Trivy JSON report (admin). */ + get: operations["getBuildScanReport"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/images/build/{id}/sbom": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Download a build's CycloneDX SBOM (admin). */ + get: operations["getBuildSBOM"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/images": { parameters: { query?: never; @@ -2289,6 +2340,64 @@ export interface components { */ finished_at?: string; }; + /** @description What a build's scan gate kept (GET /api/v1/images/build/{id}/scan): its verdict under the policy it ran with, the listed findings, and which full documents can be downloaded. */ + BuildScan: { + build_id: string; + /** Format: date-time */ + scanned_at: string; + summary: components["schemas"]["ScanSummary"]; + /** @description The full Trivy JSON report is kept (GET .../scan/report). */ + has_report: boolean; + /** @description The CycloneDX SBOM is kept (GET .../sbom). */ + has_sbom: boolean; + }; + /** @description The verdict scan-gate reached on one Trivy report (internal/build ScanSummary). */ + ScanSummary: { + policy: components["schemas"]["ScanPolicy"]; + /** @description A finding blocked the image, so it was never pushed. */ + blocked: boolean; + /** @description Packages Trivy found in the image. */ + packages: number; + /** @description Every finding by severity (CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN); a severity with none is absent. */ + counts: { + [key: string]: number; + }; + /** @description The findings the policy blocks on, by severity. */ + blocking_counts: { + [key: string]: number; + }; + /** @description Blocking findings first, then the rest, most severe first; at most 100. The downloadable report lists all of them. */ + findings: components["schemas"]["ScanFinding"][]; + /** @description Documents (report, sbom) too large to keep with the build. Omitted when none. */ + omitted?: ("report" | "sbom")[]; + }; + /** @description Which findings block an image ([registry] scan_fail_on / scan_fail_unfixed / scan_accept). */ + ScanPolicy: { + fail_on: ("CRITICAL" | "HIGH" | "MEDIUM" | "LOW" | "UNKNOWN")[]; + /** @description A vulnerability with no fixed release blocks too. */ + fail_unfixed: boolean; + /** @description Vulnerability ids and secret rule ids accepted as known risks; findings under them never block. Omitted when none. */ + accept?: string[]; + }; + /** @description One vulnerability or leaked secret (internal/build ScanFinding). */ + ScanFinding: { + /** @description The CVE/GHSA id, or the secret rule id. */ + id: string; + /** @enum {string} */ + kind: "vulnerability" | "secret"; + /** @enum {string} */ + severity: "CRITICAL" | "HIGH" | "MEDIUM" | "LOW" | "UNKNOWN"; + package?: string; + installed?: string; + /** @description The first release that fixes it. Omitted when none exists. */ + fixed?: string; + /** @description The file or layer Trivy found it in. */ + target: string; + title?: string; + blocking: boolean; + /** @description The policy accepts this id, so it never blocks. Omitted when false. */ + accepted?: boolean; + }; /** @description One whitelisted image (internal/build Image). */ Image: { image_ref: string; @@ -7506,6 +7615,108 @@ export interface operations { 503: components["responses"]["ServiceUnavailable"]; }; }; + getBuildScan: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The kept scan. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BuildScan"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + /** @description No scan for this build (scan_not_found) — it has not reached the scan step, or it ran before builds kept their scans. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; + getBuildScanReport: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The Trivy report (SchemaVersion 2), served as the attachment -trivy.json. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + /** @description No scan for this build (scan_not_found), or the report was too large to keep (scan_document_not_kept). */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; + getBuildSBOM: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The CycloneDX JSON SBOM, served as the attachment .cdx.json. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/vnd.cyclonedx+json": Record; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + /** @description No scan for this build (scan_not_found), or the SBOM was too large to keep or its step failed (scan_document_not_kept). */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; listImages: { parameters: { query?: never; diff --git a/panel/src/lib/types.parity.ts b/panel/src/lib/types.parity.ts index 918ae25..792ef7d 100644 --- a/panel/src/lib/types.parity.ts +++ b/panel/src/lib/types.parity.ts @@ -37,6 +37,10 @@ export type WireParity = [ Holds>, Holds>, Holds>, + Holds>, + Holds>, + Holds>, + Holds>, Holds>, Holds>, Holds>, diff --git a/panel/src/lib/types.ts b/panel/src/lib/types.ts index df1ab02..0b557b1 100644 --- a/panel/src/lib/types.ts +++ b/panel/src/lib/types.ts @@ -310,6 +310,53 @@ export interface Build { context_digest?: string; } +export type ScanSeverity = "CRITICAL" | "HIGH" | "MEDIUM" | "LOW" | "UNKNOWN"; + +/** ScanFinding mirrors build.ScanFinding — one vulnerability or leaked secret. */ +export interface ScanFinding { + id: string; + kind: "vulnerability" | "secret"; + severity: ScanSeverity; + package?: string; + installed?: string; + /** The first release that fixes it; absent when none exists. */ + fixed?: string; + target: string; + title?: string; + blocking: boolean; + /** The policy accepts this id, so it never blocks; absent when false. */ + accepted?: boolean; +} + +/** ScanPolicy mirrors build.ScanPolicy ([registry] scan_fail_on / scan_fail_unfixed / scan_accept). */ +export interface ScanPolicy { + fail_on: ScanSeverity[]; + fail_unfixed: boolean; + /** Finding ids accepted as known risks; absent when none. */ + accept?: string[]; +} + +/** ScanSummary mirrors build.ScanSummary — the verdict scan-gate reached. */ +export interface ScanSummary { + policy: ScanPolicy; + blocked: boolean; + packages: number; + counts: Record; + blocking_counts: Record; + /** Blocking first, then most severe first; at most 100. */ + findings: ScanFinding[]; + omitted?: ("report" | "sbom")[]; +} + +/** BuildScan is GET /images/build/{id}/scan. */ +export interface BuildScan { + build_id: string; + scanned_at: string; + summary: ScanSummary; + has_report: boolean; + has_sbom: boolean; +} + export type SubmissionStatus = "pending_review" | "approved" | "rejected"; /** Submission mirrors an image_submissions row (spec §6, migration 0002). */ diff --git a/panel/src/pages/admin/BuildScanPanel.test.tsx b/panel/src/pages/admin/BuildScanPanel.test.tsx new file mode 100644 index 0000000..b1f219a --- /dev/null +++ b/panel/src/pages/admin/BuildScanPanel.test.tsx @@ -0,0 +1,173 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { BuildScanPanel } from "./BuildScanPanel"; +import type { Build, BuildScan } from "@/lib/types"; + +const calls = vi.hoisted(() => ({ + getBuildScan: vi.fn(), + downloadBuildScanDocument: vi.fn(), +})); +vi.mock("@/lib/config", () => ({ loadConfig: () => Promise.resolve({}) })); +vi.mock("@/lib/api", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, api: { ...actual.api, ...calls } }; +}); + +const FAILED: Build = { + id: "bld-7", + image_ref: "registry.felis.svc:5000/user-uploads/s-1:latest", + status: "failed", + requested_by: "owner@example.test", + created_at: "2026-09-20T09:58:00Z", + finished_at: "2026-09-20T10:00:00Z", +}; + +// Two blocking findings (a fixable CRITICAL and a leaked key), a fixable HIGH the +// policy accepts, one unfixed HIGH that only gets listed, and two more findings +// past the listed ones. +const BLOCKED: BuildScan = { + build_id: "bld-7", + scanned_at: "2026-09-20T10:00:00Z", + has_report: true, + has_sbom: false, + summary: { + policy: { fail_on: ["CRITICAL", "HIGH"], fail_unfixed: false, accept: ["CVE-2021-35515"] }, + blocked: true, + packages: 12, + counts: { CRITICAL: 2, HIGH: 2, MEDIUM: 2 }, + blocking_counts: { CRITICAL: 2 }, + findings: [ + { id: "CVE-2024-0001", kind: "vulnerability", severity: "CRITICAL", package: "log4j-core", + installed: "2.14.1", fixed: "2.17.1", target: "mods/core.jar", blocking: true }, + { id: "aws-access-key-id", kind: "secret", severity: "CRITICAL", target: "config/keys.txt", + title: "AWS Access Key ID", blocking: true }, + { id: "CVE-2021-35515", kind: "vulnerability", severity: "HIGH", package: "org.apache.commons:commons-compress", + installed: "1.5", fixed: "1.21", target: "paper/paper.jar", blocking: false, accepted: true }, + { id: "CVE-2024-0002", kind: "vulnerability", severity: "HIGH", package: "openssl", + installed: "3.0.13", target: "usr/lib/libssl.so.3", blocking: false }, + ], + }, +}; + +const CLEAN: BuildScan = { + build_id: "bld-8", + scanned_at: "2026-09-20T10:00:00Z", + has_report: true, + has_sbom: true, + summary: { + policy: { fail_on: ["HIGH"], fail_unfixed: true }, + blocked: false, + packages: 1, + counts: {}, + blocking_counts: {}, + findings: [], + }, +}; + +beforeEach(() => { + for (const fn of Object.values(calls)) fn.mockReset(); +}); +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("BuildScanPanel", () => { + it("shows what blocked the image, the listed findings, and how many more the report holds", async () => { + calls.getBuildScan.mockResolvedValue(BLOCKED); + render(); + const panel = await screen.findByRole("region", { name: "Security scan" }); + expect(calls.getBuildScan).toHaveBeenCalledWith("bld-7"); + expect(within(panel).getByText("Blocked")).toBeTruthy(); + expect(panel.textContent).toContain( + "· 12 packages · Blocks on CRITICAL, HIGH; vulnerabilities with no fixed release are listed only · 1 ID accepted as a known risk", + ); + expect(within(panel).getByText("1 ID accepted as a known risk").getAttribute("title")).toBe("CVE-2021-35515"); + + const chips = within(within(panel).getByRole("list", { name: "Findings by severity" })).getAllByRole("listitem"); + expect(chips.map((c) => c.textContent)).toEqual(["CRITICAL2", "HIGH2", "MEDIUM2", "LOW0", "UNKNOWN0"]); + expect(chips[0].getAttribute("title")).toBe("2 block the image"); + expect(chips[1].getAttribute("title")).toBeNull(); + + const cve = within(panel).getByText("CVE-2024-0001").closest("li")!; + expect(cve.textContent).toBe("CVE-2024-0001BlocksCRITICALlog4j-core2.14.1 → 2.17.1mods/core.jar"); + const secret = within(panel).getByText("aws-access-key-id").closest("li")!; + expect(secret.textContent).toBe("aws-access-key-idBlocksCRITICALSecret: AWS Access Key ID—config/keys.txt"); + const accepted = within(panel).getByText("CVE-2021-35515").closest("li")!; + expect(accepted.textContent).toBe("CVE-2021-35515AcceptedHIGHorg.apache.commons:commons-compress1.5 → 1.21paper/paper.jar"); + expect(within(accepted).getByText("Accepted").getAttribute("title")).toBe( + "Listed in [registry] scan_accept as a known risk, so it never blocks.", + ); + const unfixed = within(panel).getByText("CVE-2024-0002").closest("li")!; + expect(unfixed.textContent).toBe("CVE-2024-0002HIGHopenssl3.0.13 → no fix yetusr/lib/libssl.so.3"); + expect(within(panel).getByText("Showing 4 of 6 findings. The full report lists every one.")).toBeTruthy(); + }); + + it("downloads the report it kept and explains the SBOM it did not", async () => { + calls.getBuildScan.mockResolvedValue(BLOCKED); + calls.downloadBuildScanDocument.mockResolvedValue(undefined); + render(); + const panel = await screen.findByRole("region", { name: "Security scan" }); + const sbom = within(panel).getByRole("button", { name: "SBOM" }) as HTMLButtonElement; + expect(sbom.disabled).toBe(true); + expect(sbom.getAttribute("title")).toBe( + "Not kept with this build: the file was too large, or the step that writes it failed.", + ); + await userEvent.click(within(panel).getByRole("button", { name: "Trivy report" })); + expect(calls.downloadBuildScanDocument).toHaveBeenCalledWith("bld-7", "report"); + + calls.downloadBuildScanDocument.mockRejectedValue({ + status: 404, + code: "scan_document_not_kept", + message: "this build's scan kept no report: it was too large to keep, or the step that writes it failed", + }); + await userEvent.click(within(panel).getByRole("button", { name: "Trivy report" })); + expect((await within(panel).findByRole("alert")).textContent).toBe( + "Couldn't download: this build's scan kept no report: it was too large to keep, or the step that writes it failed", + ); + }); + + it("says a clean scan found nothing and names the stricter policy it ran under", async () => { + calls.getBuildScan.mockResolvedValue(CLEAN); + render(); + const panel = await screen.findByRole("region", { name: "Security scan" }); + expect(within(panel).getByText("Passed")).toBeTruthy(); + expect(panel.textContent).toContain("· 1 package · Blocks on HIGH, including vulnerabilities with no fixed release"); + expect(panel.textContent).not.toContain("accepted"); + expect(within(panel).getByText("Trivy found nothing to report in this image.")).toBeTruthy(); + expect(within(panel).queryByText("Found in")).toBeNull(); + calls.downloadBuildScanDocument.mockResolvedValue(undefined); + await userEvent.click(within(panel).getByRole("button", { name: "SBOM" })); + expect(calls.downloadBuildScanDocument).toHaveBeenCalledWith("bld-8", "sbom"); + }); + + it("says when a build kept no scan, and retries a scan that failed to load", async () => { + calls.getBuildScan.mockRejectedValue({ status: 404, code: "scan_not_found", message: "this build has no scan" }); + const { unmount } = render(); + expect( + await screen.findByText( + "No security scan was kept for this build. It stopped before the scan step, or it ran before builds kept their scans.", + ), + ).toBeTruthy(); + unmount(); + + calls.getBuildScan.mockRejectedValueOnce({ status: 500, code: "internal", message: "database is away" }); + calls.getBuildScan.mockResolvedValueOnce(BLOCKED); + render(); + expect((await screen.findByRole("alert")).textContent).toBe( + "Couldn't load the security scan: The service is unavailable right now (it may be restarting or upgrading). Try again shortly.", + ); + await userEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(await screen.findByRole("region", { name: "Security scan" })).toBeTruthy(); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it("asks nothing for a build that is still running or was cancelled", () => { + const { container, rerender } = render(); + rerender(); + rerender(); + expect(container.textContent).toBe(""); + expect(calls.getBuildScan).not.toHaveBeenCalled(); + }); +}); diff --git a/panel/src/pages/admin/BuildScanPanel.tsx b/panel/src/pages/admin/BuildScanPanel.tsx new file mode 100644 index 0000000..5863066 --- /dev/null +++ b/panel/src/pages/admin/BuildScanPanel.tsx @@ -0,0 +1,259 @@ +import { useCallback, useState } from "react"; +import { Download, ShieldAlert, ShieldCheck } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { MessageLine } from "@/components/MessageLine"; +import { api, humanizeError } from "@/lib/api"; +import { formatAbsolute, formatRelative } from "@/lib/format"; +import { useAsync } from "@/lib/hooks"; +import { cn } from "@/lib/utils"; +import type { Build, BuildScan, ScanFinding, ScanSeverity } from "@/lib/types"; + +const SEVERITIES: ScanSeverity[] = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN"]; + +const SEVERITY_STYLE: Record = { + CRITICAL: "bg-rose-500/10 text-rose-500 border-rose-500/25", + HIGH: "bg-orange-500/10 text-orange-500 border-orange-500/25", + MEDIUM: "bg-amber-500/10 text-amber-500 border-amber-500/25", + LOW: "bg-sky-500/10 text-sky-500 border-sky-500/25", + UNKNOWN: "bg-zinc-500/10 text-zinc-400 border-zinc-500/25", +}; + +const GRID = "md:grid md:grid-cols-[minmax(0,10rem)_5.5rem_minmax(0,1fr)_minmax(0,11rem)_minmax(0,1fr)] md:gap-3"; + +/** The scan a finished build's scan gate kept: its verdict under the policy it + * ran with, the findings, and the full Trivy report and SBOM to download. A + * build still running or cancelled has none, so nothing is fetched for it. */ +export function BuildScanPanel({ build }: { build: Build }) { + const scanned = build.status === "succeeded" || build.status === "failed"; + if (!scanned) return null; + return ; +} + +function ScanView({ buildId }: { buildId: string }) { + const { t } = useTranslation("admin"); + const load = useCallback(() => api.getBuildScan(buildId), [buildId]); + const { data: scan, error, loading, reload } = useAsync(load, [load]); + + if (scan === null && loading) { + return

{t("scan_loading")}

; + } + if (scan === null && error) { + if ((error as { code?: string }).code === "scan_not_found") { + return ( +

+ {t("scan_none")} +

+ ); + } + return ( +
+ + +
+ ); + } + if (scan === null) return null; + return ; +} + +function ScanReport({ scan }: { scan: BuildScan }) { + const { t, i18n } = useTranslation("admin"); + const locale = i18n.language; + const [downloadError, setDownloadError] = useState(null); + const [downloading, setDownloading] = useState<"report" | "sbom" | null>(null); + const s = scan.summary; + const total = SEVERITIES.reduce((n, sev) => n + (s.counts[sev] ?? 0), 0); + const Icon = s.blocked ? ShieldAlert : ShieldCheck; + + const download = async (doc: "report" | "sbom") => { + setDownloading(doc); + setDownloadError(null); + try { + await api.downloadBuildScanDocument(scan.build_id, doc); + } catch (e) { + setDownloadError(t("scan_download_failed", { reason: humanizeError(e) })); + } finally { + setDownloading(null); + } + }; + + return ( +
+
+ +
+
+

{t("scan_title")}

+ + {s.blocked ? t("scan_blocked") : t("scan_passed")} + +
+

+ + {t("scan_meta", { when: formatRelative(scan.scanned_at, Date.now(), locale), count: s.packages })} + + {" · "} + {t(s.policy.fail_unfixed ? "scan_policy_unfixed" : "scan_policy", { + severities: s.policy.fail_on.join(", "), + })} + {s.policy.accept && s.policy.accept.length > 0 && ( + <> + {" · "} + + {t("scan_policy_accepts", { count: s.policy.accept.length })} + + + )} +

+
+ {/* Narrow screens: the downloads take their own line under the text. */} +
+ download("report")} + /> + download("sbom")} + /> +
+
+ +
    + {SEVERITIES.map((sev) => { + const n = s.counts[sev] ?? 0; + const blocking = s.blocking_counts[sev] ?? 0; + return ( +
  • 0 ? t("scan_blocking_count", { count: blocking }) : undefined} + className={cn( + "flex items-center gap-1.5 rounded border px-2 py-0.5 font-mono text-[10px] font-semibold", + n > 0 ? SEVERITY_STYLE[sev] : "border-border text-muted-foreground/60", + blocking > 0 && "ring-1 ring-current", + )} + > + {sev} + {n} +
  • + ); + })} +
+ + {downloadError && } + + {total === 0 ? ( +

{t("scan_clean")}

+ ) : ( +
+
+
{t("scan_col_id")}
+
{t("scan_col_severity")}
+
{t("scan_col_package")}
+
{t("scan_col_version")}
+
{t("scan_col_target")}
+
+
    + {s.findings.map((f, i) => ( + + ))} +
+ {total > s.findings.length && ( +

+ {t("scan_showing", { shown: s.findings.length, total })} +

+ )} +
+ )} +
+ ); +} + +function FindingRow({ finding: f }: { finding: ScanFinding }) { + const { t } = useTranslation("admin"); + const secret = f.kind === "secret"; + return ( +
  • +
    + {f.id} + {f.blocking && ( + + {t("scan_blocks")} + + )} + {f.accepted && ( + + {t("scan_accepted")} + + )} +
    +
    + + {f.severity} + +
    +
    + {secret ? t("scan_secret", { title: f.title ?? f.id }) : {f.package}} +
    +
    + {secret ? "—" : ( + <> + {f.installed} + {" → "} + {f.fixed ? {f.fixed} : {t("scan_no_fix")}} + + )} +
    +
    + {f.target} +
    +
  • + ); +} + +function DownloadButton({ label, kept, busy, onClick }: { + label: string; + kept: boolean; + busy: boolean; + onClick: () => void; +}) { + const { t } = useTranslation("admin"); + return ( + + ); +} diff --git a/panel/src/pages/admin/ImageBuildPage.tsx b/panel/src/pages/admin/ImageBuildPage.tsx index 4d0b85d..20d8167 100644 --- a/panel/src/pages/admin/ImageBuildPage.tsx +++ b/panel/src/pages/admin/ImageBuildPage.tsx @@ -28,6 +28,7 @@ import { Pagination } from "@/components/Pagination"; import { api, buildLogsStreamURL, humanizeError } from "@/lib/api"; import { formatRelative, formatAbsolute } from "@/lib/format"; import { useAsync, useConfig } from "@/lib/hooks"; +import { BuildScanPanel } from "./BuildScanPanel"; import { useTier } from "@/lib/tier"; import type { Build, BuildStatus, Submission } from "@/lib/types"; @@ -522,11 +523,14 @@ export function ImageBuildPage() { - {/* Inline SSE Log Console */} + {/* The scan gate's verdict, then the inline SSE log console */} {activeLogBuildId === b.id && ( -
    - -
    + <> + +
    + +
    + )} ))}