Skip to content

Commit a50a503

Browse files
authored
Merge pull request #41 from bordumb/dev-pureFuncClassification
feat: add resource/pure crate classification with Cargo.toml metadata
2 parents 29dbc9b + 4edcd1c commit a50a503

6 files changed

Lines changed: 491 additions & 37 deletions

File tree

README.md

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -297,18 +297,67 @@ fn main(root: CapRoot) -> Result<(), Box<dyn std::error::Error>> {
297297
}
298298
```
299299

300+
### Audited capabilities
301+
302+
`LoggedCap<P>` records every `try_cap()` invocation in an append-only audit log — implementing Saltzer & Schroeder's *compromise recording* principle ([The Protection of Information in Computer Systems](https://www.cs.virginia.edu/~evans/cs551/saltzer/), 1975, Design Principle #8):
303+
304+
```rust
305+
use capsec::prelude::*;
306+
307+
#[capsec::main]
308+
fn main(root: CapRoot) -> Result<(), Box<dyn std::error::Error>> {
309+
let logged_cap = LoggedCap::new(root.fs_read());
310+
311+
// Every exercise is recorded
312+
let cap = logged_cap.try_cap()?;
313+
capsec::fs::read("/dev/null", &cap)?;
314+
315+
// Inspect the audit trail
316+
for entry in logged_cap.entries() {
317+
println!("{}: {} (granted={})",
318+
entry.permission, entry.timestamp.elapsed().as_micros(), entry.granted);
319+
}
320+
Ok(())
321+
}
322+
```
323+
324+
### Dual-key authorization
325+
326+
`DualKeyCap<P>` requires two independent approvals before `try_cap()` succeeds — implementing Saltzer & Schroeder's *separation of privilege* principle (Design Principle #5: "a mechanism that requires two keys to unlock it is more robust than one that allows access to the presenter of only a single key"):
327+
328+
```rust
329+
use capsec::prelude::*;
330+
331+
#[capsec::main]
332+
fn main(root: CapRoot) -> Result<(), Box<dyn std::error::Error>> {
333+
let (dual_cap, approver_a, approver_b) = DualKeyCap::new(root.fs_write());
334+
335+
// Distribute handles to separate subsystems
336+
// Neither alone can exercise the capability
337+
approver_a.approve(); // manager approves
338+
approver_b.approve(); // security officer approves
339+
340+
// Only now does try_cap() succeed
341+
let cap = dual_cap.try_cap()?;
342+
capsec::fs::write("/tmp/authorized.txt", "data", &cap)?;
343+
Ok(())
344+
}
345+
```
346+
300347
### Key properties
301348

302-
- `RuntimeCap` and `TimedCap` do **not** implement `Has<P>` — fallibility is explicit via `try_cap()` at every call site
303-
- Both are `!Send` by default — use `make_send()` to opt into cross-thread transfer
349+
- `RuntimeCap`, `TimedCap`, `LoggedCap`, and `DualKeyCap` do **not** implement `Has<P>` — fallibility is explicit via `try_cap()` at every call site
350+
- All are `!Send` by default — use `make_send()` to opt into cross-thread transfer
304351
- Cloning a `RuntimeCap` shares the revocation flag — revoking one revokes all clones
352+
- Cloning a `LoggedCap` shares the audit log — entries from any clone appear in the same log
305353
- `Revoker` is `Send + Sync + Clone` — revoke from any thread
354+
- `ApproverA` / `ApproverB` are `Send + Sync` but **not** `Clone` — move-only to enforce separation of privilege
306355

307356
### How capsec compares
308357

309358
| Tool | Approach | Layer |
310359
|------|----------|-------|
311-
| **capsec** | Compile-time types (`Has<P>` bounds) + runtime caps (`RuntimeCap`, `TimedCap`) + static audit | Source-level, cooperative |
360+
| **capsec** | Compile-time types + runtime caps (revocable, timed, audited, dual-key) + static audit | Source-level, cooperative |
312361
| **[cap-std](https://github.com/bytecodealliance/cap-std)** | Runtime capability handles (ambient authority removal) | OS-level, WASI-oriented |
313362
| **[cargo-scan](https://github.com/AlfredoSystems/cargo-scan)** | Static analysis of dangerous API usage | Source-level, research prototype |
314363
| **[cargo-cgsec](https://github.com/nicholasgasior/cargo-cgsec)** | Call graph capability analysis (Capslock port) | Source-level, audit only |
@@ -321,6 +370,18 @@ fn main(root: CapRoot) -> Result<(), Box<dyn std::error::Error>> {
321370

322371
---
323372

373+
## Academic foundations
374+
375+
capsec's design draws from three foundational papers in capability-based security:
376+
377+
- **Dennis & Van Horn (1966)**[Programming Semantics for Multiprogrammed Computations](https://dl.acm.org/doi/10.1145/365230.365252). Introduced capability lists (C-lists), unforgeable capability tokens, and spheres of protection. capsec's `Cap<P>` is a direct descendant of their capability concept; `Cap::new()` being `pub(crate)` enforces unforgeability in software the way their hardware enforced it in the supervisor.
378+
379+
- **Saltzer & Schroeder (1975)**[The Protection of Information in Computer Systems](https://www.cs.virginia.edu/~evans/cs551/saltzer/). Defined the eight design principles for protection mechanisms. capsec implements six: economy of mechanism (zero-sized types), fail-safe defaults (no cap = no access), least privilege (the core mission), open design (open source + adversarial test suite), separation of privilege (`DualKeyCap`), and compromise recording (`LoggedCap`). The two partially met — complete mediation and least common mechanism — are inherent limitations of a library-level approach.
380+
381+
- **Melicher et al. (2017)**[A Capability-Based Module System for Authority Control](https://www.cs.cmu.edu/~aldrich/papers/ecoop17modules.pdf) (ECOOP 2017). Formalized non-transitive authority in the Wyvern language, proving that a module's authority can be determined by inspecting only its interface. capsec achieves the same property: `Has<P>` bounds make a function's authority visible in its signature, and `Attenuated<P, S>` / runtime cap types that don't implement `Has<P>` enforce non-transitivity.
382+
383+
---
384+
324385
## License
325386

326387
Apache-2.0

crates/cargo-capsec/src/config.rs

Lines changed: 239 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,23 @@
3030
3131
use crate::authorities::{Category, CustomAuthority, Risk};
3232
use crate::detector::Finding;
33-
use serde::Deserialize;
33+
use serde::{Deserialize, Serialize};
3434
use std::path::Path;
3535

36+
/// Crate classification for capability-based security analysis.
37+
///
38+
/// Inspired by Wyvern's resource/pure module distinction (Melicher et al., ECOOP 2017).
39+
/// A "pure" crate should contain no ambient authority (no I/O, no process spawning, etc.).
40+
/// A "resource" crate is expected to have ambient authority findings.
41+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42+
#[serde(rename_all = "lowercase")]
43+
pub enum Classification {
44+
/// No I/O, no state, no side effects — safe to import without capability grants.
45+
Pure,
46+
/// Contains I/O or ambient authority — requires explicit capability grants.
47+
Resource,
48+
}
49+
3650
const CONFIG_FILE: &str = ".capsec.toml";
3751

3852
/// Top-level configuration loaded from `.capsec.toml`.
@@ -49,6 +63,20 @@ pub struct Config {
4963
pub authority: Vec<AuthorityEntry>,
5064
#[serde(default)]
5165
pub allow: Vec<AllowEntry>,
66+
#[serde(default)]
67+
pub classify: Vec<ClassifyEntry>,
68+
}
69+
70+
/// A crate classification entry from `[[classify]]` in `.capsec.toml`.
71+
///
72+
/// Overrides any `[package.metadata.capsec]` classification in the crate's own Cargo.toml.
73+
#[derive(Debug, Deserialize)]
74+
pub struct ClassifyEntry {
75+
/// Crate name to classify (e.g., `"serde"`, `"my-app"`).
76+
#[serde(rename = "crate")]
77+
pub crate_name: String,
78+
/// Classification: `pure` or `resource`.
79+
pub classification: Classification,
5280
}
5381

5482
/// Crate-level deny configuration from `[deny]` in `.capsec.toml`.
@@ -187,6 +215,68 @@ pub fn should_allow(finding: &Finding, config: &Config) -> bool {
187215
})
188216
}
189217

218+
/// Result of classification verification for a single crate.
219+
#[derive(Debug, Clone, Serialize)]
220+
pub struct ClassificationResult {
221+
/// Crate name.
222+
pub crate_name: String,
223+
/// Crate version.
224+
pub crate_version: String,
225+
/// Resolved classification (`None` if unclassified).
226+
pub classification: Option<Classification>,
227+
/// `true` if the classification is valid (no violations).
228+
pub valid: bool,
229+
/// Number of non-build.rs findings that violate a "pure" classification.
230+
pub violation_count: usize,
231+
}
232+
233+
/// Verifies whether a crate's classification matches its audit findings.
234+
///
235+
/// A crate classified as `Pure` that has non-build.rs findings is a violation.
236+
/// Build.rs findings are excluded (compile-time only, not runtime authority).
237+
/// Resource and unclassified crates always pass.
238+
pub fn verify_classification(
239+
classification: Option<Classification>,
240+
findings: &[Finding],
241+
crate_name: &str,
242+
crate_version: &str,
243+
) -> ClassificationResult {
244+
let violation_count = match classification {
245+
Some(Classification::Pure) => findings
246+
.iter()
247+
.filter(|f| f.crate_name == crate_name && !f.is_build_script)
248+
.count(),
249+
_ => 0,
250+
};
251+
252+
ClassificationResult {
253+
crate_name: crate_name.to_string(),
254+
crate_version: crate_version.to_string(),
255+
classification,
256+
valid: violation_count == 0,
257+
violation_count,
258+
}
259+
}
260+
261+
/// Resolves the final classification for a crate by merging Cargo.toml metadata
262+
/// with `.capsec.toml` `[[classify]]` overrides.
263+
///
264+
/// Precedence: `.capsec.toml` wins over `Cargo.toml` metadata (consumer > author).
265+
pub fn resolve_classification(
266+
crate_name: &str,
267+
cargo_toml_classification: Option<Classification>,
268+
config: &Config,
269+
) -> Option<Classification> {
270+
// Check .capsec.toml [[classify]] first (consumer override)
271+
for entry in &config.classify {
272+
if entry.crate_name == crate_name {
273+
return Some(entry.classification);
274+
}
275+
}
276+
// Fall back to Cargo.toml metadata
277+
cargo_toml_classification
278+
}
279+
190280
/// Returns `true` if a file path matches any `[analysis].exclude` glob pattern.
191281
///
192282
/// Uses the [`globset`] crate for correct glob semantics (supports `**`, `*`,
@@ -289,6 +379,154 @@ mod tests {
289379
assert_eq!(normalized, vec!["fs", "net"]);
290380
}
291381

382+
#[test]
383+
fn parse_classify_entries() {
384+
let toml = r#"
385+
[[classify]]
386+
crate = "serde"
387+
classification = "pure"
388+
389+
[[classify]]
390+
crate = "tokio"
391+
classification = "resource"
392+
"#;
393+
let config: Config = toml::from_str(toml).unwrap();
394+
assert_eq!(config.classify.len(), 2);
395+
assert_eq!(config.classify[0].crate_name, "serde");
396+
assert_eq!(config.classify[0].classification, Classification::Pure);
397+
assert_eq!(config.classify[1].crate_name, "tokio");
398+
assert_eq!(config.classify[1].classification, Classification::Resource);
399+
}
400+
401+
#[test]
402+
fn missing_classify_defaults_to_empty() {
403+
let toml = r#"
404+
[[allow]]
405+
crate = "tracing"
406+
"#;
407+
let config: Config = toml::from_str(toml).unwrap();
408+
assert!(config.classify.is_empty());
409+
}
410+
411+
#[test]
412+
fn resolve_capsec_toml_overrides_cargo_metadata() {
413+
let config = Config {
414+
classify: vec![ClassifyEntry {
415+
crate_name: "my-lib".to_string(),
416+
classification: Classification::Resource,
417+
}],
418+
..Config::default()
419+
};
420+
let result = resolve_classification("my-lib", Some(Classification::Pure), &config);
421+
assert_eq!(result, Some(Classification::Resource));
422+
}
423+
424+
#[test]
425+
fn resolve_falls_back_to_cargo_metadata() {
426+
let config = Config::default();
427+
let result = resolve_classification("my-lib", Some(Classification::Pure), &config);
428+
assert_eq!(result, Some(Classification::Pure));
429+
}
430+
431+
#[test]
432+
fn resolve_unclassified_returns_none() {
433+
let config = Config::default();
434+
let result = resolve_classification("my-lib", None, &config);
435+
assert_eq!(result, None);
436+
}
437+
438+
#[test]
439+
fn verify_pure_crate_with_no_findings_passes() {
440+
let result = verify_classification(Some(Classification::Pure), &[], "my-lib", "0.1.0");
441+
assert!(result.valid);
442+
assert_eq!(result.violation_count, 0);
443+
}
444+
445+
#[test]
446+
fn verify_pure_crate_with_findings_fails() {
447+
let findings = vec![Finding {
448+
file: "src/lib.rs".to_string(),
449+
function: "do_io".to_string(),
450+
function_line: 1,
451+
call_line: 2,
452+
call_col: 5,
453+
call_text: "std::fs::read".to_string(),
454+
category: crate::authorities::Category::Fs,
455+
subcategory: "read".to_string(),
456+
risk: crate::authorities::Risk::Medium,
457+
description: "Read file".to_string(),
458+
is_build_script: false,
459+
crate_name: "my-lib".to_string(),
460+
crate_version: "0.1.0".to_string(),
461+
is_deny_violation: false,
462+
is_transitive: false,
463+
}];
464+
let result =
465+
verify_classification(Some(Classification::Pure), &findings, "my-lib", "0.1.0");
466+
assert!(!result.valid);
467+
assert_eq!(result.violation_count, 1);
468+
}
469+
470+
#[test]
471+
fn verify_pure_crate_excludes_build_script_findings() {
472+
let findings = vec![Finding {
473+
file: "build.rs".to_string(),
474+
function: "main".to_string(),
475+
function_line: 1,
476+
call_line: 2,
477+
call_col: 5,
478+
call_text: "std::env::var".to_string(),
479+
category: crate::authorities::Category::Env,
480+
subcategory: "read".to_string(),
481+
risk: crate::authorities::Risk::Low,
482+
description: "Read env var".to_string(),
483+
is_build_script: true,
484+
crate_name: "my-lib".to_string(),
485+
crate_version: "0.1.0".to_string(),
486+
is_deny_violation: false,
487+
is_transitive: false,
488+
}];
489+
let result =
490+
verify_classification(Some(Classification::Pure), &findings, "my-lib", "0.1.0");
491+
assert!(result.valid);
492+
assert_eq!(result.violation_count, 0);
493+
}
494+
495+
#[test]
496+
fn verify_resource_crate_always_passes() {
497+
let findings = vec![Finding {
498+
file: "src/lib.rs".to_string(),
499+
function: "do_io".to_string(),
500+
function_line: 1,
501+
call_line: 2,
502+
call_col: 5,
503+
call_text: "std::fs::read".to_string(),
504+
category: crate::authorities::Category::Fs,
505+
subcategory: "read".to_string(),
506+
risk: crate::authorities::Risk::Medium,
507+
description: "Read file".to_string(),
508+
is_build_script: false,
509+
crate_name: "my-lib".to_string(),
510+
crate_version: "0.1.0".to_string(),
511+
is_deny_violation: false,
512+
is_transitive: false,
513+
}];
514+
let result =
515+
verify_classification(Some(Classification::Resource), &findings, "my-lib", "0.1.0");
516+
assert!(result.valid);
517+
}
518+
519+
#[test]
520+
fn invalid_classification_value_errors() {
521+
let toml = r#"
522+
[[classify]]
523+
crate = "bad"
524+
classification = "unknown"
525+
"#;
526+
let result: Result<Config, _> = toml::from_str(toml);
527+
assert!(result.is_err());
528+
}
529+
292530
#[test]
293531
fn exclude_pattern_matching() {
294532
assert!(should_exclude(

0 commit comments

Comments
 (0)