use crate::{
BlockFamily, BlockSummary, ContributingFinding, ObservabilityStatus, StrategyOutcomeKind,
};
const MIN_OPPORTUNITIES: usize = 3;
const BLOCKED_THRESHOLD: f64 = 0.80;
const PARTIALLY_BLOCKED_LOWER: f64 = 0.20;
#[must_use]
pub fn compute_observability(
findings: &[ContributingFinding],
) -> (ObservabilityStatus, Option<BlockSummary>) {
let evidence_count = count_evidence_bearing(findings);
if evidence_count >= 1 {
return (ObservabilityStatus::EvidenceObserved, None);
}
let opportunities = count_expected_opportunities(findings);
if opportunities < MIN_OPPORTUNITIES {
return (ObservabilityStatus::Underpowered, None);
}
let (scan_wide_blocked, surface_blocked) = count_blocked(findings);
#[allow(clippy::cast_precision_loss)]
let blocked_fraction = scan_wide_blocked as f64 / opportunities as f64;
let dominant = dominant_scan_wide_family(findings);
if surface_blocked > 0 && scan_wide_blocked == 0 {
return (ObservabilityStatus::SurfaceMismatch, None);
}
if blocked_fraction >= BLOCKED_THRESHOLD
&& matches!(
dominant,
Some(BlockFamily::Authorization | BlockFamily::Method)
)
{
let summary = build_block_summary(findings, opportunities, scan_wide_blocked, dominant);
return (ObservabilityStatus::BlockedBeforeOracleLayer, Some(summary));
}
let reached_fraction = 1.0 - blocked_fraction;
if reached_fraction >= BLOCKED_THRESHOLD {
return (ObservabilityStatus::ProbedNoEvidence, None);
}
if blocked_fraction >= PARTIALLY_BLOCKED_LOWER {
let summary = build_block_summary(findings, opportunities, scan_wide_blocked, dominant);
return (ObservabilityStatus::PartiallyBlocked, Some(summary));
}
(ObservabilityStatus::ProbedNoEvidence, None)
}
fn count_evidence_bearing(findings: &[ContributingFinding]) -> usize {
findings
.iter()
.filter(|f| {
matches!(
f.outcome_kind,
StrategyOutcomeKind::Positive | StrategyOutcomeKind::Contradictory
)
})
.count()
}
fn count_expected_opportunities(findings: &[ContributingFinding]) -> usize {
findings
.iter()
.filter(|f| f.outcome_kind == StrategyOutcomeKind::Inapplicable)
.count()
}
fn count_blocked(findings: &[ContributingFinding]) -> (usize, usize) {
let mut scan_wide = 0usize;
let mut surface = 0usize;
for f in findings {
if f.outcome_kind != StrategyOutcomeKind::Inapplicable {
continue;
}
match f.block_family {
Some(BlockFamily::Authorization | BlockFamily::Method) => scan_wide += 1,
Some(BlockFamily::Surface) => surface += 1,
_ => {}
}
}
(scan_wide, surface)
}
fn dominant_scan_wide_family(findings: &[ContributingFinding]) -> Option<BlockFamily> {
let mut auth_count = 0usize;
let mut method_count = 0usize;
for f in findings {
if f.outcome_kind != StrategyOutcomeKind::Inapplicable {
continue;
}
match f.block_family {
Some(BlockFamily::Authorization) => auth_count += 1,
Some(BlockFamily::Method) => method_count += 1,
_ => {}
}
}
if auth_count == 0 && method_count == 0 {
return None;
}
if auth_count >= method_count {
Some(BlockFamily::Authorization)
} else {
Some(BlockFamily::Method)
}
}
fn build_block_summary(
findings: &[ContributingFinding],
opportunities: usize,
blocked: usize,
dominant: Option<BlockFamily>,
) -> BlockSummary {
let dominant_family = dominant.unwrap_or(BlockFamily::Authorization);
let reasons = collect_dominant_reasons(findings, dominant_family);
let operator_action = derive_operator_action(dominant_family, &reasons);
#[allow(clippy::cast_precision_loss)]
let blocked_fraction = if opportunities > 0 {
blocked as f64 / opportunities as f64
} else {
0.0
};
BlockSummary {
expected_observation_opportunities: opportunities,
blocked_before_oracle_layer: blocked,
blocked_fraction,
dominant_block_family: dominant_family,
dominant_block_reasons: reasons,
operator_action,
}
}
fn collect_dominant_reasons(findings: &[ContributingFinding], family: BlockFamily) -> Vec<String> {
let mut seen = std::collections::BTreeSet::new();
for f in findings {
if f.outcome_kind != StrategyOutcomeKind::Inapplicable {
continue;
}
if f.block_family == Some(family) {
if let Some(reason) = &f.block_reason {
seen.insert(reason.clone());
}
}
}
seen.into_iter().collect()
}
fn derive_operator_action(family: BlockFamily, reasons: &[String]) -> Option<String> {
match family {
BlockFamily::Authorization => Some(auth_action(reasons)),
BlockFamily::Method => Some(
"Retry with a different HTTP method — the server rejects this method before resource \
lookup"
.to_owned(),
),
_ => None,
}
}
fn auth_action(reasons: &[String]) -> String {
let joined = reasons.join(" ");
if joined.contains("no credential provided") {
return r#"Retry with --header "Authorization: Bearer <token>""#.to_owned();
}
if joined.contains("credential rejected") {
return "Retry with a valid credential — current token is invalid/expired".to_owned();
}
if joined.contains("lacks required scope") {
return "Retry with a credential whose scope includes the required permission".to_owned();
}
if joined.contains("proxy auth required") {
return "Configure proxy authentication".to_owned();
}
if joined.contains("network/captive-portal") {
return "Authenticate to network/captive portal".to_owned();
}
if joined.contains("login-redirect fired") {
return "Establish a session via the login flow before scanning".to_owned();
}
r#"Retry with --header "Authorization: Bearer <token>""#.to_owned()
}
#[cfg(test)]
#[path = "observability_compute_tests.rs"]
mod tests;