Skip to main content

parlov_core/
lib.rs

1//! Shared types, error types, and oracle class definitions used across all parlov crates.
2//!
3//! This crate is the dependency root of the workspace — it carries no deps on other workspace
4//! crates and is designed to compile fast. Everything in here is pure data: no I/O, no async,
5//! no heavy dependencies.
6
7#![deny(clippy::all)]
8#![warn(clippy::pedantic)]
9#![deny(missing_docs)]
10
11mod endpoint_stop;
12mod endpoint_verdict;
13mod exchange;
14mod finding_id;
15mod observability;
16mod observability_compute;
17mod outcome;
18mod response_class;
19mod scoring;
20mod serde_helpers;
21mod signal;
22mod technique;
23
24pub use endpoint_stop::StrategyMetaForStop;
25pub use endpoint_verdict::{
26    posterior_to_verdict, verdict_to_severity, ContributingFinding, EndpointStopReason,
27    EndpointVerdict, StrategyOutcomeKind,
28};
29pub use exchange::{DifferentialSet, ProbeExchange};
30pub use finding_id::finding_id;
31pub use observability::{BlockFamily, BlockSummary, ObservabilityStatus};
32pub use observability_compute::compute_observability;
33pub use outcome::StrategyOutcome;
34pub use response_class::ResponseClass;
35pub use scoring::{ScoringDimension, ScoringReason};
36pub use signal::{ImpactClass, Signal, SignalKind};
37pub use technique::{
38    always_applicable, Applicability, NormativeStrength, RequestAuthState, SignalSurface,
39    Technique, Vector,
40};
41
42use bytes::Bytes;
43use http::{HeaderMap, Method, StatusCode};
44use serde::{Deserialize, Serialize};
45use serde_helpers::{
46    bytes_serde, header_map_serde, method_serde, opt_bytes_serde, status_code_serde,
47};
48
49/// A single HTTP interaction: full response surface and wall-clock timing.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ResponseSurface {
52    /// Verbatim server-returned code — never normalized or synthesized.
53    #[serde(with = "status_code_serde")]
54    pub status: StatusCode,
55    /// Complete response headers, including multi-valued entries.
56    #[serde(with = "header_map_serde")]
57    pub headers: HeaderMap,
58    /// Body bytes. Base64-encoded when serialized.
59    #[serde(with = "bytes_serde")]
60    pub body: Bytes,
61    /// Wall-clock round-trip in nanoseconds.
62    pub timing_ns: u64,
63}
64
65/// A single HTTP request to execute against a target.
66///
67/// The authorization context is expressed entirely through the `headers` field — set an
68/// `Authorization` header for bearer tokens, API keys, or Basic auth. No special-case auth
69/// fields.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ProbeDefinition {
72    /// absolute URL — not a relative path
73    pub url: String,
74    /// HTTP method
75    #[serde(with = "method_serde")]
76    pub method: Method,
77    /// Auth credentials live here — no dedicated auth fields exist.
78    #[serde(with = "header_map_serde")]
79    pub headers: HeaderMap,
80    /// Request body. `None` for GET, HEAD, DELETE; `Some` for POST, PATCH, PUT.
81    #[serde(with = "opt_bytes_serde")]
82    pub body: Option<Bytes>,
83}
84
85/// The oracle class being probed.
86///
87/// Each variant corresponds to a distinct detection strategy and analysis pipeline.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
89#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
90#[non_exhaustive]
91pub enum OracleClass {
92    /// Status-code or body differential between an existing and nonexistent resource.
93    Existence,
94}
95
96/// Confidence level of an oracle detection result.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
98#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
99pub enum OracleVerdict {
100    /// Signal is unambiguous: differential is consistent, statistically significant, and matches
101    /// a known oracle pattern.
102    Confirmed,
103    /// Signal is present and consistent with an oracle, but evidence is not conclusive (e.g.
104    /// borderline p-value, single sample).
105    Likely,
106    /// Signal is present but too weak or inconsistent to classify.
107    Inconclusive,
108    /// No differential signal detected; the endpoint does not exhibit this oracle.
109    NotPresent,
110}
111
112/// Severity of a confirmed or likely oracle.
113///
114/// `None` on an `OracleResult` when the verdict is `NotPresent` or `Inconclusive`.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
116#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
117pub enum Severity {
118    /// Resource existence, valid credentials, or session state leaked to unauthorized callers.
119    High,
120    /// Leaks internal state but requires additional steps to exploit.
121    Medium,
122    /// Informational: leaks metadata that may assist further enumeration.
123    Low,
124}
125
126/// The result of running an oracle analyzer against a differential set.
127///
128/// Carries the full signal chain that produced the verdict alongside technique context and
129/// scoring breakdown.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
132pub struct OracleResult {
133    /// Oracle class this result describes.
134    pub class: OracleClass,
135    /// confidence in the oracle detection
136    pub verdict: OracleVerdict,
137    /// Severity when the verdict is `Confirmed` or `Likely`; `None` when `NotPresent`.
138    pub severity: Option<Severity>,
139    /// Numeric confidence score (0-100). Determines verdict via threshold mapping.
140    #[serde(default)]
141    pub confidence: u8,
142    /// Impact classification based on leak type. Determines severity when gated by confidence.
143    #[serde(skip_serializing_if = "Option::is_none", default)]
144    pub impact_class: Option<ImpactClass>,
145    /// Breakdown of how confidence and impact were computed.
146    #[serde(skip_serializing_if = "Vec::is_empty", default)]
147    pub reasons: Vec<ScoringReason>,
148    /// typed observations from differential analysis — the atoms that determined the verdict
149    #[serde(skip_serializing_if = "Vec::is_empty", default)]
150    pub signals: Vec<Signal>,
151    /// e.g. `"if-none-match"` — the strategy that generated the probe
152    #[serde(skip_serializing_if = "Option::is_none", default)]
153    pub technique_id: Option<String>,
154    /// Detection vector that elicited the differential.
155    #[serde(skip_serializing_if = "Option::is_none", default)]
156    pub vector: Option<Vector>,
157    /// RFC mandate level. Affects confidence calibration.
158    #[serde(skip_serializing_if = "Option::is_none", default)]
159    pub normative_strength: Option<NormativeStrength>,
160    /// e.g. `"Authorization-based differential"`
161    #[serde(skip_serializing_if = "Option::is_none", default)]
162    pub label: Option<String>,
163    /// e.g. `"Resource existence confirmed to low-privilege callers"`
164    #[serde(skip_serializing_if = "Option::is_none", default)]
165    pub leaks: Option<String>,
166    /// e.g. `"RFC 9110 \u{00a7}15.5.4"`
167    #[serde(skip_serializing_if = "Option::is_none", default)]
168    pub rfc_basis: Option<String>,
169}
170
171impl OracleResult {
172    /// Evidence from the `StatusCodeDiff` signal, else the first signal, else `"—"`.
173    #[must_use]
174    pub fn primary_evidence(&self) -> &str {
175        self.signals
176            .iter()
177            .find(|s| s.kind == SignalKind::StatusCodeDiff)
178            .or_else(|| self.signals.first())
179            .map_or("—", |s| s.evidence.as_str())
180    }
181
182    /// Builds an `OracleResult` with one signal and technique context populated from `technique`.
183    #[must_use]
184    pub fn from_technique(
185        verdict: OracleVerdict,
186        severity: Option<Severity>,
187        evidence: String,
188        signal_kind: SignalKind,
189        confidence: u8,
190        technique: &Technique,
191    ) -> Self {
192        Self {
193            class: technique.oracle_class,
194            verdict,
195            severity,
196            confidence,
197            impact_class: None,
198            reasons: vec![],
199            signals: vec![Signal {
200                kind: signal_kind,
201                evidence,
202                rfc_basis: None,
203            }],
204            technique_id: Some(technique.id.to_string()),
205            vector: Some(technique.vector),
206            normative_strength: Some(technique.strength),
207            label: None,
208            leaks: None,
209            rfc_basis: None,
210        }
211    }
212
213    /// `Confirmed` → `Positive`; all other verdicts → `NoSignal`.
214    #[must_use]
215    pub fn into_outcome(self) -> StrategyOutcome {
216        if self.verdict == OracleVerdict::Confirmed {
217            StrategyOutcome::Positive(self)
218        } else {
219            StrategyOutcome::NoSignal(self)
220        }
221    }
222}
223
224/// Probe, analysis, CLI, and serialization failures.
225#[derive(Debug, thiserror::Error)]
226#[non_exhaustive]
227pub enum Error {
228    /// HTTP client or network failure during probing.
229    #[error("http error: {0}")]
230    Http(String),
231    /// Invalid or missing CLI arguments.
232    #[error("cli error: {0}")]
233    Cli(String),
234    /// Insufficient or malformed probe data for analysis.
235    #[error("analysis error: {0}")]
236    Analysis(String),
237    /// JSON serialization or deserialization failure.
238    #[error("serialization error: {0}")]
239    Serialization(#[from] serde_json::Error),
240}
241
242impl std::fmt::Display for OracleClass {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        match self {
245            Self::Existence => write!(f, "Existence"),
246        }
247    }
248}
249
250impl std::fmt::Display for OracleVerdict {
251    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252        match self {
253            Self::Confirmed => write!(f, "Confirmed"),
254            Self::Likely => write!(f, "Likely"),
255            Self::Inconclusive => write!(f, "Inconclusive"),
256            Self::NotPresent => write!(f, "NotPresent"),
257        }
258    }
259}
260
261impl std::fmt::Display for Severity {
262    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263        match self {
264            Self::High => write!(f, "High"),
265            Self::Medium => write!(f, "Medium"),
266            Self::Low => write!(f, "Low"),
267        }
268    }
269}
270
271#[cfg(test)]
272#[path = "lib_tests.rs"]
273mod tests;