From bc0d21d3daab55d6ab17430ce1af12d756dc83f9 Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Mon, 2 Nov 2026 21:23:10 -0700 Subject: [PATCH] Publish a detector catalog with each release `earlylight detectors --format json` prints every detector's ID, title, CVEs, advisories, disclosure date, the commands that run it (fs, image, host, repo, fix) and its finding kinds, all taken from the manifests. The release workflow writes it to detectors.json before the checksums are taken, so the signed checksums.txt covers it, and fails if its version does not match the tag. Plain `earlylight detectors` is unchanged. A contract test checks every real detector's entry: ID, a real disclosure date, https advisories, at least one command and unique finding kinds. eMudhra's unpatched-go findings now carry CVE-2026-111943, so tools that file findings under CVEs can group them. Trust findings are about the CA keys and still carry no CVE. The engine comment now lists the note level brotli already uses. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 4 ++ README.md | 3 ++ detectors/all/all_test.go | 73 +++++++++++++++++++++++++++ detectors/emudhra/emudhra.go | 11 ++++- detectors/emudhra/finding_test.go | 27 ++++++++++ internal/cli/catalog.go | 82 +++++++++++++++++++++++++++++++ internal/cli/cli.go | 43 +++++++++++++--- internal/cli/cli_test.go | 43 ++++++++++++++++ internal/engine/engine.go | 3 +- 9 files changed, 281 insertions(+), 8 deletions(-) create mode 100644 detectors/all/all_test.go create mode 100644 detectors/emudhra/finding_test.go create mode 100644 internal/cli/catalog.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85db816..bfc2d1b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,6 +30,10 @@ jobs: scripts/third-party-notices.sh "$RUNNER_TEMP/notices.txt" diff -u THIRD_PARTY_NOTICES.txt "$RUNNER_TEMP/notices.txt" cp LICENSE THIRD_PARTY_NOTICES.txt dist/ + # The detector catalog DefectDojo reads to tell which detectors this release + # contains, also covered by the signed checksums. + "dist/earlylight_${GITHUB_REF_NAME}_linux_amd64" detectors --format json > dist/detectors.json + grep -q "\"version\": \"${GITHUB_REF_NAME}\"" dist/detectors.json (cd dist && sha256sum -- * > checksums.txt) - uses: sigstore/cosign-installer@v3 - name: Sign checksums diff --git a/README.md b/README.md index b66e5a0..a2fd231 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Release binaries for Linux, macOS and Windows are on the [releases page](https:/ ```bash earlylight detectors # list detectors +earlylight detectors --format json # the detector catalog, as each release's detectors.json earlylight scan image php:8.3-fpm # every detector that scans images earlylight scan image --detector php2shell php:8.3-fpm earlylight scan image --detector brotli node:24.22.0-slim @@ -56,6 +57,8 @@ earlylight fix --dry-run . # show the image updates Exit status: `0` nothing vulnerable or unknown, `1` something vulnerable, `2` error (including "nothing was scanned"), `3` nothing vulnerable but something unknown, or with `--fail-on-skip`, something not scanned. Output is `--format text`, `json` or `sarif`. SARIF rule IDs are `/`. +Each release also attaches `detectors.json`, listed in the signed `checksums.txt`. It is the output of `earlylight detectors --format json` for that release: for every detector its ID, title, CVEs, advisories, disclosure date, the commands that run it (`fs`, `image`, `host`, `repo`, `fix`) and its finding kinds. Tools such as DefectDojo read it to tell which detectors a release contains without running a binary. `schema` changes only when a field is removed or changes meaning. + ## GitHub Action ```yaml diff --git a/detectors/all/all_test.go b/detectors/all/all_test.go new file mode 100644 index 0000000..c8a42cc --- /dev/null +++ b/detectors/all/all_test.go @@ -0,0 +1,73 @@ +package all_test + +import ( + "bytes" + "encoding/json" + "net/url" + "regexp" + "testing" + "time" + + _ "github.com/DefectDojo/earlylight/detectors/all" + "github.com/DefectDojo/earlylight/internal/cli" +) + +// The catalog is a contract: tools such as DefectDojo read detectors.json from +// each release to tell which detectors it contains and how to run each one. +// Every real detector must fill it in completely. +func TestCatalogContract(t *testing.T) { + var out, errb bytes.Buffer + if got := cli.Run(cli.Config{Tool: "earlylight", Version: "v0.0.0", Summary: "t"}, []string{"detectors", "--format", "json"}, &out, &errb); got != 0 { + t.Fatalf("exit %d: %s", got, errb.String()) + } + var cat cli.Catalog + if err := json.Unmarshal(out.Bytes(), &cat); err != nil { + t.Fatal(err) + } + if len(cat.Detectors) < 3 { + t.Fatalf("only %d detectors", len(cat.Detectors)) + } + id := regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + cve := regexp.MustCompile(`^CVE-\d{4}-\d{4,}$`) + modes := map[string]bool{"fs": true, "image": true, "host": true, "repo": true, "fix": true} + levels := map[string]bool{"error": true, "warning": true, "note": true} + for _, d := range cat.Detectors { + if !id.MatchString(d.ID) || d.Title == "" { + t.Errorf("%q: id or title", d.ID) + } + if _, err := time.Parse("2006-01-02", d.Disclosed); err != nil { + t.Errorf("%s: disclosed %q is not YYYY-MM-DD", d.ID, d.Disclosed) + } + for _, c := range d.CVEs { + if !cve.MatchString(c) { + t.Errorf("%s: CVE %q", d.ID, c) + } + } + if len(d.Advisories) == 0 { + t.Errorf("%s: no advisory link (the first one is the SARIF help link)", d.ID) + } + for _, a := range d.Advisories { + if u, err := url.Parse(a); err != nil || u.Scheme != "https" || u.Host == "" { + t.Errorf("%s: advisory %q is not an https link", d.ID, a) + } + } + if len(d.Modes) == 0 { + t.Errorf("%s: no modes", d.ID) + } + for _, m := range d.Modes { + if !modes[m] { + t.Errorf("%s: mode %q", d.ID, m) + } + } + if len(d.Kinds) == 0 { + t.Errorf("%s: no kinds", d.ID) + } + seen := map[string]bool{} + for _, k := range d.Kinds { + if !id.MatchString(k.ID) || seen[k.ID] || k.Title == "" || !levels[k.Level] { + t.Errorf("%s/%s: kind id, title or level (%q)", d.ID, k.ID, k.Level) + } + seen[k.ID] = true + } + } +} diff --git a/detectors/emudhra/emudhra.go b/detectors/emudhra/emudhra.go index db7ba0a..13a6d46 100644 --- a/detectors/emudhra/emudhra.go +++ b/detectors/emudhra/emudhra.go @@ -22,6 +22,10 @@ import ( // ID is the detector's ID. const ID = "emudhra" +// goCVE is Go's fix for the compromised intermediates. Only unpatched-go +// findings carry it: the trust findings are about the CA keys, which have no CVE. +const goCVE = "CVE-2026-111943" + // Detector implements engine.Detector and engine.Prober. type Detector struct { det *detect.Detector @@ -34,7 +38,7 @@ func (d *Detector) Manifest() engine.Manifest { return engine.Manifest{ ID: ID, Title: "Trust in the compromised eMudhra intermediate CA keys", - CVEs: []string{"CVE-2026-111943"}, + CVEs: []string{goCVE}, Advisories: []string{"https://bugzilla.mozilla.org/show_bug.cgi?id=2083449"}, Disclosed: "2026-10-20", Targets: []engine.TargetKind{engine.Filesystem, engine.Image, engine.Host}, @@ -153,6 +157,10 @@ func finding(r assess.Result) engine.Finding { if !ok { v = engine.Unknown } + var cves []engine.CVEResult + if r.Kind == detect.UnpatchedGo { + cves = []engine.CVEResult{{ID: goCVE, Verdict: v, Reason: r.Reason}} + } return engine.Finding{ Kind: string(r.Kind), Path: r.Path, @@ -161,6 +169,7 @@ func finding(r assess.Result) engine.Finding { Message: r.Detail, Method: r.Method, Facts: facts, + CVEs: cves, Details: r.Finding, } } diff --git a/detectors/emudhra/finding_test.go b/detectors/emudhra/finding_test.go new file mode 100644 index 0000000..dbf6cc8 --- /dev/null +++ b/detectors/emudhra/finding_test.go @@ -0,0 +1,27 @@ +package emudhra + +import ( + "testing" + + "github.com/DefectDojo/earlylight/detectors/emudhra/internal/assess" + "github.com/DefectDojo/earlylight/detectors/emudhra/internal/detect" + "github.com/DefectDojo/earlylight/internal/engine" +) + +// Only a Go binary without the blocklist is CVE-2026-111943. DefectDojo files +// findings under their CVEs, so a trust finding that carried it would be +// grouped with a Go bug it has nothing to do with. +func TestOnlyUnpatchedGoCarriesTheCVE(t *testing.T) { + gof := finding(assess.Result{Finding: detect.Finding{Kind: detect.UnpatchedGo, Path: "/usr/bin/app"}, Status: assess.Active}) + if len(gof.CVEs) != 1 || gof.CVEs[0].ID != goCVE || gof.CVEs[0].Verdict != engine.Vulnerable { + t.Errorf("unpatched-go CVEs = %+v, want %s vulnerable", gof.CVEs, goCVE) + } + inactive := finding(assess.Result{Finding: detect.Finding{Kind: detect.UnpatchedGo, Path: "/usr/bin/app"}, Status: assess.Inactive, Reason: "never verifies chains"}) + if len(inactive.CVEs) != 1 || inactive.CVEs[0].Verdict != engine.Inactive || inactive.CVEs[0].Reason == "" { + t.Errorf("an inactive Go binary's CVE must say inactive and why: %+v", inactive.CVEs) + } + trust := finding(assess.Result{Finding: detect.Finding{Kind: detect.TrustsCompromisedKey, Path: "/etc/ssl/cert.pem"}, Status: assess.Active}) + if len(trust.CVEs) != 0 { + t.Errorf("trust finding carries CVEs %+v", trust.CVEs) + } +} diff --git a/internal/cli/catalog.go b/internal/cli/catalog.go new file mode 100644 index 0000000..f0ce1e1 --- /dev/null +++ b/internal/cli/catalog.go @@ -0,0 +1,82 @@ +package cli + +import ( + "github.com/DefectDojo/earlylight/internal/engine" +) + +// CatalogSchema changes only when a field is removed or changes meaning. New +// fields are added without changing it. +const CatalogSchema = 1 + +// Catalog is what "earlylight detectors --format json" prints. Each release +// attaches it as detectors.json, so DefectDojo can tell which detectors a +// release contains without downloading and running a binary. +type Catalog struct { + Schema int `json:"schema"` + Tool string `json:"tool"` + Version string `json:"version"` + Detectors []CatalogEntry `json:"detectors"` +} + +// CatalogEntry is one detector, taken from its manifest. +type CatalogEntry struct { + ID string `json:"id"` + Title string `json:"title"` + CVEs []string `json:"cves"` + Advisories []string `json:"advisories"` + Disclosed string `json:"disclosed"` + // Modes are the commands that run the detector: fs, image, host, repo, fix. + Modes []string `json:"modes"` + Kinds []CatalogKind `json:"kinds"` + Tags []string `json:"tags"` + Data string `json:"data,omitempty"` +} + +// CatalogKind is one type of finding. Its SARIF rule ID is "/". +type CatalogKind struct { + ID string `json:"id"` + Title string `json:"title"` + Level string `json:"level"` + Description string `json:"description,omitempty"` + Severity string `json:"severity,omitempty"` + CWEs []string `json:"cwes,omitempty"` +} + +// modeOf is the command for each target kind, in the order the usage text +// lists them. +var modeOf = []struct { + kind engine.TargetKind + mode string +}{{engine.Filesystem, "fs"}, {engine.Image, "image"}, {engine.Host, "host"}, {engine.Repository, "repo"}} + +// catalog describes every registered detector. Lists are never null, so a +// reader can range over them without checking. +func (c Config) catalog() Catalog { + out := Catalog{Schema: CatalogSchema, Tool: c.Tool, Version: c.version(), Detectors: []CatalogEntry{}} + for _, d := range engine.Detectors() { + m := d.Manifest() + e := CatalogEntry{ID: m.ID, Title: m.Title, CVEs: nonNil(m.CVEs), Advisories: nonNil(m.Advisories), + Disclosed: m.Disclosed, Modes: []string{}, Kinds: []CatalogKind{}, Tags: nonNil(m.Tags), Data: m.Data} + for _, k := range modeOf { + if m.Supports(k.kind) { + e.Modes = append(e.Modes, k.mode) + } + } + if _, ok := d.(engine.Fixer); ok && m.Supports(engine.Repository) { + e.Modes = append(e.Modes, "fix") + } + for _, k := range m.Kinds { + e.Kinds = append(e.Kinds, CatalogKind{ID: k.ID, Title: k.Title, Level: k.Level, Description: k.Description, + Severity: k.SecuritySeverity, CWEs: k.CWEs}) + } + out.Detectors = append(out.Detectors, e) + } + return out +} + +func nonNil(s []string) []string { + if s == nil { + return []string{} + } + return s +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index dc717bc..6d3dcab 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -5,6 +5,7 @@ package cli import ( "context" + "encoding/json" "flag" "fmt" "io" @@ -85,7 +86,8 @@ func (c Config) usage() string { fmt.Fprintf(&b, " %s fix [flags] DIR... move those image references to published fixed images\n", c.Tool) } if c.Detector == "" { - fmt.Fprintf(&b, " %s detectors list the detectors\n", c.Tool) + fmt.Fprintf(&b, " %s detectors [--format json] list the detectors (json: the catalog each release\n", c.Tool) + fmt.Fprintf(&b, " %s attaches as detectors.json)\n", strings.Repeat(" ", len(c.Tool))) } fmt.Fprintf(&b, " %s version\n", c.Tool) b.WriteString("\nFlags:\n") @@ -144,6 +146,39 @@ func (l *list) Set(s string) error { return nil } +// detectors lists the detectors: one line each, or the catalog as JSON. +func (c Config) detectors(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("detectors", flag.ContinueOnError) + fs.SetOutput(stderr) + fs.Usage = func() { fmt.Fprint(stderr, c.usage()) } + format := fs.String("format", "text", "") + if err := fs.Parse(args); err != nil { + return 2 + } + if fs.NArg() > 0 { + fs.Usage() + return 2 + } + switch *format { + case "text": + for _, d := range engine.Detectors() { + m := d.Manifest() + fmt.Fprintf(stdout, "%-12s %s\n", m.ID, m.Title) + } + case "json": + enc := json.NewEncoder(stdout) + enc.SetIndent("", " ") + if err := enc.Encode(c.catalog()); err != nil { + fmt.Fprintf(stderr, "%s: %v\n", c.Tool, err) + return 2 + } + default: + fmt.Fprintf(stderr, "%s: unknown --format %q (detectors: text or json)\n", c.Tool, *format) + return 2 + } + return 0 +} + var kinds = map[string]engine.TargetKind{"fs": engine.Filesystem, "image": engine.Image, "host": engine.Host, "repo": engine.Repository, "fix": engine.Repository} // Run runs the command line and returns the exit status. @@ -162,11 +197,7 @@ func Run(c Config, args []string, stdout, stderr io.Writer) int { return 0 case "detectors": if c.Detector == "" { - for _, d := range engine.Detectors() { - m := d.Manifest() - fmt.Fprintf(stdout, "%-12s %s\n", m.ID, m.Title) - } - return 0 + return c.detectors(args[1:], stdout, stderr) } } if c.Detector == "" && args[0] != "fix" { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 7732368..2b45c09 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "encoding/json" "os" "path/filepath" "strings" @@ -67,3 +68,45 @@ func TestUnknownDetectorRefused(t *testing.T) { t.Fatalf("exit %d", got) } } + +func TestDetectorsCatalog(t *testing.T) { + c := Config{Tool: "earlylight", Version: "v9.9.9", Summary: "t"} + var out, errb bytes.Buffer + if got := Run(c, []string{"detectors", "--format", "json"}, &out, &errb); got != 0 { + t.Fatalf("exit %d: %s", got, errb.String()) + } + var cat Catalog + if err := json.Unmarshal(out.Bytes(), &cat); err != nil { + t.Fatalf("not JSON: %v\n%s", err, out.String()) + } + if cat.Schema != CatalogSchema || cat.Tool != "earlylight" || cat.Version != "v9.9.9" { + t.Errorf("header = %d %q %q", cat.Schema, cat.Tool, cat.Version) + } + var e *CatalogEntry + for i := range cat.Detectors { + if cat.Detectors[i].ID == "t-unsure" { + e = &cat.Detectors[i] + } + } + if e == nil { + t.Fatalf("t-unsure missing:\n%s", out.String()) + } + if strings.Join(e.Modes, ",") != "fs" || len(e.Kinds) != 1 || e.Kinds[0].ID != "runtime" || e.Kinds[0].Level != "error" { + t.Errorf("entry = %+v", e) + } + // Readers range over lists without checking for null. + if strings.Contains(out.String(), "null") { + t.Errorf("catalog has a null list:\n%s", out.String()) + } + + out.Reset() + if got := Run(c, []string{"detectors"}, &out, &errb); got != 0 || !strings.Contains(out.String(), "t-unsure") || strings.Contains(out.String(), "{") { + t.Errorf("text listing: exit %d\n%s", got, out.String()) + } + for _, args := range [][]string{{"detectors", "--format", "sarif"}, {"detectors", "extra"}, {"detectors", "--nope"}} { + errb.Reset() + if got := Run(c, args, &out, &errb); got != 2 || errb.Len() == 0 { + t.Errorf("%v: exit %d, stderr %q", args, got, errb.String()) + } + } +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 1051154..aaad129 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -48,7 +48,8 @@ const ( type Kind struct { ID string // stable, used in SARIF rule IDs as "/" Title string - // Level is the SARIF level for a vulnerable finding: "error" or "warning". + // Level is the SARIF level for a vulnerable finding: "error", "warning" or + // "note" (something to look at that is not itself a vulnerability). Level string // Description explains the kind in a sentence or two (SARIF fullDescription). Description string