rustinel static supply-chain risk scanner for Cargo github crates.io docs.rs

The supply-chain attack exists before the CVE does. Scan for the attack.

rustinel is a static, offline-first risk scanner and pull-request firewall for Cargo dependencies. It flags maintainer takeovers, typosquats, secret-exfiltration code and hidden payloads while they are still invisible to advisory databases — and matches cargo audit advisory-for-advisory on the ones that aren’t. It never executes the code it analyzes.

version0.1.1
licenseMIT OR Apache-2.0
msrvrustc 1.86
platformslinux · macos · windows
distributioncrates.io · marketplace
$cargo install cargo-rustinel
01

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)
02

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.

03

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.

signalfires whenprecedent
owners_changedtakeoverThe 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_publishedtakeoverThe 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_typosquatimpersonationThe 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_substitutionimpersonationA 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 codeRuntime 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 codeA 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 codeCode 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 codeA 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 timeA 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_cratepolicyA 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.

04

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

incidentyearshapecaught by
xz / liblzma2024maintainer takeover of a ubiquitous dependency, years in the makingowners_changed, freshness
event-stream2018npm handover to a volunteer attacker — same shape, different ecosystemowners_changed
faster_log2025crates.io typosquat scanning source files for wallet keys, exfiltrating via workers.devexfil, typosquat, freshness
rustdecimal2022crates.io typosquat with a CI-gated payload (GITLAB_CI → download → exec)env_gated_payload
dep. confusiona popular crate name served from a non-crates.io sourcesource_substitution

Each reconstruction is a deterministic, permanently-passing test — what advisory scanners structurally cannot see.

05

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.
06

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 critical rule. Risk scoring applies diminishing returns per signal class — thirty -sys crates cannot drown one exfiltration finding.
  • Explainable decisions--explain prints the live score breakdown; every decision lists the exact violations, review items and warnings that produced it.
07

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.

formatstandarduse
cyclonedxCycloneDX 1.5SBOM with embedded vulnerabilities and SHA-256 component hashes — EU CRA / US EO 14028
spdxSPDX 2.3SBOM with packages, relationships and validated license expressions
osvosv.devvulnerability records for OSV-compatible tooling
openvexOpenVEX 0.2machine-readable exploitability statements; policy-waived advisories become not_affected
sarifSARIF 2.1.0code-scanning dashboards — GitHub Security tab and others
08

Security model

A supply-chain tool must not be a supply-chain risk. Every input rustinel touches is treated as hostile.

  1. No executionNever runs build.rs, never runs cargo build on an analyzed project, never loads or executes dependency code. Analysis is static and read-only.
  2. Offline by defaultAdvisory matching runs from a local cache. The only network feature, --online-metadata, is opt-in; a --no-default-features build contains no HTTPS client at all.
  3. 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.
  4. Hostile-input hardenedEvery parser of untrusted input is panic-guarded and continuously fuzzed (8 nightly coverage-guided targets).
  5. Bounded everythingSize-capped reads — oversized files are scanned as a prefix, never skipped. Depth- and entry-bounded walks; symlinks never followed.
  6. Injection-safe outputPR comments, SARIF and terminal output escape untrusted text, including Trojan-Source bidi overrides and zero-width characters.
  7. Loud degradationA failed lookup, a partial result, a capped walk — every degraded answer says so. No silent fail-open paths.
09

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