222 lines
6.3 KiB
Go
222 lines
6.3 KiB
Go
// Command vulncheck runs govulncheck over the module and fails on each
|
|
// vulnerability that the code calls, the required check "Vulnerabilities"
|
|
// of docs/agentic-workflow.md, with the levels of docs/risk-analysis.md.
|
|
//
|
|
// With -base it fails only on those the branch brings: called at HEAD,
|
|
// not called at the merge base with that ref. So a vulnerability found
|
|
// anew in the standard library of main stops no unrelated branch; the
|
|
// nightly build checks main in full.
|
|
//
|
|
// go run ./tools/vulncheck # main, in full
|
|
// go run ./tools/vulncheck -base origin/main # a branch, only its own
|
|
//
|
|
// It is a development tool, not part of the werkdock binary.
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// The Go toolchain of the module downloads it on first use; it runs on a
|
|
// newer Go than the module's, but checks the standard library of the
|
|
// module's own toolchain.
|
|
const govulncheck = "golang.org/x/vuln/cmd/govulncheck@v1.8.0"
|
|
|
|
const exceptionsFile = "tools/vulnerability-exceptions.txt"
|
|
|
|
// vuln is one OSV entry as far as the verdict needs it.
|
|
// Calls holds each vulnerable symbol that the code calls, as
|
|
// "module@version package.function", so that a branch that calls another
|
|
// symbol of the same advisory, or moves to another affected version, is
|
|
// not mistaken for its merge base.
|
|
type vuln struct {
|
|
ID string
|
|
Summary string
|
|
Fixed string
|
|
Calls map[string]bool
|
|
}
|
|
|
|
func main() {
|
|
base := flag.String("base", "", "ref whose merge base with HEAD is the baseline; empty checks in full")
|
|
flag.Parse()
|
|
if err := run(*base, os.Stdout); err != nil {
|
|
fmt.Fprintln(os.Stderr, "vulncheck:", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run(base string, out io.Writer) error {
|
|
exceptions, err := readExceptions(exceptionsFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
head, err := scan(".")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var baseline map[string]vuln
|
|
if base != "" {
|
|
if baseline, err = scanMergeBase(base); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
failing := verdict(head, baseline, exceptions)
|
|
for _, v := range failing {
|
|
fmt.Fprintf(out, "%s: %s; fixed in %s; https://pkg.go.dev/vuln/%s\n", v.ID, v.Summary, v.Fixed, v.ID)
|
|
}
|
|
if len(failing) > 0 {
|
|
return fmt.Errorf("%d called vulnerabilities; fix them, or add an exception with its reason to %s",
|
|
len(failing), exceptionsFile)
|
|
}
|
|
fmt.Fprintln(out, "vulncheck: no called vulnerability")
|
|
return nil
|
|
}
|
|
|
|
// verdict returns the called vulnerabilities of head without an exception,
|
|
// and with a baseline only those with a call that the baseline lacks,
|
|
// sorted by id.
|
|
func verdict(head, baseline map[string]vuln, exceptions map[string]string) []vuln {
|
|
var failing []vuln
|
|
for id, v := range head {
|
|
if len(v.Calls) == 0 || exceptions[id] != "" {
|
|
continue
|
|
}
|
|
if baseline != nil && !bringsCall(v, baseline[id]) {
|
|
continue
|
|
}
|
|
failing = append(failing, v)
|
|
}
|
|
sort.Slice(failing, func(i, j int) bool { return failing[i].ID < failing[j].ID })
|
|
return failing
|
|
}
|
|
|
|
func bringsCall(head, base vuln) bool {
|
|
for call := range head.Calls {
|
|
if !base.Calls[call] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func scan(dir string) (map[string]vuln, error) {
|
|
cmd := exec.Command("go", "run", govulncheck, "-format", "json", "./...")
|
|
cmd.Dir = dir
|
|
cmd.Stderr = os.Stderr
|
|
stdout, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("govulncheck in %s: %w", dir, err)
|
|
}
|
|
return parse(bytes.NewReader(stdout))
|
|
}
|
|
|
|
// scanMergeBase scans an export of the merge base, which writes nothing into the repository.
|
|
func scanMergeBase(ref string) (map[string]vuln, error) {
|
|
mergeBase, err := exec.Command("git", "merge-base", "HEAD", ref).Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("merge base with %s: %w", ref, err)
|
|
}
|
|
dir, err := os.MkdirTemp("", "vulncheck-")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer os.RemoveAll(dir)
|
|
export := exec.Command("sh", "-c", `git archive "$1" | tar -x -C "$2"`, "sh", strings.TrimSpace(string(mergeBase)), dir)
|
|
if out, err := export.CombinedOutput(); err != nil {
|
|
return nil, fmt.Errorf("export of the merge base: %w: %s", err, out)
|
|
}
|
|
return scan(dir)
|
|
}
|
|
|
|
// parse reads govulncheck's JSON stream: one "osv" object per entry, and
|
|
// "finding" objects whose first trace frame names a function when the
|
|
// code calls the vulnerable symbol.
|
|
func parse(r io.Reader) (map[string]vuln, error) {
|
|
type frame struct {
|
|
Module string `json:"module"`
|
|
Version string `json:"version"`
|
|
Package string `json:"package"`
|
|
Function string `json:"function"`
|
|
}
|
|
var msg struct {
|
|
OSV *struct {
|
|
ID string `json:"id"`
|
|
Summary string `json:"summary"`
|
|
} `json:"osv"`
|
|
Finding *struct {
|
|
OSV string `json:"osv"`
|
|
FixedVersion string `json:"fixed_version"`
|
|
Trace []frame `json:"trace"`
|
|
} `json:"finding"`
|
|
}
|
|
vulns := map[string]vuln{}
|
|
dec := json.NewDecoder(r)
|
|
for {
|
|
msg.OSV, msg.Finding = nil, nil
|
|
if err := dec.Decode(&msg); errors.Is(err, io.EOF) {
|
|
return vulns, nil
|
|
} else if err != nil {
|
|
return nil, fmt.Errorf("govulncheck output: %w", err)
|
|
}
|
|
if o := msg.OSV; o != nil {
|
|
v := vulns[o.ID]
|
|
v.ID, v.Summary = o.ID, o.Summary
|
|
vulns[o.ID] = v
|
|
}
|
|
if f := msg.Finding; f != nil {
|
|
v := vulns[f.OSV]
|
|
v.ID = f.OSV
|
|
if f.FixedVersion != "" {
|
|
v.Fixed = f.FixedVersion
|
|
}
|
|
if len(f.Trace) > 0 && f.Trace[0].Function != "" {
|
|
t := f.Trace[0]
|
|
if v.Calls == nil {
|
|
v.Calls = map[string]bool{}
|
|
}
|
|
v.Calls[t.Module+"@"+t.Version+" "+t.Package+"."+t.Function] = true
|
|
}
|
|
vulns[f.OSV] = v
|
|
}
|
|
}
|
|
}
|
|
|
|
// readExceptions reads "ID reason" lines; '#' starts a comment.
|
|
// An exception without a reason is an error, since the reason is what the
|
|
// human approves.
|
|
func readExceptions(path string) (map[string]string, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
return parseExceptions(f)
|
|
}
|
|
|
|
func parseExceptions(r io.Reader) (map[string]string, error) {
|
|
exceptions := map[string]string{}
|
|
sc := bufio.NewScanner(r)
|
|
for n := 1; sc.Scan(); n++ {
|
|
line := strings.TrimSpace(sc.Text())
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
id, reason, _ := strings.Cut(line, " ")
|
|
if strings.TrimSpace(reason) == "" {
|
|
return nil, fmt.Errorf("%s:%d: exception %s without a reason", exceptionsFile, n, id)
|
|
}
|
|
exceptions[id] = strings.TrimSpace(reason)
|
|
}
|
|
return exceptions, sc.Err()
|
|
}
|