Skip to main content

termivar_scanner/
web_execution.rs

1//! Scope-bound executors for standard web discovery actions.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** default via `scanning`.
6//! - **Execution:** Surface B (deterministic decision runtime).
7//! - **Default `termivar scan`:** yes, through `StandardWebDecisionRuntime`.
8//! - **Support:** implemented and tested.
9//!
10//! See `docs/internals/runtime-map.md`.
11//!
12//! This profile binds a deliberately small set of semantic planner actions to
13//! the existing HTTP evidence boundary. It performs no credential guessing,
14//! payload injection, redirect following, or cross-origin navigation.
15
16use std::{collections::BTreeSet, sync::Arc};
17
18use serde::Serialize;
19use thiserror::Error;
20
21use crate::{
22    decision_runner::{
23        DecisionActionExecutor, DecisionExecutionStage, DecisionExecutorRegistry,
24        DecisionRunnerError,
25    },
26    http_evidence::{
27        HttpEvidenceError, HttpEvidenceExecutor, HttpEvidencePolicy, HttpProbeMethod,
28        HttpRequestBroker, SubjectHttpProbeProvider,
29    },
30    web_actions::{StandardWebActionKind, STANDARD_WEB_DISCOVERY_ACTION_COUNT},
31};
32
33pub use crate::web_actions::STANDARD_WEB_DISCOVERY_ACTIONS;
34
35/// Number of standard semantic actions backed by discovery-only HTTP probes.
36pub const STANDARD_WEB_DISCOVERY_EXECUTOR_COUNT: usize = STANDARD_WEB_DISCOVERY_ACTION_COUNT;
37
38/// Failures while constructing or atomically installing discovery executors.
39#[derive(Debug, Error)]
40#[non_exhaustive]
41pub enum StandardWebExecutionError {
42    /// A semantic action has no built-in discovery transport mapping.
43    #[error("standard web action {action:?} has no built-in discovery executor")]
44    UnsupportedAction {
45        /// Action rejected while the profile was being constructed.
46        action: StandardWebActionKind,
47    },
48
49    /// A bounded HTTP executor could not be constructed.
50    #[error(transparent)]
51    Http(#[from] HttpEvidenceError),
52
53    /// An executor identity or action route conflicted with registry state.
54    #[error(transparent)]
55    Runner(#[from] DecisionRunnerError),
56}
57
58/// Count of executors added by an idempotent profile installation.
59#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
60pub struct StandardWebDiscoveryInstallReport {
61    executors_inserted: usize,
62}
63
64impl StandardWebDiscoveryInstallReport {
65    /// Returns the number of previously absent executor identities installed.
66    pub fn executors_inserted(self) -> usize {
67        self.executors_inserted
68    }
69}
70
71struct ExecutorBinding {
72    kind: StandardWebActionKind,
73    executor: Arc<dyn DecisionActionExecutor>,
74}
75
76/// Built-in low-risk executor bindings for route, component, and auth discovery.
77///
78/// Every executor inherits the supplied [`HttpEvidencePolicy`]. The policy is
79/// therefore the single authority for allowed origins, timeout, body limits,
80/// captured headers, and optional text samples. Existing executor identities
81/// are treated as explicit host overrides and are never replaced.
82///
83/// PHP input discovery may derive form-control names from an authorized HTML
84/// sample. That optional derivation has its own fixed 64-KiB parser ceiling:
85/// larger body samples remain ordinary evidence, but produce no partial
86/// form-control observation. Missing derived evidence is never a negative fact.
87///
88/// # Examples
89///
90/// ```rust
91/// use url::Url;
92/// use termivar_scanner::{
93///     DecisionExecutorRegistry, HttpEvidencePolicy, StandardWebDiscoveryExecutorProfile,
94///     STANDARD_WEB_DISCOVERY_EXECUTOR_COUNT,
95/// };
96///
97/// let target = Url::parse("https://example.test/")?;
98/// let profile = StandardWebDiscoveryExecutorProfile::new(
99///     HttpEvidencePolicy::for_origin(target)?,
100/// )?;
101/// let mut registry = DecisionExecutorRegistry::new();
102///
103/// let report = profile.install(&mut registry)?;
104/// assert_eq!(report.executors_inserted(), STANDARD_WEB_DISCOVERY_EXECUTOR_COUNT);
105/// # Ok::<(), Box<dyn std::error::Error>>(())
106/// ```
107pub struct StandardWebDiscoveryExecutorProfile {
108    bindings: Vec<ExecutorBinding>,
109}
110
111impl StandardWebDiscoveryExecutorProfile {
112    /// Constructs all discovery executors under one immutable HTTP policy.
113    pub fn new(policy: HttpEvidencePolicy) -> Result<Self, StandardWebExecutionError> {
114        Self::new_with_request_broker(HttpRequestBroker::new_unmetered(policy)?)
115    }
116
117    pub(crate) fn new_with_request_broker(
118        requests: HttpRequestBroker,
119    ) -> Result<Self, StandardWebExecutionError> {
120        Self::new_with_request_broker_and_assessment_projection(requests, false)
121    }
122
123    pub(crate) fn new_with_request_broker_and_assessment_projection(
124        requests: HttpRequestBroker,
125        assessment_defense_projection: bool,
126    ) -> Result<Self, StandardWebExecutionError> {
127        let bindings = STANDARD_WEB_DISCOVERY_ACTIONS
128            .into_iter()
129            .map(|kind| {
130                let provider = Arc::new(SubjectHttpProbeProvider::new(probe_method(kind)?));
131                let executor = HttpEvidenceExecutor::with_id_and_request_broker(
132                    kind.executor_id(),
133                    requests.clone(),
134                    provider,
135                )?;
136                let executor = if assessment_defense_projection {
137                    executor.with_assessment_defense_projection()
138                } else {
139                    executor
140                };
141                // php input discovery is the only route that reads application
142                // structure from the bounded HTML sample; every other route
143                // observes response metadata only.
144                let executor = if matches!(kind, StandardWebActionKind::PhpInputDiscovery) {
145                    executor.with_form_control_capture()
146                } else {
147                    executor
148                };
149                Ok(ExecutorBinding {
150                    kind,
151                    executor: Arc::new(executor),
152                })
153            })
154            .collect::<Result<Vec<_>, StandardWebExecutionError>>()?;
155        Ok(Self { bindings })
156    }
157
158    /// Installs executors and passive/active routes as one atomic registry update.
159    ///
160    /// Reinstalling the same profile is idempotent. A host-provided executor
161    /// with a standard ID remains authoritative; conflicting action routes
162    /// reject the complete installation without partially changing `registry`.
163    pub fn install(
164        &self,
165        registry: &mut DecisionExecutorRegistry,
166    ) -> Result<StandardWebDiscoveryInstallReport, StandardWebExecutionError> {
167        let mut prospective = registry.clone();
168        let mut executors_inserted = 0;
169
170        for binding in &self.bindings {
171            let executor_id = binding.kind.executor_id();
172            if !prospective.contains(executor_id) {
173                prospective.register(binding.executor.clone())?;
174                executors_inserted += 1;
175            }
176            for stage in [
177                DecisionExecutionStage::Passive,
178                DecisionExecutionStage::Active,
179            ] {
180                prospective.route_action(stage, binding.kind.action_id(), executor_id)?;
181            }
182        }
183
184        *registry = prospective;
185        Ok(StandardWebDiscoveryInstallReport { executors_inserted })
186    }
187
188    /// Returns the semantic actions supplied by this profile.
189    pub const fn actions(&self) -> &'static [StandardWebActionKind] {
190        &STANDARD_WEB_DISCOVERY_ACTIONS
191    }
192
193    /// Returns the distinct executor identities supplied by this profile.
194    pub fn executor_ids(&self) -> BTreeSet<&str> {
195        self.bindings
196            .iter()
197            .map(|binding| binding.kind.executor_id())
198            .collect()
199    }
200}
201
202const fn probe_method(
203    kind: StandardWebActionKind,
204) -> Result<HttpProbeMethod, StandardWebExecutionError> {
205    match kind {
206        StandardWebActionKind::LaravelRouteDiscovery => Ok(HttpProbeMethod::Options),
207        // GET probes read the bounded response body: livewire matches a DOM
208        // marker, php input discovery reads named form controls from the sample.
209        StandardWebActionKind::LivewireComponentDiscovery
210        | StandardWebActionKind::PhpInputDiscovery => Ok(HttpProbeMethod::Get),
211        // A header-only HEAD probe: the nginx/apache configuration signals read
212        // the `Server` response header. The probe only needs response metadata
213        // already captured from a HEAD response and never requires a body, so it
214        // introduces no new capture surface.
215        StandardWebActionKind::NginxConfiguration
216        | StandardWebActionKind::ApacheConfiguration
217        | StandardWebActionKind::SanctumAuthBoundary
218        | StandardWebActionKind::HttpBasicAuthBoundary
219        | StandardWebActionKind::HttpBearerAuthBoundary => Ok(HttpProbeMethod::Head),
220        StandardWebActionKind::LaravelInputAnalysis => {
221            Err(StandardWebExecutionError::UnsupportedAction { action: kind })
222        },
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use std::sync::Arc;
229
230    use termivar_core::{
231        ConfidenceScore, EntityId, Evidence, EvidenceKind, EvidenceSource, EvidenceValue,
232        KnowledgePredicate,
233    };
234    use tokio::{
235        io::{AsyncReadExt, AsyncWriteExt},
236        net::TcpListener,
237        sync::Mutex,
238    };
239    use url::Url;
240
241    use super::*;
242    use crate::{
243        AdaptationLimits, BenefitScore, DecisionActionOrigin, DecisionExecutionFailureKind,
244        DecisionLoop, DecisionLoopCommand, DecisionLoopConfig, DecisionRunnerAdapter,
245        DecisionSession, ExperiencePolicy, ExperienceStore, KnowledgeBase, PlanningContext,
246        RiskScore, StandardWebAttackProfile, StandardWebReasoning, VerificationCase,
247    };
248
249    async fn serve_requests(count: usize) -> (Url, Arc<Mutex<Vec<String>>>) {
250        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
251        let address = listener.local_addr().unwrap();
252        let methods = Arc::new(Mutex::new(Vec::new()));
253        let recorded = methods.clone();
254        tokio::spawn(async move {
255            for _ in 0..count {
256                let (mut stream, _) = listener.accept().await.unwrap();
257                let mut request = [0_u8; 2048];
258                let bytes = stream.read(&mut request).await.unwrap();
259                let line = String::from_utf8_lossy(&request[..bytes]);
260                let method = line.split_whitespace().next().unwrap().to_owned();
261                recorded.lock().await.push(method);
262                stream
263                    .write_all(
264                        b"HTTP/1.1 200 OK\r\nAllow: GET, HEAD, OPTIONS\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
265                    )
266                    .await
267                    .unwrap();
268                stream.shutdown().await.unwrap();
269            }
270        });
271        (
272            Url::parse(&format!("http://{address}/app")).unwrap(),
273            methods,
274        )
275    }
276
277    fn case(url: &Url, id: &str, kind: StandardWebActionKind) -> VerificationCase {
278        VerificationCase::new(
279            id,
280            EntityId::new(format!("endpoint:{url}")).unwrap(),
281            kind.action_id(),
282            "hypothesis:web",
283        )
284        .unwrap()
285    }
286
287    fn command(
288        url: &Url,
289        id: &str,
290        kind: StandardWebActionKind,
291        executor: Option<&str>,
292    ) -> DecisionLoopCommand {
293        DecisionLoopCommand::ExecuteAction {
294            case: case(url, id, kind),
295            executor: executor.map(str::to_owned),
296            origin: DecisionActionOrigin::Planned,
297            delay_ms: None,
298        }
299    }
300
301    fn observed_method(receipt: &crate::DecisionEvidenceReceipt) -> Option<&EvidenceValue> {
302        receipt
303            .after_execution()
304            .evidence()
305            .iter()
306            .find(|evidence| {
307                evidence.source().correlation_id() == Some(receipt.case().id())
308                    && evidence.predicate().dotted() == "http.request.method"
309            })
310            .map(Evidence::value)
311    }
312
313    #[test]
314    fn profile_installs_atomically_and_idempotently() {
315        let target = Url::parse("https://example.test/app").unwrap();
316        let profile = StandardWebDiscoveryExecutorProfile::new(
317            HttpEvidencePolicy::for_origin(target).unwrap(),
318        )
319        .unwrap();
320        let mut registry = DecisionExecutorRegistry::new();
321
322        let first = profile.install(&mut registry).unwrap();
323        let second = profile.install(&mut registry).unwrap();
324
325        assert_eq!(
326            first.executors_inserted(),
327            STANDARD_WEB_DISCOVERY_EXECUTOR_COUNT
328        );
329        assert_eq!(second, StandardWebDiscoveryInstallReport::default());
330        assert_eq!(registry.len(), STANDARD_WEB_DISCOVERY_EXECUTOR_COUNT);
331        assert_eq!(
332            profile.executor_ids().len(),
333            STANDARD_WEB_DISCOVERY_EXECUTOR_COUNT
334        );
335    }
336
337    #[test]
338    fn every_discovery_action_has_an_executor_mapping() {
339        for kind in STANDARD_WEB_DISCOVERY_ACTIONS {
340            assert!(probe_method(kind).is_ok(), "missing mapping for {kind:?}");
341        }
342    }
343
344    #[test]
345    fn actions_without_a_discovery_executor_fail_during_mapping() {
346        for kind in StandardWebActionKind::all()
347            .into_iter()
348            .filter(|kind| !STANDARD_WEB_DISCOVERY_ACTIONS.contains(kind))
349        {
350            assert!(matches!(
351                probe_method(kind),
352                Err(StandardWebExecutionError::UnsupportedAction { action }) if action == kind
353            ));
354        }
355    }
356
357    #[test]
358    fn route_conflict_leaves_the_original_registry_unchanged() {
359        let target = Url::parse("https://example.test/app").unwrap();
360        let policy = HttpEvidencePolicy::for_origin(target).unwrap();
361        let host_executor = HttpEvidenceExecutor::with_id(
362            "host.custom",
363            policy.clone(),
364            Arc::new(SubjectHttpProbeProvider::new(HttpProbeMethod::Head)),
365        )
366        .unwrap();
367        let kind = StandardWebActionKind::LaravelRouteDiscovery;
368        let mut registry = DecisionExecutorRegistry::new();
369        registry.register(Arc::new(host_executor)).unwrap();
370        registry
371            .route_action(
372                DecisionExecutionStage::Passive,
373                kind.action_id(),
374                "host.custom",
375            )
376            .unwrap();
377        let profile = StandardWebDiscoveryExecutorProfile::new(policy).unwrap();
378
379        assert!(matches!(
380            profile.install(&mut registry),
381            Err(StandardWebExecutionError::Runner(
382                DecisionRunnerError::ActionRouteConflict { .. }
383            ))
384        ));
385        assert_eq!(registry.len(), 1);
386        assert!(registry.contains("host.custom"));
387        assert!(!registry.contains(kind.executor_id()));
388    }
389
390    #[tokio::test]
391    async fn passive_and_active_routes_use_discovery_only_methods() {
392        let (target, methods) = serve_requests(3).await;
393        let profile = StandardWebDiscoveryExecutorProfile::new(
394            HttpEvidencePolicy::for_origin(target.clone()).unwrap(),
395        )
396        .unwrap();
397        let mut registry = DecisionExecutorRegistry::new();
398        profile.install(&mut registry).unwrap();
399        let adapter = DecisionRunnerAdapter::new(registry);
400        let knowledge = KnowledgeBase::new();
401
402        let route = StandardWebActionKind::LaravelRouteDiscovery;
403        let route_receipt = adapter
404            .execute_command(&command(&target, "case:route", route, None), &knowledge)
405            .await
406            .unwrap();
407        assert_eq!(route_receipt.executor_id(), route.executor_id());
408        assert_eq!(
409            observed_method(&route_receipt),
410            Some(&EvidenceValue::Text("OPTIONS".to_owned()))
411        );
412
413        let component = StandardWebActionKind::LivewireComponentDiscovery;
414        let component_receipt = adapter
415            .execute_command(
416                &command(
417                    &target,
418                    "case:component",
419                    component,
420                    Some(component.executor_id()),
421                ),
422                &knowledge,
423            )
424            .await
425            .unwrap();
426        assert_eq!(
427            observed_method(&component_receipt),
428            Some(&EvidenceValue::Text("GET".to_owned()))
429        );
430
431        let auth = StandardWebActionKind::HttpBasicAuthBoundary;
432        let auth_receipt = adapter
433            .execute_command(
434                &DecisionLoopCommand::CollectActiveEvidence {
435                    case: case(&target, "case:auth", auth),
436                },
437                &knowledge,
438            )
439            .await
440            .unwrap();
441        assert_eq!(auth_receipt.executor_id(), auth.executor_id());
442        assert_eq!(
443            observed_method(&auth_receipt),
444            Some(&EvidenceValue::Text("HEAD".to_owned()))
445        );
446        assert_eq!(methods.lock().await.as_slice(), ["OPTIONS", "GET", "HEAD"]);
447    }
448
449    #[tokio::test]
450    async fn reasoning_plan_executes_through_the_semantic_registry() {
451        let (target, methods) = serve_requests(1).await;
452        let subject = EntityId::new(format!("endpoint:{target}")).unwrap();
453        let knowledge = KnowledgeBase::new();
454        knowledge
455            .insert_evidence_batch(
456                ["laravel_session", "XSRF-TOKEN"]
457                    .into_iter()
458                    .map(|cookie| {
459                        Evidence::new(
460                            subject.clone(),
461                            EvidenceKind::Http,
462                            KnowledgePredicate::new("http.cookie", "name").unwrap(),
463                            EvidenceValue::Text(cookie.to_owned()),
464                            EvidenceSource::new("http.evidence", cookie).unwrap(),
465                            ConfidenceScore::MAX,
466                        )
467                    })
468                    .collect(),
469            )
470            .unwrap();
471        let config = DecisionLoopConfig::new(
472            PlanningContext::new(
473                BenefitScore::from_percent(90).unwrap(),
474                200,
475                RiskScore::from_percent(100).unwrap(),
476            ),
477            AdaptationLimits::default(),
478            ExperiencePolicy::new(3).unwrap(),
479            8,
480        )
481        .unwrap();
482        let mut decision_loop = DecisionLoop::new(config);
483        StandardWebReasoning::new()
484            .unwrap()
485            .install(&knowledge, decision_loop.rules_mut())
486            .unwrap();
487        StandardWebAttackProfile::new()
488            .unwrap()
489            .install(decision_loop.planner_mut())
490            .unwrap();
491        let experience = ExperienceStore::new();
492        let mut session = DecisionSession::new(subject);
493        let planned = decision_loop
494            .plan_next(&knowledge, &experience, &mut session)
495            .unwrap();
496
497        let profile = StandardWebDiscoveryExecutorProfile::new(
498            HttpEvidencePolicy::for_origin(target).unwrap(),
499        )
500        .unwrap();
501        let mut registry = DecisionExecutorRegistry::new();
502        profile.install(&mut registry).unwrap();
503        let receipt = DecisionRunnerAdapter::new(registry)
504            .execute_command(planned.command(), &knowledge)
505            .await
506            .unwrap();
507
508        assert_eq!(
509            receipt.executor_id(),
510            StandardWebActionKind::LaravelRouteDiscovery.executor_id()
511        );
512        assert_eq!(methods.lock().await.as_slice(), ["OPTIONS"]);
513    }
514
515    #[tokio::test]
516    async fn policy_rejects_cross_origin_subject_before_network_io() {
517        let allowed = Url::parse("http://127.0.0.1:1/app").unwrap();
518        let outside = Url::parse("http://127.0.0.1:2/app").unwrap();
519        let profile = StandardWebDiscoveryExecutorProfile::new(
520            HttpEvidencePolicy::for_origin(allowed).unwrap(),
521        )
522        .unwrap();
523        let mut registry = DecisionExecutorRegistry::new();
524        profile.install(&mut registry).unwrap();
525        let adapter = DecisionRunnerAdapter::new(registry);
526        let knowledge = KnowledgeBase::new();
527        let kind = StandardWebActionKind::HttpBearerAuthBoundary;
528
529        let error = adapter
530            .execute_command(
531                &command(&outside, "case:outside", kind, Some(kind.executor_id())),
532                &knowledge,
533            )
534            .await
535            .unwrap_err();
536
537        let failure = error.execution_failure().unwrap();
538        assert_eq!(
539            failure.kind(),
540            DecisionExecutionFailureKind::BlockedByPolicy
541        );
542        assert_eq!(failure.executor_id(), kind.executor_id());
543        assert_eq!(failure.action_id(), kind.action_id());
544        assert!(failure.diagnostic().contains("outside policy"));
545        assert_eq!(knowledge.stats().evidence, 0);
546    }
547}