Skip to main content

termivar_scanner/
web_reasoning.rs

1//! Standard deterministic ontology and reasoning rules for web observations.
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//! The standard runtime composes this profile by default. Direct lower-level
13//! hosts may choose whether to install it. It defines semantic vocabulary and
14//! Bayesian rules, but performs no network I/O and never verifies a vulnerability.
15
16use serde::Serialize;
17use termivar_core::{
18    ConceptId, EvidenceValue, HttpEvidencePredicate, HypothesisState, HypothesisStrength,
19    KnowledgePredicate, Ontology, OntologyAxiom, OntologyConcept, OntologyError, Probability,
20    WebKnowledgePredicate,
21};
22use thiserror::Error;
23
24use crate::{
25    knowledge::KnowledgeBase,
26    rules::{
27        EvidenceCalibration, EvidenceSelector, Expression, HypothesisConclusion, KnowledgeLayer,
28        ReasoningRule, RuleEngine, RuleEngineError, RuleWrite,
29    },
30};
31
32/// Number of concepts defined by [`StandardWebReasoning`].
33pub const STANDARD_WEB_CONCEPT_COUNT: usize = 14;
34
35/// Number of semantic axioms defined by [`StandardWebReasoning`].
36pub const STANDARD_WEB_AXIOM_COUNT: usize = 16;
37
38/// Number of deterministic fingerprint rules defined by [`StandardWebReasoning`].
39pub const STANDARD_WEB_RULE_COUNT: usize = 10;
40
41/// Failures while constructing or installing the standard web profile.
42#[derive(Debug, Error)]
43#[non_exhaustive]
44pub enum StandardWebReasoningError {
45    /// An ontology definition or relationship was invalid or conflicted.
46    #[error(transparent)]
47    Ontology(#[from] OntologyError),
48
49    /// A reasoning rule was invalid or conflicted.
50    #[error(transparent)]
51    Rules(#[from] RuleEngineError),
52}
53
54/// Counts of new definitions installed by one idempotent profile operation.
55#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
56pub struct StandardWebInstallReport {
57    concepts_inserted: usize,
58    axioms_inserted: usize,
59    rules_inserted: usize,
60}
61
62impl StandardWebInstallReport {
63    /// Returns the number of newly registered concepts.
64    pub fn concepts_inserted(self) -> usize {
65        self.concepts_inserted
66    }
67
68    /// Returns the number of newly registered semantic axioms.
69    pub fn axioms_inserted(self) -> usize {
70        self.axioms_inserted
71    }
72
73    /// Returns the number of newly registered reasoning rules.
74    pub fn rules_inserted(self) -> usize {
75        self.rules_inserted
76    }
77}
78
79/// Validated web-domain ontology and deterministic fingerprint rule pack.
80///
81/// The profile recognizes disclosed nginx, Apache HTTP Server, PHP, Laravel,
82/// Livewire, Sanctum, HTTP Basic, and HTTP Bearer signals. Conclusions remain
83/// hypotheses: only verifier stages may mark them confirmed or rejected.
84///
85/// # Examples
86///
87/// ```rust
88/// use termivar_scanner::{KnowledgeBase, RuleEngine, StandardWebReasoning};
89///
90/// let knowledge = KnowledgeBase::new();
91/// let mut rules = RuleEngine::new();
92/// let profile = StandardWebReasoning::new()?;
93/// let installed = profile.install(&knowledge, &mut rules)?;
94///
95/// assert_eq!(installed.rules_inserted(), profile.rules().len());
96/// # Ok::<(), Box<dyn std::error::Error>>(())
97/// ```
98#[derive(Debug, Clone)]
99pub struct StandardWebReasoning {
100    concepts: Vec<OntologyConcept>,
101    axioms: Vec<OntologyAxiom>,
102    rules: Vec<ReasoningRule>,
103}
104
105impl StandardWebReasoning {
106    /// Builds and validates the standard vocabulary and rule definitions.
107    pub fn new() -> Result<Self, StandardWebReasoningError> {
108        let concepts = standard_concepts()?;
109        let axioms = standard_axioms()?;
110        let rules = standard_rules()?;
111
112        let mut validation = Ontology::new();
113        for concept in &concepts {
114            validation.add_concept(concept.clone())?;
115        }
116        for axiom in &axioms {
117            validation.add_axiom(axiom.clone())?;
118        }
119
120        debug_assert_eq!(concepts.len(), STANDARD_WEB_CONCEPT_COUNT);
121        debug_assert_eq!(axioms.len(), STANDARD_WEB_AXIOM_COUNT);
122        debug_assert_eq!(rules.len(), STANDARD_WEB_RULE_COUNT);
123        Ok(Self {
124            concepts,
125            axioms,
126            rules,
127        })
128    }
129
130    /// Installs the complete profile idempotently into knowledge and rules.
131    ///
132    /// Definitions are first applied to owned clones, so a known identity or
133    /// semantic conflict is detected before shared state is changed.
134    pub fn install(
135        &self,
136        knowledge: &KnowledgeBase,
137        engine: &mut RuleEngine,
138    ) -> Result<StandardWebInstallReport, StandardWebReasoningError> {
139        let mut prospective_engine = engine.clone();
140        let mut rules_inserted = 0;
141        for rule in &self.rules {
142            rules_inserted += usize::from(matches!(
143                prospective_engine.register(rule.clone())?,
144                RuleWrite::Inserted
145            ));
146        }
147
148        let (concepts_inserted, axioms_inserted) =
149            knowledge.install_ontology_definitions(&self.concepts, &self.axioms)?;
150        *engine = prospective_engine;
151
152        Ok(StandardWebInstallReport {
153            concepts_inserted,
154            axioms_inserted,
155            rules_inserted,
156        })
157    }
158
159    /// Returns concepts in stable declaration order.
160    pub fn concepts(&self) -> &[OntologyConcept] {
161        &self.concepts
162    }
163
164    /// Returns semantic axioms in stable declaration order.
165    pub fn axioms(&self) -> &[OntologyAxiom] {
166        &self.axioms
167    }
168
169    /// Returns reasoning rules in stable declaration order.
170    pub fn rules(&self) -> &[ReasoningRule] {
171        &self.rules
172    }
173}
174
175fn standard_concepts() -> Result<Vec<OntologyConcept>, OntologyError> {
176    [
177        ("technology", "Technology"),
178        ("server-software", "Server software"),
179        ("programming-language", "Programming language"),
180        ("web-framework", "Web framework"),
181        ("ui-framework", "UI framework"),
182        ("authentication-mechanism", "Authentication mechanism"),
183        ("nginx", "nginx"),
184        ("apache-http-server", "Apache HTTP Server"),
185        ("php", "PHP"),
186        ("laravel", "Laravel"),
187        ("livewire", "Livewire"),
188        ("sanctum", "Laravel Sanctum"),
189        ("http-basic", "HTTP Basic authentication"),
190        ("http-bearer", "HTTP Bearer authentication"),
191    ]
192    .into_iter()
193    .map(|(id, label)| OntologyConcept::new(ConceptId::new(id)?, label))
194    .collect()
195}
196
197fn standard_axioms() -> Result<Vec<OntologyAxiom>, OntologyError> {
198    let is_a = Ontology::relation_id(Ontology::IS_A)?;
199    let implemented_in = Ontology::relation_id(Ontology::IMPLEMENTED_IN)?;
200    let depends_on = Ontology::relation_id(Ontology::DEPENDS_ON)?;
201    [
202        ("server-software", &is_a, "technology"),
203        ("programming-language", &is_a, "technology"),
204        ("web-framework", &is_a, "technology"),
205        ("ui-framework", &is_a, "technology"),
206        ("authentication-mechanism", &is_a, "technology"),
207        ("nginx", &is_a, "server-software"),
208        ("apache-http-server", &is_a, "server-software"),
209        ("php", &is_a, "programming-language"),
210        ("laravel", &is_a, "web-framework"),
211        ("livewire", &is_a, "ui-framework"),
212        ("sanctum", &is_a, "authentication-mechanism"),
213        ("http-basic", &is_a, "authentication-mechanism"),
214        ("http-bearer", &is_a, "authentication-mechanism"),
215        ("laravel", &implemented_in, "php"),
216        ("livewire", &depends_on, "laravel"),
217        ("sanctum", &depends_on, "laravel"),
218    ]
219    .into_iter()
220    .map(|(subject, relation, object)| {
221        Ok(OntologyAxiom::new(
222            ConceptId::new(subject)?,
223            relation.clone(),
224            ConceptId::new(object)?,
225        ))
226    })
227    .collect()
228}
229
230fn standard_rules() -> Result<Vec<ReasoningRule>, RuleEngineError> {
231    Ok(vec![
232        disclosed_product_rule(
233            "web.server.apache.header",
234            HttpEvidencePredicate::HEADER_SERVER.into(),
235            "apache",
236            WebKnowledgePredicate::TECHNOLOGY_WEB_SERVER.into(),
237            "apache-http-server",
238            20,
239            94,
240            6,
241            HypothesisStrength::Weak,
242            "Server header contains an Apache product token",
243        )?,
244        auth_header_rule("basic", "http-basic")?,
245        auth_header_rule("bearer", "http-bearer")?,
246        framework_laravel_rule()?,
247        framework_livewire_rule()?,
248        disclosed_product_rule(
249            "web.server.nginx.header",
250            HttpEvidencePredicate::HEADER_SERVER.into(),
251            "nginx",
252            WebKnowledgePredicate::TECHNOLOGY_WEB_SERVER.into(),
253            "nginx",
254            20,
255            97,
256            3,
257            HypothesisStrength::Weak,
258            "Server header contains the nginx product token",
259        )?,
260        disclosed_product_rule(
261            "web.language.php.header",
262            HttpEvidencePredicate::HEADER_X_POWERED_BY.into(),
263            "php",
264            WebKnowledgePredicate::TECHNOLOGY_LANGUAGE.into(),
265            "php",
266            20,
267            96,
268            4,
269            HypothesisStrength::Weak,
270            "X-Powered-By contains the PHP runtime token",
271        )?,
272        sanctum_cookie_rule()?,
273        form_convention_rule(
274            "web.form.convention.csrf-token",
275            "_token",
276            "csrf-token",
277            "_token matches a CSRF form-field convention used by Laravel",
278        )?,
279        form_convention_rule(
280            "web.form.convention.method-override",
281            "_method",
282            "method-override",
283            "_method matches an HTTP method-override form-field convention used by Laravel",
284        )?,
285    ])
286}
287
288/// A zero-request reasoning rule that maps one exact form-control name to a
289/// normalized convention label. It reads only the deterministic
290/// `http.response.form-control-names` observation and concludes a **Weak,
291/// Supported** `web.form.convention` hypothesis — never Confirmed (only a
292/// verifier stage may confirm). The claim is "compatible with Laravel,
293/// observed"; it never asserts the framework, that the parameter is accepted,
294/// or that the control set is complete.
295fn form_convention_rule(
296    id: &str,
297    marker: &str,
298    convention: &str,
299    rationale: &str,
300) -> Result<ReasoningRule, RuleEngineError> {
301    let form_controls = HttpEvidencePredicate::RESPONSE_FORM_CONTROL_NAMES.into_knowledge();
302    ReasoningRule::new(
303        id,
304        Expression::text_list_contains_exact(
305            KnowledgeLayer::Evidence,
306            form_controls.clone(),
307            marker,
308        )?,
309        HypothesisConclusion::new(
310            WebKnowledgePredicate::FORM_CONVENTION.into(),
311            EvidenceValue::Text(convention.to_owned()),
312            probability(5)?,
313            HypothesisStrength::Weak,
314            HypothesisState::Supported,
315            vec![list_exact_calibration(
316                form_controls,
317                marker,
318                90,
319                10,
320                rationale,
321            )?],
322        )?,
323    )
324}
325
326#[allow(clippy::too_many_arguments)]
327fn disclosed_product_rule(
328    id: &str,
329    source_predicate: KnowledgePredicate,
330    token: &str,
331    conclusion_predicate: KnowledgePredicate,
332    conclusion_value: &str,
333    prior: u8,
334    likelihood_if_true: u8,
335    likelihood_if_false: u8,
336    strength: HypothesisStrength,
337    rationale: &str,
338) -> Result<ReasoningRule, RuleEngineError> {
339    ReasoningRule::new(
340        id,
341        Expression::text_contains_ascii_case_insensitive(
342            KnowledgeLayer::Evidence,
343            source_predicate.clone(),
344            token,
345        )?,
346        HypothesisConclusion::new(
347            conclusion_predicate,
348            EvidenceValue::Text(conclusion_value.to_owned()),
349            probability(prior)?,
350            strength,
351            HypothesisState::Supported,
352            vec![text_calibration(
353                source_predicate,
354                token,
355                likelihood_if_true,
356                likelihood_if_false,
357                rationale,
358            )?],
359        )?,
360    )
361}
362
363fn auth_header_rule(token: &str, value: &str) -> Result<ReasoningRule, RuleEngineError> {
364    disclosed_product_rule(
365        &format!("web.authentication.{token}.header"),
366        HttpEvidencePredicate::HEADER_WWW_AUTHENTICATE.into(),
367        token,
368        WebKnowledgePredicate::AUTHENTICATION_MECHANISM.into(),
369        value,
370        15,
371        99,
372        1,
373        HypothesisStrength::Strong,
374        "WWW-Authenticate explicitly advertises this authentication scheme",
375    )
376}
377
378fn framework_laravel_rule() -> Result<ReasoningRule, RuleEngineError> {
379    let powered_by = HttpEvidencePredicate::HEADER_X_POWERED_BY.into_knowledge();
380    let cookie_name = HttpEvidencePredicate::COOKIE_NAME.into_knowledge();
381    let laravel_session = EvidenceValue::Text("laravel_session".to_owned());
382    let xsrf_token = EvidenceValue::Text("XSRF-TOKEN".to_owned());
383    let condition = Expression::any(vec![
384        Expression::text_contains_ascii_case_insensitive(
385            KnowledgeLayer::Evidence,
386            powered_by.clone(),
387            "laravel",
388        )?,
389        Expression::all(vec![
390            Expression::equals(
391                KnowledgeLayer::Evidence,
392                cookie_name.clone(),
393                laravel_session.clone(),
394            ),
395            Expression::equals(
396                KnowledgeLayer::Evidence,
397                cookie_name.clone(),
398                xsrf_token.clone(),
399            ),
400        ])?,
401    ])?;
402    ReasoningRule::new(
403        "web.framework.laravel",
404        condition,
405        HypothesisConclusion::new(
406            WebKnowledgePredicate::TECHNOLOGY_FRAMEWORK.into(),
407            EvidenceValue::Text("laravel".to_owned()),
408            probability(5)?,
409            HypothesisStrength::Strong,
410            HypothesisState::Supported,
411            vec![
412                text_calibration(
413                    powered_by,
414                    "laravel",
415                    99,
416                    1,
417                    "X-Powered-By explicitly names Laravel",
418                )?,
419                exact_calibration(
420                    cookie_name.clone(),
421                    laravel_session,
422                    95,
423                    3,
424                    "Laravel session cookie is present",
425                )?,
426                exact_calibration(
427                    cookie_name,
428                    xsrf_token,
429                    80,
430                    20,
431                    "Laravel-compatible XSRF cookie is present",
432                )?,
433            ],
434        )?,
435    )
436}
437
438fn framework_livewire_rule() -> Result<ReasoningRule, RuleEngineError> {
439    let body_sample = HttpEvidencePredicate::RESPONSE_BODY_SAMPLE.into_knowledge();
440    let markers = ["wire:id=", "wire:snapshot="];
441    let conditions = markers
442        .iter()
443        .map(|marker| {
444            Expression::text_contains_ascii_case_insensitive(
445                KnowledgeLayer::Evidence,
446                body_sample.clone(),
447                *marker,
448            )
449        })
450        .collect::<Result<Vec<_>, _>>()?;
451    let calibrations = markers
452        .iter()
453        .map(|marker| {
454            text_calibration(
455                body_sample.clone(),
456                marker,
457                97,
458                3,
459                "Bounded response sample contains a Livewire DOM marker",
460            )
461        })
462        .collect::<Result<Vec<_>, _>>()?;
463    ReasoningRule::new(
464        "web.framework.livewire.body",
465        Expression::any(conditions)?,
466        HypothesisConclusion::new(
467            WebKnowledgePredicate::TECHNOLOGY_UI_FRAMEWORK.into(),
468            EvidenceValue::Text("livewire".to_owned()),
469            probability(5)?,
470            HypothesisStrength::Weak,
471            HypothesisState::Supported,
472            calibrations,
473        )?,
474    )
475}
476
477fn sanctum_cookie_rule() -> Result<ReasoningRule, RuleEngineError> {
478    let cookie_name = HttpEvidencePredicate::COOKIE_NAME.into_knowledge();
479    let laravel_session = EvidenceValue::Text("laravel_session".to_owned());
480    let xsrf_token = EvidenceValue::Text("XSRF-TOKEN".to_owned());
481    ReasoningRule::new(
482        "web.authentication.sanctum.cookies",
483        Expression::all(vec![
484            Expression::equals(
485                KnowledgeLayer::Evidence,
486                cookie_name.clone(),
487                laravel_session.clone(),
488            ),
489            Expression::equals(
490                KnowledgeLayer::Evidence,
491                cookie_name.clone(),
492                xsrf_token.clone(),
493            ),
494        ])?,
495        HypothesisConclusion::new(
496            WebKnowledgePredicate::AUTHENTICATION_MECHANISM.into(),
497            EvidenceValue::Text("sanctum".to_owned()),
498            probability(3)?,
499            HypothesisStrength::Weak,
500            HypothesisState::Supported,
501            vec![
502                exact_calibration(
503                    cookie_name.clone(),
504                    laravel_session,
505                    75,
506                    20,
507                    "Stateful Laravel session is compatible with Sanctum SPA authentication",
508                )?,
509                exact_calibration(
510                    cookie_name,
511                    xsrf_token,
512                    90,
513                    10,
514                    "XSRF cookie is compatible with Sanctum SPA authentication",
515                )?,
516            ],
517        )?,
518    )
519}
520
521fn text_calibration(
522    predicate: KnowledgePredicate,
523    token: &str,
524    likelihood_if_true: u8,
525    likelihood_if_false: u8,
526    rationale: &str,
527) -> Result<EvidenceCalibration, RuleEngineError> {
528    EvidenceCalibration::new(
529        EvidenceSelector::text_contains_ascii_case_insensitive(predicate, token)?,
530        probability(likelihood_if_true)?,
531        probability(likelihood_if_false)?,
532        rationale,
533    )
534}
535
536fn exact_calibration(
537    predicate: KnowledgePredicate,
538    value: EvidenceValue,
539    likelihood_if_true: u8,
540    likelihood_if_false: u8,
541    rationale: &str,
542) -> Result<EvidenceCalibration, RuleEngineError> {
543    EvidenceCalibration::new(
544        EvidenceSelector::equals(predicate, value),
545        probability(likelihood_if_true)?,
546        probability(likelihood_if_false)?,
547        rationale,
548    )
549}
550
551fn list_exact_calibration(
552    predicate: KnowledgePredicate,
553    value: &str,
554    likelihood_if_true: u8,
555    likelihood_if_false: u8,
556    rationale: &str,
557) -> Result<EvidenceCalibration, RuleEngineError> {
558    EvidenceCalibration::new(
559        EvidenceSelector::text_list_contains_exact(predicate, value)?,
560        probability(likelihood_if_true)?,
561        probability(likelihood_if_false)?,
562        rationale,
563    )
564}
565
566fn probability(percent: u8) -> Result<Probability, RuleEngineError> {
567    Ok(Probability::from_percent(percent)?)
568}
569
570#[cfg(test)]
571mod tests {
572    use termivar_core::{
573        ConfidenceScore, EntityId, Evidence, EvidenceKind, EvidenceSource, Hypothesis,
574        RelationTypeId,
575    };
576
577    use super::*;
578
579    fn subject() -> EntityId {
580        EntityId::new("endpoint:https://example.test").unwrap()
581    }
582
583    fn evidence(namespace: &str, name: &str, value: &str) -> Evidence {
584        Evidence::new(
585            subject(),
586            EvidenceKind::Technology,
587            KnowledgePredicate::new(namespace, name).unwrap(),
588            EvidenceValue::Text(value.to_owned()),
589            EvidenceSource::new("http.evidence", "test-observation").unwrap(),
590            ConfidenceScore::MAX,
591        )
592    }
593
594    fn hypothesis<'a>(hypotheses: &'a [Hypothesis], value: &str) -> Option<&'a Hypothesis> {
595        hypotheses
596            .iter()
597            .find(|hypothesis| hypothesis.value() == &EvidenceValue::Text(value.to_owned()))
598    }
599
600    #[test]
601    fn profile_installs_idempotently_and_defines_semantics() {
602        let knowledge = KnowledgeBase::new();
603        let mut engine = RuleEngine::new();
604        let profile = StandardWebReasoning::new().unwrap();
605
606        let first = profile.install(&knowledge, &mut engine).unwrap();
607        let second = profile.install(&knowledge, &mut engine).unwrap();
608
609        assert_eq!(first.concepts_inserted(), STANDARD_WEB_CONCEPT_COUNT);
610        assert_eq!(first.axioms_inserted(), STANDARD_WEB_AXIOM_COUNT);
611        assert_eq!(first.rules_inserted(), STANDARD_WEB_RULE_COUNT);
612        assert_eq!(second, StandardWebInstallReport::default());
613        assert!(knowledge
614            .ontology_is_a(
615                &ConceptId::new("laravel").unwrap(),
616                &ConceptId::new("technology").unwrap(),
617            )
618            .unwrap());
619        assert!(knowledge
620            .ontology_is_related(
621                &ConceptId::new("sanctum").unwrap(),
622                &RelationTypeId::new(Ontology::DEPENDS_ON).unwrap(),
623                &ConceptId::new("laravel").unwrap(),
624            )
625            .unwrap());
626    }
627
628    #[test]
629    fn disclosed_headers_materialize_explainable_weak_hypotheses() {
630        let knowledge = KnowledgeBase::new();
631        knowledge
632            .insert_evidence_batch(vec![
633                evidence("http.header", "server", "NGINX/1.26.1"),
634                evidence("http.header", "x-powered-by", "PHP/8.3.7"),
635            ])
636            .unwrap();
637        let mut engine = RuleEngine::new();
638        StandardWebReasoning::new()
639            .unwrap()
640            .install(&knowledge, &mut engine)
641            .unwrap();
642
643        let applications = engine.apply(&knowledge, &subject()).unwrap();
644        let hypotheses = knowledge.hypotheses_for_subject(&subject());
645        let nginx = hypothesis(&hypotheses, "nginx").unwrap();
646        let php = hypothesis(&hypotheses, "php").unwrap();
647
648        assert_eq!(nginx.strength(), HypothesisStrength::Weak);
649        assert_eq!(php.strength(), HypothesisStrength::Weak);
650        assert!(nginx.posterior() > Probability::from_percent(50).unwrap());
651        assert!(php.posterior() > Probability::from_percent(50).unwrap());
652        assert_eq!(nginx.belief().evidence().len(), 1);
653        assert_eq!(
654            applications
655                .iter()
656                .filter(|item| item.write().is_some())
657                .count(),
658            2
659        );
660    }
661
662    #[test]
663    fn cookie_pair_materializes_strong_laravel_and_weak_sanctum() {
664        let knowledge = KnowledgeBase::new();
665        knowledge
666            .insert_evidence_batch(vec![
667                evidence("http.cookie", "name", "laravel_session"),
668                evidence("http.cookie", "name", "XSRF-TOKEN"),
669            ])
670            .unwrap();
671        let mut engine = RuleEngine::new();
672        StandardWebReasoning::new()
673            .unwrap()
674            .install(&knowledge, &mut engine)
675            .unwrap();
676
677        engine.apply(&knowledge, &subject()).unwrap();
678        let hypotheses = knowledge.hypotheses_for_subject(&subject());
679        let laravel = hypothesis(&hypotheses, "laravel").unwrap();
680        let sanctum = hypothesis(&hypotheses, "sanctum").unwrap();
681
682        assert_eq!(laravel.strength(), HypothesisStrength::Strong);
683        assert_eq!(sanctum.strength(), HypothesisStrength::Weak);
684        assert_eq!(laravel.state(), HypothesisState::Supported);
685        assert!(laravel.posterior() > Probability::from_percent(80).unwrap());
686        assert!(sanctum.posterior() > Probability::from_percent(50).unwrap());
687        assert_eq!(laravel.belief().evidence().len(), 2);
688    }
689
690    #[test]
691    fn isolated_xsrf_cookie_does_not_overclaim_framework_or_authentication() {
692        let knowledge = KnowledgeBase::new();
693        knowledge
694            .insert_evidence(evidence("http.cookie", "name", "XSRF-TOKEN"))
695            .unwrap();
696        let mut engine = RuleEngine::new();
697        StandardWebReasoning::new()
698            .unwrap()
699            .install(&knowledge, &mut engine)
700            .unwrap();
701
702        engine.apply(&knowledge, &subject()).unwrap();
703
704        assert!(knowledge.hypotheses_for_subject(&subject()).is_empty());
705    }
706
707    fn form_controls(values: &[&str]) -> Evidence {
708        Evidence::new(
709            subject(),
710            EvidenceKind::Http,
711            HttpEvidencePredicate::RESPONSE_FORM_CONTROL_NAMES.into_knowledge(),
712            EvidenceValue::TextList(values.iter().map(|value| (*value).to_owned()).collect()),
713            EvidenceSource::new("http.evidence", "test-observation").unwrap(),
714            ConfidenceScore::MAX,
715        )
716    }
717
718    fn convention_hypotheses(controls: &[&str]) -> Vec<Hypothesis> {
719        let knowledge = KnowledgeBase::new();
720        knowledge.insert_evidence(form_controls(controls)).unwrap();
721        let mut engine = RuleEngine::new();
722        StandardWebReasoning::new()
723            .unwrap()
724            .install(&knowledge, &mut engine)
725            .unwrap();
726        engine.apply(&knowledge, &subject()).unwrap();
727        knowledge
728            .hypotheses_for_subject(&subject())
729            .into_iter()
730            .filter(|hypothesis| {
731                hypothesis.predicate() == &WebKnowledgePredicate::FORM_CONVENTION.into_knowledge()
732            })
733            .collect()
734    }
735
736    #[test]
737    fn exact_token_marker_maps_to_a_supported_csrf_convention() {
738        let conventions = convention_hypotheses(&["_token", "email"]);
739        assert_eq!(conventions.len(), 1);
740        let csrf = &conventions[0];
741        assert_eq!(csrf.predicate().dotted(), "web.form.convention");
742        assert_eq!(csrf.value(), &EvidenceValue::Text("csrf-token".to_owned()));
743        assert_eq!(csrf.strength(), HypothesisStrength::Weak);
744        // A convention observation is a candidate signal, never conclusive: only
745        // a verifier stage may confirm a hypothesis.
746        assert_eq!(csrf.state(), HypothesisState::Supported);
747        assert_ne!(csrf.state(), HypothesisState::Confirmed);
748        assert_eq!(csrf.belief().evidence().len(), 1);
749    }
750
751    #[test]
752    fn exact_method_marker_maps_to_a_supported_method_override_convention() {
753        let conventions = convention_hypotheses(&["_method"]);
754        assert_eq!(conventions.len(), 1);
755        assert_eq!(
756            conventions[0].value(),
757            &EvidenceValue::Text("method-override".to_owned())
758        );
759        assert_eq!(conventions[0].state(), HypothesisState::Supported);
760    }
761
762    #[test]
763    fn non_exact_and_unrelated_markers_produce_no_convention() {
764        for controls in [
765            &["_token_old"][..],
766            &["my_token"][..],
767            &["_METHOD"][..],
768            &["email", "password"][..],
769        ] {
770            assert!(
771                convention_hypotheses(controls).is_empty(),
772                "controls {controls:?} must not produce a form convention"
773            );
774        }
775    }
776
777    #[test]
778    fn both_markers_produce_two_distinct_supported_conventions() {
779        let mut values: Vec<_> = convention_hypotheses(&["_token", "_method", "email"])
780            .iter()
781            .map(|hypothesis| match hypothesis.value() {
782                EvidenceValue::Text(text) => text.clone(),
783                other => panic!("unexpected convention value {other:?}"),
784            })
785            .collect();
786        values.sort();
787        assert_eq!(values, vec!["csrf-token", "method-override"]);
788    }
789
790    #[test]
791    fn convention_provenance_excludes_substring_only_records() {
792        // The calibration uses exact text-list membership, so a separate record
793        // that only substring-contains the marker is never cited as provenance.
794        let knowledge = KnowledgeBase::new();
795        let exact = form_controls(&["_token"]);
796        let exact_id = exact.id().clone();
797        let substring_only = form_controls(&["_token_backup"]);
798        let substring_id = substring_only.id().clone();
799        knowledge
800            .insert_evidence_batch(vec![exact, substring_only])
801            .unwrap();
802        let mut engine = RuleEngine::new();
803        StandardWebReasoning::new()
804            .unwrap()
805            .install(&knowledge, &mut engine)
806            .unwrap();
807        engine.apply(&knowledge, &subject()).unwrap();
808
809        let csrf = knowledge
810            .hypotheses_for_subject(&subject())
811            .into_iter()
812            .find(|hypothesis| hypothesis.value() == &EvidenceValue::Text("csrf-token".to_owned()))
813            .expect("csrf-token convention present");
814        let cited: Vec<_> = csrf
815            .belief()
816            .evidence()
817            .iter()
818            .map(|observation| observation.evidence_id().clone())
819            .collect();
820        assert!(cited.contains(&exact_id));
821        assert!(!cited.contains(&substring_id));
822    }
823}