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