use parlov_core::{OracleClass, OracleResult, OracleVerdict};
use serde_json::{json, Map, Value};
use crate::ScanFinding;
fn oracle_rule_id(class: OracleClass) -> &'static str {
match class {
OracleClass::Existence => "PAR001",
_ => "PAR000",
}
}
fn oracle_rule_name(class: OracleClass) -> &'static str {
match class {
OracleClass::Existence => "ExistenceOracle",
_ => "UnknownOracle",
}
}
fn oracle_rule_description(class: OracleClass) -> &'static str {
match class {
OracleClass::Existence => "Resource existence leaked via HTTP differential",
_ => "Unknown oracle class",
}
}
fn verdict_level(verdict: OracleVerdict) -> &'static str {
match verdict {
OracleVerdict::Confirmed => "error",
OracleVerdict::Likely => "warning",
OracleVerdict::Inconclusive | OracleVerdict::NotPresent => "note",
}
}
fn build_rules(classes: &[OracleClass]) -> Value {
let rules: Vec<Value> = classes
.iter()
.copied()
.collect::<std::collections::HashSet<_>>()
.into_iter()
.map(|c| {
json!({
"id": oracle_rule_id(c),
"name": oracle_rule_name(c),
"shortDescription": { "text": oracle_rule_description(c) }
})
})
.collect();
json!(rules)
}
fn build_properties(result: &OracleResult, extras: &Map<String, Value>) -> Map<String, Value> {
let mut props: Map<String, Value> = Map::new();
props.insert("evidence".to_owned(), json!(result.evidence));
if let Some(s) = result.severity {
props.insert("severity".to_owned(), json!(format!("{s:?}")));
}
if let Some(label) = &result.label {
props.insert("label".to_owned(), json!(label));
}
if let Some(rfc) = &result.rfc_basis {
props.insert("rfc_basis".to_owned(), json!(rfc));
}
if let Some(bs) = &result.baseline_summary {
props.insert("baseline_status".to_owned(), json!(bs.status));
}
if let Some(ps) = &result.probe_summary {
props.insert("probe_status".to_owned(), json!(ps.status));
}
if !result.header_diffs.is_empty() {
props.insert("header_diffs".to_owned(), json!(result.header_diffs));
}
for (k, v) in extras {
props.insert(k.clone(), v.clone());
}
props
}
fn build_result(
target_url: &str,
result: &OracleResult,
extras: &Map<String, Value>,
) -> Option<Value> {
if result.verdict == OracleVerdict::NotPresent {
return None;
}
let message_text = result
.leaks
.as_deref()
.map_or_else(|| result.evidence.join("; "), str::to_owned);
let props = build_properties(result, extras);
Some(json!({
"ruleId": oracle_rule_id(result.class),
"level": verdict_level(result.verdict),
"message": { "text": message_text },
"locations": [{
"physicalLocation": {
"artifactLocation": { "uri": target_url }
}
}],
"properties": Value::Object(props)
}))
}
fn build_sarif_document(rules: &Value, results: &[Value]) -> Value {
json!({
"$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0.json",
"version": "2.1.0",
"runs": [{
"tool": {
"driver": {
"name": "parlov",
"version": env!("CARGO_PKG_VERSION"),
"rules": rules
}
},
"results": results
}]
})
}
pub fn render_sarif(target_url: &str, result: &OracleResult) -> Result<String, serde_json::Error> {
let rules = build_rules(&[result.class]);
let results: Vec<Value> =
build_result(target_url, result, &Map::new()).into_iter().collect();
serde_json::to_string_pretty(&build_sarif_document(&rules, &results))
}
pub fn render_scan_sarif(findings: &[ScanFinding]) -> Result<String, serde_json::Error> {
let classes: Vec<OracleClass> = findings.iter().map(|f| f.result.class).collect();
let rules = build_rules(&classes);
let results: Vec<Value> = findings
.iter()
.filter(|f| f.result.verdict != OracleVerdict::NotPresent)
.filter_map(|f| {
let mut extras: Map<String, Value> = Map::new();
extras.insert("method".to_owned(), json!(f.method));
extras.insert("strategy_id".to_owned(), json!(f.strategy_id));
build_result(&f.target_url, &f.result, &extras)
})
.collect();
serde_json::to_string_pretty(&build_sarif_document(&rules, &results))
}
#[cfg(test)]
mod tests {
use super::*;
use parlov_core::{DiffedHeader, OracleClass, OracleResult, OracleVerdict, ResponseSummary, Severity};
use crate::ScanFinding;
fn confirmed_result() -> OracleResult {
OracleResult {
class: OracleClass::Existence,
verdict: OracleVerdict::Confirmed,
evidence: vec!["403 (baseline) vs 404 (probe)".into()],
severity: Some(Severity::High),
label: Some("Authorization-based differential".into()),
leaks: Some("Resource existence confirmed".into()),
rfc_basis: Some("RFC 9110 §15.5.4".into()),
baseline_summary: None,
probe_summary: None,
header_diffs: vec![],
}
}
fn not_present_result() -> OracleResult {
OracleResult {
class: OracleClass::Existence,
verdict: OracleVerdict::NotPresent,
evidence: vec!["404 (baseline) vs 404 (probe)".into()],
severity: None,
label: None,
leaks: None,
rfc_basis: None,
baseline_summary: None,
probe_summary: None,
header_diffs: vec![],
}
}
fn scan_finding(verdict: OracleVerdict, severity: Option<Severity>) -> ScanFinding {
ScanFinding {
target_url: "https://api.example.com/users/1".to_owned(),
strategy_id: "existence-get-200-404".to_owned(),
strategy_name: "GET 200/404 existence".to_owned(),
method: "GET".to_owned(),
result: OracleResult {
class: OracleClass::Existence,
verdict,
evidence: vec!["403 (baseline) vs 404 (probe)".into()],
severity,
label: None,
leaks: None,
rfc_basis: None,
baseline_summary: None,
probe_summary: None,
header_diffs: vec![],
},
}
}
#[test]
fn render_sarif_confirmed_produces_error_level() {
let json = render_sarif("https://api.example.com/users/1", &confirmed_result())
.expect("render failed");
let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
assert_eq!(v["runs"][0]["results"][0]["level"], "error");
}
#[test]
fn render_sarif_not_present_produces_empty_results() {
let json = render_sarif("https://api.example.com/users/1", ¬_present_result())
.expect("render failed");
let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
assert!(v["runs"][0]["results"].as_array().expect("results array").is_empty());
}
#[test]
fn render_sarif_message_uses_leaks_when_present() {
let json = render_sarif("https://api.example.com/users/1", &confirmed_result())
.expect("render failed");
let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
assert_eq!(v["runs"][0]["results"][0]["message"]["text"], "Resource existence confirmed");
}
#[test]
fn render_sarif_message_falls_back_to_evidence() {
let mut result = confirmed_result();
result.leaks = None;
let json =
render_sarif("https://api.example.com/users/1", &result).expect("render failed");
let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
assert_eq!(
v["runs"][0]["results"][0]["message"]["text"],
"403 (baseline) vs 404 (probe)"
);
}
#[test]
fn render_scan_sarif_filters_not_present() {
let findings = vec![
scan_finding(OracleVerdict::Confirmed, Some(Severity::High)),
scan_finding(OracleVerdict::NotPresent, None),
];
let json = render_scan_sarif(&findings).expect("render failed");
let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
let results = v["runs"][0]["results"].as_array().expect("results array");
assert_eq!(results.len(), 1);
}
#[test]
fn render_scan_sarif_valid_json_with_version() {
let findings = vec![scan_finding(OracleVerdict::Confirmed, Some(Severity::High))];
let json = render_scan_sarif(&findings).expect("render failed");
let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
assert_eq!(v["version"], "2.1.0");
}
#[test]
fn render_scan_sarif_includes_baseline_and_probe_status() {
let mut finding = scan_finding(OracleVerdict::Confirmed, Some(Severity::High));
finding.result.baseline_summary = Some(ResponseSummary { status: 403 });
finding.result.probe_summary = Some(ResponseSummary { status: 404 });
let json = render_scan_sarif(&[finding]).expect("render failed");
let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
let props = &v["runs"][0]["results"][0]["properties"];
assert_eq!(props["baseline_status"], 403);
assert_eq!(props["probe_status"], 404);
}
#[test]
fn render_scan_sarif_omits_empty_header_diffs() {
let finding = scan_finding(OracleVerdict::Confirmed, Some(Severity::High));
let json = render_scan_sarif(&[finding]).expect("render failed");
let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
let props = &v["runs"][0]["results"][0]["properties"];
assert!(props.get("header_diffs").is_none());
}
#[test]
fn render_scan_sarif_includes_header_diffs_when_present() {
let mut finding = scan_finding(OracleVerdict::Confirmed, Some(Severity::High));
finding.result.header_diffs = vec![DiffedHeader {
name: "x-rate-limit-remaining".into(),
baseline: Some("100".into()),
probe: None,
}];
let json = render_scan_sarif(&[finding]).expect("render failed");
let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
let diffs = &v["runs"][0]["results"][0]["properties"]["header_diffs"];
assert_eq!(diffs[0]["name"], "x-rate-limit-remaining");
}
}