Skip to content

Commit dd91585

Browse files
hyperpolymathclaudecoderabbitai[bot]
authored
fix(assail): make assail reports deterministic (#245)
Follow-up to #237 (JCS reports), as noted in that PR. ## Problem Running `assail` twice on the same input gave **different reports**. On `main`, six runs of `examples/vulnerable_program.rs` produced six distinct reports. The cause is that these fields were built from `HashSet`/`HashMap` iteration order, which Rust randomises per run: - `frameworks` - `recommended_attacks` - the taint-matrix rows - the dependency-graph edges, despite a code comment claiming "deterministic output" - the test-only-function scan JCS sorts object keys but, by design, leaves array order alone. So since #237, the canonical JSON, and any hash over it such as the `--attest` seal, differed for identical input. ## Fix - Those five sites now use `BTreeSet`/`BTreeMap`. - `AttackAxis`, `Framework` and `WeakPointCategory` now derive `PartialOrd` and `Ord` (declaration order). The derives are additive. ## Evidence - After the fix, runs are byte-identical: - six runs on the example file; - three runs on `examples/`; - three runs on the whole `src/` tree. - **New test `test_assail_report_is_deterministic`.** It builds a multi-directory fixture whose `Cargo.toml` triggers several frameworks and asserts that nine analyses give identical canonical JSON. Every `HashMap` in a process gets its own random seed, so this catches regressions within a single test run. - **The test is not vacuous.** With the old `HashMap` code restored, it fails with "assail report differs between runs". ## Verification - `cargo fmt --check`: clean. - `cargo build --locked`: no warnings. - `cargo clippy --locked --all-features --all-targets -- -D warnings`: clean. - `cargo test --locked --all-features --no-fail-fast`: 693 passed, 0 failed. --- _Generated by [Claude Code](https://claude.ai/code/session_01PFaqv6iBXrt3W5Akd1Fq5k)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent cb50e1b commit dd91585

4 files changed

Lines changed: 94 additions & 10 deletions

File tree

‎CHANGELOG.adoc‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
=== [Unreleased]
44

5+
==== Fixed — assail reports are deterministic
6+
7+
* The same input now yields the same report on every run. `+frameworks+`,
8+
`+recommended_attacks+`, the taint-matrix rows and dependency-graph edges were
9+
built from `+HashSet+`/`+HashMap+` iteration order, which changes per run, so
10+
the canonical JSON (and any hash over it, such as the `+--attest+` seal)
11+
differed for identical input. They now use `+BTreeSet+`/`+BTreeMap+` (enum
12+
declaration order via derived `+Ord+` on `+AttackAxis+`, `+Framework+` and
13+
`+WeakPointCategory+`). A regression test runs the analysis nine times.
14+
515
==== Added — KYAML report output
616

717
* `+--output-format kyaml+` writes reports in KYAML, the estate YAML dialect

‎src/assail/analyzer.rs‎

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use crate::types::*;
1212
use anyhow::Result;
1313
use regex::Regex;
1414
use std::cell::RefCell;
15-
use std::collections::{HashMap, HashSet};
15+
use std::collections::{BTreeMap, BTreeSet, HashSet};
1616
use std::fs;
1717
use std::io::Read;
1818
use std::path::{Path, PathBuf};
@@ -4128,12 +4128,15 @@ impl Analyzer {
41284128
(test_ptr_ops, test_c_imports)
41294129
}
41304130

4131-
/// Detect functions that are only called from test blocks (test-only helper functions)
4131+
/// Return candidate Zig test-helper names containing `scan` in lexical order.
4132+
///
4133+
/// Names come from recognised definitions and call patterns; this heuristic
4134+
/// does not verify that they are called exclusively from test blocks.
41324135
fn detect_test_only_helper_functions(&self, content: &str) -> Vec<String> {
41334136
use std::collections::HashSet;
41344137

41354138
let mut test_only_functions = Vec::new();
4136-
let mut function_calls = std::collections::HashMap::new();
4139+
let mut function_calls = std::collections::BTreeMap::new();
41374140
let mut in_test_block = false;
41384141
let mut current_function = String::new();
41394142

@@ -5301,8 +5304,14 @@ impl Analyzer {
53015304
// Framework detection (expanded)
53025305
// ============================================================
53035306

5307+
/// Detect framework hints from manifests and supported source import patterns.
5308+
///
5309+
/// Manifests are read from the target directory, or a file target's parent.
5310+
/// Returns unique frameworks in enum declaration order; Rust detection uses
5311+
/// manifests only. Reads at most 4 MiB per manifest and 64 MiB per source file,
5312+
/// skipping files with I/O or UTF-8 decoding errors. Always returns `Ok`.
53045313
fn detect_frameworks(&self, files: &[PathBuf]) -> Result<Vec<Framework>> {
5305-
let mut frameworks = HashSet::new();
5314+
let mut frameworks = BTreeSet::new();
53065315

53075316
// Primary signal: dependency manifest files. These are the most reliable
53085317
// because they declare actual dependencies, not just keyword mentions.
@@ -5579,12 +5588,17 @@ impl Analyzer {
55795588
Ok(frameworks.into_iter().collect())
55805589
}
55815590

5591+
/// Combine weak-point attack recommendations with aggregate activity heuristics.
5592+
///
5593+
/// Returns unique axes in enum declaration order, always including CPU.
5594+
/// Adds memory for more than 10 allocation sites, disk for more than five
5595+
/// I/O operations, and concurrency for more than three threading constructs.
55825596
fn generate_recommendations(
55835597
&self,
55845598
weak_points: &[WeakPoint],
55855599
stats: &ProgramStatistics,
55865600
) -> Vec<AttackAxis> {
5587-
let mut recommendations = HashSet::new();
5601+
let mut recommendations = BTreeSet::new();
55885602

55895603
// Base recommendations come from weak-point categories.
55905604
for wp in weak_points {
@@ -5609,12 +5623,18 @@ impl Analyzer {
56095623
recommendations.into_iter().collect()
56105624
}
56115625

5626+
/// Build heuristic edges between consecutive files sharing a directory and
5627+
/// from each file to every supplied framework.
5628+
///
5629+
/// Shared-directory edges have weight 1.0 and follow directory path order,
5630+
/// retaining input file order within each directory. Framework edges follow
5631+
/// input file and framework order, with risk-based weights of at least 1.0.
56125632
fn build_dependency_graph(
56135633
file_statistics: &[FileStatistics],
56145634
frameworks: &[Framework],
56155635
) -> DependencyGraph {
56165636
let mut edges = Vec::new();
5617-
let mut dir_groups: HashMap<String, Vec<String>> = HashMap::new();
5637+
let mut dir_groups: BTreeMap<String, Vec<String>> = BTreeMap::new();
56185638

56195639
// Group by directory first to approximate local import neighborhoods.
56205640
for stat in file_statistics {
@@ -5662,8 +5682,15 @@ impl Analyzer {
56625682
DependencyGraph { edges }
56635683
}
56645684

5685+
/// Group weak points by category and recommended attack axis, returning rows
5686+
/// in category then axis enum declaration order.
5687+
///
5688+
/// Each row retains the highest numeric severity and all contributing
5689+
/// locations in input order, including duplicates; missing locations become
5690+
/// `"unknown"`. Every row includes all supplied frameworks. Weak points
5691+
/// without recommended attacks contribute no rows.
56655692
fn build_taint_matrix(weak_points: &[WeakPoint], frameworks: &[Framework]) -> TaintMatrix {
5666-
let mut matrix: HashMap<(WeakPointCategory, AttackAxis), TaintMatrixRow> = HashMap::new();
5693+
let mut matrix: BTreeMap<(WeakPointCategory, AttackAxis), TaintMatrixRow> = BTreeMap::new();
56675694

56685695
// Rows are keyed by source category x sink axis to enable pivot-friendly reporting.
56695696
for wp in weak_points {

‎src/types.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ impl Language {
247247
}
248248

249249
/// Application frameworks detected in the codebase
250-
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
250+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
251251
#[allow(clippy::upper_case_acronyms)]
252252
pub enum Framework {
253253
WebServer,
@@ -282,7 +282,7 @@ pub struct DependencyGraph {
282282
}
283283

284284
/// Attack axes for stress testing
285-
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
285+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
286286
#[serde(rename_all = "lowercase")]
287287
pub enum AttackAxis {
288288
Cpu,
@@ -399,7 +399,7 @@ impl WeakPoint {
399399
}
400400
}
401401

402-
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
402+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
403403
pub enum WeakPointCategory {
404404
// Original categories
405405
UncheckedAllocation,

‎tests/analyzer_tests.rs‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,3 +474,50 @@ fn test_js_ffi_eval_symbol_not_flagged() -> Result<(), Box<dyn std::error::Error
474474
assert!(flagged2, "genuine JS eval() must still be flagged");
475475
Ok(())
476476
}
477+
478+
/// Repeated analysis of the same tree must give byte-identical canonical
479+
/// JSON. Each `HashMap`/`HashSet` gets its own random seed, so running the
480+
/// analysis several times in one process catches any report field built
481+
/// from hash iteration order (frameworks, recommended attacks, the taint
482+
/// matrix, dependency-graph edges).
483+
#[test]
484+
fn test_assail_report_is_deterministic() -> Result<(), Box<dyn std::error::Error>> {
485+
let dir = TempDir::new()?;
486+
fs::write(
487+
dir.path().join("Cargo.toml"),
488+
"[package]\nname = \"det\"\n\n[dependencies]\ntokio = \"1\"\nrayon = \"1\"\naxum = \"0.7\"\nsqlx = \"0.8\"\n",
489+
)?;
490+
for sub in ["src", "src/net", "src/db"] {
491+
fs::create_dir_all(dir.path().join(sub))?;
492+
}
493+
let body = "use std::thread;\nfn main() {\n let v: Option<u8> = None;\n v.unwrap();\n unsafe { std::ptr::null::<u8>().read() };\n thread::spawn(|| panic!(\"boom\"));\n let _ = std::fs::read(\"x\").expect(\"io\");\n let _ = vec![0u8; 1 << 20];\n}\n";
494+
for file in [
495+
"src/main.rs",
496+
"src/lib.rs",
497+
"src/net/a.rs",
498+
"src/net/b.rs",
499+
"src/db/c.rs",
500+
"src/db/d.rs",
501+
] {
502+
fs::write(dir.path().join(file), body)?;
503+
}
504+
505+
let first = panic_attack::json::to_canonical_string(&assail::analyze(dir.path())?)?;
506+
let parsed: serde_json::Value = serde_json::from_str(&first)?;
507+
assert!(
508+
parsed["frameworks"].as_array().is_some_and(|f| f.len() > 1),
509+
"fixture should detect several frameworks: {}",
510+
parsed["frameworks"]
511+
);
512+
assert!(
513+
parsed["recommended_attacks"]
514+
.as_array()
515+
.is_some_and(|a| a.len() > 1),
516+
"fixture should recommend several attacks"
517+
);
518+
for _ in 0..8 {
519+
let again = panic_attack::json::to_canonical_string(&assail::analyze(dir.path())?)?;
520+
assert_eq!(first, again, "assail report differs between runs");
521+
}
522+
Ok(())
523+
}

0 commit comments

Comments
 (0)