termivar_scanner/semantic/
entity.rs1use serde::{Deserialize, Serialize};
4use std::collections::{BTreeMap, BTreeSet};
5use termivar_core::{EntityId, EvidenceId};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10#[non_exhaustive]
11pub enum SemanticEntityType {
12 Endpoint,
17 Domain,
19 IpAddress,
21 AuthArtifact,
23 Header,
25 Technology,
27 Parameter,
29 UserRole,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36#[non_exhaustive]
37pub enum AuthArtifactKind {
38 BearerToken,
40 Jwt,
42 ApiKey,
44 SessionCookie,
46 Unknown,
48}
49
50impl AuthArtifactKind {
51 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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)]
65pub enum LimitsError {
66 #[error("limit {name} is zero, which is invalid")]
68 ZeroLimit { name: &'static str },
69 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct SemanticExtractionLimits {
81 pub max_entities: usize,
83 pub max_attribute_keys: usize,
85 pub max_values_per_attribute: usize,
87 pub max_value_bytes: usize,
89 pub max_source_evidence_ids: usize,
91 pub max_url_bytes: usize,
93}
94
95impl SemanticExtractionLimits {
96 #[cfg(feature = "scanning")]
98 pub(crate) const HARD_MAX_ASSESSMENT_PARAMETER_NAME_BYTES: usize = 256;
99 #[cfg(feature = "scanning")]
101 pub(crate) const HARD_MAX_ASSESSMENT_PARAMETER_NAMES_PER_REFERENCE: usize = 256;
102 pub const HARD_MAX_ENTITIES: usize = 10_000;
104 pub const HARD_MAX_ATTRIBUTE_KEYS: usize = 200;
106 pub const HARD_MAX_VALUES_PER_ATTRIBUTE: usize = 500;
108 pub const HARD_MAX_VALUE_BYTES: usize = 65_536;
110 pub const HARD_MAX_SOURCE_EVIDENCE_IDS: usize = 10_000;
112 pub const HARD_MAX_URL_BYTES: usize = 8_192;
114
115 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 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 pub const fn max_entities(&self) -> usize {
168 self.max_entities
169 }
170
171 pub const fn max_attribute_keys(&self) -> usize {
173 self.max_attribute_keys
174 }
175
176 pub const fn max_values_per_attribute(&self) -> usize {
178 self.max_values_per_attribute
179 }
180
181 pub const fn max_value_bytes(&self) -> usize {
183 self.max_value_bytes
184 }
185
186 pub const fn max_source_evidence_ids(&self) -> usize {
188 self.max_source_evidence_ids
189 }
190
191 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226pub struct SemanticExtractionResult {
227 pub entities: Vec<SemanticEntity>,
229 pub truncated: bool,
231 pub dropped_entities: usize,
233 pub dropped_attributes: usize,
235 pub dropped_sources: usize,
237}
238
239#[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 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 pub fn id(&self) -> &EntityId {
274 &self.id
275 }
276
277 pub const fn entity_type(&self) -> SemanticEntityType {
279 self.entity_type
280 }
281
282 pub fn attributes(&self) -> &BTreeMap<String, BTreeSet<String>> {
284 &self.attributes
285 }
286
287 pub fn source_evidence_ids(&self) -> &[EvidenceId] {
289 &self.source_evidence_ids
290 }
291
292 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}