diff --git a/.github/workflows/osv.yml b/.github/workflows/osv.yml index 2b7adce..49051e3 100644 --- a/.github/workflows/osv.yml +++ b/.github/workflows/osv.yml @@ -1,7 +1,7 @@ name: OSV # Every day, what OSV knows against the newest release's SBOM (task osv), and an issue for each -# finding nobody has been told of yet. A release's libraries are a commit's, and a vulnerability is +# package with findings, closed once a release has none. A release's libraries are a commit's, and a vulnerability is # published after the commit: nothing in a build can find it, only asking again later can. on: @@ -42,18 +42,9 @@ jobs: GH_TOKEN: ${{ github.token }} run: task osv - # One issue a finding, by its title, ever: one closed - fixed, or judged not to matter - is - # not opened again, and the same finding in the next release is the same issue. - - name: Open an issue for each new finding + # One issue a package, which a release closes once it has no finding in it; hack/osvissues + # says why a package and not a finding. + - name: Bring the issues to the findings env: GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - known=$(gh issue list --state all --limit 1000 --json title -q '.[].title') - while IFS=$'\t' read -r id pkg summary; do - title="osv: ${id} in ${pkg}" - if printf '%s\n' "$known" | grep -Fxq -- "$title"; then - continue - fi - gh issue create --title "$title" --body "$(printf '%s\n\n%s is in the newest release'"'"'s SBOM (task osv). https://osv.dev/vulnerability/%s' "$summary" "$pkg" "$id")" - done < _output/osv.tsv + run: go run ./hack/osvissues -release "$(gh release view --json tagName -q .tagName)" -findings _output/osv.tsv diff --git a/Taskfile.yml b/Taskfile.yml index 431dc0c..7ae56b0 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -292,7 +292,8 @@ tasks: desc: >- What OSV knows against the newest release's SBOM, once its signature is the release's: one line a finding, id, package@version and summary, in _output/osv.tsv. Needs gh, cosign and - the network; osv.yml runs it daily and opens an issue for a finding it has not opened before. + the network; osv.yml runs it daily and keeps an issue for each package with findings + (hack/osvissues). # The newest release, because it is what anything takes next; an older one still pinned is # its consumer's to scan, which spin's control plane does for what it was installed with. cmds: diff --git a/hack/osvissues/main.go b/hack/osvissues/main.go new file mode 100644 index 0000000..454a1d9 --- /dev/null +++ b/hack/osvissues/main.go @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Command osvissues keeps one issue per package of the base image for what OSV knows against the +// newest release (task osv): it opens one for a package that has findings, rewrites its list as +// the findings change, and closes it once the release has none in that package. +// +// One issue a package and not a finding, because a package is what gets fixed: Ubuntu ships one +// version of curl that answers a notice and the nineteen CVEs it names, and a new release closes +// all of them at once. An issue per finding made nineteen issues of one fix, and a notice and the +// CVEs it covers two issues of one finding. +// +// A closed issue is an answer - fixed, or judged not to matter - and stays closed while its +// package has only the findings it listed. A finding it did not list reopens it. +// +// go run ./hack/osvissues -release v20261014.01 -findings _output/osv.tsv +package main + +import ( + "bufio" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "slices" + "strings" +) + +func main() { + os.Exit(run(os.Args[1:], gh, os.Stdout, os.Stderr)) // mutate-exempt: os.Args[0] is the program; only a process run by hand sees which of the rest run is given +} + +// run plans against the repository's issues and applies the plan through cli: 0 when every +// action was taken, 2 for a command line it cannot use, 1 when reading or acting failed. +func run(args []string, cli func(args ...string) ([]byte, error), stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("osvissues", flag.ContinueOnError) + fs.SetOutput(stderr) + release := fs.String("release", "", "the release the findings are of") + findingsPath := fs.String("findings", "", "task osv's findings: id, package@version and summary, a tab between them") + dryRun := fs.Bool("n", false, "say what would be done, and do nothing") + if err := fs.Parse(args); err != nil { + return 2 + } + if *release == "" || *findingsPath == "" { + fmt.Fprintln(stderr, "osvissues: -release and -findings are required") + return 2 + } + f, err := os.Open(*findingsPath) + if err != nil { + fmt.Fprintln(stderr, "osvissues:", err) + return 1 + } + defer f.Close() + findings, err := readFindings(f) + if err != nil { + fmt.Fprintln(stderr, "osvissues:", err) + return 1 + } + // Every issue and not a search: the search index lags the issues by minutes, and an issue + // it has not seen yet is one opened again. + out, err := cli("issue", "list", "--state", "all", "--limit", "1000", "--json", "number,title,state,body") + if err != nil { + fmt.Fprintln(stderr, "osvissues: listing issues:", err) + return 1 + } + var issues []issue + if err := json.Unmarshal(out, &issues); err != nil { + fmt.Fprintln(stderr, "osvissues: listing issues:", err) + return 1 + } + for _, a := range plan(*release, findings, issues) { + fmt.Fprintln(stdout, a.describe()) + if *dryRun { + continue + } + if _, err := cli(a.args()...); err != nil { + fmt.Fprintf(stderr, "osvissues: %s: %v\n", a.describe(), err) + return 1 + } + } + return 0 +} + +// gh runs the GitHub CLI, which reads its token and repository from the environment. +func gh(args ...string) ([]byte, error) { + cmd := exec.Command("gh", args...) + cmd.Stderr = os.Stderr // mutate-exempt: gh's own words go to the job's log for a person; run reads only its exit status and output + return cmd.Output() +} + +type finding struct { + id, pkg, summary string +} + +// name is the package without its version: what an issue is about across releases. +func (f finding) name() string { + name, _, _ := strings.Cut(f.pkg, "@") + return name +} + +// readFindings reads task osv's lines: id, package@version and summary. +func readFindings(r io.Reader) ([]finding, error) { + var out []finding + sc := bufio.NewScanner(r) + for n := 1; sc.Scan(); n++ { + if sc.Text() == "" { + continue + } + cols := strings.SplitN(sc.Text(), "\t", 3) + if len(cols) < 2 || cols[0] == "" || !strings.Contains(cols[1], "@") { + return nil, fmt.Errorf("findings line %d: want id, package@version and summary: %q", n, sc.Text()) + } + f := finding{id: cols[0], pkg: cols[1]} + if len(cols) == 3 { + f.summary = cols[2] + } + out = append(out, f) + } + return out, sc.Err() +} + +type issue struct { + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + Body string `json:"body"` +} + +// listed is the findings an issue's body names, by the links body writes. +var listed = regexp.MustCompile(`osv\.dev/vulnerability/([^)\s]+)`) + +func (i issue) ids() []string { + var ids []string + for _, m := range listed.FindAllStringSubmatch(i.Body, -1) { // mutate-exempt: any negative count is every match, so -1 and -2 find the same + ids = append(ids, m[1]) + } + return ids +} + +func title(pkg string) string { return "osv: " + pkg } + +// body is a package's issue: the release, the package as it is in it, and one line a finding. +func body(release string, fs []finding) string { + var b strings.Builder + var pkgs []string + for _, f := range fs { + if !slices.Contains(pkgs, f.pkg) { + pkgs = append(pkgs, f.pkg) + } + } + fmt.Fprintf(&b, "%s, the newest release, has %s with what OSV lists below (`task osv`, run daily by osv.yml). "+ + "A new release built after the distribution ships a fix closes this issue; closing it by hand says the rest does not matter here, "+ + "and only a finding not listed reopens it.\n\n", release, "`"+strings.Join(pkgs, "`, `")+"`") + for _, f := range fs { + fmt.Fprintf(&b, "- [%s](https://osv.dev/vulnerability/%s)", f.id, f.id) + if f.summary != "" { + fmt.Fprintf(&b, ": %s", f.summary) + } + b.WriteString("\n") + } + return b.String() +} + +type action struct { + verb string // create, edit, reopen, close + number int + title string + body string +} + +func (a action) describe() string { + if a.number == 0 { + return fmt.Sprintf("%s %q", a.verb, a.title) + } + return fmt.Sprintf("%s #%d %q", a.verb, a.number, a.title) +} + +func (a action) args() []string { + n := fmt.Sprint(a.number) + switch a.verb { + case "create": + return []string{"issue", "create", "--title", a.title, "--body", a.body} + case "edit": + return []string{"issue", "edit", n, "--body", a.body} + case "reopen": + return []string{"issue", "reopen", n, "--comment", a.body} + default: + return []string{"issue", "close", n, "--reason", "completed", "--comment", a.body} + } +} + +// plan is what brings the issues to the findings: one issue a package, its body the package's +// findings in release. +func plan(release string, findings []finding, issues []issue) []action { + byPkg := map[string][]finding{} + var names []string + for _, f := range findings { + if _, ok := byPkg[f.name()]; !ok { + names = append(names, f.name()) + } + byPkg[f.name()] = append(byPkg[f.name()], f) + } + slices.Sort(names) + + // The newest issue of a title is the package's: an older one is history. + byTitle := map[string]issue{} + for _, i := range issues { + if have, ok := byTitle[i.Title]; !ok || i.Number > have.Number { + byTitle[i.Title] = i + } + } + + var out []action + for _, name := range names { + fs := byPkg[name] + want := body(release, fs) + i, ok := byTitle[title(name)] + switch { + case !ok: + out = append(out, action{verb: "create", title: title(name), body: want}) + case i.State == "OPEN": + if i.Body != want { + out = append(out, action{verb: "edit", number: i.Number, title: i.Title, body: want}) + } + default: + var unlisted []string + for _, f := range fs { + if !slices.Contains(i.ids(), f.id) { + unlisted = append(unlisted, f.id) + } + } + if len(unlisted) > 0 { + out = append(out, + action{verb: "reopen", number: i.Number, title: i.Title, body: fmt.Sprintf("%s has findings this issue did not list: %s.", release, strings.Join(unlisted, ", "))}, + action{verb: "edit", number: i.Number, title: i.Title, body: want}) + } + } + } + var gone []string + for t, i := range byTitle { + name, ok := strings.CutPrefix(t, "osv: ") + if !ok || i.State != "OPEN" || strings.Contains(name, " ") { + continue + } + if _, has := byPkg[name]; !has { + gone = append(gone, t) + } + } + slices.Sort(gone) + for _, t := range gone { + i := byTitle[t] + out = append(out, action{verb: "close", number: i.Number, title: t, body: fmt.Sprintf("%s has no finding in this package.", release)}) + } + return out +} diff --git a/hack/osvissues/main_test.go b/hack/osvissues/main_test.go new file mode 100644 index 0000000..888a5dd --- /dev/null +++ b/hack/osvissues/main_test.go @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +const rel = "v20261014.01" + +var ( + usn = finding{"USN-8927-1", "curl@8.18.0-1ubuntu2.7", "curl vulnerabilities"} + curl1 = finding{"UBUNTU-CVE-2026-92386", "curl@8.18.0-1ubuntu2.7", ""} + jq1 = finding{"UBUNTU-CVE-2026-40612", "jq@1.8.1-4ubuntu2", ""} + legacy = issue{Number: 194, Title: "osv: USN-8927-1 in curl@8.18.0-1ubuntu2.7", State: "OPEN"} +) + +// verbs is a plan as "verb #number title", the part of an action a reader checks first. +func verbs(as []action) []string { + var out []string + for _, a := range as { + out = append(out, strings.TrimSpace(a.describe())) + } + return out +} + +func TestAPackageHasOneIssueThatFollowsItsFindings(t *testing.T) { + both := body(rel, []finding{usn, curl1}) + for _, tc := range []struct { + name string + findings []finding + issues []issue + want []string + }{ + {"a package with findings and no issue gets one", []finding{usn, curl1, jq1}, nil, + []string{`create "osv: curl"`, `create "osv: jq"`}}, + {"an open issue that lists the findings is left", []finding{usn, curl1}, + []issue{{Number: 3, Title: "osv: curl", State: "OPEN", Body: both}}, nil}, + {"an open issue is rewritten when the findings change", []finding{usn}, + []issue{{Number: 3, Title: "osv: curl", State: "OPEN", Body: both}}, + []string{`edit #3 "osv: curl"`}}, + {"an open issue whose package has no finding is closed", []finding{jq1}, + []issue{{Number: 3, Title: "osv: curl", State: "OPEN", Body: both}}, + []string{`create "osv: jq"`, `close #3 "osv: curl"`}}, + {"a closed issue stays closed while it listed every finding", []finding{usn}, + []issue{{Number: 3, Title: "osv: curl", State: "CLOSED", Body: both}}, nil}, + {"a finding a closed issue did not list reopens it", []finding{usn, curl1}, + []issue{{Number: 3, Title: "osv: curl", State: "CLOSED", Body: body(rel, []finding{usn})}}, + []string{`reopen #3 "osv: curl"`, `edit #3 "osv: curl"`}}, + {"the newest issue of a title is the package's", []finding{usn, curl1}, + []issue{{Number: 9, Title: "osv: curl", State: "OPEN", Body: both}, {Number: 3, Title: "osv: curl", State: "CLOSED"}}, nil}, + {"the newest issue of a title is the package's, listed after an older one", []finding{usn, curl1}, + []issue{{Number: 3, Title: "osv: curl", State: "CLOSED"}, {Number: 9, Title: "osv: curl", State: "OPEN", Body: both}}, nil}, + {"an open issue of one word that is not osv's is left", nil, + []issue{{Number: 120, Title: "flaky", State: "OPEN"}}, nil}, + {"an issue of one finding, as osv.yml opened them, is neither the package's nor closed", nil, + []issue{legacy}, nil}, + {"an issue that is not osv's is left", nil, + []issue{{Number: 113, Title: "Checkpoints resume across machine releases", State: "OPEN"}}, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := verbs(plan(rel, tc.findings, tc.issues)); !slices.Equal(got, tc.want) { + t.Errorf("plan = %q, want %q", got, tc.want) + } + }) + } +} + +func TestAnIssueListsEachFindingByTheLinkItIsReadBackBy(t *testing.T) { + curl2 := finding{"UBUNTU-CVE-2026-92387", "curl@8.18.0-1ubuntu2.7", ""} + b := body(rel, []finding{usn, curl1, curl2}) + for _, want := range []string{rel, "`curl@8.18.0-1ubuntu2.7`", "[USN-8927-1](https://osv.dev/vulnerability/USN-8927-1): curl vulnerabilities\n"} { + if !strings.Contains(b, want) { + t.Errorf("body does not say %q:\n%s", want, b) + } + } + if got := (issue{Body: b}).ids(); !slices.Equal(got, []string{usn.id, curl1.id, curl2.id}) { + t.Errorf("ids read back = %q, want the three written", got) + } +} + +func TestFindingsAreReadAsTaskOSVWritesThem(t *testing.T) { + for _, tc := range []struct { + name, in string + want []finding + err bool + }{ + {"id, package and summary", "USN-8927-1\tcurl@8.18.0-1ubuntu2.7\tcurl vulnerabilities\n", []finding{usn}, false}, + {"no summary", "UBUNTU-CVE-2026-92386\tcurl@8.18.0-1ubuntu2.7\n", []finding{curl1}, false}, + {"a summary is the rest of the line", "USN-8927-1\tcurl@8.18.0-1ubuntu2.7\tcurl\tvulnerabilities\n", + []finding{{usn.id, usn.pkg, "curl\tvulnerabilities"}}, false}, + {"an empty line is nothing", "\n", nil, false}, + {"a package without its version", "USN-8927-1\tcurl\tcurl vulnerabilities\n", nil, true}, + {"no id", "\tcurl@8.18.0-1ubuntu2.7\tcurl vulnerabilities\n", nil, true}, + {"one column", "USN-8927-1\n", nil, true}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := readFindings(strings.NewReader(tc.in)) + if (err != nil) != tc.err { + t.Fatalf("err = %v, want an error: %v", err, tc.err) + } + if err != nil && !strings.Contains(err.Error(), "line 1:") { + t.Errorf("err = %v, want it to name line 1", err) + } + if !slices.Equal(got, tc.want) { + t.Errorf("findings = %+v, want %+v", got, tc.want) + } + }) + } +} + +func TestRunTakesThePlanThroughGH(t *testing.T) { + path := filepath.Join(t.TempDir(), "osv.tsv") + if err := os.WriteFile(path, []byte("USN-8927-1\tcurl@8.18.0-1ubuntu2.7\tcurl vulnerabilities\n"), 0o644); err != nil { + t.Fatal(err) + } + existing, err := json.Marshal([]issue{{Number: 7, Title: "osv: jq", State: "OPEN"}}) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + args []string + want []string + }{ + {"acts", []string{"-release", rel, "-findings", path}, + []string{"issue list", "issue create --title osv: curl", "issue close 7"}}, + {"with -n only reads", []string{"-n", "-release", rel, "-findings", path}, + []string{"issue list"}}, + } { + t.Run(tc.name, func(t *testing.T) { + var calls []string + cli := func(args ...string) ([]byte, error) { + calls = append(calls, strings.Join(args, " ")) + if args[1] == "list" { + return existing, nil + } + return nil, nil + } + var stdout, stderr bytes.Buffer + if code := run(tc.args, cli, &stdout, &stderr); code != 0 { + t.Fatalf("run = %d: %s", code, stderr.String()) + } + if len(calls) != len(tc.want) { + t.Fatalf("gh was called %d times, want %d: %q", len(calls), len(tc.want), calls) + } + for i, w := range tc.want { + if !strings.HasPrefix(calls[i], w) { + t.Errorf("call %d = %q, want it to start %q", i, calls[i], w) + } + } + }) + } +} + +func TestRunFailsWhenItCannotReadOrAct(t *testing.T) { + dir := t.TempDir() + good := filepath.Join(dir, "osv.tsv") + bad := filepath.Join(dir, "bad.tsv") + if err := os.WriteFile(good, []byte("USN-8927-1\tcurl@8.18.0-1ubuntu2.7\tcurl vulnerabilities\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(bad, []byte("USN-8927-1\n"), 0o644); err != nil { + t.Fatal(err) + } + fails := errors.New("gh failed") + for _, tc := range []struct { + name string + findings string + list []byte + listErr error + actErr error + }{ + {"no findings file", filepath.Join(dir, "missing"), []byte("[]"), nil, nil}, + {"findings it cannot read", bad, []byte("[]"), nil, nil}, + {"gh cannot list", good, nil, fails, nil}, + {"gh lists something that is not issues", good, []byte("not json"), nil, nil}, + {"gh cannot act", good, []byte("[]"), nil, fails}, + } { + t.Run(tc.name, func(t *testing.T) { + cli := func(args ...string) ([]byte, error) { + if args[1] == "list" { + return tc.list, tc.listErr + } + return nil, tc.actErr + } + var stderr bytes.Buffer + if code := run([]string{"-release", rel, "-findings", tc.findings}, cli, &bytes.Buffer{}, &stderr); code != 1 { + t.Errorf("run = %d, want 1: %s", code, stderr.String()) + } + }) + } +} + +func TestRunRefusesACommandLineWithoutItsInputs(t *testing.T) { + for _, args := range [][]string{{"-release", rel}, {"-findings", "x"}, {"-bogus"}} { + var stderr bytes.Buffer + if code := run(args, func(...string) ([]byte, error) { t.Fatal("gh called"); return nil, nil }, &bytes.Buffer{}, &stderr); code != 2 { + t.Errorf("run %q = %d, want 2", args, code) + } + } +}