Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 `<detector>/<kind>`.

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
Expand Down
73 changes: 73 additions & 0 deletions detectors/all/all_test.go
Original file line number Diff line number Diff line change
@@ -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
}
}
}
11 changes: 10 additions & 1 deletion detectors/emudhra/emudhra.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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},
Expand Down Expand Up @@ -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,
Expand All @@ -161,6 +169,7 @@ func finding(r assess.Result) engine.Finding {
Message: r.Detail,
Method: r.Method,
Facts: facts,
CVEs: cves,
Details: r.Finding,
}
}
27 changes: 27 additions & 0 deletions detectors/emudhra/finding_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
82 changes: 82 additions & 0 deletions internal/cli/catalog.go
Original file line number Diff line number Diff line change
@@ -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 "<detector>/<id>".
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
}
43 changes: 37 additions & 6 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package cli

import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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.
Expand All @@ -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" {
Expand Down
43 changes: 43 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -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())
}
}
}
3 changes: 2 additions & 1 deletion internal/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ const (
type Kind struct {
ID string // stable, used in SARIF rule IDs as "<detector>/<kind>"
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
Expand Down
Loading