use http::HeaderMap;
use parlov_core::{Error, OracleClass, OracleResult, OracleVerdict, Severity, Signal, SignalKind};
use parlov_elicit::{BurstSpec, ProbePair, ProbeSpec, 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_with_technique;
use crate::scan_context::build_scan_context;
use crate::vector_filter::{apply_vector_filters, parse_vector_flag, VectorFilter};
pub async fn run(args: ScanArgs, format: OutputFormat) -> Result<(), Error> {
let vector_filters = parse_vector_flags(&args.vectors)?;
validate_vector_risk_exclusivity(&args, &vector_filters)?;
let ctx = build_scan_context(&args, &vector_filters)?;
let plan = build_filtered_plan(&ctx, &vector_filters);
let mut findings: Vec<ScanFinding> = Vec::new();
for spec in &plan {
match dispatch_spec(&args.target, spec).await {
Ok(finding) => findings.push(finding),
Err(e) => warn!("strategy failed, skipping: {e}"),
}
}
let output = format_scan_results(format, &args.target, &findings)?;
println!("{output}");
Ok(())
}
fn format_scan_results(
format: OutputFormat,
target: &str,
findings: &[ScanFinding],
) -> Result<String, Error> {
match format {
OutputFormat::Table => Ok(render_scan_table(findings)),
OutputFormat::Json => {
render_scan_json(target, findings).map_err(Error::Serialization)
}
OutputFormat::Sarif => {
render_scan_sarif(target, 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_with_technique(&pair.baseline, &pair.probe, pair.technique.clone()).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 (baseline_429, probe_429) = collect_429_counts(&client, spec).await?;
let result = burst_result(baseline_429, probe_429, &spec.technique);
let method = spec.baseline.method.as_str().to_owned();
Ok(make_finding(target, &spec.metadata, &method, result))
}
async fn collect_429_counts(
client: &HttpProbe,
spec: &BurstSpec,
) -> Result<(usize, usize), Error> {
let mut baseline_429 = 0usize;
let mut probe_429 = 0usize;
for _ in 0..spec.burst_count {
let exchange = client.execute(&spec.baseline).await?;
if exchange.response.status.as_u16() == 429 {
baseline_429 += 1;
}
}
for _ in 0..spec.burst_count {
let exchange = client.execute(&spec.probe).await?;
if exchange.response.status.as_u16() == 429 {
probe_429 += 1;
}
}
Ok((baseline_429, probe_429))
}
fn burst_result(
baseline_429: usize,
probe_429: usize,
technique: &parlov_core::Technique,
) -> 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)
};
technique_result(verdict, severity, evidence, technique)
}
async fn run_header_diff(target: &str, pair: &ProbePair) -> Result<ScanFinding, Error> {
let client = HttpProbe::new();
let (b_exchange, p_exchange) =
tokio::try_join!(client.execute(&pair.baseline), client.execute(&pair.probe))?;
let diff_headers =
rate_limit_diff(&b_exchange.response.headers, &p_exchange.response.headers);
let result = header_diff_result(&diff_headers, &pair.technique);
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| is_rate_limit_header(k.as_str()) && !probe.contains_key(*k))
.map(|k| k.as_str().to_owned())
.collect()
}
fn is_rate_limit_header(name: &str) -> bool {
name.starts_with("ratelimit-")
|| name.starts_with("x-ratelimit-")
|| name == "ratelimit"
|| name == "x-ratelimit"
}
fn header_diff_result(diff: &[String], technique: &parlov_core::Technique) -> OracleResult {
let (verdict, severity, evidence) = if diff.is_empty() {
(OracleVerdict::NotPresent, None, "no rate-limit header differential".to_owned())
} else {
let ev = format!("rate-limit headers in baseline only: {}", diff.join(", "));
(OracleVerdict::Confirmed, Some(Severity::Low), ev)
};
technique_result(verdict, severity, evidence, technique)
}
fn technique_result(
verdict: OracleVerdict,
severity: Option<Severity>,
evidence: String,
technique: &parlov_core::Technique,
) -> OracleResult {
OracleResult {
class: OracleClass::Existence,
verdict,
severity,
confidence: 0,
impact_class: None,
reasons: vec![],
signals: vec![Signal {
kind: SignalKind::StatusCodeDiff,
evidence,
rfc_basis: None,
}],
technique_id: Some(technique.id.to_string()),
vector: Some(technique.vector),
normative_strength: Some(technique.strength),
label: None,
leaks: None,
rfc_basis: None,
}
}
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_vector_flags(raw: &[String]) -> Result<Vec<VectorFilter>, Error> {
raw.iter().map(|s| parse_vector_flag(s)).collect()
}
fn validate_vector_risk_exclusivity(
args: &ScanArgs,
filters: &[VectorFilter],
) -> Result<(), Error> {
if !filters.is_empty() && args.risk != "safe" {
return Err(Error::Http(
"--vector and --risk are mutually exclusive".to_owned(),
));
}
Ok(())
}
fn build_filtered_plan(
ctx: &parlov_elicit::ScanContext,
filters: &[VectorFilter],
) -> Vec<ProbeSpec> {
let plan = parlov_elicit::generate_plan(ctx);
if filters.is_empty() {
plan
} else {
apply_vector_filters(plan, filters)
}
}
#[cfg(test)]
mod tests {
use super::*;
use parlov_elicit::RiskLevel;
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,
vectors: vec![],
body: None,
}
}
#[test]
fn vector_and_risk_mutually_exclusive() {
let mut args = minimal_args("https://api.example.com/users/{id}", "1001");
args.risk = "method-destructive".to_owned();
args.vectors = vec!["cache-probing".to_owned()];
let filters = parse_vector_flags(&args.vectors).unwrap();
assert!(validate_vector_risk_exclusivity(&args, &filters).is_err());
}
#[test]
fn vector_with_default_risk_is_not_exclusive() {
let mut args = minimal_args("https://api.example.com/users/{id}", "1001");
args.vectors = vec!["cache-probing".to_owned()];
let filters = parse_vector_flags(&args.vectors).unwrap();
assert!(validate_vector_risk_exclusivity(&args, &filters).is_ok());
}
#[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);
}
}