Threat model
An advisory database is a list of incidents that already happened, got noticed, got triaged
and got published. Every supply-chain attack has a window — days to years — in which it is live and no
database knows it. The xz backdoor sat in a trusted release; faster_log stole wallet keys from
crates.io for weeks with a clean cargo audit report. rustinel is built for that window, and
composes with the tools that aren’t.
cargo audit — after disclosure
- Known RustSec advisories, matched by semver against the lockfile
- Requires the incident to be discovered, triaged and published first
- Nothing to say about a brand-new crate, a new maintainer, or a redirected source
rustinel — before disclosure, and after
- Everything on the left: advisory-for-advisory parity, verified against the live database and gated in CI
- Ten pre-CVE detectors for takeover, impersonation, malicious code and provenance (§03)
- The PR risk diff: how this specific change moves your risk, and why (§05)
- Compliance artifacts from the same scan: SBOM, VEX, OSV, SARIF (§07)
Demonstration
A pull request adds one dependency. There is no advisory to match — when this crate was live on crates.io, no database knew it. Three independent signals flag it statically, source never executed.
$ cargo rustinel diff --base-lockfile main/Cargo.lock --head-lockfile pr/Cargo.lock \ --source-path vendor/ --policy rustinel.toml Risk delta: 12 → 64 (+52) HIGH Decision: FAIL Added packages: + [email protected] Top findings: [HIGH] [email protected]: source scans the project’s own .rs files and contacts a known exfiltration endpoint (*.workers.dev) [HIGH] [email protected]: crate name is one edit away from the popular crate `fast_log` — likely typosquat / impersonation [LOW ] [email protected]: version published 2 day(s) ago — the window in which an attack lives before anyone has reviewed it
Fig. 1 — the September 2025 faster_log crypto-stealer, reconstructed as a
permanent test in rustinel’s suite. Every finding carries evidence, a confidence score and the dependency
path that pulls the crate into the tree.
Signal catalog
All detectors are static and read-only. rustinel never runs build.rs, never
compiles, never loads the code it analyzes — analysis is substring- and structure-level over unpacked
sources, lockfiles and registry metadata.
| signal | fires when | precedent |
|---|---|---|
| owners_changedtakeover | The owner set of a dependency changed against your recorded trust baseline. A maintainer handover is invisible in code and in every advisory feed. | xz, event-stream |
| freshly_publishedtakeover | The locked version was published within the last 14 days. New means unreviewed; the publication window is where a compromised release does its damage. | xz 5.6.0 |
| possible_typosquatimpersonation | The name is one edit from a popular crate, corroborated against download counts so established look-alikes stay quiet. Detects Unicode homoglyphs (serdе, Cyrillic е) that byte-level checks cannot see. | faster_log |
| source_substitutionimpersonation | A popular crate name resolves from a source that is not crates.io — dependency confusion, including a lockfile that silently redirects one pinned version to a git repository. | dep. confusion |
| suspicious_source_exfilmalicious code | Runtime source reads the project’s own files and reaches the network or handles wallet-key formats — in the same file. | faster_log |
| suspicious_exfil_domainmalicious code | A hard-coded exfiltration endpoint: *.workers.dev, webhook.site, pastebin, ngrok. Dual-use services (Telegram, Discord webhooks) require same-file secret handling, so bots and notifiers stay clean. | faster_log, xrvrv |
| env_gated_payloadmalicious code | Code that detonates only under CI environment variables — env read, network fetch and process execution within a tight window. Built to hide on a laptop and fire in CI. | rustdecimal |
| obfuscated_payloadmalicious code | A large embedded base64/hex blob, decoded and fed to a process spawn or dynamic loader. A blob decoded into data — a certificate, a fixture — is not flagged; the execution sink is the discriminator. | — |
| build_script_suspiciousbuild time | A build.rs that reaches the network or unpacks an opaque payload, scanned statically. Legitimate cc-style native builds are not flagged. | build-time droppers |
| denied_cratepolicy | A crate on the operator deny list — matched across -/_ variants and enforced on git and path dependencies, so it can never silently no-op. | — |
Baseline coverage
RustSec advisory matching (full cargo-audit parity, v4 database, offline cache) ·
yanked versions · license detection with SPDX expression evaluation and allow/deny policy ·
unsafe usage counts · native/FFI surface · duplicate versions · lockfile-poisoning
diff (a redirected source or swapped checksum on an unchanged version) · a
dependency path on every transitive finding.
Measured precision
A heuristic scanner is only useful if it does not cry wolf. Every number below is reproducible from the repository: the data study ships as a script, the reconstructions run as permanent tests, and parity is enforced by a CI gate.
- 0 / 966
- false positives from the signals that assert malice, across 966 real crates — full dependency closures plus freshly-published crates.io uploads. Methodology and raw numbers.
- 1 : 1
- advisory parity with cargo-audit, verified finding-for-finding against the live RustSec database on every CI run.
- 8 fuzz targets
- coverage-guided fuzzing over every parser that touches untrusted input — lockfiles, manifests, sources, advisories — running nightly.
Reconstructed incidents
| incident | year | shape | caught by |
|---|---|---|---|
| xz / liblzma | 2024 | maintainer takeover of a ubiquitous dependency, years in the making | owners_changed, freshness |
| event-stream | 2018 | npm handover to a volunteer attacker — same shape, different ecosystem | owners_changed |
| faster_log | 2025 | crates.io typosquat scanning source files for wallet keys, exfiltrating via workers.dev | exfil, typosquat, freshness |
| rustdecimal | 2022 | crates.io typosquat with a CI-gated payload (GITLAB_CI → download → exec) | env_gated_payload |
| dep. confusion | — | a popular crate name served from a non-crates.io source | source_substitution |
Each reconstruction is a deterministic, permanently-passing test — what advisory scanners structurally cannot see.
CI integration
Dependencies change in pull requests, so that is where review belongs. The GitHub Action posts a sticky comment with the risk delta — created once, updated in place on every push — and fails the check on a policy violation.
# .github/workflows/supply-chain.yml permissions: contents: read pull-requests: write jobs: supply-chain: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: { fetch-depth: 0 } - run: | git show "origin/${{ github.base_ref }}:Cargo.lock" \ > base.Cargo.lock || cp Cargo.lock base.Cargo.lock - uses: kosiorkosa47/rustinel@v0 with: command: diff base-lockfile: base.Cargo.lock head-lockfile: Cargo.lock policy: rustinel.toml online-metadata: "true" # code-scanning: "true" # findings -> Security tab
- Sticky PR commentAdvisories and pre-CVE signals in separate sections: reviewers see what cargo-audit would have said, and what only rustinel sees.
- Policy-driven exit codeThe gate is your
rustinel.toml, not a hardcoded threshold.fail→ exit 1. - Code-scanning alertsOpt-in SARIF upload files findings on the Security tab with stable fingerprints — alerts resolve when fixed instead of churning.
- Pinned installA version-tagged ref installs exactly that version; any other ref builds from the exact pinned commit. The scanner is not itself a supply-chain risk.
Policy engine
Three built-in profiles (strict / balanced / permissive),
overridable per project. The engine is fail-closed by design: misconfiguration degrades loudly, never silently.
# rustinel.toml version = 1 [profile] name = "balanced" [risk] max_project_score = 70 fail_on_delta_above = 35 [advisories] fail_on = ["critical", "high"] [licenses] deny = ["GPL-3.0", "AGPL-3.0"] [deny] crates = ["leftpad-rs"]
- Fail-closed semanticsA malformed SPDX expression containing a denied license is denied. An advisory severity the policy forgot to list surfaces as a warning. A misspelled profile name is a hard error, not a silent fallback.
- Severity that means somethingCVSS v3.x vectors are computed per the first.org specification, so a 9.8 drives the
criticalrule. Risk scoring applies diminishing returns per signal class — thirty-syscrates cannot drown one exfiltration finding. - Explainable decisions
--explainprints the live score breakdown; every decision lists the exact violations, review items and warnings that produced it.
Interchange formats
cargo rustinel export emits standards-grade artifacts from the same scan —
deterministic (--no-timestamp for byte-identical output), JSON-encoded, with correct
provenance qualifiers for git and alternate-registry dependencies.
| format | standard | use |
|---|---|---|
| cyclonedx | CycloneDX 1.5 | SBOM with embedded vulnerabilities and SHA-256 component hashes — EU CRA / US EO 14028 |
| spdx | SPDX 2.3 | SBOM with packages, relationships and validated license expressions |
| osv | osv.dev | vulnerability records for OSV-compatible tooling |
| openvex | OpenVEX 0.2 | machine-readable exploitability statements; policy-waived advisories become not_affected |
| sarif | SARIF 2.1.0 | code-scanning dashboards — GitHub Security tab and others |
Security model
A supply-chain tool must not be a supply-chain risk. Every input rustinel touches is treated as hostile.
- No executionNever runs
build.rs, never runscargo buildon an analyzed project, never loads or executes dependency code. Analysis is static and read-only. - Offline by defaultAdvisory matching runs from a local cache. The only network feature,
--online-metadata, is opt-in; a--no-default-featuresbuild contains no HTTPS client at all. - SSRF-proof lookupsOnline queries go to a fixed host with validated crate-name paths and redirects disabled. No request target is ever derived from analyzed data.
- Hostile-input hardenedEvery parser of untrusted input is panic-guarded and continuously fuzzed (8 nightly coverage-guided targets).
- Bounded everythingSize-capped reads — oversized files are scanned as a prefix, never skipped. Depth- and entry-bounded walks; symlinks never followed.
- Injection-safe outputPR comments, SARIF and terminal output escape untrusted text, including Trojan-Source bidi overrides and zero-width characters.
- Loud degradationA failed lookup, a partial result, a capped walk — every degraded answer says so. No silent fail-open paths.
Installation
install
# from crates.io cargo install cargo-rustinel # prebuilt binary cargo binstall cargo-rustinel # zero network dependencies cargo install cargo-rustinel \ --no-default-features
MSRV 1.86 · Linux, macOS, Windows
scan
# one lockfile cargo rustinel check \ --lockfile Cargo.lock # how does this PR change risk? cargo rustinel diff \ --base-lockfile base.Cargo.lock \ --head-lockfile Cargo.lock \ --format markdown
Formats: human · json · markdown · sarif
gate & export
# starter policy cargo rustinel policy init \ --profile balanced > rustinel.toml # sync the RustSec database cargo rustinel advisory update # compliance artifacts cargo rustinel export --format cyclonedx
Exit code is policy-driven: fail → 1