Supply-Chain Scanning

The Supply-Chain Scanner detects known-malicious (MAL-) and known-vulnerable (CVE / GHSA) software packages across a target's dependency surface, verdicted fully offline against a local copy of the OSV database. It ships as three layers that share one engine and one graph model, so a repository scan, a live-target harvest, and an on-demand agent lookup all dedup into the same Package and MalPackageFinding nodes.

This page is the operator guide. For the full technical reference (container topology, the DIRTY/CLEAN security split, the artifact schema, and the graph writer internals), read README.SUPPLY_CHAIN.md in the repository.


The three layers

LayerWhat it isHow you start itWrites graph nodes?
L1Standalone audit of an uploaded SBOM / lockfile or a GitHub repositoryConfigured in Project Settings > Other Scans; launched from the Other Scans modalYes: Package, MalPackageFinding, Vulnerability{source:'osv'}
L2Black-box harvest of a live target's served packagesRecon pipeline (GROUP 5.5), or a partial-recon runYes: same nodes, anchored to BaseURL
L3On-demand lookups the AI agent calls mid-engagementexecute_osv_scanner / execute_guarddog agent toolsNo: returns text to the agent

A vulnerability id starting with MAL- is a terminal malicious verdict (the package itself is malware, for example a typosquat). CVE- / GHSA- ids are ordinary known-vulnerable findings and are never written as malicious. A GuardDog behavioural hit is always suspicious, never malicious; only an OSV MAL- hit is malicious.


The tools

ToolRoleVersionRuntime
OSV-ScannerVerdict engine: is a package MAL- (malicious) or CVE / GHSA (vulnerable)?v2.4.0Go static binary, offline
GuardDogBehavioural analysis: does the package behave like malware (install hooks, obfuscation, exfil, typosquat)?v3.0.1Python (+ semgrep, YARA)
retire.jsBlack-box JS library + version harvest (L2 only)v5.4.3Node CLI

SBOMs are synthesized in-process as CycloneDX 1.5 from whatever packages a layer harvested; RedAmon does not run syft, trivy, or cdxgen. The OSV verdict path makes zero network calls. GuardDog and retire.js need registry / signature-file egress and run inside a hardened, isolated analyzer container.


One-time setup: the offline OSV database

The verdict path reads a local copy of the OSV database from the shared Docker volume redamon-osv-db. It is not downloaded at install time (the container images are eager, the data is lazy). Populate it once per ecosystem:

./redamon.sh supply-chain-sync npm            # ~208 MB, first run only
./redamon.sh supply-chain-sync npm PyPI Go    # add more ecosystems

Supported ecosystems: npm, PyPI, Go, Maven, crates.io, Packagist, RubyGems, NuGet.

Automatic refresh. OSV publishes new MAL- / CVE advisories daily. Once an ecosystem has been synced, the orchestrator refreshes it lazily on the scan-spawn path, TTL-guarded (default 24h). A cold database (never synced) is not bootstrapped on the scan path, because the first download is large and the feature is off by default. Run supply-chain-sync yourself the first time. A refresh failure (offline host) never blocks a scan; it proceeds against the existing database.

The refresh behaviour is controlled by four orchestrator environment variables, all wired in docker-compose.yml:

KnobDefaultMeaning
OSV_DB_AUTO_REFRESHtrueSet false for a strictly air-gapped deploy (manual sync only)
OSV_DB_ECOSYSTEMSnpm (auto-refresh set)Ecosystems kept fresh automatically
OSV_DB_TTL_SECONDS86400Freshness window (24h)
OSV_DB_REFRESH_TIMEOUT900Ceiling so a slow download cannot stall a scan spawn

The incident catalog (threat intel)

A second offline dataset sits beside the OSV database and carries what OSV does not: the attacker domains, the remediation text, and the typosquat labels from the public supplychainattack.org incident catalog. It is ~5 MB and lives in its own read-only volume, redamon-sca-intel.

./redamon.sh sca-intel-sync             # populate it
./redamon.sh sca-intel-sync --force     # ignore the TTL and the retry floor

Like the OSV database it is refreshed lazily on the scan-spawn path and a failure never blocks a scan. Two differences, both because 5 MB is not 208 MB: it may populate a cold volume on the scan path, and its ceiling is 120s. It also has a retry floor, so a feed that is down or serving a bad payload is retried once an hour rather than on every scan.

KnobDefaultMeaning
SCA_INTEL_AUTO_REFRESHtruefalse for a strictly air-gapped deploy
SCA_INTEL_TTL_SECONDS86400Freshness window (24h)
SCA_INTEL_RETRY_SECONDS3600Retry floor after a failed or rejected fetch
SCA_INTEL_REFRESH_TIMEOUT120Ceiling on the sync sidecar
SCA_INTEL_BOOTSTRAP_ON_SCANtruefalse = never populate a cold volume on a scan
SCA_INTEL_MATCH_ENABLEDtrueKill switch for the captured-traffic match

What it adds

What you getWhere you see it
Incident contextsummary, remediation steps, blast radius, status and the feed revision, attached to findings that already existSCA table → Verdicts → the Incident column expands
Malicious hosts (recon)the target was seen contacting a host a published incident namesThreat Intel table (BaseURL rows) and the graph
Malicious hosts (traffic)the same, for requests the capture proxy recordedTraffic page → the ioc flag
Typosquatsharvested names that are near-misses of popular packagesSCA table → Verdicts, tool typosquat

Three things it deliberately does not do. It never changes a verdict — only an OSV MAL- id makes a package malicious, and a catalog match is name-only, which is weaker evidence. It never creates a node for the attacker host: that host is a third party your target contacted, not part of your target's attack surface, so it lives on the relationship instead. And a catalog that was never synced is recorded as "did not run", never as a clean result — an empty Incident column means "not in the catalog or never synced", not "this package is fine".

Scope, stated honestly. Of the incidents carrying attacker domains, roughly a fifth are browser-side (compromised script, CDN hijack, skimmer) and a quarter are install-time (a postinstall phoning home during npm install). RedAmon observes browser traffic and the JS it downloads, so it catches the browser-side class. This is not "detects supply-chain attacks" in general.


Layer L1: the Supply Chain Scanner (Other Scans)

L1 is a standalone scan you launch from the Red Zone. It audits a dependency manifest you provide, with no live target required.

The Supply Chain Scanner in the Other Scans modal

Step by step:

  1. Configure what to scan first, in Project Settings > Other Scans > Supply Chain Scanner (the gear on the card links straight there). Pick one input source:

    • Uploaded SBOM / lockfile (default),
    • GitHub repository, or
    • GitHub organization (scans every repository in an org — see below).

    Provide the input for the selected source (details below) and Save. Uploads are written immediately; the other fields are saved with the form.

  2. Open the project's graph (Red Zone). In the toolbar, click Other Scans.

  3. Find the Supply Chain Scanner card (bottom left, below GitHub Secret Hunt). It names the input it would read; until one is configured, Start stays disabled and the card links to the settings section.

  4. Click Start (or Queue org batch in organization mode). The logs drawer opens and streams the scan live. Use Pause / Resume / Stop as needed.

  5. When the scan completes, the Package / MalPackageFinding / Vulnerability nodes are merged into the live graph, and Download yields the raw result JSON.

Upload mode

In the settings section, click Upload file and choose a manifest. Accepted types: CycloneDX / SPDX SBOMs and lockfiles, extensions .json, .xml, .txt, .lock, .toml, .mod, .sum, .yaml, .yml (for example package-lock.json, yarn.lock, poetry.lock, go.sum, Gemfile.lock, bom.cdx.json). Max 10 MB. One file per project: a new upload replaces the current one. No API key is required for an upload.

File naming matters. OSV-Scanner picks its parser from the file basename, so keep the recognized lockfile / SBOM name (for example package-lock.json, not deps.json).

GitHub repository mode

In the settings section, enter a repository as owner/repo or https://github.com/owner/repo, and optionally a branch or tag. The repository is cloned shallowly inside the scan sandbox and its lockfiles are audited; the checkout is discarded afterwards.

  • Public repositories clone anonymously (no token needed).
  • Private repositories use the Supply Chain GitHub Token from Global Settings > API Keys. It is its own credential: GitHub Secret Hunt and the Secret Multiscanner each hold a different github.com token, so scoping or revoking one does not touch the others.

An L1 GitHub-repo scan anchors its packages to a GithubRepository node; an uploaded SBOM anchors to an SbomDocument node. Both anchors hang off the project's Domain when one exists, so the scan is part of the graph rather than a detached island.

GitHub organization mode

Auditing an organization one repository at a time does not scale, so this mode does it in bulk. In the settings section, enter a GitHub organization or user - either a bare name (letters, digits and dashes, 39 characters max) or its URL - then Queue org batch from the card in Other Scans.

Organization and user accounts share the same name rules, so which one you typed cannot be told from the string: RedAmon asks the API. It tries the organization endpoint first and falls back to the user endpoint on a 404. That fallback matters for what you can see:

You enterEnumerated asPrivate repos
an organization you belong to/orgs/<name>/reposincluded, as far as your token's membership reaches
another person's account/users/<name>/reposnever, whatever the token
your own account/user/repos (authenticated)included

RedAmon enumerates the account's repositories server-side and queues one supply-chain scan per repository, which then run one after another as capacity frees up. Follow their progress in the Scans tab → Scan queue (see Scan Timeline); each finished repo also lands in Run history as a Supply chain (repo) row.

  • This is a batch, not a single scan. The card replaces Start with Queue org batch while this mode is selected: Start runs the project's one configured input, whereas this queues many scans.
  • The mode is saved with the project, which is how the card knows to offer the batch. Your upload and repository values are kept, so switching back to either finds the previous input intact.
  • Which repositories are included is governed by the project's saved supply-chain org options: include forks, include archived, max repos, and deep analysis.
  • Public organizations enumerate anonymously. Private ones need the Supply Chain GitHub Token from Global Settings > API Keys. A token that exists but lacks access is reported as an error rather than silently falling back to a partial list.
  • The queued jobs are ordinary queue entries: cancel any of them from Scan queue before they start.

GitHub Enterprise

A self-hosted or custom-domain GitHub Enterprise server works in both GitHub modes, once it is registered:

  1. In Global Settings > API Keys, set GitHub Enterprise Host to the bare hostname (ghe.example.com; no scheme, port or path) and GitHub Enterprise Token to a PAT for that server.
  2. In the Supply Chain card, enter the account as a URL: https://ghe.example.com/orgs/acme-corp (or ghe.example.com/acme-corp). A bare name still means github.com.

Two rules are enforced server-side and again inside the scan container, and neither can be relaxed from the UI:

  • The host must be the one you configured. The host you type is fetched by the webapp and ends up in a git clone, so an unregistered host - or an IP literal, localhost, or a host carrying a port or credentials - is refused before any request leaves. The error names the host and tells you to register it.
  • Credentials are chosen by host. A GitHub Enterprise PAT is never sent to github.com, and the github.com token is never sent to your server. A host that somehow reaches the container without matching your configured one gets no credential at all.

Enumeration uses the Enterprise API root (https://<host>/api/v3), and clone URLs are rebuilt from the validated owner/repo on that host rather than from the clone_url the server returns.

If your Enterprise server uses a private CA, the webapp container needs it (NODE_EXTRA_CA_CERTS) for enumeration and the scan container needs it (GIT_SSL_CAINFO) for the clone; without it both fail on certificate verification.

Each repository is scanned exactly as in GitHub repository mode (shallow clone in the sandbox, lockfiles audited, checkout discarded), so every repo gets its own GithubRepository anchor in the graph.


Layer L2: Supply-Chain Recon (pipeline)

Against a live target with no manifest, L2 harvests the npm package set the target actually serves, verdicts it offline, and merges the same node types, anchored to the target's BaseURL nodes. It runs as GROUP 5.5 of the recon pipeline, immediately after JS Recon (whose downloaded JS and detected technologies it consumes), and is also runnable on its own as a partial-recon tool.

The harvest is pure parsing of data JS Recon already downloaded; it makes no new network request. Sources:

  1. Source-map mining extracts node_modules/<pkg> names from source maps (exact names, usually no version).
  2. Import mining collects bare specifiers from import / require.
  3. Technology to purl maps http_probe technologies (for example React:18.2.0) to versioned npm purls.
  4. retire.js (inside the hardened analyzer) reads a library name and version straight out of the served JavaScript, so it can verdict libraries the technology table misses.

Names harvested without a version are recorded as Package inventory but cannot be OSV-verdicted (OSV needs a version to match a version-specific advisory); they surface in the SCA table as unverdictable.

Configure it in the project form's JS Recon tab, under Supply Chain Recon:

SettingFieldDefaultMeaning
EnablesupplyChainReconEnabledOffRuns GROUP 5.5 during a full scan
EcosystemssupplyChainReconEcosystemsnpmWhich ecosystems to verdict
Deep analysissupplyChainReconDeepAnalysisEnabledOffRuns GuardDog behavioural analysis on OSV-flagged packages (opt-in)
Detect malicious hostsscaIntelCorrelationEnabledOnCompares the hosts this scan already saw against the incident catalog. Local lookup, no extra requests
Detect typosquattingsupplyChainTyposquatEnabledOffFlags names 1–2 characters from a popular package. The exact-match check against known-bad names always runs and is unaffected

You can also run just this module with Run partial recon on the section (tool id SupplyChainRecon), which fetches the served JS from the graph's BaseURL / Endpoint URLs (plus any you supply) and runs the harvest, verdict, and graph write standalone.

GuardDog deep analysis is opt-in and flagged-package-only. After the offline OSV pass, it takes the packages OSV already flagged (capped, malicious first) and runs guarddog <eco> scan inside the hardened analyzer image. Results become suspicious findings that attach to the existing Package node. A download failure becomes a soft_error finding (rendered as not analysed), never a silent clean.


Layer L3: agent tools

Two MCP tools the AI agent calls mid-engagement, exposed on the network_recon server inside the kali-sandbox. Neither writes graph nodes; each returns a compact text summary the agent reasons over. See the full entries in the AI Agent Guide.

  • execute_osv_scanner is passive and fully offline. It accepts a purl (for example pkg:npm/lodash@4.17.21, synthesized into a one-component SBOM), a workspace lockfile path, or an SBOM path, and returns the OSV verdict. MAL- is a terminal malicious verdict; CVE- / GHSA- are known-vulnerable. Available in all phases.
  • execute_guarddog runs GuardDog behavioural analysis of one named package (<ecosystem> <name> [version]). It is DANGEROUS (it downloads the attacker-authored tarball), so it dispatches to the hardened analyzer container rather than unpacking anything in the sandbox. A hit is suspicious, never a terminal verdict. Restricted to the informational and exploitation phases.

Reading the results: the Supply-Chain SCA table

Every layer writes into the graph; the Supply-Chain SCA table is where you read it. Open it from the Data Table tab's dropdown (PackageSearch icon, flagged NEW). It is the only view that joins the three node types, and it has three sheets:

SheetOne row perAnswers
VerdictsMalPackageFindingWhat is on fire right now
PackagesPackage, with rolled-up countsWhat am I running, and how much of it was actually checked
AdvisoriesVulnerability{source:'osv'}The CVE / GHSA half

Three things the table derives rather than reads:

  • Verdict is three-state: malicious / suspicious / not analysed. A finding with soft_error is a package GuardDog never verdicted, rendered as unchecked rather than as a low-severity suspicious hit.
  • unverdictable is a first-class package status and a headline count. A short verdict list next to a large unversioned count means "mostly unchecked", not "mostly clean".
  • Origin (L1 repo / L1 SBOM / L2 live) is inferred from the anchor node.
  • The verdict wording follows the tool that produced it. A suspicious finding from GuardDog reads as a behavioural hit; one from typosquat reads as a near-miss of a popular name. (Before 6.10.0 every non-malicious verdict was labelled a GuardDog hit regardless of its source.)

The Incident column appears on the Verdicts sheet when the incident catalog matched the package. Expanding it shows the write-up: summary, status, blast radius, remediation steps, the feed revision that produced the enrichment, and a link to the incident. An empty column means the package is not in the catalog or the catalog was never synced — it is not a statement that the package is safe.

Each sheet is deep-linkable (/graph?table=supplyChainSca&sheet=advisories) and exports per sheet to XLSX / JSON / MD. The table is empty until an L1 scan has run from Other Scans or a recon scan has run with Supply-Chain Recon enabled, and it needs the offline OSV database populated first (./redamon.sh supply-chain-sync npm). See the Red Zone table reference.


Graph model

Two node types are shared by all layers, so a repo scan, an SBOM upload, and a live harvest of the same project dedup onto the same nodes. See the full schema on Attack Surface Graph.

flowchart LR
    DOM[Domain]
    GR[GithubRepository - L1 repo]
    SB[SbomDocument - L1 upload]
    BU[BaseURL - L2 live]
    PK[Package]
    MF[MalPackageFinding]
    VU[Vulnerability source=osv]

    DOM -->|HAS_REPOSITORY| GR
    DOM -->|HAS_SBOM_DOCUMENT| SB
    GR -->|DEPENDS_ON| PK
    SB -->|DEPENDS_ON| PK
    BU -->|DEPENDS_ON| PK
    PK -->|FLAGGED_AS| MF
    PK -->|HAS_VULNERABILITY| VU
    BU -->|CONTACTS_MALICIOUS_HOST| TP[ThreatPulse - incident]
  • Package is a discovered dependency (purl, ecosystem, name, version, source, source_path). Merge key (purl, user_id, project_id).
  • MalPackageFinding is a verdict about a package (verdict = malicious / suspicious, source_tool = osv / guarddog / typosquat, advisory_id, severity, soft_error, aliases). Merge key (finding_id, user_id, project_id). When the incident catalog matched, it also carries incident_id, incident_url, incident_summary, incident_blast_radius, incident_remediation, incident_status and incident_feed_revised.
  • ThreatPulse is reused for a matched incident (pulse_id prefixed sca-), reached by CONTACTS_MALICIOUS_HOST from the BaseURL that contacted it. That edge is not APPEARS_IN_PULSE: the OTX edge means "this asset of mine is named in the report", while this one means "my target reached someone else's malicious host". The attacker host is never a node; it rides the relationship in matched_host, which is part of the merge key so several hosts under one incident do not collapse onto a single edge.
  • All writes MERGE (idempotent), every key is tenant-scoped, and only OSV MAL- ids become verdict=malicious.

Security posture

The feature separates the code that touches untrusted bytes from the code that holds secrets. Package tarballs, target-served JS, and registry metadata are all attacker-influenceable; the Neo4j password and the GitHub token are not.

  • DIRTY zone (the analyzer container): cap_drop=ALL, read-only rootfs + tmpfs, non-root, memory / pids / cpu caps, no secrets, on an isolated network. It runs GuardDog and retire.js and emits only a schema-validated JSON artifact.
  • CLEAN zone (the L1 scan writer and the recon container): holds the Neo4j credentials, validates the artifact (charset + caps + shape) before it crosses the boundary, and writes the graph.
  • Offline by default: the OSV verdict path makes zero network calls. GuardDog's registry egress is opt-in and fails closed.
  • NO-INSTALL invariant: RedAmon never runs npm / pip install on a target manifest (lifecycle scripts are RCE); it parses statically only.

For the container topology, the broker allowlist, and the full artifact schema, see README.SUPPLY_CHAIN.md.