1use serde::Serialize;
17use termivar_core::{
18 EvidenceValue, HttpEvidencePredicate, OutcomeStatus, Probability, ReasoningModelError,
19 VerificationStage,
20};
21use thiserror::Error;
22
23use crate::{
24 rules::{Expression, KnowledgeLayer, RuleEngineError},
25 verification::{VerificationError, VerificationPipeline, VerificationRule, VerifierWrite},
26 web_actions::{StandardWebActionKind, STANDARD_WEB_DISCOVERY_ACTIONS},
27};
28
29pub const STANDARD_WEB_VERIFICATION_RULE_COUNT: usize = 32;
31
32#[derive(Debug, Error)]
34#[non_exhaustive]
35pub enum StandardWebVerificationError {
36 #[error("standard web action {action:?} has no built-in verification rules")]
38 UnsupportedAction {
39 action: StandardWebActionKind,
41 },
42
43 #[error("verification stage {stage:?} is unsupported by the standard web profile")]
45 UnsupportedStage {
46 stage: VerificationStage,
48 },
49
50 #[error(transparent)]
52 Reasoning(#[from] ReasoningModelError),
53
54 #[error(transparent)]
56 Rule(#[from] RuleEngineError),
57
58 #[error(transparent)]
60 Verification(#[from] VerificationError),
61}
62
63#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
65pub struct StandardWebVerificationInstallReport {
66 passive_rules_inserted: usize,
67 active_rules_inserted: usize,
68}
69
70impl StandardWebVerificationInstallReport {
71 pub fn passive_rules_inserted(self) -> usize {
73 self.passive_rules_inserted
74 }
75
76 pub fn active_rules_inserted(self) -> usize {
78 self.active_rules_inserted
79 }
80
81 pub fn total_rules_inserted(self) -> usize {
83 self.passive_rules_inserted + self.active_rules_inserted
84 }
85}
86
87#[derive(Debug, Clone)]
110pub struct StandardWebVerificationProfile {
111 rules: Vec<VerificationRule>,
112}
113
114impl StandardWebVerificationProfile {
115 pub fn new() -> Result<Self, StandardWebVerificationError> {
117 let mut rules = Vec::with_capacity(STANDARD_WEB_VERIFICATION_RULE_COUNT);
118 for kind in STANDARD_WEB_DISCOVERY_ACTIONS {
119 for stage in [VerificationStage::Passive, VerificationStage::Active] {
120 rules.push(blocked_rule(kind, stage)?);
121 rules.push(signal_rule(kind, stage)?);
122 }
123 }
124 debug_assert_eq!(rules.len(), STANDARD_WEB_VERIFICATION_RULE_COUNT);
125 Ok(Self { rules })
126 }
127
128 pub fn install(
130 &self,
131 pipeline: &mut VerificationPipeline,
132 ) -> Result<StandardWebVerificationInstallReport, StandardWebVerificationError> {
133 let mut prospective = pipeline.clone();
134 let mut report = StandardWebVerificationInstallReport::default();
135
136 for rule in &self.rules {
137 let write = match rule.stage() {
138 VerificationStage::Passive => prospective.passive_mut().register(rule.clone())?,
139 VerificationStage::Active => prospective.active_mut().register(rule.clone())?,
140 stage => return Err(StandardWebVerificationError::UnsupportedStage { stage }),
141 };
142 if matches!(write, VerifierWrite::Inserted) {
143 match rule.stage() {
144 VerificationStage::Passive => report.passive_rules_inserted += 1,
145 VerificationStage::Active => report.active_rules_inserted += 1,
146 stage => {
147 return Err(StandardWebVerificationError::UnsupportedStage { stage });
148 },
149 }
150 }
151 }
152
153 *pipeline = prospective;
154 Ok(report)
155 }
156
157 pub fn rules(&self) -> &[VerificationRule] {
159 &self.rules
160 }
161}
162
163fn blocked_rule(
164 kind: StandardWebActionKind,
165 stage: VerificationStage,
166) -> Result<VerificationRule, StandardWebVerificationError> {
167 let status = HttpEvidencePredicate::RESPONSE_STATUS.into_knowledge();
168 let condition = Expression::any(
169 [401_u64, 403, 429]
170 .into_iter()
171 .map(|value| {
172 Expression::equals(
173 KnowledgeLayer::Evidence,
174 status.clone(),
175 EvidenceValue::Unsigned(value),
176 )
177 })
178 .collect(),
179 )?;
180 scoped_rule(
181 format!("web.verify.{}.{}.blocked", stage.as_str(), kind.slug()),
182 kind,
183 stage,
184 800,
185 condition,
186 OutcomeStatus::Blocked,
187 90,
188 "The current discovery request was denied or rate limited",
189 )
190}
191
192fn signal_rule(
193 kind: StandardWebActionKind,
194 stage: VerificationStage,
195) -> Result<VerificationRule, StandardWebVerificationError> {
196 let (condition, outcome, priority, confidence, rationale) = match kind {
197 StandardWebActionKind::LaravelRouteDiscovery => (
198 Expression::exists(
199 KnowledgeLayer::Evidence,
200 HttpEvidencePredicate::HEADER_ALLOW.into(),
201 ),
202 OutcomeStatus::NeedsReview,
203 600,
204 70,
205 "The endpoint advertised methods, but this does not independently confirm Laravel",
206 ),
207 StandardWebActionKind::LivewireComponentDiscovery => (
208 Expression::any(
209 ["wire:id=", "wire:snapshot="]
210 .into_iter()
211 .map(|marker| {
212 Expression::text_contains_ascii_case_insensitive(
213 KnowledgeLayer::Evidence,
214 HttpEvidencePredicate::RESPONSE_BODY_SAMPLE.into(),
215 marker,
216 )
217 })
218 .collect::<Result<Vec<_>, RuleEngineError>>()?,
219 )?,
220 OutcomeStatus::Success,
221 900,
222 97,
223 "The bounded response sample contains an explicit Livewire DOM marker",
224 ),
225 StandardWebActionKind::SanctumAuthBoundary => (
226 Expression::all(vec![
227 Expression::equals(
228 KnowledgeLayer::Evidence,
229 HttpEvidencePredicate::COOKIE_NAME.into(),
230 EvidenceValue::Text("laravel_session".to_owned()),
231 ),
232 Expression::equals(
233 KnowledgeLayer::Evidence,
234 HttpEvidencePredicate::COOKIE_NAME.into(),
235 EvidenceValue::Text("XSRF-TOKEN".to_owned()),
236 ),
237 ])?,
238 OutcomeStatus::Success,
239 900,
240 92,
241 "The current response issued both Laravel session and XSRF cookie names",
242 ),
243 StandardWebActionKind::HttpBasicAuthBoundary => (
244 auth_header_condition("Basic")?,
245 OutcomeStatus::Success,
246 950,
247 99,
248 "WWW-Authenticate explicitly advertises HTTP Basic for this request",
249 ),
250 StandardWebActionKind::HttpBearerAuthBoundary => (
251 auth_header_condition("Bearer")?,
252 OutcomeStatus::Success,
253 950,
254 99,
255 "WWW-Authenticate explicitly advertises HTTP Bearer for this request",
256 ),
257 StandardWebActionKind::NginxConfiguration => (
266 server_version_disclosure_condition("nginx")?,
267 OutcomeStatus::Success,
268 600,
269 90,
270 "The Server header discloses a specific nginx version",
271 ),
272 StandardWebActionKind::ApacheConfiguration => (
273 server_version_disclosure_condition("apache")?,
274 OutcomeStatus::Success,
275 600,
276 90,
277 "The Server header discloses a specific Apache version",
278 ),
279 StandardWebActionKind::PhpInputDiscovery => (
285 Expression::exists(
286 KnowledgeLayer::Evidence,
287 HttpEvidencePredicate::RESPONSE_FORM_CONTROL_NAMES.into(),
288 ),
289 OutcomeStatus::Success,
290 600,
291 85,
292 "One or more named HTML form controls were observed in the bounded response sample",
293 ),
294 StandardWebActionKind::LaravelInputAnalysis => {
295 return Err(StandardWebVerificationError::UnsupportedAction { action: kind });
296 },
297 };
298 scoped_rule(
299 format!("web.verify.{}.{}.signal", stage.as_str(), kind.slug()),
300 kind,
301 stage,
302 priority,
303 condition,
304 outcome,
305 confidence,
306 rationale,
307 )
308}
309
310fn auth_header_condition(token: &str) -> Result<Expression, RuleEngineError> {
311 Expression::text_contains_ascii_case_insensitive(
312 KnowledgeLayer::Evidence,
313 HttpEvidencePredicate::HEADER_WWW_AUTHENTICATE.into(),
314 token,
315 )
316}
317
318fn server_version_disclosure_condition(product: &str) -> Result<Expression, RuleEngineError> {
329 Expression::any(
330 (0..=9)
331 .map(|digit| {
332 Expression::text_contains_ascii_case_insensitive(
333 KnowledgeLayer::Evidence,
334 HttpEvidencePredicate::HEADER_SERVER.into(),
335 format!("{product}/{digit}"),
336 )
337 })
338 .collect::<Result<Vec<_>, RuleEngineError>>()?,
339 )
340}
341
342#[allow(clippy::too_many_arguments)]
343fn scoped_rule(
344 id: String,
345 kind: StandardWebActionKind,
346 stage: VerificationStage,
347 priority: u16,
348 condition: Expression,
349 outcome: OutcomeStatus,
350 confidence: u8,
351 rationale: &str,
352) -> Result<VerificationRule, StandardWebVerificationError> {
353 Ok(VerificationRule::new(
354 id,
355 stage,
356 priority,
357 condition,
358 outcome,
359 Probability::from_percent(confidence)?,
360 rationale,
361 )?
362 .scoped_to_action(kind.action_id())?
363 .with_case_correlated_evidence()?)
364}
365
366#[cfg(test)]
367mod tests {
368 use termivar_core::{
369 ConfidenceScore, EntityId, Evidence, EvidenceKind, EvidenceSource, Hypothesis,
370 HypothesisState, HypothesisStrength, KnowledgePredicate,
371 };
372
373 use super::*;
374 use crate::{KnowledgeBase, VerificationCase};
375
376 fn subject() -> EntityId {
377 EntityId::new("endpoint:https://example.test/app").unwrap()
378 }
379
380 fn case(id: &str, kind: StandardWebActionKind) -> VerificationCase {
381 VerificationCase::new(id, subject(), kind.action_id(), "hypothesis:web").unwrap()
382 }
383
384 fn knowledge() -> KnowledgeBase {
385 let knowledge = KnowledgeBase::new();
386 let mut hypothesis = Hypothesis::with_id(
387 "hypothesis:web",
388 subject(),
389 KnowledgePredicate::new("technology", "candidate").unwrap(),
390 EvidenceValue::Text("web".to_owned()),
391 Probability::from_percent(50).unwrap(),
392 )
393 .unwrap();
394 hypothesis.set_strength(HypothesisStrength::Weak);
395 hypothesis.set_state(HypothesisState::Supported);
396 knowledge.upsert_hypothesis(hypothesis).unwrap();
397 knowledge
398 }
399
400 fn evidence(
401 case: &VerificationCase,
402 namespace: &str,
403 name: &str,
404 value: EvidenceValue,
405 ) -> Evidence {
406 Evidence::new(
407 subject(),
408 EvidenceKind::Http,
409 KnowledgePredicate::new(namespace, name).unwrap(),
410 value,
411 EvidenceSource::new(
412 StandardWebActionKind::HttpBasicAuthBoundary.executor_id(),
413 "test-observation",
414 )
415 .unwrap()
416 .with_correlation_id(case.id())
417 .unwrap(),
418 ConfidenceScore::MAX,
419 )
420 }
421
422 fn pipeline() -> VerificationPipeline {
423 let mut pipeline = VerificationPipeline::default();
424 StandardWebVerificationProfile::new()
425 .unwrap()
426 .install(&mut pipeline)
427 .unwrap();
428 pipeline
429 }
430
431 #[test]
432 fn profile_installs_atomically_and_idempotently() {
433 let profile = StandardWebVerificationProfile::new().unwrap();
434 let mut pipeline = VerificationPipeline::default();
435
436 let first = profile.install(&mut pipeline).unwrap();
437 let second = profile.install(&mut pipeline).unwrap();
438
439 assert_eq!(first.passive_rules_inserted(), 16);
440 assert_eq!(first.active_rules_inserted(), 16);
441 assert_eq!(
442 first.total_rules_inserted(),
443 STANDARD_WEB_VERIFICATION_RULE_COUNT
444 );
445 assert_eq!(second, StandardWebVerificationInstallReport::default());
446 assert_eq!(pipeline.passive().len(), 16);
447 assert_eq!(pipeline.active().len(), 16);
448 }
449
450 #[test]
451 fn every_discovery_action_has_passive_and_active_verifier_mappings() {
452 for kind in STANDARD_WEB_DISCOVERY_ACTIONS {
453 for stage in [VerificationStage::Passive, VerificationStage::Active] {
454 assert!(
455 blocked_rule(kind, stage).is_ok(),
456 "missing blocked rule for {kind:?}"
457 );
458 assert!(
459 signal_rule(kind, stage).is_ok(),
460 "missing signal rule for {kind:?}"
461 );
462 }
463 }
464 }
465
466 #[test]
467 fn actions_without_verifier_mappings_fail_during_construction() {
468 for kind in StandardWebActionKind::all()
469 .into_iter()
470 .filter(|kind| !STANDARD_WEB_DISCOVERY_ACTIONS.contains(kind))
471 {
472 assert!(matches!(
473 signal_rule(kind, VerificationStage::Passive),
474 Err(StandardWebVerificationError::UnsupportedAction { action }) if action == kind
475 ));
476 }
477 }
478
479 #[test]
480 fn matching_basic_challenge_outranks_the_blocked_status() {
481 let kind = StandardWebActionKind::HttpBasicAuthBoundary;
482 let case = case("case:basic", kind);
483 let knowledge = knowledge();
484 knowledge
485 .insert_evidence_batch(vec![
486 evidence(
487 &case,
488 "http.response",
489 "status",
490 EvidenceValue::Unsigned(401),
491 ),
492 evidence(
493 &case,
494 "http.header",
495 "www-authenticate",
496 EvidenceValue::Text("Basic realm=\"admin\"".to_owned()),
497 ),
498 ])
499 .unwrap();
500
501 let report = pipeline().passive().verify(&knowledge, &case).unwrap();
502
503 assert_eq!(report.outcome().status(), OutcomeStatus::Success);
504 assert_eq!(
505 report.outcome().verifier_rule_id(),
506 Some("web.verify.passive.http-basic-auth.signal")
507 );
508 }
509
510 #[test]
511 fn action_scope_prevents_basic_evidence_from_confirming_bearer() {
512 let basic = case("case:bearer", StandardWebActionKind::HttpBearerAuthBoundary);
513 let knowledge = knowledge();
514 knowledge
515 .insert_evidence_batch(vec![
516 evidence(
517 &basic,
518 "http.response",
519 "status",
520 EvidenceValue::Unsigned(401),
521 ),
522 evidence(
523 &basic,
524 "http.header",
525 "www-authenticate",
526 EvidenceValue::Text("Basic realm=\"admin\"".to_owned()),
527 ),
528 ])
529 .unwrap();
530
531 let report = pipeline().passive().verify(&knowledge, &basic).unwrap();
532 let basic_signal = report
533 .evaluations()
534 .iter()
535 .find(|evaluation| evaluation.rule_id().ends_with("http-basic-auth.signal"))
536 .unwrap();
537
538 assert!(basic_signal.condition().matched());
539 assert!(!basic_signal.action_matched());
540 assert!(!basic_signal.eligible());
541 assert_eq!(report.outcome().status(), OutcomeStatus::Blocked);
542 }
543
544 #[test]
545 fn stale_challenge_from_another_case_is_ignored() {
546 let kind = StandardWebActionKind::HttpBasicAuthBoundary;
547 let current = case("case:current", kind);
548 let stale = case("case:stale", kind);
549 let knowledge = knowledge();
550 knowledge
551 .insert_evidence_batch(vec![
552 evidence(
553 ¤t,
554 "http.response",
555 "status",
556 EvidenceValue::Unsigned(200),
557 ),
558 evidence(
559 &stale,
560 "http.header",
561 "www-authenticate",
562 EvidenceValue::Text("Basic realm=\"old\"".to_owned()),
563 ),
564 ])
565 .unwrap();
566
567 let report = pipeline().passive().verify(&knowledge, ¤t).unwrap();
568
569 assert_eq!(report.outcome().status(), OutcomeStatus::Unknown);
570 assert!(report.outcome().evidence_ids().is_empty());
571 }
572
573 #[test]
574 fn active_success_requires_a_fresh_livewire_marker() {
575 let kind = StandardWebActionKind::LivewireComponentDiscovery;
576 let case = case("case:livewire", kind);
577 let knowledge = knowledge();
578 knowledge
579 .insert_evidence(evidence(
580 &case,
581 "http.response",
582 "body-sample",
583 EvidenceValue::Text("<div wire:id=\"old\">".to_owned()),
584 ))
585 .unwrap();
586 let baseline = knowledge.snapshot_for_subject(&subject());
587 let pipeline = pipeline();
588
589 let stale = pipeline
590 .active()
591 .verify_snapshots(&case, &baseline, &baseline)
592 .unwrap();
593 assert_eq!(stale.outcome().status(), OutcomeStatus::Unknown);
594
595 knowledge
596 .insert_evidence(evidence(
597 &case,
598 "http.response",
599 "body-sample",
600 EvidenceValue::Text("<div wire:snapshot=\"fresh\">".to_owned()),
601 ))
602 .unwrap();
603 let after = knowledge.snapshot_for_subject(&subject());
604 let verified = pipeline
605 .active()
606 .verify_snapshots(&case, &baseline, &after)
607 .unwrap();
608
609 assert_eq!(verified.outcome().status(), OutcomeStatus::Success);
610 assert_eq!(
611 verified
612 .evaluations()
613 .iter()
614 .find(|evaluation| evaluation.selected())
615 .unwrap()
616 .fresh_evidence_ids()
617 .len(),
618 1
619 );
620 }
621
622 #[test]
623 fn server_version_disclosure_succeeds_but_bare_token_and_blocked_do_not() {
624 let pipeline = pipeline();
625
626 let products = [
629 (
630 StandardWebActionKind::NginxConfiguration,
631 "nginx",
632 "nginx/1.26.0",
633 ),
634 (
635 StandardWebActionKind::ApacheConfiguration,
636 "Apache",
637 "Apache/2.4.58",
638 ),
639 ];
640 for (kind, bare, versioned) in products {
641 let status_of = |label: &str, server: &str| {
642 let case = case(label, kind);
643 let knowledge = knowledge();
644 knowledge
645 .insert_evidence(evidence(
646 &case,
647 "http.header",
648 "server",
649 EvidenceValue::Text(server.to_owned()),
650 ))
651 .unwrap();
652 pipeline
653 .passive()
654 .verify(&knowledge, &case)
655 .unwrap()
656 .outcome()
657 .status()
658 };
659
660 let slash_only = format!("{bare}/");
663 let non_numeric = format!("{bare}/foo");
664 for server in [bare, slash_only.as_str(), non_numeric.as_str()] {
665 assert_eq!(
666 status_of("case:bare", server),
667 OutcomeStatus::Unknown,
668 "`Server: {server}` must not be a version disclosure",
669 );
670 }
671
672 let one = format!("{bare}/1");
675 let upper = versioned.to_uppercase();
676 for server in [one.as_str(), versioned, upper.as_str()] {
677 assert_eq!(
678 status_of("case:versioned", server),
679 OutcomeStatus::Success,
680 "`Server: {server}` must be a version disclosure",
681 );
682 }
683
684 let blocked = case("case:blocked", kind);
686 let blocked_knowledge = knowledge();
687 blocked_knowledge
688 .insert_evidence_batch(vec![
689 evidence(
690 &blocked,
691 "http.response",
692 "status",
693 EvidenceValue::Unsigned(403),
694 ),
695 evidence(
696 &blocked,
697 "http.header",
698 "server",
699 EvidenceValue::Text(versioned.to_owned()),
700 ),
701 ])
702 .unwrap();
703 assert_eq!(
704 pipeline
705 .passive()
706 .verify(&blocked_knowledge, &blocked)
707 .unwrap()
708 .outcome()
709 .status(),
710 OutcomeStatus::Blocked
711 );
712 }
713 }
714
715 #[test]
716 fn allow_header_remains_needs_review_and_rate_limit_is_blocked() {
717 let route = case("case:route", StandardWebActionKind::LaravelRouteDiscovery);
718 let knowledge = knowledge();
719 knowledge
720 .insert_evidence(evidence(
721 &route,
722 "http.header",
723 "allow",
724 EvidenceValue::Text("GET, HEAD, OPTIONS".to_owned()),
725 ))
726 .unwrap();
727 let pipeline = pipeline();
728 assert_eq!(
729 pipeline
730 .passive()
731 .verify(&knowledge, &route)
732 .unwrap()
733 .outcome()
734 .status(),
735 OutcomeStatus::NeedsReview
736 );
737
738 let limited = case(
739 "case:limited",
740 StandardWebActionKind::LivewireComponentDiscovery,
741 );
742 knowledge
743 .insert_evidence(evidence(
744 &limited,
745 "http.response",
746 "status",
747 EvidenceValue::Unsigned(429),
748 ))
749 .unwrap();
750 assert_eq!(
751 pipeline
752 .passive()
753 .verify(&knowledge, &limited)
754 .unwrap()
755 .outcome()
756 .status(),
757 OutcomeStatus::Blocked
758 );
759 }
760}