1use base64::Engine;
4use sha2::{Digest, Sha256};
5use std::collections::{BTreeMap, BTreeSet};
6use std::net::IpAddr;
7use std::str::FromStr;
8use termivar_core::{EntityId, Evidence, EvidenceId, EvidenceKind, EvidenceValue};
9use url::Url;
10
11use crate::knowledge::KnowledgeSnapshot;
12use crate::semantic::entity::{
13 AuthArtifactKind, SemanticEntity, SemanticEntityType, SemanticExtractionLimits,
14 SemanticExtractionResult,
15};
16
17const CANONICAL_ID_VERSION: &str = "v1";
19
20#[derive(Debug, Clone)]
22pub struct EntityExtractor {
23 limits: SemanticExtractionLimits,
24}
25
26impl Default for EntityExtractor {
27 fn default() -> Self {
28 Self::new()
29 }
30}
31
32impl EntityExtractor {
33 pub fn new() -> Self {
35 Self {
36 limits: SemanticExtractionLimits::default(),
37 }
38 }
39
40 pub fn with_limits(limits: SemanticExtractionLimits) -> Self {
42 Self { limits }
43 }
44
45 pub fn limits(&self) -> &SemanticExtractionLimits {
47 &self.limits
48 }
49
50 pub fn extract_from_snapshot(&self, snapshot: &KnowledgeSnapshot) -> SemanticExtractionResult {
52 self.extract_from_evidence(snapshot.evidence())
53 }
54
55 pub fn extract_from_evidence(&self, evidence_list: &[Evidence]) -> SemanticExtractionResult {
57 let mut sorted_evidence: Vec<&Evidence> = evidence_list.iter().collect();
58 sorted_evidence.sort_by_key(|e| e.id());
59
60 self.merge_and_bound(
61 sorted_evidence
62 .into_iter()
63 .filter_map(|evidence| self.project_evidence(evidence)),
64 )
65 }
66
67 #[cfg(feature = "scanning")]
76 pub(crate) fn extract_from_web_assessment_evidence(
77 &self,
78 evidence_list: &[Evidence],
79 ) -> SemanticExtractionResult {
80 let index = evidence_list
81 .iter()
82 .map(|evidence| (evidence.id().clone(), evidence))
83 .collect::<BTreeMap<_, _>>();
84 let mut sorted_evidence = index.values().copied().collect::<Vec<_>>();
85 sorted_evidence.sort_by_key(|evidence| evidence.id());
86
87 let mut projected = Vec::new();
88 for evidence in sorted_evidence {
89 let predicate = evidence.predicate();
90 if evidence.kind() == &EvidenceKind::Http
91 && (predicate
92 == &termivar_core::HttpEvidencePredicate::REQUEST_URL.into_knowledge()
93 || predicate
94 == &termivar_core::HttpEvidencePredicate::REQUEST_METHOD.into_knowledge())
95 {
96 if let Some(entity) = self.project_evidence(evidence) {
97 projected.push(entity);
98 }
99 continue;
100 }
101 projected.extend(self.project_web_discovery_evidence(evidence, &index));
102 }
103 self.merge_and_bound(projected)
104 }
105
106 fn merge_and_bound(
107 &self,
108 projected: impl IntoIterator<Item = SemanticEntity>,
109 ) -> SemanticExtractionResult {
110 let mut entity_map = BTreeMap::<
111 EntityId,
112 (
113 SemanticEntityType,
114 BTreeMap<String, BTreeSet<String>>,
115 BTreeSet<EvidenceId>,
116 ),
117 >::new();
118
119 for extracted in projected {
120 let (id, etype, attrs, sources) = extracted.into_parts();
121 let entry = entity_map
122 .entry(id)
123 .or_insert_with(|| (etype, BTreeMap::new(), BTreeSet::new()));
124
125 for (k, vals) in attrs {
126 entry.1.entry(k).or_default().extend(vals);
127 }
128 entry.2.extend(sources);
129 }
130
131 let mut dropped_entities = 0;
132 let mut dropped_attributes = 0;
133 let mut dropped_sources = 0;
134 let mut truncated = false;
135
136 if entity_map.len() > self.limits.max_entities {
138 dropped_entities = entity_map.len() - self.limits.max_entities;
139 truncated = true;
140 let keys_to_keep: Vec<EntityId> = entity_map
141 .keys()
142 .take(self.limits.max_entities)
143 .cloned()
144 .collect();
145 entity_map.retain(|k, _| keys_to_keep.contains(k));
146 }
147
148 let mut final_entities = Vec::with_capacity(entity_map.len());
149
150 for (id, (etype, mut attrs, sources)) in entity_map {
151 let source_vec: Vec<EvidenceId> = if sources.len() > self.limits.max_source_evidence_ids
152 {
153 dropped_sources += sources.len() - self.limits.max_source_evidence_ids;
154 truncated = true;
155 sources
156 .into_iter()
157 .take(self.limits.max_source_evidence_ids)
158 .collect()
159 } else {
160 sources.into_iter().collect()
161 };
162
163 if attrs.len() > self.limits.max_attribute_keys {
164 dropped_attributes += attrs.len() - self.limits.max_attribute_keys;
165 truncated = true;
166 let attr_keys_to_keep: Vec<String> = attrs
167 .keys()
168 .take(self.limits.max_attribute_keys)
169 .cloned()
170 .collect();
171 attrs.retain(|k, _| attr_keys_to_keep.contains(k));
172 }
173
174 for values in attrs.values_mut() {
175 if values.len() > self.limits.max_values_per_attribute {
176 dropped_attributes += values.len() - self.limits.max_values_per_attribute;
177 truncated = true;
178 let val_set_to_keep: BTreeSet<String> = values
179 .iter()
180 .take(self.limits.max_values_per_attribute)
181 .cloned()
182 .collect();
183 *values = val_set_to_keep;
184 }
185 }
186
187 final_entities.push(SemanticEntity::new(id, etype, attrs, source_vec));
188 }
189
190 SemanticExtractionResult {
191 entities: final_entities,
192 truncated,
193 dropped_entities,
194 dropped_attributes,
195 dropped_sources,
196 }
197 }
198
199 #[cfg(feature = "scanning")]
200 fn project_web_discovery_evidence(
201 &self,
202 evidence: &Evidence,
203 index: &BTreeMap<EvidenceId, &Evidence>,
204 ) -> Vec<SemanticEntity> {
205 if evidence.kind() != &EvidenceKind::Content {
206 return Vec::new();
207 }
208 let predicate = evidence.predicate();
209 let (method_attribute, endpoint_method) = if predicate
210 == &termivar_core::predicates::WebDiscoveryEvidencePredicate::GET_ROUTE.into_knowledge()
211 || predicate
212 == &termivar_core::predicates::WebDiscoveryEvidencePredicate::GET_FORM_ACTION
213 .into_knowledge()
214 {
215 ("method", "GET")
216 } else if predicate
217 == &termivar_core::predicates::WebDiscoveryEvidencePredicate::HEAD_ROUTE
218 .into_knowledge()
219 {
220 ("method", "HEAD")
221 } else if predicate
222 == &termivar_core::predicates::WebDiscoveryEvidencePredicate::POST_FORM_ACTION
223 .into_knowledge()
224 {
225 ("method", "POST")
226 } else if predicate
227 == &termivar_core::predicates::WebDiscoveryEvidencePredicate::DIALOG_FORM_ACTION
228 .into_knowledge()
229 {
230 ("form_method", "dialog")
231 } else {
232 return self.project_web_discovery_names(evidence, index);
233 };
234
235 let EvidenceValue::Text(raw_url) = evidence.value() else {
236 return Vec::new();
237 };
238 let Some((id, canonical_url)) = parse_strict_canonical_endpoint(raw_url, &self.limits)
239 else {
240 return Vec::new();
241 };
242 let Some(sources) = lineage_sources(evidence, index) else {
243 return Vec::new();
244 };
245 let attributes = BTreeMap::from([
246 ("url".to_owned(), BTreeSet::from([canonical_url])),
247 (
248 method_attribute.to_owned(),
249 BTreeSet::from([endpoint_method.to_owned()]),
250 ),
251 ]);
252 vec![SemanticEntity::new(
253 id,
254 SemanticEntityType::Endpoint,
255 attributes,
256 sources,
257 )]
258 }
259
260 #[cfg(feature = "scanning")]
261 fn project_web_discovery_names(
262 &self,
263 evidence: &Evidence,
264 index: &BTreeMap<EvidenceId, &Evidence>,
265 ) -> Vec<SemanticEntity> {
266 let predicate = evidence.predicate();
267 let (location, route_parent) = if predicate
268 == &termivar_core::predicates::WebDiscoveryEvidencePredicate::ROUTE_QUERY_PARAMETER_NAMES
269 .into_knowledge()
270 {
271 ("query", true)
272 } else if predicate
273 == &termivar_core::predicates::WebDiscoveryEvidencePredicate::FORM_QUERY_PARAMETER_NAMES
274 .into_knowledge()
275 {
276 ("query", false)
277 } else if predicate
278 == &termivar_core::predicates::WebDiscoveryEvidencePredicate::FORM_CONTROL_NAMES
279 .into_knowledge()
280 {
281 ("form_control", false)
282 } else {
283 return Vec::new();
284 };
285 let Some(derivation) = evidence.origin().derivation() else {
286 return Vec::new();
287 };
288 let [parent_id] = derivation.parents() else {
289 return Vec::new();
290 };
291 let Some(parent) = index.get(parent_id).copied() else {
292 return Vec::new();
293 };
294 let parent_predicate = parent.predicate();
295 let accepted_parent = if route_parent {
296 parent_predicate
297 == &termivar_core::predicates::WebDiscoveryEvidencePredicate::GET_ROUTE
298 .into_knowledge()
299 || parent_predicate
300 == &termivar_core::predicates::WebDiscoveryEvidencePredicate::HEAD_ROUTE
301 .into_knowledge()
302 } else {
303 parent_predicate
304 == &termivar_core::predicates::WebDiscoveryEvidencePredicate::GET_FORM_ACTION
305 .into_knowledge()
306 || parent_predicate
307 == &termivar_core::predicates::WebDiscoveryEvidencePredicate::POST_FORM_ACTION
308 .into_knowledge()
309 || parent_predicate
310 == &termivar_core::predicates::WebDiscoveryEvidencePredicate::DIALOG_FORM_ACTION
311 .into_knowledge()
312 };
313 if !accepted_parent || parent.kind() != &EvidenceKind::Content {
314 return Vec::new();
315 }
316 let EvidenceValue::Text(raw_url) = parent.value() else {
317 return Vec::new();
318 };
319 let Some((_, canonical_url)) = parse_strict_canonical_endpoint(raw_url, &self.limits)
320 else {
321 return Vec::new();
322 };
323 let EvidenceValue::TextList(names) = evidence.value() else {
324 return Vec::new();
325 };
326 if names.is_empty()
327 || names.len()
328 > SemanticExtractionLimits::HARD_MAX_ASSESSMENT_PARAMETER_NAMES_PER_REFERENCE
329 || names.windows(2).any(|pair| pair[0] >= pair[1])
330 || names.iter().any(|name| !valid_parameter_name(name))
331 {
332 return Vec::new();
333 }
334 let Some(sources) = lineage_sources(evidence, index) else {
335 return Vec::new();
336 };
337
338 names
339 .iter()
340 .filter_map(|name| {
341 let id = parameter_id(&canonical_url, location, name)?;
342 let attributes = BTreeMap::from([
343 (
344 "endpoint_url".to_owned(),
345 BTreeSet::from([canonical_url.clone()]),
346 ),
347 ("location".to_owned(), BTreeSet::from([location.to_owned()])),
348 ("name".to_owned(), BTreeSet::from([name.clone()])),
349 ]);
350 Some(SemanticEntity::new(
351 id,
352 SemanticEntityType::Parameter,
353 attributes,
354 sources.clone(),
355 ))
356 })
357 .collect()
358 }
359
360 fn project_evidence(&self, evidence: &Evidence) -> Option<SemanticEntity> {
361 let predicate = evidence.predicate();
362 let predicate_namespace = predicate.namespace();
363 let predicate_name = predicate.name();
364
365 let val_str = match evidence.value() {
366 EvidenceValue::Text(s) => s.as_str(),
367 _ => return None,
368 };
369
370 match (evidence.kind(), predicate_namespace, predicate_name) {
375 (
376 EvidenceKind::Technology,
377 "technology",
378 "web-server" | "language" | "framework" | "ui-framework",
379 ) => {
380 if val_str.len() > self.limits.max_value_bytes {
381 return None;
382 }
383 let name = val_str.trim();
384 if name.is_empty() {
385 return None;
386 }
387 if !name.chars().any(|ch| ch.is_ascii_alphabetic()) {
388 return None;
389 }
390 let canonical_id = EntityId::new(format!(
391 "{CANONICAL_ID_VERSION}:tech:{}",
392 name.to_lowercase()
393 ))
394 .ok()?;
395 let mut attrs = BTreeMap::new();
396 attrs.insert("name".to_string(), BTreeSet::from([name.to_string()]));
397
398 Some(SemanticEntity::new(
399 canonical_id,
400 SemanticEntityType::Technology,
401 attrs,
402 vec![evidence.id().clone()],
403 ))
404 },
405 (EvidenceKind::Http, "http.request", "url") => {
406 let (canonical_id, url_str) =
407 parse_canonical_endpoint(evidence.subject().as_str(), val_str, &self.limits)?;
408 let mut attrs = BTreeMap::new();
409 attrs.insert("url".to_string(), BTreeSet::from([url_str]));
410
411 Some(SemanticEntity::new(
412 canonical_id,
413 SemanticEntityType::Endpoint,
414 attrs,
415 vec![evidence.id().clone()],
416 ))
417 },
418 (EvidenceKind::Http, "http.request", "method") => {
419 if val_str.len() > self.limits.max_value_bytes {
420 return None;
421 }
422 let (canonical_id, url_str) =
423 parse_canonical_endpoint(evidence.subject().as_str(), "", &self.limits)?;
424 let normalized_method = normalize_http_method(val_str)?;
425
426 let mut attrs = BTreeMap::new();
427 attrs.insert("url".to_string(), BTreeSet::from([url_str]));
428 attrs.insert("method".to_string(), BTreeSet::from([normalized_method]));
429
430 Some(SemanticEntity::new(
431 canonical_id,
432 SemanticEntityType::Endpoint,
433 attrs,
434 vec![evidence.id().clone()],
435 ))
436 },
437 (EvidenceKind::Dns, "dns", "ip") => {
438 let raw_val = val_str.trim();
439 if raw_val.is_empty() {
440 return None;
441 }
442 let canonical_ip = parse_canonical_ip(raw_val)?;
443 let canonical_id =
444 EntityId::new(format!("{CANONICAL_ID_VERSION}:ip:{canonical_ip}")).ok()?;
445 let mut attrs = BTreeMap::new();
446 attrs.insert("ip".to_string(), BTreeSet::from([canonical_ip]));
447 Some(SemanticEntity::new(
448 canonical_id,
449 SemanticEntityType::IpAddress,
450 attrs,
451 vec![evidence.id().clone()],
452 ))
453 },
454 (EvidenceKind::Dns, "dns", "domain" | "hostname") => {
455 let raw_val = val_str.trim();
456 if raw_val.is_empty() {
457 return None;
458 }
459 let canonical_domain = parse_canonical_domain(raw_val)?;
460 let canonical_id =
461 EntityId::new(format!("{CANONICAL_ID_VERSION}:domain:{canonical_domain}"))
462 .ok()?;
463 let mut attrs = BTreeMap::new();
464 attrs.insert("domain".to_string(), BTreeSet::from([canonical_domain]));
465 Some(SemanticEntity::new(
466 canonical_id,
467 SemanticEntityType::Domain,
468 attrs,
469 vec![evidence.id().clone()],
470 ))
471 },
472 (EvidenceKind::Authentication, "authentication", "api_key" | "bearer" | "jwt") => {
473 if val_str.len() > self.limits.max_value_bytes {
474 return None;
475 }
476 let raw_token = val_str.trim();
478 if raw_token.is_empty() {
479 return None;
480 }
481
482 let clean_token = strip_bearer_prefix(raw_token);
483 if clean_token.is_empty() {
484 return None;
485 }
486
487 let kind = classify_auth_kind(predicate_name, clean_token);
488 let fingerprint = hash_token(kind, clean_token);
489 let canonical_id = EntityId::new(format!(
490 "{CANONICAL_ID_VERSION}:auth_artifact:{fingerprint}"
491 ))
492 .ok()?;
493
494 let mut attrs = BTreeMap::new();
495 attrs.insert(
496 "auth_kind".to_string(),
497 BTreeSet::from([kind.slug().to_string()]),
498 );
499 attrs.insert("fingerprint".to_string(), BTreeSet::from([fingerprint]));
500 attrs.insert(
501 "length".to_string(),
502 BTreeSet::from([clean_token.len().to_string()]),
503 );
504
505 Some(SemanticEntity::new(
506 canonical_id,
507 SemanticEntityType::AuthArtifact,
508 attrs,
509 vec![evidence.id().clone()],
510 ))
511 },
512 (EvidenceKind::Http, "http.header", header_name) => {
513 let name_lower = header_name.to_lowercase();
514 if !is_valid_header_name(&name_lower) {
515 return None;
516 }
517 let name_lower = header_name.to_lowercase();
518 if name_lower.is_empty() {
519 return None;
520 }
521
522 let canonical_id =
525 EntityId::new(format!("{CANONICAL_ID_VERSION}:header:{name_lower}")).ok()?;
526 let mut attrs = BTreeMap::new();
527 attrs.insert("name".to_string(), BTreeSet::from([name_lower]));
528
529 Some(SemanticEntity::new(
530 canonical_id,
531 SemanticEntityType::Header,
532 attrs,
533 vec![evidence.id().clone()],
534 ))
535 },
536 _ => None,
539 }
540 }
541}
542
543#[cfg(feature = "scanning")]
544fn parse_strict_canonical_endpoint(
545 value: &str,
546 limits: &SemanticExtractionLimits,
547) -> Option<(EntityId, String)> {
548 if value != value.trim() || value.contains('#') || value.contains('?') {
549 return None;
550 }
551 let (id, canonical) = parse_canonical_endpoint("", value, limits)?;
552 (canonical == value).then_some((id, canonical))
553}
554
555#[cfg(feature = "scanning")]
556fn valid_parameter_name(name: &str) -> bool {
557 !name.is_empty()
558 && name.len() <= SemanticExtractionLimits::HARD_MAX_ASSESSMENT_PARAMETER_NAME_BYTES
559 && !name.chars().any(char::is_control)
560}
561
562#[cfg(feature = "scanning")]
563fn parameter_id(canonical_url: &str, location: &str, name: &str) -> Option<EntityId> {
564 let mut hasher = Sha256::new();
565 hasher.update(b"venom:parameter:v1\0");
566 hasher.update(canonical_url.as_bytes());
567 hasher.update(b"\0");
568 hasher.update(location.as_bytes());
569 hasher.update(b"\0");
570 hasher.update(name.as_bytes());
571 let digest = hasher
572 .finalize()
573 .iter()
574 .map(|byte| format!("{byte:02x}"))
575 .collect::<String>();
576 EntityId::new(format!("{CANONICAL_ID_VERSION}:parameter:{digest}")).ok()
577}
578
579#[cfg(feature = "scanning")]
580fn lineage_sources(
581 evidence: &Evidence,
582 index: &BTreeMap<EvidenceId, &Evidence>,
583) -> Option<Vec<EvidenceId>> {
584 let mut pending = vec![evidence.id().clone()];
585 let mut sources = BTreeSet::new();
586 while let Some(id) = pending.pop() {
587 if !sources.insert(id.clone()) {
588 continue;
589 }
590 if sources.len() > SemanticExtractionLimits::HARD_MAX_SOURCE_EVIDENCE_IDS {
591 return None;
592 }
593 let record = index.get(&id).copied()?;
594 if record.subject() != evidence.subject() {
595 return None;
596 }
597 if let Some(derivation) = record.origin().derivation() {
598 if derivation.algorithm().name() != "web.discovery.html5ever-names-only"
599 || derivation.algorithm().version() != 1
600 {
601 return None;
602 }
603 pending.extend(derivation.parents().iter().cloned());
604 }
605 }
606 Some(sources.into_iter().collect())
607}
608
609fn parse_canonical_ip(raw: &str) -> Option<String> {
610 let clean = raw.trim();
611 if clean.is_empty() {
612 return None;
613 }
614 let ip = IpAddr::from_str(clean).ok()?;
615 Some(ip.to_string())
616}
617
618fn parse_canonical_domain(raw: &str) -> Option<String> {
619 let s = raw.trim();
620 if s.is_empty() || s.len() > 253 {
621 return None;
622 }
623
624 if parse_canonical_ip(s).is_some() {
625 return None;
626 }
627
628 if s.contains("://")
629 || s.contains('/')
630 || s.contains('@')
631 || s.contains('#')
632 || s.contains('?')
633 || s.contains(':')
634 {
635 return None;
636 }
637
638 let trimmed = s.strip_suffix('.').unwrap_or(s);
639 let lower = trimmed.to_lowercase();
640
641 if lower.chars().any(|c| c.is_whitespace() || c.is_control()) {
642 return None;
643 }
644
645 let labels: Vec<&str> = lower.split('.').collect();
646 if labels.is_empty() {
647 return None;
648 }
649
650 if let Some(tld) = labels.last() {
651 if tld.chars().all(|c| c.is_ascii_digit()) {
652 return None;
653 }
654 }
655
656 for label in &labels {
657 if label.is_empty() || label.len() > 63 {
658 return None;
659 }
660 if label.starts_with('-') || label.ends_with('-') {
661 return None;
662 }
663 if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
664 return None;
665 }
666 }
667
668 Some(lower)
669}
670
671fn strip_bearer_prefix(raw: &str) -> &str {
672 let trimmed = raw.trim();
673 if let Some(stripped) = trimmed.strip_prefix("Bearer ") {
674 stripped.trim()
675 } else if let Some(stripped) = trimmed.strip_prefix("bearer ") {
676 stripped.trim()
677 } else {
678 trimmed
679 }
680}
681
682fn hash_token(kind: AuthArtifactKind, clean_token: &str) -> String {
683 let mut hasher = Sha256::new();
684 let domain_sep = format!(
685 "venom:auth-artifact:{CANONICAL_ID_VERSION}:{}:{clean_token}",
686 kind.slug()
687 );
688 hasher.update(domain_sep.as_bytes());
689 let result = hasher.finalize();
690 result.iter().map(|b| format!("{b:02x}")).collect()
691}
692
693fn classify_auth_kind(predicate: &str, clean_token: &str) -> AuthArtifactKind {
694 if predicate == "api_key" {
700 return AuthArtifactKind::ApiKey;
701 }
702 if is_valid_jwt_structure(clean_token) {
703 return AuthArtifactKind::Jwt;
704 }
705 if predicate == "bearer" {
706 return AuthArtifactKind::BearerToken;
707 }
708 AuthArtifactKind::Unknown
709}
710
711fn is_valid_jwt_structure(raw: &str) -> bool {
712 let parts: Vec<&str> = raw.split('.').collect();
713 if parts.len() != 3 {
714 return false;
715 }
716
717 is_valid_base64url_json_object(parts[0]) && is_valid_base64url_json_object(parts[1])
718}
719
720fn is_valid_base64url_json_object(segment: &str) -> bool {
721 if segment.is_empty() {
722 return false;
723 }
724
725 let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
726 .decode(segment)
727 .or_else(|_| {
728 let pad = match segment.len() % 4 {
729 2 => "==",
730 3 => "=",
731 _ => "",
732 };
733 base64::engine::general_purpose::URL_SAFE.decode(format!("{segment}{pad}"))
734 });
735
736 if let Ok(bytes) = decoded {
737 if let Ok(serde_json::Value::Object(_)) = serde_json::from_slice(&bytes) {
738 return true;
739 }
740 }
741
742 false
743}
744
745fn is_valid_header_name(name: &str) -> bool {
746 if name.is_empty() {
747 return false;
748 }
749 name.bytes().all(|b| {
750 matches!(
751 b,
752 b'a'..=b'z'
753 | b'A'..=b'Z'
754 | b'0'..=b'9'
755 | b'!'
756 | b'#'
757 | b'$'
758 | b'%'
759 | b'&'
760 | b'\''
761 | b'*'
762 | b'+'
763 | b'-'
764 | b'.'
765 | b'^'
766 | b'_'
767 | b'`'
768 | b'|'
769 | b'~'
770 )
771 })
772}
773
774fn parse_canonical_endpoint(
775 subject_str: &str,
776 val_str: &str,
777 limits: &SemanticExtractionLimits,
778) -> Option<(EntityId, String)> {
779 let clean_val = val_str.trim();
780 let source_url = if clean_val.is_empty() {
781 subject_str
782 } else {
783 clean_val
784 };
785
786 let unstripped_target = source_url.strip_prefix("endpoint:").unwrap_or(source_url);
787
788 let target_url_str = if unstripped_target.starts_with('/') {
789 let subj_clean = subject_str.strip_prefix("endpoint:").unwrap_or(subject_str);
790 let base_url = Url::parse(subj_clean).ok()?;
791 base_url.join(unstripped_target).ok()?.to_string()
792 } else {
793 unstripped_target.to_string()
794 };
795
796 if target_url_str.len() > limits.max_url_bytes {
797 return None;
798 }
799
800 let mut url = Url::parse(&target_url_str).ok()?;
801 let scheme = url.scheme().to_lowercase();
802 if scheme != "http" && scheme != "https" {
803 return None;
804 }
805
806 let _ = url.set_username("");
807 let _ = url.set_password(None);
808 url.set_query(None);
809 url.set_fragment(None);
810
811 if (scheme == "http" && url.port() == Some(80))
812 || (scheme == "https" && url.port() == Some(443))
813 {
814 let _ = url.set_port(None);
815 }
816
817 let normalized_url = url.to_string();
818
819 let canonical_str = format!("{CANONICAL_ID_VERSION}:endpoint:{normalized_url}");
820 let canonical_id = EntityId::new(canonical_str).ok()?;
821 Some((canonical_id, normalized_url))
822}
823
824fn normalize_http_method(raw_method: &str) -> Option<String> {
825 if raw_method != raw_method.trim() {
829 return None;
830 }
831 if raw_method.is_empty() {
832 return None;
833 }
834 if raw_method.len() > 20 {
835 return None;
836 }
837 if !raw_method
840 .as_bytes()
841 .iter()
842 .all(|b| is_token_char(*b as char))
843 {
844 return None;
845 }
846 Some(raw_method.to_ascii_uppercase())
847}
848
849fn is_token_char(byte: char) -> bool {
850 matches!(
851 byte,
852 '0'..='9'
853 | 'A'..='Z'
854 | 'a'..='z'
855 | '!'
856 | '#'
857 | '$'
858 | '%'
859 | '&'
860 | '\''
861 | '*'
862 | '+'
863 | '-'
864 | '.'
865 | '^'
866 | '_'
867 | '`'
868 | '|'
869 | '~'
870 )
871}
872
873#[cfg(test)]
874mod tests {
875 use super::*;
876 use termivar_core::{ConfidenceScore, EvidenceSource, KnowledgePredicate};
877 #[cfg(feature = "scanning")]
878 use termivar_core::{
879 DerivationAlgorithm, EvidenceDerivation, HttpEvidencePredicate, PredicateDescriptor,
880 };
881
882 fn subject() -> EntityId {
883 EntityId::new("endpoint:https://example.test/api/user").unwrap()
884 }
885
886 fn source() -> EvidenceSource {
887 EvidenceSource::new("scanner", "test").unwrap()
888 }
889
890 fn ev(kind: EvidenceKind, predicate: KnowledgePredicate, value: EvidenceValue) -> Evidence {
891 Evidence::new(
892 subject(),
893 kind,
894 predicate,
895 value,
896 source(),
897 ConfidenceScore::from_percent(50).unwrap(),
898 )
899 }
900
901 fn ev_subj(
902 subj: EntityId,
903 kind: EvidenceKind,
904 predicate: KnowledgePredicate,
905 value: EvidenceValue,
906 ) -> Evidence {
907 Evidence::new(
908 subj,
909 kind,
910 predicate,
911 value,
912 source(),
913 ConfidenceScore::from_percent(50).unwrap(),
914 )
915 }
916
917 #[cfg(feature = "scanning")]
918 fn fixed_evidence(
919 id: &str,
920 subject: &EntityId,
921 kind: EvidenceKind,
922 predicate: KnowledgePredicate,
923 value: EvidenceValue,
924 method: &str,
925 ) -> Evidence {
926 Evidence::with_id_at(
927 EvidenceId::parse(id).unwrap(),
928 subject.clone(),
929 kind,
930 predicate,
931 value,
932 EvidenceSource::new("venom.http-evidence", method)
933 .unwrap()
934 .with_correlation_id("web.bootstrap.case")
935 .unwrap(),
936 ConfidenceScore::from_percent(100).unwrap(),
937 1,
938 )
939 }
940
941 #[cfg(feature = "scanning")]
942 fn derived_discovery_evidence(
943 id: &str,
944 subject: &EntityId,
945 predicate: PredicateDescriptor,
946 value: EvidenceValue,
947 method: &str,
948 parents: impl IntoIterator<Item = EvidenceId>,
949 ) -> Evidence {
950 fixed_evidence(
951 id,
952 subject,
953 EvidenceKind::Content,
954 predicate.into_knowledge(),
955 value,
956 method,
957 )
958 .derived_from(
959 EvidenceDerivation::new(
960 parents,
961 DerivationAlgorithm::new("web.discovery.html5ever-names-only", 1).unwrap(),
962 )
963 .unwrap(),
964 )
965 }
966
967 #[cfg(feature = "scanning")]
968 fn assessment_route_evidence(names: Vec<String>) -> Vec<Evidence> {
969 let subject = EntityId::new("endpoint:https://example.test/").unwrap();
970 let base = vec![
971 fixed_evidence(
972 "semantic-base-method",
973 &subject,
974 EvidenceKind::Http,
975 HttpEvidencePredicate::REQUEST_METHOD.into_knowledge(),
976 EvidenceValue::Text("GET".to_owned()),
977 "request-method",
978 ),
979 fixed_evidence(
980 "semantic-base-url",
981 &subject,
982 EvidenceKind::Http,
983 HttpEvidencePredicate::REQUEST_URL.into_knowledge(),
984 EvidenceValue::Text("https://example.test/".to_owned()),
985 "request-url",
986 ),
987 fixed_evidence(
988 "semantic-base-status",
989 &subject,
990 EvidenceKind::Http,
991 HttpEvidencePredicate::RESPONSE_STATUS.into_knowledge(),
992 EvidenceValue::Unsigned(200),
993 "response-status",
994 ),
995 fixed_evidence(
996 "semantic-base-media",
997 &subject,
998 EvidenceKind::Http,
999 HttpEvidencePredicate::RESPONSE_MEDIA_TYPE.into_knowledge(),
1000 EvidenceValue::Text("text/html".to_owned()),
1001 "response-media-type",
1002 ),
1003 fixed_evidence(
1004 "semantic-base-truncated",
1005 &subject,
1006 EvidenceKind::Content,
1007 HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED.into_knowledge(),
1008 EvidenceValue::Boolean(false),
1009 "response-body-truncation",
1010 ),
1011 fixed_evidence(
1012 "semantic-base-digest",
1013 &subject,
1014 EvidenceKind::Content,
1015 HttpEvidencePredicate::RESPONSE_BODY_SHA256.into_knowledge(),
1016 EvidenceValue::Text("a".repeat(64)),
1017 "response-body-sha256",
1018 ),
1019 ];
1020 let marker = derived_discovery_evidence(
1021 "semantic-document",
1022 &subject,
1023 termivar_core::predicates::WebDiscoveryEvidencePredicate::DOCUMENT_PROJECTED,
1024 EvidenceValue::Boolean(true),
1025 "document-projected",
1026 base.iter().map(|evidence| evidence.id().clone()),
1027 );
1028 let route = derived_discovery_evidence(
1029 "semantic-route",
1030 &subject,
1031 termivar_core::predicates::WebDiscoveryEvidencePredicate::GET_ROUTE,
1032 EvidenceValue::Text("https://example.test/search".to_owned()),
1033 "get-route",
1034 [marker.id().clone()],
1035 );
1036 let names = derived_discovery_evidence(
1037 "semantic-route-names",
1038 &subject,
1039 termivar_core::predicates::WebDiscoveryEvidencePredicate::ROUTE_QUERY_PARAMETER_NAMES,
1040 EvidenceValue::TextList(names),
1041 "route-query-parameter-names",
1042 [route.id().clone()],
1043 );
1044 base.into_iter().chain([marker, route, names]).collect()
1045 }
1046
1047 #[test]
1048 fn unsupported_evidence_produces_no_entity() {
1049 let extractor = EntityExtractor::new();
1050 let e = ev(
1051 EvidenceKind::Custom("unsupported_kind".to_string()),
1052 KnowledgePredicate::new("custom", "unsupported_pred").unwrap(),
1053 EvidenceValue::Text("raw-unsupported-value".into()),
1054 );
1055 let res = extractor.extract_from_evidence(&[e]);
1056 assert!(res.entities.is_empty());
1057 }
1058
1059 #[test]
1060 fn namespace_collision_is_ignored() {
1061 let extractor = EntityExtractor::new();
1062 let e = ev(
1063 EvidenceKind::Http,
1064 KnowledgePredicate::new("http.request", "method").unwrap(),
1065 EvidenceValue::Text("GET".into()),
1066 );
1067 let e2 = ev(
1068 EvidenceKind::Http,
1069 KnowledgePredicate::new("api.request", "url").unwrap(),
1070 EvidenceValue::Text("https://example.test/admin".to_string()),
1071 );
1072 let e3 = ev(
1073 EvidenceKind::Http,
1074 KnowledgePredicate::new("api.request", "method").unwrap(),
1075 EvidenceValue::Text("GET".into()),
1076 );
1077
1078 let res1 = extractor.extract_from_evidence(&[e]);
1079 let res2 = extractor.extract_from_evidence(&[e2]);
1080 let res3 = extractor.extract_from_evidence(&[e3]);
1081 assert_eq!(res1.entities.len(), 1);
1082 assert_eq!(
1083 res1.entities[0].id().as_str(),
1084 "v1:endpoint:https://example.test/api/user"
1085 );
1086 assert!(res2.entities.is_empty());
1087 assert!(res3.entities.is_empty());
1088 }
1089
1090 #[test]
1091 fn custom_predicate_namespace_does_not_route_as_http_method() {
1092 let extractor = EntityExtractor::new();
1093 let e = ev(
1094 EvidenceKind::Http,
1095 KnowledgePredicate::new("custom", "method").unwrap(),
1096 EvidenceValue::Text("GET".into()),
1097 );
1098 let res = extractor.extract_from_evidence(&[e]);
1099 assert!(res.entities.is_empty());
1100 }
1101
1102 #[test]
1103 fn unknown_authentication_predicate_never_leaks_raw_value() {
1104 let extractor = EntityExtractor::new();
1105 let e = ev(
1106 EvidenceKind::Authentication,
1107 KnowledgePredicate::new("authentication", "client_secret").unwrap(),
1108 EvidenceValue::Text("super_secret_token_value_12345".into()),
1109 );
1110 let res = extractor.extract_from_evidence(&[e]);
1111 assert!(res.entities.is_empty());
1112 }
1113
1114 #[test]
1115 fn raw_secret_never_appears_in_any_serialized_entity() {
1116 let extractor = EntityExtractor::new();
1117 let secret = "super_secret_jwt_payload_value";
1118 let e = ev(
1119 EvidenceKind::Authentication,
1120 KnowledgePredicate::new("authentication", "bearer").unwrap(),
1121 EvidenceValue::Text(secret.into()),
1122 );
1123 let res = extractor.extract_from_evidence(&[e]);
1124 assert_eq!(res.entities.len(), 1);
1125 let json = serde_json::to_string(&res.entities[0]).unwrap();
1126 assert!(!json.contains(secret));
1127 }
1128
1129 #[test]
1130 fn equivalent_ipv6_forms_produce_same_id() {
1131 let extractor = EntityExtractor::new();
1132 let ev1 = ev(
1133 EvidenceKind::Dns,
1134 KnowledgePredicate::new("dns", "ip").unwrap(),
1135 EvidenceValue::Text("2001:0db8:0000:0000:0000:0000:0000:0001".into()),
1136 );
1137 let ev2 = ev(
1138 EvidenceKind::Dns,
1139 KnowledgePredicate::new("dns", "ip").unwrap(),
1140 EvidenceValue::Text("2001:db8::1".into()),
1141 );
1142 let res1 = extractor.extract_from_evidence(&[ev1]);
1143 let res2 = extractor.extract_from_evidence(&[ev2]);
1144 assert_eq!(res1.entities[0].id(), res2.entities[0].id());
1145 assert_eq!(res1.entities[0].id().as_str(), "v1:ip:2001:db8::1");
1146 }
1147
1148 #[test]
1149 fn invalid_ip_does_not_become_domain() {
1150 let extractor = EntityExtractor::new();
1151 let e = ev(
1152 EvidenceKind::Dns,
1153 KnowledgePredicate::new("dns", "ip").unwrap(),
1154 EvidenceValue::Text("999.999.999.999".into()),
1155 );
1156 let res = extractor.extract_from_evidence(&[e]);
1157 assert!(res.entities.is_empty());
1158 }
1159
1160 #[test]
1161 fn trailing_dot_domain_matches_non_trailing_form() {
1162 let extractor = EntityExtractor::new();
1163 let ev1 = ev(
1164 EvidenceKind::Dns,
1165 KnowledgePredicate::new("dns", "domain").unwrap(),
1166 EvidenceValue::Text("EXAMPLE.TEST.".into()),
1167 );
1168 let ev2 = ev(
1169 EvidenceKind::Dns,
1170 KnowledgePredicate::new("dns", "domain").unwrap(),
1171 EvidenceValue::Text("example.test".into()),
1172 );
1173 let res1 = extractor.extract_from_evidence(&[ev1]);
1174 let res2 = extractor.extract_from_evidence(&[ev2]);
1175 assert_eq!(res1.entities[0].id(), res2.entities[0].id());
1176 }
1177
1178 #[test]
1179 fn malformed_hostname_produces_no_entity() {
1180 let extractor = EntityExtractor::new();
1181 let e = ev(
1182 EvidenceKind::Dns,
1183 KnowledgePredicate::new("dns", "hostname").unwrap(),
1184 EvidenceValue::Text("invalid_host_name#label".into()),
1185 );
1186 let res = extractor.extract_from_evidence(&[e]);
1187 assert!(res.entities.is_empty());
1188 }
1189
1190 #[test]
1191 fn url_userinfo_never_appears_in_id_or_attributes() {
1192 let extractor = EntityExtractor::new();
1193 let e = ev(
1194 EvidenceKind::Http,
1195 KnowledgePredicate::new("http.request", "url").unwrap(),
1196 EvidenceValue::Text("https://admin:secret123@example.test/api/v1/users".into()),
1197 );
1198 let res = extractor.extract_from_evidence(&[e]);
1199 assert_eq!(res.entities.len(), 1);
1200 let json = serde_json::to_string(&res.entities[0]).unwrap();
1201 assert!(!json.contains("admin"));
1202 assert!(!json.contains("secret123"));
1203 assert_eq!(
1204 res.entities[0].id().as_str(),
1205 "v1:endpoint:https://example.test/api/v1/users"
1206 );
1207 }
1208
1209 #[test]
1210 fn url_fragment_is_not_interpreted_as_http_method() {
1211 let extractor = EntityExtractor::new();
1212 let e = ev(
1213 EvidenceKind::Http,
1214 KnowledgePredicate::new("http.request", "url").unwrap(),
1215 EvidenceValue::Text("https://example.test/api/v1/users#DELETE".into()),
1216 );
1217 let res = extractor.extract_from_evidence(&[e]);
1218 assert_eq!(res.entities.len(), 1);
1219 assert_eq!(
1220 res.entities[0].id().as_str(),
1221 "v1:endpoint:https://example.test/api/v1/users"
1222 );
1223 assert!(res.entities[0].attributes().get("method").is_none());
1224 }
1225
1226 #[test]
1227 fn ipv6_endpoint_is_canonicalized() {
1228 let extractor = EntityExtractor::new();
1229 let e = ev(
1230 EvidenceKind::Http,
1231 KnowledgePredicate::new("http.request", "url").unwrap(),
1232 EvidenceValue::Text(
1233 "http://[2001:0db8:0000:0000:0000:0000:0000:0001]:80/api/v1".into(),
1234 ),
1235 );
1236 let res = extractor.extract_from_evidence(&[e]);
1237 assert_eq!(res.entities.len(), 1);
1238 assert_eq!(
1239 res.entities[0].id().as_str(),
1240 "v1:endpoint:http://[2001:db8::1]/api/v1"
1241 );
1242 }
1243
1244 #[test]
1245 fn malformed_url_produces_no_entity() {
1246 let extractor = EntityExtractor::new();
1247 let e = ev(
1248 EvidenceKind::Http,
1249 KnowledgePredicate::new("http.request", "url").unwrap(),
1250 EvidenceValue::Text("not_a_valid_url".into()),
1251 );
1252 let res = extractor.extract_from_evidence(&[e]);
1253 assert!(res.entities.is_empty());
1254 }
1255
1256 #[test]
1257 fn get_and_post_share_endpoint_identity_but_retain_distinct_method_attributes() {
1258 let extractor = EntityExtractor::new();
1259 let res_get = extractor.extract_from_evidence(&[ev_subj(
1260 subject(),
1261 EvidenceKind::Http,
1262 KnowledgePredicate::new("http.request", "method").unwrap(),
1263 EvidenceValue::Text("GET".into()),
1264 )]);
1265 let res_post = extractor.extract_from_evidence(&[ev_subj(
1266 subject(),
1267 EvidenceKind::Http,
1268 KnowledgePredicate::new("http.request", "method").unwrap(),
1269 EvidenceValue::Text("POST".into()),
1270 )]);
1271 assert_eq!(
1272 res_get.entities[0].id().as_str(),
1273 "v1:endpoint:https://example.test/api/user"
1274 );
1275 assert_eq!(
1276 res_post.entities[0].id().as_str(),
1277 "v1:endpoint:https://example.test/api/user"
1278 );
1279 assert_ne!(
1280 res_get.entities[0].attributes().get("method"),
1281 res_post.entities[0].attributes().get("method")
1282 );
1283 }
1284
1285 fn method_entities(value: &str) -> usize {
1286 let extractor = EntityExtractor::new();
1287 extractor
1288 .extract_from_evidence(&[ev_subj(
1289 subject(),
1290 EvidenceKind::Http,
1291 KnowledgePredicate::new("http.request", "method").unwrap(),
1292 EvidenceValue::Text(value.into()),
1293 )])
1294 .entities
1295 .len()
1296 }
1297
1298 #[test]
1299 fn method_with_internal_whitespace_is_rejected() {
1300 assert_eq!(method_entities(" G\nET "), 0);
1301 assert_eq!(method_entities("GE T"), 0);
1302 }
1303
1304 #[test]
1305 fn method_with_leading_or_trailing_space_is_rejected() {
1306 assert_eq!(method_entities(" GET"), 0);
1307 assert_eq!(method_entities("GET "), 0);
1308 }
1309
1310 #[test]
1311 fn method_with_tab_is_rejected() {
1312 assert_eq!(method_entities("\tPOST\t"), 0);
1313 }
1314
1315 #[test]
1316 fn method_with_crlf_is_rejected() {
1317 assert_eq!(method_entities("\r\nOPTIONS\r\n"), 0);
1318 }
1319
1320 #[test]
1321 fn lowercase_token_is_normalized_in_synthetic_contract() {
1322 let extractor = EntityExtractor::new();
1323 let res = extractor.extract_from_evidence(&[ev_subj(
1324 subject(),
1325 EvidenceKind::Http,
1326 KnowledgePredicate::new("http.request", "method").unwrap(),
1327 EvidenceValue::Text("get".into()),
1328 )]);
1329 assert_eq!(res.entities.len(), 1);
1330 assert_eq!(
1331 res.entities[0].attributes().get("method"),
1332 Some(&BTreeSet::from(["GET".to_string()]))
1333 );
1334 }
1335
1336 #[test]
1337 fn method_with_separator_is_rejected() {
1338 let extractor = EntityExtractor::new();
1339 let res = extractor.extract_from_evidence(&[ev_subj(
1340 subject(),
1341 EvidenceKind::Http,
1342 KnowledgePredicate::new("http.request", "method").unwrap(),
1343 EvidenceValue::Text("GET/POST".into()),
1344 )]);
1345 assert_eq!(res.entities.len(), 0);
1346 }
1347
1348 #[test]
1349 fn method_with_unreasonably_long_token_is_rejected() {
1350 let extractor = EntityExtractor::new();
1351 let res = extractor.extract_from_evidence(&[ev_subj(
1352 subject(),
1353 EvidenceKind::Http,
1354 KnowledgePredicate::new("http.request", "method").unwrap(),
1355 EvidenceValue::Text("VERYLONGCUSTOMHTTPMETHODNAME".into()),
1356 )]);
1357 assert_eq!(res.entities.len(), 0);
1358 }
1359
1360 #[test]
1361 fn missing_method_does_not_claim_observed_get() {
1362 let extractor = EntityExtractor::new();
1363 let e = ev(
1364 EvidenceKind::Http,
1365 KnowledgePredicate::new("http.request", "url").unwrap(),
1366 EvidenceValue::Text("https://example.test/api/user".into()),
1367 );
1368 let res = extractor.extract_from_evidence(&[e]);
1369 assert_eq!(
1370 res.entities[0].id().as_str(),
1371 "v1:endpoint:https://example.test/api/user"
1372 );
1373 assert!(res.entities[0].attributes().get("method").is_none());
1374 }
1375
1376 #[test]
1377 fn same_input_serializes_byte_for_byte_identically() {
1378 let extractor = EntityExtractor::new();
1379 let ev1 = ev(
1380 EvidenceKind::Dns,
1381 KnowledgePredicate::new("dns", "domain").unwrap(),
1382 EvidenceValue::Text("example.test".into()),
1383 );
1384 let ev2 = ev(
1385 EvidenceKind::Technology,
1386 KnowledgePredicate::new("tech", "framework").unwrap(),
1387 EvidenceValue::Text("actix-web".into()),
1388 );
1389 let res1 = extractor.extract_from_evidence(&[ev1.clone(), ev2.clone()]);
1390 let res2 = extractor.extract_from_evidence(&[ev2, ev1]);
1391
1392 let bytes1 = serde_json::to_vec(&res1.entities).unwrap();
1393 let bytes2 = serde_json::to_vec(&res2.entities).unwrap();
1394 assert_eq!(bytes1, bytes2);
1395 }
1396
1397 #[test]
1398 fn fingerprint_is_domain_separated_by_artifact_kind() {
1399 let extractor = EntityExtractor::new();
1400 let token = "same_secret_value_12345";
1401 let ev1 = ev(
1402 EvidenceKind::Authentication,
1403 KnowledgePredicate::new("authentication", "bearer").unwrap(),
1404 EvidenceValue::Text(token.into()),
1405 );
1406 let ev2 = ev(
1407 EvidenceKind::Authentication,
1408 KnowledgePredicate::new("authentication", "api_key").unwrap(),
1409 EvidenceValue::Text(token.into()),
1410 );
1411 let res1 = extractor.extract_from_evidence(&[ev1]);
1412 let res2 = extractor.extract_from_evidence(&[ev2]);
1413
1414 let fp1 = res1.entities[0]
1415 .attributes()
1416 .get("fingerprint")
1417 .unwrap()
1418 .iter()
1419 .next()
1420 .unwrap();
1421 let fp2 = res2.entities[0]
1422 .attributes()
1423 .get("fingerprint")
1424 .unwrap()
1425 .iter()
1426 .next()
1427 .unwrap();
1428 assert_ne!(fp1, fp2);
1429 }
1430
1431 #[test]
1432 fn header_values_are_not_persisted() {
1433 let extractor = EntityExtractor::new();
1434 let e = ev(
1435 EvidenceKind::Http,
1436 KnowledgePredicate::new("http.header", "authorization").unwrap(),
1437 EvidenceValue::Text("Authorization: Bearer my_secret_token".into()),
1438 );
1439 let res = extractor.extract_from_evidence(&[e]);
1440 assert_eq!(res.entities.len(), 1);
1441 let json = serde_json::to_string(&res.entities[0]).unwrap();
1442 assert!(!json.contains("my_secret_token"));
1443 assert!(!json.contains("Authorization"));
1444 assert_eq!(res.entities[0].id().as_str(), "v1:header:authorization");
1445 assert_eq!(res.entities[0].attributes().len(), 1);
1446 assert!(res.entities[0].attributes().contains_key("name"));
1447 }
1448
1449 #[test]
1450 fn oversized_header_value_still_produces_name_only_header_concept() {
1451 let extractor = EntityExtractor::new();
1455 let huge_value = "A".repeat(extractor.limits().max_value_bytes + 512);
1456 let e = ev(
1457 EvidenceKind::Http,
1458 KnowledgePredicate::new("http.header", "content-security-policy").unwrap(),
1459 EvidenceValue::Text(huge_value.clone()),
1460 );
1461 let res = extractor.extract_from_evidence(&[e]);
1462 assert_eq!(res.entities.len(), 1);
1463 assert_eq!(
1464 res.entities[0].id().as_str(),
1465 "v1:header:content-security-policy"
1466 );
1467 let json = serde_json::to_string(&res.entities[0]).unwrap();
1468 let debug = format!("{:?}", res.entities[0]);
1469 assert!(!json.contains(&huge_value));
1470 assert!(!debug.contains(&huge_value));
1471 }
1472
1473 #[test]
1474 fn bounded_output_is_independent_of_input_order() {
1475 let limits = SemanticExtractionLimits::new(1, 50, 50, 4096, 100, 2048).unwrap();
1476 let extractor = EntityExtractor::with_limits(limits);
1477
1478 let ev1 = ev(
1479 EvidenceKind::Dns,
1480 KnowledgePredicate::new("dns", "domain").unwrap(),
1481 EvidenceValue::Text("aaa.example.test".into()),
1482 );
1483 let ev2 = ev(
1484 EvidenceKind::Dns,
1485 KnowledgePredicate::new("dns", "domain").unwrap(),
1486 EvidenceValue::Text("zzz.example.test".into()),
1487 );
1488
1489 let res1 = extractor.extract_from_evidence(&[ev1.clone(), ev2.clone()]);
1490 let res2 = extractor.extract_from_evidence(&[ev2, ev1]);
1491
1492 assert_eq!(res1.entities, res2.entities);
1493 assert_eq!(res1.dropped_entities, 1);
1494 assert_eq!(res2.dropped_entities, 1);
1495 assert!(res1.truncated);
1496 assert!(res2.truncated);
1497 }
1498
1499 #[test]
1500 fn reaching_entity_limit_still_merges_retained_entities() {
1501 let limits = SemanticExtractionLimits::new(1, 50, 50, 4096, 100, 2048).unwrap();
1502 let extractor = EntityExtractor::with_limits(limits);
1503
1504 let ev1 = ev(
1505 EvidenceKind::Dns,
1506 KnowledgePredicate::new("dns", "domain").unwrap(),
1507 EvidenceValue::Text("aaa.example.test".into()),
1508 );
1509 let ev2 = ev(
1510 EvidenceKind::Dns,
1511 KnowledgePredicate::new("dns", "domain").unwrap(),
1512 EvidenceValue::Text("zzz.example.test".into()),
1513 );
1514 let ev3 = ev(
1515 EvidenceKind::Dns,
1516 KnowledgePredicate::new("dns", "domain").unwrap(),
1517 EvidenceValue::Text("aaa.example.test".into()),
1518 );
1519
1520 let res = extractor.extract_from_evidence(&[ev1, ev2, ev3]);
1521 assert_eq!(res.entities.len(), 1);
1522 assert_eq!(res.entities[0].id().as_str(), "v1:domain:aaa.example.test");
1523 assert_eq!(res.entities[0].source_evidence_ids().len(), 2);
1524 }
1525
1526 #[test]
1527 fn duplicate_source_ids_do_not_consume_source_budget() {
1528 let limits = SemanticExtractionLimits::new(10, 50, 50, 4096, 2, 2048).unwrap();
1529 let extractor = EntityExtractor::with_limits(limits);
1530
1531 let ev1 = ev(
1532 EvidenceKind::Dns,
1533 KnowledgePredicate::new("dns", "domain").unwrap(),
1534 EvidenceValue::Text("example.test".into()),
1535 );
1536
1537 let res = extractor.extract_from_evidence(&[ev1.clone(), ev1.clone(), ev1]);
1538 assert_eq!(res.entities[0].source_evidence_ids().len(), 1);
1539 assert_eq!(res.dropped_sources, 0);
1540 assert!(!res.truncated);
1541 }
1542
1543 #[test]
1544 fn invalid_ip_predicate_never_falls_back_to_domain() {
1545 let extractor = EntityExtractor::new();
1546 let e = ev(
1547 EvidenceKind::Dns,
1548 KnowledgePredicate::new("dns", "ip").unwrap(),
1549 EvidenceValue::Text("example.test".into()),
1550 );
1551 let res = extractor.extract_from_evidence(&[e]);
1552 assert!(res.entities.is_empty());
1553 }
1554
1555 #[test]
1556 fn domain_predicate_never_silently_changes_type_to_ip() {
1557 let extractor = EntityExtractor::new();
1558 let e = ev(
1559 EvidenceKind::Dns,
1560 KnowledgePredicate::new("dns", "domain").unwrap(),
1561 EvidenceValue::Text("192.0.2.1".into()),
1562 );
1563 let res = extractor.extract_from_evidence(&[e]);
1564 assert!(res.entities.is_empty());
1565 }
1566
1567 #[test]
1568 fn numeric_invalid_ipv4_is_rejected() {
1569 let extractor = EntityExtractor::new();
1570 let e = ev(
1571 EvidenceKind::Dns,
1572 KnowledgePredicate::new("dns", "ip").unwrap(),
1573 EvidenceValue::Text("999.999.999.999".into()),
1574 );
1575 let res = extractor.extract_from_evidence(&[e]);
1576 assert!(res.entities.is_empty());
1577 }
1578
1579 #[test]
1580 fn equal_zero_runs_use_leftmost_compression() {
1581 let extractor = EntityExtractor::new();
1582 let e = ev(
1583 EvidenceKind::Dns,
1584 KnowledgePredicate::new("dns", "ip").unwrap(),
1585 EvidenceValue::Text("2001:db8:0:0:1:0:0:1".into()),
1586 );
1587 let res = extractor.extract_from_evidence(&[e]);
1588 assert_eq!(res.entities[0].id().as_str(), "v1:ip:2001:db8::1:0:0:1");
1589 }
1590
1591 #[test]
1592 fn all_accepted_ipv6_forms_round_trip_canonically() {
1593 let extractor = EntityExtractor::new();
1594 let e = ev(
1595 EvidenceKind::Dns,
1596 KnowledgePredicate::new("dns", "ip").unwrap(),
1597 EvidenceValue::Text("FE80:0000:0000:0000:0202:B3FF:FE1E:8329".into()),
1598 );
1599 let res = extractor.extract_from_evidence(&[e]);
1600 assert_eq!(
1601 res.entities[0].id().as_str(),
1602 "v1:ip:fe80::202:b3ff:fe1e:8329"
1603 );
1604 }
1605
1606 #[test]
1607 fn relative_url_respects_max_url_bytes() {
1608 let limits = SemanticExtractionLimits::new(10, 50, 50, 4096, 100, 20).unwrap();
1609 let extractor = EntityExtractor::with_limits(limits);
1610 let e = ev(
1611 EvidenceKind::Http,
1612 KnowledgePredicate::new("http.request", "url").unwrap(),
1613 EvidenceValue::Text("/very_long_relative_path_that_exceeds_max_url_bytes".into()),
1614 );
1615 let res = extractor.extract_from_evidence(&[e]);
1616 assert!(res.entities.is_empty());
1617 }
1618
1619 #[test]
1620 fn relative_url_rejects_non_http_base() {
1621 let extractor = EntityExtractor::new();
1622 let non_http_subj = EntityId::new("endpoint:ftp://example.test/pub").unwrap();
1623 let e = ev_subj(
1624 non_http_subj,
1625 EvidenceKind::Http,
1626 KnowledgePredicate::new("http.request", "url").unwrap(),
1627 EvidenceValue::Text("/file.txt".into()),
1628 );
1629 let res = extractor.extract_from_evidence(&[e]);
1630 assert!(res.entities.is_empty());
1631 }
1632
1633 #[test]
1634 fn absolute_and_relative_forms_produce_same_entity() {
1635 let extractor = EntityExtractor::new();
1636 let ev_rel = ev(
1637 EvidenceKind::Http,
1638 KnowledgePredicate::new("http.request", "url").unwrap(),
1639 EvidenceValue::Text("/api/user".into()),
1640 );
1641 let ev_abs = ev(
1642 EvidenceKind::Http,
1643 KnowledgePredicate::new("http.request", "url").unwrap(),
1644 EvidenceValue::Text("https://example.test/api/user".into()),
1645 );
1646 let res_rel = extractor.extract_from_evidence(&[ev_rel]);
1647 let res_abs = extractor.extract_from_evidence(&[ev_abs]);
1648 assert_eq!(res_rel.entities[0].id(), res_abs.entities[0].id());
1649 }
1650
1651 #[test]
1652 fn relative_ipv6_endpoint_is_canonical() {
1653 let extractor = EntityExtractor::new();
1654 let v6_subj = EntityId::new("endpoint:http://[2001:db8::1]/api/v1").unwrap();
1655 let e = ev_subj(
1656 v6_subj,
1657 EvidenceKind::Http,
1658 KnowledgePredicate::new("http.request", "url").unwrap(),
1659 EvidenceValue::Text("/v2/users".into()),
1660 );
1661 let res = extractor.extract_from_evidence(&[e]);
1662 assert_eq!(
1663 res.entities[0].id().as_str(),
1664 "v1:endpoint:http://[2001:db8::1]/v2/users"
1665 );
1666 }
1667
1668 #[test]
1669 fn malformed_header_name_produces_no_entity() {
1670 let extractor = EntityExtractor::new();
1671 let e = ev(
1672 EvidenceKind::Http,
1673 KnowledgePredicate::new("http.header", "bad-header name").unwrap(),
1674 EvidenceValue::Text("Bad Header Name: Value".into()),
1675 );
1676 let res = extractor.extract_from_evidence(&[e]);
1677 assert!(res.entities.is_empty());
1678 }
1679
1680 #[test]
1681 fn generic_extractor_keeps_value_bearing_request_query_unsupported() {
1682 let secret = "session=generic-query-secret";
1683 let evidence = ev(
1684 EvidenceKind::Http,
1685 KnowledgePredicate::new("http.request", "query").unwrap(),
1686 EvidenceValue::Text(secret.to_owned()),
1687 );
1688
1689 let result = EntityExtractor::new().extract_from_evidence(&[evidence]);
1690
1691 assert!(result.entities.is_empty());
1692 assert!(!serde_json::to_string(&result).unwrap().contains(secret));
1693 }
1694
1695 #[cfg(feature = "scanning")]
1696 #[test]
1697 fn assessment_extractor_is_deterministic_names_only_and_preserves_full_lineage() {
1698 let mut evidence = assessment_route_evidence(vec!["page".to_owned(), "q".to_owned()]);
1699 let subject = EntityId::new("endpoint:https://example.test/").unwrap();
1700 let secret = "Bearer assessment-semantic-secret";
1701 evidence.push(fixed_evidence(
1702 "semantic-unrelated-auth",
1703 &subject,
1704 EvidenceKind::Authentication,
1705 KnowledgePredicate::new("authentication", "bearer").unwrap(),
1706 EvidenceValue::Text(secret.to_owned()),
1707 "unrelated-auth",
1708 ));
1709 evidence.push(fixed_evidence(
1710 "semantic-value-query",
1711 &subject,
1712 EvidenceKind::Http,
1713 KnowledgePredicate::new("http.request", "query").unwrap(),
1714 EvidenceValue::Text("token=query-secret".to_owned()),
1715 "request-query",
1716 ));
1717
1718 let extractor = EntityExtractor::new();
1719 let forward = extractor.extract_from_web_assessment_evidence(&evidence);
1720 evidence.reverse();
1721 let reverse = extractor.extract_from_web_assessment_evidence(&evidence);
1722
1723 assert_eq!(
1724 serde_json::to_vec(&forward).unwrap(),
1725 serde_json::to_vec(&reverse).unwrap()
1726 );
1727 assert!(!forward.truncated);
1728 assert!(forward.entities.iter().all(|entity| matches!(
1729 entity.entity_type(),
1730 SemanticEntityType::Endpoint | SemanticEntityType::Parameter
1731 )));
1732 let parameters = forward
1733 .entities
1734 .iter()
1735 .filter(|entity| entity.entity_type() == SemanticEntityType::Parameter)
1736 .collect::<Vec<_>>();
1737 assert_eq!(parameters.len(), 2);
1738 assert_eq!(
1739 parameters
1740 .iter()
1741 .flat_map(|entity| entity.attributes()["name"].iter())
1742 .cloned()
1743 .collect::<BTreeSet<_>>(),
1744 BTreeSet::from(["page".to_owned(), "q".to_owned()])
1745 );
1746 let admitted_ids = evidence
1747 .iter()
1748 .map(|record| record.id().clone())
1749 .collect::<BTreeSet<_>>();
1750 for parameter in parameters {
1751 assert_eq!(parameter.source_evidence_ids().len(), 9);
1752 assert!(parameter
1753 .source_evidence_ids()
1754 .iter()
1755 .all(|id| admitted_ids.contains(id)));
1756 assert!(!parameter
1757 .source_evidence_ids()
1758 .iter()
1759 .any(|id| id.as_str() == "semantic-unrelated-auth"));
1760 }
1761 let serialized = serde_json::to_string(&forward).unwrap();
1762 assert!(!serialized.contains(secret));
1763 assert!(!serialized.contains("query-secret"));
1764 assert!(!serialized.contains("auth_artifact"));
1765 }
1766
1767 #[cfg(feature = "scanning")]
1768 #[test]
1769 fn assessment_parameter_names_enforce_exact_byte_and_count_hard_limits() {
1770 let extractor = EntityExtractor::new();
1771
1772 let at_byte_cap =
1773 extractor.extract_from_web_assessment_evidence(&assessment_route_evidence(vec![
1774 "n".repeat(SemanticExtractionLimits::HARD_MAX_ASSESSMENT_PARAMETER_NAME_BYTES)
1775 ]));
1776 assert_eq!(
1777 at_byte_cap
1778 .entities
1779 .iter()
1780 .filter(|entity| entity.entity_type() == SemanticEntityType::Parameter)
1781 .count(),
1782 1
1783 );
1784
1785 let over_byte_cap =
1786 extractor.extract_from_web_assessment_evidence(&assessment_route_evidence(vec![
1787 "n".repeat(SemanticExtractionLimits::HARD_MAX_ASSESSMENT_PARAMETER_NAME_BYTES + 1)
1788 ]));
1789 assert!(over_byte_cap
1790 .entities
1791 .iter()
1792 .all(|entity| entity.entity_type() != SemanticEntityType::Parameter));
1793
1794 let at_count_cap = (0
1795 ..SemanticExtractionLimits::HARD_MAX_ASSESSMENT_PARAMETER_NAMES_PER_REFERENCE)
1796 .map(|index| format!("name-{index:03}"))
1797 .collect::<Vec<_>>();
1798 let accepted = extractor
1799 .extract_from_web_assessment_evidence(&assessment_route_evidence(at_count_cap));
1800 assert_eq!(
1801 accepted
1802 .entities
1803 .iter()
1804 .filter(|entity| entity.entity_type() == SemanticEntityType::Parameter)
1805 .count(),
1806 SemanticExtractionLimits::HARD_MAX_ASSESSMENT_PARAMETER_NAMES_PER_REFERENCE
1807 );
1808
1809 let over_count_cap = (0
1810 ..=SemanticExtractionLimits::HARD_MAX_ASSESSMENT_PARAMETER_NAMES_PER_REFERENCE)
1811 .map(|index| format!("name-{index:03}"))
1812 .collect::<Vec<_>>();
1813 let rejected = extractor
1814 .extract_from_web_assessment_evidence(&assessment_route_evidence(over_count_cap));
1815 assert!(rejected
1816 .entities
1817 .iter()
1818 .all(|entity| entity.entity_type() != SemanticEntityType::Parameter));
1819 }
1820
1821 #[cfg(feature = "scanning")]
1822 #[test]
1823 fn assessment_parameter_name_lists_must_be_nonempty_sorted_unique_and_valid() {
1824 let extractor = EntityExtractor::new();
1825 for names in [
1826 Vec::new(),
1827 vec!["z".to_owned(), "a".to_owned()],
1828 vec!["same".to_owned(), "same".to_owned()],
1829 vec!["bad\nname".to_owned()],
1830 ] {
1831 let result =
1832 extractor.extract_from_web_assessment_evidence(&assessment_route_evidence(names));
1833 assert!(result
1834 .entities
1835 .iter()
1836 .all(|entity| entity.entity_type() != SemanticEntityType::Parameter));
1837 }
1838 }
1839
1840 #[cfg(feature = "scanning")]
1841 #[test]
1842 fn assessment_parameter_requires_the_exact_committed_parent_chain() {
1843 let extractor = EntityExtractor::new();
1844 let mut missing_parent = assessment_route_evidence(vec!["q".to_owned()]);
1845 missing_parent.retain(|evidence| evidence.id().as_str() != "semantic-route");
1846 let result = extractor.extract_from_web_assessment_evidence(&missing_parent);
1847 assert!(result
1848 .entities
1849 .iter()
1850 .all(|entity| entity.entity_type() != SemanticEntityType::Parameter));
1851
1852 let mut wrong_parent = assessment_route_evidence(vec!["q".to_owned()]);
1853 wrong_parent.pop();
1854 let subject = EntityId::new("endpoint:https://example.test/").unwrap();
1855 wrong_parent.push(derived_discovery_evidence(
1856 "semantic-route-names-wrong-parent",
1857 &subject,
1858 termivar_core::predicates::WebDiscoveryEvidencePredicate::ROUTE_QUERY_PARAMETER_NAMES,
1859 EvidenceValue::TextList(vec!["q".to_owned()]),
1860 "route-query-parameter-names",
1861 [EvidenceId::parse("semantic-document").unwrap()],
1862 ));
1863 let result = extractor.extract_from_web_assessment_evidence(&wrong_parent);
1864 assert!(result
1865 .entities
1866 .iter()
1867 .all(|entity| entity.entity_type() != SemanticEntityType::Parameter));
1868 }
1869
1870 #[cfg(feature = "scanning")]
1871 #[test]
1872 fn assessment_semantic_entity_bound_is_deterministic_and_truthfully_truncated() {
1873 let limits = SemanticExtractionLimits::new(1, 50, 256, 8192, 100, 8192).unwrap();
1874 let extractor = EntityExtractor::with_limits(limits);
1875 let evidence = assessment_route_evidence(vec!["page".to_owned(), "q".to_owned()]);
1876 let forward = extractor.extract_from_web_assessment_evidence(&evidence);
1877 let reverse = extractor.extract_from_web_assessment_evidence(
1878 &evidence.iter().cloned().rev().collect::<Vec<_>>(),
1879 );
1880
1881 assert_eq!(forward, reverse);
1882 assert_eq!(forward.entities.len(), 1);
1883 assert!(forward.truncated);
1884 assert!(forward.dropped_entities >= 1);
1885 }
1886
1887 #[test]
1888 fn limits_hard_ceiling_rejects_invalid_config() {
1889 assert!(SemanticExtractionLimits::new(0, 50, 50, 4096, 100, 2048).is_err());
1890 assert!(SemanticExtractionLimits::new(20_000, 50, 50, 4096, 100, 2048).is_err());
1891 }
1892}