Skip to content

Commit 315917b

Browse files
hyperpolymathclaude
andcommitted
feat(assail): user-classification registry flips audited findings to suppressed
Adds an optional project-local classification registry (`audits/assail-classifications.a2ml` or `.panic-attack-classifications.a2ml`) that lets repositories record audited findings as `suppressed = true` out-of-band from the source under scan. Pattern: adding a new suppression requires editing BOTH the source under audit AND the classification registry — so a PR that tries to sneak a new unsafe block past the zero-findings gate cannot self-suppress; the registry edit is reviewable alongside the source change. Applied after the kanren structural-suppression pass, so the registry only catches residuals the structural rules didn't already suppress. Format (A2ML): ``` (assail-classifications (classification (file "path/relative/to/root") (category "WeakPointCategoryDebugName") (audit "pointer to audit document") (classification "legitimate-ffi|false-positive|etc") (rationale "one-line summary"))) ``` API: - `UserClassification { file, category }` — record type - `load_user_classifications(project_root) -> Vec<UserClassification>` - `apply_user_classifications(&mut report, project_root)` — post-pass - Wired through `Analyzer::analyze_inner`, plus the top-level `analyze`/`analyze_verbose` entry points. Verified against 007: with the registry listing zig_bridge.rs UnsafeCode as legitimate-ffi (per audit-ffi-unsafe.md §1), active finding count drops from 2 to 0. Combined with the prior Rocq detector fix (`6d6822a`), the 007 canonical-proof-suite + FFI surface now presents zero surfaced findings to `panic-attack assail .` — closing the TRG-D zero-findings gate. Tests: 5 new unit tests in `classifications_tests` module covering missing-registry, single-entry, multiple-entry, comment handling, and the end-to-end suppression-flip scenario. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6d6822a commit 315917b

2 files changed

Lines changed: 324 additions & 0 deletions

File tree

src/assail/analyzer.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,14 @@ impl Analyzer {
717717
// make the finding likely a false positive.
718718
super::apply_suppression(&mut report);
719719

720+
// Apply user classifications from a project-local registry
721+
// (`<target>/audits/assail-classifications.a2ml` or
722+
// `<target>/.panic-attack-classifications.a2ml`). Entries in the
723+
// registry flip matching findings to `suppressed = true` after
724+
// the structural suppression pass, letting repositories record
725+
// audited residuals alongside their source audit documents.
726+
super::apply_user_classifications(&mut report, &base);
727+
720728
Ok(report)
721729
}
722730

src/assail/mod.rs

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,21 @@ pub fn analyze<P: AsRef<Path>>(target: P) -> Result<AssailReport> {
2222
// Non-verbose mode keeps stdout clean for automation pipelines.
2323
let analyzer = Analyzer::new(target.as_ref())?;
2424
let mut report = analyzer.analyze()?;
25+
// analyze() already runs suppression + user classifications; the
26+
// explicit calls below are no-ops for existing reports but keep
27+
// the contract explicit: apply_suppression first, then
28+
// apply_user_classifications against the same target root.
2529
apply_suppression(&mut report);
30+
let target_ref = target.as_ref();
31+
let root = if target_ref.is_dir() {
32+
target_ref.to_path_buf()
33+
} else {
34+
target_ref
35+
.parent()
36+
.unwrap_or(Path::new("."))
37+
.to_path_buf()
38+
};
39+
apply_user_classifications(&mut report, &root);
2640
Ok(report)
2741
}
2842

@@ -33,6 +47,16 @@ pub fn analyze_verbose<P: AsRef<Path>>(target: P) -> Result<AssailReport> {
3347
let analyzer = Analyzer::new_verbose(target.as_ref())?;
3448
let mut report = analyzer.analyze()?;
3549
apply_suppression(&mut report);
50+
let target_ref = target.as_ref();
51+
let root = if target_ref.is_dir() {
52+
target_ref.to_path_buf()
53+
} else {
54+
target_ref
55+
.parent()
56+
.unwrap_or(Path::new("."))
57+
.to_path_buf()
58+
};
59+
apply_user_classifications(&mut report, &root);
3660

3761
let active_count = report.weak_points.iter().filter(|wp| !wp.suppressed).count();
3862
let suppressed_count = report.suppressed_count;
@@ -159,6 +183,145 @@ pub fn analyze_verbose_browser_extension<P: AsRef<Path>>(target: P) -> Result<As
159183
Ok(report)
160184
}
161185

186+
/// A single classification entry read from a project's
187+
/// `audits/assail-classifications.a2ml` (or `.panic-attack-classifications.a2ml`).
188+
/// When such a file exists, findings matching `(file, category)` are flipped
189+
/// to `suppressed = true` after the kanren suppression pass runs.
190+
///
191+
/// The registry pattern lets repositories record "this finding has been
192+
/// audited and is sound" out-of-band from the source file — so the
193+
/// suppression is not gameable by code-only edits (adding a new unsafe
194+
/// block cannot also add its own classification without editing the
195+
/// registry, which is reviewable in the same PR).
196+
#[derive(Debug, Clone)]
197+
pub struct UserClassification {
198+
pub file: String,
199+
pub category: String,
200+
}
201+
202+
/// Load user classifications from `<project_root>/audits/assail-classifications.a2ml`.
203+
///
204+
/// Empty vector if the file is absent or unreadable. Errors are swallowed by
205+
/// design — the classification registry is optional and a missing file must
206+
/// not break the assail pass.
207+
///
208+
/// Format (A2ML S-expression):
209+
///
210+
/// ```text
211+
/// (assail-classifications
212+
/// (classification
213+
/// (file "crates/oo7-core/src/zig_bridge.rs")
214+
/// (category "UnsafeCode")
215+
/// (audit "audits/audit-ffi-unsafe.md §1"))
216+
/// ...)
217+
/// ```
218+
pub fn load_user_classifications(project_root: &Path) -> Vec<UserClassification> {
219+
use std::fs;
220+
let candidate_paths = [
221+
project_root
222+
.join("audits")
223+
.join("assail-classifications.a2ml"),
224+
project_root.join(".panic-attack-classifications.a2ml"),
225+
];
226+
let mut content = String::new();
227+
for p in &candidate_paths {
228+
if let Ok(c) = fs::read_to_string(p) {
229+
content = c;
230+
break;
231+
}
232+
}
233+
if content.is_empty() {
234+
return Vec::new();
235+
}
236+
237+
// Strip `;;` line comments.
238+
let stripped: String = content
239+
.lines()
240+
.map(|l| match l.find(";;") {
241+
Some(idx) => &l[..idx],
242+
None => l,
243+
})
244+
.collect::<Vec<_>>()
245+
.join("\n");
246+
247+
let mut classifications = Vec::new();
248+
let needle = "(classification";
249+
let mut rest = stripped.as_str();
250+
while let Some(start) = rest.find(needle) {
251+
let after_keyword = &rest[start + needle.len()..];
252+
// Walk characters tracking paren depth to find the matching ')'.
253+
let mut depth: i32 = 1;
254+
let mut end_idx: Option<usize> = None;
255+
for (i, c) in after_keyword.char_indices() {
256+
match c {
257+
'(' => depth += 1,
258+
')' => {
259+
depth -= 1;
260+
if depth == 0 {
261+
end_idx = Some(i);
262+
break;
263+
}
264+
}
265+
_ => {}
266+
}
267+
}
268+
let Some(end) = end_idx else {
269+
break;
270+
};
271+
let body = &after_keyword[..end];
272+
let file = extract_classification_field(body, "file");
273+
let category = extract_classification_field(body, "category");
274+
if let (Some(f), Some(c)) = (file, category) {
275+
classifications.push(UserClassification {
276+
file: f,
277+
category: c,
278+
});
279+
}
280+
rest = &after_keyword[end + 1..];
281+
}
282+
283+
classifications
284+
}
285+
286+
fn extract_classification_field(body: &str, field: &str) -> Option<String> {
287+
let marker = format!("({} \"", field);
288+
let idx = body.find(&marker)?;
289+
let after = &body[idx + marker.len()..];
290+
let end = after.find('"')?;
291+
Some(after[..end].to_string())
292+
}
293+
294+
/// Apply user classifications to a report in-place. Findings whose file
295+
/// and category match an entry in the project's
296+
/// `assail-classifications.a2ml` are marked `suppressed = true` and
297+
/// counted toward `report.suppressed_count`.
298+
///
299+
/// Runs after the kanren-based `apply_suppression` so that the
300+
/// structural suppression rules get first pass and the classification
301+
/// registry covers only the genuinely-audited residuals.
302+
pub fn apply_user_classifications(report: &mut AssailReport, project_root: &Path) {
303+
let classifications = load_user_classifications(project_root);
304+
if classifications.is_empty() {
305+
return;
306+
}
307+
let mut additional: usize = 0;
308+
for wp in &mut report.weak_points {
309+
if wp.suppressed {
310+
continue;
311+
}
312+
let cat = format!("{:?}", wp.category);
313+
let loc = wp.location.as_deref().unwrap_or("");
314+
for cl in &classifications {
315+
if cl.category == cat && cl.file == loc {
316+
wp.suppressed = true;
317+
additional += 1;
318+
break;
319+
}
320+
}
321+
}
322+
report.suppressed_count += additional;
323+
}
324+
162325
/// Apply context-aware FP suppression to an assail report in-place.
163326
///
164327
/// Runs the full kanren logic engine, collects every `suppressed(Category, Location)`
@@ -300,3 +463,156 @@ fn run_logic_engine(report: &AssailReport) {
300463
}
301464
}
302465
}
466+
467+
#[cfg(test)]
468+
mod classifications_tests {
469+
use super::*;
470+
use std::fs;
471+
use tempfile::TempDir;
472+
473+
fn write_registry(dir: &Path, content: &str) {
474+
let audits = dir.join("audits");
475+
fs::create_dir_all(&audits).unwrap();
476+
fs::write(audits.join("assail-classifications.a2ml"), content).unwrap();
477+
}
478+
479+
#[test]
480+
fn load_empty_when_no_registry() {
481+
let tmp = TempDir::new().unwrap();
482+
let classifications = load_user_classifications(tmp.path());
483+
assert!(
484+
classifications.is_empty(),
485+
"Missing registry must yield empty classification list"
486+
);
487+
}
488+
489+
#[test]
490+
fn load_single_classification() {
491+
let tmp = TempDir::new().unwrap();
492+
write_registry(
493+
tmp.path(),
494+
r#";; SPDX-License-Identifier: PMPL-1.0-or-later
495+
(assail-classifications
496+
(classification
497+
(file "crates/oo7-core/src/zig_bridge.rs")
498+
(category "UnsafeCode")
499+
(audit "audits/audit-ffi-unsafe.md §1")))
500+
"#,
501+
);
502+
let classifications = load_user_classifications(tmp.path());
503+
assert_eq!(classifications.len(), 1);
504+
assert_eq!(classifications[0].file, "crates/oo7-core/src/zig_bridge.rs");
505+
assert_eq!(classifications[0].category, "UnsafeCode");
506+
}
507+
508+
#[test]
509+
fn load_multiple_classifications() {
510+
let tmp = TempDir::new().unwrap();
511+
write_registry(
512+
tmp.path(),
513+
r#"(assail-classifications
514+
(classification
515+
(file "a/b.rs")
516+
(category "UnsafeCode")
517+
(audit "doc1"))
518+
(classification
519+
(file "c/d.rs")
520+
(category "PanicPath")
521+
(audit "doc2")))
522+
"#,
523+
);
524+
let classifications = load_user_classifications(tmp.path());
525+
assert_eq!(classifications.len(), 2);
526+
assert_eq!(classifications[0].file, "a/b.rs");
527+
assert_eq!(classifications[1].category, "PanicPath");
528+
}
529+
530+
#[test]
531+
fn comment_lines_are_ignored() {
532+
let tmp = TempDir::new().unwrap();
533+
write_registry(
534+
tmp.path(),
535+
r#";; Header comment
536+
;; (classification (file "should-not-parse") (category "X"))
537+
(assail-classifications
538+
(classification
539+
(file "real/path.rs")
540+
(category "UnsafeCode")
541+
(audit "doc")))
542+
"#,
543+
);
544+
let classifications = load_user_classifications(tmp.path());
545+
assert_eq!(classifications.len(), 1);
546+
assert_eq!(classifications[0].file, "real/path.rs");
547+
}
548+
549+
#[test]
550+
fn apply_flips_matching_finding_to_suppressed() {
551+
use crate::types::{AssailReport, AttackAxis, Language, ProgramStatistics, Severity, WeakPoint, WeakPointCategory};
552+
let tmp = TempDir::new().unwrap();
553+
write_registry(
554+
tmp.path(),
555+
r#"(assail-classifications
556+
(classification
557+
(file "crates/oo7-core/src/zig_bridge.rs")
558+
(category "UnsafeCode")
559+
(audit "audits/audit-ffi-unsafe.md §1")))
560+
"#,
561+
);
562+
563+
let mut report = AssailReport {
564+
program_path: tmp.path().to_path_buf(),
565+
language: Language::Rust,
566+
frameworks: vec![],
567+
weak_points: vec![
568+
WeakPoint {
569+
file: None,
570+
line: None,
571+
category: WeakPointCategory::UnsafeCode,
572+
location: Some("crates/oo7-core/src/zig_bridge.rs".to_string()),
573+
severity: Severity::High,
574+
description: "8 unsafe blocks".to_string(),
575+
recommended_attack: vec![AttackAxis::Memory],
576+
suppressed: false,
577+
},
578+
WeakPoint {
579+
file: None,
580+
line: None,
581+
category: WeakPointCategory::UnsafeCode,
582+
location: Some("other/file.rs".to_string()),
583+
severity: Severity::High,
584+
description: "unsafe block".to_string(),
585+
recommended_attack: vec![AttackAxis::Memory],
586+
suppressed: false,
587+
},
588+
],
589+
statistics: ProgramStatistics {
590+
total_lines: 0,
591+
unsafe_blocks: 0,
592+
panic_sites: 0,
593+
unwrap_calls: 0,
594+
allocation_sites: 0,
595+
io_operations: 0,
596+
threading_constructs: 0,
597+
},
598+
file_statistics: vec![],
599+
recommended_attacks: vec![],
600+
dependency_graph: Default::default(),
601+
taint_matrix: Default::default(),
602+
migration_metrics: None,
603+
suppressed_count: 0,
604+
};
605+
606+
apply_user_classifications(&mut report, tmp.path());
607+
608+
assert!(
609+
report.weak_points[0].suppressed,
610+
"Classified finding must be suppressed"
611+
);
612+
assert!(
613+
!report.weak_points[1].suppressed,
614+
"Unclassified finding must stay active"
615+
);
616+
assert_eq!(report.suppressed_count, 1);
617+
}
618+
}

0 commit comments

Comments
 (0)