Files
claude-code c30be8b441
werkator build succeeded after 00:06
werkator/vulnerability-data build succeeded after 00:03
The shared agentic workflow, and the required checks for Go (0.12.1) (#15)
Co-authored-by: claude-code <claude-code@javagil.de>
2026-10-20 15:01:21 +02:00

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()
}