feat(build): 扫描门按可配严重度拦截并可接受已知风险,留存 Trivy 报告与 CycloneDX SBOM,面板构建页展示扫描结果
This commit is contained in:
37 files changed
+3550
-64
No files matched your search
@@ -175,6 +175,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
||||
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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 <id>-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 <id>.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]
|
||||
|
||||
+60
-6
@@ -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/<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 `<id>-trivy.json` |
|
||||
| `GET /api/v1/images/build/{id}/sbom` | the CycloneDX SBOM as `<id>.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` |
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{},
|
||||
|
||||
+144
-15
@@ -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.)
|
||||
|
||||
@@ -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
|
||||
|
||||
+98
-16
@@ -27,15 +27,19 @@ 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"
|
||||
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.
|
||||
@@ -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,6 +145,11 @@ type JobParams struct {
|
||||
TrivyJavaDBRepository string
|
||||
KanikoImage string
|
||||
TrivyImage 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
|
||||
@@ -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}
|
||||
@@ -308,6 +342,7 @@ func BuildJob(p JobParams) (*batchv1.Job, error) {
|
||||
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})
|
||||
@@ -345,14 +380,18 @@ func BuildJob(p JobParams) (*batchv1.Job, error) {
|
||||
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}},
|
||||
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
|
||||
@@ -404,6 +485,7 @@ func BuildJob(p JobParams) (*batchv1.Job, error) {
|
||||
VolumeMounts: []corev1.VolumeMount{{Name: imageVolume, MountPath: imageMountPath, ReadOnly: true}},
|
||||
Resources: withDisk(limits, pushDisk),
|
||||
SecurityContext: pushSec,
|
||||
TerminationMessagePolicy: corev1.TerminationMessageFallbackToLogsOnError,
|
||||
}
|
||||
|
||||
job := &batchv1.Job{
|
||||
|
||||
+108
-17
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: "[email protected]", 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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: <url>/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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
+91
-1
@@ -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: "[email protected]",
|
||||
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<boolean> {
|
||||
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)) {
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
@@ -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}}"
|
||||
}
|
||||
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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);
|
||||
|
||||
@@ -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<Build>("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<BuildScan>("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",
|
||||
|
||||
@@ -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 <id>-trivy.json. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": Record<string, never>;
|
||||
};
|
||||
};
|
||||
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 <id>.cdx.json. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/vnd.cyclonedx+json": Record<string, never>;
|
||||
};
|
||||
};
|
||||
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;
|
||||
|
||||
@@ -37,6 +37,10 @@ export type WireParity = [
|
||||
Holds<Parity<T.FleetServer, S["FleetServer"]>>,
|
||||
Holds<Parity<T.BackupView, S["BackupView"]>>,
|
||||
Holds<Parity<T.Build, S["Build"]>>,
|
||||
Holds<Parity<T.BuildScan, S["BuildScan"]>>,
|
||||
Holds<Parity<T.ScanSummary, S["ScanSummary"]>>,
|
||||
Holds<Parity<T.ScanPolicy, S["ScanPolicy"]>>,
|
||||
Holds<Parity<T.ScanFinding, S["ScanFinding"]>>,
|
||||
Holds<Parity<T.WhitelistImage, S["Image"]>>,
|
||||
Holds<Parity<T.Submission, S["Submission"]>>,
|
||||
Holds<Parity<T.UserView, S["UserView"]>>,
|
||||
|
||||
@@ -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<string, number>;
|
||||
blocking_counts: Record<string, number>;
|
||||
/** 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). */
|
||||
|
||||
@@ -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<typeof import("@/lib/api")>();
|
||||
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: "[email protected]",
|
||||
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(<BuildScanPanel build={FAILED} />);
|
||||
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(<BuildScanPanel build={FAILED} />);
|
||||
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(<BuildScanPanel build={{ ...FAILED, id: "bld-8", status: "succeeded" }} />);
|
||||
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(<BuildScanPanel build={FAILED} />);
|
||||
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(<BuildScanPanel build={FAILED} />);
|
||||
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(<BuildScanPanel build={{ ...FAILED, status: "building" }} />);
|
||||
rerender(<BuildScanPanel build={{ ...FAILED, status: "cancelled" }} />);
|
||||
rerender(<BuildScanPanel build={{ ...FAILED, status: "pending" }} />);
|
||||
expect(container.textContent).toBe("");
|
||||
expect(calls.getBuildScan).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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<ScanSeverity, string> = {
|
||||
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 <ScanView buildId={build.id} />;
|
||||
}
|
||||
|
||||
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 <p className="mt-3 text-[11px] text-muted-foreground">{t("scan_loading")}</p>;
|
||||
}
|
||||
if (scan === null && error) {
|
||||
if ((error as { code?: string }).code === "scan_not_found") {
|
||||
return (
|
||||
<p className="mt-3 rounded-md border border-dashed border-border px-3 py-2 text-[11px] text-muted-foreground">
|
||||
{t("scan_none")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<MessageLine kind="error" message={t("scan_load_failed", { reason: humanizeError(error) })} className="flex-1" />
|
||||
<Button variant="outline" size="sm" onClick={reload}>
|
||||
{t("common:try_again")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (scan === null) return null;
|
||||
return <ScanReport scan={scan} />;
|
||||
}
|
||||
|
||||
function ScanReport({ scan }: { scan: BuildScan }) {
|
||||
const { t, i18n } = useTranslation("admin");
|
||||
const locale = i18n.language;
|
||||
const [downloadError, setDownloadError] = useState<string | null>(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 (
|
||||
<section
|
||||
aria-label={t("scan_title")}
|
||||
className={cn(
|
||||
"mt-3 rounded-lg border p-3 space-y-3",
|
||||
s.blocked ? "border-rose-500/30 bg-rose-500/[0.03]" : "border-emerald-500/25 bg-emerald-500/[0.03]",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-start gap-x-3 gap-y-2">
|
||||
<Icon className={cn("h-4 w-4 mt-0.5 shrink-0", s.blocked ? "text-rose-500" : "text-emerald-500")} />
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-xs font-semibold text-foreground">{t("scan_title")}</h3>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"px-1.5 py-0 text-[10px] font-semibold uppercase tracking-wider",
|
||||
s.blocked
|
||||
? "bg-rose-500/10 text-rose-500 border-rose-500/20"
|
||||
: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20",
|
||||
)}
|
||||
>
|
||||
{s.blocked ? t("scan_blocked") : t("scan_passed")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
<span title={formatAbsolute(scan.scanned_at, locale)}>
|
||||
{t("scan_meta", { when: formatRelative(scan.scanned_at, Date.now(), locale), count: s.packages })}
|
||||
</span>
|
||||
{" · "}
|
||||
{t(s.policy.fail_unfixed ? "scan_policy_unfixed" : "scan_policy", {
|
||||
severities: s.policy.fail_on.join(", "),
|
||||
})}
|
||||
{s.policy.accept && s.policy.accept.length > 0 && (
|
||||
<>
|
||||
{" · "}
|
||||
<span title={s.policy.accept.join(", ")} className="underline decoration-dotted underline-offset-2">
|
||||
{t("scan_policy_accepts", { count: s.policy.accept.length })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{/* Narrow screens: the downloads take their own line under the text. */}
|
||||
<div className="flex w-full flex-wrap items-center gap-1.5 pl-7 sm:w-auto sm:pl-0">
|
||||
<DownloadButton
|
||||
label={t("scan_download_report")}
|
||||
kept={scan.has_report}
|
||||
busy={downloading === "report"}
|
||||
onClick={() => download("report")}
|
||||
/>
|
||||
<DownloadButton
|
||||
label={t("scan_download_sbom")}
|
||||
kept={scan.has_sbom}
|
||||
busy={downloading === "sbom"}
|
||||
onClick={() => download("sbom")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-wrap gap-1.5" aria-label={t("scan_counts_label")}>
|
||||
{SEVERITIES.map((sev) => {
|
||||
const n = s.counts[sev] ?? 0;
|
||||
const blocking = s.blocking_counts[sev] ?? 0;
|
||||
return (
|
||||
<li
|
||||
key={sev}
|
||||
title={blocking > 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",
|
||||
)}
|
||||
>
|
||||
<span>{sev}</span>
|
||||
<span>{n}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{downloadError && <MessageLine kind="error" message={downloadError} compact />}
|
||||
|
||||
{total === 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground">{t("scan_clean")}</p>
|
||||
) : (
|
||||
<div className="rounded-md border border-border bg-background/60">
|
||||
<div className={cn("hidden px-3 py-2 text-[10px] font-semibold text-muted-foreground border-b", GRID)}>
|
||||
<div>{t("scan_col_id")}</div>
|
||||
<div>{t("scan_col_severity")}</div>
|
||||
<div>{t("scan_col_package")}</div>
|
||||
<div>{t("scan_col_version")}</div>
|
||||
<div>{t("scan_col_target")}</div>
|
||||
</div>
|
||||
<ul className="max-h-72 overflow-y-auto divide-y divide-border">
|
||||
{s.findings.map((f, i) => (
|
||||
<FindingRow key={`${f.id}-${f.package ?? ""}-${f.target}-${i}`} finding={f} />
|
||||
))}
|
||||
</ul>
|
||||
{total > s.findings.length && (
|
||||
<p className="border-t border-border px-3 py-2 text-[10px] text-muted-foreground">
|
||||
{t("scan_showing", { shown: s.findings.length, total })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FindingRow({ finding: f }: { finding: ScanFinding }) {
|
||||
const { t } = useTranslation("admin");
|
||||
const secret = f.kind === "secret";
|
||||
return (
|
||||
<li className={cn("px-3 py-2 text-[11px] flex flex-wrap items-center gap-x-3 gap-y-1", GRID, f.blocking && "bg-rose-500/[0.04]")}>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="font-mono font-medium text-foreground select-all truncate" title={f.id}>{f.id}</span>
|
||||
{f.blocking && (
|
||||
<span className="shrink-0 rounded bg-rose-500/15 px-1 text-[9px] font-bold uppercase tracking-wide text-rose-500">
|
||||
{t("scan_blocks")}
|
||||
</span>
|
||||
)}
|
||||
{f.accepted && (
|
||||
<span
|
||||
title={t("scan_accepted_title")}
|
||||
className="shrink-0 rounded bg-zinc-500/15 px-1 text-[9px] font-bold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{t("scan_accepted")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className={cn("rounded border px-1.5 py-px font-mono text-[9px] font-semibold", SEVERITY_STYLE[f.severity])}>
|
||||
{f.severity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full min-w-0 truncate md:w-auto" title={secret ? f.title : f.package}>
|
||||
{secret ? t("scan_secret", { title: f.title ?? f.id }) : <span className="font-mono">{f.package}</span>}
|
||||
</div>
|
||||
<div className="min-w-0 truncate font-mono text-muted-foreground">
|
||||
{secret ? "—" : (
|
||||
<>
|
||||
{f.installed}
|
||||
{" → "}
|
||||
{f.fixed ? <span className="text-emerald-500">{f.fixed}</span> : <span className="italic">{t("scan_no_fix")}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full min-w-0 truncate font-mono text-[10px] text-muted-foreground md:w-auto" title={f.target}>
|
||||
{f.target}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadButton({ label, kept, busy, onClick }: {
|
||||
label: string;
|
||||
kept: boolean;
|
||||
busy: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("admin");
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-[11px]"
|
||||
disabled={!kept || busy}
|
||||
title={kept ? undefined : t("scan_not_kept")}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline SSE Log Console */}
|
||||
{/* The scan gate's verdict, then the inline SSE log console */}
|
||||
{activeLogBuildId === b.id && (
|
||||
<>
|
||||
<BuildScanPanel build={b} />
|
||||
<div className="mt-3 h-[300px] flex flex-col">
|
||||
<LogConsole url={buildLogsStreamURL(config.apiBase, b.id)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
|
||||
Reference in new issue
Block a user