All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
While the project is pre-1.0 the public Rust API (the SignalProvider trait,
Signal/Reason enums, and Policy schema) is treated as additive-only on
minor bumps; breaking changes are called out under a Breaking subsection.
- One-level lockfile auto-discovery.
installguard scan(and every other eval subcommand) used to error out as soon as no recognised lockfile existed at--path(default: current directory). Many Python projects keeppoetry.lock/uv.lockinside a named package subdir (cpi_myca/poetry.lock,backend/uv.lock) while the git root is a thin shell of CI config and READMEs. We now scan the immediate children when the root has no lockfile and use the unique match, printing anote: using <relpath> (no lockfile at <root>)line on stderr so it's never invisible. Strictly one level deep — surprises about which lockfile got picked are worse than a clear error. node_modules,.git,.venv,venv,target,dist,build, and any dotfile directory are skipped by the scan so vendored / build-output lockfiles can't accidentally win.
- The "no lockfile" error message now suggests
--path <dir>explicitly and, when multiple subdirs each contain a candidate lockfile, lists them so you can pick one. InstallGuard does not auto-merge across subprojects.
- PyPI source builds no longer go silent on in-tree PEP 517 backends.
The
pypi-sdistprovider used to stop atsetup.py: if a source release was modern PEP 517-only (pyproject.toml, nosetup.py), InstallGuard emitted no install-time signal at all and the module comments misleadingly implied that meant "no install-time Python". The provider now readspyproject.toml, detects[build-system].backend-path, treats that as install-time code, and scans every Python file under those backend roots with the same shell + Python suspicious-pattern rules already used forsetup.py. Packages that ship an in-tree build backend now emitlifecycle_scripts: ["pyproject build-backend"], plus anysuspicious_scriptfindings tied to the backend file path.
- PyPI install-time coverage docs now match the runtime behaviour.
Updated the README, roadmap, CLI help text, and docs-site coverage
matrix / changelog so they describe the current scope precisely:
InstallGuard scans legacy
setup.pyand in-tree PEP 517backend-pathbackends inside canonical.tar.gzsdists, while external build backends referenced only viabuild-system.requiresremain out of scope for this provider.
- Release workflow no longer races itself uploading assets. The
softprops/action-gh-releasefiles:glob list had three redundant entries (*.cosign.bundle,*.sig,*.pem) that matched the same files as the broaderinstallguard-*glob, because the cosign sidecars are namedinstallguard-<target>.cosign.bundle(etc.). The action uploaded each sidecar twice concurrently and hit GitHub's "asset already exists" race-condition path; the v0.3.1 run retried, refreshed, and ultimately failed thePublish releasejob hard, which skipped the SLSA provenance and Homebrew tap-bump jobs. Collapsed to two non-overlapping globs (installguard-*+checksums.txt*). v0.3.1's binaries and cosign bundles are still on the GitHub release but it is missing SLSA provenance and the Homebrew tap was not bumped — use v0.3.2 for the full release.
Three small correctness fixes shipped together — none change a single verdict on existing fixtures, but each closes a "silent in the wrong direction" hole.
-
--frozenreplay no longer loses the PyPI source kind. The lock format recordssource: "pypi"since 0.2.7, but the CLI'ssource_from_kind()lookup table was missing thepypiarm and collapsed it back toSource::Registryon rebuild. Decisions were unaffected (the policy gates that consumesourcetreat both as non-exotic), but JSON / explain output mis-attributed PyPI deps as generic registry entries when reading a v2 lock. Added thepypiarm and noted the bounded scope of the fall-through default. -
Scorecard repo discovery no longer fails open.
fetch_npm_repo_urlandfetch_pypi_repo_urlcollapsed every transport / decode failure toNone, indistinguishable from "package legitimately has norepositoryfield".signals()then returned an empty vec, hiding the outage. They now returnResult<Option<String>, String>:Ok(Some)= found,Ok(None)= legitimate absence (or 404),Err= transport / decode failure surfaced asSignal::Unavailable. In default setups the dedicatednpm-registry/pypi-registryproviders already raised the alarm; this matters for users who disable those providers and rely on Scorecard alone.
requireProvenancehonesty pass extended to user-facing docs. The 0.2.6 cleanup removed the "verified" overclaim from code comments and theProvenanceMissingreason text, but a few user-facing surfaces still implied DSSE/Rekor verification we don't actually perform. Updatedpolicy-yaml.md(therequireProvenancerow now spells out the structural check and links M9 for the verified upgrade) andwhitepaper.md§6.5 (the trust-score factor list now reads "presence of provenance attestations (claimed today; cryptographic verification … tracked under M9)" and the closing line matches).whitepaper.md§13 andDESIGN.md§2 are now accurate as of 0.3.0 — release-binary signing and SLSA L3 provenance shipped, so no edits needed there.
Release-binary signing and SLSA Build Level 3 provenance. The
release workflow now Cosign-signs every published binary plus
checksums.txt, and emits a SLSA v1.0 Build Level 3 provenance
attestation covering the same artefacts. This closes the
"known-pending" item from 0.2.9 and the long-standing
v0.3 roadmap Sigstore signing milestone.
-
Cosign keyless signing. The release job runs
cosign sign-blobagainst every binary in the matrix and againstchecksums.txt, producing a*.cosign.bundleSigstore bundle (DSSE envelope + Fulcio cert chain + Rekor inclusion proof) for each. Signing is keyless: cosign exchanges the ambient GitHub OIDC token for a 10-minute Fulcio code-signing certificate whose SAN is bound to this workflow file at the published tag, signs, submits to Rekor, and writes the bundle. There are no long-lived signing keys for an attacker to steal.Verifiers paste the published
cosign verify-blobcommand into their shell after downloading the binary plus its.cosign.bundlesidecar — the install page documents the full command including the mandatory--certificate-identity-regexpand--certificate-oidc-issuerflags. The identity regex accepts any tag from this repo by default; consumers wanting stricter pinning swapv.*for the exact tag. -
SLSA v1.0 Build Level 3 provenance. A new
provenancejob invokes theslsa-github-generatorreusable workflow on a hardened GitHub-hosted builder. The generator emits a SLSA v1.0 provenance attestation (installguard-<TAG>.intoto.jsonl) covering every binary pluschecksums.txt, signed via the same Fulcio/Rekor path, and uploads it to the same release. Consumers verify withslsa-verifierpinned to the source repo + tag — the install page documents the command. -
What this does not change: the
requireProvenancepolicy gate still validates npm and PyPI publisher attestations structurally (in-toto subject digest match against the tarball'sdist.integrity, or a 200 from PyPI's Integrity API); cryptographic verification of those bundles against a pinned Sigstore Fulcio root is a separate piece of work tracked under ROADMAP M9. See the 0.2.6 entry for the current honest scope ofrequireProvenance.The
cosign verify-blobcommand above verifies InstallGuard's own release artefacts — i.e. the binary you downloaded was built by this repo's release workflow at the published tag — not the dependencies it scans.
Honesty pass on the README and the public docs site. No behaviour change; this release closes three documentation overclaims that an external review surfaced.
-
README's quick-start no longer says "alpha 0.1.0" or implies binaries don't exist yet. Updated to reflect the current
0.2.xseries, the SHA-256 checksums file we publish per release, and the v0.3 roadmap item for Cosign-signed binaries. Network-provider defaults (registry metadata, OSV, deps.dev, Scorecard, PyPI Integrity API are on by default; each has a--no-…flag;--frozenruns entirely from the lockfile) are spelled out so users can size the network blast radius before they invoke us. -
Site landing page no longer claims InstallGuard "never opens an outbound socket". The card describing zero side-effects was correct on
--frozenand incorrect everywhere else (registry metadata, advisory lookups, project metadata, and Scorecard pulls all open sockets in the default scan path). Replaced with the truthful description plus an explicit pointer to the--frozenmode for true zero-network runs. The full lockfile coverage list (uv.lock,poetry.lock, pinnedrequirements.txt) was added at the same time so the card doesn't accidentally undercount our PyPI support. -
Site install page no longer says "signed binaries". Releases ship SHA-256 checksums and SLSA L3 attestations are produced for the SBOM and policy-evaluation predicates today, but the binaries themselves are not yet Cosign-signed and the
checksums.txtfile is not yet attested. The page now documents the present state plus the v0.3 roadmap item.start/what.mdcarries the same correction.
Known pending (tracked, not blocking this release): the release workflow itself does not yet Cosign-sign the published binaries or attest the checksums file. That work is captured in the ROADMAP under the v0.3 milestone alongside Sigstore Fulcio verification of npm/PyPI provenance bundles.
Yarn workspace member package.json files are now walked for
direct-dep detection. The Yarn Berry adapter previously only
read the root package.json to populate the direct-dep set. In
a typical monorepo the root has only devDependencies (or is
entirely empty under private: true with everything declared in
packages/*/package.json); every member dep was therefore
demoted to "transitive" and any directOnly policy rule
silently no-op'd against them.
The adapter now reads the root package.json's workspaces
field (both shapes — bare array ["packages/*", "apps/web"] and
the Yarn-1 nohoist-compatibility object form
{ "packages": [...] }), expands each pattern under the
lockfile's parent directory, and unions the direct-dep specs
across the root and every member it finds. Two glob shapes are
supported, covering the overwhelming majority of real
workspaces:
- literal segments (
packages/web) — read that onepackage.jsondirectly, - trailing single-star (
packages/*) — list the parent dir and read each immediate-childpackage.json.
** and other exotic globs are deliberately not supported (they
are vanishingly rare in workspaces arrays). A member
package.json that fails to read or parse is silently skipped,
matching the rest of this adapter's "best-effort enrichment,
never load-bearing for correctness" stance.
installguard-adapter-pnpm and installguard-adapter-npm are
unaffected: pnpm's pnpm-lock.yaml records the workspace member
graph in its importers map (already handled), and npm's
package-lock.json v3 stores the workspace tree under
packages (also already handled). This release brings yarn to
parity.
purl is now ecosystem-aware, and the lock format records each entry's ecosystem. Two related correctness fixes that an external review surfaced.
-
purl_fordistinguishes PyPI from npm. Until this release, every component in a CycloneDX SBOM and every product reference in a generated VEX document was emitted aspkg:npm/<name>@…, including PyPI deps. Downstream tooling (Dependency-Track, GUAC, OSV-Scanner ingestion of our SBOMs) couldn't tell a Pythonrequestsfrom an npmrequestsand would either match the wrong advisory set or skip the dep entirely.purl_fornow producespkg:pypi/<name>@<version>for anyEcosystem::Pypidep, with the name normalised per PEP 503 (lowercased; runs of_,-, and.collapsed to a single-) as the purl spec requires for thepypitype.npm/pnpm/yarndeps still emitpkg:npm/…(they share the npm registry, so the purl spec keeps the type the same). Smoke:[email protected]now appears in the SBOM aspkg:pypi/[email protected]instead ofpkg:npm/[email protected]. -
installguard.lockschema bumped to v2 with a per-entryecosystemfield. The frozen-policy rebuild (installguard scan --frozenand friends) used to hardcode every reconstructed dependency toEcosystem::Npm, so an offline run replayed PyPI decisions against the wrong policy family and could mis-attribute reasons in the audit log. EachLockDecisionnow carries anecosystemfield; frozen rebuilds use it directly. v1 locks (written by ≤0.2.6) still load — the field defaults to absent, which the rebuild treats asNpm(the only ecosystem v1 locks could have contained), then re-emits as v2 on the nextinstallguard lock. Forward-incompatible schema versions still abort with exit 2.
Honesty pass on the provenance gate, fail-loud on catalogue
outages, and a freshness window on the trust-score published_at
penalty. No new providers; this release closes three correctness
issues that an external review surfaced.
-
requireProvenanceno longer overclaims. The doc-comment said "verified npm provenance" but the gate has only ever checked that the bundle's in-toto subject digest matches the tarball'sdist.integrity(and, since 0.2.4, that PyPI's Integrity API returned 200 for the file). Both are claimed attestations, not cryptographically verified ones — we never walk the DSSE signature against a pinned Sigstore Fulcio root, and we never verify the Rekor inclusion proof. TherequireProvenancedoc, theReason::ProvenanceMissingdoc, and the policy-gate comment all now say so explicitly. The schema regenerates with the corrected text. ATODO(M9)marks where the verified-peer signal will land alongside Sigstore Fulcio verification; when it does, this gate will require the verified signal and the present behaviour will move behind a separate, weakerrequireProvenanceClaimtoggle. No behaviour change — every gate that fired before still fires; we only owned what we ship. -
deps.dev and Scorecard now distinguish "not indexed" from "outage". Both providers used to collapse network failures, 5xx responses, and decode errors to a silent
None, then return an empty signal set. The policy layer's existingsignal-unavailablereason therefore never fired and a clean scan could hide a deps.dev outage or a Scorecard interference. Both providers now returnResult<Option<T>, String>from their fetch helpers:Ok(Some(_))is a hit,Ok(None)is a 404/410 (legitimate absence — the package isn't indexed yet, cached as a soft miss), andErr(reason)covers every other failure mode (network, 5xx, decode, both not cached). Thesignals()impls lift theErrarm to aSignal::Unavailable { provider, reason }. Operators who want hard failure on catalogue outages can now useseverity: signal-unavailable: block; the default stays atwarnso transient 5xxs don't break CI. -
The trust-score
published_atpenalty now respects a freshness window. The matrix in thetrust_scoredoc said the −10 was for "very recent publish", but the rule actually applied to every package — every dependency carries apublished_at, so the steady-state trust score was silently capped at 90 andminTrustScore: 90+would block healthy packages for the wrong reason. The penalty now only applies when the publish time is withintrust_score::FRESHNESS_WINDOW_DAYS(14 days, aligned with the docs' defaultminimumReleaseAgerecommendation). Outside the window the contribution is zero and the signal is omitted from the breakdown — it still appears in the audit signal set for explainability. Future-dated publishes (clock skew or forged metadata) are also treated as outside the window rather than counting as "fresh".TrustScore::computekeeps its current signature for backwards compatibility; a newTrustScore::compute_at(set, now)is added for deterministic callers (the policy gate now uses it, threading the samenowit uses for every other time-relative check).Smoke-validated:
[email protected](a 2023 release) now scores 100/100 on a default policy instead of 90/100.
PyPI sdists are now scanned for install-time RCE patterns.
A new provider crate (installguard-signal-pypi-sdist) closes
the last two cells in the PyPI coverage matrix that had a viable
path: lifecycle_scripts and suspicious_script.
For every resolved PyPI dependency the provider:
- downloads the canonical
.tar.gzsdist from PyPI (subject to a 25 MiB hard cap; oversized releases are skipped with apypi-sdist unavailablereason rather than scanned); - HEAD-probes the file first so a pathological size never costs bandwidth;
- verifies the tarball's SHA-256 against the digest PyPI
publishes for that file, when available — a mismatch logs a
tracing::warnand emits no signal (registry-integrity is separately handled by lockfile-hash verification); - extracts
setup.py(1 MiB cap on the body, UTF-8 lossy fallback so a non-UTF-8 byte sequence still gets scanned) and emitsSignal::LifecycleScripts { scripts: ["setup.py"] }whenever the file is present —setup.pyruns duringpip install, full stop; - runs the body through both the existing shell-pattern
detector (
curl … | sh,wget … | bash,/dev/tcp, base64-decoded shell, …) and a new Python-aware ruleset coveringos.system/subprocesscalls that fetch over the network,exec/evalofurlopen/requests.get/b64decodepayloads, the canonicalsocket.socket(…) + os.dup2 / pty.spawn / sh -ireverse-shell layout, and__import__('os').system(…)obfuscation. Each rule fires at most once per body and emitsSignal::SuspiciousScript.
The provider fails soft on every kind of network or parse
error: anything other than "the file was scanned and we found
findings" produces zero signals (or a single
Signal::Unavailable when the failure is informative).
PEP 517-only sdists (no setup.py, just a pyproject.toml)
correctly produce no lifecycle signal — that is the safe shape
and we want users moving toward it.
A new --no-pypi-sdist flag matches the existing
--no-pypi-registry / --no-osv / --no-deps-dev /
--no-scorecard opt-out family for offline / air-gapped CI
runs or bandwidth-constrained environments.
Smoke-validated against [email protected] (classic setup.py
sdist): lifecycle_scripts: ["setup.py"] is emitted, the
default policy blocks the install, and --no-pypi-sdist
correctly suppresses the signal.
PEP 740 publisher attestations are now surfaced as
provenance_claimed on PyPI deps. The pypi-registry provider
gains a second probe — after fetching /pypi/<name>/<version>/json
to derive published_at and yanked status, it also asks PyPI's
Integrity API
(GET /integrity/<name>/<version>/<filename>/provenance) about
the canonical sdist (or first wheel as fallback) for the release.
A 200 response means the file was uploaded with a Trusted
Publisher attestation that PyPI cryptographically verified at
upload time; we surface that as Signal::ProvenanceClaimed with
bundle_url set to the integrity URL itself, ready for callers
who want to re-fetch and verify.
- Same signal shape as npm provenance (
Signal::ProvenanceClaimed { bundle_url }), so the+10trust-score boost applies identically across ecosystems andpolicy.requireProvenancenow works for PyPI deps too. - Probe is silent on absence: a clean
404(the common case today — Trusted Publishers are still rolling out across the index) emits no signal. Network errors on the probe are swallowed so the metadata signals remain authoritative. pick_attestation_filenameis a pure helper, unit-tested against.tar.gz,.zip-only sdists, and wheel-only releases. Sdists are preferred because publishers attest every artifact in a release with the same identity, so probing one file is enough to detect provenance for the version.- Smoke-tested live:
[email protected]now surfacesprovenance_claimedagainstpypi.org/integrity/sigstore/3.6.1/sigstore-3.6.1.tar.gz/provenance, lifting its trust score to 98/100.
This closes the provenance_claimed deferral on the PyPI side
of the ecosystems coverage matrix. publisher_change and
maintainer_new_account remain deferred — PyPI still does not
expose a stable per-version publisher identity outside of the
attestation envelope, and tracking change across versions
needs that to be queryable cheaply.
Poetry lockfiles are now first-class. The PyPI adapter grows
a third format alongside uv.lock and hash-pinned
requirements.txt: poetry.lock, the TOML lockfile written by
Poetry.
- New
parse_poetry_lockreader. Lock-version1.xand2.xare accepted; the per-package shape is stable across them. Future major versions are rejected withAdapterError::UnsupportedVersionso a schema change can't silently slip through. - Direct vs transitive: poetry stores the project's direct
dependency set in
pyproject.toml, not the lockfile. The adapter peeks at the siblingpyproject.toml(when present) and reads three shapes:[tool.poetry.dependencies](poetry classic)[tool.poetry.group.<name>.dependencies](any group, dev included)[project.dependencies](PEP 621, used by poetry 2.x in modern mode) PEP 508 markers and extras (requests[security]>=2; python_version>='3.8') are stripped to recover the bare distribution name. Thepythonpin is excluded.
- When no sibling
pyproject.tomlexists every entry is conservatively flagged transitive — better than lying about provenance when we genuinely don't know. - Source classification mirrors the other PyPI shapes:
[package.source]withtype = "git"→Source::Git(withresolved_referencepreferred overreference),type = "url"→Source::Tarball,type = "file"/"directory"→Source::File,"legacy"and registry-default →Source::Pypi. - Integrity preference: any non-
.whlfile (typically the sdist) over the first wheel hash, mirroringuv.lock. - CLI auto-discovery extended:
installguard explain/evaluatenow findspoetry.lockin--pathdirectories alongside the other supported lockfiles.
Smoke-tested against a real [email protected] poetry.lock +
pyproject.toml pair — all six PyPI signals
(published_at, three OSV advisories, project_metadata,
scorecard_score) emit identically to the uv.lock path.
OpenSSF Scorecard now scores PyPI dependencies. The Scorecard
provider previously skipped Python deps because it discovered the
upstream source-repo URL via the npm packument. This release
teaches it to read PyPI's info.project_urls map (with
info.home_page as a last-resort fallback) so any PyPI package
that points its Source / Repository / Source Code URL at a
GitHub repo gets a scorecard_score signal.
- Scorecard provider: ecosystem-aware repo lookup. npm-family
deps still hit the npm packument; PyPI deps hit
https://pypi.org/pypi/<name>/<version>/jsonand walkproject_urlsin preference order —Source,Repository,Source Code,Code(case- and separator-insensitive), then any value containinggithub.com, thenhome_page. supports()extended toEcosystem::Pypi.- New pure helper
pick_pypi_repo_url, unit-tested against the inconsistent labelling PyPI maintainers use in the wild (Source-Code,repository,Tracker → /issues, etc). - GitHub-hosting requirement is unchanged: non-github source URLs resolve to no signal (Scorecard's gitlab.com / bitbucket.org coverage is too sparse to be useful today).
- Smoke-tested live:
[email protected]now surfacesscorecard_score: 8againstgithub.com/psf/requests.
Trust scoring on PyPI deps with linked GitHub repos now reflects their Scorecard posture the same way npm-family deps do.
PyPI dependencies are now scored and gated. The 0.2.0 adapter made PyPI deps visible; this release wires three signal providers to them so they actually participate in policy decisions.
- New crate
installguard-signal-pypi-registrycalling the PyPI JSON API (https://pypi.org/pypi/<name>/<version>/json) and emitting:published_at— earliestupload_time_iso_8601across the sdist + wheel files for the resolved version. Drivesmin-release-agegating for PyPI deps.deprecated_version— wheninfo.yanked == true(PEP 592). The maintainer'syanked_reasonbecomes the deprecation message.
- OSV advisory provider now speaks PyPI:
Ecosystem::Pypimaps to the OSV"PyPI"ecosystem label, so GHSA / PyPA advisories land on Python deps the same way they do on npm-family deps. This is the headline value of the slice —cryptography@<X,[email protected],urllib3@<1.26.18etc. now block / warn per the same severity policy as their npm equivalents. - deps.dev provider: system selector parameterised; PyPI version
records now fetch from
/v3alpha/systems/pypi/...and the in-process cache is keyed by(system, name@version)so npm and PyPI never alias. - New CLI flag
--no-pypi-registryfor fully offline / air-gapped CI runs (mirrors--no-osv/--no-deps-dev/--no-scorecard).
Out of scope for this slice (deferred):
- Maintainer / publisher signals — PyPI's JSON API does not
expose per-version publisher identity, so
PublisherChangeandMaintainerNewAccountare not derivable from this endpoint. - OpenSSF Scorecard for PyPI deps — needs
info.project_urlsplumbed into the Scorecard provider; tracked as a follow-up. setup.pystatic analysis for sdists — requires download + extract, a different provider shape; tracked separately.
First non-npm ecosystem. PyPI lockfiles now parse, evaluate, and
report alongside npm / pnpm / yarn projects. The signal providers
will follow in 0.2.x; this release ships the adapter so users can
immediately see PyPI dependencies in scan / ci / lock /
sbom / vex output, and so policy authors can start writing
forward-compatible pypi:-prefixed allowlists today.
- New crate
installguard-adapter-pypirecognising two formats:uv.lock— TOML schema version 1, the canonical lockfile for uv. Pulls per-package sdist/wheel URLs andsha256hashes; root virtual package is suppressed; transitive vs direct is computed from the root'sdependencieslist.requirements.txt— only when generated with hashes (uv pip compile --generate-hashesorpip-compile --generate-hashes). Hash-less files are rejected with a clear actionable error: a wishlist is not a lockfile, and shipping a lockfile-shaped adapter against one would silently lower the bar.
- PEP 503 name normalisation throughout (
Re_quests→requests); ecosystem matchers and cache keys all see the normalised form. pip-compile's# via -r requirements.inannotation classifies direct deps; everything else is transitive.locate_lockfilepriority order is nowpnpm-lock.yaml→yarn.lock→package-lock.json→uv.lock→requirements.txt. npm-family lockfiles still win when both are present (a polyglot repo running InstallGuard from the JS root keeps its existing behaviour).- PyPI deps with no signal provider currently resolve to
allowwith empty signals — visible inscanoutput andsbomcomponents, but not gated until 0.2.x ships the PyPI providers.
This is the first release on the 0.2.x line. Existing 0.1.x
policies, locks, and audit logs are forward-compatible without
changes.
Documentation catch-up release. No binary changes.
The Usage section of https://installguard.dev grew from 9 to 18 pages, covering every subcommand that ships in the binary. Previously undocumented and now landed:
cache— inspect & manage the on-disk signal cache (new in 0.1.17).schema— print the policy JSON Schema for editor integration.lock— deterministic policy-evaluation snapshot.verify— re-evaluate and check against a lock or signed bundle (online, frozen, or signature-verifying modes).attest— unsigned in-toto v1 statement wrapping the verdict.sbom— CycloneDX 1.5 SBOM withinstallguard:*decision properties per component.vex— OpenVEX 0.2.0 mapping decisions to VEX statements.key— generate Sigstore-compatible Ed25519 keypairs.sign— DSSE v1 envelope cosign can verify.
The attestation chain (lock → attest → sign →
verify --bundle) is cross-linked end-to-end so the SLSA L3 /
cosign story is finally walkable from the docs alone.
Documentation & examples release. No binary changes; same gate, more places to plug it in.
- New recipe Dependency bots (Dependabot & Renovate) at https://installguard.dev/recipes/dependency-bots/: how to scope an InstallGuard workflow to bot-authored bump PRs, gate Dependabot automerge on a clean verdict, and configure Renovate to defer automerge to required status checks.
- New drop-in workflow
examples/workflows/installguard-bot-prs.ymlwith a scoped scan job + an optional automerge job for clean patch/minor Dependabot bumps. Includes the security rationale for keeping the gate in a target-branch workflow file so bots can't silently weaken it via a PR-branch edit.
Cache invalidation, finally automatic. The on-disk signal cache
(~/Library/Caches/installguard on macOS, ~/.cache/installguard
on Linux, %LOCALAPPDATA%\installguard\Cache on Windows) now
stamps every entry with the producing tool's CARGO_PKG_VERSION
on write. On read, any entry whose stored tool_version differs
from the running build is treated as stale and dropped — exactly
as a SCHEMA_VERSION mismatch already was. Closes the historical
foot-gun where signal-shape changes shipped between schema bumps
left users hand-running rm -rf ~/Library/Caches/installguard
after every release.
Legacy entries written by 0.1.16 and earlier (which had no
tool_version field) deserialise with the default empty string
and are dropped on first read under 0.1.17 — guaranteeing a clean
slate on the upgrade.
New installguard cache subcommand for inspecting and managing
the cache without reaching for rm:
installguard cache path— prints the resolved cache directory and exits.installguard cache info— per-status breakdown (fresh / stale by version / stale by schema / unreadable) plus the running tool version.installguard cache clear— drops every entry; the nextscanre-fetches signals from the network. Both subcommands honour--cache-dirfor parity withscan.
Type-system placeholders for PyPI: Ecosystem::Pypi and
Source::Pypi { url } now ship in the core crate. Neither variant
is emitted by any adapter today (the PyPI adapter lands in a
later slice — see ROADMAP M8); they exist so downstream match
arms over Ecosystem and Source are forced to handle PyPI
before the adapter starts producing them, eliminating a class
of "we shipped PyPI but cargo build started failing in third
crate X" cliff edges.
Source::Pypiis treated as non-exotic alongsideSource::RegistryandSource::Workspace(PyPI is a first-party registry source).ResolvedDependency::key()for a PyPI dep now produces the expectedpypi/<name>@<version>form.- The OSV provider deliberately skips PyPI deps for now
(returns
Nonefromecosystem_label); the"PyPI"label will be wired in alongside the PyPI signal slice. - The cache key generator no longer hardcodes
"npm"— it derives the registry namespace fromEcosystem::registry_family().as_str(), picking uppypifor free.
No user-facing CLI behaviour changes in this release.
Policy allowlists now accept an optional family: ecosystem
prefix. Bare entries (gaxios, my-pkg) keep working unchanged
and match a package of that name in any registry family —
preserving back-compat with every 0.1.x policy in the wild. New
prefixed entries scope the allow to one family: npm:lodash
matches only npm-family packages (npm/pnpm/yarn), and pypi:requests
parses today as forward-compat for the PyPI adapter (ROADMAP M8).
The grammar applies to defaults.nameSquatAllow and
scripts.allow; scoped npm names (@scope/name,
npm:@scope/name) are accepted in both forms. Unknown family
prefixes (pypy:lodash) fail policy load loudly rather than
silently allowing nothing.
Internally the dependency cache key now derives from
Ecosystem::registry_family() rather than a hardcoded "npm"
literal — paving the way for a pypi/<name>@<version> namespace
without further core changes when the PyPI adapter lands.
New installguard simulate <candidate.yaml> subcommand. Runs the
same evaluation pipeline as scan once against the project's
current policy, then re-evaluates every dependency against the
candidate policy using the same signals (no second network
round-trip), and prints the per-package decision diff: which
packages would be newly blocked, newly warned, newly allowed, or
have their reasons change while staying in the same decision
class. Pretty output groups by class with a +/- reason-code
delta per package; --format json emits a stable
machine-readable shape (schemaVersion: 1) with per-change
before/after details and reasonCodes. Always exits 0 —
simulate is advisory; gating belongs in scan or ci.
Completes the explain (why was this blocked?) /
doctor (what should I add to my policy?) /
simulate (what would happen if I added this?) triad — the
"propose → preview → merge" loop for policy changes without
requiring a separate scratch repo or a network re-fetch.
--frozen is rejected for simulate with a clear error: the
lock stores decisions, not raw signals, so a candidate policy
cannot be re-evaluated against it.
New installguard explain <name>@<version> subcommand. Runs the
same evaluation pipeline as scan / doctor, but for one
package coordinate already present in the lockfile, prints the
full per-package audit trail: every signal observed (rendered as
compact JSON, one per line, so every variant round-trips
losslessly), every reason produced (with stable kebab-case code,
human summary, and remediation hint), and the trust-score
breakdown with each weighted contribution and rationale. Pretty
output is the default; --format json emits a stable
machine-readable shape (schemaVersion: 1) suitable for piping
into tooling. Always exits 0 — explain is informational; gating
belongs in scan or ci. Closes the "scan flagged this — why?"
loop without requiring operators to dig through audit logs or
re-run with RUST_LOG=debug.
dist-tag-anomaly heuristic tightened with three new
suppressions, all driven by false positives observed on real
production lockfiles. (1) Sentinel filter: versions with
major >= 999 are dropped from the highest_published
candidate set. The motivating case is react-native, which
publishes 1000.0.0 precisely to break npm install react-native@latest; treating it as the highest produced a
guaranteed false positive for every RN lockfile. (2)
User-bypass at-or-past max: if the resolved dep version is
itself >= highest_published, the operator has pinned past
latest deliberately (e.g. [email protected] while latest=1.3.8
during a cautious 2.x rollout) and the tag drift is irrelevant
to their install. (3) User-bypass below latest major: if
the resolved dep version is on a major older than latest,
the operator has explicitly stepped off the latest train
(e.g. @expo/[email protected] while Expo SDK 55 is latest and SDK
56 is published) — the tag drift is information about an
ecosystem they're not on. The structural cross-major case
(latest.major < highest.major, both within the user's major)
remains the high-precision pattern we still surface.
Default scripts.allow gains core-js and protobufjs. Both
are the postinstall-runs-helper-script pattern (same shape as
esbuild, playwright, supabase): the script genuinely needs
to run for the package to function (core-js prints its sponsor
banner; protobufjs rebuilds its bundled gRPC descriptors), and
both packages satisfy the existing inclusion criteria — tens of
millions of weekly downloads each, single well-understood
install purpose, no historical takeover advisory tied to the
install script. Defaults remain a curated list, not a
free-for-all: operators wanting different behaviour set
scripts.allow: [] to opt out, or list specific packages to
override.
New installguard doctor subcommand. Runs the same evaluation
pipeline as scan, but instead of printing a verdict it groups
the actionable findings by class and emits a ready-to-paste
installguard.yaml block that resolves the false positives we
have a known fix for: lifecycle-script blocks become a scripts. allow list (one entry per package, commented with the scripts
seen so reviewers can vet before allowing), name-squat blocks
become a defaults.nameSquatAllow list (commented with the
package each one resembles, so operators verify they intended
the package they have), and dist-tag-anomaly /
signal-unavailable blocks become explicit severity: warn
overrides (their default since 0.1.6 / 0.1.7 — surfacing this
suggests the operator had locally promoted them and may want to
revert). Doctor is advisory only — it always exits 0; use scan
or ci to gate. Closes the "blocked → triage → write config"
loop into a single command for first-time adopters.
Default scripts.allow gains supabase. The npm-distributed
Supabase CLI is the postinstall-downloads-platform-binary pattern
(same shape as esbuild, playwright, @biomejs/biome): the
script genuinely needs to run for the package to function, and
the package satisfies the existing inclusion criteria — well over
1M weekly downloads, single well-understood install purpose
(fetch the platform-appropriate CLI binary from GitHub Releases
and install it into node_modules/.bin), no historical
takeover advisory tied to the install script. User-supplied
scripts.allow continues to extend (not replace) the built-in
default.
Policy: defaults.nameSquatAllow allowlist for the name-squat
detector. Levenshtein-1 against the popular-name list catches
typosquats but also produces false positives for legitimate
packages whose names happen to sit close to a popular one — most
visibly gaxios (Google's official HTTP client) being flagged
against axios. Operators can now suppress specific names
without disabling the detector globally:
policyVersion: 1
defaults:
nameSquatAllow: [gaxios]Allowlist is exact-match only — typo-of-an-allowlisted-name still fires.
Registry lookup: tolerate v-prefixed lockfile versions. Some
lockfiles record dependency versions with a leading v
(e.g. @upstash/[email protected] when npm/yarn resolved against a
GitHub release tag). The npm registry stores bare semver per the
npmjs.org docs,
so a literal lookup of v1.35.1 against the packument's time
or versions map missed every time and surfaced as
signal-unavailable. The provider now retries with the leading
v stripped (only when followed by an ASCII digit, so package
names like velocity are unaffected). The dependency continues
to be recorded with its lockfile-fidelity version in
installguard.lock and audit output — only the lookup is
normalized.
Workspace-aware policy. Real-world monorepos (npm workspaces,
where each member appears in package-lock.json at its on-disk
path with no resolved URL) were producing one
signal-unavailable finding per workspace member because the
public registry returned HTTP 404 for the private name. The npm
adapter now classifies these entries as Source::Workspace, the
CLI skips signal gathering for them, and Policy::evaluate
short-circuits to Allow. First-party code is not something the
registry-shaped detectors have anything useful to say about. The
yarn adapter already classified workspace members correctly; pnpm
keeps workspace members out of its packages: map and so was
unaffected.
Policy: signal-unavailable default severity demoted from
block to warn. A provider failing to answer ("the npm
registry timed out", "the OSV API returned 503", "the
package was 404 because it's a private workspace package") is
not evidence of compromise — absence of evidence is not
evidence of attack. Real-world scans against monorepos and
networks with flaky egress were producing dozens of blocks per
run for transient or structural reasons. Operators who want
strict-fail-closed semantics can promote with
severity.signal-unavailable: block in installguard.yaml.
Policy: dist-tag-anomaly default severity demoted from block
to warn. A backwards-moving latest tag is structurally unusual
but most often indicates a maintainer running an LTS line as
latest while a newer major exists on a separate tag (e.g.
error-stack-parser keeping 2.x as latest while 3.x is
published) — not an active attack. Operators who treat every
backwards tag as suspect can promote with
severity.dist-tag-anomaly: block in installguard.yaml.
Bugfix: the per-reason ↳ remediation hint promised in 0.1.4
was wired into Reason::remediation() but never rendered —
the call site in write_pretty_entry was lost in a rebase.
Restored, so each finding now actually prints its hint
immediately under the bullet. The "Next steps" footer was
unaffected and shipped correctly in 0.1.4.
Scan UX: actionable next-steps. A blocked install is only useful
if the operator knows what to do about it. Each finding now
carries a one-line remediation hint specific to its signal class
(e.g. name-squat → "verify you meant this package, not the
popular one it resembles"; suspicious-script → "treat as
suspected supply-chain attack; do NOT install — report to npm
security"), and the pretty output ends with a generic four-bullet
"Next steps" footer pointing at investigation, allowlisting,
freezing, and reporting paths.
Reason::remediation()returns anOption<&'static str>short hint per variant. Exhaustivematchkeeps the table honest: adding a newReasonis a compile error inevery_reason_variant_has_a_remediation_or_is_explicitly_noneuntil its remediation is considered. Hints are capped at ~100 chars to fit one terminal line.- Pretty CLI output (
scan/ci/lock/attest) now renders a dim↳ <hint>line under each finding, plus a "Next steps" footer when blocks or warns are present. The footer carries a concrete registry URL for the first blocked package so the operator can click straight through to investigate. - The footer is suppressed on clean scans and respects the same
NO_COLOR/ non-TTY rules as the rest of the pretty output.
Scan UX: live progress indicator. The evaluate phase used to
sit silent for several seconds while it fanned out to the
registry, deps.dev, OSV and Scorecard for every dependency. On
real-world lockfiles (~1k packages, ~3 s) this read as a hang.
A small Braille spinner now ticks on stderr at 10 Hz with a
done/total counter, redrawn in place; on completion the line
is cleared so the regular pretty verdict starts on column 0.
- Live
\u{2802}\u{2823}\u{2807}Braille spinner during the signal-gather phase ofinstallguard scan,ci,lockandattest. Format:\u{2839} scanning 423/1276. Ticks from a Tokio task so it keeps moving even when the network stalls between completions. - Indicator is fully suppressed when stderr is not a TTY (CI,
pipes, redirects) and when
NO_COLORis set, on the same reasoning as the rest of the CLI's decorative output. No new dependencies — the helper is ~90 lines ofstd::io::stderrandtokio::time.
0.1.2 — 2026-05-14
Second maintenance release. Cuts a further 21 false-positive blocks from the same real-world 1276-package scan that v0.1.1 drove down from ~120 to ~21 — the dominant remaining noise was intentional LTS dist-tag holds and well-known native-binary install scripts. Also retires the on-disk cache schema so the v0.1.1 npm-registry fixes actually take effect on machines that had already populated their cache.
- Default
scripts.allownow includes a curated set of well-known native-binary / asset-bootstrap packages:bcrypt,cypress,electron,esbuild,fsevents,msw,node-gyp,node-pre-gyp,playwright,puppeteer,sharp. Inclusion criteria documented inline next to the constant. Same pattern as the typo allow-list shipped in v0.1.1: sorted slice,binary_searchlookup, sortedness enforced by a unit test. The user-suppliedscripts.allowcontinues to extend (not replace) the built-in default.
DistTagAnomalynow only fires whenlatest's major version is strictly less than the highest published non-prerelease major. Same-major patch / minor drift is overwhelmingly intentional LTS-line maintenance (e.g. Storybook holdinglatest=8.6.14while8.6.18is published and9.xridesnext) and was the dominant remaining source of false-positive blocks. The cross-major case — the structural high-precision signal — is unchanged. A future history-aware re-introduction of the same-major case (firing only whenlatestregressed from a previously-higher value) is possible once we cache prior packument metadata.
installguard scanno longer reportsDistTagAnomalyfor a dependency that is itself on the versionlatestadvertises. In real lockfiles this surfaced as actionable-looking blocks for[email protected]and[email protected]whosedist-tags.latestdeliberately points at the older release line; the user is onlatestand is not actually affected by the gap. The signal itself is still emitted (and feeds the trust score / audit log) so a future history-aware variant can consume it.- Bumped the on-disk signal cache
SCHEMA_VERSIONfrom 1 to 2 so caches written by v0.1.0 / v0.1.1 are invalidated automatically on first use under v0.1.2. Without this bump, the v0.1.1 binary still surfaced stalepreparelifecycle-script blocks and stalesignal provider "npm-registry" unavailable: decode: …warnings for any package whose packument was fetched and cached under the pre-fix code paths. The schema-version check that drops mismatched entries was already in place; only the constant needed bumping.
0.1.1 — 2026-05-14
First maintenance release. Reduces noise from real-world scans,
fixes a packument decode regression that affected the React 19
family, and ships the new installguard report subcommand that
was already merged on develop after v0.1.0.
- New
installguard report --from <summary.json>subcommand that renders aci --summary-fileJSON document as the canonical Markdown sticky-comment body (GitHub PR / GitLab MR / any GFM consumer). Output is deterministic, includes the<!-- installguard-summary -->HTML marker for sticky-comment idempotency, escapes|in reason cells, and truncates with--max-rows. Optional--commitand--exit-codeflags surface context in the comment footer. Reason::human_summary()promoted from a private function invex.rsto a public method onReason. This is the single source of truth for English renderings of reason variants and is shared by VEXaction_statement, audit logs, the newreportsubcommand, and the newscan --format prettyrenderer. Stability guarantee: existing variants' meaning will not change between minor versions; new variants add new arms only.installguard scangains a newprettyoutput format (now the default) that groups results by severity, renders each reason viaReason::human_summary(), and ANSI-colours the verdict / counts. Honours the conventionalNO_COLORenv var (https://no-color.org) and disables colour automatically when stdout is not a TTY. The previoushumanandjsonformats remain available.- Curated allow-list inside
name_similarity::classifyfor well-known packages whose names are exactly distance-1 from a popular target (ulid/uuid,nuxt/next,preact/react, plusredis,vitest,fastly). Allow-listed names short- circuit toClassification::Okwithout being promoted to new typosquat targets themselves.
- GitHub Action (
.github/actions/installguard/action.yml) and GitLab CI template (ci/gitlab/installguard.gitlab-ci.yml) now shell out toinstallguard reportfor the PR/MR comment body. Previously each surface had its own renderer (JavaScript and Python respectively) covering only 6 of the ~20Reasonvariants — every M3/M4 reason was rendered as an opaque kebab-case code. Both surfaces now describe every variant in plain English with no template-side maintenance. - Default
--formatforinstallguard scanswitches fromhumantopretty. Scripts that grep one-line-per-decision output should pass--format humanexplicitly.
installguard-signal-npm-registrycould fail to decode any packument whose per-versiondeprecatedfield arrived as a JSON boolean instead of the documented string — notably[email protected],[email protected],[email protected]+,react-is, andreact-reconciler. Previously the entire packument decode errored withinvalid type: boolean, which downgraded those packages tosignal_unavailableand forced a BLOCK on policies requiring publish-time anomaly checks. The field now uses a custom deserialiser that preserves any string verbatim and coerces every other shape (boolean, null, number, array, object) toNone.- The npm registry adapter no longer reports
prepareas a registry lifecycle script.prepareonly runs onnpm installfrom a git source, never from a registry tarball, so reporting it for registry packuments generatedDisallowedLifecycleScriptnoise on every package whose maintainers declare a build-timeprepare(Husky, TypeScript libraries, the React monorepo, etc.) without flagging anything that can actually execute on the consumer's machine. Git-source dependencies remain gated by theSource::Gitrules in policy.rs. - PR / MR sticky comments now describe
advisory_known,license_disallowed,scorecard_below_threshold,maintainer_new_account,name_squat,version_surface_change,dist_tag_anomaly,trust_score_below_threshold,provenance_missing,project_archived,license_missing,publisher_change,deprecated_version, andsuspicious_scriptproperly. Previously these displayed only their kebab-case code on both GitHub and GitLab.
0.1.0 — 2026-05-13
First tagged alpha. Covers milestones M0 through M4 from
ROADMAP.md.
- Project scaffolding, workspace layout, lint baseline, design docs, CI matrix, release workflow stub, deny.toml, pinned toolchain.
- npm/pnpm/yarn lockfile parsers and resolved-dependency model.
npm-registrysignal provider with on-disk caching.- Core
Signal,Reason,Decision, andPolicytypes with YAML + JSON schema and golden-file round-tripping. lifecycle-scripts,published-at(minimum release age), andsuspicious-scriptheuristics.- CLI
installguard evalwith allow/warn/block exit codes and human + machine-readable output.
- CycloneDX 1.5 SBOM export with per-component policy-decision properties.
- in-toto v1 attestation predicate (
policy-evaluation/v1). - OpenVEX 0.2.0 export, one document per blocked decision, with human-readable justifications.
- JSONL audit log sink for downstream SIEM ingestion.
--frozen-policymode that pins all signal inputs into the lockfile so later evaluations are reproducible without network.- Cosign-compatible DSSE signing of attestations (ed25519 keys; keyless flow deferred until first tagged release proves the workflow end-to-end).
- Publisher-change detection from npm packument history.
- Deprecated-version detection.
- Static analysis of install-script bodies (curl-pipe-to-shell, base64 exec, network egress in postinstall, etc.).
- Version-surface-change detector (file-list deltas between adjacent versions).
- Dist-tag anomaly detector (e.g.
latestmoved to an older version). - Typosquat / homoglyph name-similarity detector against a curated high-value-target list.
- Maintainer-account-age detector with
minMaintainerAccountAgeDayspolicy gate. - Sigstore provenance attestation structural verification (bundle parsed, certificate chain validated against Fulcio root, identity/issuer matched).
- Trust-score capstone: per-signal contributions fold into a 0-100 score with
a
minTrustScorepolicy gate.
installguard-signal-osv— OSV.dev advisory provider, severity bucketed from CVSS v3 base score, gated bymaxAdvisorySeverity.installguard-signal-depsdev— deps.dev project-metadata provider feedingrequireLicense,licenseAllowlist, andblockArchivedpolicy gates.installguard-signal-scorecard— OpenSSF Scorecard provider with two-step npm→repo→score lookup, gated byminScorecardScore.CompositeProvider— fans signal collection out across multiple providers in parallel, materialising per-provider failures asSignal::Unavailablerather than aborting the run.- CLI flags
--no-osv,--no-deps-dev,--no-scorecardto opt out of individual external providers. - Public
SignalProvidertrait stabilised with semver guarantees from 0.1 onwards; worked example atcrates/core/examples/minimal_provider.rs.
- Maintainer 2FA enforcement signal — npm's public registry does not expose per-account 2FA status; revisit if/when a credible upstream source appears.
- Socket / Snyk providers — both require paid API keys; left as
community-maintained out-of-tree crates against the now-public
SignalProvidertrait. - Plugin discovery + signature verification — needs
dlopen/wasm infrastructure and a signing trust root; tracked for M7+. - Sigstore keyless signing in CI — the SLSA generator step in
.github/workflows/release.ymlis wired but commented out pending a real first-release dry run.