#![deny(clippy::all)]
#![warn(clippy::pedantic)]
#![deny(missing_docs)]
mod sarif;
pub use sarif::{render_sarif, render_scan_sarif};
use comfy_table::{Cell, Color, Table};
use parlov_core::{OracleResult, OracleVerdict, Severity};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanFinding {
pub target_url: String,
pub strategy_id: String,
pub strategy_name: String,
pub method: String,
pub result: OracleResult,
}
#[must_use]
pub fn render_table(result: &OracleResult) -> String {
let mut table = Table::new();
table.set_header(vec!["Oracle", "Verdict", "Severity", "Evidence"]);
let verdict_cell = verdict_cell(result.verdict);
let severity_cell = severity_cell(result.severity.as_ref());
let oracle_label = format!("{:?}", result.class);
add_evidence_rows(&mut table, &oracle_label, verdict_cell, severity_cell, &result.evidence);
add_metadata_rows(&mut table, result);
table.to_string()
}
fn add_evidence_rows(
table: &mut Table,
oracle_label: &str,
verdict_cell: Cell,
severity_cell: Cell,
evidence: &[String],
) {
if evidence.is_empty() {
table.add_row(vec![
Cell::new(oracle_label),
verdict_cell,
severity_cell,
Cell::new("—"),
]);
return;
}
for (i, ev) in evidence.iter().enumerate() {
if i == 0 {
table.add_row(vec![
Cell::new(oracle_label),
verdict_cell.clone(),
severity_cell.clone(),
Cell::new(ev.as_str()),
]);
} else {
table.add_row(vec![
Cell::new(""),
Cell::new(""),
Cell::new(""),
Cell::new(ev.as_str()),
]);
}
}
}
fn add_metadata_rows(table: &mut Table, result: &OracleResult) {
if let Some(label) = &result.label {
add_detail_row(table, "Label", label);
}
if let Some(leaks) = &result.leaks {
add_detail_row(table, "Leaks", leaks);
}
if let Some(rfc_basis) = &result.rfc_basis {
add_detail_row(table, "RFC Basis", rfc_basis);
}
}
fn add_detail_row(table: &mut Table, key: &str, value: &str) {
table.add_row(vec![
Cell::new(""),
Cell::new(""),
Cell::new(key),
Cell::new(value),
]);
}
pub fn render_json(result: &OracleResult) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(result)
}
#[must_use]
pub fn render_scan_table(findings: &[ScanFinding]) -> String {
let mut table = Table::new();
table.set_header(vec!["Strategy", "Method", "Verdict", "Severity", "Evidence"]);
if findings.is_empty() {
return table.to_string();
}
for f in findings {
let evidence = f.result.evidence.first().map_or("—", String::as_str);
table.add_row(vec![
Cell::new(&f.strategy_name),
Cell::new(&f.method),
verdict_cell(f.result.verdict),
severity_cell(f.result.severity.as_ref()),
Cell::new(evidence),
]);
}
table.to_string()
}
pub fn render_scan_json(findings: &[ScanFinding]) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(findings)
}
fn verdict_cell(verdict: OracleVerdict) -> Cell {
let (label, color) = match verdict {
OracleVerdict::Confirmed => ("Confirmed", Color::Red),
OracleVerdict::Likely => ("Likely", Color::Yellow),
OracleVerdict::Inconclusive => ("Inconclusive", Color::Blue),
OracleVerdict::NotPresent => ("NotPresent", Color::Green),
};
Cell::new(label).fg(color)
}
fn severity_cell(severity: Option<&Severity>) -> Cell {
match severity {
Some(Severity::High) => Cell::new("High").fg(Color::Red),
Some(Severity::Medium) => Cell::new("Medium").fg(Color::Yellow),
Some(Severity::Low) => Cell::new("Low").fg(Color::Cyan),
None => Cell::new("—"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use parlov_core::{OracleClass, OracleResult, OracleVerdict, Severity};
fn confirmed_with_metadata() -> 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_no_metadata() -> 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![],
}
}
#[test]
fn table_includes_label_when_present() {
let table = render_table(&confirmed_with_metadata());
assert!(table.contains("Authorization-based differential"));
}
#[test]
fn table_includes_leaks_when_present() {
let table = render_table(&confirmed_with_metadata());
assert!(table.contains("Resource existence confirmed"));
}
#[test]
fn table_includes_rfc_basis_when_present() {
let table = render_table(&confirmed_with_metadata());
assert!(table.contains("RFC 9110 §15.5.4"));
}
#[test]
fn table_omits_label_row_when_none() {
let table = render_table(¬_present_no_metadata());
assert!(!table.contains("Label"));
}
#[test]
fn table_omits_leaks_row_when_none() {
let table = render_table(¬_present_no_metadata());
assert!(!table.contains("Leaks"));
}
#[test]
fn table_omits_rfc_basis_row_when_none() {
let table = render_table(¬_present_no_metadata());
assert!(!table.contains("RFC Basis"));
}
#[test]
fn json_omits_none_metadata_fields() {
let result = not_present_no_metadata();
let json = render_json(&result).expect("serialization failed");
assert!(!json.contains("label"));
assert!(!json.contains("leaks"));
assert!(!json.contains("rfc_basis"));
}
#[test]
fn json_includes_some_metadata_fields() {
let result = confirmed_with_metadata();
let json = render_json(&result).expect("serialization failed");
assert!(json.contains("\"label\""));
assert!(json.contains("\"leaks\""));
assert!(json.contains("\"rfc_basis\""));
}
fn scan_finding(verdict: OracleVerdict, severity: Option<Severity>) -> ScanFinding {
ScanFinding {
target_url: "https://api.example.com/users/1".to_owned(),
strategy_id: "accept-elicit".to_owned(),
strategy_name: "Accept header elicitation".to_owned(),
method: "GET".to_owned(),
result: OracleResult {
class: OracleClass::Existence,
verdict,
evidence: vec!["406 (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_scan_table_contains_strategy_name_and_method() {
let findings = vec![scan_finding(OracleVerdict::NotPresent, None)];
let table = render_scan_table(&findings);
assert!(table.contains("Accept header elicitation"));
assert!(table.contains("GET"));
}
#[test]
fn render_scan_table_confirmed_verdict_appears() {
let findings = vec![scan_finding(OracleVerdict::Confirmed, Some(Severity::High))];
let table = render_scan_table(&findings);
assert!(table.contains("Confirmed"));
}
#[test]
fn render_scan_table_empty_findings_no_panic() {
let table = render_scan_table(&[]);
assert!(!table.is_empty());
}
#[test]
fn render_scan_json_produces_valid_json_array() {
let findings = vec![scan_finding(OracleVerdict::NotPresent, None)];
let json = render_scan_json(&findings).expect("serialization failed");
let value: serde_json::Value =
serde_json::from_str(&json).expect("result is not valid JSON");
assert!(value.is_array());
}
#[test]
fn render_scan_json_contains_strategy_id() {
let findings = vec![scan_finding(OracleVerdict::NotPresent, None)];
let json = render_scan_json(&findings).expect("serialization failed");
assert!(json.contains("accept-elicit"));
}
}