feat(build): 扫描门按可配严重度拦截并可接受已知风险,留存 Trivy 报告与 CycloneDX SBOM,面板构建页展示扫描结果
This commit is contained in:
37 files changed
+3577
-91
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user