Skip to content

Commit db603c3

Browse files
hyperpolymathclaude
andcommitted
feat: multi-lockfile, OSV retry, language-lock, attest verify, Rule 14, groove versioning
Patch Bridge multi-lockfile (bridge/lockfile.rs, bridge/mod.rs): Add parsers for mix.lock (Hex/Elixir), package-lock.json (npm v1/v2/v3), and requirements.txt (PyPI exact pins only). discover_and_parse() tries all four lockfiles and merges results; triage() no longer bails on non-Rust repos. OSV API resilience (bridge/intelligence.rs): osv_post_with_retry() wraps the ureq POST with 3 attempts and exponential backoff (1s, 2s, 4s). Connection errors and 5xx retry; 4xx return immediately. Framework language-lock (assail/patterns.rs): Phoenix, OTP, and Ecto (Cowboy via OTP) patterns now guarded by is_beam check. Prevents BEAM-specific attack patterns firing on JS/Rust files in polyglot repos that share a top-level mix.exs. Attestation verify subcommand (attestation/mod.rs, main.rs): panic-attack attest verify <file.attestation.json> recomputes the chain hash from intent/evidence/report hashes and checks it matches seal.chain_hash. Ed25519 signature verification available with --features signing. Kanren Rule 14 — scanner source literal suppression (kanren/core.rs): suppress_scanner_source_literals: suppresses PanicPath on files tagged scanner_source (path contains patterns/analyzer/scanner/detector AND unwrap_calls/lines >= 0.10), preventing detector string literals from producing false PanicPath findings when scanning a code-analysis tool. Groove capability versioning (groove.rs): Service manifest gains capability_version fields per capability and a batch_scan capability entry; applicability expanded to include "fleet". Fix schema_version/safe_unwrap_calls in 9 test files (all 218 lib tests pass). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9609d93 commit db603c3

15 files changed

Lines changed: 724 additions & 47 deletions

File tree

src/a2ml/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,6 +829,7 @@ mod tests {
829829

830830
fn sample_assail_report() -> AssailReport {
831831
AssailReport {
832+
schema_version: "2.5".to_string(),
832833
program_path: PathBuf::from("src/main.rs"),
833834
language: Language::Rust,
834835
frameworks: vec![Framework::Unknown],
@@ -850,6 +851,7 @@ mod tests {
850851
allocation_sites: 0,
851852
io_operations: 0,
852853
threading_constructs: 0,
854+
safe_unwrap_calls: 0,
853855
},
854856
file_statistics: vec![FileStatistics {
855857
file_path: "src/main.rs".to_string(),
@@ -861,6 +863,7 @@ mod tests {
861863
io_operations: 0,
862864
threading_constructs: 0,
863865
ffi_safe_wrapper: false,
866+
safe_unwrap_calls: 0,
864867
}],
865868
recommended_attacks: vec![AttackAxis::Concurrency],
866869
dependency_graph: DependencyGraph::default(),
@@ -898,6 +901,7 @@ mod tests {
898901

899902
fn sample_ambush_report() -> AssaultReport {
900903
AssaultReport {
904+
schema_version: "2.5".to_string(),
901905
assail_report: sample_assail_report(),
902906
attack_results: sample_attack_results(),
903907
total_crashes: 1,
@@ -994,6 +998,7 @@ mod tests {
994998

995999
fn sample_adjudicate_report() -> adjudicate::AdjudicateReport {
9961000
adjudicate::AdjudicateReport {
1001+
schema_version: "2.5".to_string(),
9971002
created_at: chrono::Utc::now().to_rfc3339(),
9981003
reports: vec![
9991004
PathBuf::from("reports/a.json"),

src/assail/analyzer.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6107,6 +6107,7 @@ pub fn call(compiled: &CompiledFunction, args: &[i64]) -> Option<i64> {
61076107
allocation_sites: 0,
61086108
io_operations: 0,
61096109
threading_constructs: 0,
6110+
safe_unwrap_calls: 0,
61106111
};
61116112
let mut weak_points = Vec::new();
61126113
analyzer
@@ -6161,6 +6162,7 @@ fn pun_u64_to_two_u32s(x: u64) -> (u32, u32) {
61616162
allocation_sites: 0,
61626163
io_operations: 0,
61636164
threading_constructs: 0,
6165+
safe_unwrap_calls: 0,
61646166
};
61656167
let mut weak_points = Vec::new();
61666168
analyzer
@@ -6212,6 +6214,7 @@ fn pun_in_jit_file(x: u64) -> [u8; 8] {
62126214
allocation_sites: 0,
62136215
io_operations: 0,
62146216
threading_constructs: 0,
6217+
safe_unwrap_calls: 0,
62156218
};
62166219
let mut weak_points = Vec::new();
62176220
analyzer
@@ -6260,6 +6263,7 @@ fn inspect_token(token: &str) -> TokenData<Claims> {
62606263
allocation_sites: 0,
62616264
io_operations: 0,
62626265
threading_constructs: 0,
6266+
safe_unwrap_calls: 0,
62636267
};
62646268
let mut weak_points = Vec::new();
62656269
analyzer

src/assail/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,7 @@ mod classifications_tests {
572572
);
573573

574574
let mut report = AssailReport {
575+
schema_version: "2.5".to_string(),
575576
program_path: tmp.path().to_path_buf(),
576577
language: Language::Rust,
577578
frameworks: vec![],
@@ -605,6 +606,7 @@ mod classifications_tests {
605606
allocation_sites: 0,
606607
io_operations: 0,
607608
threading_constructs: 0,
609+
safe_unwrap_calls: 0,
608610
},
609611
file_statistics: vec![],
610612
recommended_attacks: vec![],

src/assail/patterns.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,21 @@ impl PatternDetector {
4343
_ => {}
4444
}
4545

46-
// Framework-specific patterns
46+
// Framework-specific patterns, with language-lock guards.
47+
// Phoenix / OTP / Ecto are BEAM-ecosystem frameworks — they must not
48+
// fire on non-BEAM languages even if the framework was detected from
49+
// a top-level mix.exs that sits alongside a polyglot source tree.
50+
let is_beam = matches!(
51+
language,
52+
Language::Elixir | Language::Erlang | Language::Gleam
53+
);
4754
for framework in frameworks {
4855
match framework {
4956
Framework::WebServer => patterns.extend(Self::webserver_patterns()),
5057
Framework::Database => patterns.extend(Self::database_patterns()),
5158
Framework::Concurrent => patterns.extend(Self::concurrency_patterns()),
52-
Framework::Phoenix => patterns.extend(Self::phoenix_patterns()),
53-
Framework::OTP => patterns.extend(Self::otp_patterns()),
59+
Framework::Phoenix if is_beam => patterns.extend(Self::phoenix_patterns()),
60+
Framework::OTP if is_beam => patterns.extend(Self::otp_patterns()),
5461
_ => {}
5562
}
5663
}

src/attestation/mod.rs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,145 @@ pub use evidence::{EvidenceAccumulator, ExecutionEvidence};
3737
pub use intent::ExecutionIntent;
3838
#[allow(unused_imports)]
3939
pub use seal::ReportSeal;
40+
41+
// ============================================================================
42+
// Verification
43+
// ============================================================================
44+
45+
/// Outcome of an attestation verification run.
46+
#[derive(Debug)]
47+
pub enum VerifyResult {
48+
/// All chain invariants pass; signature verified if present.
49+
Ok {
50+
issuer: String,
51+
issued_at: String,
52+
chain_hash: String,
53+
signature_verified: bool,
54+
},
55+
/// One or more chain invariants failed.
56+
Failed(Vec<String>),
57+
}
58+
59+
/// Verify an attestation sidecar file.
60+
///
61+
/// Reads the JSON file at `path`, recomputes the chain hash from the stored
62+
/// intent/evidence/report hashes, and checks that it matches `seal.chain_hash`.
63+
/// If `seal.signature` and `seal.public_key` are present, also verifies the
64+
/// Ed25519 signature over the chain hash.
65+
///
66+
/// Returns `VerifyResult::Ok` when all checks pass, `VerifyResult::Failed`
67+
/// with a list of failure reasons otherwise.
68+
pub fn verify_attestation_file(path: &std::path::Path) -> anyhow::Result<VerifyResult> {
69+
use sha2::{Digest, Sha256};
70+
71+
let content = std::fs::read_to_string(path)
72+
.map_err(|e| anyhow::anyhow!("reading {}: {}", path.display(), e))?;
73+
74+
let envelope: A2mlEnvelope = serde_json::from_str(&content)
75+
.map_err(|e| anyhow::anyhow!("parsing attestation envelope: {}", e))?;
76+
77+
let chain = &envelope.attestation;
78+
let seal = &chain.seal;
79+
let mut failures = Vec::new();
80+
81+
// Recompute chain_hash = SHA-256(intent_hash || evidence_hash || report_hash)
82+
let mut hasher = Sha256::new();
83+
hasher.update(seal.intent_hash.as_bytes());
84+
hasher.update(seal.evidence_hash.as_bytes());
85+
hasher.update(seal.report_hash.as_bytes());
86+
let computed_chain_hash = hex::encode(hasher.finalize());
87+
88+
if computed_chain_hash != seal.chain_hash {
89+
failures.push(format!(
90+
"chain_hash mismatch: stored={} computed={}",
91+
seal.chain_hash, computed_chain_hash
92+
));
93+
}
94+
95+
// decision_hash in the envelope must match seal.report_hash
96+
if envelope.decision_hash != seal.report_hash {
97+
failures.push(format!(
98+
"decision_hash mismatch: envelope={} seal={}",
99+
envelope.decision_hash, seal.report_hash
100+
));
101+
}
102+
103+
// Ed25519 signature verification (requires `signing` feature)
104+
let signature_verified = verify_signature(seal, &mut failures);
105+
106+
if !failures.is_empty() {
107+
return Ok(VerifyResult::Failed(failures));
108+
}
109+
110+
Ok(VerifyResult::Ok {
111+
issuer: envelope.issuer.clone(),
112+
issued_at: envelope.issued_at.clone(),
113+
chain_hash: seal.chain_hash.clone(),
114+
signature_verified,
115+
})
116+
}
117+
118+
#[cfg(feature = "signing")]
119+
fn verify_signature(seal: &ReportSeal, failures: &mut Vec<String>) -> bool {
120+
use ed25519_dalek::{Signature, VerifyingKey};
121+
122+
let (sig_hex, pk_hex) = match (&seal.signature, &seal.public_key) {
123+
(Some(s), Some(k)) => (s, k),
124+
_ => return false, // no signature present — skip
125+
};
126+
127+
let sig_bytes = match hex::decode(sig_hex) {
128+
Ok(b) => b,
129+
Err(_) => {
130+
failures.push("signature field is not valid hex".to_string());
131+
return false;
132+
}
133+
};
134+
135+
let pk_bytes = match hex::decode(pk_hex) {
136+
Ok(b) => b,
137+
Err(_) => {
138+
failures.push("public_key field is not valid hex".to_string());
139+
return false;
140+
}
141+
};
142+
143+
let sig_array: [u8; 64] = match sig_bytes.try_into() {
144+
Ok(a) => a,
145+
Err(_) => {
146+
failures.push("signature must be 64 bytes".to_string());
147+
return false;
148+
}
149+
};
150+
151+
let pk_array: [u8; 32] = match pk_bytes.try_into() {
152+
Ok(a) => a,
153+
Err(_) => {
154+
failures.push("public_key must be 32 bytes".to_string());
155+
return false;
156+
}
157+
};
158+
159+
let verifying_key = match VerifyingKey::from_bytes(&pk_array) {
160+
Ok(k) => k,
161+
Err(e) => {
162+
failures.push(format!("invalid Ed25519 public key: {}", e));
163+
return false;
164+
}
165+
};
166+
167+
let signature = Signature::from_bytes(&sig_array);
168+
use ed25519_dalek::Verifier;
169+
match verifying_key.verify(seal.chain_hash.as_bytes(), &signature) {
170+
Ok(()) => true,
171+
Err(e) => {
172+
failures.push(format!("Ed25519 signature verification failed: {}", e));
173+
false
174+
}
175+
}
176+
}
177+
178+
#[cfg(not(feature = "signing"))]
179+
fn verify_signature(_seal: &ReportSeal, _failures: &mut Vec<String>) -> bool {
180+
false
181+
}

src/bridge/intelligence.rs

Lines changed: 75 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -136,33 +136,7 @@ pub fn query_osv_batch(deps: &[LockedDependency]) -> Result<Vec<Vulnerability>>
136136

137137
let body_bytes = serde_json::to_vec(&request)?;
138138

139-
let mut resp = ureq::post("https://api.osv.dev/v1/querybatch")
140-
.header("Content-Type", "application/json")
141-
.send(&body_bytes[..])
142-
.map_err(|e| anyhow::anyhow!("OSV API request failed: {}", e))?;
143-
144-
let status = resp.status().as_u16();
145-
if !(200..300).contains(&status) {
146-
// Cap error-body reads at 64 KiB — for error paths we only
147-
// need enough context to report, not the full OSV error blob.
148-
let buf = resp
149-
.body_mut()
150-
.with_config()
151-
.limit(64 * 1024)
152-
.read_to_string()
153-
.unwrap_or_default();
154-
anyhow::bail!("OSV API returned HTTP {}: {}", status, buf);
155-
}
156-
157-
// Cap success-body reads at 256 MiB. OSV batch responses for
158-
// ~1000-dep projects run a few MiB at most; 256 MiB is an order
159-
// of magnitude beyond any realistic response and prevents a
160-
// misbehaving or hostile OSV endpoint from exhausting memory.
161-
let response_text = resp
162-
.body_mut()
163-
.with_config()
164-
.limit(256 * 1024 * 1024)
165-
.read_to_string()?;
139+
let response_text = osv_post_with_retry(&body_bytes)?;
166140
let response: OsvBatchResponse = serde_json::from_str(&response_text)?;
167141

168142
// Map OSV results back to dependencies
@@ -177,6 +151,80 @@ pub fn query_osv_batch(deps: &[LockedDependency]) -> Result<Vec<Vulnerability>>
177151
Ok(all_vulns)
178152
}
179153

154+
// ============================================================================
155+
// Networking helpers
156+
// ============================================================================
157+
158+
const OSV_ENDPOINT: &str = "https://api.osv.dev/v1/querybatch";
159+
/// Maximum number of retry attempts for transient OSV API failures.
160+
const OSV_MAX_RETRIES: u32 = 3;
161+
162+
/// POST body bytes to the OSV batch endpoint with exponential-backoff retry.
163+
///
164+
/// Retries on connection errors and 5xx responses. 4xx errors are permanent
165+
/// failures (bad request / auth) and are returned immediately without retry.
166+
/// Delays: 1 s, 2 s, 4 s (capped at 3 attempts total).
167+
fn osv_post_with_retry(body_bytes: &[u8]) -> Result<String> {
168+
let mut last_err = anyhow::anyhow!("no attempts made");
169+
170+
for attempt in 0..OSV_MAX_RETRIES {
171+
if attempt > 0 {
172+
let delay = std::time::Duration::from_secs(1u64 << (attempt - 1));
173+
std::thread::sleep(delay);
174+
}
175+
176+
let send_result = ureq::post(OSV_ENDPOINT)
177+
.header("Content-Type", "application/json")
178+
.send(body_bytes);
179+
180+
match send_result {
181+
Err(e) => {
182+
// Connection-level error — always retry
183+
last_err = anyhow::anyhow!("OSV API request failed (attempt {}): {}", attempt + 1, e);
184+
log::warn!("[bridge] OSV attempt {}/{}: {}", attempt + 1, OSV_MAX_RETRIES, last_err);
185+
continue;
186+
}
187+
Ok(mut resp) => {
188+
let status = resp.status().as_u16();
189+
190+
if status >= 500 {
191+
// Server error — retry
192+
let buf = resp
193+
.body_mut()
194+
.with_config()
195+
.limit(64 * 1024)
196+
.read_to_string()
197+
.unwrap_or_default();
198+
last_err = anyhow::anyhow!("OSV API returned HTTP {} (attempt {}): {}", status, attempt + 1, buf);
199+
log::warn!("[bridge] OSV attempt {}/{}: HTTP {}", attempt + 1, OSV_MAX_RETRIES, status);
200+
continue;
201+
}
202+
203+
if !(200..300).contains(&status) {
204+
// 4xx — permanent failure, do not retry
205+
let buf = resp
206+
.body_mut()
207+
.with_config()
208+
.limit(64 * 1024)
209+
.read_to_string()
210+
.unwrap_or_default();
211+
anyhow::bail!("OSV API returned HTTP {}: {}", status, buf);
212+
}
213+
214+
// 2xx — success. Cap body at 256 MiB.
215+
let text = resp
216+
.body_mut()
217+
.with_config()
218+
.limit(256 * 1024 * 1024)
219+
.read_to_string()?;
220+
return Ok(text);
221+
}
222+
}
223+
}
224+
225+
Err(last_err)
226+
}
227+
180228
// ============================================================================
181229
// Mapping helpers
182230
// ============================================================================

0 commit comments

Comments
 (0)