use parlov_core::{
finding_id, ImpactClass, NormativeStrength, OracleResult, ScoringReason, Signal, Vector,
};
use serde::Serialize;
use crate::ScanFinding;
const SCHEMA_VERSION: &str = "1.0.0";
#[derive(Serialize)]
pub(crate) struct SingleFindingOutput<'a> {
schema_version: &'static str,
target_url: &'a str,
finding: FindingOutput,
}
#[derive(Serialize)]
pub(crate) struct ScanOutput<'a> {
schema_version: &'static str,
target_url: &'a str,
findings: Vec<FindingOutput>,
}
#[derive(Serialize)]
struct FindingOutput {
finding_id: String,
strategy: StrategyOutput,
result: ResultOutput,
technique: TechniqueOutput,
matched_pattern: MatchedPatternOutput,
evidence: EvidenceOutput,
}
#[derive(Serialize)]
struct StrategyOutput {
id: String,
name: String,
method: String,
}
#[derive(Serialize)]
struct ResultOutput {
oracle_class: String,
verdict: String,
confidence: u8,
severity: String,
#[serde(skip_serializing_if = "Option::is_none")]
impact_class: Option<String>,
}
#[derive(Serialize)]
struct TechniqueOutput {
id: String,
vector: String,
normative_strength: String,
}
#[derive(Serialize)]
struct MatchedPatternOutput {
#[serde(skip_serializing_if = "Option::is_none")]
label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
leaks: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
rfc_basis: Option<String>,
}
#[derive(Serialize)]
struct EvidenceOutput {
reasons: Vec<ScoringReason>,
signals: Vec<Signal>,
}
pub fn render_json(
target_url: &str,
result: &OracleResult,
strategy_id: &str,
strategy_name: &str,
method: &str,
) -> Result<String, serde_json::Error> {
let finding = build_finding(target_url, result, strategy_id, strategy_name, method);
let output = SingleFindingOutput {
schema_version: SCHEMA_VERSION,
target_url,
finding,
};
serde_json::to_string_pretty(&output)
}
pub fn render_scan_json(
target_url: &str,
findings: &[ScanFinding],
) -> Result<String, serde_json::Error> {
let items: Vec<FindingOutput> = findings
.iter()
.map(|f| {
build_finding(
target_url,
&f.result,
&f.strategy_id,
&f.strategy_name,
&f.method,
)
})
.collect();
let output = ScanOutput {
schema_version: SCHEMA_VERSION,
target_url,
findings: items,
};
serde_json::to_string_pretty(&output)
}
fn build_finding(
target_url: &str,
result: &OracleResult,
strategy_id: &str,
strategy_name: &str,
method: &str,
) -> FindingOutput {
let oracle_class = format!("{:?}", result.class);
let technique_id = result.technique_id.as_deref().unwrap_or("unknown");
let fid = finding_id(technique_id, target_url, &oracle_class, method, strategy_id);
FindingOutput {
finding_id: fid,
strategy: StrategyOutput {
id: strategy_id.to_owned(),
name: strategy_name.to_owned(),
method: method.to_owned(),
},
result: build_result_output(result, &oracle_class),
technique: build_technique_output(result),
matched_pattern: build_matched_pattern(result),
evidence: EvidenceOutput {
reasons: result.reasons.clone(),
signals: result.signals.clone(),
},
}
}
fn build_result_output(result: &OracleResult, oracle_class: &str) -> ResultOutput {
ResultOutput {
oracle_class: oracle_class.to_owned(),
verdict: format!("{:?}", result.verdict),
confidence: result.confidence,
severity: format_severity(result.severity.as_ref()),
impact_class: result.impact_class.map(format_impact_class),
}
}
fn build_technique_output(result: &OracleResult) -> TechniqueOutput {
TechniqueOutput {
id: result.technique_id.clone().unwrap_or_else(|| "unknown".to_owned()),
vector: result.vector.map_or_else(|| "Unknown".to_owned(), format_vector),
normative_strength: result
.normative_strength
.map_or_else(|| "Unknown".to_owned(), format_normative_strength),
}
}
fn build_matched_pattern(result: &OracleResult) -> MatchedPatternOutput {
MatchedPatternOutput {
label: result.label.clone(),
leaks: result.leaks.clone(),
rfc_basis: result.rfc_basis.clone(),
}
}
fn format_severity(severity: Option<&parlov_core::Severity>) -> String {
match severity {
Some(s) => format!("{s:?}"),
None => "None".to_owned(),
}
}
fn format_impact_class(ic: ImpactClass) -> String {
format!("{ic:?}")
}
fn format_vector(v: Vector) -> String {
format!("{v:?}")
}
fn format_normative_strength(ns: NormativeStrength) -> String {
format!("{ns:?}")
}
#[cfg(test)]
mod tests {
use super::*;
use parlov_core::{OracleClass, OracleResult, OracleVerdict, Severity, Signal, SignalKind};
fn confirmed() -> 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("test-tech".into()),
vector: Some(Vector::StatusCodeDiff),
normative_strength: Some(NormativeStrength::Should),
label: Some("Auth differential".into()),
leaks: Some("Resource existence".into()),
rfc_basis: Some("RFC 9110".into()),
}
}
fn not_present() -> OracleResult {
OracleResult {
class: OracleClass::Existence,
verdict: OracleVerdict::NotPresent,
severity: None,
confidence: 0,
impact_class: None,
reasons: vec![],
signals: vec![],
technique_id: None,
vector: None,
normative_strength: None,
label: None,
leaks: None,
rfc_basis: None,
}
}
#[test]
fn single_json_has_schema_version_and_target() {
let json = render_json("https://x.com/api", &confirmed(), "s1", "n1", "GET")
.expect("serialization");
let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
assert_eq!(v["schema_version"], "1.0.0");
assert_eq!(v["target_url"], "https://x.com/api");
}
#[test]
fn single_json_has_finding_id_12_hex() {
let json = render_json("https://x.com", &confirmed(), "s1", "n1", "GET")
.expect("serialization");
let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
let fid = v["finding"]["finding_id"].as_str().expect("string");
assert_eq!(fid.len(), 12);
assert!(fid.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn single_json_omits_none_matched_pattern_fields() {
let json = render_json("https://x.com", ¬_present(), "s1", "n1", "GET")
.expect("serialization");
let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
let mp = &v["finding"]["matched_pattern"];
assert!(mp.get("label").is_none());
assert!(mp.get("leaks").is_none());
}
#[test]
fn scan_json_uses_findings_array() {
let findings = vec![ScanFinding {
target_url: "https://x.com".to_owned(),
strategy_id: "s1".to_owned(),
strategy_name: "n1".to_owned(),
method: "GET".to_owned(),
result: confirmed(),
}];
let json = render_scan_json("https://x.com", &findings).expect("serialization");
let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
assert!(v["findings"].is_array());
assert_eq!(v["findings"].as_array().expect("array").len(), 1);
}
#[test]
fn scan_json_empty_findings_produces_empty_array() {
let json = render_scan_json("https://x.com", &[]).expect("serialization");
let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
assert!(v["findings"].as_array().expect("array").is_empty());
}
}