Skip to content

Commit 5f0bddd

Browse files
authored
Merge pull request #51 from auths-dev/dev-dependencyDepthCheck
feat: add transitive dependency checks
2 parents d10c584 + 5e9b05f commit 5f0bddd

File tree

29 files changed

+2186
-53
lines changed

29 files changed

+2186
-53
lines changed

Cargo.lock

Lines changed: 52 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ toml = "1.0.7"
4646
# Output
4747
colored = "3.1.1"
4848

49+
# Parallelism
50+
rayon = "1"
51+
4952
# Testing
5053
trybuild = "1"
5154
insta = { version = "1", features = ["yaml"] }

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,27 @@ cargo install --path crates/cargo-capsec
4545
### Run
4646

4747
```bash
48+
# Scan workspace crates only (fast, default)
4849
cargo capsec audit
50+
51+
# Scan workspace + dependencies — cross-crate propagation shows
52+
# which of YOUR functions inherit authority from dependencies
53+
cargo capsec audit --include-deps
54+
55+
# Control dependency depth (default: 1 = direct deps only)
56+
cargo capsec audit --include-deps --dep-depth 3 # up to 3 hops
57+
cargo capsec audit --include-deps --dep-depth 0 # unlimited
58+
59+
# Supply-chain view — only dependency findings
60+
cargo capsec audit --deps-only
4961
```
5062

5163
```
5264
my-app v0.1.0
5365
─────────────
5466
FS src/config.rs:8:5 fs::read_to_string load_config()
55-
NET src/api.rs:15:9 TcpStream::connect fetch_data()
67+
NET src/api.rs:15:9 reqwest::get fetch_data()
68+
↳ Cross-crate: reqwest::get() → TcpStream::connect [NET]
5669
PROC src/deploy.rs:42:17 Command::new run_migration()
5770
5871
Summary

crates/cargo-capsec/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ categories = ["development-tools", "command-line-utilities"]
1212
name = "cargo-capsec"
1313
path = "src/main.rs"
1414

15+
[features]
16+
default = ["parallel"]
17+
parallel = ["dep:rayon"]
18+
1519
[dependencies]
1620
clap.workspace = true
1721
syn.workspace = true
@@ -22,6 +26,7 @@ serde.workspace = true
2226
serde_json.workspace = true
2327
toml.workspace = true
2428
colored.workspace = true
29+
rayon = { workspace = true, optional = true }
2530
capsec-core.workspace = true
2631
capsec-std.workspace = true
2732

crates/cargo-capsec/src/authorities.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
//! The registry is compiled into the binary via [`build_registry`]. Users can extend it
99
//! at runtime with custom patterns loaded from `.capsec.toml` (see [`CustomAuthority`]).
1010
11-
use serde::Serialize;
11+
use serde::{Deserialize, Serialize};
1212

1313
/// The kind of ambient authority a call exercises.
1414
///
@@ -24,7 +24,7 @@ use serde::Serialize;
2424
/// | `Env` | Environment variable access | Yellow |
2525
/// | `Process` | Subprocess spawning (`Command::new`) | Magenta |
2626
/// | `Ffi` | Foreign function interface (`extern` blocks) | Cyan |
27-
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
27+
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2828
#[non_exhaustive]
2929
pub enum Category {
3030
/// Filesystem access: reads, writes, deletes, directory operations.
@@ -65,7 +65,7 @@ impl Category {
6565
/// | `Medium` | Can read data or create resources | `fs::read`, `env::var`, `File::open` |
6666
/// | `High` | Can write, delete, or open network connections | `fs::write`, `TcpStream::connect` |
6767
/// | `Critical` | Can destroy data or execute arbitrary code | `remove_dir_all`, `Command::new` |
68-
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
68+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
6969
#[non_exhaustive]
7070
pub enum Risk {
7171
/// Read-only metadata or low-impact queries.

crates/cargo-capsec/src/cli.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,21 @@ pub struct AuditArgs {
5555
#[arg(short, long, default_value = "text", value_parser = ["text", "json", "sarif"])]
5656
pub format: String,
5757

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

64+
/// Only scan dependencies, skip workspace crates (supply-chain view)
65+
#[arg(long, conflicts_with = "include_deps")]
66+
pub deps_only: bool,
67+
68+
/// Maximum dependency depth to scan (0 = unlimited, default: 1 = direct deps only).
69+
/// Only meaningful with --include-deps or --deps-only.
70+
#[arg(long, default_value_t = 1)]
71+
pub dep_depth: usize,
72+
6273
/// Minimum risk level to report
6374
#[arg(long, default_value = "low", value_parser = ["low", "medium", "high", "critical"])]
6475
pub min_risk: String,
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
//! Cross-crate authority propagation.
2+
//!
3+
//! Converts export maps from dependency crates into [`CustomAuthority`] values
4+
//! that can be injected into the detector. This bridges dependency analysis
5+
//! (Phase 1) with workspace crate analysis (Phase 2).
6+
7+
use crate::authorities::CustomAuthority;
8+
use crate::export_map::CrateExportMap;
9+
10+
/// Converts a collection of export maps into [`CustomAuthority`] values for
11+
/// injection into the detector.
12+
///
13+
/// For each entry in each export map, creates a `CustomAuthority` with the
14+
/// module-qualified path split into segments. The suffix matching in
15+
/// [`Detector::matches_custom_path`](crate::detector) handles both
16+
/// fully-qualified calls and imported calls.
17+
#[must_use]
18+
pub fn export_map_to_custom_authorities(export_maps: &[CrateExportMap]) -> Vec<CustomAuthority> {
19+
let mut customs = Vec::new();
20+
21+
for map in export_maps {
22+
for (key, authorities) in &map.exports {
23+
let path: Vec<String> = key.split("::").map(String::from).collect();
24+
25+
for auth in authorities {
26+
customs.push(CustomAuthority {
27+
path: path.clone(),
28+
category: auth.category.clone(),
29+
risk: auth.risk,
30+
description: format!(
31+
"Cross-crate: {}() → {} [{}]",
32+
key,
33+
auth.leaf_call,
34+
auth.category.label(),
35+
),
36+
});
37+
}
38+
}
39+
}
40+
41+
customs
42+
}
43+
44+
#[cfg(test)]
45+
mod tests {
46+
use super::*;
47+
use crate::authorities::{Category, Risk};
48+
use crate::export_map::{CrateExportMap, ExportedAuthority};
49+
use std::collections::HashMap;
50+
51+
fn make_export_map(
52+
crate_name: &str,
53+
entries: Vec<(&str, Category, Risk, &str)>,
54+
) -> CrateExportMap {
55+
let mut exports = HashMap::new();
56+
for (key, category, risk, leaf_call) in entries {
57+
exports
58+
.entry(key.to_string())
59+
.or_insert_with(Vec::new)
60+
.push(ExportedAuthority {
61+
category,
62+
risk,
63+
leaf_call: leaf_call.to_string(),
64+
is_transitive: false,
65+
});
66+
}
67+
CrateExportMap {
68+
crate_name: crate_name.to_string(),
69+
crate_version: "1.0.0".to_string(),
70+
exports,
71+
}
72+
}
73+
74+
#[test]
75+
fn single_export_map() {
76+
let map = make_export_map(
77+
"reqwest",
78+
vec![(
79+
"reqwest::get",
80+
Category::Net,
81+
Risk::High,
82+
"TcpStream::connect",
83+
)],
84+
);
85+
let customs = export_map_to_custom_authorities(&[map]);
86+
assert_eq!(customs.len(), 1);
87+
assert_eq!(customs[0].path, vec!["reqwest", "get"]);
88+
assert_eq!(customs[0].category, Category::Net);
89+
assert!(customs[0].description.contains("Cross-crate"));
90+
assert!(customs[0].description.contains("reqwest::get"));
91+
}
92+
93+
#[test]
94+
fn multiple_exports_per_crate() {
95+
let map = make_export_map(
96+
"tokio",
97+
vec![
98+
(
99+
"tokio::fs::read",
100+
Category::Fs,
101+
Risk::Medium,
102+
"std::fs::read",
103+
),
104+
(
105+
"tokio::net::connect",
106+
Category::Net,
107+
Risk::High,
108+
"TcpStream::connect",
109+
),
110+
],
111+
);
112+
let customs = export_map_to_custom_authorities(&[map]);
113+
assert_eq!(customs.len(), 2);
114+
}
115+
116+
#[test]
117+
fn multiple_crates() {
118+
let map1 = make_export_map(
119+
"reqwest",
120+
vec![(
121+
"reqwest::get",
122+
Category::Net,
123+
Risk::High,
124+
"TcpStream::connect",
125+
)],
126+
);
127+
let map2 = make_export_map(
128+
"rusqlite",
129+
vec![(
130+
"rusqlite::execute",
131+
Category::Ffi,
132+
Risk::High,
133+
"extern sqlite3_exec",
134+
)],
135+
);
136+
let customs = export_map_to_custom_authorities(&[map1, map2]);
137+
assert_eq!(customs.len(), 2);
138+
}
139+
140+
#[test]
141+
fn empty_export_maps() {
142+
let customs = export_map_to_custom_authorities(&[]);
143+
assert!(customs.is_empty());
144+
}
145+
146+
#[test]
147+
fn empty_exports_in_map() {
148+
let map = CrateExportMap {
149+
crate_name: "empty".to_string(),
150+
crate_version: "1.0.0".to_string(),
151+
exports: HashMap::new(),
152+
};
153+
let customs = export_map_to_custom_authorities(&[map]);
154+
assert!(customs.is_empty());
155+
}
156+
157+
#[test]
158+
fn path_segments_split_correctly() {
159+
let map = make_export_map(
160+
"reqwest",
161+
vec![(
162+
"reqwest::blocking::client::get",
163+
Category::Net,
164+
Risk::High,
165+
"connect",
166+
)],
167+
);
168+
let customs = export_map_to_custom_authorities(&[map]);
169+
assert_eq!(
170+
customs[0].path,
171+
vec!["reqwest", "blocking", "client", "get"]
172+
);
173+
}
174+
}

0 commit comments

Comments
 (0)