@@ -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