use http::{HeaderMap, Method};
use parlov_core::{
always_applicable, NormativeStrength, OracleClass, ResponseClass, SignalSurface, Technique,
Vector,
};
use crate::chain::{Consumer, Producer, ProducerOutput, ProducerOutputKind};
use crate::context::ScanContext;
use crate::types::{ProbeSpec, RiskLevel, StrategyMetadata};
use crate::util::{build_pair, try_clone_headers_with};
static CHAIN_METADATA: StrategyMetadata = StrategyMetadata {
strategy_id: "scd-auth-challenge-chain",
strategy_name: "Auth-Challenge Chain (scope-manipulation probe)",
risk: RiskLevel::Safe,
};
static CHAIN_TECHNIQUE: Technique = Technique {
id: "auth-challenge-chain",
name: "Auth-challenge-informed scope-manipulation chain",
oracle_class: OracleClass::Existence,
vector: Vector::StatusCodeDiff,
strength: NormativeStrength::Should,
normalization_weight: None,
inverted_signal_weight: None,
method_relevant: false,
parser_relevant: false,
applicability: always_applicable,
contradiction_surface: SignalSurface::Status,
};
pub(super) struct ScdAuthChallengeProducer;
impl Producer for ScdAuthChallengeProducer {
fn admits(&self, class: ResponseClass) -> bool {
matches!(class, ResponseClass::AuthChallenge)
}
fn extract(&self, _class: ResponseClass, headers: &HeaderMap) -> Option<ProducerOutput> {
let raw = headers.get(http::header::WWW_AUTHENTICATE)?.to_str().ok()?;
let scheme = parse_scheme(raw);
if scheme.is_empty() {
return None;
}
let realm = parse_param(raw, "realm");
let scope = parse_param(raw, "scope");
Some(ProducerOutput::AuthChallenge {
scheme: scheme.to_owned(),
realm,
scope,
})
}
}
fn parse_scheme(raw: &str) -> &str {
raw.split_ascii_whitespace()
.next()
.unwrap_or("")
.trim_end_matches(',')
}
fn parse_param(raw: &str, key: &str) -> Option<String> {
let needle = key;
let params_start = raw.find(char::is_whitespace).unwrap_or(raw.len());
let params = &raw[params_start..];
for part in params.split(',') {
let part = part.trim();
let Some(rest) = part.strip_prefix(needle) else {
continue;
};
let rest = rest.trim_start_matches(char::is_whitespace);
let Some(rest) = rest.strip_prefix('=') else {
continue;
};
let rest = rest.trim();
let value = if rest.starts_with('"') {
rest.trim_matches('"').to_owned()
} else {
rest.split_ascii_whitespace()
.next()
.unwrap_or(rest)
.to_owned()
};
if !value.is_empty() {
return Some(value);
}
}
None
}
pub(super) struct ScdAuthChallengeConsumer;
impl Consumer for ScdAuthChallengeConsumer {
fn needs(&self) -> ProducerOutputKind {
ProducerOutputKind::AuthChallenge
}
fn generate(&self, ctx: &ScanContext, output: &ProducerOutput) -> Vec<ProbeSpec> {
let ProducerOutput::AuthChallenge { scheme, realm, .. } = output else {
return vec![];
};
let auth_value = build_auth_value(scheme, realm.as_deref());
let Some(hdrs) = try_clone_headers_with(&ctx.headers, "authorization", &auth_value) else {
return vec![];
};
vec![ProbeSpec::Pair(build_pair(
ctx,
Method::GET,
hdrs.clone(),
hdrs,
None,
CHAIN_METADATA.clone(),
CHAIN_TECHNIQUE,
))]
}
}
fn build_auth_value(scheme: &str, realm: Option<&str>) -> String {
match scheme {
"Bearer" => match realm {
Some(r) => format!("Bearer realm=\"{r}\""),
None => "Bearer".to_owned(),
},
"Basic" => "Basic Og==".to_owned(),
other => other.to_owned(),
}
}
#[cfg(test)]
#[path = "auth_challenge_tests.rs"]
mod tests;