1use 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
32pub const STANDARD_WEB_CONCEPT_COUNT: usize = 14;
34
35pub const STANDARD_WEB_AXIOM_COUNT: usize = 16;
37
38pub const STANDARD_WEB_RULE_COUNT: usize = 10;
40
41#[derive(Debug, Error)]
43#[non_exhaustive]
44pub enum StandardWebReasoningError {
45 #[error(transparent)]
47 Ontology(#[from] OntologyError),
48
49 #[error(transparent)]
51 Rules(#[from] RuleEngineError),
52}
53
54#[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 pub fn concepts_inserted(self) -> usize {
65 self.concepts_inserted
66 }
67
68 pub fn axioms_inserted(self) -> usize {
70 self.axioms_inserted
71 }
72
73 pub fn rules_inserted(self) -> usize {
75 self.rules_inserted
76 }
77}
78
79#[derive(Debug, Clone)]
99pub struct StandardWebReasoning {
100 concepts: Vec<OntologyConcept>,
101 axioms: Vec<OntologyAxiom>,
102 rules: Vec<ReasoningRule>,
103}
104
105impl StandardWebReasoning {
106 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 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 pub fn concepts(&self) -> &[OntologyConcept] {
161 &self.concepts
162 }
163
164 pub fn axioms(&self) -> &[OntologyAxiom] {
166 &self.axioms
167 }
168
169 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
288fn 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 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 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}