Skip to main content

termivar_scanner/http_evidence/
probe.rs

1use std::{collections::BTreeMap, fmt};
2
3use reqwest::{
4    header::{HeaderName, HeaderValue},
5    Method, Url,
6};
7use serde::{Deserialize, Serialize};
8
9use crate::DecisionExecutionRequest;
10
11use super::{
12    policy::{forbidden_request_header, validate_http_url},
13    HttpEvidenceError,
14};
15
16/// Discovery-only HTTP methods supported by the evidence executor.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19#[non_exhaustive]
20pub enum HttpProbeMethod {
21    /// Retrieve response headers and a bounded representation of the body.
22    Get,
23    /// Retrieve response headers without a response body.
24    Head,
25    /// Discover methods and protocol behavior exposed by the endpoint.
26    Options,
27}
28
29impl HttpProbeMethod {
30    pub(super) fn as_reqwest(self) -> Method {
31        match self {
32            Self::Get => Method::GET,
33            Self::Head => Method::HEAD,
34            Self::Options => Method::OPTIONS,
35        }
36    }
37
38    /// Returns the stable uppercase method name.
39    pub const fn as_str(self) -> &'static str {
40        match self {
41            Self::Get => "GET",
42            Self::Head => "HEAD",
43            Self::Options => "OPTIONS",
44        }
45    }
46}
47
48/// One validated, bodyless discovery request.
49#[derive(Clone, PartialEq, Eq)]
50pub struct HttpProbe {
51    url: Url,
52    method: HttpProbeMethod,
53    headers: BTreeMap<String, String>,
54}
55
56impl fmt::Debug for HttpProbe {
57    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
58        formatter
59            .debug_struct("HttpProbe")
60            .field("url", &"<redacted>")
61            .field("method", &self.method)
62            .field("header_names", &self.headers.keys().collect::<Vec<_>>())
63            .field("header_values", &"<redacted>")
64            .finish()
65    }
66}
67
68impl HttpProbe {
69    /// Creates a request for one absolute HTTP or HTTPS URL.
70    pub fn new(url: Url, method: HttpProbeMethod) -> Result<Self, HttpEvidenceError> {
71        validate_http_url(&url)?;
72        Ok(Self {
73            url,
74            method,
75            headers: BTreeMap::new(),
76        })
77    }
78
79    /// Adds or replaces a validated request header.
80    ///
81    /// `Host`, hop-by-hop framing headers, and proxy authorization are
82    /// rejected because they can change the scoped destination or transport
83    /// interpretation. Authentication and cookie headers remain explicit host
84    /// choices for authorized authenticated scans.
85    pub fn with_header(
86        mut self,
87        name: impl Into<String>,
88        value: impl Into<String>,
89    ) -> Result<Self, HttpEvidenceError> {
90        let name = name.into();
91        let parsed_name = HeaderName::from_bytes(name.as_bytes())
92            .map_err(|_| HttpEvidenceError::InvalidHeaderName { name: name.clone() })?;
93        if forbidden_request_header(&parsed_name) {
94            return Err(HttpEvidenceError::ForbiddenRequestHeader {
95                name: parsed_name.as_str().to_owned(),
96            });
97        }
98        let value = value.into();
99        HeaderValue::from_str(&value).map_err(|_| HttpEvidenceError::InvalidHeaderValue {
100            name: parsed_name.as_str().to_owned(),
101        })?;
102        self.headers.insert(parsed_name.as_str().to_owned(), value);
103        Ok(self)
104    }
105
106    /// Returns the absolute request URL.
107    pub fn url(&self) -> &Url {
108        &self.url
109    }
110
111    pub(super) fn url_mut(&mut self) -> &mut Url {
112        &mut self.url
113    }
114
115    /// Returns the discovery method.
116    pub fn method(&self) -> HttpProbeMethod {
117        self.method
118    }
119
120    /// Returns request headers in stable lowercase-name order.
121    pub fn headers(&self) -> &BTreeMap<String, String> {
122        &self.headers
123    }
124}
125
126/// Host-owned mapping from a decision case to an HTTP discovery request.
127pub trait HttpProbeProvider: Send + Sync {
128    /// Resolves one request without performing I/O or changing decision state.
129    fn probe_for(&self, request: &DecisionExecutionRequest)
130        -> Result<HttpProbe, HttpEvidenceError>;
131}
132
133impl<F> HttpProbeProvider for F
134where
135    F: Fn(&DecisionExecutionRequest) -> Result<HttpProbe, HttpEvidenceError> + Send + Sync,
136{
137    fn probe_for(
138        &self,
139        request: &DecisionExecutionRequest,
140    ) -> Result<HttpProbe, HttpEvidenceError> {
141        self(request)
142    }
143}
144
145/// Default provider that interprets `endpoint:<absolute-url>` subjects.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub struct SubjectHttpProbeProvider {
148    method: HttpProbeMethod,
149}
150
151impl SubjectHttpProbeProvider {
152    /// Creates a subject-backed provider using the selected discovery method.
153    pub const fn new(method: HttpProbeMethod) -> Self {
154        Self { method }
155    }
156}
157
158impl Default for SubjectHttpProbeProvider {
159    fn default() -> Self {
160        Self::new(HttpProbeMethod::Get)
161    }
162}
163
164impl HttpProbeProvider for SubjectHttpProbeProvider {
165    fn probe_for(
166        &self,
167        request: &DecisionExecutionRequest,
168    ) -> Result<HttpProbe, HttpEvidenceError> {
169        let subject = request.case().subject().as_str();
170        let raw_url = subject.strip_prefix("endpoint:").ok_or_else(|| {
171            HttpEvidenceError::InvalidEndpointSubject {
172                subject: subject.to_owned(),
173            }
174        })?;
175        let url = Url::parse(raw_url).map_err(|source| HttpEvidenceError::InvalidUrl {
176            value: raw_url.to_owned(),
177            source,
178        })?;
179        HttpProbe::new(url, self.method)
180    }
181}