Skip to main content

termivar_scanner/semantic/
extractor.rs

1//! Deterministic entity extraction rules mapping evidence to semantic entities.
2
3use 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
17/// Version prefix for canonical entity identifiers.
18const CANONICAL_ID_VERSION: &str = "v1";
19
20/// Deterministic engine extracting strongly-typed semantic entities from scanner evidence.
21#[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    /// Creates a new entity extractor with default safety limits.
34    pub fn new() -> Self {
35        Self {
36            limits: SemanticExtractionLimits::default(),
37        }
38    }
39
40    /// Creates a new entity extractor with custom safety limits.
41    pub fn with_limits(limits: SemanticExtractionLimits) -> Self {
42        Self { limits }
43    }
44
45    /// Returns a reference to the extractor limits.
46    pub fn limits(&self) -> &SemanticExtractionLimits {
47        &self.limits
48    }
49
50    /// Extracts entities from a knowledge snapshot.
51    pub fn extract_from_snapshot(&self, snapshot: &KnowledgeSnapshot) -> SemanticExtractionResult {
52        self.extract_from_evidence(snapshot.evidence())
53    }
54
55    /// Extracts entities deterministically from a slice of evidence records.
56    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    /// Extracts the strict endpoint/name-only semantic surface from evidence
68    /// explicitly owned by a web assessment.
69    ///
70    /// This intentionally remains crate-private. The assessment host first
71    /// proves that every supplied record belongs to a committed bootstrap
72    /// receipt and is structurally equal to the live knowledge-base record.
73    /// Unlike the general extractor, this surface can never create auth,
74    /// header, technology, domain, or IP entities.
75    #[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        // Truncate entity count canonically (BTreeMap keys are canonically sorted EntityIds)
137        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        // `max_value_bytes` is applied per-branch below, only to branches that
371        // consume, store, or hash the evidence value (Technology, HTTP method,
372        // AuthArtifact). URLs use `max_url_bytes`; header concepts are name-only
373        // and must not be dropped because of an ignored oversized header value.
374        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                // REDACTION GUARANTEE: Never store raw token in attributes
477                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                // Model A (Global Name-Only Concept): Header identity represents the header concept name.
523                // Values belong to evidence/relations and are NOT merged globally per header.
524                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            // STRICT ALLOWLIST ONLY: Unsupported evidence or unknown predicates return None.
537            // NEVER fallback to raw text or mistyped Endpoint entities!
538            _ => 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    // Routing only reaches this branch for the `authentication` allowlist
695    // {`api_key`, `bearer`, `jwt`}. There is no `cookie` or `token` predicate in
696    // that allowlist — cookie names are handled by the `http.cookie` mapping and
697    // are intentionally ignored — so those classifications are unreachable here
698    // and are deliberately omitted.
699    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    // Strict production contract: reject any leading or trailing whitespace rather
826    // than silently trimming it, so a method value that a real producer would never
827    // emit cannot be normalized into a valid token.
828    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    // Internal whitespace and control characters are not RFC 7230 token chars and
838    // are rejected here.
839    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        // Header concepts are name-only. An ignored header value larger than
1452        // `max_value_bytes` must not suppress the header concept, and the value
1453        // must never appear anywhere in the entity.
1454        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}