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 exchange;
12mod finding_id;
13mod scoring;
14mod serde_helpers;
15mod signal;
16mod technique;
17
18pub use exchange::{DifferentialSet, ProbeExchange};
19pub use finding_id::finding_id;
20pub use scoring::{ScoringDimension, ScoringReason};
21pub use signal::{ImpactClass, Signal, SignalKind};
22pub use technique::{NormativeStrength, Technique, Vector};
23
24use bytes::Bytes;
25use http::{HeaderMap, Method, StatusCode};
26use serde::{Deserialize, Serialize};
27use serde_helpers::{
28    bytes_serde, header_map_serde, method_serde, opt_bytes_serde, status_code_serde,
29};
30
31/// A single HTTP interaction: full response surface and wall-clock timing.
32///
33/// Captures everything needed for differential analysis — status, headers, body, and timing —
34/// in one flat structure.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ResponseSurface {
37    /// HTTP status code returned by the server.
38    #[serde(with = "status_code_serde")]
39    pub status: StatusCode,
40    /// Full response header map.
41    #[serde(with = "header_map_serde")]
42    pub headers: HeaderMap,
43    /// Raw response body bytes, serialized as a base64-encoded byte sequence.
44    #[serde(with = "bytes_serde")]
45    pub body: Bytes,
46    /// Wall-clock response time in nanoseconds, measured from first byte sent to last byte
47    /// received.
48    pub timing_ns: u64,
49}
50
51/// A single HTTP request to execute against a target.
52///
53/// The authorization context is expressed entirely through the `headers` field — set an
54/// `Authorization` header for bearer tokens, API keys, or Basic auth. No special-case auth
55/// fields.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct ProbeDefinition {
58    /// Fully-qualified target URL including scheme, host, path, and any query parameters.
59    pub url: String,
60    /// HTTP method for the request.
61    #[serde(with = "method_serde")]
62    pub method: Method,
63    /// Request headers, including any authorization context.
64    #[serde(with = "header_map_serde")]
65    pub headers: HeaderMap,
66    /// Request body. `None` for GET, HEAD, DELETE; `Some` for POST, PATCH, PUT.
67    #[serde(with = "opt_bytes_serde")]
68    pub body: Option<Bytes>,
69}
70
71/// Paired response surfaces for differential analysis.
72///
73/// `baseline` holds responses for the control input (e.g. a known-existing resource ID).
74/// `probe` holds responses for the variable input (e.g. a randomly generated nonexistent ID).
75/// Multiple samples per side support statistical analysis for timing oracles.
76///
77/// # Deprecated
78///
79/// Use [`DifferentialSet`] instead. `DifferentialSet` pairs each response with the request that
80/// produced it and carries [`Technique`] metadata end-to-end. This type will be removed in Pass 4.
81#[deprecated(since = "0.4.0", note = "use DifferentialSet instead — see system-design.md")]
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct ProbeSet {
84    /// Responses for the known-valid / control input.
85    pub baseline: Vec<ResponseSurface>,
86    /// Responses for the unknown / suspect input.
87    pub probe: Vec<ResponseSurface>,
88}
89
90/// The oracle class being probed.
91///
92/// Each variant corresponds to a distinct detection strategy and analysis pipeline.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
94#[non_exhaustive]
95pub enum OracleClass {
96    /// Status-code or body differential between an existing and nonexistent resource.
97    Existence,
98}
99
100/// Confidence level of an oracle detection result.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
102pub enum OracleVerdict {
103    /// Signal is unambiguous: differential is consistent, statistically significant, and matches
104    /// a known oracle pattern.
105    Confirmed,
106    /// Signal is present and consistent with an oracle, but evidence is not conclusive (e.g.
107    /// borderline p-value, single sample).
108    Likely,
109    /// Signal is present but too weak or inconsistent to classify.
110    Inconclusive,
111    /// No differential signal detected; the endpoint does not exhibit this oracle.
112    NotPresent,
113}
114
115/// Severity of a confirmed or likely oracle.
116///
117/// `None` on an `OracleResult` when the verdict is `NotPresent` or `Inconclusive`.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
119pub enum Severity {
120    /// Directly actionable: resource existence, valid credentials, or session state is leaked
121    /// to unauthenticated or low-privilege callers.
122    High,
123    /// Leaks internal state but requires additional steps to exploit.
124    Medium,
125    /// Informational: leaks metadata that may assist further enumeration.
126    Low,
127}
128
129/// The result of running an oracle analyzer against a differential set.
130///
131/// Carries the full signal chain that produced the verdict alongside technique context and
132/// scoring breakdown. Status codes, header diffs, and flat evidence strings previously stored
133/// in dedicated fields are now represented as typed [`Signal`] values in the `signals` vec.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct OracleResult {
136    /// Which oracle class produced this result.
137    pub class: OracleClass,
138    /// Confidence verdict.
139    pub verdict: OracleVerdict,
140    /// Severity when the verdict is `Confirmed` or `Likely`; `None` when `NotPresent`.
141    pub severity: Option<Severity>,
142    /// Numeric confidence score (0-100). Determines verdict via threshold mapping.
143    #[serde(default)]
144    pub confidence: u8,
145    /// Impact classification based on leak type. Determines severity when gated by confidence.
146    #[serde(skip_serializing_if = "Option::is_none", default)]
147    pub impact_class: Option<ImpactClass>,
148    /// Breakdown of how confidence and impact were computed.
149    #[serde(skip_serializing_if = "Vec::is_empty", default)]
150    pub reasons: Vec<ScoringReason>,
151    /// Typed signals extracted during differential analysis.
152    #[serde(skip_serializing_if = "Vec::is_empty", default)]
153    pub signals: Vec<Signal>,
154    /// Machine-readable technique identifier, e.g. `"if-none-match"`.
155    #[serde(skip_serializing_if = "Option::is_none", default)]
156    pub technique_id: Option<String>,
157    /// Detection vector used by the technique.
158    #[serde(skip_serializing_if = "Option::is_none", default)]
159    pub vector: Option<Vector>,
160    /// RFC normative strength of the technique's expected differential.
161    #[serde(skip_serializing_if = "Option::is_none", default)]
162    pub normative_strength: Option<NormativeStrength>,
163    /// Human-readable name for the detected pattern, e.g. `"Authorization-based differential"`.
164    #[serde(skip_serializing_if = "Option::is_none", default)]
165    pub label: Option<String>,
166    /// What information the oracle leaks, e.g.
167    /// `"Resource existence confirmed to low-privilege callers"`.
168    #[serde(skip_serializing_if = "Option::is_none", default)]
169    pub leaks: Option<String>,
170    /// RFC section grounding the behavior, e.g. `"RFC 9110 \u{00a7}15.5.4"`.
171    #[serde(skip_serializing_if = "Option::is_none", default)]
172    pub rfc_basis: Option<String>,
173}
174
175impl OracleResult {
176    /// Returns the evidence string from the primary `StatusCodeDiff` signal, if present.
177    ///
178    /// Falls back to the first signal of any kind, then `"—"` when no signals exist.
179    #[must_use]
180    pub fn primary_evidence(&self) -> &str {
181        self.signals
182            .iter()
183            .find(|s| s.kind == SignalKind::StatusCodeDiff)
184            .or_else(|| self.signals.first())
185            .map_or("—", |s| s.evidence.as_str())
186    }
187}
188
189/// Errors produced by parlov crates.
190#[derive(Debug, thiserror::Error)]
191#[non_exhaustive]
192pub enum Error {
193    /// HTTP-level error from the probe engine.
194    #[error("http error: {0}")]
195    Http(String),
196    /// Analysis failed due to insufficient or malformed probe data.
197    #[error("analysis error: {0}")]
198    Analysis(String),
199    /// Serialization or deserialization failure.
200    #[error("serialization error: {0}")]
201    Serialization(#[from] serde_json::Error),
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    fn confirmed_result_with_metadata() -> OracleResult {
209        OracleResult {
210            class: OracleClass::Existence,
211            verdict: OracleVerdict::Confirmed,
212            severity: Some(Severity::High),
213            confidence: 0,
214            impact_class: None,
215            reasons: vec![],
216            signals: vec![Signal {
217                kind: SignalKind::StatusCodeDiff,
218                evidence: "403 (baseline) vs 404 (probe)".into(),
219                rfc_basis: None,
220            }],
221            technique_id: None,
222            vector: None,
223            normative_strength: None,
224            label: Some("Authorization-based differential".into()),
225            leaks: Some("Resource existence confirmed to low-privilege callers".into()),
226            rfc_basis: Some("RFC 9110 \u{00a7}15.5.4".into()),
227        }
228    }
229
230    fn not_present_result() -> OracleResult {
231        OracleResult {
232            class: OracleClass::Existence,
233            verdict: OracleVerdict::NotPresent,
234            severity: None,
235            confidence: 0,
236            impact_class: None,
237            reasons: vec![],
238            signals: vec![Signal {
239                kind: SignalKind::StatusCodeDiff,
240                evidence: "404 (baseline) vs 404 (probe)".into(),
241                rfc_basis: None,
242            }],
243            technique_id: None,
244            vector: None,
245            normative_strength: None,
246            label: None,
247            leaks: None,
248            rfc_basis: None,
249        }
250    }
251
252    #[test]
253    fn serialize_confirmed_includes_metadata_fields() {
254        let result = confirmed_result_with_metadata();
255        let json = serde_json::to_value(&result).expect("serialization failed");
256        assert_eq!(json["label"], "Authorization-based differential");
257        assert_eq!(json["leaks"], "Resource existence confirmed to low-privilege callers");
258        assert_eq!(json["rfc_basis"], "RFC 9110 \u{00a7}15.5.4");
259    }
260
261    #[test]
262    fn serialize_not_present_omits_none_metadata() {
263        let result = not_present_result();
264        let json = serde_json::to_value(&result).expect("serialization failed");
265        assert!(!json.as_object().expect("expected object").contains_key("label"));
266        assert!(!json.as_object().expect("expected object").contains_key("leaks"));
267        assert!(!json.as_object().expect("expected object").contains_key("rfc_basis"));
268    }
269
270    #[test]
271    fn roundtrip_confirmed_preserves_metadata() {
272        let original = confirmed_result_with_metadata();
273        let json = serde_json::to_string(&original).expect("serialization failed");
274        let deserialized: OracleResult =
275            serde_json::from_str(&json).expect("deserialization failed");
276        assert_eq!(deserialized.label, original.label);
277        assert_eq!(deserialized.leaks, original.leaks);
278        assert_eq!(deserialized.rfc_basis, original.rfc_basis);
279    }
280
281    #[test]
282    fn roundtrip_not_present_preserves_none_metadata() {
283        let original = not_present_result();
284        let json = serde_json::to_string(&original).expect("serialization failed");
285        let deserialized: OracleResult =
286            serde_json::from_str(&json).expect("deserialization failed");
287        assert_eq!(deserialized.label, None);
288        assert_eq!(deserialized.leaks, None);
289        assert_eq!(deserialized.rfc_basis, None);
290    }
291
292    #[test]
293    fn deserialize_minimal_json_defaults_to_none() {
294        let minimal = r#"{
295            "class": "Existence",
296            "verdict": "Confirmed",
297            "severity": "High"
298        }"#;
299        let result: OracleResult =
300            serde_json::from_str(minimal).expect("deserialization failed");
301        assert_eq!(result.label, None);
302        assert_eq!(result.leaks, None);
303        assert_eq!(result.rfc_basis, None);
304        assert!(result.signals.is_empty());
305        assert_eq!(result.technique_id, None);
306        assert_eq!(result.confidence, 0);
307        assert_eq!(result.impact_class, None);
308        assert!(result.reasons.is_empty());
309    }
310
311    #[test]
312    fn oracle_result_with_technique_context_serializes() {
313        let result = OracleResult {
314            class: OracleClass::Existence,
315            verdict: OracleVerdict::Confirmed,
316            severity: Some(Severity::High),
317            confidence: 0,
318            impact_class: None,
319            reasons: vec![],
320            signals: vec![Signal {
321                kind: SignalKind::StatusCodeDiff,
322                evidence: "304 vs 404".into(),
323                rfc_basis: Some("RFC 9110 \u{00a7}13.1.2".into()),
324            }],
325            technique_id: Some("if-none-match".into()),
326            vector: Some(Vector::CacheProbing),
327            normative_strength: Some(NormativeStrength::Must),
328            label: None,
329            leaks: None,
330            rfc_basis: None,
331        };
332        let json = serde_json::to_value(&result).expect("serialization failed");
333        assert_eq!(json["technique_id"], "if-none-match");
334        assert_eq!(json["vector"], "CacheProbing");
335        assert_eq!(json["normative_strength"], "Must");
336        assert_eq!(json["signals"][0]["kind"], "StatusCodeDiff");
337        assert_eq!(json["signals"][0]["evidence"], "304 vs 404");
338        assert_eq!(json["signals"][0]["rfc_basis"], "RFC 9110 \u{00a7}13.1.2");
339    }
340
341    #[test]
342    fn oracle_result_roundtrip_with_technique_context() {
343        let original = OracleResult {
344            class: OracleClass::Existence,
345            verdict: OracleVerdict::Likely,
346            severity: Some(Severity::Medium),
347            confidence: 0,
348            impact_class: None,
349            reasons: vec![],
350            signals: vec![Signal {
351                kind: SignalKind::HeaderPresence,
352                evidence: "ETag present in baseline, absent in probe".into(),
353                rfc_basis: None,
354            }],
355            technique_id: Some("get-200-404".into()),
356            vector: Some(Vector::StatusCodeDiff),
357            normative_strength: Some(NormativeStrength::Should),
358            label: Some("Status code differential".into()),
359            leaks: Some("Resource existence".into()),
360            rfc_basis: Some("RFC 9110 \u{00a7}15.5.5".into()),
361        };
362        let json = serde_json::to_string(&original).expect("serialization failed");
363        let back: OracleResult = serde_json::from_str(&json).expect("deserialization failed");
364        assert_eq!(back.technique_id, original.technique_id);
365        assert_eq!(back.vector, original.vector);
366        assert_eq!(back.normative_strength, original.normative_strength);
367        assert_eq!(back.signals.len(), 1);
368        assert_eq!(back.signals[0].kind, SignalKind::HeaderPresence);
369    }
370
371    #[test]
372    fn primary_evidence_returns_status_code_diff() {
373        let result = confirmed_result_with_metadata();
374        assert_eq!(result.primary_evidence(), "403 (baseline) vs 404 (probe)");
375    }
376
377    #[test]
378    fn primary_evidence_falls_back_to_first_signal() {
379        let result = OracleResult {
380            class: OracleClass::Existence,
381            verdict: OracleVerdict::Confirmed,
382            severity: Some(Severity::Medium),
383            confidence: 0,
384            impact_class: None,
385            reasons: vec![],
386            signals: vec![Signal {
387                kind: SignalKind::HeaderPresence,
388                evidence: "etag present in baseline".into(),
389                rfc_basis: None,
390            }],
391            technique_id: None,
392            vector: None,
393            normative_strength: None,
394            label: None,
395            leaks: None,
396            rfc_basis: None,
397        };
398        assert_eq!(result.primary_evidence(), "etag present in baseline");
399    }
400
401    #[test]
402    fn primary_evidence_returns_dash_when_empty() {
403        let result = not_present_result();
404        let mut empty = result;
405        empty.signals.clear();
406        assert_eq!(empty.primary_evidence(), "\u{2014}");
407    }
408
409    #[test]
410    fn signal_kind_copy_and_eq() {
411        let a = SignalKind::StatusCodeDiff;
412        let b = a;
413        assert_eq!(a, b);
414    }
415
416    #[test]
417    fn vector_copy_and_eq() {
418        let a = Vector::CacheProbing;
419        let b = a;
420        assert_eq!(a, b);
421    }
422
423    #[test]
424    fn normative_strength_copy_and_eq() {
425        let a = NormativeStrength::Must;
426        let b = a;
427        assert_eq!(a, b);
428        assert_ne!(a, NormativeStrength::May);
429    }
430
431    #[test]
432    fn technique_clone() {
433        let t = Technique {
434            id: "test",
435            name: "Test technique",
436            oracle_class: OracleClass::Existence,
437            vector: Vector::StatusCodeDiff,
438            strength: NormativeStrength::Must,
439        };
440        let t2 = t.clone();
441        assert_eq!(t2.id, "test");
442        assert_eq!(t2.vector, Vector::StatusCodeDiff);
443    }
444
445    #[test]
446    fn probe_exchange_pairs_request_and_response() {
447        let exchange = ProbeExchange {
448            request: ProbeDefinition {
449                url: "https://example.com/resource/1".into(),
450                method: http::Method::GET,
451                headers: HeaderMap::new(),
452                body: None,
453            },
454            response: ResponseSurface {
455                status: http::StatusCode::OK,
456                headers: HeaderMap::new(),
457                body: Bytes::new(),
458                timing_ns: 1_000_000,
459            },
460        };
461        assert_eq!(exchange.request.url, "https://example.com/resource/1");
462        assert_eq!(exchange.response.status, http::StatusCode::OK);
463    }
464
465    #[test]
466    fn differential_set_carries_technique() {
467        let technique = Technique {
468            id: "get-200-404",
469            name: "GET 200/404",
470            oracle_class: OracleClass::Existence,
471            vector: Vector::StatusCodeDiff,
472            strength: NormativeStrength::Must,
473        };
474        let ds = DifferentialSet {
475            baseline: vec![],
476            probe: vec![],
477            technique,
478        };
479        assert_eq!(ds.technique.id, "get-200-404");
480        assert_eq!(ds.technique.strength, NormativeStrength::Must);
481    }
482
483    #[test]
484    fn technique_without_target_signals_constructs() {
485        let t = Technique {
486            id: "range-416",
487            name: "Range 416/404",
488            oracle_class: OracleClass::Existence,
489            vector: Vector::CacheProbing,
490            strength: NormativeStrength::Should,
491        };
492        let t2 = t.clone();
493        assert_eq!(t2.id, "range-416");
494        assert_eq!(t2.strength, NormativeStrength::Should);
495        assert_eq!(t2.oracle_class, OracleClass::Existence);
496    }
497
498    #[test]
499    fn impact_class_copy_and_eq() {
500        let a = ImpactClass::High;
501        let b = a;
502        assert_eq!(a, b);
503        assert_ne!(a, ImpactClass::Low);
504    }
505
506    #[test]
507    fn impact_class_serialize_roundtrip() {
508        let json = serde_json::to_string(&ImpactClass::Medium).expect("serialization failed");
509        let back: ImpactClass = serde_json::from_str(&json).expect("deserialization failed");
510        assert_eq!(back, ImpactClass::Medium);
511    }
512
513    #[test]
514    fn scoring_dimension_copy_and_eq() {
515        let a = ScoringDimension::Confidence;
516        let b = a;
517        assert_eq!(a, b);
518        assert_ne!(a, ScoringDimension::Impact);
519    }
520
521    #[test]
522    fn scoring_reason_serialize_roundtrip() {
523        let reason = ScoringReason {
524            description: "Status differential 416 vs 404".into(),
525            points: 75,
526            dimension: ScoringDimension::Confidence,
527        };
528        let json = serde_json::to_string(&reason).expect("serialization failed");
529        let back: ScoringReason = serde_json::from_str(&json).expect("deserialization failed");
530        assert_eq!(back.description, "Status differential 416 vs 404");
531        assert_eq!(back.points, 75);
532        assert_eq!(back.dimension, ScoringDimension::Confidence);
533    }
534
535    #[test]
536    fn scoring_reason_negative_points() {
537        let reason = ScoringReason {
538            description: "Inconsistent across samples".into(),
539            points: -10,
540            dimension: ScoringDimension::Confidence,
541        };
542        let json = serde_json::to_string(&reason).expect("serialization failed");
543        let back: ScoringReason = serde_json::from_str(&json).expect("deserialization failed");
544        assert_eq!(back.points, -10);
545    }
546
547    #[test]
548    fn oracle_result_with_confidence_and_impact_serializes() {
549        let result = OracleResult {
550            class: OracleClass::Existence,
551            verdict: OracleVerdict::Confirmed,
552            severity: Some(Severity::High),
553            confidence: 88,
554            impact_class: Some(ImpactClass::High),
555            reasons: vec![
556                ScoringReason {
557                    description: "Status differential 416 vs 404".into(),
558                    points: 75,
559                    dimension: ScoringDimension::Confidence,
560                },
561                ScoringReason {
562                    description: "Content-Range reveals exact size".into(),
563                    points: 12,
564                    dimension: ScoringDimension::Impact,
565                },
566            ],
567            signals: vec![],
568            technique_id: None,
569            vector: None,
570            normative_strength: None,
571            label: None,
572            leaks: None,
573            rfc_basis: None,
574        };
575        let json = serde_json::to_value(&result).expect("serialization failed");
576        assert_eq!(json["confidence"], 88);
577        assert_eq!(json["impact_class"], "High");
578        assert_eq!(json["reasons"].as_array().expect("expected array").len(), 2);
579        assert_eq!(json["reasons"][0]["points"], 75);
580        assert_eq!(json["reasons"][1]["dimension"], "Impact");
581    }
582
583    #[test]
584    fn oracle_result_zero_confidence_omits_impact_and_reasons() {
585        let result = OracleResult {
586            class: OracleClass::Existence,
587            verdict: OracleVerdict::NotPresent,
588            severity: None,
589            confidence: 0,
590            impact_class: None,
591            reasons: vec![],
592            signals: vec![],
593            technique_id: None,
594            vector: None,
595            normative_strength: None,
596            label: None,
597            leaks: None,
598            rfc_basis: None,
599        };
600        let json = serde_json::to_value(&result).expect("serialization failed");
601        let obj = json.as_object().expect("expected object");
602        assert!(!obj.contains_key("impact_class"));
603        assert!(!obj.contains_key("reasons"));
604    }
605}