use http::HeaderMap;
use parlov_core::{Error, OracleClass, OracleResult, OracleVerdict, Severity};
use parlov_elicit::{
BurstSpec, KnownDuplicate, ProbePair, ProbeSpec, RiskLevel, ScanContext, StateField,
StrategyMetadata,
};
use parlov_output::{render_scan_json, render_scan_sarif, render_scan_table, ScanFinding};
use parlov_probe::http::HttpProbe;
use parlov_probe::Probe;
use tracing::warn;
use crate::cli::{OutputFormat, ScanArgs};
use crate::existence::collect_until_verdict;
use crate::util::parse_headers;
pub async fn run(args: ScanArgs, format: OutputFormat) -> Result<(), Error> {
let ctx = build_scan_context(&args)?;
let plan = parlov_elicit::generate_plan(&ctx);
let mut findings: Vec<ScanFinding> = Vec::new();
for spec in &plan {
let result = dispatch_spec(&args.target, spec).await;
match result {
Ok(finding) => findings.push(finding),
Err(e) => warn!("strategy failed, skipping: {e}"),
}
}
let output = format_scan_results(format, &findings)?;
println!("{output}");
Ok(())
}
fn format_scan_results(format: OutputFormat, findings: &[ScanFinding]) -> Result<String, Error> {
match format {
OutputFormat::Table => Ok(render_scan_table(findings)),
OutputFormat::Json => render_scan_json(findings).map_err(Error::Serialization),
OutputFormat::Sarif => render_scan_sarif(findings).map_err(Error::Serialization),
}
}
async fn dispatch_spec(target: &str, spec: &ProbeSpec) -> Result<ScanFinding, Error> {
match spec {
ProbeSpec::Pair(pair) => run_pair(target, pair).await,
ProbeSpec::Burst(burst) => run_burst(target, burst).await,
ProbeSpec::HeaderDiff(pair) => run_header_diff(target, pair).await,
}
}
async fn run_pair(target: &str, pair: &ProbePair) -> Result<ScanFinding, Error> {
let result = collect_until_verdict(&pair.baseline, &pair.probe).await?;
let method = pair.baseline.method.as_str().to_owned();
Ok(make_finding(target, &pair.metadata, &method, result))
}
async fn run_burst(target: &str, spec: &BurstSpec) -> Result<ScanFinding, Error> {
let client = HttpProbe::new();
let mut baseline_429 = 0usize;
let mut probe_429 = 0usize;
for _ in 0..spec.burst_count {
let surface = client.execute(&spec.baseline).await?;
if surface.status.as_u16() == 429 {
baseline_429 += 1;
}
}
for _ in 0..spec.burst_count {
let surface = client.execute(&spec.probe).await?;
if surface.status.as_u16() == 429 {
probe_429 += 1;
}
}
let result = burst_result(baseline_429, probe_429);
let method = spec.baseline.method.as_str().to_owned();
Ok(make_finding(target, &spec.metadata, &method, result))
}
fn burst_result(baseline_429: usize, probe_429: usize) -> OracleResult {
let evidence = format!("baseline 429 count: {baseline_429}, probe 429 count: {probe_429}");
let (verdict, severity) = if baseline_429 > 0 && probe_429 == 0 {
(OracleVerdict::Confirmed, Some(Severity::Medium))
} else {
(OracleVerdict::NotPresent, None)
};
OracleResult {
class: OracleClass::Existence,
verdict,
evidence: vec![evidence],
severity,
label: None,
leaks: None,
rfc_basis: None,
baseline_summary: None,
probe_summary: None,
header_diffs: vec![],
}
}
async fn run_header_diff(target: &str, pair: &ProbePair) -> Result<ScanFinding, Error> {
let client = HttpProbe::new();
let (b_surface, p_surface) =
tokio::try_join!(client.execute(&pair.baseline), client.execute(&pair.probe))?;
let diff_headers = rate_limit_diff(&b_surface.headers, &p_surface.headers);
let result = header_diff_result(&diff_headers);
let method = pair.baseline.method.as_str().to_owned();
Ok(make_finding(target, &pair.metadata, &method, result))
}
fn rate_limit_diff(baseline: &HeaderMap, probe: &HeaderMap) -> Vec<String> {
baseline
.keys()
.filter(|k| {
let name = k.as_str();
(name.starts_with("ratelimit-")
|| name.starts_with("x-ratelimit-")
|| name == "ratelimit"
|| name == "x-ratelimit")
&& !probe.contains_key(*k)
})
.map(|k| k.as_str().to_owned())
.collect()
}
fn header_diff_result(diff: &[String]) -> OracleResult {
let (verdict, severity, evidence) = if diff.is_empty() {
(OracleVerdict::NotPresent, None, vec!["no rate-limit header differential".to_owned()])
} else {
let ev = format!("rate-limit headers in baseline only: {}", diff.join(", "));
(OracleVerdict::Confirmed, Some(Severity::Low), vec![ev])
};
OracleResult {
class: OracleClass::Existence,
verdict,
evidence,
severity,
label: None,
leaks: None,
rfc_basis: None,
baseline_summary: None,
probe_summary: None,
header_diffs: vec![],
}
}
fn make_finding(
target: &str,
meta: &StrategyMetadata,
method: &str,
result: OracleResult,
) -> ScanFinding {
ScanFinding {
target_url: target.to_owned(),
strategy_id: meta.strategy_id.to_owned(),
strategy_name: meta.strategy_name.to_owned(),
method: method.to_owned(),
result,
}
}
fn parse_risk(s: &str) -> Result<RiskLevel, Error> {
match s {
"safe" => Ok(RiskLevel::Safe),
"method-destructive" => Ok(RiskLevel::MethodDestructive),
"operation-destructive" => Ok(RiskLevel::OperationDestructive),
other => Err(Error::Http(format!(
"invalid risk level '{other}'; expected safe | method-destructive | operation-destructive"
))),
}
}
fn parse_known_duplicate(s: &str) -> Result<KnownDuplicate, Error> {
let (field, value) = s.split_once('=').ok_or_else(|| {
Error::Http(format!("known-duplicate must be 'field=value', got '{s}'"))
})?;
Ok(KnownDuplicate { field: field.to_owned(), value: value.to_owned() })
}
fn parse_state_field(s: &str) -> Result<StateField, Error> {
let (field, value) = s.split_once('=').ok_or_else(|| {
Error::Http(format!("state-field must be 'field=value', got '{s}'"))
})?;
Ok(StateField { field: field.to_owned(), value: value.to_owned() })
}
fn parse_alt_credential(s: &str) -> Result<HeaderMap, Error> {
parse_headers(&[s.to_owned()])
}
fn build_scan_context(args: &ScanArgs) -> Result<ScanContext, Error> {
let probe_id = args
.probe_id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let max_risk = parse_risk(&args.risk)?;
let headers = parse_headers(&args.headers)?;
let known_duplicate = args
.known_duplicate
.as_deref()
.map(parse_known_duplicate)
.transpose()?;
let state_field = args.state_field.as_deref().map(parse_state_field).transpose()?;
let alt_credential = args
.alt_credential
.as_deref()
.map(parse_alt_credential)
.transpose()?;
Ok(ScanContext {
target: args.target.clone(),
baseline_id: args.baseline_id.clone(),
probe_id,
headers,
max_risk,
known_duplicate,
state_field,
alt_credential,
body_template: args.body.clone(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal_args(target: &str, baseline_id: &str) -> ScanArgs {
ScanArgs {
target: target.to_owned(),
baseline_id: baseline_id.to_owned(),
probe_id: Some("9999".to_owned()),
risk: "safe".to_owned(),
headers: vec![],
alt_credential: None,
known_duplicate: None,
state_field: None,
body: None,
}
}
#[test]
fn parse_risk_safe() {
assert_eq!(parse_risk("safe").unwrap(), RiskLevel::Safe);
}
#[test]
fn parse_risk_method_destructive() {
assert_eq!(parse_risk("method-destructive").unwrap(), RiskLevel::MethodDestructive);
}
#[test]
fn parse_risk_operation_destructive() {
assert_eq!(parse_risk("operation-destructive").unwrap(), RiskLevel::OperationDestructive);
}
#[test]
fn parse_risk_invalid_returns_err() {
assert!(parse_risk("invalid").is_err());
}
#[test]
fn parse_known_duplicate_splits_field_and_value() {
let kd = parse_known_duplicate("email=alice@example.com").unwrap();
assert_eq!(kd.field, "email");
assert_eq!(kd.value, "alice@example.com");
}
#[test]
fn parse_known_duplicate_splits_on_first_equals_only() {
let kd = parse_known_duplicate("foo=bar=baz").unwrap();
assert_eq!(kd.field, "foo");
assert_eq!(kd.value, "bar=baz");
}
#[test]
fn parse_known_duplicate_no_divider_returns_err() {
assert!(parse_known_duplicate("nodivider").is_err());
}
#[test]
fn parse_state_field_splits_correctly() {
let sf = parse_state_field("status=invalid").unwrap();
assert_eq!(sf.field, "status");
assert_eq!(sf.value, "invalid");
}
#[test]
fn build_scan_context_target_matches_and_risk_defaults_to_safe() {
let args = minimal_args("https://api.example.com/users/{id}", "1001");
let ctx = build_scan_context(&args).unwrap();
assert_eq!(ctx.target, "https://api.example.com/users/{id}");
assert_eq!(ctx.max_risk, RiskLevel::Safe);
}
}