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: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@ jobs:
go test ./internal/archive -run '^$' -fuzz '^FuzzWalk$' -fuzztime 30s
go test ./detectors/emudhra/internal/detect -run '^$' -fuzz '^FuzzScan$' -fuzztime 30s
go test ./detectors/php2shell/internal/detect -run '^$' -fuzz '^FuzzInspect$' -fuzztime 20s
go test ./detectors/brotli/internal/fingerprint -run '^$' -fuzz '^FuzzFingerprint$' -fuzztime 30s
go test ./detectors/brotli/internal/pins -run '^$' -fuzz '^FuzzPins$' -fuzztime 30s
for f in FuzzParseDpkgStatus FuzzParseAPKInstalled FuzzParseHeaderBlob FuzzReadBDB FuzzReadNDB FuzzResolver; do
go test ./detectors/php2shell/internal/pkgdb -run '^$' -fuzz "^$f\$" -fuzztime 15s
done
for f in FuzzDockerfile FuzzCompose FuzzKubernetes FuzzHelmValues FuzzCI FuzzDotEnv; do
go test ./detectors/php2shell/internal/refs -run '^$' -fuzz "^$f\$" -fuzztime 10s
go test ./internal/refs -run '^$' -fuzz "^$f\$" -fuzztime 10s
done
for f in FuzzCompareDpkg FuzzCompareAPK FuzzCompareRPM; do
go test ./detectors/php2shell/internal/version -run '^$' -fuzz "^$f\$" -fuzztime 10s
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ One engine for DefectDojo's emerging-vulnerability scanners. Each new vulnerabil
| --- | --- | --- |
| `emudhra` | Trust in the eight eMudhra intermediate CA keys compromised in October 2026 ([Mozilla bug 2083449](https://bugzilla.mozilla.org/show_bug.cgi?id=2083449)): trust stores, bundles, keystores and binaries that still trust them, Go binaries without Go's blocklist (CVE-2026-111943), and servers presenting certificates the compromised keys signed | `fs`, `image`, `host` |
| `php2shell` | PHP runtimes vulnerable to CVE-2026-109618 and CVE-2026-109619 ("PHP2Shell"), fixed in PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12 ([GHSA-w8hw-w9fx-j79w](https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w), [GHSA-6wmf-4rv5-p3rp](https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp)). A packaged PHP is judged by its vendor's backport data, never by the version it reports. Also checks the images a source tree builds on and deploys, and can rewrite them to fixed images | `fs`, `image`, `repo`, `fix` |
| `brotli` | Brotli decoders vulnerable to CVE-2026-111497 ("Node2Shell", "Broccoli2Shell"), Brotli 1.0.3 through 1.2.0, fixed in 1.0.10, 1.1.1 and 1.2.1 ([GHSA-9v8h-qqgm-4m35](https://github.com/advisories/GHSA-9v8h-qqgm-4m35)): system `libbrotlidec` and every copy bundled into other software (Node.js, .NET, Bun, Electron, Envoy and Istio, Python wheels such as Pillow, pyarrow, duckdb, pycurl and PyMuPDF, brotli4j, and more), on Linux, Windows and macOS. A decoder is judged first by the error code the fix added, so vendor backports are recognised without distribution data; a build that strips it by its own version stamp or by its code, which returns that error code only once fixed. In a source tree it finds the Node.js, .NET and Bun releases and the bundling packages a project pins, and `fix` moves each to the fixed release | `fs`, `image`, `repo`, `fix` |

Each detector also builds as a standalone binary under the name people search for: `emudhra-scan` and `php2shell-scan`. They keep their original commands, flags and exit status rules.
The detectors ported from standalone scanners also build as standalone binaries under the names people search for: `emudhra-scan` and `php2shell-scan`. They keep their original commands, flags and exit status rules. `brotli` was written here and runs through `earlylight` (`--detector brotli`).

## Install

Expand All @@ -39,6 +40,9 @@ Release binaries for Linux, macOS and Windows are on the [releases page](https:/
earlylight detectors # list detectors
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
earlylight scan repo --detector brotli . # Node.js, .NET, Bun and bundling packages a source tree pins
earlylight fix --detector brotli --dry-run . # the one-line moves to fixed releases
earlylight scan image --platform all myorg/app:prod # every platform the image has
sudo earlylight scan fs / # this machine
earlylight scan fs --as-root /host # a host mounted elsewhere
Expand Down Expand Up @@ -98,6 +102,8 @@ The engine enforces one rule for every detector: a finding can only be `fixed`,
| `internal/cli` | Command line shared by every binary |
| `detectors/emudhra` | Adapter, plus `internal/` copied verbatim from emudhra-scan |
| `detectors/php2shell` | Adapter, `verbatim.go` from php2shell-scan's command line, and `internal/` copied verbatim from php2shell-scan |
| `detectors/brotli` | Adapter, plus `internal/fingerprint` (what a binary proves: the decoder's error table, its tables and code, exports and version stamps), `internal/assess` (the verdict and fix for one file) and `internal/pins` (pins and bundling packages in a source tree, and their verified edits), which know nothing about the engine |
| `internal/refs` | Image references in Dockerfiles, Compose, Kubernetes, Helm values and CI files (moved from php2shell, unchanged, so every detector can read them) |
| `parity/` | Proves each port gives the same results as its standalone scanner |
| `brand/` | The Early Light logo, favicons, avatar and social preview |

Expand All @@ -108,6 +114,8 @@ The engine enforces one rule for every detector: a finding can only be `fixed`,
Every port must give exactly the verdicts of the scanner it came from before it ships:

- `parity/parity.py` runs emudhra-scan and Early Light over emudhra-scan's public validation images, directories and live TLS servers ([RESULTS.md](parity/RESULTS.md)).
- `parity/brotli.py` has no standalone scanner to compare with, so it checks every decoder in 38 images against the vendor's own fix status: Node.js, .NET, Bun, Envoy and curl images before and after their October 2026 releases, distribution packages pinned to their exact unfixed and backported builds, and the Python wheels ([BROTLI-RESULTS.md](parity/BROTLI-RESULTS.md)).
- `parity/brotli_files.py` checks 73 release binaries and packages, unpacked and never run: Node.js, .NET and Bun for Windows and macOS, seven Electron releases, Envoy and Istio, and the packages that bundle the decoder ([BROTLI-FILES-RESULTS.md](parity/BROTLI-FILES-RESULTS.md)). A WebAssembly build of the decoder is the one case it cannot settle: it is reported as unknown.
- `parity/php.py` runs php2shell-scan, this repo's php2shell-scan and Early Light over php2shell-scan's validation matrix: official images, distribution packages, source builds and hostile images. Each finding's complete record is compared field by field. It also covers filesystem scans at and below a system root, repository scans, `fix --dry-run`, and every detector at once ([PHP-RESULTS.md](parity/PHP-RESULTS.md)).

## Adding a detector
Expand Down
2 changes: 1 addition & 1 deletion SNAPSHOT
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ Engine-only changes since the copy (port these back to emudhra-scan and php2shel
- internal/source: readOne opens with O_NOFOLLOW|O_NONBLOCK and checks the opened file is the same regular file the walk saw
- internal/engine: a detector panic on one file is contained and reported as not scanned; the testdata/ exemption from the coverage-gap count applies only to real files on a filesystem scan, never image paths, archive members or detector crashes (emudhra-scan exempts testdata/ everywhere)
- detectors/php2shell/internal/report/report_test.go: gofmt whitespace only
- detectors/php2shell/internal/refs/shell.go and yaml.go: Dockerfile and Compose variable expansion capped at 8 KiB (a chain of doubling ARGs exhausted memory and time); regression tests expansion_bomb_test.go and dotenv_bomb_test.go
- internal/refs/shell.go and yaml.go (refs moved from detectors/php2shell/internal/refs to internal/refs on 2026-10-27, unchanged, so the brotli detector can read image references too): Dockerfile and Compose variable expansion capped at 8 KiB (a chain of doubling ARGs exhausted memory and time); regression tests expansion_bomb_test.go and dotenv_bomb_test.go
- detectors/emudhra/internal/detect/detect_test.go: TestManyBeginsNoEndIsFast checks linear growth instead of a 2 s limit (flaked under -race on slow CI; same fix as defectdojo-scan-and-fix #279)
1 change: 1 addition & 0 deletions detectors/all/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
package all

import (
_ "github.com/DefectDojo/earlylight/detectors/brotli"
_ "github.com/DefectDojo/earlylight/detectors/emudhra"
_ "github.com/DefectDojo/earlylight/detectors/php2shell"
)
143 changes: 143 additions & 0 deletions detectors/brotli/brotli.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Package brotli is the detector for CVE-2026-111497 (also called Node2Shell,
// Broccoli2Shell and HTTP2Shell): an out-of-bounds write in Google Brotli's C
// decoder, in every release from 1.0.3 through 1.2.0, fixed in 1.0.10, 1.1.1
// and 1.2.1 on October 21, 2026. It finds Brotli decoders in filesystems and
// container images, as shared libraries (libbrotlidec) and as copies bundled
// into Node.js, .NET, Python packages, Bun and other software, and judges each
// by what the binary itself proves (see internal/fingerprint), so vendor
// backports are recognised without any distribution data.
//
// internal/fingerprint and internal/assess know nothing about the engine;
// this file only adapts them.
package brotli

import (
"github.com/DefectDojo/earlylight/detectors/brotli/internal/assess"
"github.com/DefectDojo/earlylight/detectors/brotli/internal/fingerprint"
"github.com/DefectDojo/earlylight/internal/archive"
"github.com/DefectDojo/earlylight/internal/engine"
)

// ID is the detector's ID.
const ID = "brotli"

// Kind IDs.
const (
KindLibrary = string(assess.Library)
KindBundled = string(assess.Bundled)
KindGoPort = string(assess.GoPort)
)

// Detector implements engine.Detector, engine.Reader, engine.RepoScanner and
// engine.Fixer.
type Detector struct{}

func init() { engine.Register(&Detector{}) }

const about = " Brotli 1.0.3 through 1.2.0 discard an error from the block-switch decoder, so a crafted" +
" compressed stream corrupts decoder memory: a crash, disclosure of process memory or code execution." +
" Anything that decompresses untrusted Brotli data with it is exposed, such as HTTP clients and" +
" servers that accept Content-Encoding: br. Fixed in Brotli 1.0.10, 1.1.1 and 1.2.1."

// Manifest describes the detector.
func (d *Detector) Manifest() engine.Manifest {
cwe := []string{"CWE-252", "CWE-787"}
return engine.Manifest{
ID: ID,
Title: "Brotli decoder vulnerable to " + assess.CVE,
CVEs: []string{assess.CVE},
Advisories: []string{
"https://github.com/advisories/GHSA-9v8h-qqgm-4m35",
"https://github.com/google/brotli/releases/tag/v1.2.1",
"https://nodejs.org/en/blog/vulnerability/october-2026-security-releases",
"https://github.com/dotnet/announcements/issues/494",
},
Disclosed: "2026-10-21",
Targets: []engine.TargetKind{engine.Filesystem, engine.Image, engine.Repository},
Kinds: append([]engine.Kind{
{ID: KindLibrary, Title: "Brotli decoder library is vulnerable to " + assess.CVE, Level: "error", SecuritySeverity: "9.2", CWEs: cwe,
Description: "A shared Brotli decoder library (libbrotlidec) without the fix. Every program that loads it is exposed." + about},
{ID: KindBundled, Title: "Bundled Brotli decoder is vulnerable to " + assess.CVE, Level: "error", SecuritySeverity: "9.2", CWEs: cwe,
Description: "A program or library that carries its own copy of the Brotli decoder (Node.js, .NET, a Python package, Bun and others) without the fix. Updating the system Brotli package does not fix it." + about},
{ID: KindGoPort, Title: "Go program with a Brotli port that panics on the same malformed stream", Level: "warning", SecuritySeverity: "5.3", CWEs: []string{"CWE-252"},
Description: "A Go program built with github.com/andybalholm/brotli before v1.2.7, a pure Go port of the decoder with the same block-switch logic error. In Go it panics instead of writing out of bounds: a denial of service, not CVE-2026-111497 itself. Fixed in v1.2.7."},
}, repoKinds()...),
Tags: []string{"security", "brotli", "Node2Shell", "Broccoli2Shell"},
Archives: true, // wheels, jars, npm tarballs and NuGet packages carry native decoders
Data: "Brotli decoder error-code tables (google/brotli 1.0.0 to 1.2.1) and the fixed Node.js, .NET and Bun releases",
}
}

// Load has nothing to load: the knowledge is in the code.
func (d *Detector) Load() error { return nil }

// Wants reads executables and shared libraries, where decoders live, and
// archives, which can carry them.
func (d *Detector) Wants(_ string, head []byte, _ bool) bool {
return archive.IsExecutable(head) || archive.IsArchive(head) || fingerprint.IsWasm(head)
}

// NewScan starts a filesystem or image scan.
func (d *Detector) NewScan(engine.TargetKind) engine.Scan { return &scan{} }

type scan struct{ findings []engine.Finding }

func (s *scan) Visit(f engine.File) []engine.Skip {
if !archive.IsExecutable(f.Data) && !fingerprint.IsWasm(f.Data) {
return nil
}
if x, ok := assess.File(f.Path, f.Data); ok {
s.findings = append(s.findings, finding(x))
}
return nil
}

func (s *scan) Finish(engine.TargetContext) []engine.Finding { return s.findings }

var verdicts = map[assess.Status]engine.Verdict{
assess.Vulnerable: engine.Vulnerable,
assess.Fixed: engine.Fixed,
assess.Unknown: engine.Unknown,
}

func finding(x assess.Finding) engine.Finding {
v, ok := verdicts[x.Status]
if !ok {
v = engine.Unknown
}
facts := map[string]string{"product": string(x.Product)}
if x.Version != "" {
facts["version"] = x.Version
}
if x.Table != "" {
facts["error_table"] = x.Table
facts["block_switch"] = yes(x.BlockSwitch)
facts["distance"] = yes(x.Distance)
}
return engine.Finding{
Kind: string(x.Kind),
Path: x.Path,
Verdict: v,
Reason: x.Reason,
Message: x.Message,
Method: x.Method,
Facts: facts,
CVEs: cves(x, v),
Fix: x.Fix,
Details: x,
}
}

func cves(x assess.Finding, v engine.Verdict) []engine.CVEResult {
if x.NotTheCVE {
return nil
}
return []engine.CVEResult{{ID: assess.CVE, Verdict: v, Reason: x.Reason}}
}

func yes(b bool) string {
if b {
return "yes"
}
return "no"
}
148 changes: 148 additions & 0 deletions detectors/brotli/brotli_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package brotli_test

import (
"archive/zip"
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"

"github.com/DefectDojo/earlylight/detectors/brotli"
"github.com/DefectDojo/earlylight/detectors/brotli/internal/testbin"
"github.com/DefectDojo/earlylight/internal/cli"
)

var cfg = cli.Config{Tool: "earlylight", Version: "test", Summary: "test"}

var decoder = []testbin.Symbol{{Name: "BrotliDecoderDecompressStream", Defined: true}}

func write(t *testing.T, root, rel string, data []byte) {
t.Helper()
p := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, data, 0o755); err != nil {
t.Fatal(err)
}
}

func wheel(t *testing.T, member string, data []byte) []byte {
t.Helper()
var b bytes.Buffer
z := zip.NewWriter(&b)
w, err := z.Create(member)
if err != nil {
t.Fatal(err)
}
w.Write(data)
z.Close()
return b.Bytes()
}

type report struct {
Targets []struct {
Findings []struct {
Detector, Kind, Path, Verdict, Reason, Fix string
Facts map[string]string
}
}
}

func scan(t *testing.T, dir string, format string) (int, string) {
t.Helper()
var out, errb bytes.Buffer
code := cli.Run(cfg, []string{"scan", "fs", "--detector", brotli.ID, "--format", format, dir}, &out, &errb)
if errb.Len() > 0 && code == 2 {
t.Fatalf("exit 2: %s", errb.String())
}
return code, out.String()
}

func TestScanFilesystem(t *testing.T) {
dir := t.TempDir()
write(t, dir, "usr/lib/libbrotlidec.so.1.0.9", testbin.ELF(decoder, testbin.Table("1.0.9")))
write(t, dir, "usr/local/bin/node", testbin.Executable([]byte("nodejs.org/download/release/v24.22.1/node-v24.22.1-headers.tar.gz"), testbin.Table("1.2.1")))
write(t, dir, "wheels/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.whl",
wheel(t, "_brotli.cpython-313-x86_64-linux-gnu.so", testbin.Executable(testbin.Table("1.2.0"))))
write(t, dir, "usr/bin/curl", testbin.ELF([]testbin.Symbol{{Name: "BrotliDecoderDecompressStream"}}, nil))
write(t, dir, "notes.txt", []byte("\x00EXUBERANT_META_NIBBLE\x00"+string(testbin.Table("1.2.0"))))

code, out := scan(t, dir, "json")
if code != 1 {
t.Fatalf("exit %d, want 1:\n%s", code, out)
}
var r report
if err := json.Unmarshal([]byte(out), &r); err != nil {
t.Fatalf("%v\n%s", err, out)
}
real, err := filepath.EvalSymlinks(dir) // macOS reports /var/folders as /private/var/folders
if err != nil {
t.Fatal(err)
}
got := map[string]string{}
for _, tg := range r.Targets {
for _, f := range tg.Findings {
rel := strings.TrimPrefix(strings.TrimPrefix(f.Path, filepath.ToSlash(real)), filepath.ToSlash(dir))
got[rel] = f.Verdict + " " + f.Kind + " " + f.Facts["product"]
if f.Detector != brotli.ID || f.Reason == "" {
t.Errorf("%s: detector %q reason %q", rel, f.Detector, f.Reason)
}
}
}
want := map[string]string{
"/usr/lib/libbrotlidec.so.1.0.9": "vulnerable decoder-library libbrotlidec",
"/usr/local/bin/node": "fixed bundled-decoder node",
"/wheels/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.whl!/_brotli.cpython-313-x86_64-linux-gnu.so": "vulnerable bundled-decoder python-brotli",
}
if len(got) != len(want) {
t.Errorf("findings %v, want %v", got, want)
}
for p, w := range want {
if got[p] != w {
t.Errorf("%s: got %q, want %q", p, got[p], w)
}
}
}

func TestSARIFRules(t *testing.T) {
dir := t.TempDir()
write(t, dir, "libbrotlidec.so.1", testbin.ELF(decoder, testbin.Table("1.1.0")))
code, out := scan(t, dir, "sarif")
if code != 1 {
t.Fatalf("exit %d", code)
}
for _, s := range []string{`"brotli/decoder-library"`, `"brotli/bundled-decoder"`, `"security-severity": "9.2"`, "CVE-2026-111497", "CWE-787"} {
if !strings.Contains(out, s) {
t.Errorf("SARIF has no %s", s)
}
}
}

func TestFixedOnlyExitsZero(t *testing.T) {
dir := t.TempDir()
write(t, dir, "libbrotlidec.so.1", testbin.ELF(decoder, testbin.Table("1.2.1")))
if code, out := scan(t, dir, "text"); code != 0 {
t.Fatalf("exit %d:\n%s", code, out)
}
}

func TestUnknownExitsThree(t *testing.T) {
dir := t.TempDir()
write(t, dir, "usr/local/bin/envoy", testbin.Executable(testbin.DecoderTables(16)))
if code, out := scan(t, dir, "text"); code != 3 {
t.Fatalf("exit %d:\n%s", code, out)
}
}

func TestListed(t *testing.T) {
var out, errb bytes.Buffer
if code := cli.Run(cfg, []string{"detectors"}, &out, &errb); code != 0 {
t.Fatalf("exit %d: %s", code, errb.String())
}
if !strings.Contains(out.String(), brotli.ID) || !strings.Contains(out.String(), "CVE-2026-111497") {
t.Errorf("detectors list:\n%s", out.String())
}
}
Loading
Loading