use std::fmt::Write as _;
use parlov_core::{finding_id, OracleResult, OracleVerdict};
use serde_json::{json, Value};
use crate::ScanFinding;
fn verdict_level(verdict: OracleVerdict) -> &'static str {
match verdict {
OracleVerdict::Confirmed => "error",
OracleVerdict::Likely => "warning",
OracleVerdict::Inconclusive | OracleVerdict::NotPresent => "note",
}
}
fn host_path(url: &str) -> String {
url.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.unwrap_or(url)
.to_owned()
}
fn security_severity(confidence: u8) -> f64 {
f64::from(confidence) / 10.0
}
fn build_rule(result: &OracleResult) -> Value {
let technique_id = result.technique_id.as_deref().unwrap_or("unknown");
let oracle_class = format!("{:?}", result.class);
let vector = result.vector.map_or("Unknown", |v| match v {
parlov_core::Vector::StatusCodeDiff => "StatusCodeDiff",
parlov_core::Vector::CacheProbing => "CacheProbing",
});
json!({
"id": technique_id,
"name": format!("{oracle_class}Oracle"),
"shortDescription": {
"text": result.label.as_deref().unwrap_or("HTTP differential oracle")
},
"properties": {
"oracle_class": oracle_class,
"vector": vector,
"security-severity": format!("{:.1}", security_severity(result.confidence))
}
})
}
fn deduplicate_rules(rules: Vec<Value>) -> Vec<Value> {
let mut seen = std::collections::HashSet::new();
rules
.into_iter()
.filter(|r| {
let id = r["id"].as_str().unwrap_or("").to_owned();
seen.insert(id)
})
.collect()
}
struct ResultContext<'a> {
target_url: &'a str,
result: &'a OracleResult,
strategy_id: &'a str,
method: &'a str,
}
fn build_sarif_result(ctx: &ResultContext<'_>) -> Option<Value> {
if ctx.result.verdict == OracleVerdict::NotPresent {
return None;
}
let technique_id = ctx.result.technique_id.as_deref().unwrap_or("unknown");
let oracle_class = format!("{:?}", ctx.result.class);
let fid = finding_id(technique_id, ctx.target_url, &oracle_class, ctx.method, ctx.strategy_id);
let message_text = ctx
.result
.leaks
.as_deref()
.unwrap_or_else(|| ctx.result.primary_evidence());
let related_locations = build_related_locations(ctx.result);
let properties = build_result_properties(ctx.result, ctx.method);
Some(json!({
"ruleId": technique_id,
"level": verdict_level(ctx.result.verdict),
"message": { "text": message_text },
"locations": [{
"physicalLocation": {
"artifactLocation": { "uri": ctx.target_url }
}
}],
"logicalLocations": [{
"kind": "url",
"name": format!("{} {}", ctx.method, ctx.target_url)
}],
"fingerprints": {
"oracleFingerprint/v1": fid
},
"partialFingerprints": {
"techniqueTargetHash/v1": format!("{}:{}", technique_id, host_path(ctx.target_url))
},
"relatedLocations": related_locations,
"properties": Value::Object(properties)
}))
}
fn build_related_locations(result: &OracleResult) -> Vec<Value> {
result
.signals
.iter()
.enumerate()
.map(|(i, signal)| {
let mut msg = format!("[{:?}] {}", signal.kind, signal.evidence);
if let Some(rfc) = &signal.rfc_basis {
let _ = write!(msg, " ({rfc})");
}
json!({
"id": i,
"message": { "text": msg }
})
})
.collect()
}
fn build_result_properties(
result: &OracleResult,
method: &str,
) -> serde_json::Map<String, Value> {
let mut props = serde_json::Map::new();
props.insert("oracle_class".to_owned(), json!(format!("{:?}", result.class)));
props.insert("verdict".to_owned(), json!(format!("{:?}", result.verdict)));
props.insert("confidence".to_owned(), json!(result.confidence));
props.insert("method".to_owned(), json!(method));
if let Some(ic) = result.impact_class {
props.insert("impact_class".to_owned(), json!(format!("{ic:?}")));
}
if !result.reasons.is_empty() {
props.insert("reasons".to_owned(), json!(result.reasons));
}
props
}
fn build_sarif_document(
rules: &[Value],
results: &[Value],
run_properties: Option<&Value>,
) -> Value {
let mut run = json!({
"tool": {
"driver": {
"name": "parlov",
"version": env!("CARGO_PKG_VERSION"),
"rules": rules
}
},
"results": results
});
if let Some(props) = run_properties {
run["properties"] = props.clone();
}
json!({
"$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0.json",
"version": "2.1.0",
"runs": [run]
})
}
pub fn render_sarif(
target_url: &str,
result: &OracleResult,
strategy_id: &str,
method: &str,
) -> Result<String, serde_json::Error> {
let rules = deduplicate_rules(vec![build_rule(result)]);
let ctx = ResultContext { target_url, result, strategy_id, method };
let results: Vec<Value> = build_sarif_result(&ctx).into_iter().collect();
let doc = build_sarif_document(&rules, &results, None);
serde_json::to_string_pretty(&doc)
}
pub fn render_scan_sarif(
target_url: &str,
findings: &[ScanFinding],
) -> Result<String, serde_json::Error> {
let rules: Vec<Value> = findings.iter().map(|f| build_rule(&f.result)).collect();
let rules = deduplicate_rules(rules);
let results: Vec<Value> = findings
.iter()
.filter_map(|f| {
let ctx = ResultContext {
target_url,
result: &f.result,
strategy_id: &f.strategy_id,
method: &f.method,
};
build_sarif_result(&ctx)
})
.collect();
let run_props = json!({ "target_url": target_url });
let doc = build_sarif_document(&rules, &results, Some(&run_props));
serde_json::to_string_pretty(&doc)
}
#[cfg(test)]
mod tests {
use super::*;
use parlov_core::{OracleClass, OracleResult, OracleVerdict, Severity, Signal, SignalKind};
use crate::ScanFinding;
fn confirmed_result() -> OracleResult {
OracleResult {
class: OracleClass::Existence,
verdict: OracleVerdict::Confirmed,
severity: Some(Severity::High),
confidence: 85,
impact_class: None,
reasons: vec![],
signals: vec![Signal {
kind: SignalKind::StatusCodeDiff,
evidence: "403 (baseline) vs 404 (probe)".into(),
rfc_basis: None,
}],
technique_id: Some("get-403-404".into()),
vector: Some(parlov_core::Vector::StatusCodeDiff),
normative_strength: Some(parlov_core::NormativeStrength::Should),
label: Some("Authorization-based differential".into()),
leaks: Some("Resource existence confirmed".into()),
rfc_basis: Some("RFC 9110 \u{00a7}15.5.4".into()),
}
}
fn not_present_result() -> OracleResult {
OracleResult {
class: OracleClass::Existence,
verdict: OracleVerdict::NotPresent,
severity: None,
confidence: 0,
impact_class: None,
reasons: vec![],
signals: vec![Signal {
kind: SignalKind::StatusCodeDiff,
evidence: "404 (baseline) vs 404 (probe)".into(),
rfc_basis: None,
}],
technique_id: Some("get-404-404".into()),
vector: Some(parlov_core::Vector::StatusCodeDiff),
normative_strength: None,
label: None,
leaks: None,
rfc_basis: None,
}
}
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,
severity,
confidence: 85,
impact_class: None,
reasons: vec![],
signals: vec![Signal {
kind: SignalKind::StatusCodeDiff,
evidence: "403 (baseline) vs 404 (probe)".into(),
rfc_basis: None,
}],
technique_id: Some("get-200-404".into()),
vector: Some(parlov_core::Vector::StatusCodeDiff),
normative_strength: None,
label: None,
leaks: None,
rfc_basis: None,
},
}
}
#[test]
fn sarif_confirmed_produces_error_level() {
let r = confirmed_result();
let json = render_sarif("https://api.example.com/users/1", &r, "s1", "GET")
.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 sarif_not_present_produces_empty_results() {
let r = not_present_result();
let json = render_sarif("https://api.example.com/users/1", &r, "s1", "GET")
.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").is_empty());
}
#[test]
fn sarif_rule_id_is_technique_id() {
let r = confirmed_result();
let json = render_sarif("https://api.example.com/users/1", &r, "s1", "GET")
.expect("render failed");
let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
assert_eq!(v["runs"][0]["results"][0]["ruleId"], "get-403-404");
assert_eq!(v["runs"][0]["tool"]["driver"]["rules"][0]["id"], "get-403-404");
}
#[test]
fn sarif_has_fingerprints() {
let r = confirmed_result();
let json = render_sarif("https://api.example.com/users/1", &r, "s1", "GET")
.expect("render failed");
let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
let fp = &v["runs"][0]["results"][0]["fingerprints"]["oracleFingerprint/v1"];
assert_eq!(fp.as_str().expect("string").len(), 12);
}
#[test]
fn sarif_has_related_locations_for_signals() {
let r = confirmed_result();
let json = render_sarif("https://api.example.com/users/1", &r, "s1", "GET")
.expect("render failed");
let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
let rl = v["runs"][0]["results"][0]["relatedLocations"]
.as_array()
.expect("array");
assert_eq!(rl.len(), 1);
assert!(rl[0]["message"]["text"]
.as_str()
.expect("text")
.contains("StatusCodeDiff"));
}
#[test]
fn sarif_message_uses_leaks_when_present() {
let r = confirmed_result();
let json = render_sarif("https://api.example.com/users/1", &r, "s1", "GET")
.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 sarif_message_falls_back_to_primary_evidence() {
let mut r = confirmed_result();
r.leaks = None;
let json = render_sarif("https://api.example.com/users/1", &r, "s1", "GET")
.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 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("https://api.example.com/users/1", &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");
assert_eq!(results.len(), 1);
}
#[test]
fn scan_sarif_has_run_properties() {
let findings = vec![scan_finding(OracleVerdict::Confirmed, Some(Severity::High))];
let json = render_scan_sarif("https://api.example.com/users/1", &findings)
.expect("render failed");
let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
assert_eq!(v["runs"][0]["properties"]["target_url"], "https://api.example.com/users/1");
}
#[test]
fn scan_sarif_valid_json_with_version() {
let findings = vec![scan_finding(OracleVerdict::Confirmed, Some(Severity::High))];
let json = render_scan_sarif("https://api.example.com/users/1", &findings)
.expect("render failed");
let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
assert_eq!(v["version"], "2.1.0");
}
}