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
88 changes: 88 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Security Model

capsec enforces I/O permissions at compile time via a zero-cost capability type system. This document describes what it protects against, what it doesn't, and where the boundaries are.

## The security boundary: `Cap::new()` is `pub(crate)`

The only way to construct a `Cap<P>` in safe Rust is through `CapRoot::grant()`. The constructor `Cap::new()` is `pub(crate)` — external crates cannot call it.

```rust
// This is the entire security gate:
pub(crate) fn new() -> Self { ... }
```

Every capability in a capsec program traces back to a `CapRoot::grant()` call. `CapRoot` is a singleton — `root()` panics if called twice, ensuring a single point of authority.

## `Has<P>` is intentionally open

The `Has<P>` trait is open for external implementation. This is by design — the `#[capsec::context]` macro generates `impl Has<P>` on user-defined structs to enable capability threading through call stacks.

This is safe because `Has<P>` requires returning a `Cap<P>`:

```rust
pub trait Has<P: Permission> {
fn cap_ref(&self) -> Cap<P>;
}
```

Any implementation must produce a `Cap<P>`. Since `Cap::new()` is `pub(crate)`, the only way to satisfy this in safe Rust is to hold a real `Cap<P>` obtained from `CapRoot::grant()`.

## Enforcement: type-witnessed proofs

All `capsec-std` and `capsec-tokio` wrapper functions extract a typed proof before executing I/O:

```rust
pub fn read(path: impl AsRef<Path>, cap: &impl Has<FsRead>) -> Result<Vec<u8>, CapSecError> {
let _proof: Cap<FsRead> = cap.cap_ref(); // proof extracted here
Ok(std::fs::read(path)?) // I/O happens here
}
```

The concrete type annotation `Cap<FsRead>` is not cosmetic. It forces the compiler to resolve the return type of `cap_ref()` and prevents dead-code elimination. If a `Has<P>` implementation diverges (panics, loops, calls `process::exit`), the divergence fires at the `_proof` line — the I/O on the next line never executes.

The adversarial test suite (`capsec-tests/tests/type_system.rs`, section L) proves this with a `PanicForge` impl that panics in `cap_ref()` — the test confirms the panic fires before `std::fs::read` runs.

## What capsec does NOT protect against

### `unsafe` code

`transmute`, `MaybeUninit`, and `ptr::read` can forge a `Cap<P>` because `Cap` is a zero-sized type. All three attacks are documented and tested in `capsec-tests/tests/type_system.rs` (section C). This is expected — capsec's threat model is safe Rust only.

### Direct `std` / `tokio` calls

A function can always call `std::fs::read()` or `tokio::fs::read()` directly without a capability token. The Rust compiler won't stop it — capsec wrappers are opt-in replacements, not mandatory.

This is where `cargo capsec audit` comes in. The audit tool statically scans source code for ambient authority calls and reports them. Functions annotated with `#[capsec::deny(all)]` that contain I/O calls are promoted to critical risk.

### FFI and inline assembly

`extern` blocks and inline `asm!` interact with the OS directly. The audit tool flags `extern` blocks but cannot analyze what foreign code does.

### Proc-macro-generated code

Code generated by procedural macros is invisible to the static scanner. This is inherent to syntax-level tooling.

## Audit tool scope

`cargo capsec audit` is an AST-level heuristic scanner, not a data flow analyzer. It:

- Detects qualified calls (`std::fs::read`, `TcpStream::connect`, `Command::new`)
- Expands import aliases (`use std::fs::read as load; load(...)`)
- Handles glob imports (`use std::fs::*; read(...)`)
- Uses contextual method matching (`.output()` only flags when `Command::new` is in the same function)
- Flags `extern` blocks as FFI findings

Known blind spots (documented in `capsec-tests/tests/audit_evasion.rs`):

- Function pointers and closures that hide the call target
- `include!()` directives that pull in code from other files
- Module re-exports across crate boundaries
- Dependency re-exports (a crate wrapping `std::fs` under a different name)
- `libc` / `nix` crate calls (not in the pattern registry)
- `cfg`-conditional code that may or may not compile

## Reporting vulnerabilities

If you discover a security issue in capsec, please email the maintainers directly rather than opening a public issue. Contact: [open an issue with the `security` label](https://github.com/bordumb/capsec/issues/new?labels=security).

For issues that are not security-sensitive (e.g., audit tool false negatives, documentation gaps), please open a regular GitHub issue.
47 changes: 47 additions & 0 deletions crates/capsec-tests/tests/type_system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -432,3 +432,50 @@ fn create_returns_writable_file() {
std::io::Write::write_all(&mut file, b"test").unwrap();
std::fs::remove_file(&path).ok();
}

// ============================================================================
// L. FORGERY DEFENSE — proving the _proof pattern stops diverging Has<P> impls
// ============================================================================
//
// The _proof pattern (`let _proof: Cap<P> = cap.cap_ref()`) forces cap_ref()
// to evaluate and return before any I/O executes. If an external impl of
// Has<P> diverges (panic, loop, process::exit), the divergence fires at the
// _proof line — the I/O on the next line never runs.
//
// This is the enforcement mechanism that makes open Has<P> safe: you can
// implement the trait, but you can't skip the proof extraction.

/// A malicious Has<FsRead> impl that panics instead of returning a Cap.
struct PanicForge;

impl Has<FsRead> for PanicForge {
fn cap_ref(&self) -> Cap<FsRead> {
panic!("forged capability — this should fire before I/O");
}
}

/// Proves: a diverging Has<P> impl (panic) fires BEFORE std::fs::read executes.
/// If the _proof pattern were missing or incorrect, the I/O would run first.
#[test]
#[should_panic(expected = "forged capability")]
fn forged_has_impl_panics_before_io_executes() {
let _ = capsec_std::fs::read("/dev/null", &PanicForge);
}

/// A malicious Has<FsRead> impl that loops forever instead of returning a Cap.
///
/// Cannot be tested directly (would hang the test runner), but the behavior is
/// correct: LoopForge::cap_ref() never returns, so the I/O call on the next
/// line of the wrapper function is never reached.
///
/// This struct exists to document the behavior and prove it compiles —
/// confirming that Has<P> being open does not enable I/O bypass via divergence.
#[allow(dead_code)]
struct LoopForge;

#[allow(dead_code)]
impl Has<FsRead> for LoopForge {
fn cap_ref(&self) -> Cap<FsRead> {
loop {} // diverges — I/O never executes
}
}
Loading