1use 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
35pub const STANDARD_WEB_DISCOVERY_EXECUTOR_COUNT: usize = STANDARD_WEB_DISCOVERY_ACTION_COUNT;
37
38#[derive(Debug, Error)]
40#[non_exhaustive]
41pub enum StandardWebExecutionError {
42 #[error("standard web action {action:?} has no built-in discovery executor")]
44 UnsupportedAction {
45 action: StandardWebActionKind,
47 },
48
49 #[error(transparent)]
51 Http(#[from] HttpEvidenceError),
52
53 #[error(transparent)]
55 Runner(#[from] DecisionRunnerError),
56}
57
58#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
60pub struct StandardWebDiscoveryInstallReport {
61 executors_inserted: usize,
62}
63
64impl StandardWebDiscoveryInstallReport {
65 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
76pub struct StandardWebDiscoveryExecutorProfile {
108 bindings: Vec<ExecutorBinding>,
109}
110
111impl StandardWebDiscoveryExecutorProfile {
112 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 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 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 pub const fn actions(&self) -> &'static [StandardWebActionKind] {
190 &STANDARD_WEB_DISCOVERY_ACTIONS
191 }
192
193 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 StandardWebActionKind::LivewireComponentDiscovery
210 | StandardWebActionKind::PhpInputDiscovery => Ok(HttpProbeMethod::Get),
211 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}