Skip to main content

Rust reachability with rusi

Learning objective

After this chapter you will be able to run dep-scan reachability on a Rust project, explain how rusi produces the slices dep-scan consumes, and predict which crates will be marked Reachable versus merely present in the BOM. You will also understand the safety implications of the stable and compiler backends.

What rusi is and when to use it

Rusi (Rust Source Inspector) is the native slicer dep-scan uses for Rust. It emits a call graph and interprocedural source-to-sink data-flow slices with purl attribution, which dep-scan converts into its shared, purl-keyed reachability pipeline. The result is that a vulnerable crate whose functions are actually called (for example time::now() in the RUSTSEC-2020-0071 segfault advisory) is marked Reachable, while a crate that is merely listed in the BOM but never called is not. Reachability is on by default for Rust once the slices are present, so for most projects you simply scan with the research profile and read the Reachable insights.

Prerequisites

You need the standard Rust toolchain plus dep-scan and cdxgen. For the default backend you do not need a nightly compiler.

  • Rust (stable) and Cargo, so cargo metadata can resolve a populated Cargo.lock and the BOM carries versioned pkg:cargo purls.
  • cdxgen and dep-scan, installed by npm install -g @cyclonedx/cdxgen and pip install owasp-depscan.
  • The rusi binary, resolved automatically (see below) or supplied explicitly.

How dep-scan runs rusi

Under --profile research, which dep-scan forces when reachability is on, cdxgen runs rusi via its evinse layer for Rust projects and persists the full raw rusi report to the *-semantics.slices.json path. dep-scan then consumes that persisted report; it does not spawn rusi itself when cdxgen and the plugins are available. rusi is invoked directly only as a fallback.

The normal data path routes through cdxgen, which is the same pattern .NET uses but different from Go, where dep-scan invokes the slicer itself.

  cdxgen (evinse) ──runs──▶ rusi ──writes──▶ *-semantics.slices.json

consumed by

dep-scan converter ──▶ reached_purls
(fallback only: dep-scan spawns rusi directly if cdxgen/plugins absent)

dep-scan resolves the rusi binary the same way cdxgen does. It checks the RUSI_CMD environment variable, then DEPSCAN_RUSI_BINARY, then rusi on PATH, then the bundled cdxgen-plugins-bin layout at <pluginsDir>/rusi/rusi-<platform>-<arch>. When cdxgen spawns, dep-scan propagates RUSI_CMD and CDXGEN_PLUGINS_DIR to the subprocess so both sides use the same binary. If rusi cannot be found, Rust reachability is skipped with a warning and never a crash, and other languages are unaffected.

Example invocations

The hermetic fixtures under test/data/rusi/repos/ let you reproduce the reachable-versus-present contrast offline. The rustsec-app fixture pins time = "0.1", which resolves to time@0.1.45, the crate behind RUSTSEC-2020-0071, and its main calls time::now() on an environment-influenced path.

# Reachable case: time@0.1.45 is both present and called
depscan -i ./test/data/rusi/repos/rustsec-app -o ./reports --profile research --explain

# Point dep-scan at a dev build of rusi
DEPSCAN_RUSI_BINARY=/path/to/rusi depscan -i ./test/data/rusi/repos/rustsec-app -o ./reports --profile research --explain

The control is unreachable-app, whose Cargo.toml lists dependencies but whose main only prints a greeting and never calls into any external crate.

# Present-but-unreachable case: no crate sits on a traced path
depscan -i ./test/data/rusi/repos/unreachable-app -o ./reports --profile research --explain

Reachability is on by default (--reachability-analyzer FrameworkReachability). Switch to SemanticReachability only when you also want reached services and endpoints attributed.

Analysis walkthrough

Consider the rustsec-app source. Its main reads env::var("TZ") and then calls time::now(), so the vulnerable time API is reached with tainted influence. rusi emits a call-graph edge from vulnerable_web_app::main to time::now, and the converter reconciles that onto the BOM's versioned pkg:cargo/time@0.1.45 purl. That purl lands in reached_purls, and because RUSTSEC-2020-0071 affects exactly that version, analyze_cve_vuln tags the finding Reachable and sets depscan:prioritized. The --explain output shows the flow from source to sink, which is how an analyst confirms the call is real rather than a heuristic guess.

A representative slice (drawn from the committed test/data/rust-reachables.slices.json fixture, trimmed for readability) shows the shape dep-scan consumes:

{
"flows": [
{
"code": "vulnerable_web_app::main",
"parentFileName": "src/main.rs",
"tags": "call-graph, rust, call-graph"
},
{
"code": "sqlx::query",
"parentFileName": "src/main.rs",
"lineNumber": 11,
"isExternal": true,
"tags": "pkg:cargo/sqlx@0.6.2, sql, sql-query, rust, file-to-sql-query"
}
],
"purls": ["pkg:cargo/sqlx@0.6.2"]
}

Notice the versioned purl in the tags string and in the purls array. That versioned purl is what the reconciler matches against the BOM component, and it is what makes the flow count as evidence of reachability rather than a loose mention of the crate name.

The contrast with unreachable-app is precise. That fixture's main does not call any external crate, so rusi produces no call-graph edge and no data-flow slice touching a dependency. The converter therefore emits no flow carrying a pkg:cargo/... purl, reached_purls stays empty, and time (had it been declared) would never earn the Reachable insight. The package is present in the BOM and would still appear in the full Vulnerability Disclosure Report, but it carries depscan:prioritized=false and no reachable insight, which is the triage signal to move on.

Reading the artifacts

Three files in the reports directory tell the story. The *-reachables.slices.json (and the *-semantics.slices.json carrying the raw rusi report) hold the slices; check here first if no finding is Reachable, because an empty or purl-less file explains the silence. The <base>.vdr.json carries the depscan:prioritized property and the depscan:insights text ("Reachable", "Endpoint-Reachable") per vulnerability; filter the vulnerabilities array on those properties to reconstruct the prioritized set, as described in the VDR guide. When you generate a VEX document with --csaf, the reached pkg:cargo/time@0.1.45 becomes known_affected while a present-but-unreached crate becomes known_not_affected carrying vulnerable_code_not_in_execute_path, as covered in the CSAF VEX guide.

Backend selection and safety

The backend you select changes what rusi is allowed to do, and the safety posture follows from that. The default stable backend is syn-based parsing only: it reads and analyzes source without compiling or executing anything, so it is safe to run on untrusted repositories. Select it explicitly with --rust-analyzer-backend stable.

The compiler backend embeds a nightly rustc and builds the target, which means it runs cargo and rustc and can therefore execute build scripts. It is enabled only via explicit opt-in such as --deep or --rust-analyzer-backend compiler, and you should consult the rusi threat model before pointing it at untrusted code. The reachability quality is higher with the compiler backend because it has full type information, but the trust assumption is different, so the default remains the safe, parsing-only path.

Troubleshooting

If *-semantics.slices.json is absent or empty, rusi was not invoked, which usually means the binary was not found. Confirm resolution by setting SCAN_DEBUG_MODE=debug and checking the diagnostic, then point RUSI_CMD or DEPSCAN_RUSI_BINARY at a known-good binary. If the file exists but no crate is Reachable, ensure Cargo.lock is populated so the BOM carries versioned purls; a versionless purl cannot be reconciled onto a flow. If the compiler backend fails on an untrusted repo, that is expected behavior for build-script execution; fall back to the stable backend.

Summary

rusi gives Rust the same reachable-versus-present semantics every other language enjoys in dep-scan, through a converter that reconciles native call-graph and data-flow slices onto the BOM's versioned purls. The rustsec-app fixture shows a real advisory (RUSTSEC-2020-0071 on time@0.1.45) marked Reachable because time::now() is called, while unreachable-app shows a crate that is merely declared and therefore never reached. Keep the default stable backend for untrusted repos, reserve the compiler backend for trusted code where the extra precision is worth the build execution, and read the prioritized set from the VDR or the CSAF status from the VEX. The neighboring chapters Go reachability and .NET reachability cover the other native slicers, and the Framework reachability chapter explains the engine these slicers feed.