Supply-chain malware scanner and MCP server: vet packages in 15 ecosystems before install, offline.
Open-source supply-chain security scanner that runs locally and offline. It matches known-malicious packages, extensions, plugins, providers, container images and CI actions in 15 ecosystems, with tested matchers ready for more (see Ecosystem Coverage), reading manifests and lockfiles at any depth of a repository, including the transitive dependencies a lockfile pins; and it analyses what you install for malware behavior: GlassWorm, Vidar, Shai-Hulud, fake AI tool repos, account takeovers and 350+ threat indicators in all. It generates CycloneDX 1.6 SBOMs with real dependency inventories, grades SLSA provenance (parses and structurally validates in-toto/DSSE attestations), and correlates findings into attack-chain incidents. Supports EU Cyber Resilience Act SBOM and component-documentation work, and NIS2 supply chain risk-management measures.
| Package | |
| Build | |
| Security |

Scan a project. No account, no configuration, and the scan itself makes no network request:
npx supply-chain-guard scan .
It exits 1 on a high finding or a scan that could not examine everything, and
2 on a critical finding, so it can gate a script as it is. To add the historical
package catalog, run npx supply-chain-guard feed refresh once with network
access. The catalog is cached per user (see Catalog cache), so
scans from any directory use it; it belongs to the installed version, so refresh
again after an upgrade.
Gate every pull request:
- uses: actions/checkout@v4
- uses: homeofe/supply-chain-guard@v6.4.3
Let your AI coding agent check a package before it installs it (MCP):
npm install -g supply-chain-guard
claude mcp add supply-chain-guard supply-chain-guard mcp
Every release is published to npm from this repository's CI with a signed
SLSA provenance attestation,
and the Action installs that exact version. Each GitHub Release also carries
the tarball with that provenance as a Sigstore bundle (.sigstore.json). To check
one yourself:
gh attestation verify supply-chain-guard-X.Y.Z.tgz \
--bundle supply-chain-guard-X.Y.Z.tgz.sigstore.json \
--repo homeofe/supply-chain-guard --digest-alg sha512
Everything else, from output formats to policies, is further down: Quickstart, GitHub Action, For AI Coding Agents (MCP).
For a deep dive into how GlassWorm infiltrates the software supply chain and the detection techniques behind this tool, read the blog post: How GlassWorm Gets In and How We Locked It Out.
Package, extension, plugin and image identities are matched against the threat feed. This list is generated from the indicators that actually ship, so an ecosystem is only named as covered once there is something to match:
Known-malicious indicators ship for 15 ecosystems: npm, PyPI, RubyGems, Composer (PHP), NuGet (.NET), Cargo (Rust), Go modules, Maven / Gradle / SBT / Bazel, Dart / Flutter (pub), Terraform / OpenTofu providers, Container images, GitHub Actions, VS Code / Open VSX extensions, Browser extensions (Chrome, Edge, Firefox) and JetBrains plugins.
Homebrew has a tested matcher and ships an indicator, but is not counted: its one indicator (the compromised Trivy tap release 0.69.4) needs a version, and only the legacy Brewfile.lock.json records one; current Homebrew writes no lock file, so a Brewfile alone cannot match it.
Matchers are built and tested, but no malicious package is publicly known yet, for 8 more: Swift Package Manager, CocoaPods, Hex (Elixir / Erlang), CRAN (R), Conan (C / C++), Terraform / OpenTofu modules, Helm charts and Ansible Galaxy. They report the day an indicator is published, through the importer or a curated entry.
The files read per ecosystem are in Ecosystem Coverage.
Manifests and lockfiles are read wherever they sit in the tree, so a monorepo service or a .NET project
in src/App/ is covered, and a lockfile's transitive dependencies are matched, not only direct ones.
A version pin fires only on the exact malicious release; a hijacked legitimate package is never blocked by name. Registry-specific identities stay separate (Marketplace vs Open VSX, Chrome vs Edge, public vs private registries), and a commit SHA or image digest matches under any repository name.
import()FILE_TOO_LARGE_SKIPPED (info severity, never affects exit codes) instead of being silently skipped - padding a payload past the limit no longer hides it from the report#!/bin/sh, #!/usr/bin/env node, python3, ruby, perl and others), and an extensionless file named like a git hook (pre-commit, pre-push, ...) is read as shell even without one, so hooks under scripts/hooks/ or .husky/ and bin/ launchers in a directory scan or an npm tarball are content-scanned. *.bats suites are read as bash and, like *.test.ts, count as test files, and Perl source is read as .pl/.pm too. A file with no extension, no shebang and no hook name is still not readnpm <pkg> mode, corroborates a package's claimed repository against the repo's own package.json and flags a repo borrowed from an unrelated popular project to inherit its stars/trust (conservative: monorepos, forks, related names, and unfetchable repos are not flagged).github/workflows/*.md that ingest untrusted issue/PR text, hold a cross-repo token, and can post publicly - the prompt-injection data-leak postureimage: value (Compose, Kubernetes,
workflow containers), see Ecosystem CoverageDetects LLM-control tokens embedded in package READMEs that target downstream AI coding agents (Claude Code, Cursor, Copilot) reading the docs on behalf of a human developer. The example tokens below are HTML-escaped in the raw README so the patterns do not flag this documentation itself - they render normally in any markdown viewer:
<system-reminder> / <system-prompt> (Anthropic family)<|im_start|> / <|im_end|> ChatML (OpenAI, Llama, Mistral, Qwen)[INST] / [/INST] (Mistral, Llama instruction-tuned)<|system|> / <|user|> / <|assistant|> (Phi, Gemma, Granite, generic role tokens)Not credentials: the map of your network that a public repository hands out for free. Private and non-routable addresses (RFC1918, CGNAT, link-local, IPv6 ULA), internal-only hostnames (.internal, .local, .lan, .corp, .home, .intranet), clone URLs pointing at a forge that is not a known public one, developer home-directory paths, and internal service endpoints. Reported at medium (reconnaissance value, not compromise), with an optional deny-list for the names only your project knows. See Internal Disclosure.
Links individual findings into incident-level attack chains:
Multi-dimension trust scoring for package and repository inspections:
npm, pypi, repo, and remote scan <github-url> modes; local directory scans evaluate Code Quality and Dependency Trust with renormalised weights).Requires Node.js 22 or newer. Every release runs its complete test suite, and
installs and executes its own packed tarball, on Node 22 and on Node 24, the current
Active LTS. Full policy, including what the
package is published from and what the Action and container image run on:
docs/node-support.md.
npm install -g supply-chain-guard
Or use directly with npx:
npx supply-chain-guard scan ./my-project
Run the scanner as a pre-commit hook (Python-ecosystem teams get the same gate without touching npm). Add this to your .pre-commit-config.yaml:
repos:
- repo: https://github.com/homeofe/supply-chain-guard
rev: v6.4.3
hooks:
- id: supply-chain-guard
The scanner writes its risk history to .scg-history/ in the scanned repo;
it is not written when --no-history is set, which the hook now uses. For
plain scans without that flag, add the folder to your .gitignore.
If a file in .scg-history/ cannot be read, the scan says so and fails. The
two stores there, risk-history.json and triage-decisions.json, are the
baseline that trend, forecast and triage-governance rules compare against. A
store that is absent is a first scan and stays silent, which is the normal case
on a fresh checkout or a hosted runner. A store that exists but does not parse,
because a scan was interrupted mid-write or the file was edited by hand, is lost
evidence, and the two are deliberately not reported the same way: the scan emits
RISK_HISTORY_UNREADABLE or TRIAGE_STORE_UNREADABLE at high, sets
partialScan: true, and exits nonzero regardless of --fail-on, because an
unusable baseline is an indeterminate result rather than a clean one. The
unreadable file is left on disk rather than overwritten, so complete entries can
still be recovered from it, usually by closing the truncated JSON array by hand.
Delete the file to start a new baseline once you have decided the old trend is
expendable. --no-history does not silence this: that flag stops the write, not
the read, so a corrupt store still degrades the verdict and is still reported.
The hook scans the repository root on every commit and fails on high or critical findings.
Run the scanner without a Node toolchain via the official multi-arch image (linux/amd64, linux/arm64), published to GHCR on every release tag:
docker run --rm -v ${PWD}:/scan ghcr.io/homeofe/supply-chain-guard:6.4.3 scan /scan
${PWD} works in bash, zsh, and PowerShell; in cmd.exe use %cd% instead.
The image keeps the catalog cache in /cache (SCG_CACHE_DIR), so it is gone
after --rm. To keep it across runs, mount a named volume there:
docker run --rm -v scg-cache:/cache ghcr.io/homeofe/supply-chain-guard:6.4.3 feed refresh
docker run --rm -v scg-cache:/cache -v ${PWD}:/scan ghcr.io/homeofe/supply-chain-guard:6.4.3 scan /scan
# Scan a local directory
supply-chain-guard scan ./my-project
# Scan a GitHub repo (includes trust signal analysis)
supply-chain-guard scan https://github.com/user/repo
# Analyze a GitHub repo for trust signals + malware
supply-chain-guard repo https://github.com/user/repo
# Scan an npm package (downloads without installing)
supply-chain-guard npm suspicious-package-name
# Scan a PyPI package
supply-chain-guard pypi suspicious-package
# Scan a VS Code extension
supply-chain-guard vscode publisher.extension-name
# Scan a VS Code extension from the Open VSX registry (VSCodium etc.)
supply-chain-guard vscode publisher.extension --registry openvsx
# Detect dependency confusion
supply-chain-guard confusion ./my-project
# Scan an entire GitHub organization
supply-chain-guard org my-github-org
# Scan only files changed since a commit (diff mode)
supply-chain-guard scan ./project --since HEAD~5
# Opt in to public registry queries (requires network; sends package names to npm and PyPI)
supply-chain-guard scan ./project --check-registry
# Expand every repeated text finding (text groups by rule and file by default)
supply-chain-guard scan ./project --all-findings
# Opt into correlated two-tier gating and composite risk scoring
supply-chain-guard scan ./project --two-tier
# Query resolved npm coordinates plus OSV, EPSS, CISA KEV and Scorecard
# This requires network access and implies --two-tier
supply-chain-guard scan ./project --external-intel
# Supply a previously obtained Scorecard value; also implies --two-tier
supply-chain-guard scan ./project --scorecard 8.4
# Monitor a Solana C2 wallet
supply-chain-guard monitor <wallet-address> --once
supply-chain-guard scan ./project # Human-readable text (default)
supply-chain-guard scan ./project --format json # JSON (for CI/CD pipelines)
supply-chain-guard scan ./project --format html # Standalone HTML report
supply-chain-guard scan ./project --format markdown # Markdown (for PR comments)
supply-chain-guard scan ./project --format sarif # SARIF 2.1.0 (GitHub Code Scanning)
supply-chain-guard scan ./project --format sbom # CycloneDX 1.6 SBOM with real dependency inventory
supply-chain-guard scan ./project --sbom-output sbom.json # The same SBOM, written to a file instead of stdout
supply-chain-guard scan ./project --format badge # Shields.io endpoint JSON
supply-chain-guard scan ./project --format gitlab # GitLab Dependency Scanning report (security-report-schemas 15.2.4, see examples/gitlab-ci.yml)
supply-chain-guard scan ./project --format markdown --json-output canonical.json # Same scan, human report plus canonical JSON
Publish the badge JSON from CI (gist or gh-pages), then point Shields at it:
The scan exits non-zero when it finds high/critical issues - exactly when the
badge MUST update to red. Neutralize the exit code on the generate step (or use
if: always() on the publish step) so a bad scan never freezes the badge green:
- name: Generate badge JSON
run: supply-chain-guard scan . --format badge > badge.json || true
- name: Publish to gist
if: always()
run: gh api gists/YOUR_GIST_ID -X PATCH -F "files[badge.json][content]=@badge.json"
env:
GH_TOKEN: ${{ secrets.BADGE_GIST_TOKEN }}

supply-chain-guard scan ./project --fail-on critical # Fail only on critical
supply-chain-guard scan ./project --fail-on high # Fail on high or above
supply-chain-guard scan ./project --fail-on info # Fail on any finding
--min-severity may reduce report noise, but it cannot be stricter than the
active --fail-on gate because that would hide findings required for the exit
verdict. Invalid combinations fail before scanning. Incomplete coverage also
exits nonzero regardless of the severity threshold and is reported as
partialScan: true in JSON.
supply-chain-guard scan ./project --min-severity high
supply-chain-guard scan ./project --exclude SOLANA_MAINNET,HEX_ARRAY
Secret scanners answer one question: did a credential get committed? This family answers a different one: did our internal topology get committed?
Internal hostnames, private LAN addresses, self-hosted forge URLs, developer home directories and private repository names are not credentials, so no secret scanner reports them. Together they are the reconnaissance map an attacker draws before touching anything: what exists, what it is called, where it listens, and who works on it. It leaks through the same boring channels every time. A copied clone command in a README. A .env.example that kept the real staging host. A comment with the path the author built from. A lockfile pointing at an internal registry. None of it is a secret, all of it is intelligence, and it stays in git history long after the file is fixed.
The rules are shape-based, so they work on a repository whose owner has configured nothing at all. You never have to write down what your infrastructure is called in order to be protected from publishing it.
| Rule | Severity | Shape |
|---|---|---|
INTERNAL_PRIVATE_IP | medium | RFC1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), CGNAT (100.64.0.0/10), link-local (169.254.0.0/16) |
INTERNAL_PRIVATE_IPV6 | medium | IPv6 Unique Local Addresses (fc00::/7) |
INTERNAL_HOSTNAME | medium | Hostnames in an internal-only TLD: .internal, .local, .lan, .corp, .home, .intranet |
INTERNAL_SERVICE_ENDPOINT | medium | http(s)://HOST:PORT where HOST is private or internal |
INTERNAL_GIT_REMOTE | medium | ssh://git@<host>:<port>/<path> and scp-style git@<host>:<path> where the host is not a known public forge |
INTERNAL_DEV_PATH | medium | C:\Users\<name>\, /home/<name>/, /Users/<name>/ in committed code or docs. /Users/ is matched case-sensitively, because /users/ is a REST route |
INTERNAL_SINGLE_LABEL_URL | low | A URL whose host has no domain at all, so it only resolves through internal DNS or a hosts file |
INTERNAL_DENYLIST_MATCH | medium | A term your project configured (see below). Off unless configured |
INTERNAL_DISCLOSURE_TRUNCATED | info | A limit stopped this family short on one file (see Bounded cost). Never silent about a gap |
Severity follows the host, not the rule. A host with no domain part is the weakest signal in the family whichever rule reports it, so a dotless payments host with a port is low, exactly like the same host without one. Only a dotted internal name or a private address makes an endpoint medium.
INTERNAL_GIT_REMOTE is the one worth pointing at: it finds a self-hosted forge without anyone having to name it. Any clone URL that is not github.com, gitlab.com, bitbucket.org, codeberg.org, git.sr.ht and the other well-known public hosts is, by shape alone, a forge somebody runs privately.
Topology is reconnaissance value, not compromise, so the family reports medium and low. high and critical stay reserved for credential-shaped findings, which the existing rules already own.
Practically: the default gate exits non-zero on critical and high only, so upgrading cannot turn a passing build red. --fail-on high and --fail-on critical are equally unaffected. Two things do change: the risk score rises (each medium adds points), and a pipeline that runs --fail-on medium or lower will see the new findings. If you would rather not see them at all, they respect every existing control:
rules:
disable:
INTERNAL_PRIVATE_IP: RFC1918 addresses are expected in this repository's fixtures
INTERNAL_HOSTNAME: internal names are already covered by a separate review
The parser reads block style only; a flow sequence on one line
(disable: [A, B]) is reported as POLICY_UNKNOWN_KEY and disables nothing.
A rule that screams on every README gets switched off, and a switched-off rule protects nothing. Three independent layers keep this quiet.
1. The reserved documentation space never fires. Anything written the way the RFCs intend is invisible to these rules:
192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24example.com, example.org, example.net, the .example TLD, .invalid, .testlocalhost URLsrunner, vscode, ubuntu, jenkins, you, dev, user, Public and moredb, redis, api, minio, nginx, and the unix / npipe pseudo-hosts that mean "a UNIX domain socket, not a machine"10.0.0.0/16 is a subnet layout, not a host, so it is not reported (a /32 host route is)169.254.169.254 (and the ECS 169.254.170.2, Amazon Time Sync 169.254.169.123, Alibaba 100.100.100.200), the Kubernetes defaults 10.96.0.1 and 10.96.0.10 and the k3s 10.43.0.1 / 10.43.0.10, the default service and pod CIDRs (10.96.0.0/12, 10.244.0.0/16, 10.42.0.0/16), the Docker bridge gateway 172.17.0.1, and the Docker Desktop names host.docker.internal and friends. A real address inside the same ranges is still reported.2. A match has to sit where its rule can mean what it claims.
config.internal.timeout, com.acme.internal.util and settings.local.json are never hosts../config.local, src/config.local and ../lib/settings.local are module specifiers. https://db.example.corp/, //registry.svc.example.corp/ and git@forge.internal.example:... still are hosts.( is a method call: res.local(name, val) in a changelog is not a machine.config.internal.timeout and state.local.value are property accesses. Data and config files (.yml, .json, .toml, .env, Dockerfile, lockfiles) carry unquoted values, so no quotes are required there./Users/ is matched case-sensitively and :id, {id}, <id> and ${user} are rejected after the account segment, so app.get("/users/:id"), "/users/{id}" and app.get("/users/profile/edit") are routes, not macOS home directories. A Windows path keeps both spellings, because C:\users\ is unambiguous.3. The surface decides which rules stay armed.
| Surface | Rules that still fire |
|---|---|
Source files (.ts, .py, .tf, .yml, Dockerfile, .npmrc, lockfiles) | all of them |
Documentation prose and fenced code blocks: .md / .rst / .txt, anything under docs/ | everything except the single-label URL |
Markdown inline code spans, and fenced blocks tagged ```text / ```plaintext | hostname, endpoint, clone URL |
Files that exist to BE an example: examples/, samples/, fixtures/, testdata/, *.example.* / *.sample.* / *.template.* | hostname, endpoint, clone URL |
Test, spec, mock and fixture files and directories (test/, tests/, spec/, e2e/, __tests__/, __mocks__/, *.test.*), minified and bundled output | none |
The reasoning changed here, deliberately. Documentation used to be excluded wholesale, which silenced precisely the case this family exists for: a private address or a developer path inside a README is one of the most common ways internal topology reaches a public repository, and a /home/<name>/ in a pasted stack trace is a real leak, not a teaching aid. The reserved namespace above is what protects a writer who follows the RFCs, and it works on every surface. What stays excluded is what measurement showed to be noise rather than signal: inline code spans (on the sample used to tune this, eight findings, all of them API signatures or documented examples), placeholder fences, and files whose whole purpose is to show a shape.
Two things are reported on purpose even though they can be examples. Kubernetes in-cluster names (<service>.<namespace>.svc.cluster.local) name your service inventory. And an address or path inside a code comment (a JSDoc @example block, say) is reported, because a comment is the single most common place a real host gets written down and nothing distinguishes an illustrative address from a real one there. Use RFC5737 addresses in code examples, or suppress by path.
A generated bundle is one 800 KB line, and a rule family that takes minutes on it is a rule family that gets switched off. Four limits keep the cost flat, and none of them is silent:
FILE_TOO_LARGE_SKIPPEDINTERNAL_DEV_PATH and INTERNAL_GIT_REMOTE) are each written as two
patterns, so those can reach 50 from a single file. When the per-file cap
drops findings it keeps the most severe ones.Whenever a limit is reached, the file gets one INTERNAL_DISCLOSURE_TRUNCATED finding at info severity naming the limit. A scanner that quietly stopped looking is indistinguishable from a repository with nothing to find, and that is not a trade this tool makes.
On top of that, everything else already in this tool applies: suppress with a path: glob, ignore: globs, --exclude, --min-severity, and inline // scg-ignore-next-line INTERNAL_HOSTNAME reason.
Shape rules cannot know that sample-service is one of your private repositories. A deny-list can. But a list of your internal hostnames committed to a public repository is exactly the leak you were trying to prevent, so there are three ways to configure one and only one of them puts plaintext in the repo.
# yaml-language-server: $schema=./node_modules/supply-chain-guard/policy-schema.json
# Whether the downloadable historical catalog must be present for a scan to
# count as complete. "optional" (the default) reports THREAT_FEED_CATALOG_MISSING
# and carries on; "required" raises it to critical.
catalog: optional
internalDisclosure:
# (a) HASHED. Publishable: the digest hides the term from a reader and from
# grep. It is not a vault - see "What hashing is worth" below.
# Generate with: supply-chain-guard internal-hash forge.internal.example
hashedTerms:
# sha256("forge.internal.example") and sha256("acme/sample-service"),
# so you can verify the recipe below against these two lines.
- 113fbef8cb1afd8d755cfa3c5b954244973c1b4182824c64755235de60a3d106
- 479ec322598b9047aaac200d2c1c2d5ab9658ce50ef7e60dc1081741f037d7d3
# (b) EXTERNAL. Full regex/plaintext patterns that must never be published.
# Gitignore this file, or provision it on the runner. Matches are
# reported REDACTED, so the report cannot leak it either.
externalFile: .scg-internal-terms.local
# (c) PLAINTEXT. For a private repository scanning itself, or terms that
# are not sensitive. Literals, or /regex/flags.
patterns:
- sample-service
- /build-\d{2}\.corp/
Hashing recipe. Normalisation is trim, then lowercase. Then sha256, lowercase hex. That is the whole rule, so any tool can reproduce it:
# Bundled helper (prints only the digest, so nothing sensitive rides along)
supply-chain-guard internal-hash forge.internal.example
# The same digest, without this tool
printf '%s' "forge.internal.example" | tr 'A-Z' 'a-z' | sha256sum
What hashing is worth, honestly.
As a matcher it is exact-token matching, nothing more. A token is a maximal run of letters, digits, ., _ and - (so https://forge.internal.example/x yields forge.internal.example), plus an org/repo pair and a .git suffix stripped, all lowercased. A hashed entry for forge.internal.example therefore matches that host but not sub.forge.internal.example, and there is no way around it: a scanner that could match substrings of a hash would be a scanner that could recover the term. When you need substring or regex power, use externalFile (b).
As a secret it buys less than "hashed" suggests, and it is worth saying plainly. An unsalted, single-round sha256 of a low-entropy value is dictionary-attackable: hostnames come from a small, guessable space (a short site or service word, a two-digit index, one of a handful of internal TLDs), so anyone with your repository can hash candidate names until one matches. What a digest genuinely buys is that the term is not sitting in the file to be read, copied or grepped, and that it does not travel into a report, a log or a screenshot. That is real, and it is not the same as being unrecoverable.
If you need the stronger claim, salt it with a value that lives outside the repository:
export SCG_INTERNAL_HASH_SALT="$(openssl rand -hex 16)" # store it wherever your CI secrets live
supply-chain-guard internal-hash forge.internal.example # generates a salted digest
internalDisclosure:
hashSalted: true # says the digests below are salted
hashedTerms:
- <salted digest>
The salt has to be held outside the repository to be worth anything: a salt committed next to the digests is hashed by the same reader who reads them, which is why there is no config key for the salt itself. hashSalted: true is what keeps this fail-visible - a scan that runs without the salt matches nothing, which looks exactly like a clean repository, so the declaration turns that silence into an INTERNAL_DENYLIST_UNAVAILABLE finding instead.
An environment variable does the same thing as externalFile without touching the committed config at all:
SCG_INTERNAL_DISCLOSURE_FILE=~/.config/scg/internal-terms supply-chain-guard scan .
The external file is one entry per line, # for comments, sha256:<digest> for a hashed entry, /pattern/flags for a regex, anything else is a case-insensitive literal. If the file is configured but absent (a shared CI runner that never received it), you get an INTERNAL_DENYLIST_UNAVAILABLE finding at info severity rather than silence: a deny-list that quietly stopped running looks exactly like a repository that is clean. An entry that cannot be compiled is reported the same way (INTERNAL_DENYLIST_INVALID_ENTRY, medium). Neither finding ever prints the entry, and the environment variable is named but its value is not, because a path can itself contain an account name.
The two sources are not equally trusted, and the difference is deliberate. SCG_INTERNAL_DISCLOSURE_FILE is set by whoever runs the scan, so it may name any path on the machine and carry any pattern. internalDisclosure.externalFile and internalDisclosure.patterns live in the committed policy file, which travels inside the repository being scanned, and scanning a repository you do not own is the ordinary case for this tool. Entries from there are therefore bounded:
externalFile must stay inside the scanned directory. An absolute path is refused, a relative path that climbs out with .. is refused, and so is one that leaves through a symbolic link. The file is not opened, so nothing about a path outside the tree reaches the report. The bound is the scanned directory and nothing narrower: a path that stays inside it is still read, .git/config included, so a committed externalFile can still point at whatever your runner wrote into the workspace. Matches from it stay redacted.patterns, or from an externalFile that is inside the tree, is capped at 200 characters and refused when it quantifies a group that already contains a variable quantifier ((a+)+, (a?)*, and the like). That shape can take exponential time to report no match, so one committed line would otherwise occupy a runner until the workflow times out.INTERNAL_DISCLOSURE_TRUNCATED rather than running on.A refusal is an INTERNAL_DENYLIST_REFUSED finding at medium severity, and like every other coverage finding it marks the scan partial rather than passing quietly. In the published Action a partial scan exits 1 on its own, independently of fail-on. None of this applies to the environment-variable source.
Two limits of the shape check, both worth knowing before you upgrade.
It refuses more than it has to, and the shape it most often refuses is the ordinary one. A chained label group is how an internal hostname is normally written, and it is rejected even though it is linear in practice:
internalDisclosure:
patterns:
- /(?:[a-z0-9-]+\.)+corp\.example/ # REFUSED: quantified group holding "+"
- /[a-z0-9.-]+\.corp\.example/ # accepted, and matches the same hosts
If you have the first form today, in patterns or in your own gitignored externalFile, rewrite it before you upgrade. Left as it is, the term stops being looked for, the scan becomes partial, and the Action exits 1.
It also refuses less than it has to, so an accepted pattern is not a promise about time. The check reads the source text, which cannot see ambiguity that comes from overlapping alternation, so /(a|a)+$/ and /(a|ab)+$/ are accepted and are still catastrophic, and the wall-clock budget cannot interrupt a match that is already running. Availability from a committed pattern is narrowed here, not closed; the remaining case is tracked on issue 169.
One more note on the paradox. allowlist.domains also answers INTERNAL_HOSTNAME, INTERNAL_SERVICE_ENDPOINT and INTERNAL_GIT_REMOTE for a given host, which is convenient and publishes the host name. If that is not acceptable, suppress by path instead, which names nothing:
suppress:
- rule: INTERNAL_HOSTNAME
reason: vendored upstream config, reviewed
path: vendor/**
Create .supply-chain-guard.yml in your project root to customize behavior:
rules:
# Every disabled rule needs a written reason, the same bar `suppress` has met
# since v5.3. The bare list form (`- HEX_ARRAY`) still disables the rule and is
# reported as POLICY_DISABLE_NO_REASON.
disable:
HEX_ARRAY: minified vendor bundles in this repository, reviewed 2026-08
CHARCODE_OBFUSCATION: same bundles, same review
severityOverrides:
GHA_UNPINNED_ACTION: medium
allowlist:
packages:
- internal-utils
domains:
# Suppresses THREAT_INTEL_MATCH / IOC_KNOWN_C2_DOMAIN findings whose matched
# host is this domain or a subdomain of it.
- company.example.internal
githubOrgs:
# Trusted action publishers. Suppresses the ownership-trust findings
# (GHA_THIRD_PARTY_ACTION, GHA_TAG_NOT_SHA) for actions owned by these
# orgs. Pinning and known-malicious-SHA rules stay armed: trusting an org
# says who publishes the code, not that every version of it is safe.
- my-org
# Skip files matched by these path globs (** / * / ?) during the scan. These
# files are never opened, so nothing about them reaches the report except the
# policy block below. Each glob needs a written reason; the bare list form
# (`- vendor/**`) still skips the path and is reported as POLICY_IGNORE_NO_REASON.
ignore:
"vendor/**": third-party code, tracked by the upstream project's own scanning
"**/*.min.js": build output, scanned at source instead
suppress:
- rule: RELEASE_EXE_ARTIFACT
reason: Legitimate Windows installer
# Optional path glob: suppress a rule only under a matching path.
- rule: EVAL_ATOB
reason: Vendored third-party bundle, reviewed
path: vendor/**
baseline:
file: .scg-baseline.json
Findings can also be suppressed inline with a comment on the line directly
above them: // scg-ignore-next-line RULE reason (JS/TS) or
# scg-ignore-next-line RULE (Python/YAML/shell).
The policy file is read from the directory being scanned, and from nowhere else. There is no flag, environment variable or Action input that points the scanner at a policy outside the scan target.
On a pull_request event the checkout materialises the head of the proposing
branch, so the policy that governs the scan is the one on the branch under
review, not the one on your default branch. A change that adds
.supply-chain-guard.yml alongside the code it excuses is applying its own
policy to itself. Anyone who can push a branch can therefore narrow the scan of
that branch.
That is a property of reading policy from the tree, and it is stated here rather than left to be discovered. What it is not is silent:
ignore:, which removes files before any rule opens them
and used to leave no trace anywhere.POLICY_DISABLE_NO_REASON, POLICY_IGNORE_NO_REASON,
POLICY_SUPPRESSION_NO_REASON), so an undocumented exclusion costs a line in
the report rather than nothing.If your threat model includes an untrusted proposer, the controls that actually
hold are outside this tool: require review on .supply-chain-guard.yml through
CODEOWNERS, or scan a base-ref checkout in a separate job. Treat a policy file
in a pull request diff as a change to your security gate, because it is one.
Only report NEW findings (ignore known baseline):
# Save current findings as baseline
supply-chain-guard scan ./project --save-baseline .scg-baseline.json
# On subsequent scans, only show new findings
supply-chain-guard scan ./project --baseline .scg-baseline.json
╔══════════════════════════════════════════════════════════════════════════════╗
║ supply-chain-guard v5.1.0 ║
╚══════════════════════════════════════════════════════════════════════════════╝
Target ./suspicious-package
Type directory · 18 / 18 files scanned
Duration 142 ms
Time 2026-04-07T12:00:00.000Z
┌────────────────────────────── DETECTED RISK ───────────────────────────────┐
│ │
│ 83 / 100 █████████████████████████████████░░░░░ CRITICAL │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────── FINDINGS SUMMARY ───────────────────────────────┐
│ CRITICAL 3 ████████████████████████████████ │
│ HIGH 1 ██████████ │
│ MEDIUM 0 ──────────────────────────────── │
│ LOW 0 ──────────────────────────────── │
│ INFO 0 ──────────────────────────────── │
└──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────── FINDINGS ───────────────────────────────────┐
│ │
│ [CRITICAL] DEAD_DROP_STEAM │
│ Steam Community profile URL used as dead-drop C2 resolver │
│ src/config.js:12 │
│ match https://steamcommunity[.]com/profiles/76561198... │
│ fix Remove external URL resolution; use static configuration │
│ │
│ ············································································· │
│ │
│ [CRITICAL] VIDAR_BROWSER_THEFT │
│ Browser credential file access (infostealer pattern) │
│ src/steal.js:45 │
│ match AppData[...]Google[...]Chrome[...]Login Data │
│ fix Never access browser credential stores │
│ │
│ ············································································· │
│ │
│ [CRITICAL] DROPPER_TEMP_EXEC │
│ Dropper: file written and executed from temp directory │
│ src/loader.js:23 │
│ match saveFile(tmpdir, payload); exe‹c›(tmpPath) │
│ fix Remove dropper logic; audit all exec() call sites │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────── TRUST BREAKDOWN ─────────────────────────────────┐
│ Publisher ██████░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 20/100 │
│ Code █████████░░░░░░░░░░░░░░░░░░░░░░░░░ 30/100 │
│ Dependencies ████████████████████████████████████ 100/100 │
│ Release ██████████████████████████░░░░░░░░░ 80/100 │
│────────────────────────────────────────────────────────────────────────────│
│ Assessed █████████████░░░░░░░░░░░░░░░░░░░░░░ 48/100 │
│ 4/4 trust dimensions assessed │
└──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────── CORRELATED INCIDENTS ───────────────────────────┐
│ │
│ [CRITICAL] Vidar Stealer Infection 95% confidence │
│ Multiple infostealer indicators: dead-drop resolvers for C2, │
│ browser credential theft, and crypto wallet targeting. │
│ Indicators: DEAD_DROP_STEAM, VIDAR_BROWSER_THEFT, DROPPER_TEMP_EXEC │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
Two different claims live here, and they are proven differently.
Known-malicious identity matching. For every ecosystem below, a directory scan reads the listed files
at the scan root and at any depth below it, extracts the package, extension, plugin, provider, image or
action identities, and matches them against the threat feed: the bundled indicators, and the downloadable
catalog after feed refresh. A version pin fires only on the exact malicious release; a range or a
constraint in a manifest leaves the version unknown, so only a whole-name entry can match there.
This table is generated from src/ecosystem-coverage.json and checked by
the build (check:coverage); it is not written by hand. Every row is proven by
coverage-matrix.test.ts, which puts an indicator into each
listed file format, at the scan root and one directory down, runs a real scan and requires the rule to
report it exactly once. A format listed here without such a test fails the test suite. "Indicators shipped"
says whether any indicator exists today; "none yet (matcher ready)" means the matcher is proven but no
malicious package is known in that ecosystem yet, and the importer or a curated entry will fill it.
Source-derived launch command. Check the maintainer’s required arguments and credentials before running:
npx -y supply-chain-guardMerge this template into ~/Library/Application Support/Claude/claude_desktop_config.json. Keep existing servers. Add any arguments, credentials, and permissions required by the maintainer; this template has not been install-tested.
{
"mcpServers": {
"io-github-homeofe-supply-chain-guard": {
"command": "npx",
"args": [
"-y",
"supply-chain-guard"
]
}
}
}Restart Claude Desktop completely for changes to take effect. Confirm the server appears connected in the client’s tool list, then try a read-only example from its documentation.
Claude Desktop setup referenceio.github.homeofe/supply-chain-guard works with any MCP-compatible client. Copy the config snippet from the Configuration section above and add it to the file shown for your client, then restart the application.
~/Library/Application Support/Claude/claude_desktop_config.jsonRestart Claude Desktop completely for changes to take effect.~/.cursor/mcp.jsonRestart Cursor for changes to take effect..vscode/mcp.jsonReload VS Code window for changes to take effect.~/.codeium/windsurf/mcp_config.jsonRestart Windsurf for changes to take effect..mcp.jsonSave at the project root, then start Claude Code in that project and review the MCP server approval prompt. Keep real credentials out of shared files.