diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bc857de..2a19491 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,7 @@ New catalogs land under `threat_intel/`. Before submitting: ```sh python3 -c "import json, jsonschema; \ jsonschema.validate(json.load(open('threat_intel/your-catalog.json')), \ - json.load(open('docs/schema/v0.1.0/exposure-catalog.schema.json')))" + json.load(open('docs/schema/v0.2.0/exposure-catalog.schema.json')))" ``` - Include a `_comment` field at the catalog root with the methodology @@ -54,10 +54,10 @@ Catalogs can also be generated offline from OSV data with ## Schema changes -Any change to `docs/schema/v0.1.0/*.json` or the wire format that breaks -existing consumers is a breaking change. Land it as a new version -directory (`docs/schema/v0.2.0/`) and bump `model.SchemaVersion` -together; do not edit a published schema in place. +Any change to a published `docs/schema//*.json` or the wire +format that breaks existing consumers is a breaking change. Land it as a +new version directory (e.g. `docs/schema/v0.3.0/`) and bump +`model.SchemaVersion` together; do not edit a published schema in place. ## Security issues diff --git a/README.md b/README.md index 0776dc0..98327ec 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ Package record: { "record_type": "package", "record_id": "package:...", - "schema_version": "0.1.0", + "schema_version": "0.2.0", "scanner_name": "bumblebee", "scanner_version": "v0.1.1", "run_id": "9b1f0c2e4d5a6b7c8d9e0f1a2b3c4d5e", @@ -212,7 +212,7 @@ Finding record (exposure-catalog match): { "record_type": "finding", "record_id": "finding:...", - "schema_version": "0.1.0", + "schema_version": "0.2.0", "scanner_name": "bumblebee", "scanner_version": "v0.1.1", "run_id": "3a8c7d1e9f0b2a4c6d8e0f1a2b3c4d5e", @@ -251,11 +251,12 @@ guidance: [docs/state-model.md](docs/state-model.md#record-identity-record_id). ## Exposure Catalog Format -Minimal JSON, exact `(ecosystem, name, version)` matching only: +Minimal JSON, exact `(ecosystem, name, version)` matching. An entry may +declare `"versions": ["*"]` to match every version of the package: ```json { - "schema_version": "0.1.0", + "schema_version": "0.2.0", "entries": [ { "id": "advisory-2026-0042", @@ -270,10 +271,11 @@ Minimal JSON, exact `(ecosystem, name, version)` matching only: ``` The catalog must be a JSON object with `schema_version` and `entries` -keys. Bare top-level arrays are rejected. Unsupported future -`schema_version` values are rejected. Multiple catalog files can be -loaded together by pointing `--exposure-catalog` at a directory; see -the flag description above. +keys. Bare top-level arrays are rejected. `schema_version` `0.1.0` +catalogs are still accepted (they cannot use `"*"`); unsupported future +values are rejected. Multiple catalog files can be loaded together by +pointing `--exposure-catalog` at a directory; see the flag description +above. ### Sample exposure catalogs diff --git a/cmd/bumblebee/main.go b/cmd/bumblebee/main.go index 476fe1a..2bda0c1 100644 --- a/cmd/bumblebee/main.go +++ b/cmd/bumblebee/main.go @@ -137,7 +137,7 @@ func registerScanFlags(fs *flag.FlagSet, o *scanOpts) { fs.IntVar(&o.concurrency, "concurrency", 4, "number of concurrent file parsers") fs.StringVar(&o.exposureCatalog, "exposure-catalog", "", - "path to a JSON exposure catalog file, or a directory containing one or more *.json catalogs (merged non-recursively). Matches emit record_type=finding alongside packages. v0.1 matches by exact (ecosystem, normalized_name, version). The catalog is package-presence criteria only; it is NOT an EDR IOC feed.") + "path to a JSON exposure catalog file, or a directory containing one or more *.json catalogs (merged non-recursively). Matches emit record_type=finding alongside packages. Matching is by exact (ecosystem, normalized_name, version); an entry with versions [\"*\"] matches any version. The catalog is package-presence criteria only; it is NOT an EDR IOC feed.") fs.Int64Var(&o.maxCatalogSize, "max-catalog-size", 64*1024*1024, "max bytes to read from any single exposure catalog file (0 = unbounded). Applied per file when --exposure-catalog is a directory.") fs.BoolVar(&o.findingsOnly, "findings-only", false, diff --git a/cmd/bumblebee/selftest/catalog.json b/cmd/bumblebee/selftest/catalog.json index f2702aa..c66637a 100644 --- a/cmd/bumblebee/selftest/catalog.json +++ b/cmd/bumblebee/selftest/catalog.json @@ -1,5 +1,5 @@ { - "schema_version": "0.1.0", + "schema_version": "0.2.0", "_comment": "Synthetic exposure catalog used by `bumblebee selftest`. Entries reference fixture packages that do not exist on any real registry; the names are deliberately obvious so this catalog is never confused with production threat intel.", "entries": [ { diff --git a/docs/schema/v0.2.0/diagnostic-record.schema.json b/docs/schema/v0.2.0/diagnostic-record.schema.json new file mode 100644 index 0000000..39cd42c --- /dev/null +++ b/docs/schema/v0.2.0/diagnostic-record.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/perplexityai/bumblebee/docs/schema/v0.2.0/diagnostic-record.schema.json", + "title": "bumblebee diagnostic record", + "type": "object", + "additionalProperties": false, + "required": [ + "record_type", + "record_id", + "run_id", + "time", + "level", + "message" + ], + "properties": { + "record_type": { "const": "diagnostic" }, + "record_id": { "type": "string", "pattern": "^diagnostic:[0-9a-f]{64}$" }, + "run_id": { "type": "string" }, + "time": { "type": "string", "format": "date-time" }, + "level": { "enum": ["debug", "info", "warn", "error"] }, + "path": { "type": "string" }, + "message": { "type": "string" } + } +} diff --git a/docs/schema/v0.2.0/exposure-catalog.schema.json b/docs/schema/v0.2.0/exposure-catalog.schema.json new file mode 100644 index 0000000..ad53b33 --- /dev/null +++ b/docs/schema/v0.2.0/exposure-catalog.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/perplexityai/bumblebee/docs/schema/v0.2.0/exposure-catalog.schema.json", + "title": "bumblebee exposure catalog", + "type": "object", + "additionalProperties": true, + "required": ["schema_version", "entries"], + "properties": { + "schema_version": { "const": "0.2.0" }, + "entries": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true, + "required": ["id", "ecosystem", "package", "versions"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "ecosystem": { + "enum": ["npm", "pypi", "go", "rubygems", "packagist", "mcp", "editor-extension", "browser-extension", "homebrew"] + }, + "package": { "type": "string" }, + "versions": { + "oneOf": [ + { "const": ["*"] }, + { + "type": "array", + "minItems": 1, + "items": { "type": "string", "not": { "const": "*" } } + } + ] + }, + "severity": { "type": "string" } + } + } + } + } +} diff --git a/docs/schema/v0.2.0/finding-record.schema.json b/docs/schema/v0.2.0/finding-record.schema.json new file mode 100644 index 0000000..0d85b58 --- /dev/null +++ b/docs/schema/v0.2.0/finding-record.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/perplexityai/bumblebee/docs/schema/v0.2.0/finding-record.schema.json", + "title": "bumblebee finding record", + "type": "object", + "additionalProperties": false, + "required": [ + "record_type", + "record_id", + "schema_version", + "scanner_name", + "scanner_version", + "run_id", + "scan_time", + "endpoint", + "profile", + "finding_type", + "catalog_id", + "ecosystem", + "package_name", + "normalized_name", + "version", + "source_type", + "source_file", + "confidence" + ], + "properties": { + "record_type": { "const": "finding" }, + "record_id": { "type": "string", "pattern": "^finding:[0-9a-f]{64}$" }, + "schema_version": { "const": "0.2.0" }, + "scanner_name": { "const": "bumblebee" }, + "scanner_version": { "type": "string" }, + "run_id": { "type": "string" }, + "scan_time": { "type": "string", "format": "date-time" }, + "endpoint": { "$ref": "package-record.schema.json#/$defs/endpoint" }, + "profile": { "enum": ["baseline", "project", "deep"] }, + "finding_type": { "const": "package_exposure" }, + "severity": { "type": "string" }, + "catalog_id": { "type": "string" }, + "catalog_name": { "type": "string" }, + "ecosystem": { + "enum": ["npm", "pypi", "go", "rubygems", "packagist", "mcp", "editor-extension", "browser-extension"] + }, + "package_name": { "type": "string" }, + "normalized_name": { "type": "string" }, + "version": { "type": "string" }, + "root_kind": { "type": "string" }, + "project_path": { "type": "string" }, + "source_type": { "type": "string" }, + "source_file": { "type": "string" }, + "confidence": { "enum": ["high", "medium", "low"] }, + "evidence": { "type": "string" } + } +} diff --git a/docs/schema/v0.2.0/package-record.schema.json b/docs/schema/v0.2.0/package-record.schema.json new file mode 100644 index 0000000..ec9702e --- /dev/null +++ b/docs/schema/v0.2.0/package-record.schema.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/perplexityai/bumblebee/docs/schema/v0.2.0/package-record.schema.json", + "title": "bumblebee package record", + "type": "object", + "additionalProperties": false, + "required": [ + "record_type", + "record_id", + "schema_version", + "scanner_name", + "scanner_version", + "run_id", + "scan_time", + "endpoint", + "profile", + "ecosystem", + "package_name", + "normalized_name", + "version", + "source_type", + "source_file", + "has_lifecycle_scripts", + "confidence" + ], + "properties": { + "record_type": { "const": "package" }, + "record_id": { "type": "string", "pattern": "^package:[0-9a-f]{64}$" }, + "schema_version": { "const": "0.2.0" }, + "scanner_name": { "const": "bumblebee" }, + "scanner_version": { "type": "string" }, + "run_id": { "type": "string" }, + "scan_time": { "type": "string", "format": "date-time" }, + "endpoint": { "$ref": "#/$defs/endpoint" }, + "profile": { "enum": ["baseline", "project", "deep"] }, + "ecosystem": { + "enum": ["npm", "pypi", "go", "rubygems", "packagist", "mcp", "editor-extension", "browser-extension", "homebrew"] + }, + "package_name": { "type": "string" }, + "normalized_name": { "type": "string" }, + "version": { "type": "string" }, + "project_path": { "type": "string" }, + "root_kind": { "type": "string" }, + "install_scope": { "type": "string" }, + "package_manager": { "type": "string" }, + "source_type": { "type": "string" }, + "source_file": { "type": "string" }, + "direct_dependency": { "type": "boolean" }, + "has_lifecycle_scripts": { "type": "boolean" }, + "lifecycle_scripts": { + "type": "array", + "items": { "type": "string" } + }, + "confidence": { "enum": ["high", "medium", "low"] }, + "requested_spec": { "type": "string" }, + "server_name": { "type": "string" } + }, + "$defs": { + "endpoint": { + "type": "object", + "additionalProperties": false, + "required": ["hostname", "os", "arch", "username", "uid"], + "properties": { + "hostname": { "type": "string" }, + "os": { "type": "string" }, + "arch": { "type": "string" }, + "username": { "type": "string" }, + "uid": { "type": "string" }, + "device_id": { "type": "string" } + } + } + } +} diff --git a/docs/schema/v0.2.0/scan-summary.schema.json b/docs/schema/v0.2.0/scan-summary.schema.json new file mode 100644 index 0000000..9fe21de --- /dev/null +++ b/docs/schema/v0.2.0/scan-summary.schema.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/perplexityai/bumblebee/docs/schema/v0.2.0/scan-summary.schema.json", + "title": "bumblebee scan summary", + "type": "object", + "additionalProperties": false, + "required": [ + "record_type", + "record_id", + "schema_version", + "scanner_name", + "scanner_version", + "run_id", + "scan_time", + "end_time", + "endpoint", + "profile", + "status", + "package_records_emitted", + "findings_emitted", + "duplicates", + "diagnostics_count", + "files_considered", + "timed_out", + "duration_ms" + ], + "properties": { + "record_type": { "const": "scan_summary" }, + "record_id": { "type": "string", "pattern": "^scan_summary:[0-9a-f]{64}$" }, + "schema_version": { "const": "0.2.0" }, + "scanner_name": { "const": "bumblebee" }, + "scanner_version": { "type": "string" }, + "run_id": { "type": "string" }, + "scan_time": { "type": "string", "format": "date-time" }, + "end_time": { "type": "string", "format": "date-time" }, + "endpoint": { "$ref": "package-record.schema.json#/$defs/endpoint" }, + "profile": { "enum": ["baseline", "project", "deep"] }, + "status": { "enum": ["complete", "partial", "error"] }, + "roots": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "kind"], + "properties": { + "path": { "type": "string" }, + "kind": { "type": "string" } + } + } + }, + "counts": { + "type": "object", + "additionalProperties": { "type": "integer" } + }, + "package_records_emitted": { "type": "integer", "minimum": 0 }, + "package_records_suppressed": { "type": "integer", "minimum": 0 }, + "findings_emitted": { "type": "integer", "minimum": 0 }, + "duplicates": { "type": "integer", "minimum": 0 }, + "diagnostics_count": { "type": "integer", "minimum": 0 }, + "files_considered": { "type": "integer", "minimum": 0 }, + "timed_out": { "type": "boolean" }, + "duration_ms": { "type": "integer", "minimum": 0 }, + "http_batches_attempted": { "type": "integer", "minimum": 0 }, + "http_batches_succeeded": { "type": "integer", "minimum": 0 }, + "http_batches_failed": { "type": "integer", "minimum": 0 }, + "http_last_status": { "type": "integer", "minimum": 0 }, + "error": { "type": "string" } + } +} diff --git a/internal/exposure/exposure.go b/internal/exposure/exposure.go index 77aa000..a5f685d 100644 --- a/internal/exposure/exposure.go +++ b/internal/exposure/exposure.go @@ -7,10 +7,12 @@ // IOC feed for network indicators, dropped files, or process behavior; // those are EDR concerns and live outside this scanner. // -// v0.1 matching is exact: a package record matches a catalog entry when +// Matching is exact: a package record matches a catalog entry when // ecosystem and normalized name are equal and the package version equals -// one of the catalog entry's versions. Version ranges, hash matching, -// and integrity-based matching are out of scope for v0.1. +// one of the catalog entry's versions. As of schema v0.2 an entry may +// instead declare versions ["*"], which matches every version of the +// package (including records with no version). Version ranges, hash +// matching, and integrity-based matching remain out of scope. package exposure import ( @@ -27,6 +29,10 @@ import ( "github.com/perplexityai/bumblebee/internal/normalize" ) +// AnyVersion is the wildcard versions value that matches every version +// of a package. Requires catalog schema_version 0.2.0 or later. +const AnyVersion = "*" + // Catalog is a parsed exposure catalog. // // Concurrent reads via Match are safe; no mutation is performed after @@ -44,7 +50,8 @@ type Catalog struct { // // Required fields: ID, Ecosystem, Package, Versions. Versions are matched // exactly against a discovered package's Version field; any of the -// listed strings is sufficient for a hit. +// listed strings is sufficient for a hit. The single-element list ["*"] +// (schema v0.2+) matches any version. // // Optional Name is a free-form human label echoed onto findings as // `catalog_name`; it is not used for matching. @@ -60,6 +67,13 @@ type Entry struct { Severity string `json:"severity,omitempty"` normalized string + anyVersion bool +} + +// AnyVersion reports whether the entry matches every version of its +// package (versions declared as ["*"]). +func (e *Entry) AnyVersion() bool { + return e.anyVersion } // Match reports whether record matches any catalog entry, and returns the @@ -79,6 +93,8 @@ func (c *Catalog) Match(r model.Record) (*Entry, string) { } // Match is a single (entry, matched-version) pair returned by MatchAll. +// For an any-version entry, Version is the record's version (possibly +// empty) rather than a catalog string. type Match struct { Entry *Entry Version string @@ -100,6 +116,10 @@ func (c *Catalog) MatchAll(r model.Record) []Match { } var out []Match for _, e := range hits { + if e.anyVersion { + out = append(out, Match{Entry: e, Version: r.Version}) + continue + } for _, v := range e.Versions { if v == r.Version { out = append(out, Match{Entry: e, Version: v}) @@ -290,7 +310,18 @@ func build(schemaVersion string, in []Entry) (*Catalog, error) { return nil, fmt.Errorf("catalog entry %q: missing package", e.ID) } if len(e.Versions) == 0 { - return nil, fmt.Errorf("catalog entry %q: at least one version is required (v0.1 only supports exact-version matching)", e.ID) + return nil, fmt.Errorf("catalog entry %q: at least one version is required", e.ID) + } + for _, v := range e.Versions { + if v == AnyVersion { + if schemaVersion == "0.1.0" { + return nil, fmt.Errorf("catalog entry %q: versions %q requires schema_version %q", e.ID, AnyVersion, model.SchemaVersion) + } + if len(e.Versions) != 1 { + return nil, fmt.Errorf("catalog entry %q: %q must be the only element of versions", e.ID, AnyVersion) + } + e.anyVersion = true + } } e.normalized = normalizeName(e.Ecosystem, e.Package) c.Entries = append(c.Entries, e) @@ -303,15 +334,21 @@ func build(schemaVersion string, in []Entry) (*Catalog, error) { return c, nil } +// supportedSchemaVersions lists catalog schema versions the loader +// accepts. v0.1.0 catalogs remain valid; they just cannot use "*". +var supportedSchemaVersions = []string{"0.1.0", model.SchemaVersion} + func validateSchemaVersion(version string) error { version = strings.TrimSpace(version) if version == "" { - return fmt.Errorf("exposure catalog schema_version is required (supported: %q)", model.SchemaVersion) + return fmt.Errorf("exposure catalog schema_version is required (supported: %q)", supportedSchemaVersions) } - if version != model.SchemaVersion { - return fmt.Errorf("unsupported exposure catalog schema_version %q (supported: %q)", version, model.SchemaVersion) + for _, v := range supportedSchemaVersions { + if version == v { + return nil + } } - return nil + return fmt.Errorf("unsupported exposure catalog schema_version %q (supported: %q)", version, supportedSchemaVersions) } // normalizeName mirrors the per-ecosystem normalization used when diff --git a/internal/exposure/exposure_test.go b/internal/exposure/exposure_test.go index 8689d0f..85a081d 100644 --- a/internal/exposure/exposure_test.go +++ b/internal/exposure/exposure_test.go @@ -390,12 +390,14 @@ func TestMatchAllReturnsOverlappingEntries(t *testing.T) { } func TestParseCatalogSchemaVersion(t *testing.T) { - c, err := Parse([]byte(`{"schema_version":"0.1.0","entries":[{"id":"x","ecosystem":"npm","package":"foo","versions":["1.0.0"]}]}`)) - if err != nil { - t.Fatalf("parse versioned catalog: %v", err) - } - if c.SchemaVersion != model.SchemaVersion { - t.Fatalf("schema_version=%q, want %q", c.SchemaVersion, model.SchemaVersion) + for _, v := range supportedSchemaVersions { + c, err := Parse([]byte(`{"schema_version":"` + v + `","entries":[{"id":"x","ecosystem":"npm","package":"foo","versions":["1.0.0"]}]}`)) + if err != nil { + t.Fatalf("parse %s catalog: %v", v, err) + } + if c.SchemaVersion != v { + t.Fatalf("schema_version=%q, want %q", c.SchemaVersion, v) + } } } @@ -404,3 +406,40 @@ func TestParseCatalogRejectsUnsupportedSchemaVersion(t *testing.T) { t.Fatal("expected unsupported schema_version error") } } + +func TestMatchAnyVersion(t *testing.T) { + c, err := Parse([]byte(`{"schema_version":"0.2.0","entries":[ + {"id":"mal-1","ecosystem":"npm","package":"evil","versions":["*"],"severity":"critical"} + ]}`)) + if err != nil { + t.Fatal(err) + } + e, v := c.Match(model.Record{Ecosystem: "npm", NormalizedName: "evil", Version: "1.2.3"}) + if e == nil || e.ID != "mal-1" || !e.AnyVersion() { + t.Fatalf("expected any-version hit, got %v", e) + } + if v != "1.2.3" { + t.Errorf("matched version=%q, want record version", v) + } + + // Records without a version (e.g. MCP servers) match too. + if e, v := c.Match(model.Record{Ecosystem: "npm", NormalizedName: "evil"}); e == nil || v != "" { + t.Fatalf("expected empty-version hit, got %v, %q", e, v) + } + + if e, _ := c.Match(model.Record{Ecosystem: "npm", NormalizedName: "good", Version: "1.2.3"}); e != nil { + t.Fatal("expected miss for other package") + } +} + +func TestParseRejectsAnyVersionMixedWithExact(t *testing.T) { + if _, err := Parse([]byte(`{"schema_version":"0.2.0","entries":[{"id":"a","ecosystem":"npm","package":"x","versions":["*","1.0.0"]}]}`)); err == nil { + t.Fatal(`expected error for "*" mixed with exact versions`) + } +} + +func TestParseRejectsAnyVersionInOldSchema(t *testing.T) { + if _, err := Parse([]byte(`{"schema_version":"0.1.0","entries":[{"id":"a","ecosystem":"npm","package":"x","versions":["*"]}]}`)); err == nil { + t.Fatal(`expected error for "*" under schema_version 0.1.0`) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index 0ba4639..1856957 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -10,7 +10,7 @@ import ( ) const ( - SchemaVersion = "0.1.0" + SchemaVersion = "0.2.0" ScannerName = "bumblebee" RecordTypePackage = "package" @@ -28,8 +28,8 @@ const ( ProfileProject = "project" // configured developer/project roots ProfileDeep = "deep" // incident-response exposure scan - // FindingType discriminates between possible finding shapes. v0.1 only - // emits package_exposure (exact name+version match against an + // FindingType discriminates between possible finding shapes. Only + // package_exposure is emitted (name+version match against an // operator-supplied exposure catalog). FindingTypePackageExposure = "package_exposure" ) @@ -175,8 +175,9 @@ type Record struct { // stream) but are tagged with record_type=finding so receivers can route // them to a separate exposure table without re-running the match. // -// v0.1 only emits FindingTypePackageExposure: an exact (ecosystem, -// normalized_name, version) match. Catalog entry id and name are echoed +// Only FindingTypePackageExposure is emitted: an exact (ecosystem, +// normalized_name, version) match, or a name match against an +// any-version catalog entry. Catalog entry id and name are echoed // so receivers can attribute the hit without re-loading the catalog. type Finding struct { RecordType string `json:"record_type"` diff --git a/internal/osv/osv.go b/internal/osv/osv.go index 2bd01b5..9b1b741 100644 --- a/internal/osv/osv.go +++ b/internal/osv/osv.go @@ -9,12 +9,13 @@ // not vulnerability tracking, and mixing the two would produce huge // catalogs whose entries can't faithfully carry an OSV-side severity. // -// Matching is by (ecosystem, normalized_name, exact version), so only -// OSV's enumerated affected[].versions are usable. An affected entry -// with only a version range (commonly introduced:"0", i.e. all versions) -// has nothing to match against — v0.1 has no all-versions wildcard — and -// is skipped. This drops a substantial share of malicious-package -// records and is the import's main coverage limit. +// Matching is by (ecosystem, normalized_name, exact version), so +// enumerated affected[].versions convert directly. An affected entry +// whose ranges declare that every version is affected (a SEMVER or +// ECOSYSTEM range with a single introduced:"0" event) is emitted as the +// any-version entry ["*"] (schema v0.2). Entries with only bounded +// ranges and no enumerated versions have nothing exact to match against +// and are still skipped. package osv import ( @@ -37,7 +38,7 @@ import ( var idShape = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/-]{0,128}$`) // Catalog is the exposure-catalog document this package emits, matching -// docs/schema/v0.1.0/exposure-catalog.schema.json with a leading +// docs/schema/v0.2.0/exposure-catalog.schema.json with a leading // `_comment` recording provenance. type Catalog struct { SchemaVersion string `json:"schema_version"` @@ -95,16 +96,20 @@ func comment(opts Options, st Stats) string { if len(skips) > 0 { skipStr = " Skipped: " + strings.Join(skips, ", ") + "." } + anyStr := "" + if st.AnyVersionEntries > 0 { + anyStr = fmt.Sprintf(" Any-version entries: %d.", st.AnyVersionEntries) + } srcStr := "" if s := strings.TrimSpace(opts.Source); s != "" { srcStr = " Source: " + s + "." } return fmt.Sprintf( "Generated offline from OSV (https://osv.dev) by tools/osvcatalog; not fetched at scan time. "+ - "Scope: malicious packages only (MAL- ids). %d entries across %d source records (by ecosystem: %s).%s%s "+ - "Affected entries with only version ranges and no enumerated versions are not included, "+ - "since v0.1 matching is exact-version only.", - st.Entries, st.RecordsSeen, byEco, skipStr, srcStr) + "Scope: malicious packages only (MAL- ids). %d entries across %d source records (by ecosystem: %s).%s%s%s "+ + "Affected entries whose ranges declare all versions affected are emitted as versions [\"*\"]; "+ + "entries with only bounded ranges and no enumerated versions are not included.", + st.Entries, st.RecordsSeen, byEco, anyStr, skipStr, srcStr) } // Record is the subset of an OSV record consumed by the converter. @@ -120,6 +125,38 @@ type Record struct { type Affected struct { Package Package `json:"package"` Versions []string `json:"versions"` + Ranges []Range `json:"ranges"` +} + +// Range is one affected-version range. Events are kept as raw key/value +// maps because only the all-versions shape ({"introduced": "0"}) is +// interpreted; bounded ranges are never converted. +type Range struct { + Type string `json:"type"` + Events []map[string]string `json:"events"` +} + +// allVersions reports whether the entry's ranges declare every version +// affected: at least one SEMVER or ECOSYSTEM range whose events are all +// exactly {"introduced": "0"}. Any other event in such a range (fixed, +// last_affected, a nonzero introduced, ...) bounds the range and +// disqualifies the entry. GIT and other range types are ignored. +func (a Affected) allVersions() bool { + found := false + for _, rng := range a.Ranges { + if rng.Type != "SEMVER" && rng.Type != "ECOSYSTEM" { + continue + } + for _, ev := range rng.Events { + if len(ev) != 1 || ev["introduced"] != "0" { + return false + } + } + if len(rng.Events) > 0 { + found = true + } + } + return found } // Package identifies the affected package in OSV's namespace. @@ -156,6 +193,7 @@ type Options struct { type Stats struct { RecordsSeen int Entries int + AnyVersionEntries int SkippedWithdrawn int SkippedNotMalicious int SkippedNoVersions int @@ -251,10 +289,13 @@ func (r Record) toEntries(opts Options, st *Stats) []CatalogEntry { } // Aggregate enumerated versions per (ecosystem, name) so multiple - // affected ranges for the same package collapse into one entry. + // affected ranges for the same package collapse into one entry. An + // all-versions range anywhere for the key wins over enumerated + // versions, since it covers them. type key struct{ eco, name string } order := []key{} versions := map[key]map[string]struct{}{} + anyVersion := map[key]bool{} for _, a := range r.Affected { eco, ok := mapEcosystem(a.Package.Ecosystem) if !ok { @@ -271,11 +312,19 @@ func (r Record) toEntries(opts Options, st *Stats) []CatalogEntry { // (empty names are effectively nonexistent in the real corpus). continue } + k := key{eco, name} if len(a.Versions) == 0 { - st.SkippedNoVersions++ + if !a.allVersions() { + st.SkippedNoVersions++ + continue + } + if _, seen := versions[k]; !seen { + versions[k] = map[string]struct{}{} + order = append(order, k) + } + anyVersion[k] = true continue } - k := key{eco, name} set, seen := versions[k] if !seen { set = map[string]struct{}{} @@ -292,15 +341,21 @@ func (r Record) toEntries(opts Options, st *Stats) []CatalogEntry { multi := len(order) > 1 var entries []CatalogEntry for _, k := range order { - set := versions[k] - if len(set) == 0 { - continue - } - vers := make([]string, 0, len(set)) - for v := range set { - vers = append(vers, v) + var vers []string + if anyVersion[k] { + vers = []string{"*"} + st.AnyVersionEntries++ + } else { + set := versions[k] + if len(set) == 0 { + continue + } + vers = make([]string, 0, len(set)) + for v := range set { + vers = append(vers, v) + } + sort.Strings(vers) } - sort.Strings(vers) id := r.ID // Keep entry ids unique when one advisory names several packages, diff --git a/internal/osv/osv_test.go b/internal/osv/osv_test.go index 28f3baa..4e09bac 100644 --- a/internal/osv/osv_test.go +++ b/internal/osv/osv_test.go @@ -241,6 +241,84 @@ func TestBuildCatalogCommentDeterministic(t *testing.T) { } } +// TestConvertAllVersionsRanges covers the introduced:"0" all-versions +// range shape, emitted as versions ["*"]. Bounded and GIT-only ranges +// stay skipped. +func TestConvertAllVersionsRanges(t *testing.T) { + records := []Record{ + {ID: "MAL-semver", Affected: []Affected{{Package: Package{Ecosystem: "npm", Name: "a"}, Ranges: []Range{{Type: "SEMVER", Events: []map[string]string{{"introduced": "0"}}}}}}}, + {ID: "MAL-eco", Affected: []Affected{{Package: Package{Ecosystem: "PyPI", Name: "b"}, Ranges: []Range{{Type: "ECOSYSTEM", Events: []map[string]string{{"introduced": "0"}}}}}}}, + {ID: "MAL-intro", Affected: []Affected{{Package: Package{Ecosystem: "npm", Name: "c"}, Ranges: []Range{{Type: "SEMVER", Events: []map[string]string{{"introduced": "1.5.0"}}}}}}}, + {ID: "MAL-fixed", Affected: []Affected{{Package: Package{Ecosystem: "npm", Name: "d"}, Ranges: []Range{{Type: "SEMVER", Events: []map[string]string{{"introduced": "0"}, {"fixed": "2.0.0"}}}}}}}, + {ID: "MAL-git", Affected: []Affected{{Package: Package{Ecosystem: "npm", Name: "e"}, Ranges: []Range{{Type: "GIT", Events: []map[string]string{{"introduced": "0"}}}}}}}, + } + entries, st := Convert(records, Options{}) + if len(entries) != 2 { + t.Fatalf("want 2 entries, got %d: %+v", len(entries), entries) + } + for _, e := range entries { + if !reflect.DeepEqual(e.Versions, []string{"*"}) { + t.Errorf("%s versions = %v, want [*]", e.ID, e.Versions) + } + } + if st.AnyVersionEntries != 2 { + t.Errorf("AnyVersionEntries = %d, want 2", st.AnyVersionEntries) + } + if st.SkippedNoVersions != 3 { + t.Errorf("SkippedNoVersions = %d, want 3", st.SkippedNoVersions) + } +} + +// TestConvertAllVersionsWinsOverEnumerated: an all-versions range for a +// package covers any enumerated versions, so the entry collapses to ["*"]. +func TestConvertAllVersionsWinsOverEnumerated(t *testing.T) { + records := []Record{{ID: "MAL-mixed", Affected: []Affected{ + {Package: Package{Ecosystem: "npm", Name: "p"}, Versions: []string{"1.0.0"}}, + {Package: Package{Ecosystem: "npm", Name: "p"}, Ranges: []Range{{Type: "SEMVER", Events: []map[string]string{{"introduced": "0"}}}}}, + }}} + entries, st := Convert(records, Options{}) + if len(entries) != 1 || !reflect.DeepEqual(entries[0].Versions, []string{"*"}) { + t.Fatalf("want single [*] entry, got %+v", entries) + } + if st.AnyVersionEntries != 1 { + t.Errorf("AnyVersionEntries = %d, want 1", st.AnyVersionEntries) + } +} + +// TestRoundTripAnyVersion: a generated any-version entry survives +// exposure.LoadFile and matches an arbitrary discovered version. +func TestRoundTripAnyVersion(t *testing.T) { + records := []Record{ + {ID: "MAL-any", Summary: "Malicious code in wild-pkg", Affected: []Affected{{Package: Package{Ecosystem: "npm", Name: "wild-pkg"}, Ranges: []Range{{Type: "SEMVER", Events: []map[string]string{{"introduced": "0"}}}}}}}, + } + entries, st := Convert(records, Options{}) + catalog := BuildCatalog(entries, Options{}, st) + if !strings.Contains(catalog.Comment, "Any-version entries: 1.") { + t.Errorf("comment missing any-version count:\n%s", catalog.Comment) + } + + data, err := json.Marshal(catalog) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "osv.json") + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + + cat, err := exposure.LoadFile(path, 1<<20) + if err != nil { + t.Fatalf("exposure.LoadFile rejected generated catalog: %v", err) + } + hit, ver := cat.Match(model.Record{Ecosystem: "npm", NormalizedName: "wild-pkg", Version: "9.9.9"}) + if hit == nil || ver != "9.9.9" { + t.Fatalf("any-version entry did not match (hit=%v ver=%q)", hit, ver) + } + if !hit.AnyVersion() { + t.Error("matched entry should report AnyVersion") + } +} + // TestSeverityCritical verifies generated entries carry severity="critical", // matching hand-curated malicious-package catalogs in threat_intel/. func TestSeverityCritical(t *testing.T) { diff --git a/internal/scanner/findings_test.go b/internal/scanner/findings_test.go index f89d193..0620bd5 100644 --- a/internal/scanner/findings_test.go +++ b/internal/scanner/findings_test.go @@ -266,6 +266,63 @@ func TestFindingEmittedPerCatalogEntryWhenOverlapping(t *testing.T) { } } +// TestFindingEvidenceForAnyVersionEntry verifies a versions ["*"] entry +// matches whatever version is installed and says so in the evidence. +func TestFindingEvidenceForAnyVersionEntry(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "proj", "package-lock.json"), + `{"lockfileVersion":3,"packages":{"node_modules/evil":{"version":"4.5.6"}}}`) + + cat, err := exposure.Parse([]byte(`{"schema_version":"0.2.0","entries":[ + {"id":"adv-any","ecosystem":"npm","package":"evil","versions":["*"],"severity":"critical"} + ]}`)) + if err != nil { + t.Fatal(err) + } + + stdout := &bytes.Buffer{} + em := output.New(stdout, &bytes.Buffer{}, "run-any") + res, err := Run(context.Background(), Config{ + Profile: model.ProfileDeep, + Roots: []Root{{Path: root, Kind: model.RootKindDeepHome}}, + MaxFileSize: 1 << 20, + Concurrency: 1, + Catalog: cat, + BaseRecord: model.Record{ + SchemaVersion: model.SchemaVersion, + ScannerName: model.ScannerName, + ScannerVersion: "test", + RunID: "run-any", + ScanTime: time.Now().UTC().Format(time.RFC3339Nano), + }, + Emitter: em, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.FindingsEmitted != 1 { + t.Fatalf("findings=%d, want 1", res.FindingsEmitted) + } + var f model.Finding + for _, line := range bytes.Split(bytes.TrimSpace(stdout.Bytes()), []byte("\n")) { + var probe map[string]any + if err := json.Unmarshal(line, &probe); err != nil { + t.Fatalf("bad ndjson: %v: %s", err, line) + } + if probe["record_type"] == model.RecordTypeFinding { + if err := json.Unmarshal(line, &f); err != nil { + t.Fatalf("decode finding: %v", err) + } + } + } + if f.CatalogID != "adv-any" || f.Version != "4.5.6" { + t.Fatalf("matched fields wrong: %+v", f) + } + if !strings.Contains(f.Evidence, "matches any version (version=4.5.6)") { + t.Errorf("evidence=%q", f.Evidence) + } +} + // TestNoFindingWithoutCatalog verifies that with no catalog wired up, // the scanner emits no finding records at all. func TestNoFindingWithoutCatalog(t *testing.T) { diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index d0de709..963455b 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -203,6 +203,10 @@ func Run(ctx context.Context, cfg Config) (Result, error) { // so per-entry findings have distinct record_ids. for _, m := range cfg.Catalog.MatchAll(r) { entry, version := m.Entry, m.Version + evidence := "exact name+version match (version=" + version + ")" + if entry.AnyVersion() { + evidence = "exact name match, catalog entry matches any version (version=" + version + ")" + } f := model.Finding{ RecordType: model.RecordTypeFinding, SchemaVersion: cfg.BaseRecord.SchemaVersion, @@ -225,7 +229,7 @@ func Run(ctx context.Context, cfg Config) (Result, error) { SourceType: r.SourceType, SourceFile: r.SourceFile, Confidence: r.Confidence, - Evidence: "exact name+version match (version=" + version + ")", + Evidence: evidence, } if err := cfg.Emitter.EmitFinding(f); err == nil { findingsMu.Lock() diff --git a/threat_intel/README.md b/threat_intel/README.md index 826550f..9f702dd 100644 --- a/threat_intel/README.md +++ b/threat_intel/README.md @@ -56,10 +56,10 @@ go run ./tools/osvcatalog -o threat_intel/osv-npm-malicious.json npm/all.zip Each input path can be a directory tree, an OSV `all.zip` archive, or a single `.json` record. Supported OSV ecosystems map to Bumblebee as: `npm`, `PyPI` → `pypi`, `Go` → `go`, `RubyGems` → `rubygems`, -`Packagist` → `packagist`, `VSCode` → `editor-extension`. Records with -only a version range and no enumerated `affected[].versions` are skipped -(v0.1 matches exact versions only); this drops the large majority of -upstream entries (~90% of the current OSSF corpus). Output is +`Packagist` → `packagist`, `VSCode` → `editor-extension`. Records whose +ranges declare all versions affected (a single `introduced: "0"` event) +are emitted with `"versions": ["*"]`; records with only bounded ranges +and no enumerated `affected[].versions` are skipped. Output is deterministic, validates against the schema, and should be reviewed before use. The generated `_comment` records scope, per-ecosystem counts, skip-reason breakdown, and the optional `-source` provenance diff --git a/threat_intel/antv-mini-shai-hulud.json b/threat_intel/antv-mini-shai-hulud.json index b6761dd..1ce10ff 100644 --- a/threat_intel/antv-mini-shai-hulud.json +++ b/threat_intel/antv-mini-shai-hulud.json @@ -1,5 +1,5 @@ { - "schema_version": "0.1.0", + "schema_version": "0.2.0", "_comment": "AntV / Mini Shai-Hulud npm worm wave reported by Socket on 2026-05-19. Scoped to artifacts the Socket tracker (attackId 22) first detected on or after 2026-05-13, i.e. the in-PR-window 5/19 wave. Pre-2026-05-13 artifacts from the cumulative Mini Shai-Hulud campaign (TanStack/2026-05-11, Lightning/2026-04-30, and earlier waves) are intentionally excluded from this catalog and are tracked separately. The compromised package.json carries preinstall: 'bun run index.js' and an optional dependency @antv/setup pinned to github:antvis/G2#1916faa365f2788b6e193514872d51a242876569. The worm validates stolen npm tokens, enumerates maintainable packages, injects payload/preinstall, bumps versions, and republishes.", "_indicators": { "exfil_endpoint": "https://t.m-kosche.com:443/api/public/otel/v1/traces", diff --git a/threat_intel/gemstuffer.json b/threat_intel/gemstuffer.json index 9771f8c..b00780d 100644 --- a/threat_intel/gemstuffer.json +++ b/threat_intel/gemstuffer.json @@ -1,5 +1,5 @@ { - "schema_version": "0.1.0", + "schema_version": "0.2.0", "_comment": "GemStuffer campaign (Socket, 2026-05-13). The actor abused RubyGems as a data transport/exfiltration channel for scraped UK local government content rather than as a conventional mass developer-compromise payload. Severity is set to high (not critical) because the gems are an exfil-channel artifact rather than a worm/credential stealer: presence on a developer endpoint still indicates either accidental install or directed compromise and is worth flagging. Embedded Socket tracker enumerated 155 artifact rows at handoff, deduped here to one entry per (ecosystem, gem) with the published versions. Example gems explicitly called out by the article include lambeth71b and agenda-sample-yard / agenda-sample-result. The scanner does exact (ecosystem, name, version) matching only.", "_indicators": { "payload_rb_sha256": "239440c830e17530dda0a8a06ed2708860998750a1e3ed2239e919465dc59420", diff --git a/threat_intel/glassworm.json b/threat_intel/glassworm.json index 1bf35d7..02be343 100644 --- a/threat_intel/glassworm.json +++ b/threat_intel/glassworm.json @@ -1,5 +1,5 @@ { - "schema_version": "0.1.0", + "schema_version": "0.2.0", "_comment": "GlassWorm self-propagating supply-chain worm targeting developer IDE extensions on the Open VSX and Visual Studio Code marketplaces, with related hijacked npm packages. GlassWorm hides its loader in invisible Unicode characters and (in the 2026 transitive wave) abuses extensionPack/extensionDependencies manifest relationships to pull in a separate GlassWorm-linked extension via updates; it uses Solana transaction memos as dead-drop C2 stage discovery, Russian locale/timezone geofencing, staged in-memory JS execution, and steals npm/GitHub/Open VSX credentials and cryptocurrency wallets while deploying SOCKS proxy and hidden VNC for remote access. First disclosed by Koi Security (2025-10-18); the large 2026 wave is tracked on Socket's continuously updated GlassWorm v2 campaign page (see _indicators.primary_source) and detailed in Socket's 2026-03-13 transitive report, with corroboration from Checkmarx (2026-03) and Sonatype (2026-03-17, npm). The bulk of entries below are imported verbatim from the Socket v2 affected-artifact CSV (exact ecosystem/namespace/name/version), supplemented with earlier Koi and Checkmarx coordinates and the Sonatype npm hijacks. Bumblebee matches exact (ecosystem, name, version) only; the editor-extension ecosystem is the existing model.EcosystemEditorExtension family emitted for VS Code, Cursor, Windsurf, and VSCodium installs, and the scanner normalizes extension names to lowercase ., so identifiers match regardless of source case. Behavioral/network IOCs in _indicators are for analyst context and are not consumed by the scanner. The CSV labels every row 'vscode' (the artifact source marketplace); these are recorded as editor-extension since Bumblebee scans the same on-disk extension trees for VS Code, Open VSX-based forks, Cursor, Windsurf, and VSCodium.", "_indicators": { "campaign": "GlassWorm", diff --git a/threat_intel/laravel-lang-2026-05-23.json b/threat_intel/laravel-lang-2026-05-23.json index 4580035..dec5ed0 100644 --- a/threat_intel/laravel-lang-2026-05-23.json +++ b/threat_intel/laravel-lang-2026-05-23.json @@ -1,5 +1,5 @@ { - "schema_version": "0.1.0", + "schema_version": "0.2.0", "_comment": "Laravel-Lang/lang, Laravel-Lang/http-statuses, Laravel-Lang/attributes, and Laravel-Lang/actions were affected during the 2026-05-22/23 UTC tag-republishing window, with Socket reporting an RCE backdoor in src/helpers.php loaded via Composer autoload.files; see https://socket.dev/blog/laravel-lang-compromise.", "entries": [ { diff --git a/threat_intel/mastra-2026-06-17.json b/threat_intel/mastra-2026-06-17.json index 5e52bb9..6037542 100644 --- a/threat_intel/mastra-2026-06-17.json +++ b/threat_intel/mastra-2026-06-17.json @@ -1,5 +1,5 @@ { - "schema_version": "0.1.0", + "schema_version": "0.2.0", "_comment": "Mastra npm supply-chain compromise reported by Socket on 2026-06-17 (https://socket.dev/blog/mastra-npm-packages-compromised). A single npm publisher account mass-published malicious patch versions of @mastra/* packages that pulled in a typosquatted dependency, easy-day-js@1.11.22, whose postinstall hook ran a cross-platform infostealer/tasking implant. Covers 141 packages / 141 package-version pairs, all npm (the @mastra scope plus create-mastra and the easy-day-js typosquat that delivered the payload). Intended for exact (ecosystem, package, version) presence checks, not network/file/process IOC checks.", "entries": [ { diff --git a/threat_intel/mini-shai-hulud-leoplatform-2026-06-24.json b/threat_intel/mini-shai-hulud-leoplatform-2026-06-24.json index 5dd4bf6..09ad69f 100644 --- a/threat_intel/mini-shai-hulud-leoplatform-2026-06-24.json +++ b/threat_intel/mini-shai-hulud-leoplatform-2026-06-24.json @@ -1,5 +1,5 @@ { - "schema_version": "0.1.0", + "schema_version": "0.2.0", "_comment": "Mini Shai-Hulud / Miasma (Hades-variant) supply-chain compromise of the LeoPlatform / RStreams npm ecosystem, wave reported on 2026-06-24 by Socket (https://socket.dev/blog/miasma-mini-shai-hulud-hits-leoplatform-npm-packages-go-ecosystem) and OX Security (https://www.ox.security/blog/alright-lets-see-if-this-works-shai-hulud-miasma-hades-variant-spreads-on-npm/). A compromised npm maintainer account (czirker) mass-published malicious patch/pre-release versions of LeoPlatform and RStreams packages that execute at install time via a node-gyp binding.gyp command-substitution hook (the \"Phantom Gyp\" technique), staging an obfuscated multi-stage infostealer under the Bun runtime that exfiltrates secrets to attacker-controlled GitHub repos marked \"Alright Lets See If This Works\". This catalog covers the full reported union: 26 npm packages (20 core czirker packages confirmed by both Socket and OX; 3 pre-release leo-connector-* packages reported by OX only; and 3 packages \u2014 hexo-deployer-wrangler, hexo-shoka-swiper, prism-silq \u2014 published by the related npm account llxlr per Socket) plus 1 Go module (github.com/verana-labs/verana-blockchain), each at the single reported compromised version (27 package-version pairs total). Intended for exact (ecosystem, package, version) presence checks, not network/file/process IOC checks.", "entries": [ { diff --git a/threat_intel/mini-shai-hulud-redhat-cloud-services.json b/threat_intel/mini-shai-hulud-redhat-cloud-services.json index 01e0681..867428a 100644 --- a/threat_intel/mini-shai-hulud-redhat-cloud-services.json +++ b/threat_intel/mini-shai-hulud-redhat-cloud-services.json @@ -1,5 +1,5 @@ { - "schema_version": "0.1.0", + "schema_version": "0.2.0", "_comment": "Mini Shai-Hulud campaign compromise of Red Hat Cloud Services (@redhat-cloud-services) npm packages, reported by Socket on 2026-06-01. Self-replicating worm variant whose payload creates attacker-controlled GitHub repositories marked \"Miasma: The Spreading Blight\". Covers 32 packages / 95 package-version pairs, all npm under the @redhat-cloud-services scope. Intended for exact (ecosystem, package, version) presence checks, not network/file/process IOC checks.", "entries": [ { diff --git a/threat_intel/mini-shai-hulud.json b/threat_intel/mini-shai-hulud.json index a0659cc..6591a95 100644 --- a/threat_intel/mini-shai-hulud.json +++ b/threat_intel/mini-shai-hulud.json @@ -1,5 +1,5 @@ { - "schema_version": "0.1.0", + "schema_version": "0.2.0", "_comment": "Best-effort public Mini/Shai-Hulud May 2026 package exposure catalog generated from the static OX Security affected-package table and cross-checked against public Fleet, Socket, Snyk, Mistral, TanStack, and The Hacker News reporting. This is intended for exact package/version presence checks, not network/file/process IOC checks. Public reporting says 170+ packages were affected; this file contains the static package/version rows available from the cited public table at generation time. Keep it updated from authoritative advisories before production use.", "entries": [ { diff --git a/threat_intel/node-ipc-credential-stealer.json b/threat_intel/node-ipc-credential-stealer.json index 29aff25..6a02677 100644 --- a/threat_intel/node-ipc-credential-stealer.json +++ b/threat_intel/node-ipc-credential-stealer.json @@ -1,5 +1,5 @@ { - "schema_version": "0.1.0", + "schema_version": "0.2.0", "_comment": "Malicious node-ipc npm releases published as part of the 2026-05 credential stealer compromise reported by Socket. The malicious CJS entrypoint runs on require(), forks a detached child process with __ntw=1, collects env and local secrets, and exfiltrates via DNS TXT to bt.node.js after a TLS bootstrap to sh.azurestaticprovider.net:443 (resolved 37.16.75.69). Entries cover the seven malicious versions called out by the report: Socket only publishes file/tarball hashes for the 9.1.6, 9.2.3, and 12.0.1 releases plus the shared malicious CJS payload; hashes for the historical 10.1.1, 10.1.2, 11.0.0, and 11.1.0 versions are not published in the source post and have not been invented here. Network/file IOCs are noted for analyst context; the scanner itself does exact (ecosystem, name, version) matching only.", "entries": [ { diff --git a/threat_intel/nx-console-vscode-2026-05-18.json b/threat_intel/nx-console-vscode-2026-05-18.json index 8c6b295..b60b652 100644 --- a/threat_intel/nx-console-vscode-2026-05-18.json +++ b/threat_intel/nx-console-vscode-2026-05-18.json @@ -1,5 +1,5 @@ { - "schema_version": "0.1.0", + "schema_version": "0.2.0", "_comment": "Compromised Nx Console VS Code extension (nrwl.angular-console v18.95.0) published to the Visual Studio Code Marketplace on 2026-05-18 12:36 UTC and removed at 2026-05-18 12:47 UTC. The malicious VSIX ships an obfuscated main.js whose Efn(td) function runs on workspace activation, registers a VS Code task 'install-mcp-extension' that invokes `npx -y github:nrwl/nx#558b09d7`, and pulls a credential stealer / persistence chain (Vault, npm, AWS IMDS/ECS/Secrets Manager/SSM, GitHub tokens, SSH/GCP/Docker/Claude Code config, 1Password; macOS LaunchAgent Python backdoor at ~/Library/LaunchAgents/com.user.kitty-monitor.plist and ~/.local/share/kitty/cat.py; possible Linux sudoers injection; Sigstore/Fulcio/SLSA provenance abuse). OpenVSX was not affected. Remediated in 18.100.0+. Bumblebee matches only exact (ecosystem, name, version); behavioral/network/file IOCs below are noted for analyst context and are not consumed by the scanner. Ecosystem value is the existing 'editor-extension' family (model.EcosystemEditorExtension) emitted for VS Code, Cursor, Windsurf, and VSCodium installs; the scanner's normalized name is . lowercased, so 'nrwl.angular-console' matches both VS Code Marketplace and VSCodium-style installs on disk.", "_indicators": { "source": "https://www.stepsecurity.io/blog/nx-console-vs-code-extension-compromised", diff --git a/threat_intel/shopsprint-decimal-typosquat.json b/threat_intel/shopsprint-decimal-typosquat.json index 4bdffbd..cbcc282 100644 --- a/threat_intel/shopsprint-decimal-typosquat.json +++ b/threat_intel/shopsprint-decimal-typosquat.json @@ -1,5 +1,5 @@ { - "schema_version": "0.1.0", + "schema_version": "0.2.0", "_comment": "Go module github.com/shopsprint/decimal v1.3.3, a long-running typosquat of github.com/shopspring/decimal. The v1.3.3 release adds an init() DNS TXT C2 polling dnslog-cdn-images.freemyip.com every five minutes and executes returned TXT values with os/exec.Command. Indicators sourced from Socket's 2026-05-19 report. Network/file IOCs (C2 domain, commit hash, file/tarball hashes) are noted for analyst context; the scanner itself does exact (ecosystem, name, version) matching only.", "entries": [ { diff --git a/threat_intel/trapdoor-crypto-stealer.json b/threat_intel/trapdoor-crypto-stealer.json index 934dbed..1ebd9d7 100644 --- a/threat_intel/trapdoor-crypto-stealer.json +++ b/threat_intel/trapdoor-crypto-stealer.json @@ -1,6 +1,6 @@ { - "schema_version": "0.1.0", - "_comment": "TrapDoor Crypto Stealer cross-ecosystem credential/wallet stealer across npm, PyPI, and Cargo/Crates.io; see https://socket.dev/blog/trapdoor-crypto-stealer-npm-pypi-crates. Source CSV: 384 versions / 34 packages (npm 21/368, pypi 7/10, cargo 6/6). Entries[] covers npm and PyPI; the 6 Cargo packages are under _cargo_packages because the v0.1 schema enum has no 'cargo' value.", + "schema_version": "0.2.0", + "_comment": "TrapDoor Crypto Stealer cross-ecosystem credential/wallet stealer across npm, PyPI, and Cargo/Crates.io; see https://socket.dev/blog/trapdoor-crypto-stealer-npm-pypi-crates. Source CSV: 384 versions / 34 packages (npm 21/368, pypi 7/10, cargo 6/6). Entries[] covers npm and PyPI; the 6 Cargo packages are under _cargo_packages because the schema ecosystem enum has no 'cargo' value.", "_indicators": { "campaign_marker": "P-2024-001", "payload_filename": "trap-core.js", @@ -9,7 +9,7 @@ "pypi_users": ["asdmini67", "dae5411"] }, "_cargo_packages": { - "_comment": "Cargo packages from the same campaign; not in entries[] because the v0.1 schema enum does not include 'cargo'.", + "_comment": "Cargo packages from the same campaign; not in entries[] because the schema ecosystem enum does not include 'cargo'.", "packages": [ { "package": "move-analyzer-build", diff --git a/tools/osvcatalog/main.go b/tools/osvcatalog/main.go index f0b8c0e..fc2a4fa 100644 --- a/tools/osvcatalog/main.go +++ b/tools/osvcatalog/main.go @@ -21,10 +21,11 @@ // // Only malicious-package records (MAL- ids, or records aliased to one) // are emitted, with severity "critical". Vulnerability advisories are -// out of scope. Records whose only version information is a range (no -// enumerated versions) are skipped — see internal/osv. +// out of scope. Records whose ranges declare all versions affected are +// emitted with versions ["*"]; records with only bounded ranges and no +// enumerated versions are skipped — see internal/osv. // -// The output validates against docs/schema/v0.1.0/exposure-catalog.schema.json +// The output validates against docs/schema/v0.2.0/exposure-catalog.schema.json // and is consumed by `bumblebee scan --exposure-catalog`. package main @@ -103,8 +104,8 @@ func run(args []string, stdout, stderr io.Writer) error { if err != nil { return err } - fmt.Fprintf(stderr, "osvcatalog: %d entries from %d records (skipped: %d non-malicious, %d no-versions, %d unsupported-ecosystem, %d withdrawn, %d bad-id)\n", - st.Entries, st.RecordsSeen, st.SkippedNotMalicious, st.SkippedNoVersions, st.SkippedEcosystem, st.SkippedWithdrawn, st.SkippedBadID) + fmt.Fprintf(stderr, "osvcatalog: %d entries (%d any-version) from %d records (skipped: %d non-malicious, %d no-versions, %d unsupported-ecosystem, %d withdrawn, %d bad-id)\n", + st.Entries, st.AnyVersionEntries, st.RecordsSeen, st.SkippedNotMalicious, st.SkippedNoVersions, st.SkippedEcosystem, st.SkippedWithdrawn, st.SkippedBadID) return nil } diff --git a/tools/osvcatalog/main_test.go b/tools/osvcatalog/main_test.go index fcc168f..dd27a8b 100644 --- a/tools/osvcatalog/main_test.go +++ b/tools/osvcatalog/main_test.go @@ -65,7 +65,7 @@ func TestRunFromZip(t *testing.T) { if err := json.Unmarshal(data, &cat); err != nil { t.Fatalf("output is not valid JSON: %v", err) } - if cat.SchemaVersion != "0.1.0" { + if cat.SchemaVersion != "0.2.0" { t.Errorf("schema_version = %q", cat.SchemaVersion) } // Malicious-only by default: only MAL-2024-1, not the GHSA vuln.