Skip to main content

termivar_scanner/semantic/
entity.rs

1//! Strongly-typed semantic entities extracted from raw scanner evidence.
2
3use serde::{Deserialize, Serialize};
4use std::collections::{BTreeMap, BTreeSet};
5use termivar_core::{EntityId, EvidenceId};
6
7/// Closed set of semantic entity types in the target system.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10#[non_exhaustive]
11pub enum SemanticEntityType {
12    /// Network endpoint or API route (e.g. `v1:endpoint:https://example.test/api/v1/user`).
13    ///
14    /// Endpoint identity is method-agnostic: any observed HTTP method is recorded
15    /// as a `method` attribute, never as part of the entity id.
16    Endpoint,
17    /// Fully qualified domain name or hostname (e.g. `v1:domain:example.test`).
18    Domain,
19    /// IP address (v4 or v6).
20    IpAddress,
21    /// Authentication token or credential artifact (JWT, Session Cookie, Bearer token).
22    AuthArtifact,
23    /// Protocol or application header concept.
24    Header,
25    /// Identified technology, framework, or runtime component.
26    Technology,
27    /// Request parameter (query, body, path).
28    Parameter,
29    /// User identity, role, or permission scope.
30    UserRole,
31}
32
33/// Structural categorization of authentication artifacts.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36#[non_exhaustive]
37pub enum AuthArtifactKind {
38    /// Generic Bearer token credential.
39    BearerToken,
40    /// Validated JSON Web Token structure (decoded base64url JSON header and payload).
41    Jwt,
42    /// API Key credential.
43    ApiKey,
44    /// Session cookie credential.
45    SessionCookie,
46    /// Unclassified authentication artifact.
47    Unknown,
48}
49
50impl AuthArtifactKind {
51    /// Returns a stable canonical slug string for artifact kind serialization and fingerprinting.
52    pub const fn slug(self) -> &'static str {
53        match self {
54            Self::BearerToken => "bearer_token",
55            Self::Jwt => "jwt",
56            Self::ApiKey => "api_key",
57            Self::SessionCookie => "session_cookie",
58            Self::Unknown => "unknown",
59        }
60    }
61}
62
63/// Errors occurring from invalid or excessive extraction limits.
64#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)]
65pub enum LimitsError {
66    /// Limit value is zero.
67    #[error("limit {name} is zero, which is invalid")]
68    ZeroLimit { name: &'static str },
69    /// Limit value exceeds the hard safety ceiling.
70    #[error("limit {name} ({requested}) exceeds maximum hard ceiling ({ceiling})")]
71    ExceedsCeiling {
72        name: &'static str,
73        requested: usize,
74        ceiling: usize,
75    },
76}
77
78/// Safety limits for bounded semantic entity extraction.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct SemanticExtractionLimits {
81    /// Maximum number of entities extracted from a single evidence batch.
82    pub max_entities: usize,
83    /// Maximum number of attribute keys per entity.
84    pub max_attribute_keys: usize,
85    /// Maximum number of values per attribute.
86    pub max_values_per_attribute: usize,
87    /// Maximum length in bytes for any single attribute value.
88    pub max_value_bytes: usize,
89    /// Maximum supporting evidence IDs recorded per entity.
90    pub max_source_evidence_ids: usize,
91    /// Maximum URL length in bytes.
92    pub max_url_bytes: usize,
93}
94
95impl SemanticExtractionLimits {
96    /// Assessment-only hard ceiling for one discovered parameter/control name.
97    #[cfg(feature = "scanning")]
98    pub(crate) const HARD_MAX_ASSESSMENT_PARAMETER_NAME_BYTES: usize = 256;
99    /// Assessment-only hard ceiling for names attached to one reference.
100    #[cfg(feature = "scanning")]
101    pub(crate) const HARD_MAX_ASSESSMENT_PARAMETER_NAMES_PER_REFERENCE: usize = 256;
102    /// Hard ceiling on entity count.
103    pub const HARD_MAX_ENTITIES: usize = 10_000;
104    /// Hard ceiling on attribute keys per entity.
105    pub const HARD_MAX_ATTRIBUTE_KEYS: usize = 200;
106    /// Hard ceiling on values per attribute key.
107    pub const HARD_MAX_VALUES_PER_ATTRIBUTE: usize = 500;
108    /// Hard ceiling on value bytes.
109    pub const HARD_MAX_VALUE_BYTES: usize = 65_536;
110    /// Hard ceiling on source evidence IDs recorded per entity.
111    pub const HARD_MAX_SOURCE_EVIDENCE_IDS: usize = 10_000;
112    /// Hard ceiling on URL length in bytes.
113    pub const HARD_MAX_URL_BYTES: usize = 8_192;
114
115    /// Validates and constructs new extraction limits bounded by hard ceilings.
116    pub fn new(
117        max_entities: usize,
118        max_attribute_keys: usize,
119        max_values_per_attribute: usize,
120        max_value_bytes: usize,
121        max_source_evidence_ids: usize,
122        max_url_bytes: usize,
123    ) -> Result<Self, LimitsError> {
124        let limits = Self {
125            max_entities,
126            max_attribute_keys,
127            max_values_per_attribute,
128            max_value_bytes,
129            max_source_evidence_ids,
130            max_url_bytes,
131        };
132        limits.validate()?;
133        Ok(limits)
134    }
135
136    /// Revalidates every configured dimension against its compiled ceiling.
137    pub fn validate(&self) -> Result<(), LimitsError> {
138        validate_limit("max_entities", self.max_entities, Self::HARD_MAX_ENTITIES)?;
139        validate_limit(
140            "max_attribute_keys",
141            self.max_attribute_keys,
142            Self::HARD_MAX_ATTRIBUTE_KEYS,
143        )?;
144        validate_limit(
145            "max_values_per_attribute",
146            self.max_values_per_attribute,
147            Self::HARD_MAX_VALUES_PER_ATTRIBUTE,
148        )?;
149        validate_limit(
150            "max_value_bytes",
151            self.max_value_bytes,
152            Self::HARD_MAX_VALUE_BYTES,
153        )?;
154        validate_limit(
155            "max_source_evidence_ids",
156            self.max_source_evidence_ids,
157            Self::HARD_MAX_SOURCE_EVIDENCE_IDS,
158        )?;
159        validate_limit(
160            "max_url_bytes",
161            self.max_url_bytes,
162            Self::HARD_MAX_URL_BYTES,
163        )
164    }
165
166    /// Returns the maximum number of retained entities.
167    pub const fn max_entities(&self) -> usize {
168        self.max_entities
169    }
170
171    /// Returns the maximum number of attribute keys on one entity.
172    pub const fn max_attribute_keys(&self) -> usize {
173        self.max_attribute_keys
174    }
175
176    /// Returns the maximum number of values retained for one attribute.
177    pub const fn max_values_per_attribute(&self) -> usize {
178        self.max_values_per_attribute
179    }
180
181    /// Returns the maximum byte length of one retained attribute value.
182    pub const fn max_value_bytes(&self) -> usize {
183        self.max_value_bytes
184    }
185
186    /// Returns the maximum number of source evidence identities per entity.
187    pub const fn max_source_evidence_ids(&self) -> usize {
188        self.max_source_evidence_ids
189    }
190
191    /// Returns the maximum accepted canonical URL byte length.
192    pub const fn max_url_bytes(&self) -> usize {
193        self.max_url_bytes
194    }
195}
196
197fn validate_limit(name: &'static str, value: usize, ceiling: usize) -> Result<(), LimitsError> {
198    if value == 0 {
199        return Err(LimitsError::ZeroLimit { name });
200    }
201    if value > ceiling {
202        return Err(LimitsError::ExceedsCeiling {
203            name,
204            requested: value,
205            ceiling,
206        });
207    }
208    Ok(())
209}
210
211impl Default for SemanticExtractionLimits {
212    fn default() -> Self {
213        Self {
214            max_entities: 1000,
215            max_attribute_keys: 50,
216            max_values_per_attribute: 50,
217            max_value_bytes: 4096,
218            max_source_evidence_ids: 100,
219            max_url_bytes: 2048,
220        }
221    }
222}
223
224/// Explicit extraction receipt detailing extracted entities and truncation counts.
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226pub struct SemanticExtractionResult {
227    /// Extracted semantic entities.
228    pub entities: Vec<SemanticEntity>,
229    /// Whether any entity, attribute, value, or source ID limit was triggered.
230    pub truncated: bool,
231    /// Number of dropped entities due to `max_entities` limit.
232    pub dropped_entities: usize,
233    /// Number of dropped attributes due to attribute limits.
234    pub dropped_attributes: usize,
235    /// Number of dropped source evidence IDs due to source limits.
236    pub dropped_sources: usize,
237}
238
239/// A strongly-typed semantic entity derived deterministically from evidence.
240///
241/// Note: Plane classification is NOT an intrinsic attribute of `SemanticEntity`.
242/// Entities are reusable across multiple planes (e.g. AuthArtifact is relevant to
243/// both Identity and API planes).
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245pub struct SemanticEntity {
246    id: EntityId,
247    entity_type: SemanticEntityType,
248    attributes: BTreeMap<String, BTreeSet<String>>,
249    source_evidence_ids: Vec<EvidenceId>,
250}
251
252impl SemanticEntity {
253    /// Creates a new semantic entity with canonical identity and deterministic attribute merging.
254    pub fn new(
255        id: EntityId,
256        entity_type: SemanticEntityType,
257        attributes: BTreeMap<String, BTreeSet<String>>,
258        source_evidence_ids: Vec<EvidenceId>,
259    ) -> Self {
260        let mut source_evidence_ids = source_evidence_ids;
261        source_evidence_ids.sort();
262        source_evidence_ids.dedup();
263
264        Self {
265            id,
266            entity_type,
267            attributes,
268            source_evidence_ids,
269        }
270    }
271
272    /// Returns the canonical entity identifier.
273    pub fn id(&self) -> &EntityId {
274        &self.id
275    }
276
277    /// Returns the semantic entity type.
278    pub const fn entity_type(&self) -> SemanticEntityType {
279        self.entity_type
280    }
281
282    /// Returns the multi-valued attribute map.
283    pub fn attributes(&self) -> &BTreeMap<String, BTreeSet<String>> {
284        &self.attributes
285    }
286
287    /// Returns reference to source evidence IDs.
288    pub fn source_evidence_ids(&self) -> &[EvidenceId] {
289        &self.source_evidence_ids
290    }
291
292    /// Destructures entity into ID, type, attributes, and source evidence IDs.
293    pub fn into_parts(
294        self,
295    ) -> (
296        EntityId,
297        SemanticEntityType,
298        BTreeMap<String, BTreeSet<String>>,
299        Vec<EvidenceId>,
300    ) {
301        (
302            self.id,
303            self.entity_type,
304            self.attributes,
305            self.source_evidence_ids,
306        )
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn checked_limits_round_trip_and_expose_read_only_dimensions() {
316        let limits = SemanticExtractionLimits::new(7, 6, 5, 4, 3, 2).unwrap();
317        assert_eq!(limits.max_entities(), 7);
318        assert_eq!(limits.max_attribute_keys(), 6);
319        assert_eq!(limits.max_values_per_attribute(), 5);
320        assert_eq!(limits.max_value_bytes(), 4);
321        assert_eq!(limits.max_source_evidence_ids(), 3);
322        assert_eq!(limits.max_url_bytes(), 2);
323        limits.validate().unwrap();
324        let encoded = serde_json::to_string(&limits).unwrap();
325        assert_eq!(
326            serde_json::from_str::<SemanticExtractionLimits>(&encoded).unwrap(),
327            limits
328        );
329    }
330
331    #[test]
332    fn validate_rejects_unchecked_public_values_without_changing_the_wire_shape() {
333        let invalid = SemanticExtractionLimits {
334            max_entities: 0,
335            ..SemanticExtractionLimits::default()
336        };
337        assert!(matches!(
338            invalid.validate(),
339            Err(LimitsError::ZeroLimit {
340                name: "max_entities"
341            })
342        ));
343    }
344}