Skip to content

Commit 7f2e05d

Browse files
feat(panicbot): teach translator about panic-attack v2.5.5 test_context (#252)
## Summary panic-attack v2.5.5 (#102 + #110, merged earlier 2026-06-02) added a `test_context` field to `AssailReport.weak_points`. This PR wires panicbot's translator to consume it. ## Behaviour | `suppressed` | `test_context` | Outcome | |---|---|---| | `true` | (any) | Drop (existing behaviour) | | `false` | `"test_only"` | **Drop** (new — defensive) | | `false` | `"doc"` | **Drop** (new — defensive) | | `false` | `"production"` | Route to fleet (existing) | | `false` | `null` (pre-v2.5.5 report) | Route to fleet (backward compat) | ## Why defensive panic-attack's `apply_v255_context_suppression` already sets `suppressed: true` for TestOnly/Doc findings. The defensive double-check ensures that: 1. If a future panic-attack release decides to preserve `suppressed: false` while still classifying as TestOnly (e.g. to surface them in audit-trail mode), the fleet doesn't inadvertently start chasing test-code findings. 2. If a TestOnly finding slips through some other code path, this is the safety net. ## Backward compatibility `#[serde(default)]` on the new field — old panic-attack reports without the field deserialise cleanly to `None` and route as production. **No fleet ops disruption when scanning against older panic-attack installs.** ## Verification - `cargo build --release`: clean - `cargo test --release --lib`: **42 pass / 0 fail** (37 baseline + 5 new tests covering all combinations) ## Forward compat When panic-attack surfaces `ffi_kind` / `jit_context` in JSON (currently only `test_context` is on `WeakPoint` itself; the other two are classifier-only), follow the same pattern: - Add `Option<String>` field on scanner's `WeakPoint` - Add `#[serde(default)]` - Translator decisions keyed on the value ## Refs - panic-attack#102 (test_context foundation) - panic-attack#110 (analyzer wire-up — sets the field) - panic-attack#112 (docs closeout — documents the cross-repo wiring need)
1 parent b2fbe4c commit 7f2e05d

4 files changed

Lines changed: 113 additions & 0 deletions

File tree

bots/panicbot/src/scanner.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,17 @@ pub struct WeakPoint {
7171
/// Suppressed items are excluded from fleet counts and CI gates.
7272
#[serde(default)]
7373
pub suppressed: bool,
74+
/// Test-vs-production classification of the finding's location
75+
/// (panic-attack v2.5.5+). `None` means the scanner predates the
76+
/// classification field; treat as "Production" for downstream
77+
/// routing decisions. `"test_only"` and `"doc"` indicate the
78+
/// finding is in test or documentation code respectively — usually
79+
/// the panic-attack engine has ALREADY set `suppressed: true` for
80+
/// these, but the metadata is preserved so the translator can emit
81+
/// audit-trail context (e.g. "PanicPath in tests/foo.rs — accepted
82+
/// by test_context rule").
83+
#[serde(default)]
84+
pub test_context: Option<String>,
7485
}
7586

7687
/// Report from `panic-attack adjudicate` — cross-report verdict.

bots/panicbot/src/translator.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,9 +400,25 @@ pub fn translate_all(
400400
// Skip weak points suppressed by panic-attack's context-aware FP engine.
401401
// Suppressed = the logic engine found a defensive pattern (e.g. mutex guard,
402402
// RAII, schema validation) that makes this finding likely a false positive.
403+
//
404+
// v2.5.5+ classifications (test_context field):
405+
// * `"test_only"` / `"doc"` → typically also `suppressed: true`
406+
// (set by `apply_v255_context_suppression` in panic-attack
407+
// after the kanren rule pass). Defensive: even if a future
408+
// scanner version preserves `suppressed: false` while
409+
// classifying as TestOnly, drop the finding here so test code
410+
// never reaches the fleet.
411+
// * `"production"` → forwarded normally.
412+
// * `None` → predates v2.5.5; treat as production.
403413
if wp.suppressed {
404414
return None;
405415
}
416+
if matches!(
417+
wp.test_context.as_deref(),
418+
Some("test_only") | Some("doc")
419+
) {
420+
return None;
421+
}
406422

407423
// Apply severity filter
408424
let severity_value = match wp.severity.to_lowercase().as_str() {
@@ -498,6 +514,7 @@ mod tests {
498514
description: "AWS_SECRET_KEY found in source".to_string(),
499515
recommended_attack: vec![],
500516
suppressed: false,
517+
test_context: None,
501518
};
502519
let config = PanicbotConfig::default();
503520
let finding = translate_weak_point(&wp, &config);
@@ -522,6 +539,7 @@ mod tests {
522539
description: "Something new".to_string(),
523540
recommended_attack: vec![],
524541
suppressed: false,
542+
test_context: None,
525543
};
526544
let config = PanicbotConfig::default();
527545
let finding = translate_weak_point(&wp, &config);
@@ -541,6 +559,7 @@ mod tests {
541559
description: "unsafe block".to_string(),
542560
recommended_attack: vec![],
543561
suppressed: false,
562+
test_context: None,
544563
};
545564
let config = PanicbotConfig {
546565
confidence_overrides: vec![crate::config::ConfidenceOverride {
@@ -563,6 +582,7 @@ mod tests {
563582
description: "low severity".to_string(),
564583
recommended_attack: vec![],
565584
suppressed: false,
585+
test_context: None,
566586
},
567587
WeakPoint {
568588
category: "CommandInjection".to_string(),
@@ -571,6 +591,7 @@ mod tests {
571591
description: "critical severity".to_string(),
572592
recommended_attack: vec![],
573593
suppressed: false,
594+
test_context: None,
574595
},
575596
];
576597
let config = PanicbotConfig {
@@ -596,6 +617,7 @@ mod tests {
596617
description: "fixable".to_string(),
597618
recommended_attack: vec![],
598619
suppressed: false,
620+
test_context: None,
599621
},
600622
&config,
601623
),
@@ -607,6 +629,7 @@ mod tests {
607629
description: "not fixable".to_string(),
608630
recommended_attack: vec![],
609631
suppressed: false,
632+
test_context: None,
610633
},
611634
&config,
612635
),
@@ -643,4 +666,75 @@ mod tests {
643666
}
644667
assert_eq!(rule_ids.len(), 25);
645668
}
669+
670+
// ─────────────────────────────────────────────────────────────────────
671+
// v2.5.5 test_context tests
672+
// ─────────────────────────────────────────────────────────────────────
673+
674+
fn make_wp(category: &str, test_context: Option<&str>, suppressed: bool) -> WeakPoint {
675+
WeakPoint {
676+
category: category.to_string(),
677+
location: Some("src/foo.rs:1".to_string()),
678+
severity: "Critical".to_string(),
679+
description: "test".to_string(),
680+
recommended_attack: vec![],
681+
suppressed,
682+
test_context: test_context.map(String::from),
683+
}
684+
}
685+
686+
#[test]
687+
fn translate_all_drops_test_only_findings_even_if_not_suppressed() {
688+
// Defensive: even when `suppressed: false`, a `test_context: test_only`
689+
// finding should not reach the fleet.
690+
let wps = vec![
691+
make_wp("UnsafeCode", Some("test_only"), false),
692+
make_wp("UnsafeCode", Some("production"), false),
693+
];
694+
let config = PanicbotConfig::default();
695+
let findings = translate_all(&wps, &config);
696+
assert_eq!(
697+
findings.len(),
698+
1,
699+
"test_only finding must be dropped regardless of suppressed flag"
700+
);
701+
}
702+
703+
#[test]
704+
fn translate_all_drops_doc_findings_even_if_not_suppressed() {
705+
let wps = vec![
706+
make_wp("PanicPath", Some("doc"), false),
707+
make_wp("PanicPath", Some("production"), false),
708+
];
709+
let config = PanicbotConfig::default();
710+
let findings = translate_all(&wps, &config);
711+
assert_eq!(findings.len(), 1, "doc finding must be dropped");
712+
}
713+
714+
#[test]
715+
fn translate_all_keeps_production_test_context() {
716+
let wps = vec![make_wp("UnsafeCode", Some("production"), false)];
717+
let config = PanicbotConfig::default();
718+
let findings = translate_all(&wps, &config);
719+
assert_eq!(findings.len(), 1);
720+
}
721+
722+
#[test]
723+
fn translate_all_treats_none_test_context_as_production() {
724+
// Pre-v2.5.5 panic-attack reports don't have the field; they should
725+
// still be routed.
726+
let wps = vec![make_wp("UnsafeCode", None, false)];
727+
let config = PanicbotConfig::default();
728+
let findings = translate_all(&wps, &config);
729+
assert_eq!(findings.len(), 1);
730+
}
731+
732+
#[test]
733+
fn translate_all_respects_suppressed_independently() {
734+
// `suppressed: true` should drop regardless of test_context.
735+
let wps = vec![make_wp("UnsafeCode", Some("production"), true)];
736+
let config = PanicbotConfig::default();
737+
let findings = translate_all(&wps, &config);
738+
assert_eq!(findings.len(), 0);
739+
}
646740
}

bots/panicbot/tests/integration_test.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ fn mock_assail_report() -> AssailReport {
2929
description: "3 unsafe blocks in FFI boundary".to_string(),
3030
recommended_attack: vec!["Memory".to_string()],
3131
suppressed: false,
32+
test_context: None,
3233
},
3334
WeakPoint {
3435
category: "HardcodedSecret".to_string(),
@@ -37,6 +38,7 @@ fn mock_assail_report() -> AssailReport {
3738
description: "AWS_SECRET_ACCESS_KEY found in source code".to_string(),
3839
recommended_attack: vec![],
3940
suppressed: false,
41+
test_context: None,
4042
},
4143
WeakPoint {
4244
category: "PanicPath".to_string(),
@@ -45,6 +47,7 @@ fn mock_assail_report() -> AssailReport {
4547
description: "unwrap() on user-provided input".to_string(),
4648
recommended_attack: vec!["CPU".to_string()],
4749
suppressed: false,
50+
test_context: None,
4851
},
4952
WeakPoint {
5053
category: "RaceCondition".to_string(),
@@ -53,6 +56,7 @@ fn mock_assail_report() -> AssailReport {
5356
description: "Shared mutable state without synchronisation".to_string(),
5457
recommended_attack: vec!["Concurrency".to_string()],
5558
suppressed: false,
59+
test_context: None,
5660
},
5761
WeakPoint {
5862
category: "UncheckedError".to_string(),
@@ -61,6 +65,7 @@ fn mock_assail_report() -> AssailReport {
6165
description: "Result ignored from database query".to_string(),
6266
recommended_attack: vec![],
6367
suppressed: false,
68+
test_context: None,
6469
},
6570
],
6671
statistics: serde_json::json!({"total_lines": 5000}),

bots/panicbot/tests/translator_test.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ fn weak_point(category: &str, severity: &str) -> WeakPoint {
2626
description: format!("Test finding for {}", category),
2727
recommended_attack: vec![],
2828
suppressed: false,
29+
test_context: None,
2930
}
3031
}
3132

@@ -38,6 +39,7 @@ fn weak_point_at(category: &str, severity: &str, location: &str) -> WeakPoint {
3839
description: format!("Test finding at {}", location),
3940
recommended_attack: vec![],
4041
suppressed: false,
42+
test_context: None,
4143
}
4244
}
4345

@@ -310,6 +312,7 @@ fn test_translate_metadata_includes_panic_attack_category() {
310312
description: "os.system with user input".to_string(),
311313
recommended_attack: vec!["Memory".to_string(), "Concurrency".to_string()],
312314
suppressed: false,
315+
test_context: None,
313316
};
314317
let finding = translator::translate_weak_point(&wp, &config);
315318

0 commit comments

Comments
 (0)