Skip to main content

termivar_scanner/
web_verification.rs

1//! Deterministic outcomes for standard web discovery evidence.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** always/default.
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//! Rules are scoped to both a semantic action and the current verification
13//! case correlation. This prevents historical responses or another executor's
14//! observations from being combined into a new outcome.
15
16use 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
29/// Number of passive and active rules installed by the standard profile.
30pub const STANDARD_WEB_VERIFICATION_RULE_COUNT: usize = 32;
31
32/// Failures while constructing or atomically installing standard web rules.
33#[derive(Debug, Error)]
34#[non_exhaustive]
35pub enum StandardWebVerificationError {
36    /// A semantic action has no built-in verification rule mapping.
37    #[error("standard web action {action:?} has no built-in verification rules")]
38    UnsupportedAction {
39        /// Action rejected while the profile was being constructed.
40        action: StandardWebActionKind,
41    },
42
43    /// A rule used a stage unsupported by this two-stage profile.
44    #[error("verification stage {stage:?} is unsupported by the standard web profile")]
45    UnsupportedStage {
46        /// Stage rejected before the prospective pipeline was committed.
47        stage: VerificationStage,
48    },
49
50    /// A predicate or probability was invalid.
51    #[error(transparent)]
52    Reasoning(#[from] ReasoningModelError),
53
54    /// An evidence expression was invalid.
55    #[error(transparent)]
56    Rule(#[from] RuleEngineError),
57
58    /// A verifier rule or identity conflicted.
59    #[error(transparent)]
60    Verification(#[from] VerificationError),
61}
62
63/// Counts of rules added by an idempotent profile installation.
64#[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    /// Returns the number of newly registered passive rules.
72    pub fn passive_rules_inserted(self) -> usize {
73        self.passive_rules_inserted
74    }
75
76    /// Returns the number of newly registered active rules.
77    pub fn active_rules_inserted(self) -> usize {
78        self.active_rules_inserted
79    }
80
81    /// Returns the total number of newly registered rules.
82    pub fn total_rules_inserted(self) -> usize {
83        self.passive_rules_inserted + self.active_rules_inserted
84    }
85}
86
87/// Action- and case-scoped verification rules for standard discovery probes.
88///
89/// The profile is intentionally conservative: explicit framework/auth markers
90/// can succeed, blocking status codes can report `Blocked`, and a Laravel
91/// `Allow` header remains `NeedsReview`. Absence alone never creates a false
92/// positive outcome.
93///
94/// # Examples
95///
96/// ```rust
97/// use termivar_scanner::{
98///     StandardWebVerificationProfile, VerificationPipeline,
99///     STANDARD_WEB_VERIFICATION_RULE_COUNT,
100/// };
101///
102/// let profile = StandardWebVerificationProfile::new()?;
103/// let mut pipeline = VerificationPipeline::default();
104/// let report = profile.install(&mut pipeline)?;
105///
106/// assert_eq!(report.total_rules_inserted(), STANDARD_WEB_VERIFICATION_RULE_COUNT);
107/// # Ok::<(), Box<dyn std::error::Error>>(())
108/// ```
109#[derive(Debug, Clone)]
110pub struct StandardWebVerificationProfile {
111    rules: Vec<VerificationRule>,
112}
113
114impl StandardWebVerificationProfile {
115    /// Constructs the complete passive/active rule set.
116    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    /// Installs all rules atomically and idempotently.
129    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    /// Returns rules in stable action, stage, and purpose order.
158    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        // The nginx/apache configuration signals fire only on a version-bearing
258        // Server token (`<product>/<version>` where a digit follows the slash,
259        // e.g. `nginx/1.26.0`, `Apache/2.4.58`). A bare product token
260        // (`server_tokens off` / `ServerTokens Prod`), a trailing slash with no
261        // version, or a non-numeric suffix never matches, so the token that gates
262        // the action can never by itself confirm a version disclosure. Priority
263        // is below the blocked rule (800) so 401/403/429 stay Blocked even when
264        // the denying response also discloses a version.
265        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        // php input discovery succeeds when the executor conservatively observed
280        // at least one named HTML form control in the bounded sample. This is
281        // candidate discovery, not proof the server accepts these parameters, and
282        // never implies a complete set. Priority is below the blocked rule so a
283        // denying status still resolves to Blocked.
284        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
318/// Condition for a version-bearing `Server` product token (`<product>/<digit>`).
319///
320/// A version disclosure requires a digit immediately after the `<product>/`
321/// delimiter (`nginx/1.26.0`, `apache/2.4.58`, ...). This is expressed with the
322/// existing `Expression` API as an `any` over `<product>/0` ..= `<product>/9` —
323/// no new primitive — so a bare product token, a trailing slash (`nginx/`), or a
324/// non-numeric suffix (`apache/foo`) never matches. Fail-closed: the product
325/// token that gates the action can never on its own satisfy the signal. The same
326/// helper serves every server-software route (nginx, apache); it is a shared
327/// condition, not a new abstraction layer.
328fn 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                    &current,
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, &current).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        // Both server-software routes share the same anti-circular version rule:
627        // only a `<product>/<digit>` token is a version disclosure.
628        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            // A bare product token, a trailing slash, and a non-numeric suffix all
661            // fail closed to Unknown; the gating token cannot self-confirm.
662            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            // A digit after the slash is the positive signal -> Success (a single
673            // digit, a full dotted version, and an upper-case product all match).
674            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            // A denying status outranks the version disclosure -> Blocked.
685            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}