Skip to content

Commit 7bbc6eb

Browse files
committed
feat: classify libc FFI calls into FS/NET/ENV/PROC via POSIX syscall table, add progress reporting and timing to --deep analysis
1 parent 8c75390 commit 7bbc6eb

7 files changed

Lines changed: 359 additions & 11 deletions

File tree

crates/capsec-deep/libc_calls

452 KB
Binary file not shown.
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
//! POSIX syscall classification table for `libc` foreign function calls.
2+
//!
3+
//! Maps common `libc` function names to ambient authority categories.
4+
//! `libc` is on the driver's skip list, so the syntactic scanner never sees it —
5+
//! this table is the only way these calls get classified beyond generic FFI.
6+
7+
use std::collections::HashMap;
8+
use std::sync::LazyLock;
9+
10+
pub struct LibcClassification {
11+
pub category: &'static str,
12+
pub subcategory: &'static str,
13+
pub risk: &'static str,
14+
}
15+
16+
static LIBC_TABLE: LazyLock<HashMap<&'static str, LibcClassification>> =
17+
LazyLock::new(build_table);
18+
19+
pub fn classify_libc_fn(fn_name: &str) -> Option<&'static LibcClassification> {
20+
LIBC_TABLE.get(fn_name)
21+
}
22+
23+
fn build_table() -> HashMap<&'static str, LibcClassification> {
24+
let mut m = HashMap::with_capacity(80);
25+
26+
// ── Filesystem read ──
27+
for name in [
28+
"open", "open64", "openat", "openat64", "creat", "creat64",
29+
"read", "pread", "pread64", "readv",
30+
"readlink", "readlinkat",
31+
"stat", "stat64", "fstat", "fstat64", "lstat", "lstat64",
32+
"fstatat", "fstatat64",
33+
"access", "faccessat",
34+
"readdir", "readdir_r", "fdopendir", "opendir",
35+
"getdents", "getdents64",
36+
"realpath",
37+
] {
38+
m.insert(name, LibcClassification {
39+
category: "Fs",
40+
subcategory: "read",
41+
risk: "Medium",
42+
});
43+
}
44+
45+
// ── Filesystem write ──
46+
for name in [
47+
"write", "pwrite", "pwrite64", "writev",
48+
"unlink", "unlinkat", "remove",
49+
"rename", "renameat", "renameat2",
50+
"mkdir", "mkdirat", "rmdir",
51+
"truncate", "ftruncate", "ftruncate64",
52+
"chmod", "fchmod", "fchmodat",
53+
"chown", "fchown", "lchown", "fchownat",
54+
"link", "linkat", "symlink", "symlinkat",
55+
"close", "fsync", "fdatasync",
56+
] {
57+
m.insert(name, LibcClassification {
58+
category: "Fs",
59+
subcategory: "write",
60+
risk: "High",
61+
});
62+
}
63+
64+
// ── Filesystem memory-mapped I/O ──
65+
for name in ["mmap", "mmap64", "munmap", "mprotect", "msync"] {
66+
m.insert(name, LibcClassification {
67+
category: "Fs",
68+
subcategory: "mmap",
69+
risk: "High",
70+
});
71+
}
72+
73+
// ── Network ──
74+
for name in [
75+
"socket", "connect", "bind", "listen",
76+
"accept", "accept4",
77+
"recv", "recvfrom", "recvmsg",
78+
"send", "sendto", "sendmsg",
79+
"shutdown",
80+
"getaddrinfo", "freeaddrinfo", "getnameinfo",
81+
"getsockname", "getpeername",
82+
"setsockopt", "getsockopt",
83+
"poll", "ppoll",
84+
"select", "pselect",
85+
"epoll_create", "epoll_create1", "epoll_ctl", "epoll_wait", "epoll_pwait",
86+
"kqueue", "kevent",
87+
] {
88+
m.insert(name, LibcClassification {
89+
category: "Net",
90+
subcategory: "socket",
91+
risk: "High",
92+
});
93+
}
94+
95+
// ── Environment read ──
96+
for name in ["getenv", "getcwd", "getuid", "geteuid", "getgid", "getegid", "getpid", "getppid"] {
97+
m.insert(name, LibcClassification {
98+
category: "Env",
99+
subcategory: "read",
100+
risk: "Medium",
101+
});
102+
}
103+
104+
// ── Environment write ──
105+
for name in ["setenv", "unsetenv", "putenv", "chdir", "fchdir"] {
106+
m.insert(name, LibcClassification {
107+
category: "Env",
108+
subcategory: "write",
109+
risk: "High",
110+
});
111+
}
112+
113+
// ── Process ──
114+
for name in [
115+
"fork", "vfork",
116+
"execve", "execvp", "execvpe", "fexecve", "execl", "execlp",
117+
"system",
118+
"posix_spawn", "posix_spawnp",
119+
"kill", "raise",
120+
"waitpid", "wait4", "wait",
121+
"ptrace",
122+
"exit", "_exit",
123+
] {
124+
m.insert(name, LibcClassification {
125+
category: "Process",
126+
subcategory: "spawn",
127+
risk: "Critical",
128+
});
129+
}
130+
131+
// ── Signal handling (process control) ──
132+
for name in ["signal", "sigaction", "sigprocmask", "sigsuspend"] {
133+
m.insert(name, LibcClassification {
134+
category: "Process",
135+
subcategory: "signal",
136+
risk: "High",
137+
});
138+
}
139+
140+
m
141+
}
142+
143+
#[cfg(test)]
144+
mod tests {
145+
use super::*;
146+
147+
#[test]
148+
fn filesystem_calls_classified() {
149+
for name in ["open", "read", "stat", "readlink", "access"] {
150+
let c = classify_libc_fn(name)
151+
.unwrap_or_else(|| panic!("{name} should be classified"));
152+
assert_eq!(c.category, "Fs", "{name} should be Fs");
153+
}
154+
}
155+
156+
#[test]
157+
fn network_calls_classified() {
158+
for name in ["socket", "connect", "bind", "accept", "send"] {
159+
let c = classify_libc_fn(name)
160+
.unwrap_or_else(|| panic!("{name} should be classified"));
161+
assert_eq!(c.category, "Net", "{name} should be Net");
162+
}
163+
}
164+
165+
#[test]
166+
fn env_calls_classified() {
167+
for name in ["getenv", "getcwd", "setenv", "chdir"] {
168+
let c = classify_libc_fn(name)
169+
.unwrap_or_else(|| panic!("{name} should be classified"));
170+
assert_eq!(c.category, "Env", "{name} should be Env");
171+
}
172+
}
173+
174+
#[test]
175+
fn process_calls_classified() {
176+
for name in ["fork", "execve", "kill", "waitpid", "system"] {
177+
let c = classify_libc_fn(name)
178+
.unwrap_or_else(|| panic!("{name} should be classified"));
179+
assert_eq!(c.category, "Process", "{name} should be Process");
180+
}
181+
}
182+
183+
#[test]
184+
fn unknown_fn_returns_none() {
185+
assert!(classify_libc_fn("some_random_fn").is_none());
186+
}
187+
188+
#[test]
189+
fn table_size_sanity() {
190+
let table = build_table();
191+
assert!(table.len() >= 60, "Table too small: {}", table.len());
192+
assert!(table.len() <= 120, "Table too large: {}", table.len());
193+
}
194+
}

crates/capsec-deep/src/main.rs

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
1313
#![feature(rustc_private)]
1414

15+
mod libc_table;
16+
1517
extern crate rustc_driver;
1618
extern crate rustc_hir;
1719
extern crate rustc_interface;
@@ -204,9 +206,10 @@ impl rustc_driver::Callbacks for CapsecCallbacks {
204206
let crate_name = tcx.crate_name(LOCAL_CRATE).to_string();
205207
let crate_version = std::env::var("CAPSEC_CRATE_VERSION").unwrap_or_else(|_| "0.0.0".to_string());
206208
let debug = std::env::var("CAPSEC_DEEP_DEBUG").is_ok();
209+
let progress = std::env::var("CAPSEC_DEEP_PROGRESS").is_ok();
207210

208-
if debug {
209-
eprintln!("[capsec-deep] Analyzing crate: {crate_name}");
211+
if debug || progress {
212+
eprintln!(" [capsec-deep] analyzing: {crate_name}");
210213
}
211214

212215
// Skip std/core/alloc — not useful for authority analysis
@@ -320,17 +323,58 @@ impl rustc_driver::Callbacks for CapsecCallbacks {
320323

321324
// Check 2: FFI — calls to foreign functions
322325
if tcx.is_foreign_item(callee_def_id) {
326+
// Classify libc calls into FS/NET/ENV/PROC via POSIX table
327+
let callee_crate_name =
328+
tcx.crate_name(callee_def_id.krate).to_string();
329+
let callee_fn_name = callee_path
330+
.rsplit("::")
331+
.next()
332+
.unwrap_or(&callee_path);
333+
334+
let (cat, subcat, risk, desc) =
335+
if callee_crate_name == "libc" {
336+
if let Some(lc) =
337+
libc_table::classify_libc_fn(callee_fn_name)
338+
{
339+
(
340+
lc.category.to_string(),
341+
lc.subcategory.to_string(),
342+
lc.risk.to_string(),
343+
format!(
344+
"libc::{callee_fn_name}() — {}",
345+
lc.subcategory
346+
),
347+
)
348+
} else {
349+
(
350+
"Ffi".to_string(),
351+
"ffi_call".to_string(),
352+
"High".to_string(),
353+
format!(
354+
"Calls FFI function {callee_path}()"
355+
),
356+
)
357+
}
358+
} else {
359+
(
360+
"Ffi".to_string(),
361+
"ffi_call".to_string(),
362+
"High".to_string(),
363+
format!("Calls FFI function {callee_path}()"),
364+
)
365+
};
366+
323367
findings.push(DeepFinding {
324368
file: fn_file.clone(),
325369
function: fn_name.clone(),
326370
function_line: fn_line,
327371
call_line,
328372
call_col,
329373
call_text: callee_path.clone(),
330-
category: "Ffi".to_string(),
331-
subcategory: "ffi_call".to_string(),
332-
risk: "High".to_string(),
333-
description: format!("Calls FFI function {callee_path}()"),
374+
category: cat,
375+
subcategory: subcat,
376+
risk,
377+
description: desc,
334378
is_build_script,
335379
crate_name: crate_name.clone(),
336380
crate_version: crate_version.clone(),
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
unsafe extern "C" {
2+
fn open(path: *const u8, flags: i32) -> i32;
3+
fn socket(domain: i32, ty: i32, proto: i32) -> i32;
4+
fn getenv(name: *const u8) -> *const u8;
5+
fn fork() -> i32;
6+
}
7+
8+
fn read_config() -> i32 {
9+
unsafe { open(b"/etc/config\0".as_ptr(), 0) }
10+
}
11+
12+
fn open_socket() -> i32 {
13+
unsafe { socket(2, 1, 0) }
14+
}
15+
16+
fn check_env() -> *const u8 {
17+
unsafe { getenv(b"HOME\0".as_ptr()) }
18+
}
19+
20+
fn spawn_child() -> i32 {
21+
unsafe { fork() }
22+
}
23+
24+
fn main() {
25+
let _ = read_config();
26+
let _ = open_socket();
27+
let _ = check_env();
28+
let _ = spawn_child();
29+
}

crates/cargo-capsec/src/deep.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use crate::discovery::{self, CrateInfo};
88
use crate::export_map::{self, CrateExportMap};
99
use std::collections::HashMap;
1010
use std::path::Path;
11+
use std::time::Instant;
1112

1213
/// Pinned nightly date for capsec-driver. Must match `crates/capsec-deep/rust-toolchain.toml`.
1314
const PINNED_NIGHTLY: &str = "nightly-2026-02-17";
@@ -37,6 +38,7 @@ pub fn run_deep_analysis(
3738
fs_read: &impl capsec_core::cap_provider::CapProvider<capsec_core::permission::FsRead>,
3839
spawn_cap: &impl capsec_core::cap_provider::CapProvider<capsec_core::permission::Spawn>,
3940
) -> DeepResult {
41+
let start = Instant::now();
4042
let mut warnings: Vec<String> = Vec::new();
4143
let output_path =
4244
std::env::temp_dir().join(format!("capsec-deep-{}.jsonl", std::process::id()));
@@ -75,6 +77,14 @@ pub fn run_deep_analysis(
7577
.env("CAPSEC_CRATE_VERSION", "0.0.0")
7678
.env("CARGO_TARGET_DIR", &deep_target_dir)
7779
.env("RUSTUP_TOOLCHAIN", toolchain)
80+
.env(
81+
"CAPSEC_DEEP_PROGRESS",
82+
if std::io::IsTerminal::is_terminal(&std::io::stderr()) {
83+
"1"
84+
} else {
85+
""
86+
},
87+
)
7888
.output()
7989
.ok()
8090
});
@@ -123,6 +133,9 @@ pub fn run_deep_analysis(
123133
// Build export maps from MIR findings
124134
let export_maps = build_mir_export_maps(&mir_findings, workspace_crates, dep_crates);
125135

136+
let elapsed = start.elapsed();
137+
eprintln!(" Deep analysis took {:.1}s", elapsed.as_secs_f64());
138+
126139
DeepResult {
127140
findings: mir_findings,
128141
export_maps,

crates/cargo-capsec/src/discovery.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -361,13 +361,22 @@ pub fn discover_crates(
361361
.unwrap_or(Path::new("."))
362362
.to_path_buf();
363363

364+
// Check for source in src/ (standard) or at crate root (common for -sys crates
365+
// like libgit2-sys which have lib.rs at the root, not in src/)
364366
let src_dir = manifest_dir.join("src");
367+
let source_dir = if src_dir.exists() {
368+
Some(src_dir)
369+
} else if manifest_dir.join("lib.rs").exists() || manifest_dir.join("main.rs").exists() {
370+
Some(manifest_dir.clone())
371+
} else {
372+
None
373+
};
365374

366-
if src_dir.exists() {
375+
if let Some(source_dir) = source_dir {
367376
crates.push(CrateInfo {
368377
name: package.name.clone(),
369378
version: package.version.clone(),
370-
source_dir: src_dir,
379+
source_dir,
371380
is_dependency: package.source.is_some(),
372381
classification: extract_classification(&package.metadata),
373382
package_id: if include_deps {

0 commit comments

Comments
 (0)