Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ toml = "1.0.7"
# Output
colored = "3.1.1"

# Parallelism
rayon = "1"

# Testing
trybuild = "1"
insta = { version = "1", features = ["yaml"] }
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,27 @@ cargo install --path crates/cargo-capsec
### Run

```bash
# Scan workspace crates only (fast, default)
cargo capsec audit

# Scan workspace + dependencies — cross-crate propagation shows
# which of YOUR functions inherit authority from dependencies
cargo capsec audit --include-deps

# Control dependency depth (default: 1 = direct deps only)
cargo capsec audit --include-deps --dep-depth 3 # up to 3 hops
cargo capsec audit --include-deps --dep-depth 0 # unlimited

# Supply-chain view — only dependency findings
cargo capsec audit --deps-only
```

```
my-app v0.1.0
─────────────
FS src/config.rs:8:5 fs::read_to_string load_config()
NET src/api.rs:15:9 TcpStream::connect fetch_data()
NET src/api.rs:15:9 reqwest::get fetch_data()
↳ Cross-crate: reqwest::get() → TcpStream::connect [NET]
PROC src/deploy.rs:42:17 Command::new run_migration()

Summary
Expand Down
5 changes: 5 additions & 0 deletions crates/cargo-capsec/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ categories = ["development-tools", "command-line-utilities"]
name = "cargo-capsec"
path = "src/main.rs"

[features]
default = ["parallel"]
parallel = ["dep:rayon"]

[dependencies]
clap.workspace = true
syn.workspace = true
Expand All @@ -22,6 +26,7 @@ serde.workspace = true
serde_json.workspace = true
toml.workspace = true
colored.workspace = true
rayon = { workspace = true, optional = true }
capsec-core.workspace = true
capsec-std.workspace = true

Expand Down
6 changes: 3 additions & 3 deletions crates/cargo-capsec/src/authorities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
//! The registry is compiled into the binary via [`build_registry`]. Users can extend it
//! at runtime with custom patterns loaded from `.capsec.toml` (see [`CustomAuthority`]).

use serde::Serialize;
use serde::{Deserialize, Serialize};

/// The kind of ambient authority a call exercises.
///
Expand All @@ -24,7 +24,7 @@ use serde::Serialize;
/// | `Env` | Environment variable access | Yellow |
/// | `Process` | Subprocess spawning (`Command::new`) | Magenta |
/// | `Ffi` | Foreign function interface (`extern` blocks) | Cyan |
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Category {
/// Filesystem access: reads, writes, deletes, directory operations.
Expand Down Expand Up @@ -65,7 +65,7 @@ impl Category {
/// | `Medium` | Can read data or create resources | `fs::read`, `env::var`, `File::open` |
/// | `High` | Can write, delete, or open network connections | `fs::write`, `TcpStream::connect` |
/// | `Critical` | Can destroy data or execute arbitrary code | `remove_dir_all`, `Command::new` |
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Risk {
/// Read-only metadata or low-impact queries.
Expand Down
13 changes: 12 additions & 1 deletion crates/cargo-capsec/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,21 @@ pub struct AuditArgs {
#[arg(short, long, default_value = "text", value_parser = ["text", "json", "sarif"])]
pub format: String,

/// Also scan dependency source code from cargo cache
/// Also scan dependency source code from cargo cache.
/// With cross-crate propagation, findings from dependencies are
/// transitively attributed to workspace functions that call them.
#[arg(long)]
pub include_deps: bool,

/// Only scan dependencies, skip workspace crates (supply-chain view)
#[arg(long, conflicts_with = "include_deps")]
pub deps_only: bool,

/// Maximum dependency depth to scan (0 = unlimited, default: 1 = direct deps only).
/// Only meaningful with --include-deps or --deps-only.
#[arg(long, default_value_t = 1)]
pub dep_depth: usize,

/// Minimum risk level to report
#[arg(long, default_value = "low", value_parser = ["low", "medium", "high", "critical"])]
pub min_risk: String,
Expand Down
174 changes: 174 additions & 0 deletions crates/cargo-capsec/src/cross_crate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
//! Cross-crate authority propagation.
//!
//! Converts export maps from dependency crates into [`CustomAuthority`] values
//! that can be injected into the detector. This bridges dependency analysis
//! (Phase 1) with workspace crate analysis (Phase 2).

use crate::authorities::CustomAuthority;
use crate::export_map::CrateExportMap;

/// Converts a collection of export maps into [`CustomAuthority`] values for
/// injection into the detector.
///
/// For each entry in each export map, creates a `CustomAuthority` with the
/// module-qualified path split into segments. The suffix matching in
/// [`Detector::matches_custom_path`](crate::detector) handles both
/// fully-qualified calls and imported calls.
#[must_use]
pub fn export_map_to_custom_authorities(export_maps: &[CrateExportMap]) -> Vec<CustomAuthority> {
let mut customs = Vec::new();

for map in export_maps {
for (key, authorities) in &map.exports {
let path: Vec<String> = key.split("::").map(String::from).collect();

for auth in authorities {
customs.push(CustomAuthority {
path: path.clone(),
category: auth.category.clone(),
risk: auth.risk,
description: format!(
"Cross-crate: {}() → {} [{}]",
key,
auth.leaf_call,
auth.category.label(),
),
});
}
}
}

customs
}

#[cfg(test)]
mod tests {
use super::*;
use crate::authorities::{Category, Risk};
use crate::export_map::{CrateExportMap, ExportedAuthority};
use std::collections::HashMap;

fn make_export_map(
crate_name: &str,
entries: Vec<(&str, Category, Risk, &str)>,
) -> CrateExportMap {
let mut exports = HashMap::new();
for (key, category, risk, leaf_call) in entries {
exports
.entry(key.to_string())
.or_insert_with(Vec::new)
.push(ExportedAuthority {
category,
risk,
leaf_call: leaf_call.to_string(),
is_transitive: false,
});
}
CrateExportMap {
crate_name: crate_name.to_string(),
crate_version: "1.0.0".to_string(),
exports,
}
}

#[test]
fn single_export_map() {
let map = make_export_map(
"reqwest",
vec![(
"reqwest::get",
Category::Net,
Risk::High,
"TcpStream::connect",
)],
);
let customs = export_map_to_custom_authorities(&[map]);
assert_eq!(customs.len(), 1);
assert_eq!(customs[0].path, vec!["reqwest", "get"]);
assert_eq!(customs[0].category, Category::Net);
assert!(customs[0].description.contains("Cross-crate"));
assert!(customs[0].description.contains("reqwest::get"));
}

#[test]
fn multiple_exports_per_crate() {
let map = make_export_map(
"tokio",
vec![
(
"tokio::fs::read",
Category::Fs,
Risk::Medium,
"std::fs::read",
),
(
"tokio::net::connect",
Category::Net,
Risk::High,
"TcpStream::connect",
),
],
);
let customs = export_map_to_custom_authorities(&[map]);
assert_eq!(customs.len(), 2);
}

#[test]
fn multiple_crates() {
let map1 = make_export_map(
"reqwest",
vec![(
"reqwest::get",
Category::Net,
Risk::High,
"TcpStream::connect",
)],
);
let map2 = make_export_map(
"rusqlite",
vec![(
"rusqlite::execute",
Category::Ffi,
Risk::High,
"extern sqlite3_exec",
)],
);
let customs = export_map_to_custom_authorities(&[map1, map2]);
assert_eq!(customs.len(), 2);
}

#[test]
fn empty_export_maps() {
let customs = export_map_to_custom_authorities(&[]);
assert!(customs.is_empty());
}

#[test]
fn empty_exports_in_map() {
let map = CrateExportMap {
crate_name: "empty".to_string(),
crate_version: "1.0.0".to_string(),
exports: HashMap::new(),
};
let customs = export_map_to_custom_authorities(&[map]);
assert!(customs.is_empty());
}

#[test]
fn path_segments_split_correctly() {
let map = make_export_map(
"reqwest",
vec![(
"reqwest::blocking::client::get",
Category::Net,
Risk::High,
"connect",
)],
);
let customs = export_map_to_custom_authorities(&[map]);
assert_eq!(
customs[0].path,
vec!["reqwest", "blocking", "client", "get"]
);
}
}
Loading
Loading