453 lines
14 KiB
Go
453 lines
14 KiB
Go
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)
|
||
}
|