npm-json-processor
Status: Live Source: npm registry + replicate.npmjs.com
_changesType:json(CouchDB _changes firehose + per-package JSON API + tarball fetch) Source slug:npmSchedule: every 30 minutes (cron(0/30 * * * ? *))
Overview
Continuously watches the npm registry for new and updated packages and flags
malicious publishes. Each run walks the CouchDB _changes feed
(replicate.npmjs.com/_changes?since=<seq>) from a stored sequence watermark,
so only packages changed since the previous run are processed. Deleted/unpublished
change rows and duplicate package ids within a run are skipped.
For every candidate package it fetches the registry document
(registry.npmjs.org/<name>), resolves the latest dist-tags.latest version,
and gathers:
- Package metadata — version, homepage, repository, license, maintainers
(publisher accounts), author, dependencies,
dist(tarball URL, shasum, integrity), and thescriptsblock. - Lifecycle install hooks —
preinstall/install/postinstall/preparescript bodies, the classic npm supply-chain execution vector. These feed the install-script detectors. - Tarball JavaScript — for packages that declare lifecycle hooks (the
high-risk subset), the published
.tgzis downloaded (bounded) and its.js/.cjs/.mjs/.tssources concatenated for deep static detection (eval(Buffer.from(...)),child_processexec of decoded payloads,process.envexfiltration, obfuscator signatures). - Upstream GitHub repo (only when the package’s
repository/homepageis agithub.comproject) — repository, contributors, and license, persisted GitHub-first in its own committed transaction.
Records produced
| Condition | Records |
|---|---|
| Every scanned package | PackageVersion (ecosystem npm); updatedAt = registry time.modified; version/maintainers/dist/scripts + non-minting context signals in metadata JSON |
| Upstream is github.com | GitHubRepository + GitHubRepoContributor + license fields |
| Malicious (≥1 evidence detection) | CVEMetadata (source="npm", GCVE-110-NPM-YYYY-NNNNNN, isMaliciousPackage=true), one CVEDescription per detection, CVEProblemType (CWE-506 + CWE-94/CWE-200 specifics), CVEAffected (vendor npm, all versions), CVEMetadataReferences (npm page + repo + homepage), PackageVersionCVE, GcveIssuance |
| Malicious + actor resolved | ThreatActor (npm maintainer accounts + author + embedded contact emails) + MalwareThreatActor edges + MalwareAttribution (attributed, claimed/victim upstream GitHub) |
| Malicious | MalwareIoc rows (exfil endpoints, IPs, domains, URLs, emails, install-commands, file hashes), plus one ownership-change IOC per hijack trigger that corroborated the verdict and one STIX IOC per known-bad infrastructure match |
| Malicious + tarball still downloadable | The live .tgz is captured at scan time (before the registry can unpublish it), encrypted at rest and uploaded to the shared malware-sample archive, then linked as provenance: Artifact (bomFormat=malware-sample) → Link → CVEMetadata.fileLinkId (internal/malsample, main.go:326-343 and 396-403). Best-effort — a capture or upload failure never blocks the mint |
| Post-batch, first time only | malwareactor.PostPass attributes up to MALWARE_ACTOR_BATCH (default 150) still-unattributed source='npm' malware records via internal/actorintel, writing ThreatActor, ThreatActorKey, MalwareThreatActor and MalwareAttribution. Records that already carry a MalwareAttribution row are skipped, so steady-state runs do ~0 work. Disabled with MALWARE_ACTORS=false; GitHub lookups use GITHUB_PAT/GITHUB_TOKEN |
Gates before minting
npm is the noisiest ecosystem in the fleet (build/dev tooling invoked through
npx, and owner-reputation cascades that carry no malicious code of their own),
so three independent gates sit between a detection and an advisory:
- LLM false-positive gate (
internal/aimalgate, main.go:308-321) — whenPIX_INFERENCE_ENABLED+ an AI-Gateway token are configured, the evidence set and the scanned source are put to the model before minting. Only a confident benign verdict drops the detection; any gateway or parse error fails open and keeps the verdict, so the gate can only remove false positives, never suppress a real detection on infrastructure failure. Cleared packages are counted asaiClearedin the run summary. - Human curation gate (
curationgate.PackageCleared, main.go:367) — a package/version a reviewer has already cleared is never re-minted, even when the detectors fire again. ThePackageVersionrow is still written. - Legitimate-maintainer guard (
legitmaintainer.SkipActor, main.go:445) — a well-known upstream maintainer is never branded a threat actor, which is what stops an ownership-reputation signal cascading onto every package they publish.
Detection
Reuses the shared malscan-engine
detect engine. Lifecycle hook bodies are scanned by the install-script
detectors; the full scripts block plus tarball JavaScript by the general /
shell / source-url detectors. npm/JS-specific evidence rules include
eval()/new Function() of base64-decoded payloads, child_process exec of
decoded commands, node -e/--eval install hooks, and base64-decoding install
hooks (CWE-94); process.env network exfiltration (CWE-200); and obfuscator
signatures (context). Findings carry one of three classes: evidence (a factual malicious behaviour
— mints the advisory on its own), trigger (a weak corroborating signal such
as a high-entropy embedded payload or a supply-chain ownership/identity change
— never mints alone), and context (reputation/risk, recorded as
PackageVersion.metadata only). detect.CombinedVerdict mints on any
evidence, a known-bad package owner, a high-entropy payload combined with an
ownership/identity change, or two independent identity-change families changing
together. The full finding-class model and the per-ecosystem capability config
(22 capabilities) live in Malware Detection.
Known-bad IOC matching — the package’s declared source is also matched
against the public per-ecosystem known-bad
STIX feed (domains, IPs, URLs), loaded
once per run and matched in memory. A hit is evidence (CWE-506) folded into
the finding set before the combination gate, and is recorded as a MalwareIoc
row whose references carry the file/line and STIX provenance.
Resume
A sequence watermark is stored in BulkDataDumpTracker under source
npm-changes (the sha256 column holds the last processed _changes
sequence). The first run (no tracker) starts a configurable window before the
registry head so recent activity is swept; subsequent runs resume from the
saved sequence.
S3 Persistence
- Archive path:
npm/files/{dist.shasum}/{package}/scripts.js✓ (falls back tonpm-{package}-{version}when the registry document carries no shasum) - Quarantine path:
failed-feeds/npm-json-processor/{YYYY-MM-DD}/{reason}/{package}/scripts.js✓ - Failure reasons emitted:
store-error - Malware-sample archive: encrypted
.tgzunder the sharedmalware-samples/prefix (internal/malsample) — a separate, attribution-gated path from the raw-payload archive above
The unit of work is a package version, not a file: the concatenated lifecycle-hook
scripts plus tarball JavaScript that was actually scanned is archived after a
successful store (main.go:433) and quarantined when the store transaction fails
(main.go:427). Skipped when S3_BUCKET_NAME is unset (local dev) — which also
disables live malware-sample capture (main.go:328).