Fixed 3 bugs in Aphoria's claim verification engine that were causing false positives in Maxwell validation testing: **Bug 1: Path matching + predicate filtering** - Added predicate filtering to prevent cross-predicate matches - Added path prefix matching to respect crate boundaries - Prevents core/imports/serde from matching hypervisor/vsock/imports/serde **Bug 2: Value-specific absent checks** - Absent mode now checks for specific forbidden value, not any observation - Example: "Clone absent" + "Debug present" = PASS (not CONFLICT) - Only conflicts when the exact forbidden value is found **Bug 3: Wildcard pattern support** - Wildcard patterns like message/*/derives now match multiple paths - Enhanced wildcard_matches() to support prefix/*/suffix patterns - Correctly strips full scheme+language from observation paths **Test coverage:** - All 39 existing tests passing - 3 new tests added for bug fixes - 2 tests updated to use correct predicates - Zero clippy warnings **Maxwell validation:** - maxwell-core-no-serde-001: CONFLICT → PASS (respects path boundaries) - maxwell-singleton-no-clone-001: CONFLICT → PASS (value-specific absent) - 5 claims now correctly show as MISSING (expose predicate mismatches) The fixes successfully eliminate false positives while exposing pre-existing issues where claims used incorrect predicates. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
530 lines
21 KiB
Rust
530 lines
21 KiB
Rust
//! SARIF output format for CI integration.
|
|
//!
|
|
//! SARIF (Static Analysis Results Interchange Format) v2.1.0 is supported by:
|
|
//! - GitHub Code Scanning
|
|
//! - GitLab SAST
|
|
//! - Azure DevOps
|
|
//!
|
|
//! Reference: <https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html>
|
|
|
|
use super::{object_value_display, verdict_label, ReportFormatter};
|
|
use crate::types::{ScanResult, Verdict};
|
|
|
|
/// SARIF report formatter for CI integration.
|
|
pub struct SarifReport;
|
|
|
|
impl ReportFormatter for SarifReport {
|
|
fn format(&self, result: &ScanResult) -> String {
|
|
// Build SARIF rules from unique conflict types
|
|
let mut rules = Vec::new();
|
|
let mut rule_indices: std::collections::HashMap<String, usize> =
|
|
std::collections::HashMap::new();
|
|
|
|
for conflict in &result.conflicts {
|
|
let rule_id = format!("aphoria/{}", extract_rule_id(&conflict.claim.concept_path));
|
|
if !rule_indices.contains_key(&rule_id) {
|
|
let idx = rules.len();
|
|
rule_indices.insert(rule_id.clone(), idx);
|
|
|
|
let level = match conflict.verdict {
|
|
Verdict::Block => "error",
|
|
Verdict::Flag | Verdict::Drift => "warning",
|
|
Verdict::Pass | Verdict::Ack => "note",
|
|
};
|
|
|
|
// Generate help URI based on RFC citation if available
|
|
let help_uri = conflict
|
|
.conflicts
|
|
.first()
|
|
.and_then(|s| s.rfc_citation.as_ref())
|
|
.map(|citation| {
|
|
if citation.starts_with("RFC ") {
|
|
let rfc_num = citation.strip_prefix("RFC ").unwrap_or("");
|
|
format!("https://www.rfc-editor.org/rfc/rfc{}", rfc_num)
|
|
} else if citation.starts_with("OWASP") {
|
|
"https://owasp.org/www-project-top-ten/".to_string()
|
|
} else {
|
|
format!(
|
|
"https://github.com/orchard9/aphoria/rules/{}",
|
|
extract_rule_id(&conflict.claim.concept_path)
|
|
)
|
|
}
|
|
})
|
|
.unwrap_or_else(|| {
|
|
format!(
|
|
"https://github.com/orchard9/aphoria/rules/{}",
|
|
extract_rule_id(&conflict.claim.concept_path)
|
|
)
|
|
});
|
|
|
|
rules.push(serde_json::json!({
|
|
"id": rule_id,
|
|
"shortDescription": {
|
|
"text": conflict.claim.description,
|
|
},
|
|
"defaultConfiguration": {
|
|
"level": level,
|
|
},
|
|
"helpUri": help_uri,
|
|
}));
|
|
}
|
|
}
|
|
|
|
// Build SARIF results
|
|
let results: Vec<serde_json::Value> = result
|
|
.conflicts
|
|
.iter()
|
|
.map(|conflict| {
|
|
let rule_id = format!("aphoria/{}", extract_rule_id(&conflict.claim.concept_path));
|
|
let rule_index = rule_indices.get(&rule_id).copied().unwrap_or(0);
|
|
|
|
let level = match conflict.verdict {
|
|
Verdict::Block => "error",
|
|
Verdict::Flag | Verdict::Drift => "warning",
|
|
Verdict::Pass | Verdict::Ack => "note",
|
|
};
|
|
|
|
// Build message with authoritative source details
|
|
let source_details: Vec<String> = conflict
|
|
.conflicts
|
|
.iter()
|
|
.map(|s| {
|
|
let mut detail = format!(
|
|
"{:?} (Tier {}): {}",
|
|
s.source_class,
|
|
s.source_class.tier(),
|
|
object_value_display(&s.value)
|
|
);
|
|
// Include policy source info if available
|
|
if let Some(ps) = &s.policy_source {
|
|
let signer = ps.signer_name.as_deref().unwrap_or(&ps.issuer_hex);
|
|
detail.push_str(&format!(
|
|
" [Source: {} v{} ({})",
|
|
ps.pack_name, ps.pack_version, signer
|
|
));
|
|
if let Some(contact) = &ps.contact {
|
|
detail.push_str(&format!(", Contact: {}", contact));
|
|
}
|
|
detail.push(']');
|
|
}
|
|
detail
|
|
})
|
|
.collect();
|
|
|
|
let message = format!(
|
|
"{}\nYour code: {} = {}\nAuthoritative: {}",
|
|
conflict.claim.description,
|
|
conflict.claim.predicate,
|
|
object_value_display(&conflict.claim.value),
|
|
source_details.join("; ")
|
|
);
|
|
|
|
let mut properties = serde_json::json!({
|
|
"conflict_score": conflict.conflict_score,
|
|
"verdict": verdict_label(conflict.verdict),
|
|
});
|
|
|
|
if let Some(breakdown) = &conflict.tier_breakdown {
|
|
let tb_json: Vec<serde_json::Value> = breakdown
|
|
.iter()
|
|
.map(|tb| {
|
|
serde_json::json!({
|
|
"tier": tb.tier,
|
|
"source_class": format!("{:?}", tb.source_class),
|
|
"assertion_count": tb.assertion_count,
|
|
"max_confidence": tb.max_confidence,
|
|
})
|
|
})
|
|
.collect();
|
|
properties["tier_breakdown"] = serde_json::json!(tb_json);
|
|
}
|
|
|
|
serde_json::json!({
|
|
"ruleId": rule_id,
|
|
"ruleIndex": rule_index,
|
|
"level": level,
|
|
"message": {
|
|
"text": message,
|
|
},
|
|
"locations": [{
|
|
"physicalLocation": {
|
|
"artifactLocation": {
|
|
"uri": conflict.claim.file,
|
|
"uriBaseId": "%SRCROOT%",
|
|
},
|
|
"region": {
|
|
"startLine": conflict.claim.line,
|
|
}
|
|
}
|
|
}],
|
|
"properties": properties,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
// Add drift rules and results
|
|
for drift in &result.drifts {
|
|
let rule_id = format!("aphoria/drift/{}", extract_rule_id(&drift.claim.concept_path));
|
|
if !rule_indices.contains_key(&rule_id) {
|
|
let idx = rules.len();
|
|
rule_indices.insert(rule_id.clone(), idx);
|
|
|
|
rules.push(serde_json::json!({
|
|
"id": rule_id,
|
|
"shortDescription": {
|
|
"text": format!("Value drift detected for {}", drift.claim.concept_path),
|
|
},
|
|
"defaultConfiguration": {
|
|
"level": "warning",
|
|
},
|
|
"helpUri": "https://github.com/orchard9/aphoria/docs/drift",
|
|
}));
|
|
}
|
|
}
|
|
|
|
// Add drift results
|
|
let drift_results: Vec<serde_json::Value> = result
|
|
.drifts
|
|
.iter()
|
|
.map(|drift| {
|
|
let rule_id = format!("aphoria/drift/{}", extract_rule_id(&drift.claim.concept_path));
|
|
let rule_index = rule_indices.get(&rule_id).copied().unwrap_or(0);
|
|
|
|
let message = format!(
|
|
"Value changed from prior observation.\nCurrent: {}\nPrior: {} (recorded at {}:{})",
|
|
object_value_display(&drift.claim.value),
|
|
object_value_display(&drift.prior.value),
|
|
drift.prior.file,
|
|
drift.prior.line
|
|
);
|
|
|
|
serde_json::json!({
|
|
"ruleId": rule_id,
|
|
"ruleIndex": rule_index,
|
|
"level": "warning",
|
|
"message": {
|
|
"text": message,
|
|
},
|
|
"locations": [{
|
|
"physicalLocation": {
|
|
"artifactLocation": {
|
|
"uri": drift.claim.file,
|
|
"uriBaseId": "%SRCROOT%",
|
|
},
|
|
"region": {
|
|
"startLine": drift.claim.line,
|
|
}
|
|
}
|
|
}],
|
|
"properties": {
|
|
"verdict": verdict_label(drift.verdict),
|
|
"prior_value": object_value_display(&drift.prior.value),
|
|
"prior_timestamp": drift.prior.timestamp,
|
|
}
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
// Add deprecated usage rules and results
|
|
for usage in &result.deprecated_usages {
|
|
let rule_id = format!("aphoria/deprecated/{}", usage.pattern_name);
|
|
if !rule_indices.contains_key(&rule_id) {
|
|
let idx = rules.len();
|
|
rule_indices.insert(rule_id.clone(), idx);
|
|
|
|
let level = match usage.severity() {
|
|
"OVERDUE" => "error",
|
|
"URGENT" => "warning",
|
|
_ => "note",
|
|
};
|
|
|
|
rules.push(serde_json::json!({
|
|
"id": rule_id,
|
|
"shortDescription": {
|
|
"text": format!("Deprecated pattern: {}", usage.pattern_name),
|
|
},
|
|
"fullDescription": {
|
|
"text": usage.reason.clone(),
|
|
},
|
|
"defaultConfiguration": {
|
|
"level": level,
|
|
},
|
|
"helpUri": usage.migration_guide.clone().unwrap_or_else(|| {
|
|
"https://github.com/orchard9/aphoria/docs/deprecation".to_string()
|
|
}),
|
|
}));
|
|
}
|
|
}
|
|
|
|
// Add deprecated usage results
|
|
let deprecated_results: Vec<serde_json::Value> = result
|
|
.deprecated_usages
|
|
.iter()
|
|
.map(|usage| {
|
|
let rule_id = format!("aphoria/deprecated/{}", usage.pattern_name);
|
|
let rule_index = rule_indices.get(&rule_id).copied().unwrap_or(0);
|
|
|
|
let level = match usage.severity() {
|
|
"OVERDUE" => "error",
|
|
"URGENT" => "warning",
|
|
_ => "note",
|
|
};
|
|
|
|
let mut message = format!(
|
|
"Deprecated pattern '{}' detected.\nReason: {}",
|
|
usage.pattern_name, usage.reason
|
|
);
|
|
|
|
if let Some(ref replacement) = usage.superseded_by {
|
|
message.push_str(&format!("\nReplace with: {}", replacement));
|
|
}
|
|
|
|
if let Some(days) = usage.days_until_sunset {
|
|
if days < 0 {
|
|
message.push_str(&format!("\nSunset: OVERDUE by {} days", -days));
|
|
} else {
|
|
message.push_str(&format!("\nSunset: {} days remaining", days));
|
|
}
|
|
}
|
|
|
|
serde_json::json!({
|
|
"ruleId": rule_id,
|
|
"ruleIndex": rule_index,
|
|
"level": level,
|
|
"message": {
|
|
"text": message,
|
|
},
|
|
"locations": [{
|
|
"physicalLocation": {
|
|
"artifactLocation": {
|
|
"uri": usage.file_path,
|
|
"uriBaseId": "%SRCROOT%",
|
|
},
|
|
"region": {
|
|
"startLine": usage.line,
|
|
}
|
|
}
|
|
}],
|
|
"properties": {
|
|
"pattern_id": usage.pattern_id.to_string(),
|
|
"severity": usage.severity(),
|
|
"days_until_sunset": usage.days_until_sunset,
|
|
}
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
// Add claims if present (as informational-level results)
|
|
let claims_results: Vec<serde_json::Value> = if let Some(claims) = &result.claims {
|
|
// Add a single rule for all claims
|
|
if !claims.is_empty() && !rule_indices.contains_key("aphoria/claim") {
|
|
let idx = rules.len();
|
|
rule_indices.insert("aphoria/claim".to_string(), idx);
|
|
rules.push(serde_json::json!({
|
|
"id": "aphoria/claim",
|
|
"shortDescription": {
|
|
"text": "Extracted claim (no conflict detected)",
|
|
},
|
|
"defaultConfiguration": {
|
|
"level": "note",
|
|
},
|
|
"helpUri": "https://github.com/orchard9/aphoria/docs/claims",
|
|
}));
|
|
}
|
|
|
|
claims
|
|
.iter()
|
|
.map(|claim| {
|
|
let rule_index = rule_indices.get("aphoria/claim").copied().unwrap_or(0);
|
|
let message = format!(
|
|
"{}\n{} = {}",
|
|
claim.description,
|
|
claim.predicate,
|
|
object_value_display(&claim.value)
|
|
);
|
|
|
|
serde_json::json!({
|
|
"ruleId": "aphoria/claim",
|
|
"ruleIndex": rule_index,
|
|
"level": "note",
|
|
"message": {
|
|
"text": message,
|
|
},
|
|
"locations": [{
|
|
"physicalLocation": {
|
|
"artifactLocation": {
|
|
"uri": claim.file,
|
|
"uriBaseId": "%SRCROOT%",
|
|
},
|
|
"region": {
|
|
"startLine": claim.line,
|
|
}
|
|
}
|
|
}],
|
|
"properties": {
|
|
"concept_path": claim.concept_path,
|
|
"confidence": claim.confidence,
|
|
}
|
|
})
|
|
})
|
|
.collect()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
// Combine all results
|
|
let mut all_results = results;
|
|
all_results.extend(drift_results);
|
|
all_results.extend(deprecated_results);
|
|
all_results.extend(claims_results);
|
|
|
|
let sarif = serde_json::json!({
|
|
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
|
|
"version": "2.1.0",
|
|
"runs": [{
|
|
"tool": {
|
|
"driver": {
|
|
"name": "aphoria",
|
|
"version": env!("CARGO_PKG_VERSION"),
|
|
"informationUri": "https://github.com/orchard9/aphoria",
|
|
"rules": rules,
|
|
}
|
|
},
|
|
"results": all_results,
|
|
"invocations": [{
|
|
"executionSuccessful": true,
|
|
"properties": {
|
|
"scan_id": result.scan_id,
|
|
"files_scanned": result.files_scanned,
|
|
"claims_extracted": result.claims_extracted,
|
|
"drifts_detected": result.drift_count(),
|
|
"deprecated_usages": result.deprecated_usage_count(),
|
|
"strict": result.strict,
|
|
}
|
|
}]
|
|
}]
|
|
});
|
|
|
|
serde_json::to_string_pretty(&sarif).unwrap_or_else(|_| sarif.to_string())
|
|
}
|
|
}
|
|
|
|
/// Extract a rule ID from a concept path.
|
|
///
|
|
/// e.g., `code://rust/myapp/tls/cert_verification` -> `tls/cert_verification`
|
|
fn extract_rule_id(concept_path: &str) -> String {
|
|
// Strip the scheme and project prefix, keep the meaningful tail
|
|
if let Some(after_scheme) = concept_path.split("://").nth(1) {
|
|
// Skip language and project segments (first two after scheme)
|
|
let segments: Vec<&str> = after_scheme.split('/').collect();
|
|
if segments.len() > 2 {
|
|
segments[2..].join("/")
|
|
} else {
|
|
after_scheme.to_string()
|
|
}
|
|
} else {
|
|
concept_path.to_string()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::types::{ConflictResult, ConflictingSource, Observation};
|
|
use stemedb_core::types::{ObjectValue, SourceClass};
|
|
|
|
#[test]
|
|
fn test_sarif_structure() {
|
|
let formatter = SarifReport;
|
|
let result = ScanResult {
|
|
project: "testproject".to_string(),
|
|
scan_id: "scan-789".to_string(),
|
|
files_scanned: 42,
|
|
claims_extracted: 5,
|
|
conflicts: vec![ConflictResult {
|
|
claim: Observation {
|
|
concept_path: "code://rust/testproject/tls/cert_verification".to_string(),
|
|
predicate: "enabled".to_string(),
|
|
value: ObjectValue::Boolean(false),
|
|
file: "src/client.rs".to_string(),
|
|
line: 23,
|
|
matched_text: "danger_accept_invalid_certs(true)".to_string(),
|
|
confidence: 1.0,
|
|
description: "TLS certificate verification disabled".to_string(),
|
|
},
|
|
conflicts: vec![ConflictingSource {
|
|
path: "rfc://5246/tls/cert_verification".to_string(),
|
|
source_class: SourceClass::Regulatory,
|
|
value: ObjectValue::Boolean(true),
|
|
confidence: 1.0,
|
|
rfc_citation: Some("RFC 5246".to_string()),
|
|
policy_source: None,
|
|
}],
|
|
conflict_score: 0.92,
|
|
verdict: Verdict::Block,
|
|
acknowledged: None,
|
|
trace: None,
|
|
tier_breakdown: None,
|
|
}],
|
|
drifts: vec![],
|
|
format: "sarif".to_string(),
|
|
debug: false,
|
|
strict: false,
|
|
observations_recorded: 0,
|
|
timing: None,
|
|
claims: None,
|
|
observations: vec![],
|
|
deprecated_usages: vec![],
|
|
verify: None,
|
|
};
|
|
|
|
let output = formatter.format(&result);
|
|
let parsed: serde_json::Value = serde_json::from_str(&output).expect("valid json");
|
|
|
|
// SARIF version
|
|
assert_eq!(parsed["version"], "2.1.0");
|
|
|
|
// Tool info
|
|
assert_eq!(parsed["runs"][0]["tool"]["driver"]["name"], "aphoria");
|
|
|
|
// Rules
|
|
let rules = parsed["runs"][0]["tool"]["driver"]["rules"].as_array().expect("rules array");
|
|
assert_eq!(rules.len(), 1);
|
|
assert_eq!(rules[0]["id"], "aphoria/tls/cert_verification");
|
|
|
|
// Results
|
|
let results = parsed["runs"][0]["results"].as_array().expect("results array");
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0]["level"], "error");
|
|
assert_eq!(
|
|
results[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
|
|
"src/client.rs"
|
|
);
|
|
assert_eq!(results[0]["locations"][0]["physicalLocation"]["region"]["startLine"], 23);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sarif_empty() {
|
|
let formatter = SarifReport;
|
|
let result = ScanResult::stub(&std::path::PathBuf::from("."), "sarif");
|
|
let output = formatter.format(&result);
|
|
let parsed: serde_json::Value = serde_json::from_str(&output).expect("valid json");
|
|
|
|
assert_eq!(parsed["version"], "2.1.0");
|
|
assert_eq!(parsed["runs"][0]["results"].as_array().map(|a| a.len()), Some(0));
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_rule_id() {
|
|
assert_eq!(
|
|
extract_rule_id("code://rust/myapp/tls/cert_verification"),
|
|
"tls/cert_verification"
|
|
);
|
|
assert_eq!(
|
|
extract_rule_id("code://go/myapp/jwt/audience_validation"),
|
|
"jwt/audience_validation"
|
|
);
|
|
assert_eq!(extract_rule_id("simple"), "simple");
|
|
}
|
|
}
|