parlov-core 0.7.0

Shared types, error types, and oracle class definitions for parlov.
Documentation
//! Shared types, error types, and oracle class definitions used across all parlov crates.
//!
//! This crate is the dependency root of the workspace — it carries no deps on other workspace
//! crates and is designed to compile fast. Everything in here is pure data: no I/O, no async,
//! no heavy dependencies.

#![deny(clippy::all)]
#![warn(clippy::pedantic)]
#![deny(missing_docs)]

mod endpoint_stop;
mod endpoint_verdict;
mod exchange;
mod finding_id;
mod observability;
mod observability_compute;
mod outcome;
mod response_class;
mod scoring;
mod serde_helpers;
mod signal;
mod technique;

pub use endpoint_stop::StrategyMetaForStop;
pub use endpoint_verdict::{
    posterior_to_verdict, verdict_to_severity, ContributingFinding, EndpointStopReason,
    EndpointVerdict, StrategyOutcomeKind,
};
pub use exchange::{DifferentialSet, ProbeExchange};
pub use finding_id::finding_id;
pub use observability::{BlockFamily, BlockSummary, ObservabilityStatus};
pub use observability_compute::compute_observability;
pub use outcome::StrategyOutcome;
pub use response_class::ResponseClass;
pub use scoring::{ScoringDimension, ScoringReason};
pub use signal::{ImpactClass, Signal, SignalKind};
pub use technique::{
    always_applicable, Applicability, NormativeStrength, RequestAuthState, SignalSurface,
    Technique, Vector,
};

use bytes::Bytes;
use http::{HeaderMap, Method, StatusCode};
use serde::{Deserialize, Serialize};
use serde_helpers::{
    bytes_serde, header_map_serde, method_serde, opt_bytes_serde, status_code_serde,
};

/// A single HTTP interaction: full response surface and wall-clock timing.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResponseSurface {
    /// Verbatim server-returned code — never normalized or synthesized.
    #[serde(with = "status_code_serde")]
    pub status: StatusCode,
    /// Complete response headers, including multi-valued entries.
    #[serde(with = "header_map_serde")]
    pub headers: HeaderMap,
    /// Body bytes. Base64-encoded when serialized.
    #[serde(with = "bytes_serde")]
    pub body: Bytes,
    /// Wall-clock round-trip in nanoseconds.
    pub timing_ns: u64,
}

/// A single HTTP request to execute against a target.
///
/// The authorization context is expressed entirely through the `headers` field — set an
/// `Authorization` header for bearer tokens, API keys, or Basic auth. No special-case auth
/// fields.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProbeDefinition {
    /// absolute URL — not a relative path
    pub url: String,
    /// HTTP method
    #[serde(with = "method_serde")]
    pub method: Method,
    /// Auth credentials live here — no dedicated auth fields exist.
    #[serde(with = "header_map_serde")]
    pub headers: HeaderMap,
    /// Request body. `None` for GET, HEAD, DELETE; `Some` for POST, PATCH, PUT.
    #[serde(with = "opt_bytes_serde")]
    pub body: Option<Bytes>,
}

/// The oracle class being probed.
///
/// Each variant corresponds to a distinct detection strategy and analysis pipeline.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub enum OracleClass {
    /// Status-code or body differential between an existing and nonexistent resource.
    Existence,
}

/// Confidence level of an oracle detection result.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum OracleVerdict {
    /// Signal is unambiguous: differential is consistent, statistically significant, and matches
    /// a known oracle pattern.
    Confirmed,
    /// Signal is present and consistent with an oracle, but evidence is not conclusive (e.g.
    /// borderline p-value, single sample).
    Likely,
    /// Signal is present but too weak or inconsistent to classify.
    Inconclusive,
    /// No differential signal detected; the endpoint does not exhibit this oracle.
    NotPresent,
}

/// Severity of a confirmed or likely oracle.
///
/// `None` on an `OracleResult` when the verdict is `NotPresent` or `Inconclusive`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum Severity {
    /// Resource existence, valid credentials, or session state leaked to unauthorized callers.
    High,
    /// Leaks internal state but requires additional steps to exploit.
    Medium,
    /// Informational: leaks metadata that may assist further enumeration.
    Low,
}

/// The result of running an oracle analyzer against a differential set.
///
/// Carries the full signal chain that produced the verdict alongside technique context and
/// scoring breakdown.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct OracleResult {
    /// Oracle class this result describes.
    pub class: OracleClass,
    /// confidence in the oracle detection
    pub verdict: OracleVerdict,
    /// Severity when the verdict is `Confirmed` or `Likely`; `None` when `NotPresent`.
    pub severity: Option<Severity>,
    /// Numeric confidence score (0-100). Determines verdict via threshold mapping.
    #[serde(default)]
    pub confidence: u8,
    /// Impact classification based on leak type. Determines severity when gated by confidence.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub impact_class: Option<ImpactClass>,
    /// Breakdown of how confidence and impact were computed.
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub reasons: Vec<ScoringReason>,
    /// typed observations from differential analysis — the atoms that determined the verdict
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub signals: Vec<Signal>,
    /// e.g. `"if-none-match"` — the strategy that generated the probe
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub technique_id: Option<String>,
    /// Detection vector that elicited the differential.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub vector: Option<Vector>,
    /// RFC mandate level. Affects confidence calibration.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub normative_strength: Option<NormativeStrength>,
    /// e.g. `"Authorization-based differential"`
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub label: Option<String>,
    /// e.g. `"Resource existence confirmed to low-privilege callers"`
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub leaks: Option<String>,
    /// e.g. `"RFC 9110 \u{00a7}15.5.4"`
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub rfc_basis: Option<String>,
}

impl OracleResult {
    /// Evidence from the `StatusCodeDiff` signal, else the first signal, else `"—"`.
    #[must_use]
    pub fn primary_evidence(&self) -> &str {
        self.signals
            .iter()
            .find(|s| s.kind == SignalKind::StatusCodeDiff)
            .or_else(|| self.signals.first())
            .map_or("", |s| s.evidence.as_str())
    }

    /// Builds an `OracleResult` with one signal and technique context populated from `technique`.
    #[must_use]
    pub fn from_technique(
        verdict: OracleVerdict,
        severity: Option<Severity>,
        evidence: String,
        signal_kind: SignalKind,
        confidence: u8,
        technique: &Technique,
    ) -> Self {
        Self {
            class: technique.oracle_class,
            verdict,
            severity,
            confidence,
            impact_class: None,
            reasons: vec![],
            signals: vec![Signal {
                kind: signal_kind,
                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,
        }
    }

    /// `Confirmed` → `Positive`; all other verdicts → `NoSignal`.
    #[must_use]
    pub fn into_outcome(self) -> StrategyOutcome {
        if self.verdict == OracleVerdict::Confirmed {
            StrategyOutcome::Positive(self)
        } else {
            StrategyOutcome::NoSignal(self)
        }
    }
}

/// Probe, analysis, CLI, and serialization failures.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    /// HTTP client or network failure during probing.
    #[error("http error: {0}")]
    Http(String),
    /// Invalid or missing CLI arguments.
    #[error("cli error: {0}")]
    Cli(String),
    /// Insufficient or malformed probe data for analysis.
    #[error("analysis error: {0}")]
    Analysis(String),
    /// JSON serialization or deserialization failure.
    #[error("serialization error: {0}")]
    Serialization(#[from] serde_json::Error),
}

impl std::fmt::Display for OracleClass {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Existence => write!(f, "Existence"),
        }
    }
}

impl std::fmt::Display for OracleVerdict {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Confirmed => write!(f, "Confirmed"),
            Self::Likely => write!(f, "Likely"),
            Self::Inconclusive => write!(f, "Inconclusive"),
            Self::NotPresent => write!(f, "NotPresent"),
        }
    }
}

impl std::fmt::Display for Severity {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::High => write!(f, "High"),
            Self::Medium => write!(f, "Medium"),
            Self::Low => write!(f, "Low"),
        }
    }
}

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