From 08ac5efd9d73067a54bdf196503b229df48ac200 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 23:55:48 +0000 Subject: [PATCH 1/3] chore(panicbot): implement FromStr for MinSeverity; apply rustfmt Groundwork for adding panicbot to the rust.yml CI matrix, where it has never been tested or linted: `cargo clippy -D warnings` failed on `MinSeverity::from_str` shadowing the std trait method (should_implement_trait), and rustfmt reported drift in seven files. MinSeverity now implements std::str::FromStr (callers use `.parse()`); the rest is `cargo fmt`. No behaviour change: 80 tests pass before and after. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PpQVLrCbURpoXtrdvy8V3B --- bots/panicbot/src/a2ml_writer.rs | 16 ++++--- bots/panicbot/src/cli.rs | 6 ++- bots/panicbot/src/config.rs | 34 +++++++------- bots/panicbot/src/directives.rs | 2 +- bots/panicbot/src/main.rs | 20 ++++---- bots/panicbot/src/scanner.rs | 35 ++++---------- bots/panicbot/src/translator.rs | 61 +++++++++++++++---------- bots/panicbot/tests/a2ml_writer_test.rs | 34 +++++++++----- bots/panicbot/tests/integration_test.rs | 27 +++++------ bots/panicbot/tests/translator_test.rs | 60 +++++++++++++----------- 10 files changed, 156 insertions(+), 139 deletions(-) diff --git a/bots/panicbot/src/a2ml_writer.rs b/bots/panicbot/src/a2ml_writer.rs index 4aa1f75a..30abcc77 100644 --- a/bots/panicbot/src/a2ml_writer.rs +++ b/bots/panicbot/src/a2ml_writer.rs @@ -120,10 +120,7 @@ fn finding_to_a2ml(finding: &Finding) -> A2mlFinding { } /// Build the summary statistics from fixable and unfixable findings. -fn build_summary( - fixable: &[&Finding], - unfixable: &[&Finding], -) -> FindingsSummary { +fn build_summary(fixable: &[&Finding], unfixable: &[&Finding]) -> FindingsSummary { let total = fixable.len() + unfixable.len(); let all_findings: Vec<&&Finding> = fixable.iter().chain(unfixable.iter()).collect(); @@ -187,8 +184,8 @@ pub fn write_a2ml(repo_path: &Path, document: &A2mlDocument) -> Result Finding { + fn make_finding( + rule_id: &str, + severity: Severity, + fixable: bool, + tier: TriangleTier, + ) -> Finding { let mut f = Finding::new(BotId::Panicbot, rule_id, severity, "test finding") .with_category("static-analysis/test") .with_triangle_tier(tier) diff --git a/bots/panicbot/src/cli.rs b/bots/panicbot/src/cli.rs index deab5f5b..5594da96 100644 --- a/bots/panicbot/src/cli.rs +++ b/bots/panicbot/src/cli.rs @@ -18,9 +18,11 @@ use std::path::PathBuf; #[command(name = "panicbot")] #[command(version)] #[command(about = "Targeted audit bot wrapping panic-attack static analysis")] -#[command(long_about = "Panicbot invokes panic-attack as a subprocess, translates its findings \ +#[command( + long_about = "Panicbot invokes panic-attack as a subprocess, translates its findings \ into gitbot-fleet Finding structs, classifies them as fixable or unfixable, and writes \ - unfixable findings to per-repo A2ML debt registers.")] + unfixable findings to per-repo A2ML debt registers." +)] pub struct Cli { /// Subcommand to run. #[command(subcommand)] diff --git a/bots/panicbot/src/config.rs b/bots/panicbot/src/config.rs index b39defb5..98f684b4 100644 --- a/bots/panicbot/src/config.rs +++ b/bots/panicbot/src/config.rs @@ -105,15 +105,19 @@ impl MinSeverity { MinSeverity::Critical => 3, } } +} + +/// Parse from string (case-insensitive). +impl std::str::FromStr for MinSeverity { + type Err = String; - /// Parse from string (case-insensitive). - pub fn from_str(s: &str) -> Option { + fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { - "low" => Some(MinSeverity::Low), - "medium" => Some(MinSeverity::Medium), - "high" => Some(MinSeverity::High), - "critical" => Some(MinSeverity::Critical), - _ => None, + "low" => Ok(MinSeverity::Low), + "medium" => Ok(MinSeverity::Medium), + "high" => Ok(MinSeverity::High), + "critical" => Ok(MinSeverity::Critical), + other => Err(format!("unknown severity: {other}")), } } } @@ -154,20 +158,18 @@ mod tests { #[test] fn test_min_severity_from_str() { - assert_eq!(MinSeverity::from_str("low"), Some(MinSeverity::Low)); - assert_eq!(MinSeverity::from_str("HIGH"), Some(MinSeverity::High)); - assert_eq!(MinSeverity::from_str("invalid"), None); + assert_eq!("low".parse::().ok(), Some(MinSeverity::Low)); + assert_eq!("HIGH".parse::().ok(), Some(MinSeverity::High)); + assert_eq!("invalid".parse::().ok(), None); } #[test] fn test_confidence_override_lookup() { let config = PanicbotConfig { - confidence_overrides: vec![ - ConfidenceOverride { - category: "UnsafeCode".to_string(), - confidence: 0.60, - }, - ], + confidence_overrides: vec![ConfidenceOverride { + category: "UnsafeCode".to_string(), + confidence: 0.60, + }], ..Default::default() }; assert_eq!(config.confidence_for("UnsafeCode"), Some(0.60)); diff --git a/bots/panicbot/src/directives.rs b/bots/panicbot/src/directives.rs index 5a3b9cd0..53547f87 100644 --- a/bots/panicbot/src/directives.rs +++ b/bots/panicbot/src/directives.rs @@ -98,7 +98,7 @@ fn parse_directive(content: &str) -> Result { .and_then(|c| c.min_severity.clone()) .or(file.min_severity_top); if let Some(s) = severity_str { - if let Some(sev) = MinSeverity::from_str(&s) { + if let Ok(sev) = s.parse::() { config.min_severity = sev; } else { tracing::warn!("Unknown min-severity '{}', using default", s); diff --git a/bots/panicbot/src/main.rs b/bots/panicbot/src/main.rs index b27fe4c2..680ca977 100644 --- a/bots/panicbot/src/main.rs +++ b/bots/panicbot/src/main.rs @@ -13,12 +13,12 @@ use anyhow::{Context, Result}; use clap::Parser; use tracing_subscriber::EnvFilter; +use panicbot::a2ml_writer; use panicbot::cli::{Cli, Command, OutputFormat}; +use panicbot::directives; use panicbot::fleet; use panicbot::scanner; use panicbot::translator; -use panicbot::directives; -use panicbot::a2ml_writer; fn main() -> Result<()> { let cli = Cli::parse(); @@ -48,8 +48,8 @@ fn cmd_scan(dir: &std::path::Path, format: OutputFormat) -> Result<()> { tracing::info!("Scanning: {}", dir.display()); let config = directives::load_directives(dir).unwrap_or_default(); - let report = scanner::run_assail(dir, config.timeout) - .context("Failed to run panic-attack assail")?; + let report = + scanner::run_assail(dir, config.timeout).context("Failed to run panic-attack assail")?; let findings = translator::translate_all(&report.weak_points, &config); @@ -83,8 +83,8 @@ fn cmd_scan(dir: &std::path::Path, format: OutputFormat) -> Result<()> { } } OutputFormat::Json => { - let json = serde_json::to_string_pretty(&findings) - .context("Failed to serialize findings")?; + let json = + serde_json::to_string_pretty(&findings).context("Failed to serialize findings")?; println!("{}", json); } } @@ -153,8 +153,8 @@ fn cmd_report( tracing::info!("Generating A2ML report for: {}", dir.display()); let config = directives::load_directives(dir).unwrap_or_default(); - let report = scanner::run_assail(dir, config.timeout) - .context("Failed to run panic-attack assail")?; + let report = + scanner::run_assail(dir, config.timeout).context("Failed to run panic-attack assail")?; let findings = translator::translate_all(&report.weak_points, &config); let (fixable, unfixable) = translator::classify_fixability(&findings); @@ -181,8 +181,8 @@ fn cmd_report( std::fs::create_dir_all(parent) .with_context(|| format!("Failed to create {}", parent.display()))?; } - let json = serde_json::to_string_pretty(&a2ml_doc) - .context("Failed to serialize A2ML document")?; + let json = + serde_json::to_string_pretty(&a2ml_doc).context("Failed to serialize A2ML document")?; std::fs::write(output_path, &json) .with_context(|| format!("Failed to write {}", output_path.display()))?; println!("A2ML report written to {}", output_path.display()); diff --git a/bots/panicbot/src/scanner.rs b/bots/panicbot/src/scanner.rs index a65c7895..6dd45caa 100644 --- a/bots/panicbot/src/scanner.rs +++ b/bots/panicbot/src/scanner.rs @@ -261,8 +261,8 @@ pub fn run_adjudicate(reports: &[PathBuf], timeout: Duration) -> Result = args.iter().map(|s| s.as_str()).collect(); - let output = invoke_panic_attack(&arg_refs, timeout) - .context("Failed to run panic-attack adjudicate")?; + let output = + invoke_panic_attack(&arg_refs, timeout).context("Failed to run panic-attack adjudicate")?; serde_json::from_str::(&output) .context("Failed to parse panic-attack adjudicate output as JSON") @@ -324,12 +324,7 @@ pub fn run_migration_snapshot( timeout: Duration, ) -> Result { let target_str = target.display().to_string(); - let mut args = vec![ - "migration-snapshot", - &target_str, - "--label", - label, - ]; + let mut args = vec!["migration-snapshot", &target_str, "--label", label]; // Allocate owned strings for optional flags so references stay valid let build_flag = "--build-time".to_string(); @@ -360,14 +355,10 @@ pub fn run_migration_diff( ) -> Result { let before_str = before.display().to_string(); let after_str = after.display().to_string(); - let args = vec![ - "migration-diff", - &before_str, - &after_str, - ]; + let args = vec!["migration-diff", &before_str, &after_str]; - let output = invoke_panic_attack(&args, timeout) - .context("Failed to run panic-attack migration-diff")?; + let output = + invoke_panic_attack(&args, timeout).context("Failed to run panic-attack migration-diff")?; serde_json::from_str::(&output) .context("Failed to parse panic-attack migration-diff output as JSON") @@ -391,10 +382,7 @@ fn find_panic_attack_binary() -> Result { } // Check common locations - let common_paths = [ - "/usr/local/bin/panic-attack", - "/usr/bin/panic-attack", - ]; + let common_paths = ["/usr/local/bin/panic-attack", "/usr/bin/panic-attack"]; for path in &common_paths { if Path::new(path).exists() { @@ -442,12 +430,9 @@ fn invoke_panic_attack(args: &[&str], timeout: Duration) -> Result { cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); - let child = cmd.spawn().with_context(|| { - format!( - "Failed to spawn panic-attack at {}", - binary.display() - ) - })?; + let child = cmd + .spawn() + .with_context(|| format!("Failed to spawn panic-attack at {}", binary.display()))?; let output = child .wait_with_output() diff --git a/bots/panicbot/src/translator.rs b/bots/panicbot/src/translator.rs index 18f1ce0d..e6b727f2 100644 --- a/bots/panicbot/src/translator.rs +++ b/bots/panicbot/src/translator.rs @@ -388,10 +388,7 @@ pub fn translate_weak_point(wp: &WeakPoint, config: &PanicbotConfig) -> Finding /// /// Applies the configured minimum severity filter — findings below the /// threshold are silently dropped. -pub fn translate_all( - weak_points: &[WeakPoint], - config: &PanicbotConfig, -) -> Vec { +pub fn translate_all(weak_points: &[WeakPoint], config: &PanicbotConfig) -> Vec { let min_threshold = config.min_severity.threshold(); weak_points @@ -413,10 +410,7 @@ pub fn translate_all( if wp.suppressed { return None; } - if matches!( - wp.test_context.as_deref(), - Some("test_only") | Some("doc") - ) { + if matches!(wp.test_context.as_deref(), Some("test_only") | Some("doc")) { return None; } @@ -581,8 +575,8 @@ mod tests { severity: "Low".to_string(), description: "low severity".to_string(), recommended_attack: vec![], - suppressed: false, - test_context: None, + suppressed: false, + test_context: None, }, WeakPoint { category: "CommandInjection".to_string(), @@ -590,8 +584,8 @@ mod tests { severity: "Critical".to_string(), description: "critical severity".to_string(), recommended_attack: vec![], - suppressed: false, - test_context: None, + suppressed: false, + test_context: None, }, ]; let config = PanicbotConfig { @@ -616,8 +610,8 @@ mod tests { severity: "High".to_string(), description: "fixable".to_string(), recommended_attack: vec![], - suppressed: false, - test_context: None, + suppressed: false, + test_context: None, }, &config, ), @@ -628,8 +622,8 @@ mod tests { severity: "High".to_string(), description: "not fixable".to_string(), recommended_attack: vec![], - suppressed: false, - test_context: None, + suppressed: false, + test_context: None, }, &config, ), @@ -645,18 +639,37 @@ mod tests { #[test] fn test_all_categories_have_unique_rule_ids() { let categories = [ - "UnsafeCode", "PanicPath", "CommandInjection", "HardcodedSecret", - "UnsafeDeserialization", "UncheckedError", "UnsafeFFI", "RaceCondition", - "ResourceLeak", "PathTraversal", "AtomExhaustion", "ExcessivePermissions", - "UnsafeTypeCoercion", "UncheckedAllocation", "UnboundedLoop", "BlockingIO", - "DeadlockPotential", "DynamicCodeExecution", "InsecureProtocol", - "InfiniteRecursion", "ProofDrift", "CryptoMisuse", "SupplyChain", - "InputBoundary", "MutationGap", + "UnsafeCode", + "PanicPath", + "CommandInjection", + "HardcodedSecret", + "UnsafeDeserialization", + "UncheckedError", + "UnsafeFFI", + "RaceCondition", + "ResourceLeak", + "PathTraversal", + "AtomExhaustion", + "ExcessivePermissions", + "UnsafeTypeCoercion", + "UncheckedAllocation", + "UnboundedLoop", + "BlockingIO", + "DeadlockPotential", + "DynamicCodeExecution", + "InsecureProtocol", + "InfiniteRecursion", + "ProofDrift", + "CryptoMisuse", + "SupplyChain", + "InputBoundary", + "MutationGap", ]; let mut rule_ids = std::collections::HashSet::new(); for cat in &categories { - let mapping = category_mapping(cat).unwrap_or_else(|| panic!("Missing mapping for {}", cat)); + let mapping = + category_mapping(cat).unwrap_or_else(|| panic!("Missing mapping for {}", cat)); assert!( rule_ids.insert(mapping.rule_id), "Duplicate rule_id {} for category {}", diff --git a/bots/panicbot/tests/a2ml_writer_test.rs b/bots/panicbot/tests/a2ml_writer_test.rs index 4c8be139..f0623cda 100644 --- a/bots/panicbot/tests/a2ml_writer_test.rs +++ b/bots/panicbot/tests/a2ml_writer_test.rs @@ -15,10 +15,15 @@ use std::path::PathBuf; /// Helper: create a minimal Finding for testing. fn test_finding(rule_id: &str, severity: Severity, fixable: bool, tier: TriangleTier) -> Finding { - let mut f = Finding::new(BotId::Panicbot, rule_id, severity, &format!("Test: {}", rule_id)) - .with_category("static-analysis/test") - .with_triangle_tier(tier) - .with_confidence(0.75); + let mut f = Finding::new( + BotId::Panicbot, + rule_id, + severity, + &format!("Test: {}", rule_id), + ) + .with_category("static-analysis/test") + .with_triangle_tier(tier) + .with_confidence(0.75); if fixable { f = f.fixable(); } @@ -107,14 +112,19 @@ fn test_summary_by_tier() { #[test] fn test_finding_fields_in_a2ml() { - let finding = Finding::new(BotId::Panicbot, "PA004", Severity::Error, "AWS key in source") - .with_rule_name("Hardcoded secret or credential") - .with_category("static-analysis/hardcoded-secret") - .with_file(PathBuf::from("config.py")) - .with_line(15) - .with_triangle_tier(TriangleTier::Eliminate) - .with_confidence(0.88) - .fixable(); + let finding = Finding::new( + BotId::Panicbot, + "PA004", + Severity::Error, + "AWS key in source", + ) + .with_rule_name("Hardcoded secret or credential") + .with_category("static-analysis/hardcoded-secret") + .with_file(PathBuf::from("config.py")) + .with_line(15) + .with_triangle_tier(TriangleTier::Eliminate) + .with_confidence(0.88) + .fixable(); let fixable = vec![&finding]; let doc = a2ml_writer::generate_a2ml(&fixable, &[], "test/fields", "2.1.0"); diff --git a/bots/panicbot/tests/integration_test.rs b/bots/panicbot/tests/integration_test.rs index 2dc0d453..17bf1943 100644 --- a/bots/panicbot/tests/integration_test.rs +++ b/bots/panicbot/tests/integration_test.rs @@ -28,8 +28,8 @@ fn mock_assail_report() -> AssailReport { severity: "High".to_string(), description: "3 unsafe blocks in FFI boundary".to_string(), recommended_attack: vec!["Memory".to_string()], - suppressed: false, - test_context: None, + suppressed: false, + test_context: None, }, WeakPoint { category: "HardcodedSecret".to_string(), @@ -37,8 +37,8 @@ fn mock_assail_report() -> AssailReport { severity: "Critical".to_string(), description: "AWS_SECRET_ACCESS_KEY found in source code".to_string(), recommended_attack: vec![], - suppressed: false, - test_context: None, + suppressed: false, + test_context: None, }, WeakPoint { category: "PanicPath".to_string(), @@ -46,8 +46,8 @@ fn mock_assail_report() -> AssailReport { severity: "Medium".to_string(), description: "unwrap() on user-provided input".to_string(), recommended_attack: vec!["CPU".to_string()], - suppressed: false, - test_context: None, + suppressed: false, + test_context: None, }, WeakPoint { category: "RaceCondition".to_string(), @@ -55,8 +55,8 @@ fn mock_assail_report() -> AssailReport { severity: "High".to_string(), description: "Shared mutable state without synchronisation".to_string(), recommended_attack: vec!["Concurrency".to_string()], - suppressed: false, - test_context: None, + suppressed: false, + test_context: None, }, WeakPoint { category: "UncheckedError".to_string(), @@ -64,8 +64,8 @@ fn mock_assail_report() -> AssailReport { severity: "Medium".to_string(), description: "Result ignored from database query".to_string(), recommended_attack: vec![], - suppressed: false, - test_context: None, + suppressed: false, + test_context: None, }, ], statistics: serde_json::json!({"total_lines": 5000}), @@ -129,12 +129,7 @@ fn test_a2ml_generation_from_translated_findings() { let findings = translator::translate_all(&report.weak_points, &config); let (fixable, unfixable) = translator::classify_fixability(&findings); - let doc = a2ml_writer::generate_a2ml( - &fixable, - &unfixable, - "hyperpolymath/test-repo", - "2.1.0", - ); + let doc = a2ml_writer::generate_a2ml(&fixable, &unfixable, "hyperpolymath/test-repo", "2.1.0"); assert_eq!(doc.a2ml.schema, "panicbot.findings"); assert_eq!(doc.payload.summary.total, 5); diff --git a/bots/panicbot/tests/translator_test.rs b/bots/panicbot/tests/translator_test.rs index 9ac16c51..c613e6b1 100644 --- a/bots/panicbot/tests/translator_test.rs +++ b/bots/panicbot/tests/translator_test.rs @@ -25,8 +25,8 @@ fn weak_point(category: &str, severity: &str) -> WeakPoint { severity: severity.to_string(), description: format!("Test finding for {}", category), recommended_attack: vec![], - suppressed: false, - test_context: None, + suppressed: false, + test_context: None, } } @@ -38,8 +38,8 @@ fn weak_point_at(category: &str, severity: &str, location: &str) -> WeakPoint { severity: severity.to_string(), description: format!("Test finding at {}", location), recommended_attack: vec![], - suppressed: false, - test_context: None, + suppressed: false, + test_context: None, } } @@ -77,11 +77,7 @@ fn test_all_23_categories_have_mappings() { for cat in &categories { let mapping = translator::category_mapping(cat); - assert!( - mapping.is_some(), - "Missing mapping for category: {}", - cat - ); + assert!(mapping.is_some(), "Missing mapping for category: {}", cat); } } @@ -244,10 +240,7 @@ fn test_translate_location_parsing() { let wp = weak_point_at("UnsafeCode", "High", "src/ffi.rs:42"); let finding = translator::translate_weak_point(&wp, &config); - assert_eq!( - finding.file, - Some(std::path::PathBuf::from("src/ffi.rs")) - ); + assert_eq!(finding.file, Some(std::path::PathBuf::from("src/ffi.rs"))); assert_eq!(finding.line, Some(42)); } @@ -257,10 +250,7 @@ fn test_translate_location_no_line() { let wp = weak_point_at("PanicPath", "Medium", "src/lib.rs"); let finding = translator::translate_weak_point(&wp, &config); - assert_eq!( - finding.file, - Some(std::path::PathBuf::from("src/lib.rs")) - ); + assert_eq!(finding.file, Some(std::path::PathBuf::from("src/lib.rs"))); assert_eq!(finding.line, None); } @@ -311,8 +301,8 @@ fn test_translate_metadata_includes_panic_attack_category() { severity: "Critical".to_string(), description: "os.system with user input".to_string(), recommended_attack: vec!["Memory".to_string(), "Concurrency".to_string()], - suppressed: false, - test_context: None, + suppressed: false, + test_context: None, }; let finding = translator::translate_weak_point(&wp, &config); @@ -386,13 +376,31 @@ fn test_classify_empty_findings() { #[test] fn test_all_confidence_values_in_valid_range() { let categories = [ - "UnsafeCode", "PanicPath", "CommandInjection", "HardcodedSecret", - "UnsafeDeserialization", "UncheckedError", "UnsafeFFI", "RaceCondition", - "ResourceLeak", "PathTraversal", "AtomExhaustion", "ExcessivePermissions", - "UnsafeTypeCoercion", "UncheckedAllocation", "UnboundedLoop", "BlockingIO", - "DeadlockPotential", "DynamicCodeExecution", "InsecureProtocol", - "InfiniteRecursion", "ProofDrift", "CryptoMisuse", "SupplyChain", - "InputBoundary", "MutationGap", + "UnsafeCode", + "PanicPath", + "CommandInjection", + "HardcodedSecret", + "UnsafeDeserialization", + "UncheckedError", + "UnsafeFFI", + "RaceCondition", + "ResourceLeak", + "PathTraversal", + "AtomExhaustion", + "ExcessivePermissions", + "UnsafeTypeCoercion", + "UncheckedAllocation", + "UnboundedLoop", + "BlockingIO", + "DeadlockPotential", + "DynamicCodeExecution", + "InsecureProtocol", + "InfiniteRecursion", + "ProofDrift", + "CryptoMisuse", + "SupplyChain", + "InputBoundary", + "MutationGap", ]; for cat in &categories { From dc7ba1cc53a7d663ae2fe4a1a6541faf0a804ba3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 00:02:39 +0000 Subject: [PATCH 2/3] fix(panicbot): use panic-attack's canonical PA rule IDs; check them in CI panicbot's translator numbered panic-attack categories with its own sequence: 20 of its 25 rule IDs disagreed with panic-attack's WeakPointCategory::rule_id (UnsafeCode PA001 vs PA004, CommandInjection PA003 vs PA009, HardcodedSecret PA004 vs PA017, ...). Hypatia deduplicates findings by these IDs and follows panic-attack's numbering (e.g. its `PA009-shell-unquoted-var` pattern), so panicbot filed findings under the wrong rules. - Vendor panic-attack's published table (data/canonical-rule-ids.json at 5175a68) into bots/panicbot/canon/, pinned by sha256 in pin.toml. - Correct the translator's rule IDs from that table. tests/canonical_rule_ids_test.rs checks every category against it; the five panic-attack categories panicbot does not translate yet (UnboundedAllocation, LicensePolicy, Advisory, BannedDep, UnknownSource) are listed explicitly, so a new upstream category fails the test until someone decides how to map it. - Fix-script headers cited six wrong PA codes (documentation only: routing is by category name). They now cite `PAnnn (Category)` from the table, and Hypatia pattern IDs are back-quoted so the two namespaces stay apart. tests/panic-attack-rule-ids-test.sh checks the pin digest and every reference (`--upstream` compares with panic-attack main). - Add panicbot to the rust.yml matrix (it was built, never tested or linted, in CI) and the new shell test to e2e.yml. Verified: panicbot 82 tests, clippy -D warnings, fmt; a reintroduced old ID (ResourceLeak PA009) fails canonical_rule_ids_test; a wrong header, an unquoted Hypatia ID and an edited table each fail the shell test; actionlint and check-lock-sync clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PpQVLrCbURpoXtrdvy8V3B --- .github/workflows/e2e.yml | 2 + .github/workflows/rust.yml | 4 + bots/panicbot/canon/canonical-rule-ids.json | 295 ++++++++++++++++++ bots/panicbot/canon/pin.toml | 16 + bots/panicbot/src/translator.rs | 55 ++-- .../panicbot/tests/canonical_rule_ids_test.rs | 69 ++++ bots/panicbot/tests/integration_test.rs | 8 +- bots/panicbot/tests/translator_test.rs | 28 +- scripts/fix-believe-me.sh | 3 +- scripts/fix-heredoc-install.sh | 3 +- scripts/fix-http-to-https.sh | 2 +- scripts/fix-innerhtml.sh | 3 +- scripts/fix-resource-leak.sh | 2 +- scripts/fix-secret-to-env.sh | 2 +- scripts/fix-shell-quoting.sh | 3 +- scripts/fix-sql-parameterize.sh | 3 +- scripts/fix-tmp-paths.sh | 2 +- scripts/fix-todo-markers.sh | 3 +- scripts/fix-unbounded-loop.sh | 4 +- scripts/fix-unchecked-error.sh | 2 +- tests/panic-attack-rule-ids-test.sh | 69 ++++ 21 files changed, 522 insertions(+), 56 deletions(-) create mode 100644 bots/panicbot/canon/canonical-rule-ids.json create mode 100644 bots/panicbot/canon/pin.toml create mode 100644 bots/panicbot/tests/canonical_rule_ids_test.rs create mode 100755 tests/panic-attack-rule-ids-test.sh diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 130dfae4..6762a099 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -29,6 +29,8 @@ jobs: run: bash tests/automation-quarantine-test.sh - name: Verify fix-outcome verdicts run: bash tests/outcome-verdict-test.sh + - name: PA rule IDs agree with panic-attack's table + run: bash tests/panic-attack-rule-ids-test.sh - name: Run E2E tests run: bash tests/e2e.sh - name: Verify canonical descriptile policy repair diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 4996e22c..80ff6520 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -45,6 +45,10 @@ jobs: dir: dashboard - module: rhodibot dir: bots/rhodibot + # panicbot was built (panicbot-sweep.yml) but never tested or linted + # in CI, so its private PA numbering went unnoticed until 2026-11-04. + - module: panicbot + dir: bots/panicbot - module: idris-dependents dir: idris-dependents defaults: diff --git a/bots/panicbot/canon/canonical-rule-ids.json b/bots/panicbot/canon/canonical-rule-ids.json new file mode 100644 index 00000000..00e7506a --- /dev/null +++ b/bots/panicbot/canon/canonical-rule-ids.json @@ -0,0 +1,295 @@ +{ + "schema_version": "1.0.0", + "title": "panic-attack canonical rule-ID table", + "description": "Public rule ID contract matching WeakPointCategory::rule_id at source commit 410a8f821ea8297833162565b50a298f787815da. CWE links are semantic associations, not assertions of exact CWE identity; [] means no direct CWE mapping is claimed. PA001 and PA001b intentionally share a semantic class and CWE and should deduplicate for class-level verdict grids.", + "cwe_catalog": "https://cwe.mitre.org/", + "source_commit": "410a8f821ea8297833162565b50a298f787815da", + "deduplication": { + "primary_key": "semantic_class", + "preserve_rule_ids": [ + "PA001", + "PA001b" + ], + "note": "Preserve the canonical rule ID for audit/report display; group findings by semantic_class for cross-tool verdict-grid comparison. Apply the first matching semantic_class_overrides entry (description_prefix matches the generated finding description) and use its semantic_class and cwe_ids before falling back to the rule metadata; this keeps PA011 DOM sinks separate from eval input. CWE associations are supporting metadata, not a unique deduplication key." + }, + "rules": [ + { + "rule_id": "PA001", + "category": "UncheckedAllocation", + "name": "unchecked-allocation", + "cwe_ids": [ + "CWE-789" + ], + "semantic_class": "unbounded-memory-allocation" + }, + { + "rule_id": "PA001b", + "category": "UnboundedAllocation", + "name": "unbounded-allocation", + "cwe_ids": [ + "CWE-789" + ], + "semantic_class": "unbounded-memory-allocation" + }, + { + "rule_id": "PA002", + "category": "UnboundedLoop", + "name": "unbounded-loop", + "cwe_ids": [ + "CWE-834" + ], + "semantic_class": "unbounded-iteration" + }, + { + "rule_id": "PA003", + "category": "BlockingIO", + "name": "blocking-io", + "cwe_ids": [ + "CWE-400" + ], + "semantic_class": "blocking-resource-consumption" + }, + { + "rule_id": "PA004", + "category": "UnsafeCode", + "name": "unsafe-code", + "cwe_ids": [], + "semantic_class": "unsafe-memory-or-type-operation" + }, + { + "rule_id": "PA005", + "category": "PanicPath", + "name": "panic-path", + "cwe_ids": [ + "CWE-248" + ], + "semantic_class": "unhandled-runtime-panic" + }, + { + "rule_id": "PA006", + "category": "RaceCondition", + "name": "race-condition", + "cwe_ids": [ + "CWE-362" + ], + "semantic_class": "concurrent-access-race" + }, + { + "rule_id": "PA007", + "category": "DeadlockPotential", + "name": "deadlock-potential", + "cwe_ids": [ + "CWE-833" + ], + "semantic_class": "concurrency-deadlock" + }, + { + "rule_id": "PA008", + "category": "ResourceLeak", + "name": "resource-leak", + "cwe_ids": [ + "CWE-772" + ], + "semantic_class": "unreleased-resource" + }, + { + "rule_id": "PA009", + "category": "CommandInjection", + "name": "command-injection", + "cwe_ids": [ + "CWE-78" + ], + "semantic_class": "improper-neutralization-of-command-input" + }, + { + "rule_id": "PA010", + "category": "UnsafeDeserialization", + "name": "unsafe-deserialization", + "cwe_ids": [ + "CWE-502" + ], + "semantic_class": "unsafe-deserialization" + }, + { + "rule_id": "PA011", + "category": "DynamicCodeExecution", + "name": "dynamic-code-execution", + "cwe_ids": [ + "CWE-95" + ], + "semantic_class": "improper-neutralization-of-eval-input", + "semantic_class_overrides": [ + { + "description_prefix": "DOM manipulation (innerHTML/document.write) in ", + "semantic_class": "improper-neutralization-of-html-input", + "cwe_ids": ["CWE-79"] + }, + { + "description_prefix": "dangerouslySetInnerHTML (XSS risk) in ", + "semantic_class": "improper-neutralization-of-html-input", + "cwe_ids": ["CWE-79"] + } + ] + }, + { + "rule_id": "PA012", + "category": "UnsafeFFI", + "name": "unsafe-ffi", + "cwe_ids": [], + "semantic_class": "unsafe-foreign-function-boundary" + }, + { + "rule_id": "PA013", + "category": "AtomExhaustion", + "name": "atom-exhaustion", + "cwe_ids": [ + "CWE-400" + ], + "semantic_class": "unbounded-resource-consumption" + }, + { + "rule_id": "PA014", + "category": "InsecureProtocol", + "name": "insecure-protocol", + "cwe_ids": [ + "CWE-319", + "CWE-326" + ], + "semantic_class": "insecure-communications-or-cryptography" + }, + { + "rule_id": "PA015", + "category": "ExcessivePermissions", + "name": "excessive-permissions", + "cwe_ids": [ + "CWE-732" + ], + "semantic_class": "incorrect-permission-assignment" + }, + { + "rule_id": "PA016", + "category": "PathTraversal", + "name": "path-traversal", + "cwe_ids": [ + "CWE-22" + ], + "semantic_class": "improper-limitation-of-pathname" + }, + { + "rule_id": "PA017", + "category": "HardcodedSecret", + "name": "hardcoded-secret", + "cwe_ids": [ + "CWE-798" + ], + "semantic_class": "hardcoded-credential" + }, + { + "rule_id": "PA018", + "category": "UncheckedError", + "name": "unchecked-error", + "cwe_ids": [ + "CWE-390", + "CWE-391" + ], + "semantic_class": "ignored-or-unchecked-error-condition" + }, + { + "rule_id": "PA019", + "category": "InfiniteRecursion", + "name": "infinite-recursion", + "cwe_ids": [ + "CWE-674" + ], + "semantic_class": "uncontrolled-recursion" + }, + { + "rule_id": "PA020", + "category": "UnsafeTypeCoercion", + "name": "unsafe-type-coercion", + "cwe_ids": [ + "CWE-704" + ], + "semantic_class": "incorrect-type-conversion" + }, + { + "rule_id": "PA021", + "category": "ProofDrift", + "name": "proof-drift", + "cwe_ids": [], + "semantic_class": "unverified-or-drifted-formal-proof" + }, + { + "rule_id": "PA022", + "category": "CryptoMisuse", + "name": "crypto-misuse", + "cwe_ids": [ + "CWE-327", + "CWE-328", + "CWE-330" + ], + "semantic_class": "cryptographic-primitive-or-validation-misuse" + }, + { + "rule_id": "PA023", + "category": "SupplyChain", + "name": "supply-chain", + "cwe_ids": [ + "CWE-1104", + "CWE-1395" + ], + "semantic_class": "untrusted-or-vulnerable-component" + }, + { + "rule_id": "PA024", + "category": "InputBoundary", + "name": "input-boundary", + "cwe_ids": [ + "CWE-20", + "CWE-502" + ], + "semantic_class": "unvalidated-input-at-deserialization-boundary" + }, + { + "rule_id": "PA025", + "category": "MutationGap", + "name": "mutation-gap", + "cwe_ids": [], + "semantic_class": "insufficient-test-or-mutation-assurance" + }, + { + "rule_id": "PA026", + "category": "LicensePolicy", + "name": "license-policy", + "cwe_ids": [], + "semantic_class": "dependency-license-policy-violation" + }, + { + "rule_id": "PA027", + "category": "Advisory", + "name": "advisory", + "cwe_ids": [ + "CWE-1395" + ], + "semantic_class": "known-vulnerable-component-version" + }, + { + "rule_id": "PA028", + "category": "BannedDep", + "name": "banned-dep", + "cwe_ids": [ + "CWE-1357" + ], + "semantic_class": "prohibited-or-insufficiently-trustworthy-component" + }, + { + "rule_id": "PA029", + "category": "UnknownSource", + "name": "unknown-source", + "cwe_ids": [ + "CWE-1357" + ], + "semantic_class": "unverified-component-provenance" + } + ] +} diff --git a/bots/panicbot/canon/pin.toml b/bots/panicbot/canon/pin.toml new file mode 100644 index 00000000..6797af3d --- /dev/null +++ b/bots/panicbot/canon/pin.toml @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# panicbot's copy of panic-attack's published rule-ID table, taken verbatim +# and pinned by digest. panicbot's translator must use these IDs (enforced by +# tests/canonical_rule_ids_test.rs); the digest is checked by +# tests/panic-attack-rule-ids-test.sh. To re-pin: copy the upstream file over +# canonical-rule-ids.json, update `commit`, `pinned` and `sha256`, and run both +# tests. `tests/panic-attack-rule-ids-test.sh --upstream` compares against +# panic-attack main. + +[source] +repo = "hyperpolymath/panic-attack" +path = "data/canonical-rule-ids.json" +commit = "5175a681f9a0c82313f06e3c2cba8089f4ecfb6b" +pinned = "2026-11-04" +sha256 = "28ffe510535e0bc6cccc92845411d6496fe9270cce58364f4d3507c5c08337d4" diff --git a/bots/panicbot/src/translator.rs b/bots/panicbot/src/translator.rs index e6b727f2..62b08fa4 100644 --- a/bots/panicbot/src/translator.rs +++ b/bots/panicbot/src/translator.rs @@ -4,7 +4,8 @@ //! Core responsibility: map each `WeakPoint` from the scanner output into a //! `Finding` struct with appropriate: //! - Fleet category (e.g., "static-analysis/unsafe-code") -//! - Rule ID (canonical PA001–PA025 pattern for dedup with Hypatia findings) +//! - Rule ID (panic-attack's canonical table, vendored in `canon/`; Hypatia +//! deduplicates by it) //! - Triangle tier (Eliminate / Substitute / Control) //! - Confidence score (honest assessment of detection accuracy) //! - Fixability (whether automated remediation is possible) @@ -43,7 +44,7 @@ pub enum Fixability { pub struct CategoryMapping { /// Fleet category string (e.g., "static-analysis/unsafe-code"). pub fleet_category: &'static str, - /// Canonical rule ID for deduplication (e.g., "PA001"). + /// Canonical panic-attack rule ID (e.g., "PA004" for UnsafeCode). pub rule_id: &'static str, /// Human-readable rule name. pub rule_name: &'static str, @@ -63,7 +64,7 @@ pub fn category_mapping(category: &str) -> Option { match category { "UnsafeCode" => Some(CategoryMapping { fleet_category: "static-analysis/unsafe-code", - rule_id: "PA001", + rule_id: "PA004", rule_name: "Unsafe code block", triangle_tier: TriangleTier::Control, default_confidence: 0.70, @@ -71,7 +72,7 @@ pub fn category_mapping(category: &str) -> Option { }), "PanicPath" => Some(CategoryMapping { fleet_category: "static-analysis/panic-path", - rule_id: "PA002", + rule_id: "PA005", rule_name: "Potential panic path", triangle_tier: TriangleTier::Substitute, default_confidence: 0.70, @@ -79,7 +80,7 @@ pub fn category_mapping(category: &str) -> Option { }), "CommandInjection" => Some(CategoryMapping { fleet_category: "static-analysis/command-injection", - rule_id: "PA003", + rule_id: "PA009", rule_name: "Command injection risk", triangle_tier: TriangleTier::Control, default_confidence: 0.80, @@ -87,7 +88,7 @@ pub fn category_mapping(category: &str) -> Option { }), "HardcodedSecret" => Some(CategoryMapping { fleet_category: "static-analysis/hardcoded-secret", - rule_id: "PA004", + rule_id: "PA017", rule_name: "Hardcoded secret or credential", triangle_tier: TriangleTier::Eliminate, default_confidence: 0.88, @@ -95,7 +96,7 @@ pub fn category_mapping(category: &str) -> Option { }), "UnsafeDeserialization" => Some(CategoryMapping { fleet_category: "static-analysis/unsafe-deser", - rule_id: "PA005", + rule_id: "PA010", rule_name: "Unsafe deserialization", triangle_tier: TriangleTier::Eliminate, default_confidence: 0.87, @@ -103,7 +104,7 @@ pub fn category_mapping(category: &str) -> Option { }), "UncheckedError" => Some(CategoryMapping { fleet_category: "static-analysis/unchecked-error", - rule_id: "PA006", + rule_id: "PA018", rule_name: "Unchecked error / unwrap", triangle_tier: TriangleTier::Eliminate, default_confidence: 0.75, @@ -111,7 +112,7 @@ pub fn category_mapping(category: &str) -> Option { }), "UnsafeFFI" => Some(CategoryMapping { fleet_category: "static-analysis/unsafe-ffi", - rule_id: "PA007", + rule_id: "PA012", rule_name: "Unsafe FFI boundary", triangle_tier: TriangleTier::Control, default_confidence: 0.75, @@ -119,7 +120,7 @@ pub fn category_mapping(category: &str) -> Option { }), "RaceCondition" => Some(CategoryMapping { fleet_category: "static-analysis/race-condition", - rule_id: "PA008", + rule_id: "PA006", rule_name: "Potential race condition", triangle_tier: TriangleTier::Control, default_confidence: 0.60, @@ -127,7 +128,7 @@ pub fn category_mapping(category: &str) -> Option { }), "ResourceLeak" => Some(CategoryMapping { fleet_category: "static-analysis/resource-leak", - rule_id: "PA009", + rule_id: "PA008", rule_name: "Resource leak", triangle_tier: TriangleTier::Substitute, default_confidence: 0.78, @@ -135,7 +136,7 @@ pub fn category_mapping(category: &str) -> Option { }), "PathTraversal" => Some(CategoryMapping { fleet_category: "static-analysis/path-traversal", - rule_id: "PA010", + rule_id: "PA016", rule_name: "Path traversal risk", triangle_tier: TriangleTier::Substitute, default_confidence: 0.78, @@ -143,7 +144,7 @@ pub fn category_mapping(category: &str) -> Option { }), "AtomExhaustion" => Some(CategoryMapping { fleet_category: "static-analysis/atom-exhaustion", - rule_id: "PA011", + rule_id: "PA013", rule_name: "Atom table exhaustion", triangle_tier: TriangleTier::Eliminate, default_confidence: 0.93, @@ -151,7 +152,7 @@ pub fn category_mapping(category: &str) -> Option { }), "ExcessivePermissions" => Some(CategoryMapping { fleet_category: "static-analysis/excessive-perms", - rule_id: "PA012", + rule_id: "PA015", rule_name: "Excessive permissions", triangle_tier: TriangleTier::Eliminate, default_confidence: 0.88, @@ -159,7 +160,7 @@ pub fn category_mapping(category: &str) -> Option { }), "UnsafeTypeCoercion" => Some(CategoryMapping { fleet_category: "static-analysis/unsafe-coercion", - rule_id: "PA013", + rule_id: "PA020", rule_name: "Unsafe type coercion", triangle_tier: TriangleTier::Control, default_confidence: 0.70, @@ -169,7 +170,7 @@ pub fn category_mapping(category: &str) -> Option { // but still valid findings: "UncheckedAllocation" => Some(CategoryMapping { fleet_category: "static-analysis/unchecked-alloc", - rule_id: "PA014", + rule_id: "PA001", rule_name: "Unchecked memory allocation", triangle_tier: TriangleTier::Control, default_confidence: 0.72, @@ -177,7 +178,7 @@ pub fn category_mapping(category: &str) -> Option { }), "UnboundedLoop" => Some(CategoryMapping { fleet_category: "static-analysis/unbounded-loop", - rule_id: "PA015", + rule_id: "PA002", rule_name: "Unbounded loop", triangle_tier: TriangleTier::Control, default_confidence: 0.68, @@ -185,7 +186,7 @@ pub fn category_mapping(category: &str) -> Option { }), "BlockingIO" => Some(CategoryMapping { fleet_category: "static-analysis/blocking-io", - rule_id: "PA016", + rule_id: "PA003", rule_name: "Blocking I/O in async context", triangle_tier: TriangleTier::Substitute, default_confidence: 0.72, @@ -193,7 +194,7 @@ pub fn category_mapping(category: &str) -> Option { }), "DeadlockPotential" => Some(CategoryMapping { fleet_category: "static-analysis/deadlock", - rule_id: "PA017", + rule_id: "PA007", rule_name: "Deadlock potential", triangle_tier: TriangleTier::Control, default_confidence: 0.55, @@ -201,7 +202,7 @@ pub fn category_mapping(category: &str) -> Option { }), "DynamicCodeExecution" => Some(CategoryMapping { fleet_category: "static-analysis/dynamic-exec", - rule_id: "PA018", + rule_id: "PA011", rule_name: "Dynamic code execution", triangle_tier: TriangleTier::Control, default_confidence: 0.82, @@ -209,7 +210,7 @@ pub fn category_mapping(category: &str) -> Option { }), "InsecureProtocol" => Some(CategoryMapping { fleet_category: "static-analysis/insecure-proto", - rule_id: "PA019", + rule_id: "PA014", rule_name: "Insecure protocol usage", triangle_tier: TriangleTier::Substitute, default_confidence: 0.85, @@ -217,7 +218,7 @@ pub fn category_mapping(category: &str) -> Option { }), "InfiniteRecursion" => Some(CategoryMapping { fleet_category: "static-analysis/infinite-recursion", - rule_id: "PA020", + rule_id: "PA019", rule_name: "Infinite recursion risk", triangle_tier: TriangleTier::Control, default_confidence: 0.65, @@ -457,7 +458,7 @@ mod tests { fn test_category_mapping_known() { let m = category_mapping("UnsafeCode").unwrap(); assert_eq!(m.fleet_category, "static-analysis/unsafe-code"); - assert_eq!(m.rule_id, "PA001"); + assert_eq!(m.rule_id, "PA004"); assert_eq!(m.triangle_tier, TriangleTier::Control); assert!((m.default_confidence - 0.70).abs() < f64::EPSILON); assert_eq!(m.fixability, Fixability::No); @@ -514,7 +515,7 @@ mod tests { let finding = translate_weak_point(&wp, &config); assert_eq!(finding.source, BotId::Panicbot); - assert_eq!(finding.rule_id, "PA004"); + assert_eq!(finding.rule_id, "PA017"); assert_eq!(finding.category, "static-analysis/hardcoded-secret"); assert_eq!(finding.severity, Severity::Error); assert!(finding.fixable); @@ -596,7 +597,7 @@ mod tests { // Only the Critical finding should pass the High filter assert_eq!(findings.len(), 1); - assert_eq!(findings[0].rule_id, "PA003"); + assert_eq!(findings[0].rule_id, "PA009"); } #[test] @@ -632,8 +633,8 @@ mod tests { let (fixable, unfixable) = classify_fixability(&findings); assert_eq!(fixable.len(), 1); assert_eq!(unfixable.len(), 1); - assert_eq!(fixable[0].rule_id, "PA004"); // HardcodedSecret is fixable - assert_eq!(unfixable[0].rule_id, "PA001"); // UnsafeCode is not + assert_eq!(fixable[0].rule_id, "PA017"); // HardcodedSecret is fixable + assert_eq!(unfixable[0].rule_id, "PA004"); // UnsafeCode is not } #[test] diff --git a/bots/panicbot/tests/canonical_rule_ids_test.rs b/bots/panicbot/tests/canonical_rule_ids_test.rs new file mode 100644 index 00000000..29213bd3 --- /dev/null +++ b/bots/panicbot/tests/canonical_rule_ids_test.rs @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Rule IDs must be panic-attack's, not a second numbering. +//! +//! `canon/canonical-rule-ids.json` is panic-attack's published rule table +//! (`data/canonical-rule-ids.json`), copied verbatim and pinned by digest in +//! `canon/pin.toml` (checked by `tests/panic-attack-rule-ids-test.sh`). Hypatia deduplicates findings by these IDs, so a +//! translator that numbers categories differently files every finding under +//! the wrong rule. + +use panicbot::translator::category_mapping; +use serde_json::Value; + +const TABLE: &str = include_str!("../canon/canonical-rule-ids.json"); + +/// Categories panic-attack defines that panicbot does not translate yet. +/// They fall through to the generic PA000 finding. Mapping one is a policy +/// call (tier, confidence, fixability) — and `LicensePolicy` findings are +/// flag-only under estate policy — so each entry here is deliberate. +const NOT_YET_MAPPED: &[&str] = &[ + "UnboundedAllocation", + "LicensePolicy", + "Advisory", + "BannedDep", + "UnknownSource", +]; + +fn rules() -> Vec<(String, String)> { + let table: Value = serde_json::from_str(TABLE).expect("vendored table is JSON"); + table["rules"] + .as_array() + .expect("rules array") + .iter() + .map(|r| { + ( + r["category"].as_str().expect("category").to_string(), + r["rule_id"].as_str().expect("rule_id").to_string(), + ) + }) + .collect() +} + +#[test] +fn every_mapped_category_uses_the_canonical_rule_id() { + for (category, rule_id) in rules() { + match category_mapping(&category) { + Some(m) => assert_eq!(m.rule_id, rule_id, "{category}"), + None => assert!( + NOT_YET_MAPPED.contains(&category.as_str()), + "{category} ({rule_id}) is in panic-attack's table but neither \ + mapped nor listed in NOT_YET_MAPPED" + ), + } + } +} + +#[test] +fn not_yet_mapped_names_only_real_unmapped_categories() { + let categories: Vec = rules().into_iter().map(|(c, _)| c).collect(); + for name in NOT_YET_MAPPED { + assert!( + categories.iter().any(|c| c == name), + "{name} is not a panic-attack category" + ); + assert!( + category_mapping(name).is_none(), + "{name} is mapped; drop it from NOT_YET_MAPPED" + ); + } +} diff --git a/bots/panicbot/tests/integration_test.rs b/bots/panicbot/tests/integration_test.rs index 17bf1943..ce7719b1 100644 --- a/bots/panicbot/tests/integration_test.rs +++ b/bots/panicbot/tests/integration_test.rs @@ -92,12 +92,12 @@ fn test_full_translation_pipeline() { } // Check specific translations - let unsafe_finding = findings.iter().find(|f| f.rule_id == "PA001").unwrap(); + let unsafe_finding = findings.iter().find(|f| f.rule_id == "PA004").unwrap(); assert_eq!(unsafe_finding.severity, Severity::Warning); // High → Warning assert_eq!(unsafe_finding.category, "static-analysis/unsafe-code"); assert!(!unsafe_finding.fixable); - let secret_finding = findings.iter().find(|f| f.rule_id == "PA004").unwrap(); + let secret_finding = findings.iter().find(|f| f.rule_id == "PA017").unwrap(); assert_eq!(secret_finding.severity, Severity::Error); // Critical → Error assert!(secret_finding.fixable); assert_eq!(secret_finding.triangle_tier, Some(TriangleTier::Eliminate)); @@ -118,8 +118,8 @@ fn test_fixability_classification_pipeline() { assert_eq!(unfixable.len(), 3); let fixable_ids: Vec<&str> = fixable.iter().map(|f| f.rule_id.as_str()).collect(); - assert!(fixable_ids.contains(&"PA004")); - assert!(fixable_ids.contains(&"PA006")); + assert!(fixable_ids.contains(&"PA017")); + assert!(fixable_ids.contains(&"PA018")); } #[test] diff --git a/bots/panicbot/tests/translator_test.rs b/bots/panicbot/tests/translator_test.rs index c613e6b1..02fbb348 100644 --- a/bots/panicbot/tests/translator_test.rs +++ b/bots/panicbot/tests/translator_test.rs @@ -83,27 +83,31 @@ fn test_all_23_categories_have_mappings() { #[test] fn test_rule_ids_sequential_pa001_to_pa020() { + // panic-attack's canonical order (WeakPointCategory::rule_id). PA001b + // (UnboundedAllocation) is not translated yet; see + // canonical_rule_ids_test.rs, which checks every ID against the + // vendored table rather than by position. let categories = [ + "UncheckedAllocation", + "UnboundedLoop", + "BlockingIO", "UnsafeCode", "PanicPath", + "RaceCondition", + "DeadlockPotential", + "ResourceLeak", "CommandInjection", - "HardcodedSecret", "UnsafeDeserialization", - "UncheckedError", + "DynamicCodeExecution", "UnsafeFFI", - "RaceCondition", - "ResourceLeak", - "PathTraversal", "AtomExhaustion", - "ExcessivePermissions", - "UnsafeTypeCoercion", - "UncheckedAllocation", - "UnboundedLoop", - "BlockingIO", - "DeadlockPotential", - "DynamicCodeExecution", "InsecureProtocol", + "ExcessivePermissions", + "PathTraversal", + "HardcodedSecret", + "UncheckedError", "InfiniteRecursion", + "UnsafeTypeCoercion", ]; for (i, cat) in categories.iter().enumerate() { diff --git a/scripts/fix-believe-me.sh b/scripts/fix-believe-me.sh index 193f7c92..7444510d 100755 --- a/scripts/fix-believe-me.sh +++ b/scripts/fix-believe-me.sh @@ -3,7 +3,8 @@ # # fix-believe-me.sh — Flag or remove Idris2 believe_me calls # -# Fixes PA004-idris-believe-me findings. +# Fixes Hypatia pattern `PA004-idris-believe-me`; panic-attack reports +# believe_me as PA021 (ProofDrift). # believe_me bypasses the type checker — it should be replaced with proper proofs. # # Usage: fix-believe-me.sh diff --git a/scripts/fix-heredoc-install.sh b/scripts/fix-heredoc-install.sh index 18de8c50..8c8bbd9c 100755 --- a/scripts/fix-heredoc-install.sh +++ b/scripts/fix-heredoc-install.sh @@ -4,7 +4,8 @@ # # fix-heredoc-install.sh — Replace eval/heredoc patterns with safe alternatives # -# Fixes PA009-shell-heredoc-eval findings. +# Fixes Hypatia pattern `PA009-shell-heredoc-eval`; panic-attack rule +# PA009 (CommandInjection). # Replaces `curl | bash`, `eval $(...)`, and heredoc-eval patterns. # # Usage: fix-heredoc-install.sh diff --git a/scripts/fix-http-to-https.sh b/scripts/fix-http-to-https.sh index aa7523d1..c4bca779 100755 --- a/scripts/fix-http-to-https.sh +++ b/scripts/fix-http-to-https.sh @@ -3,7 +3,7 @@ # # fix-http-to-https.sh — Replace HTTP URLs with HTTPS # -# Fixes PA014 InsecureProtocol findings where http:// is used. +# Fixes PA014 (InsecureProtocol) findings where http:// is used. # Upgrades to https:// where safe to do so. # # Uses git ls-files for fast traversal and hash-based caching to skip diff --git a/scripts/fix-innerhtml.sh b/scripts/fix-innerhtml.sh index 2602fac3..5bbfa7dd 100755 --- a/scripts/fix-innerhtml.sh +++ b/scripts/fix-innerhtml.sh @@ -3,7 +3,8 @@ # # fix-innerhtml.sh — Replace innerHTML with textContent to prevent XSS # -# Fixes PA014 InnerHTML / XSS findings. +# Fixes innerHTML / XSS DOM-sink findings: panic-attack rule +# PA011 (DynamicCodeExecution), semantic class improper-neutralization-of-html-input. # innerHTML allows script injection; textContent is safe for text display. # # Usage: fix-innerhtml.sh diff --git a/scripts/fix-resource-leak.sh b/scripts/fix-resource-leak.sh index e0207bd6..a101c498 100755 --- a/scripts/fix-resource-leak.sh +++ b/scripts/fix-resource-leak.sh @@ -3,7 +3,7 @@ # # fix-resource-leak.sh — Annotate potential resource leaks # -# Fixes PA020 ResourceLeak findings. +# Fixes PA008 (ResourceLeak) findings. # Adds annotations where file handles, connections, or streams may not be closed. # # Usage: fix-resource-leak.sh diff --git a/scripts/fix-secret-to-env.sh b/scripts/fix-secret-to-env.sh index 15bc9e2b..b433ab0d 100755 --- a/scripts/fix-secret-to-env.sh +++ b/scripts/fix-secret-to-env.sh @@ -4,7 +4,7 @@ # # fix-secret-to-env.sh — Replace hardcoded secrets with environment variable lookups # -# Fixes PA013 HardcodedSecret findings. +# Fixes PA017 (HardcodedSecret) findings. # Detects hardcoded passwords, API keys, and tokens, replaces with env var refs. # # Usage: fix-secret-to-env.sh diff --git a/scripts/fix-shell-quoting.sh b/scripts/fix-shell-quoting.sh index 1837eeb7..6e804549 100755 --- a/scripts/fix-shell-quoting.sh +++ b/scripts/fix-shell-quoting.sh @@ -4,7 +4,8 @@ # fix-shell-quoting.sh — Quote unquoted shell variable expansions # # Fixes patterns like: $VAR → "${VAR}" in shell scripts -# Targets PA009-shell-unquoted-var findings. +# Targets Hypatia pattern `PA009-shell-unquoted-var`; panic-attack rule +# PA009 (CommandInjection). # # Usage: fix-shell-quoting.sh # outcome-predicate: --check diff --git a/scripts/fix-sql-parameterize.sh b/scripts/fix-sql-parameterize.sh index 0ee849a5..e5b59682 100755 --- a/scripts/fix-sql-parameterize.sh +++ b/scripts/fix-sql-parameterize.sh @@ -3,7 +3,8 @@ # # fix-sql-parameterize.sh — Replace string-interpolated SQL with parameterized queries # -# Fixes PA012 SQLInjection findings. +# Fixes SQLInjection findings. No panic-attack rule ID covers SQL injection +# (it is a taint sink in panic-attack's kanren analysis, not a category). # Adds warning annotations and performs safe mechanical replacements where possible. # # Usage: fix-sql-parameterize.sh diff --git a/scripts/fix-tmp-paths.sh b/scripts/fix-tmp-paths.sh index b8234c49..4b3a1292 100755 --- a/scripts/fix-tmp-paths.sh +++ b/scripts/fix-tmp-paths.sh @@ -3,7 +3,7 @@ # # fix-tmp-paths.sh — Replace hardcoded /tmp/ paths with mktemp # -# Fixes PA016 PathTraversal findings where /tmp/ is hardcoded. +# Fixes PA016 (PathTraversal) findings where /tmp/ is hardcoded. # Adds trap cleanup and uses mktemp -d for temp directories. # # Usage: fix-tmp-paths.sh diff --git a/scripts/fix-todo-markers.sh b/scripts/fix-todo-markers.sh index 0bdc51b3..b5d8e2e8 100755 --- a/scripts/fix-todo-markers.sh +++ b/scripts/fix-todo-markers.sh @@ -3,7 +3,8 @@ # # fix-todo-markers.sh — Resolve unchecked TODO/FIXME markers # -# Fixes PA018-unchecked-todo findings. +# Fixes Hypatia pattern `PA018-unchecked-todo`; no panic-attack rule ID +# covers TODO markers. # Converts bare TODOs to structured FILL markers or resolves them. # # Uses git ls-files for fast traversal and hash-based caching to skip diff --git a/scripts/fix-unbounded-loop.sh b/scripts/fix-unbounded-loop.sh index 5d17a0c2..d9cf2a24 100755 --- a/scripts/fix-unbounded-loop.sh +++ b/scripts/fix-unbounded-loop.sh @@ -3,7 +3,7 @@ # # fix-unbounded-loop.sh — Add TODO comments above unbounded loops # -# Fixes PA019 findings: unbounded loops that could cause resource exhaustion. +# Fixes PA002 (UnboundedLoop) findings: unbounded loops that could cause resource exhaustion. # Detects unbounded loops in Rust, Elixir, and shell scripts, inserting # advisory TODO comments above them so developers add iteration limits # or break conditions. @@ -28,7 +28,7 @@ FINDING_JSON="${2:?Missing finding JSON file}" TODO_RUST="// TODO: add break condition or iteration limit to prevent resource exhaustion" TODO_SHELL="# TODO: add iteration limit (e.g., MAX_ITER) to prevent infinite loop" -echo "=== Unbounded Loop Fix (PA019) ===" +echo "=== Unbounded Loop Fix: PA002 (UnboundedLoop) ===" echo " Repo: $REPO_PATH" echo "" diff --git a/scripts/fix-unchecked-error.sh b/scripts/fix-unchecked-error.sh index 625bec5a..1f90d13d 100755 --- a/scripts/fix-unchecked-error.sh +++ b/scripts/fix-unchecked-error.sh @@ -3,7 +3,7 @@ # # fix-unchecked-error.sh — Annotate unchecked error returns # -# Fixes PA019 UncheckedError findings. +# Fixes PA018 (UncheckedError) findings. # Adds annotations where function return values are silently discarded. # # Usage: fix-unchecked-error.sh diff --git a/tests/panic-attack-rule-ids-test.sh b/tests/panic-attack-rule-ids-test.sh new file mode 100755 index 00000000..3f3c0fe6 --- /dev/null +++ b/tests/panic-attack-rule-ids-test.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# PA rule IDs in this repo are panic-attack's, not a second numbering. +# +# 1. bots/panicbot/canon/canonical-rule-ids.json matches the sha256 in +# bots/panicbot/canon/pin.toml (a copy edited in place fails). +# 2. Every PA rule ID a fix script cites is written `PAnnn (Category)` and +# agrees with that table. Hypatia pattern IDs (`PA009-shell-unquoted-var`) +# are a separate namespace and must be back-quoted, so the two cannot be +# mistaken for each other. +# +# panicbot's translator is held to the same table by +# bots/panicbot/tests/canonical_rule_ids_test.rs. +# +# --upstream additionally compares the pinned copy with panic-attack main +# (network; not run in CI, where a moved upstream should be a reviewed re-pin). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CANON="$ROOT/bots/panicbot/canon" +TABLE="$CANON/canonical-rule-ids.json" +SCRIPTS="${FIX_SCRIPTS_DIR:-$ROOT/scripts}" + +fail=0 +err() { echo "FAIL: $*" >&2; fail=1; } + +pinned=$(sed -n 's/^sha256 = "\([0-9a-f]\{64\}\)"$/\1/p' "$CANON/pin.toml") +actual=$(sha256sum "$TABLE" | cut -d' ' -f1) +[[ "$pinned" == "$actual" ]] || err "canonical-rule-ids.json hashes $actual, pin.toml says $pinned" + +declare -A category_of +while IFS=$'\t' read -r id cat; do + category_of[$id]=$cat +done < <(jq -r '.rules[] | [.rule_id, .category] | @tsv' "$TABLE") +[[ ${#category_of[@]} -gt 0 ]] || err "no rules read from $TABLE" + +checked=0 +# Only lines that mention a PA code: one grep over all scripts, not a +# subshell per line. +while IFS=: read -r f lineno line; do + # Back-quoted spans are Hypatia pattern IDs, not panic-attack rule IDs. + # shellcheck disable=SC2016 # the backticks are literal, not command substitution + bare=$(printf '%s' "$line" | sed 's/`[^`]*`//g') + while read -r ref; do + [[ -n "$ref" ]] || continue + checked=$((checked + 1)) + id=${ref%% *} + cat=$(printf '%s' "$ref" | sed -n 's/^PA[0-9]\{3\}b\{0,1\} (\([A-Za-z]*\))$/\1/p') + if [[ -z "$cat" ]]; then + err "$(basename "$f"):$lineno: $id is not written as 'PAnnn (Category)' (back-quote Hypatia pattern IDs)" + elif [[ -z "${category_of[$id]:-}" ]]; then + err "$(basename "$f"):$lineno: $id is not a panic-attack rule ID" + elif [[ "${category_of[$id]}" != "$cat" ]]; then + err "$(basename "$f"):$lineno: $id is ${category_of[$id]} in panic-attack, not $cat" + fi + done < <(printf '%s\n' "$bare" | grep -oE '\bPA[0-9]{3}b?\b( \([A-Za-z]*\))?' || true) +done < <(grep -nE '\bPA[0-9]{3}b?\b' "$SCRIPTS"/fix-*.sh || true) +echo "checked $checked PA reference(s) in $(basename "$SCRIPTS")/fix-*.sh" + +if [[ "${1:-}" == "--upstream" ]]; then + repo=$(sed -n 's/^repo = "\(.*\)"$/\1/p' "$CANON/pin.toml") + path=$(sed -n 's/^path = "\(.*\)"$/\1/p' "$CANON/pin.toml") + upstream=$(curl -sSfL --max-time 30 "https://raw.githubusercontent.com/$repo/main/$path" | sha256sum | cut -d' ' -f1) + [[ "$upstream" == "$pinned" ]] || err "panic-attack main's table hashes $upstream; re-pin (see pin.toml)" +fi + +[[ "$fail" -eq 0 ]] && echo "PASS: PA rule IDs agree with panic-attack's table" +exit "$fail" From ed1d08d03e4b7af6d3043229af9925ff141c7790 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 00:02:39 +0000 Subject: [PATCH 3/3] test(outcomes): run the runner's own run_entry and post-loop drain Section 8 drove count/fold_counts directly, so dropping IN_JOB=1 from run_entry, or the fold_counts after the post-loop wait, left the suite green while --parallel summaries read zero (CodeRabbit on #622). Section 9 extracts the real run_entry and drain from dispatch-runner.sh, as section 4 extracts record_outcome, and runs five stubbed entries at --parallel 1 and 2. The main loop itself stays behind the automation quarantine (exit 78); this test does not bypass it. Both mutations above now fail the suite (41 checks). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PpQVLrCbURpoXtrdvy8V3B --- tests/outcome-verdict-test.sh | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/outcome-verdict-test.sh b/tests/outcome-verdict-test.sh index a7e43ada..00997731 100755 --- a/tests/outcome-verdict-test.sh +++ b/tests/outcome-verdict-test.sh @@ -19,6 +19,8 @@ # 7. The E1 replay (audit-fix-outcomes.sh) counts as the fixers do, and # reads NA when git grep cannot run. # 8. Run counters from background jobs reach the parent (--parallel). +# 9. The runner's own run_entry and post-loop drain keep those counts +# (extracted; the quarantined main loop is not run). set -euo pipefail @@ -228,6 +230,32 @@ fold_counts check "background counts reach the parent" "EXECUTED=3 SUCCEEDED=1 FAILED=2 SKIPPED=1" \ "EXECUTED=$EXECUTED SUCCEEDED=$SUCCEEDED FAILED=$FAILED SKIPPED=$SKIPPED" +# --- 9. the loop's own job wrapper and drain ------------------------------ +# Section 8 drives count/fold_counts directly. This runs the runner's real +# run_entry (which must start jobs with IN_JOB=1) and its post-loop drain +# (which must wait, then fold), extracted as above. The main loop itself is +# not run: it sits behind the automation quarantine (exit 78), which this +# test does not bypass. execute_entry is a stub that counts what it is given. +{ + awk '/^run_entry\(\) \{/,/^}/' "$ROOT/scripts/dispatch-runner.sh" + awk '/^# Wait for all background jobs to finish/,/^fi/' "$ROOT/scripts/dispatch-runner.sh" \ + | sed '1s/.*/drain() {/; $s/^fi$/fi; }/' +} > "$TMP/loop.sh" +check "run_entry and the drain were extracted" 2 "$(grep -cE '^(run_entry|drain)\(\) \{' "$TMP/loop.sh")" +# shellcheck disable=SC1091 +source "$TMP/loop.sh" +execute_entry() { sleep 0.1; count EXECUTED; count "$1"; } +for PARALLEL in 1 2; do + # shellcheck disable=SC2034 # read by the sourced run_entry + PARALLEL_TMPDIR="$TMP/loop-$PARALLEL" ACTIVE_JOBS=0 + mkdir -p "$PARALLEL_TMPDIR" + EXECUTED=0 SUCCEEDED=0 FAILED=0 SKIPPED=0 + for verdict in FAILED SUCCEEDED FAILED SKIPPED SUCCEEDED; do run_entry "$verdict"; done + drain + check "dispatch loop counts with --parallel $PARALLEL" "EXECUTED=5 SUCCEEDED=2 FAILED=2 SKIPPED=1" \ + "EXECUTED=$EXECUTED SUCCEEDED=$SUCCEEDED FAILED=$FAILED SKIPPED=$SKIPPED" +done + echo "" echo "Results: $pass passed, $fail failed" [[ "$fail" -eq 0 ]]