A cross-platform CLI tool that intercepts and scans every dependency before installation. Detects supply chain attacks — typosquatting, malicious install scripts, suspicious maintainer changes, known vulnerabilities — verifies cryptographic provenance (sigstore-issued Fulcio + Rekor proofs for npm and PyPI; Ed25519-signed checksum database for Go), and enforces configurable policies like minimum package age. Cache entries are content-addressed and fail-closed on hash mismatch — a republished tarball under the same version triggers a re-scan. For pip, the verified hash is passed through with --require-hashes to close the TOCTOU window between scan and install.
Local-first, fast, open source. Single Rust binary with no runtime dependencies.
# One-liner install (Linux/macOS)
curl -fsSL https://raw.githubusercontent.com/tkdtaylor/dep-scan/main/install.sh | bash
# Or build from source (requires Rust 1.88+)
cargo install --locked --git https://github.com/tkdtaylor/dep-scan.gitEvery release artifact is signed with sigstore keyless OIDC signing. If you have cosign installed you can verify before running:
VERSION=v1.2.0
ARTIFACT=dep-scan-${VERSION}-x86_64-unknown-linux-gnu.tar.gz
cosign verify-blob \
--certificate "${ARTIFACT}.crt" \
--signature "${ARTIFACT}.sig" \
--certificate-identity-regexp 'https://github.com/tkdtaylor/dep-scan/.github/workflows/release.yml@.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
"${ARTIFACT}"The .sig and .crt companion files are published alongside each binary in the
GitHub Release. Verification is optional —
the existing sha256sums.txt check is unaffected.
Each release also ships a CycloneDX SBOM
(dep-scan.cdx.json) listing every direct and transitive Rust dependency.
Download it from the release assets to audit dep-scan's own supply chain with
Trivy, Grype, or Dependency-Track.
# Build from source (alternative)
cargo build --release
# Check a package on npm
dep-scan check lodash --registry npm
# Check multiple packages on PyPI
dep-scan check requests flask numpy --registry pypi
# Check crates
dep-scan check serde tokio --registry crates
# Check Go modules
dep-scan check github.com/gin-gonic/gin --registry go
# JSON output for CI/CD pipelines
dep-scan check express --registry npm --jsondep-scan eats its own dog food: every CI run scans dep-scan's own Cargo.lock with the same heuristics it applies to user projects, so any new transitive dependency that fails dep-scan's policies is caught before merge.
dep-scan runs 11 security policies against every package:
| Policy | What it catches | Default |
|---|---|---|
| Age | Packages published less than 48 hours ago | Block |
| Install scripts | Malicious postinstall/preinstall scripts (eval, child_process, subprocess). v1.2.0 tightened the heuristics — line/block comments are stripped before pattern matching and pure-hex sequences (SHA-256 digests, git SHAs) no longer false-positive as base64 blobs. |
Block |
| Obfuscation | Heavily encoded or unreadable install scripts (base64 blobs, hex strings, eval-of-string) | Block |
| Typosquatting | Names suspiciously similar to popular packages (e.g. expresss vs express) |
Warn/Block |
| Vulnerability | Known CVEs via OSV.dev (free, no API key) | Block |
| Maintainer change | Added/removed maintainers since last scan; full takeover detection. Opt-in policies.maintainer_first_seen_warning = true extends this to warn on first-observation of a package with zero downloads (defends against typosquat-from-day-one). Default: false (legacy behavior). |
Warn/Block |
| Popularity | Packages with very low download counts (configurable threshold) | Warn |
| Dependency confusion | Internal-looking package names on public registries | Warn |
| npm provenance | Sigstore-verified SLSA attestation (Fulcio chain walk + Rekor inclusion + cert-validity window). Defends against a lying npm registry. | Warn (missing) / Block (invalid) |
| PyPI provenance | PEP 740 sigstore attestation, same verification as npm with sha256 subject digests. Defends against a lying PyPI registry. | Warn (missing) / Block (invalid) |
| Go sumdb | Ed25519 signature verification of sum.golang.org signed-tree-head responses. Defends against a lying Go module proxy. |
Warn (missing) / Block (invalid) |
dep-scan's CI scans its own Cargo.lock on every push. When dep-scan reports a
block verdict on one of its own transitive dependencies, the maintainer has
three options:
- Fix the root cause in code — correct response for false positives (e.g. the
version_checktyposquat false-positive, fixed by adjusting heuristics). - Wait for the signal to resolve naturally — age blocks expire once the package has been published for 48 hours.
- Acknowledge the block — for investigated-and-benign findings (e.g. an
audited maintainer rotation), record the justification in
.dep-scan-dogfood-allowlist.toml.
The file .dep-scan-dogfood-allowlist.toml at the repo root is CI metadata,
not a dep-scan feature. dep-scan itself still reports every block; the
scripts/dogfood-gate.py gate reads the allowlist and downgrades matched
blocks from ::error:: (build failure) to ::warning:: (logged but not
failing). Unmatched blocks still fail the build.
Allowlist entry fields:
| Field | Required | Description |
|---|---|---|
package |
yes | Crate name; must match the package field in dep-scan JSON |
policy |
yes | Policy name: age, maintainer_change, typosquatting, etc. |
justification |
yes | Free text; reference a task, issue, or investigation writeup |
opened_at |
yes | ISO date (YYYY-MM-DD) the entry was added |
version |
no | Exact-match version string; omit to match any version |
expires |
no | ISO date after which the entry is inert; use for transient blocks |
When to use expires: Always set it for age-policy blocks — those resolve
naturally once the package is >48h old. Set it ~48-72h after the block was
first observed. For investigated maintainer changes, expires is optional but
recommended to ensure entries get periodically reviewed.
Rule: never allowlist a verdict you haven't actually investigated. An unexplained allowlist entry is indistinguishable from negligence.
Every cached verdict is content-addressed. On a cache hit, dep-scan re-fetches the registry's published digest (dist.integrity / digests.sha256 / cksum / h1:) and compares it to the stored hash. Mismatch ⇒ invalidate the cache row and re-scan from scratch. There is no flag to skip this check. The both-None case (registry stopped publishing a digest, and the cache row was a pre-029 row) is fail-closed — re-scan, never honor.
npm's legacy dist.shasum is SHA-1 and is never trust-gated. Any cache row whose digest starts with sha1: re-scans unconditionally, and new pass/warn rows for sha1-only packages store NULL for the digest — closes the SHAttered chosen-prefix-collision window.
The cache is keyed by (name, resolved_version, registry) — never by the literal string "latest" — so a republished pkg@latest cannot ride past verification on a prior version's cached verdict.
The cache DB file is created with mode 0600 and uses WAL journaling (v1.2.0) — not world-readable on shared hosts, and concurrent dep-scan runs do not block or corrupt each other. On Unix the DB file is created atomically with O_CREAT|O_EXCL and mode 0600 in a single syscall — there is no window where the file briefly exists as 0644 between Connection::open and the follow-up chmod.
See ADR 003 for the threat model.
| Code | Meaning |
|---|---|
| 0 | All checks passed |
| 1 | One or more policy violations (warn or block) |
| 2 | Runtime error (network failure, invalid config) |
Initialize a config file:
dep-scan config init # creates .dep-scan.toml in current directory
dep-scan config show # prints effective configurationExample .dep-scan.toml. The annotated comments below are explanatory
only — dep-scan config init writes the same keys with the same default
values, but without the comments (and with the multi-line array layout
toml::to_string_pretty produces).
min_package_age_hours = 48
[registries]
npm_url = "https://registry.npmjs.org"
pypi_url = "https://pypi.org"
crates_url = "https://crates.io"
go_proxy_url = "https://proxy.golang.org"
go_sum_db_url = "https://sum.golang.org"
[policies]
check_typosquatting = true
check_install_scripts = true
check_min_age = true
check_maintainer_changes = true
check_vulnerabilities = true
check_obfuscation = true
check_npm_provenance = true
require_npm_provenance = false
check_pypi_provenance = true
require_pypi_provenance = false
check_go_sumdb = true
require_go_sumdb = false
# Opt-in (v1.2.0): warn on first-observation of a zero-download package.
maintainer_first_seen_warning = false
[osv]
osv_url = "https://api.osv.dev"
[dependency_confusion]
internal_prefixes = ["internal-", "private-", "corp-"]
[popularity]
min_downloads = 1000The require_* knobs escalate a missing-attestation Warn into a Block. Most packages don't publish provenance yet, so the defaults are Warn to avoid a false-positive flood. Invalid attestations always Block regardless of these flags.
Note: The
popularityanddependency_confusionpolicies are always enabled and are configured via their own[popularity]and[dependency_confusion]sections, not bycheck_*booleans in[policies].
All settings can be overridden via environment variables:
| Variable | Overrides |
|---|---|
DEP_SCAN_MIN_AGE |
min_package_age_hours |
DEP_SCAN_NPM_URL |
registries.npm_url |
DEP_SCAN_PYPI_URL |
registries.pypi_url |
DEP_SCAN_CRATES_URL |
registries.crates_url |
DEP_SCAN_GO_PROXY_URL |
registries.go_proxy_url |
DEP_SCAN_GO_SUM_DB_URL |
registries.go_sum_db_url |
DEP_SCAN_OSV_URL |
osv.osv_url |
DEP_SCAN_CACHE_PATH |
cache_path |
| Registry | Flag | Status | Policies that apply |
|---|---|---|---|
| npm | --registry npm |
Full support | age, install scripts, obfuscation, typosquatting, vulnerability (OSV), maintainer change, popularity, dependency confusion, npm provenance (sigstore Fulcio chain walk + Rekor inclusion proof + cert-validity window) |
| PyPI | --registry pypi |
Full support | age, typosquatting, vulnerability (OSV), maintainer change, popularity, dependency confusion, PyPI provenance (PEP 740 sigstore attestation; same sigstore verification as npm with sha256 subject digests). Provenance URL is host/scheme/IP-validated before fetch. pip install receives the verified hash via --require-hashes. |
| crates.io | --registry crates |
Full support | age, typosquatting, vulnerability (OSV), maintainer change, popularity, dependency confusion |
| Go modules | --registry go |
Full support | age, typosquatting, vulnerability (OSV), dependency confusion, Go sumdb (Ed25519 signed-tree-head verification against sum.golang.org). Module paths and version strings are validated against the Go module-path and semver/pseudo-version grammar before any URL composition. |
$ dep-scan check expresss internal-utils --registry npm
Package Version Age Result
expresss 0.0.0 85259h WARN: Package 'expresss' is similar to popular package 'express' (distance: 0.12)
age: pass
install_scripts: pass
obfuscation: pass
maintainer_change: pass
typosquatting: WARN — Package 'expresss' is similar to popular package 'express' (distance: 0.12)
vulnerability: pass
popularity: pass
dependency_confusion: pass
npm_provenance: pass
internal-utils 0.1.0 1749h WARN: Package 'internal-utils' matches internal namespace pattern 'internal-' — possible dependency confusion
age: pass
install_scripts: pass
obfuscation: pass
maintainer_change: pass
typosquatting: pass
vulnerability: pass
popularity: pass
dependency_confusion: WARN — Package 'internal-utils' matches internal namespace pattern 'internal-' — possible dependency confusion
npm_provenance: pass
Ready-to-use configs, a GitHub Actions snippet, and sample JSON output live in
examples/. Each file has an inline comment explaining what it's for.
The easiest way to use dep-scan is to add it at the start of a project, before any dependencies are installed.
# Build from source (Rust 1.88+ required — uses 2024 edition)
git clone https://github.com/tkdtaylor/dep-scan.git
cd dep-scan
cargo build --release
# Copy the binary somewhere on your PATH
sudo cp target/release/dep-scan /usr/local/bin/
# or for user-local install:
cp target/release/dep-scan ~/.local/bin/cd your-project
dep-scan config init # creates .dep-scan.toml with sensible defaults (aborts if file already exists)This gives you a .dep-scan.toml you can check into your repo so the whole team shares the same security policies.
# Before running your package manager, check what you're about to add
dep-scan check express body-parser cors --registry npm
dep-scan check requests flask sqlalchemy --registry pypi
dep-scan check serde tokio clap --registry crates
dep-scan check github.com/gorilla/mux --registry go
# Or use dep-scan install — scan and exec the package manager in one step
dep-scan install express body-parser cors --registry npm
dep-scan install requests flask sqlalchemy --registry pypi
# Or set up the optional shell wrappers (see "Wrapping package managers"
# below for the install snippet) so your normal npm/pip/cargo/go calls
# scan automatically:
# npmds install express body-parser cors
# pipds install requests flask sqlalchemy
# cargods add serde tokio
# gods get github.com/gorilla/mux
# Scan everything in a lockfile in one go
dep-scan check --lockfile package-lock.json --lockfile-type npm
dep-scan check --lockfile requirements.txt --lockfile-type pypi
dep-scan check --lockfile Cargo.lock --lockfile-type crates
dep-scan check --lockfile go.sum --lockfile-type go
# In CI/CD — fail the build on any policy violation
dep-scan check --lockfile package-lock.json --lockfile-type npm --jsonRun dep-scan check any time you add a new dependency. The local SQLite cache means repeat checks are instant — only new or changed packages hit the registry.
For one-off installs, the built-in dep-scan install subcommand scans first and then invokes the underlying package manager only if every policy passes:
# Scan, then install if clean
dep-scan install express body-parser cors --registry npm
dep-scan install requests flask sqlalchemy --registry pypi
dep-scan install serde tokio clap --registry crates
dep-scan install github.com/gorilla/mux --registry go
# Override a block (e.g. for an internal package you've vetted)
dep-scan install internal-utils --registry npm --force
# Print an audit line naming the locked version + hash before exec
dep-scan install express --registry npm --verbose
# → [audit] express@5.0.1 hash=sha512:… verdict=pass sigstore_reverified=false (L-9) # npm exampleThe --verbose audit line (v1.2.0) records exactly what version + content
hash the wrapped package manager is about to fetch, and explicitly notes
that sigstore provenance is verified only at scan time — not re-run between
scan-pass and npm/cargo/go install (the documented TOCTOU gap in
ADR 003).
For pip the audit line also confirms the sha256 was re-checked between
scan-pass and pip install --require-hashes.
--registry accepts npm, pypi, crates, or go. --force proceeds with the install even when policies block — use it sparingly. Without --force, a policy violation aborts before the package manager runs.
For ongoing use across an existing workflow, the wrappers below are better — they intercept your normal npm install / pip install / cargo add / go get calls without changing your habits.
dep-scan provides drop-in wrapper commands that scan every package before installing. Same arguments, same behavior as the real commands, but every install goes through dep-scan first.
The wrapper scripts live in shims/. Copy them to a directory on your PATH:
cp shims/* ~/.local/bin/That's it — npmds, pipds, cargods, and gods are now available. See shims/README.md for customisation notes.
| Wrapper | Wraps | Distributed as |
|---|---|---|
npmds |
npm |
Shell snippet below |
pipds |
pip |
Shell snippet below |
cargods |
cargo |
Shell snippet below |
gods |
go |
Shell snippet below |
The wrappers are shell shims that call
dep-scan check— they are not separate binaries built bycargo build. Install them from the snippet in Installing the wrappers below before using them.
# These work exactly like the real commands, but scan before installing
npmds install express body-parser cors
pipds install requests flask sqlalchemy
cargods add serde tokio
gods get github.com/some/module
# All other subcommands pass through unchanged
npmds test
pipds list
cargods build
gods test ./...# Install to /usr/local/bin (system-wide)
sudo tee /usr/local/bin/npmds << 'WRAPPER' > /dev/null
#!/usr/bin/env bash
set -euo pipefail
if [[ "${DEP_SCAN_SKIP:-}" == "1" ]]; then exec npm "$@"; fi
if [[ "${1:-}" =~ ^(install|i|add)$ ]]; then
cmd="$1"; shift
pkgs=(); flags=()
for arg in "$@"; do
if [[ "$arg" == -* ]]; then flags+=("$arg"); else pkgs+=("$arg"); fi
done
if [ ${#pkgs[@]} -gt 0 ]; then
echo "dep-scan: scanning ${pkgs[*]}..."
dep-scan check "${pkgs[@]}" --registry npm || {
echo "dep-scan: blocked — resolve policy violations before installing" >&2
exit 1
}
fi
exec npm "$cmd" "${flags[@]}" "${pkgs[@]}"
else
exec npm "$@"
fi
WRAPPER
sudo chmod +x /usr/local/bin/npmds
sudo tee /usr/local/bin/pipds << 'WRAPPER' > /dev/null
#!/usr/bin/env bash
set -euo pipefail
if [[ "${DEP_SCAN_SKIP:-}" == "1" ]]; then exec pip "$@"; fi
if [[ "${1:-}" == "install" ]]; then
shift
pkgs=(); flags=()
for arg in "$@"; do
if [[ "$arg" == -* ]]; then flags+=("$arg"); else pkgs+=("$arg"); fi
done
if [ ${#pkgs[@]} -gt 0 ]; then
echo "dep-scan: scanning ${pkgs[*]}..."
dep-scan check "${pkgs[@]}" --registry pypi || {
echo "dep-scan: blocked — resolve policy violations before installing" >&2
exit 1
}
fi
exec pip install "${flags[@]}" "${pkgs[@]}"
else
exec pip "$@"
fi
WRAPPER
sudo chmod +x /usr/local/bin/pipds
# cargo wrapper
sudo tee /usr/local/bin/cargods << 'WRAPPER' > /dev/null
#!/usr/bin/env bash
set -euo pipefail
if [[ "${DEP_SCAN_SKIP:-}" == "1" ]]; then exec cargo "$@"; fi
if [[ "${1:-}" =~ ^(add|install)$ ]]; then
cmd="$1"; shift
pkgs=(); flags=()
for arg in "$@"; do
if [[ "$arg" == -* ]]; then flags+=("$arg"); else pkgs+=("$arg"); fi
done
if [ ${#pkgs[@]} -gt 0 ]; then
echo "dep-scan: scanning ${pkgs[*]}..."
dep-scan check "${pkgs[@]}" --registry crates || {
echo "dep-scan: blocked — resolve policy violations before installing" >&2
exit 1
}
fi
exec cargo "$cmd" "${flags[@]}" "${pkgs[@]}"
else
exec cargo "$@"
fi
WRAPPER
sudo chmod +x /usr/local/bin/cargods
# go wrapper
sudo tee /usr/local/bin/gods << 'WRAPPER' > /dev/null
#!/usr/bin/env bash
set -euo pipefail
if [[ "${DEP_SCAN_SKIP:-}" == "1" ]]; then exec go "$@"; fi
if [[ "${1:-}" =~ ^(get|install)$ ]]; then
cmd="$1"; shift
pkgs=(); flags=()
for arg in "$@"; do
if [[ "$arg" == -* ]]; then flags+=("$arg"); else pkgs+=("$arg"); fi
done
if [ ${#pkgs[@]} -gt 0 ]; then
echo "dep-scan: scanning ${pkgs[*]}..."
dep-scan check "${pkgs[@]}" --registry go || {
echo "dep-scan: blocked — resolve policy violations before installing" >&2
exit 1
}
fi
exec go "$cmd" "${flags[@]}" "${pkgs[@]}"
else
exec go "$@"
fi
WRAPPER
sudo chmod +x /usr/local/bin/godsFor user-local install (no sudo), put them in ~/.local/bin/ instead.
Add to your PowerShell profile ($PROFILE):
function npmds {
if ($env:DEP_SCAN_SKIP -eq '1') { & npm @args; return }
if ($args[0] -in 'install', 'i', 'add') {
$pkgs = $args[1..($args.Length-1)] | Where-Object { $_ -notlike '-*' }
if ($pkgs) {
Write-Host "dep-scan: scanning $($pkgs -join ', ')..."
& dep-scan check @pkgs --registry npm
if ($LASTEXITCODE -ne 0) { return }
}
& npm @args
} else {
& npm @args
}
}
function pipds {
if ($env:DEP_SCAN_SKIP -eq '1') { & pip @args; return }
if ($args[0] -eq 'install') {
$pkgs = $args[1..($args.Length-1)] | Where-Object { $_ -notlike '-*' }
if ($pkgs) {
Write-Host "dep-scan: scanning $($pkgs -join ', ')..."
& dep-scan check @pkgs --registry pypi
if ($LASTEXITCODE -ne 0) { return }
}
& pip @args
} else {
& pip @args
}
}
# cargo and go wrappers
function cargods {
if ($env:DEP_SCAN_SKIP -eq '1') { & cargo @args; return }
if ($args[0] -in 'add', 'install') {
$pkgs = $args[1..($args.Length-1)] | Where-Object { $_ -notlike '-*' }
if ($pkgs) {
Write-Host "dep-scan: scanning $($pkgs -join ', ')..."
& dep-scan check @pkgs --registry crates
if ($LASTEXITCODE -ne 0) { return }
}
& cargo @args
} else {
& cargo @args
}
}
function gods {
if ($env:DEP_SCAN_SKIP -eq '1') { & go @args; return }
if ($args[0] -in 'get', 'install') {
$pkgs = $args[1..($args.Length-1)] | Where-Object { $_ -notlike '-*' }
if ($pkgs) {
Write-Host "dep-scan: scanning $($pkgs -join ', ')..."
& dep-scan check @pkgs --registry go
if ($LASTEXITCODE -ne 0) { return }
}
& go @args
} else {
& go @args
}
}If you want to make npmds/pipds the only way to install packages on a system or for a team, you can redirect the bare commands:
Per-user (shell aliases) — add to ~/.bashrc or ~/.zshrc:
# Redirect all package managers to their dep-scan wrappers
alias npm='npmds'
alias pip='pipds'
alias cargo='cargods'
alias go='gods'
# To bypass: use the full path or unset the alias
# /usr/bin/npm install something
# unalias npm && npm install somethingSystem-wide (PATH override) — install shim scripts that replace npm/pip for all users:
# Create a directory that sits before the real binaries in PATH
sudo mkdir -p /usr/local/lib/dep-scan/bin
# Create shims for each package manager
for pair in "npm:npmds" "pip:pipds" "cargo:cargods" "go:gods"; do
cmd="${pair%%:*}"; wrapper="${pair##*:}"
sudo tee "/usr/local/lib/dep-scan/bin/$cmd" << SHIM > /dev/null
#!/usr/bin/env bash
exec $wrapper "\$@"
SHIM
sudo chmod +x "/usr/local/lib/dep-scan/bin/$cmd"
done
# Add to system PATH (before /usr/bin)
echo 'export PATH="/usr/local/lib/dep-scan/bin:$PATH"' | sudo tee /etc/profile.d/dep-scan.shNow npm install and pip install go through dep-scan automatically. To bypass when needed:
# Use the real binary directly
/usr/bin/npm install something
/usr/bin/pip install something
# Or skip scanning for one command
DEP_SCAN_SKIP=1 npm install somethingPer-project (direnv) — if your team uses direnv, add to .envrc:
# .envrc — enforces dep-scan for this project only
alias npm='npmds'
alias pip='pipds'
alias cargo='cargods'
alias go='gods'# GitHub Actions example
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.88" # dep-scan's MSRV
- name: Install dep-scan
run: cargo install --locked --path . # or download pre-built binary
- name: Scan dependencies before install
run: |
dep-scan check $(jq -r '.dependencies | keys[]' package.json) --registry npm --json
# Exit code 1 = policy violation, fails the workflow
- name: Install dependencies
run: npm installWhen you need to bypass scanning (e.g., trusted CI environment or installing dep-scan's own build deps):
# The wrappers are separate commands — the real tools always work directly
npm install something
pip install something
cargo add something
go get something
# Or skip scanning within a wrapper
DEP_SCAN_SKIP=1 npmds install something
DEP_SCAN_SKIP=1 pipds install something
DEP_SCAN_SKIP=1 cargods add something
DEP_SCAN_SKIP=1 gods get somethingRequires Rust 1.88+ (the crate uses the 2024 edition; MSRV bumped from 1.85 in v1.2.0 to accommodate a patched time dep).
git clone https://github.com/tkdtaylor/dep-scan.git
cd dep-scan
cargo build --release
# Binary at target/release/dep-scancargo test # run all tests
cargo clippy # lint
cargo fmt --check # check formattingSee docs/architecture/overview.md for system design and docs/architecture/decisions/ for ADRs:
- ADR 001 — Rust as implementation language
- ADR 002 — v0.2 detection strategy and external data sources
- ADR 003 — content-hash cache integrity, sigstore + sumdb provenance verification
See SECURITY.md for the vulnerability disclosure policy and how to report security issues in dep-scan itself.
See CONTRIBUTING.md for the build/test workflow, commit conventions, and how to propose new features.
See CODE_OF_CONDUCT.md.
MIT