parlov-analysis 0.7.0

Analysis engine trait and signal detection for parlov.
Documentation
//! Per-signal confidence and impact contribution weights.
//!
//! Defines the raw points each signal type contributes before family adjustment and
//! normative/reproducibility modifiers are applied.

use parlov_core::{NormativeStrength, Signal, SignalKind};

use super::families::{header_family, SignalContribution, SignalFamily};

/// Raw weight for a single signal before modifiers.
struct RawWeight {
    confidence: f32,
    impact: u8,
    family: SignalFamily,
}

/// Converts a signal into a family-tagged contribution with normative and reproducibility
/// modifiers applied.
///
/// The returned `SignalContribution` borrows `description` from `signal.evidence`.
pub(crate) fn weight_signal(
    signal: &Signal,
    strength: NormativeStrength,
    reproducibility: f32,
) -> SignalContribution<'_> {
    let raw = raw_weight(signal);
    let normative = normative_multiplier(strength);
    let adjusted_confidence = raw.confidence * normative * reproducibility;

    SignalContribution {
        family: raw.family,
        confidence: adjusted_confidence,
        impact: raw.impact,
        description: &signal.evidence,
    }
}

fn raw_weight(signal: &Signal) -> RawWeight {
    match signal.kind {
        SignalKind::HeaderPresence => header_presence_weight(signal),
        SignalKind::MetadataLeak => metadata_leak_weight(signal),
        SignalKind::HeaderValue => header_value_weight(signal),
        SignalKind::BodyDiff => body_diff_weight(signal),
        SignalKind::InputReflection => input_reflection_weight(),
        _ => RawWeight {
            confidence: 0.0,
            impact: 0,
            family: SignalFamily::General,
        },
    }
}

/// Weight for an `InputReflection` signal.
///
/// Negative confidence: the apparent body diff is fully explained by URL ID echo and is
/// evidence *against* a real existence oracle. Magnitude must dominate `body_diff_weight`
/// (-70 vs +70) so co-occurring noisy signals can't outvote the reflection penalty. Same
/// `ErrorBody` family as `BodyDiff` so family diminishing pairs them naturally.
fn input_reflection_weight() -> RawWeight {
    RawWeight {
        confidence: -70.0,
        impact: 0,
        family: SignalFamily::ErrorBody,
    }
}

fn header_presence_weight(signal: &Signal) -> RawWeight {
    let evidence = signal.evidence.to_lowercase();
    if evidence.contains("content-range") {
        return RawWeight {
            confidence: 12.0,
            impact: 8,
            family: SignalFamily::Range,
        };
    }
    if evidence.contains("etag") {
        return RawWeight {
            confidence: 10.0,
            impact: 5,
            family: SignalFamily::CacheValidator,
        };
    }
    if evidence.contains("last-modified") {
        return RawWeight {
            confidence: 8.0,
            impact: 5,
            family: SignalFamily::CacheValidator,
        };
    }
    if evidence.contains("accept-ranges") {
        return RawWeight {
            confidence: 5.0,
            impact: 0,
            family: SignalFamily::Range,
        };
    }
    if evidence.contains("www-authenticate") {
        return RawWeight {
            confidence: 8.0,
            impact: 8,
            family: SignalFamily::Auth,
        };
    }
    if evidence.contains("allow") {
        return RawWeight {
            confidence: 6.0,
            impact: 5,
            family: SignalFamily::General,
        };
    }
    if evidence.contains("location") {
        return RawWeight {
            confidence: 8.0,
            impact: 5,
            family: SignalFamily::Redirect,
        };
    }
    RawWeight {
        confidence: 3.0,
        impact: 0,
        family: SignalFamily::General,
    }
}

fn metadata_leak_weight(signal: &Signal) -> RawWeight {
    let evidence = signal.evidence.to_lowercase();
    let family = if evidence.contains("content-range") {
        SignalFamily::Range
    } else {
        header_family_from_evidence(&evidence)
    };

    if evidence.contains("content-range") && evidence.contains("size") {
        return RawWeight {
            confidence: 5.0,
            impact: 15,
            family,
        };
    }
    if evidence.contains("etag") {
        return RawWeight {
            confidence: 3.0,
            impact: 5,
            family,
        };
    }
    RawWeight {
        confidence: 3.0,
        impact: 5,
        family,
    }
}

/// Weights for body differential signals.
///
/// Body content diff is a primary signal comparable to a status code differential. When status
/// codes match (`base_confidence` = 0), this carries the full finding. Content-type mismatch is
/// a supporting signal indicating different response pipelines.
fn body_diff_weight(signal: &Signal) -> RawWeight {
    let evidence = signal.evidence.to_lowercase();
    if evidence.contains("content-type") {
        return RawWeight {
            confidence: 25.0,
            impact: 10,
            family: SignalFamily::ErrorBody,
        };
    }
    RawWeight {
        confidence: 70.0,
        impact: 15,
        family: SignalFamily::ErrorBody,
    }
}

fn header_value_weight(signal: &Signal) -> RawWeight {
    let evidence = signal.evidence.to_lowercase();
    let family = header_family_from_evidence(&evidence);
    RawWeight {
        confidence: 4.0,
        impact: 3,
        family,
    }
}

fn header_family_from_evidence(evidence: &str) -> SignalFamily {
    const KNOWN_HEADERS: &[&str] = &[
        "content-range",
        "accept-ranges",
        "etag",
        "last-modified",
        "www-authenticate",
        "allow",
        "location",
    ];
    for name in KNOWN_HEADERS {
        if evidence.contains(name) {
            return header_family(name);
        }
    }
    SignalFamily::General
}

fn normative_multiplier(strength: NormativeStrength) -> f32 {
    match strength {
        NormativeStrength::Must | NormativeStrength::MustNot => 1.0,
        NormativeStrength::Should => 0.9,
        NormativeStrength::May => 0.75,
    }
}

#[cfg(test)]
#[path = "signal_weights_tests.rs"]
mod tests;