Security
This page lists every security-relevant feature in scpm, its default, and the one-line config to turn it on or off.
To report a vulnerability, see the security policy.
The paranoid switch
The fastest way to enable the strict bundle is one line:
paranoid: true
This forces every setting in the strict bundle on, regardless of how each is configured individually:
jailBuilds = truetrustPolicy = no-downgrade(overrides explicitoff)minimumReleaseAgeStrict = true— turns the age gate into a hard fail instead of "fall back to the lowest satisfying version"strictStoreIntegrity = true— fail when a tarball ships withoutdist.integrityinstead of warningstrictDepBuilds = true— fail the install when a dep has unreviewed build scripts instead of silently skippingadvisoryCheck = required— failscpm addif OSV can't be reached instead of falling back to download-count signal alone
Use it when you want maximum protection without listing each setting.
Default-deny lifecycle scripts
Lifecycle scripts (preinstall, install, postinstall) run arbitrary code
when a package is installed, which makes them a common attack vector. scpm
doesn't run dependency lifecycle scripts unless you've approved them
explicitly:
# scpm-workspace.yaml
allowBuilds:
esbuild: true
sharp: true
Or interactively:
scpm approve-builds
Root-package lifecycle scripts (your own project's) still run normally; only dependency scripts need approval.
Settings: allowBuilds. Install adds
unreviewed build packages to scpm-workspace.yaml (or pnpm-workspace.yaml
if one already exists) as false; approving them flips the entry to true.
Suspicious-script content sniff
Before the warm-up nudge to run scpm approve-builds, scpm runs a
small pattern matcher against each unreviewed dep's preinstall /
install / postinstall script bodies and surfaces a
WARN_SCPM_SUSPICIOUS_LIFECYCLE_SCRIPT for any that match a
known-dangerous shape:
curl … | sh/wget … | bash— fetch-and-pipe-to-shell.eval(atob(…))/Function(atob(…))/eval(Buffer.from(…))— base64-decode-then-evaluate. Common dropper shape.- Reads of
~/.ssh,~/.aws,~/.npmrc,~/.config/gh— credential files a lifecycle script has no business touching. process.env.*TOKEN,*SECRET,*API_KEY, etc. — secret-shaped env vars exfilled from CI.- Discord webhooks, Telegram bot API, OAST collaborator hosts — known exfil channels.
http://1.2.3.4/…bare-IP HTTP targets.
The sniff is advisory — it never blocks an install or write.
The allowBuilds allowlist remains the only gate on whether
scripts actually execute. The signal is intended to give the user
something more than name@version to judge by when deciding
whether to approve a build. scpm approve-builds repeats the same
warnings inline next to each picker entry, and scpm ignored-builds lists them under each name@version line.
False positives are possible (an SDK that legitimately hits a
Discord webhook from a postinstall would flag), but lifecycle
script bodies are short and almost never contain bare
curl … | sh legitimately. To bypass for a known-good package,
add it to allowBuilds: true once you've inspected the script —
the warning has done its job.
Jailed lifecycle scripts
When a dependency is approved to build, jailing keeps it from getting your
full filesystem, network, and environment. On macOS scpm wraps the script with
a Seatbelt profile; on Linux it applies Landlock and seccomp before exec. Both
deny network access and limit writes to package and jail-owned temporary
directories. On Windows the env is scrubbed and HOME is redirected to a
temporary directory.
jailBuilds: true
Grant narrow exceptions per-package instead of disabling the jail wholesale:
jailBuilds: true
jailBuildPermissions:
sharp:
env: [SHARP_DIST_BASE_URL]
write: ["~/.cache/sharp"]
network: true
Default: false today, planned to flip to true in the next major.
Full reference: Jailed builds.
Trust policy
trustPolicy = no-downgrade blocks installs of a version that carries weaker
trust evidence than any earlier-published version of the same package. scpm
only counts the structured metadata shape npm emits after registry-side checks:
- npm staged publish approval — package metadata carries an
approverfield from the registry-side approval flow. - npm trusted-publisher — package was published via OIDC from a trusted
CI provider (
_npmUser.trustedPublisher.id). - Sigstore provenance — package was published with
npm publish --provenance(dist.attestations.provenance.predicateTypewith an SLSA provenance URI).
This install-time policy validates the registry metadata shape; it does not cryptographically verify the attached attestation bundle.
A trust downgrade may indicate a supply-chain incident: publisher account takeover, repository tampering, or a malicious co-maintainer publishing without the original CI flow.
trustPolicy: no-downgrade
Exempt specific packages or versions when needed (only exact versions, no ranges):
trustPolicyExclude:
- "@vendor/legacy-pkg" # all versions
- "old-thing@1.0.0" # one version
- "things@1.0.0 || 1.0.1" # version union
- "is-*" # name glob (no version)
Default: no-downgrade. Set trustPolicy: off to disable, or use
trustPolicyExclude for per-package opt-outs.
Settings: trustPolicy,
trustPolicyExclude,
trustPolicyIgnoreAfter.
Minimum release age
Wait a configurable period before installing newly published versions. Catches typo-squat and dependency-confusion attacks that get unpublished within hours.
minimumReleaseAge: 4320 # 3 days
minimumReleaseAgeStrict: true fails the install when no version satisfies
the range; otherwise the resolver falls back to the lowest satisfying version
ignoring the cutoff for that pick only.
Default: 1440 (24 hours). Set minimumReleaseAge: 0 to disable.
Settings: minimumReleaseAge,
minimumReleaseAgeExclude,
minimumReleaseAgeStrict.
Typosquat and impersonation protection
scpm add checks every package you name on the command line and the
full post-resolve transitive closure against OSV for
MAL-* malicious-package advisories — same check scpm update and any
other install path runs where the resolver picks a version that wasn't
already pinned by the lockfile. Plain reinstalls (the lockfile was
authoritative) skip the live API for latency; an opt-in local mirror
(see Install-time OSV check below) covers
that path.
Two signals, with different response levels:
Known-malicious advisories. scpm batch-queries OSV for
MAL-* advisories on every name about to be added. A hit fails the install
with ERR_SCPM_MALICIOUS_PACKAGE and a link to the advisory. If
the OSV API can't be reached, the default (advisoryCheck: on) warns and
continues; advisoryCheck: required upgrades that to a fail-closed
ERR_SCPM_ADVISORY_CHECK_FAILED so CI can tell a network outage from a
confirmed-malicious advisory.
Low download count. A typosquat or impersonation has approximately zero installs on day one regardless of how cleverly it's named, so a download-count floor catches the long tail of squats that haven't been reported yet. Below the threshold, scpm prompts for confirmation:
scpm add supabase-javascript
⚠ supabase-javascript looks suspicious:
• 3 downloads last week (threshold: 1000)
Continue adding supabase-javascript? [y/N]
In non-interactive contexts the prompt becomes a hard refusal with
ERR_SCPM_LOW_DOWNLOAD_PACKAGE unless --allow-low-downloads is passed.
Private packages skip both gates automatically. Any package routed
through a non-registry.npmjs.org registry — whether by a scoped
override (@myorg:registry=https://npm.internal.example/) or by
replacing the default registry= URL outright — is exempted from
the OSV check and the downloads gate, because npmjs has no signal on
it. Workspace deps and git/local specs are also skipped.
For names that do route through public npmjs but are known-internal
(e.g. you publish a low-traffic helper under your own brand), list
them in allowedUnpopularPackages to skip the downloads gate alone:
advisoryCheck: on # default; fail open on network error
lowDownloadThreshold: 1000 # weekly downloads, 0 disables
allowedUnpopularPackages: # glob patterns; OSV check still runs
- "@mycompany/*"
- "internal-*"
Set advisoryCheck: required to fail closed when OSV can't be reached —
appropriate for hardened CI, included in paranoid: true. Set
advisoryCheck: off or lowDownloadThreshold: 0 to disable either check
independently.
Settings: advisoryCheck,
lowDownloadThreshold,
allowedUnpopularPackages.
Install-time OSV check
OSV MAL-* checks are routed three ways post-resolve so the freshest
signal lands when it matters most without paying for a per-install
network round-trip when it doesn't:
| Install path | Backend | Setting |
|---|---|---|
scpm add, scpm update | Live API | advisoryCheck (default on) |
| Missing lockfile / resolver picked new version | Live API | advisoryCheck (default on) |
advisoryCheckEveryInstall = true | Live API | advisoryCheck (default on) |
| Plain reinstall (lockfile authoritative) | Local mirror | advisoryCheckOnInstall (default off) |
| Anything else | No check | — |
The mirror lives at $XDG_CACHE_HOME/scpm/osv/npm/ (the bulk zip from
osv-vulnerabilities.storage.googleapis.com/npm/all.zip, roughly tens
of MB) and lazily refreshes with an ETag-conditional GET every 24
hours. Hits map to the same ERR_SCPM_MALICIOUS_PACKAGE exit as the
live-API gate.
Trade-off: the mirror lags reality by up to ~24h. An advisory published in the last day won't be in your local index unless a refresh happens to fall after it. Fresh-resolution installs always go through the live API so that lag doesn't matter for new picks; plain reinstalls trade sub-day staleness for sub-millisecond lookups.
# Default: live API on scpm add / update / fresh-resolution. Mirror
# disabled — plain reinstalls skip OSV entirely.
advisoryCheck: on
advisoryCheckOnInstall: off
advisoryCheckEveryInstall: false
# Hardened CI: live API on every install, fail-closed on fetch errors.
advisoryCheck: required
advisoryCheckEveryInstall: true
# Cheap fallback: live API on fresh-resolution, local mirror covers
# plain reinstalls so even CI re-runs see SOME OSV coverage.
advisoryCheck: on
advisoryCheckOnInstall: on
Refresh-failure semantics for the mirror:
advisoryCheckOnInstall = on:WARN_SCPM_OSV_MIRROR_REFRESH_FAILED, install continues against the prior on-disk index (or empty on first sync).advisoryCheckOnInstall = required: mirror refresh failures map toERR_SCPM_ADVISORY_CHECK_FAILED. Use when a stale or unreachable mirror should block.
Settings:
advisoryCheck,
advisoryCheckOnInstall,
advisoryCheckEveryInstall.
Block exotic transitive dependencies
Reject transitive dependencies that resolve to git+, file:, or direct
tarball URLs — those skip the registry and its integrity verification. Direct
deps you pin yourself in package.json are still allowed.
blockExoticSubdeps: true # default
Settings: blockExoticSubdeps.
Tarball integrity
Every registry tarball is verified against the SHA-512 hash recorded in the
packument's dist.integrity field before it is added to the store. Mismatches
fail the install. The hash is preserved in the lockfile, so subsequent
installs reverify on every fetch.
The content-addressable store itself uses BLAKE3 for the on-disk index — fast
to compute and immune to length-extension. Linked node_modules files are
reflinks (APFS/btrfs), hardlinks (ext4), or copies; none of those paths can
modify the canonical store entry.
Auth tokens
Registry tokens are read from .npmrc (the npm convention) or environment
variables (NPM_TOKEN, SCPM_AUTH_TOKEN, etc.) and never written to the
lockfile, tarball cache, or logs. scpm login and scpm logout manage
tokens via the standard npm config file.
Inside jailed lifecycle scripts, common token env vars (NPM_TOKEN,
NODE_AUTH_TOKEN, GITHUB_TOKEN, SSH_AUTH_SOCK, AWS_*, etc.) are
scrubbed from the script environment unless explicitly granted via
jailBuildPermissions.
Pluggable security scanner
securityScanner runs a Bun-compatible security scanner
against the resolved install graph. Point the setting at the same
npm package you'd put in Bun's bunfig.toml#install.security.scanner
and scpm loads it through a node bridge — the
oven-sh template
and @socketsecurity/bun-security-scanner
both run unchanged.
# scpm-workspace.yaml
securityScanner: "@acme/bun-security-scanner"
The scanner fires post-resolve, sees the full transitive graph
with resolved versions, and fails closed on any scanner
failure (missing node, unresolvable module, timeout, etc.).
Requires Node 22.6+. Set securityScanner: "" to disable when
bootstrapping.
Full reference: Security scanner.
Auditing installed dependencies
scpm audit # list known CVEs at moderate+ severity
scpm audit --audit-level high
scpm audit --fix # write package.json overrides to patched versions
scpm audit --json | jq # machine-readable for CI
Same advisory data source as npm audit and pnpm audit; same response
schema.
Recommended baseline
For most projects, the following is a good starting point:
# scpm-workspace.yaml
paranoid: true # bundles jailBuilds, no-downgrade, strict gates
allowBuilds:
esbuild: true
sharp: true
# ...whatever your project actually needs to build
trustPolicy=no-downgrade and minimumReleaseAge: 1440 (24h) are already
default-on; paranoid: true adds the rest of the bundle on top. Pair this
with scpm audit in CI so a newly disclosed CVE fails the build instead of
silently shipping.
