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 serde_helpers;
12
13use bytes::Bytes;
14use http::{HeaderMap, Method, StatusCode};
15use serde::{Deserialize, Serialize};
16use serde_helpers::{
17    bytes_serde, header_map_serde, method_serde, opt_bytes_serde, status_code_serde,
18};
19
20/// A single HTTP interaction: full response surface and wall-clock timing.
21///
22/// Captures everything needed for differential analysis — status, headers, body, and timing —
23/// in one flat structure.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ResponseSurface {
26    /// HTTP status code returned by the server.
27    #[serde(with = "status_code_serde")]
28    pub status: StatusCode,
29    /// Full response header map.
30    #[serde(with = "header_map_serde")]
31    pub headers: HeaderMap,
32    /// Raw response body bytes, serialized as a base64-encoded byte sequence.
33    #[serde(with = "bytes_serde")]
34    pub body: Bytes,
35    /// Wall-clock response time in nanoseconds, measured from first byte sent to last byte
36    /// received.
37    pub timing_ns: u64,
38}
39
40/// A single HTTP request to execute against a target.
41///
42/// The authorization context is expressed entirely through the `headers` field — set an
43/// `Authorization` header for bearer tokens, API keys, or Basic auth. No special-case auth
44/// fields.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ProbeDefinition {
47    /// Fully-qualified target URL including scheme, host, path, and any query parameters.
48    pub url: String,
49    /// HTTP method for the request.
50    #[serde(with = "method_serde")]
51    pub method: Method,
52    /// Request headers, including any authorization context.
53    #[serde(with = "header_map_serde")]
54    pub headers: HeaderMap,
55    /// Request body. `None` for GET, HEAD, DELETE; `Some` for POST, PATCH, PUT.
56    #[serde(with = "opt_bytes_serde")]
57    pub body: Option<Bytes>,
58}
59
60/// Paired response surfaces for differential analysis.
61///
62/// `baseline` holds responses for the control input (e.g. a known-existing resource ID).
63/// `probe` holds responses for the variable input (e.g. a randomly generated nonexistent ID).
64/// Multiple samples per side support statistical analysis for timing oracles.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ProbeSet {
67    /// Responses for the known-valid / control input.
68    pub baseline: Vec<ResponseSurface>,
69    /// Responses for the unknown / suspect input.
70    pub probe: Vec<ResponseSurface>,
71}
72
73/// The oracle class being probed.
74///
75/// Each variant corresponds to a distinct detection strategy and analysis pipeline.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
77#[non_exhaustive]
78pub enum OracleClass {
79    /// Status-code or body differential between an existing and nonexistent resource.
80    Existence,
81}
82
83/// Confidence level of an oracle detection result.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
85pub enum OracleVerdict {
86    /// Signal is unambiguous: differential is consistent, statistically significant, and matches
87    /// a known oracle pattern.
88    Confirmed,
89    /// Signal is present and consistent with an oracle, but evidence is not conclusive (e.g.
90    /// borderline p-value, single sample).
91    Likely,
92    /// Signal is present but too weak or inconsistent to classify.
93    Inconclusive,
94    /// No differential signal detected; the endpoint does not exhibit this oracle.
95    NotPresent,
96}
97
98/// Severity of a confirmed or likely oracle.
99///
100/// `None` on an `OracleResult` when the verdict is `NotPresent` or `Inconclusive`.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
102pub enum Severity {
103    /// Directly actionable: resource existence, valid credentials, or session state is leaked
104    /// to unauthenticated or low-privilege callers.
105    High,
106    /// Leaks internal state but requires additional steps to exploit.
107    Medium,
108    /// Informational: leaks metadata that may assist further enumeration.
109    Low,
110}
111
112/// The result of running an oracle analyzer against a `ProbeSet`.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct OracleResult {
115    /// Which oracle class produced this result.
116    pub class: OracleClass,
117    /// Confidence verdict.
118    pub verdict: OracleVerdict,
119    /// Human-readable descriptions of each signal contributing to the verdict, e.g.
120    /// `"403 (baseline) vs 404 (probe)"`.
121    pub evidence: Vec<String>,
122    /// Severity when the verdict is `Confirmed` or `Likely`; `None` when `NotPresent`.
123    pub severity: Option<Severity>,
124    /// Human-readable name for the detected pattern, e.g. `"Authorization-based differential"`.
125    /// `None` when no oracle is detected.
126    #[serde(skip_serializing_if = "Option::is_none", default)]
127    pub label: Option<String>,
128    /// What information the oracle leaks, e.g.
129    /// `"Resource existence confirmed to low-privilege callers"`.
130    /// `None` when no oracle is detected.
131    #[serde(skip_serializing_if = "Option::is_none", default)]
132    pub leaks: Option<String>,
133    /// RFC section grounding the behavior, e.g. `"RFC 9110 §15.5.4"`.
134    /// `None` when no oracle is detected.
135    #[serde(skip_serializing_if = "Option::is_none", default)]
136    pub rfc_basis: Option<String>,
137}
138
139/// Errors produced by parlov crates.
140#[derive(Debug, thiserror::Error)]
141#[non_exhaustive]
142pub enum Error {
143    /// HTTP-level error from the probe engine.
144    #[error("http error: {0}")]
145    Http(String),
146    /// Analysis failed due to insufficient or malformed probe data.
147    #[error("analysis error: {0}")]
148    Analysis(String),
149    /// Serialization or deserialization failure.
150    #[error("serialization error: {0}")]
151    Serialization(#[from] serde_json::Error),
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    fn confirmed_result_with_metadata() -> OracleResult {
159        OracleResult {
160            class: OracleClass::Existence,
161            verdict: OracleVerdict::Confirmed,
162            evidence: vec!["403 (baseline) vs 404 (probe)".into()],
163            severity: Some(Severity::High),
164            label: Some("Authorization-based differential".into()),
165            leaks: Some("Resource existence confirmed to low-privilege callers".into()),
166            rfc_basis: Some("RFC 9110 §15.5.4".into()),
167        }
168    }
169
170    fn not_present_result() -> OracleResult {
171        OracleResult {
172            class: OracleClass::Existence,
173            verdict: OracleVerdict::NotPresent,
174            evidence: vec!["404 (baseline) vs 404 (probe)".into()],
175            severity: None,
176            label: None,
177            leaks: None,
178            rfc_basis: None,
179        }
180    }
181
182    #[test]
183    fn serialize_confirmed_includes_metadata_fields() {
184        let result = confirmed_result_with_metadata();
185        let json = serde_json::to_value(&result).expect("serialization failed");
186        assert_eq!(json["label"], "Authorization-based differential");
187        assert_eq!(json["leaks"], "Resource existence confirmed to low-privilege callers");
188        assert_eq!(json["rfc_basis"], "RFC 9110 §15.5.4");
189    }
190
191    #[test]
192    fn serialize_not_present_omits_none_metadata() {
193        let result = not_present_result();
194        let json = serde_json::to_value(&result).expect("serialization failed");
195        assert!(!json.as_object().expect("expected object").contains_key("label"));
196        assert!(!json.as_object().expect("expected object").contains_key("leaks"));
197        assert!(!json.as_object().expect("expected object").contains_key("rfc_basis"));
198    }
199
200    #[test]
201    fn roundtrip_confirmed_preserves_metadata() {
202        let original = confirmed_result_with_metadata();
203        let json = serde_json::to_string(&original).expect("serialization failed");
204        let deserialized: OracleResult = serde_json::from_str(&json).expect("deserialization failed");
205        assert_eq!(deserialized.label, original.label);
206        assert_eq!(deserialized.leaks, original.leaks);
207        assert_eq!(deserialized.rfc_basis, original.rfc_basis);
208    }
209
210    #[test]
211    fn roundtrip_not_present_preserves_none_metadata() {
212        let original = not_present_result();
213        let json = serde_json::to_string(&original).expect("serialization failed");
214        let deserialized: OracleResult = serde_json::from_str(&json).expect("deserialization failed");
215        assert_eq!(deserialized.label, None);
216        assert_eq!(deserialized.leaks, None);
217        assert_eq!(deserialized.rfc_basis, None);
218    }
219
220    #[test]
221    fn deserialize_legacy_json_without_metadata_defaults_to_none() {
222        let legacy = r#"{
223            "class": "Existence",
224            "verdict": "Confirmed",
225            "evidence": ["403 (baseline) vs 404 (probe)"],
226            "severity": "High"
227        }"#;
228        let result: OracleResult = serde_json::from_str(legacy).expect("deserialization failed");
229        assert_eq!(result.label, None);
230        assert_eq!(result.leaks, None);
231        assert_eq!(result.rfc_basis, None);
232    }
233}