1use super::{
4 CanonicalHexKeyMaterialSpec, DetectorKind, DetectorRelationKind, DetectorSpec,
5 EvidenceRequirement, EvidenceScope, HARD_NEGATIVE_TEST_EVIDENCE_SCHEMA_VERSION,
6};
7use serde::Serialize;
8use std::collections::{hash_map::Entry, HashMap, HashSet};
9
10const MAX_REGEX_PATTERN_LEN: usize = 4096;
11const MAX_COMPANION_WITHIN_LINES: usize = 100;
12const MAX_COMPANION_WITHIN_BYTES: usize = 1_048_576;
13const MIN_HTTP_STATUS: u16 = 100;
14const MAX_HTTP_STATUS: u16 = 599;
15#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
33pub enum QualityIssue {
34 Error(String),
36 Warning(String),
38}
39
40pub fn validate_detector(spec: &DetectorSpec) -> Vec<QualityIssue> {
58 validate_detector_with_hard_negative_evidence(spec, false)
59}
60
61pub fn validate_detector_for_corpus_schema(
63 spec: &DetectorSpec,
64 corpus_schema_version: u32,
65) -> Vec<QualityIssue> {
66 validate_detector_with_hard_negative_evidence(
67 spec,
68 corpus_schema_version >= HARD_NEGATIVE_TEST_EVIDENCE_SCHEMA_VERSION,
69 )
70}
71
72fn validate_detector_with_hard_negative_evidence(
73 spec: &DetectorSpec,
74 enforce_complete_hard_negative_evidence: bool,
75) -> Vec<QualityIssue> {
76 let mut issues = Vec::new();
77 let mut regex_cache = RegexAstCache::default();
78 validate_identity(spec, &mut issues);
79 validate_patterns_present(spec, &mut issues);
80 validate_regexes(spec, &mut issues, &mut regex_cache);
81 validate_required_literals(spec, &mut issues);
82 validate_pattern_groups(spec, &mut issues, &mut regex_cache);
83 validate_keywords(spec, &mut issues);
84 validate_simdsieve_prefixes(spec, &mut issues);
85 validate_offline_validators(spec, &mut issues);
86 validate_decode_transforms(spec, &mut issues);
87 validate_pattern_specificity(spec, &mut issues, &mut regex_cache);
88 validate_companions(spec, &mut issues, &mut regex_cache);
89 validate_detector_relations(spec, &mut issues);
90 validate_verify_spec(spec, &mut issues);
91 validate_thresholds(spec, &mut issues);
92 validate_entropy_floor(spec, &mut issues);
93 validate_decoded_hex_key_material_lengths(spec, &mut issues);
94 validate_canonical_hex_key_material(spec, &mut issues);
95 validate_credential_shape(spec, &mut issues);
96 validate_generic_assignment_suffixes(spec, &mut issues);
97 validate_detector_allowlists(spec, &mut issues);
98 validate_semantic_policy(spec, &mut issues);
99 validate_detector_test_evidence(spec, &mut issues, enforce_complete_hard_negative_evidence);
100 issues
101}
102
103fn validate_detector_test_evidence(
104 spec: &DetectorSpec,
105 issues: &mut Vec<QualityIssue>,
106 enforce_complete_hard_negative_evidence: bool,
107) {
108 for (test_index, test) in spec.tests.iter().enumerate() {
109 if let Some(pattern_index) = test.pattern_index {
110 if usize::try_from(pattern_index)
111 .ok()
112 .is_none_or(|index| index >= spec.patterns.len())
113 {
114 issues.push(QualityIssue::Error(format!(
115 "tests[{test_index}].pattern_index {pattern_index} is out of range for {} patterns",
116 spec.patterns.len()
117 )));
118 }
119 }
120 let has_positive = test
121 .test_positive
122 .as_deref()
123 .is_some_and(|value| !value.trim().is_empty());
124 let has_negative = test
125 .test_negative
126 .as_deref()
127 .is_some_and(|value| !value.trim().is_empty());
128 if test.pattern_index.is_some() && !has_positive && !has_negative {
129 issues.push(QualityIssue::Error(format!(
130 "tests[{test_index}].pattern_index requires non-empty positive or negative evidence"
131 )));
132 }
133 if test.negative_class.is_some() && test.pattern_index.is_none() {
134 issues.push(QualityIssue::Error(format!(
135 "tests[{test_index}].negative_class requires pattern_index"
136 )));
137 }
138 if test.negative_class.is_some() && !has_negative {
139 issues.push(QualityIssue::Error(format!(
140 "tests[{test_index}].negative_class requires non-empty test_negative"
141 )));
142 }
143 }
144
145 if !enforce_complete_hard_negative_evidence {
146 return;
147 }
148
149 if !spec.semantic_policy().is_enforcement_capable() {
150 return;
151 }
152
153 for pattern_index in 0..spec.patterns.len() {
154 let Ok(pattern_index_u32) = u32::try_from(pattern_index) else {
155 issues.push(QualityIssue::Error(
156 "detector pattern count exceeds the representable pattern_index range".into(),
157 ));
158 break;
159 };
160 let has_positive = spec.tests.iter().any(|test| {
161 test.pattern_index == Some(pattern_index_u32)
162 && test
163 .test_positive
164 .as_deref()
165 .is_some_and(|value| !value.trim().is_empty())
166 });
167 if !has_positive {
168 issues.push(QualityIssue::Error(format!(
169 "enforcement-capable pattern {pattern_index} requires direct positive evidence"
170 )));
171 }
172
173 let has_negative = spec.tests.iter().any(|test| {
174 test.pattern_index == Some(pattern_index_u32)
175 && test.negative_class.is_some()
176 && test
177 .test_negative
178 .as_deref()
179 .is_some_and(|value| !value.trim().is_empty())
180 });
181 if !has_negative {
182 issues.push(QualityIssue::Error(format!(
183 "enforcement-capable pattern {pattern_index} requires a named direct hard negative"
184 )));
185 }
186 }
187}
188
189fn validate_semantic_policy(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
190 let mut source_roles = HashSet::new();
191 for role in &spec.allowed_source_roles {
192 if !source_roles.insert(*role) {
193 issues.push(QualityIssue::Error(format!(
194 "allowed_source_roles contains duplicate role `{}`",
195 role.as_str()
196 )));
197 }
198 }
199 if spec
200 .allowed_source_roles
201 .contains(&crate::SemanticSourceRole::Unknown)
202 {
203 issues.push(QualityIssue::Error(
204 "allowed_source_roles cannot contain `unknown`; omit the field to preserve compatibility behavior".into(),
205 ));
206 }
207
208 let mut evidence = HashSet::new();
209 for requirement in &spec.required_evidence {
210 if !evidence.insert(*requirement) {
211 issues.push(QualityIssue::Error(format!(
212 "required_evidence contains duplicate requirement `{}`",
213 requirement.as_str()
214 )));
215 }
216 }
217}
218fn validate_generic_assignment_suffixes(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
219 for (field, suffixes) in [
220 ("generic_vendor_suffixes", &spec.generic_vendor_suffixes),
221 (
222 "generic_assignment_tail_suffixes",
223 &spec.generic_assignment_tail_suffixes,
224 ),
225 ] {
226 if !suffixes.is_empty() && spec.kind != crate::DetectorKind::Phase2Generic {
227 issues.push(QualityIssue::Error(format!(
228 "{field} is only valid for a phase2-generic detector"
229 )));
230 }
231 let mut seen = std::collections::BTreeSet::new();
232 for suffix in suffixes {
233 if suffix.is_empty()
234 || suffix != &suffix.to_ascii_lowercase()
235 || !suffix.bytes().all(|byte| byte.is_ascii_alphanumeric())
236 {
237 issues.push(QualityIssue::Error(format!(
238 "{field} entry {suffix:?} must be non-empty lowercase ASCII alphanumeric"
239 )));
240 } else if !seen.insert(suffix.as_str()) {
241 issues.push(QualityIssue::Error(format!(
242 "{field} contains duplicate suffix {suffix:?}"
243 )));
244 }
245 }
246 }
247}
248
249fn validate_decode_transforms(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
250 for issue in spec.decode_transforms.validate() {
251 issues.push(QualityIssue::Error(format!("decode_transforms.{issue}")));
252 }
253}
254
255fn validate_required_literals(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
256 for (index, pattern) in spec.patterns.iter().enumerate() {
257 if let Err(reason) = pattern.validate_required_literals() {
258 issues.push(QualityIssue::Error(format!(
259 "patterns[{index}].required_literals: {reason}"
260 )));
261 }
262 }
263}
264
265fn validate_offline_validators(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
266 let mut claimed_prefixes = std::collections::HashSet::new();
267 for (index, validator) in spec.validators.iter().enumerate() {
268 let prefixes = validator.prefixes();
269 if prefixes.is_empty() {
270 match validator {
271 crate::DetectorValidatorSpec::Uuid { .. }
272 | crate::DetectorValidatorSpec::HexHash { .. }
273 | crate::DetectorValidatorSpec::LuhnChecksum { .. } => {}
274 _ => {
275 issues.push(QualityIssue::Error(format!(
276 "validators[{index}].prefixes must not be empty"
277 )));
278 }
279 }
280 }
281 for prefix in prefixes {
282 if prefix.is_empty() || !prefix.is_ascii() {
283 issues.push(QualityIssue::Error(format!(
284 "validators[{index}] prefix {prefix:?} must be non-empty ASCII"
285 )));
286 }
287 if !claimed_prefixes.insert(prefix) {
288 issues.push(QualityIssue::Error(format!(
289 "detector validators claim prefix {prefix:?} more than once"
290 )));
291 }
292 }
293
294 if let Some(floor) = validator.confidence_floor() {
295 if !floor.is_finite() || !(0.0..=1.0).contains(&floor) {
296 issues.push(QualityIssue::Error(format!(
297 "validators[{index}].confidence_floor must be finite and in [0.0, 1.0], found {floor}"
298 )));
299 }
300 }
301
302 match validator {
303 crate::DetectorValidatorSpec::Crc32Base62 {
304 entropy_len,
305 checksum_len,
306 ..
307 } => {
308 if *entropy_len == 0 || *checksum_len == 0 {
309 issues.push(QualityIssue::Error(format!(
310 "validators[{index}] CRC32 entropy_len and checksum_len must both be greater than zero"
311 )));
312 }
313 }
314 crate::DetectorValidatorSpec::GithubFineGrainedCrc32 {
315 left_len,
316 right_len,
317 checksum_len,
318 ..
319 } => {
320 if *left_len == 0 || *checksum_len == 0 || *right_len <= *checksum_len {
321 issues.push(QualityIssue::Error(format!(
322 "validators[{index}] fine-grained lengths require left_len > 0 and right_len > checksum_len > 0"
323 )));
324 }
325 }
326 crate::DetectorValidatorSpec::Base64Payload {
327 min_encoded_len,
328 max_encoded_len,
329 min_decoded_len,
330 ..
331 } => {
332 if *min_encoded_len == 0
333 || *max_encoded_len < *min_encoded_len
334 || *min_decoded_len == 0
335 {
336 issues.push(QualityIssue::Error(format!(
337 "validators[{index}] base64 lengths require 0 < min_encoded_len <= max_encoded_len and min_decoded_len > 0"
338 )));
339 }
340 }
341 crate::DetectorValidatorSpec::PatternShape { .. } => {
342 if spec.patterns.is_empty() {
343 issues.push(QualityIssue::Error(format!(
344 "validators[{index}] pattern-shape requires at least one detector pattern"
345 )));
346 }
347 }
348 crate::DetectorValidatorSpec::Jwt { .. } => {}
349 crate::DetectorValidatorSpec::Uuid { .. } => {}
350 crate::DetectorValidatorSpec::HexHash { expected_len, .. } => {
351 if *expected_len == 0 {
352 issues.push(QualityIssue::Error(format!(
353 "validators[{index}] HexHash expected_len must be greater than zero"
354 )));
355 }
356 }
357 crate::DetectorValidatorSpec::LuhnChecksum {
358 min_len, max_len, ..
359 } => {
360 if *min_len == 0 || *max_len < *min_len {
361 issues.push(QualityIssue::Error(format!(
362 "validators[{index}] LuhnChecksum requires 0 < min_len <= max_len"
363 )));
364 }
365 }
366 }
367 }
368}
369
370fn validate_identity(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
371 if spec.id.is_empty() {
372 issues.push(QualityIssue::Error(
373 "detector.id must not be empty; assign a stable detector identifier".to_string(),
374 ));
375 } else if spec.id.trim() != spec.id {
376 issues.push(QualityIssue::Error(
377 "detector.id must not contain leading or trailing whitespace; remove the padding"
378 .to_string(),
379 ));
380 }
381}
382
383fn validate_decoded_hex_key_material_lengths(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
384 if spec.decoded_hex_key_material_lengths.is_empty() {
385 return;
386 }
387 if spec.kind != DetectorKind::Phase2Generic {
388 issues.push(QualityIssue::Error(
389 "decoded_hex_key_material_lengths is only valid for kind = \"phase2-generic\"".into(),
390 ));
391 }
392 let mut seen = std::collections::HashSet::new();
393 for &length in &spec.decoded_hex_key_material_lengths {
394 if length < 16 || length % 2 != 0 {
395 issues.push(QualityIssue::Error(format!(
396 "decoded_hex_key_material_lengths value {length} must be an even character count of at least 16"
397 )));
398 }
399 if !seen.insert(length) {
400 issues.push(QualityIssue::Error(format!(
401 "decoded_hex_key_material_lengths contains duplicate length {length}"
402 )));
403 }
404 }
405}
406
407fn validate_canonical_hex_key_material(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
408 if spec.canonical_hex_key_material.is_empty() {
409 return;
410 }
411 let generic_policy = spec.kind == DetectorKind::Phase2Generic;
412 let has_assignment_scope = |policy: &CanonicalHexKeyMaterialSpec| {
413 !policy.keywords.is_empty()
414 || !policy.suffixes.is_empty()
415 || !policy.excluded_keywords.is_empty()
416 };
417 if !generic_policy
418 && spec
419 .canonical_hex_key_material
420 .iter()
421 .any(has_assignment_scope)
422 {
423 issues.push(QualityIssue::Error(
424 "keyword- or suffix-scoped canonical_hex_key_material is only valid for kind = \"phase2-generic\"; regex detectors must declare length-only entries because the matched pattern is their anchor".into(),
425 ));
426 }
427
428 let owned_keywords: std::collections::HashSet<String> = spec
429 .keywords
430 .iter()
431 .filter_map(|keyword| normalize_detector_keyword(keyword))
432 .collect();
433 let mut seen_pairs = std::collections::HashSet::new();
434 let mut seen_regex_lengths = std::collections::HashSet::new();
435 for (policy_index, policy) in spec.canonical_hex_key_material.iter().enumerate() {
436 if policy.lengths.is_empty() {
437 issues.push(QualityIssue::Error(format!(
438 "canonical_hex_key_material[{policy_index}].lengths must not be empty"
439 )));
440 }
441 if generic_policy && policy.keywords.is_empty() && policy.suffixes.is_empty() {
442 issues.push(QualityIssue::Error(format!(
443 "phase2-generic canonical_hex_key_material[{policy_index}] must declare keywords or suffixes"
444 )));
445 }
446 let mut seen_lengths = std::collections::HashSet::new();
447 for &length in &policy.lengths {
448 if length < 16 || length % 2 != 0 {
449 issues.push(QualityIssue::Error(format!(
450 "canonical_hex_key_material[{policy_index}] length {length} must be an even character count of at least 16"
451 )));
452 }
453 if !seen_lengths.insert(length) {
454 issues.push(QualityIssue::Error(format!(
455 "canonical_hex_key_material[{policy_index}] contains duplicate length {length}"
456 )));
457 }
458 if !generic_policy && !seen_regex_lengths.insert(length) {
459 issues.push(QualityIssue::Error(format!(
460 "canonical_hex_key_material repeats regex-detector length {length} across policies"
461 )));
462 }
463 }
464 let mut seen_keywords = std::collections::HashSet::new();
465 for keyword in &policy.keywords {
466 let Some(normalized) = normalize_detector_keyword(keyword) else {
467 issues.push(QualityIssue::Error(format!(
468 "canonical_hex_key_material[{policy_index}] keyword {keyword:?} must contain ASCII alphanumerics with only `_`, `-`, or `.` separators"
469 )));
470 continue;
471 };
472 if !seen_keywords.insert(normalized.clone()) {
473 issues.push(QualityIssue::Error(format!(
474 "canonical_hex_key_material[{policy_index}] contains duplicate normalized keyword {normalized:?}"
475 )));
476 }
477 if !owned_keywords.contains(&normalized) {
478 issues.push(QualityIssue::Error(format!(
479 "canonical_hex_key_material[{policy_index}] keyword {keyword:?} must also appear in detector.keywords"
480 )));
481 }
482 for &length in &policy.lengths {
483 if !seen_pairs.insert((normalized.clone(), length)) {
484 issues.push(QualityIssue::Error(format!(
485 "canonical_hex_key_material repeats keyword {keyword:?} at length {length} across policies"
486 )));
487 }
488 }
489 }
490 let mut seen_suffixes = std::collections::HashSet::new();
491 for suffix in &policy.suffixes {
492 let Some(normalized) = normalize_detector_keyword(suffix) else {
493 issues.push(QualityIssue::Error(format!(
494 "canonical_hex_key_material[{policy_index}] suffix {suffix:?} must contain ASCII alphanumerics with only `_`, `-`, or `.` separators"
495 )));
496 continue;
497 };
498 if normalized.is_empty() {
499 issues.push(QualityIssue::Error(format!(
500 "canonical_hex_key_material[{policy_index}] suffix {suffix:?} must not be empty"
501 )));
502 }
503 if !seen_suffixes.insert(normalized) {
504 issues.push(QualityIssue::Error(format!(
505 "canonical_hex_key_material[{policy_index}] contains duplicate normalized suffix {suffix:?}"
506 )));
507 }
508 }
509 let mut seen_exclusions = std::collections::HashSet::new();
510 for excluded in &policy.excluded_keywords {
511 let Some(normalized) = normalize_detector_keyword(excluded) else {
512 issues.push(QualityIssue::Error(format!(
513 "canonical_hex_key_material[{policy_index}] excluded keyword {excluded:?} must contain ASCII alphanumerics with only `_`, `-`, or `.` separators"
514 )));
515 continue;
516 };
517 if !seen_exclusions.insert(normalized) {
518 issues.push(QualityIssue::Error(format!(
519 "canonical_hex_key_material[{policy_index}] contains duplicate excluded keyword {excluded:?}"
520 )));
521 }
522 }
523 }
524}
525
526fn normalize_detector_keyword(keyword: &str) -> Option<String> {
527 let mut normalized = String::with_capacity(keyword.len());
528 for byte in keyword.bytes() {
529 if byte.is_ascii_alphanumeric() {
530 normalized.push(byte.to_ascii_lowercase() as char);
531 } else if !matches!(byte, b'_' | b'-' | b'.') {
532 return None;
533 }
534 }
535 (!normalized.is_empty()).then_some(normalized)
536}
537
538fn validate_simdsieve_prefixes(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
539 let mut seen = std::collections::HashSet::new();
540 for (index, prefix) in spec.simdsieve_prefixes.iter().enumerate() {
541 if prefix.is_empty() {
542 issues.push(QualityIssue::Error(format!(
543 "simdsieve_prefixes[{index}] must not be empty"
544 )));
545 } else if !prefix.is_ascii() {
546 issues.push(QualityIssue::Error(format!(
547 "simdsieve_prefixes[{index}] must be ASCII because simdsieve performs byte-prefix matching"
548 )));
549 }
550 if !seen.insert(prefix) {
551 issues.push(QualityIssue::Error(format!(
552 "simdsieve_prefixes contains duplicate literal {prefix:?}"
553 )));
554 }
555 }
556}
557
558fn validate_thresholds(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
565 if !(0.0..=1.0).contains(&spec.ml.weight) {
566 issues.push(QualityIssue::Error(format!(
567 "ml.weight {} is out of range; detector model weight must be finite and in [0.0, 1.0]",
568 spec.ml.weight
569 )));
570 }
571 if spec.ml.context_radius_lines > 64 {
572 issues.push(QualityIssue::Error(format!(
573 "ml.context_radius_lines {} exceeds the bounded maximum of 64",
574 spec.ml.context_radius_lines
575 )));
576 }
577 let owns_entropy = spec.owns_entropy_policy();
578 match spec.match_confidence {
579 None => issues.push(QualityIssue::Error(
580 "detector must declare match_confidence; scanner-wide match scoring defaults are not permitted"
581 .into(),
582 )),
583 Some(confidence) => {
584 if let Err(error) = confidence.validate() {
585 issues.push(QualityIssue::Error(format!(
586 "match_confidence is invalid: {error}"
587 )));
588 }
589 if owns_entropy {
590 if confidence.named_anchor_floor.is_some() {
591 issues.push(QualityIssue::Error(
592 "generic entropy owners must omit match_confidence.named_anchor_floor because their regex candidates do not receive the named-detector lift"
593 .into(),
594 ));
595 }
596 if confidence.low_promise_confidence.is_none() {
597 issues.push(QualityIssue::Error(
598 "generic entropy owners must declare match_confidence.low_promise_confidence"
599 .into(),
600 ));
601 }
602 } else {
603 if confidence.named_anchor_floor.is_none() {
604 issues.push(QualityIssue::Error(
605 "named detectors must declare match_confidence.named_anchor_floor"
606 .into(),
607 ));
608 }
609 if confidence.low_promise_confidence.is_some() {
610 issues.push(QualityIssue::Error(
611 "named detectors must omit match_confidence.low_promise_confidence because the promise gate cannot replace service-owned evidence"
612 .into(),
613 ));
614 }
615 }
616 }
617 }
618 if owns_entropy && spec.ml.entropy_mode == crate::DetectorMlMode::Disabled {
619 issues.push(QualityIssue::Error(
620 "an active entropy-policy owner must declare a non-disabled ml.entropy_mode"
621 .to_string(),
622 ));
623 }
624 if !owns_entropy && spec.ml.entropy_mode != crate::DetectorMlMode::Disabled {
625 issues.push(QualityIssue::Error(
626 "ml.entropy_mode is only valid for a detector that owns entropy policy".to_string(),
627 ));
628 }
629 for (name, value) in [
630 ("min_len", spec.min_len),
631 ("max_len", spec.max_len),
632 ("keyword_free_min_len", spec.keyword_free_min_len),
633 ] {
634 if value == Some(0) {
635 issues.push(QualityIssue::Error(format!(
636 "{name} must be greater than 0 when present; use omission to inherit the path default"
637 )));
638 }
639 }
640 if let (Some(min_len), Some(max_len)) = (spec.min_len, spec.max_len) {
641 if min_len > max_len {
642 issues.push(QualityIssue::Error(format!(
643 "min_len {min_len} exceeds max_len {max_len}"
644 )));
645 }
646 }
647 if spec.max_len.is_some_and(|max_len| max_len < 8) {
648 issues.push(QualityIssue::Error(
649 "max_len must be at least the generic assignment path minimum of 8".to_string(),
650 ));
651 }
652 if spec.max_len.is_some() && !spec.owns_entropy_policy() {
653 issues.push(QualityIssue::Error(
654 "max_len is only valid for detectors that own generic entropy policy".to_string(),
655 ));
656 }
657 if let Some(mc) = spec.min_confidence {
658 if !(0.0..=1.0).contains(&mc) {
659 issues.push(QualityIssue::Error(format!(
660 "min_confidence {mc} is out of range; confidence is a probability in [0.0, 1.0] \
661 (outside it silently breaks the gate: < 0 always passes, > 1 never fires, NaN is undefined)"
662 )));
663 }
664 }
665 if let Some(bound) = spec.bpe_max_bytes_per_token {
666 if !bound.is_finite() || bound <= 0.0 {
667 issues.push(QualityIssue::Error(format!(
668 "bpe_max_bytes_per_token {bound} must be finite and greater than 0; \
669 zero or a negative value suppresses every candidate and NaN/inf makes the gate undefined"
670 )));
671 }
672 }
673 if spec.bpe_enabled == Some(false) && spec.bpe_max_bytes_per_token.is_some() {
674 issues.push(QualityIssue::Error(
675 "bpe_enabled = false conflicts with bpe_max_bytes_per_token; remove the ceiling when token efficiency is disabled"
676 .into(),
677 ));
678 }
679 if !spec.entropy_roles.is_empty() && !spec.owns_entropy_policy() {
680 issues.push(QualityIssue::Error(
681 "entropy_roles require a detector that owns a complete entropy policy".into(),
682 ));
683 }
684 let mut entropy_roles = std::collections::HashSet::new();
685 for role in &spec.entropy_roles {
686 if !entropy_roles.insert(*role) {
687 issues.push(QualityIssue::Error(format!(
688 "entropy_roles contains duplicate role {:?}",
689 role.as_str()
690 )));
691 }
692 }
693 for (name, value) in [
694 ("entropy_high", spec.entropy_high),
695 ("entropy_low", spec.entropy_low),
696 ("entropy_very_high", spec.entropy_very_high),
697 (
698 "sensitive_path_entropy_very_high",
699 spec.sensitive_path_entropy_very_high,
700 ),
701 ] {
702 let Some(score) = value else {
703 continue;
704 };
705 if !score.is_finite() || !(0.0..=8.0).contains(&score) {
706 issues.push(QualityIssue::Error(format!(
707 "{name} must be a finite Shannon entropy score in [0.0, 8.0], found {score}"
708 )));
709 }
710 }
711 if let (Some(low), Some(high)) = (spec.entropy_low, spec.entropy_high) {
712 if low > high {
713 issues.push(QualityIssue::Error(format!(
714 "entropy_low {low} must not exceed entropy_high {high}"
715 )));
716 }
717 }
718 if let (Some(high), Some(very_high)) = (spec.entropy_high, spec.entropy_very_high) {
719 if high > very_high {
720 issues.push(QualityIssue::Error(format!(
721 "entropy_high {high} must not exceed entropy_very_high {very_high}"
722 )));
723 }
724 }
725 if let Some(plausibility) = spec.plausibility {
726 for (name, score) in [
727 (
728 "plausibility.mixed_alnum_floor",
729 plausibility.mixed_alnum_floor,
730 ),
731 (
732 "plausibility.symbolic_entropy_floor",
733 plausibility.symbolic_entropy_floor,
734 ),
735 (
736 "plausibility.second_half_entropy_floor",
737 plausibility.second_half_entropy_floor,
738 ),
739 (
740 "plausibility.isolated_mixed_entropy_floor",
741 plausibility.isolated_mixed_entropy_floor,
742 ),
743 (
744 "plausibility.leading_slash_base64_entropy_floor",
745 plausibility.leading_slash_base64_entropy_floor,
746 ),
747 ] {
748 if !score.is_finite() || !(0.0..=8.0).contains(&score) {
749 issues.push(QualityIssue::Error(format!(
750 "{name} must be a finite Shannon entropy score in [0.0, 8.0], found {score}"
751 )));
752 }
753 }
754 if let Some(margin) = plausibility.keyword_free_operator_margin {
755 if !margin.is_finite() || !(0.0..=8.0).contains(&margin) {
756 issues.push(QualityIssue::Error(format!(
757 "plausibility.keyword_free_operator_margin must be finite and in [0.0, 8.0], found {margin}"
758 )));
759 }
760 }
761 if plausibility.mixed_alnum_min_len == 0 {
762 issues.push(QualityIssue::Error(
763 "plausibility.mixed_alnum_min_len must be greater than zero".into(),
764 ));
765 }
766 for (name, length) in [
767 (
768 "plausibility.second_half_min_len",
769 plausibility.second_half_min_len,
770 ),
771 (
772 "plausibility.unique_chars_min_len",
773 plausibility.unique_chars_min_len,
774 ),
775 (
776 "plausibility.min_unique_chars",
777 plausibility.min_unique_chars,
778 ),
779 (
780 "plausibility.unanchored_hex_max_len",
781 plausibility.unanchored_hex_max_len,
782 ),
783 (
784 "plausibility.identical_char_max_len",
785 plausibility.identical_char_max_len,
786 ),
787 (
788 "plausibility.structured_dotted_min_len",
789 plausibility.structured_dotted_min_len,
790 ),
791 (
792 "plausibility.isolated_symbolic_min_len",
793 plausibility.isolated_symbolic_min_len,
794 ),
795 (
796 "plausibility.isolated_symbolic_min_symbols",
797 plausibility.isolated_symbolic_min_symbols,
798 ),
799 (
800 "plausibility.isolated_alpha_only_min_symbols",
801 plausibility.isolated_alpha_only_min_symbols,
802 ),
803 (
804 "plausibility.source_type_name_max_len",
805 plausibility.source_type_name_max_len,
806 ),
807 (
808 "plausibility.source_type_name_min_uppercase",
809 plausibility.source_type_name_min_uppercase,
810 ),
811 (
812 "plausibility.url_path_high_entropy_min_len",
813 plausibility.url_path_high_entropy_min_len,
814 ),
815 (
816 "plausibility.isolated_colon_left_min_len",
817 plausibility.isolated_colon_left_min_len,
818 ),
819 (
820 "plausibility.isolated_colon_right_min_len",
821 plausibility.isolated_colon_right_min_len,
822 ),
823 (
824 "plausibility.leading_slash_base64_min_len",
825 plausibility.leading_slash_base64_min_len,
826 ),
827 ] {
828 if length == 0 {
829 issues.push(QualityIssue::Error(format!(
830 "{name} must be greater than zero"
831 )));
832 }
833 }
834 if !plausibility.isolated_alpha_only_min_alpha_ratio.is_finite()
835 || !(0.0..=1.0).contains(&plausibility.isolated_alpha_only_min_alpha_ratio)
836 || plausibility.isolated_alpha_only_min_alpha_ratio == 0.0
837 {
838 issues.push(QualityIssue::Error(format!(
839 "plausibility.isolated_alpha_only_min_alpha_ratio must be finite and in (0.0, 1.0], found {}",
840 plausibility.isolated_alpha_only_min_alpha_ratio
841 )));
842 }
843 if !plausibility.min_alnum_ratio.is_finite()
844 || !(0.0..=1.0).contains(&plausibility.min_alnum_ratio)
845 || plausibility.min_alnum_ratio == 0.0
846 {
847 issues.push(QualityIssue::Error(format!(
848 "plausibility.min_alnum_ratio must be finite and in (0.0, 1.0], found {}",
849 plausibility.min_alnum_ratio
850 )));
851 }
852 if plausibility.source_type_name_min_uppercase > plausibility.source_type_name_max_len {
853 issues.push(QualityIssue::Error(format!(
854 "plausibility.source_type_name_min_uppercase ({}) must not exceed plausibility.source_type_name_max_len ({})",
855 plausibility.source_type_name_min_uppercase,
856 plausibility.source_type_name_max_len
857 )));
858 }
859 if plausibility.min_unique_chars > plausibility.unique_chars_min_len {
860 issues.push(QualityIssue::Error(format!(
861 "plausibility.min_unique_chars ({}) must not exceed plausibility.unique_chars_min_len ({})",
862 plausibility.min_unique_chars, plausibility.unique_chars_min_len
863 )));
864 }
865 }
866 if let (Some(very_high), Some(sensitive)) = (
867 spec.entropy_very_high,
868 spec.sensitive_path_entropy_very_high,
869 ) {
870 if sensitive > very_high {
871 issues.push(QualityIssue::Error(format!(
872 "sensitive_path_entropy_very_high {sensitive} must not exceed entropy_very_high {very_high}; sensitive paths may lower the keyword-free bar, never raise it"
873 )));
874 }
875 }
876 let entropy_owner = spec.owns_entropy_policy();
877 let has_weak_pattern = spec.patterns.iter().any(|pattern| pattern.weak_anchor);
878 if spec.weak_anchor && has_weak_pattern {
879 issues.push(QualityIssue::Error(
880 "detector weak_anchor=true already applies to every pattern; remove redundant pattern weak_anchor flags"
881 .into(),
882 ));
883 }
884 if spec.weak_anchor || has_weak_pattern {
885 if spec.entropy_high.is_none() {
886 issues.push(QualityIssue::Error(
887 "weak_anchor detectors and patterns must declare entropy_high in their own detector TOML".into(),
888 ));
889 }
890 if spec.entropy_floor.is_empty() {
891 issues.push(QualityIssue::Error(
892 "weak_anchor detectors and patterns must declare entropy_floor in their own detector TOML"
893 .into(),
894 ));
895 }
896 }
897 if entropy_owner {
898 for (field, present) in [
899 ("entropy_high", spec.entropy_high.is_some()),
900 ("entropy_low", spec.entropy_low.is_some()),
901 ("entropy_very_high", spec.entropy_very_high.is_some()),
902 (
903 "sensitive_path_entropy_very_high",
904 spec.sensitive_path_entropy_very_high.is_some(),
905 ),
906 ("[detector.plausibility]", spec.plausibility.is_some()),
907 ("keyword_free_min_len", spec.keyword_free_min_len.is_some()),
908 ("min_len", spec.min_len.is_some()),
909 ("max_len", spec.max_len.is_some()),
910 (
911 "entropy_policy_priority",
912 spec.entropy_policy_priority.is_some(),
913 ),
914 ] {
915 if !present {
916 issues.push(QualityIssue::Error(format!(
917 "active entropy owner must declare {field} in its detector TOML; runtime fallback policy is forbidden"
918 )));
919 }
920 }
921 if spec.entropy_shapes.is_empty() {
922 issues.push(QualityIssue::Error(
923 "active entropy owner must declare detector.entropy_shapes in its detector TOML"
924 .into(),
925 ));
926 }
927 if spec.entropy_floor.is_empty() {
928 issues.push(QualityIssue::Error(
929 "active entropy owner must declare entropy_floor in its detector TOML".into(),
930 ));
931 }
932 if spec.bpe_enabled.is_none() {
933 issues.push(QualityIssue::Error(
934 "active entropy owner must declare bpe_enabled in its detector TOML".into(),
935 ));
936 }
937 if spec.bpe_enabled != Some(false) && spec.bpe_max_bytes_per_token.is_none() {
938 issues.push(QualityIssue::Error(
939 "active entropy owner must declare bpe_max_bytes_per_token or bpe_enabled = false in its detector TOML"
940 .into(),
941 ));
942 }
943 }
944 let owns_keyword_free = spec
945 .entropy_roles
946 .contains(&crate::EntropyDetectionRole::KeywordFree);
947 let keyword_free_operator_margin = spec
948 .plausibility
949 .and_then(|policy| policy.keyword_free_operator_margin);
950 match (owns_keyword_free, keyword_free_operator_margin) {
951 (true, None) => issues.push(QualityIssue::Error(
952 "the detector claiming entropy role `keyword-free` must declare plausibility.keyword_free_operator_margin"
953 .into(),
954 )),
955 (false, Some(_)) => issues.push(QualityIssue::Error(
956 "plausibility.keyword_free_operator_margin is valid only on the detector claiming entropy role `keyword-free`"
957 .into(),
958 )),
959 _ => {}
960 }
961 if entropy_owner && spec.entropy_fallback.is_none() {
962 issues.push(QualityIssue::Error(
963 "active entropy owner must declare entropy_fallback metadata; omission would make synthetic finding identity ambiguous".into(),
964 ));
965 }
966 if entropy_owner && spec.entropy_fallback_confidence.is_none() {
967 issues.push(QualityIssue::Error(
968 "active entropy owner must declare entropy_fallback_confidence; omission would leave detector confidence in scanner literals".into(),
969 ));
970 }
971 if entropy_owner && spec.generic_assignment_confidence.is_none() {
972 issues.push(QualityIssue::Error(
973 "active entropy owner must declare generic_assignment_confidence; omission would leave generic assignment scoring in scanner literals".into(),
974 ));
975 }
976 if let Some(confidence) = spec.entropy_fallback_confidence {
977 if !entropy_owner {
978 issues.push(QualityIssue::Error(
979 "entropy_fallback_confidence requires an active detector-owned entropy policy"
980 .into(),
981 ));
982 }
983 if let Err(error) = confidence.validate() {
984 issues.push(QualityIssue::Error(format!(
985 "entropy_fallback_confidence is invalid: {error}"
986 )));
987 }
988 }
989 if let Some(confidence) = spec.generic_assignment_confidence {
990 if !entropy_owner {
991 issues.push(QualityIssue::Error(
992 "generic_assignment_confidence requires an active detector-owned entropy policy"
993 .into(),
994 ));
995 }
996 if let Err(error) = confidence.validate() {
997 issues.push(QualityIssue::Error(format!(
998 "generic_assignment_confidence is invalid: {error}"
999 )));
1000 }
1001 }
1002 if let Some(metadata) = &spec.entropy_fallback {
1003 if !entropy_owner {
1004 issues.push(QualityIssue::Error(
1005 "entropy_fallback requires an active detector-owned entropy policy".into(),
1006 ));
1007 }
1008 if !metadata.id.strip_prefix("entropy-").is_some_and(|suffix| {
1009 !suffix.is_empty()
1010 && suffix
1011 .bytes()
1012 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
1013 }) {
1014 issues.push(QualityIssue::Error(format!(
1015 "entropy_fallback.id {:?} must use a lowercase entropy- namespace id",
1016 metadata.id
1017 )));
1018 }
1019 if metadata.name.trim().is_empty() {
1020 issues.push(QualityIssue::Error(
1021 "entropy_fallback.name must not be empty".into(),
1022 ));
1023 }
1024 if metadata.service.trim().is_empty() {
1025 issues.push(QualityIssue::Error(
1026 "entropy_fallback.service must not be empty".into(),
1027 ));
1028 }
1029 }
1030 if !spec.entropy_shapes.is_empty() && !entropy_owner {
1031 issues.push(QualityIssue::Error(
1032 "entropy_shapes require an active detector-owned entropy policy".into(),
1033 ));
1034 }
1035 if spec.entropy_shapes.len() > 1 {
1036 issues.push(QualityIssue::Error(format!(
1037 "active entropy policy accepts exactly one detector.entropy_shapes entry, found {}",
1038 spec.entropy_shapes.len()
1039 )));
1040 }
1041 let mut shape_signatures: Vec<(crate::spec::ShapeCharset, Option<(usize, usize, char)>)> =
1042 Vec::new();
1043 for (index, shape) in spec.entropy_shapes.iter().enumerate() {
1044 let signature = (
1045 shape.charset,
1046 shape
1047 .grouping
1048 .map(|g| (g.group_count, g.group_length, g.separator)),
1049 );
1050 if shape_signatures.contains(&signature) {
1051 issues.push(QualityIssue::Error(format!(
1052 "entropy_shapes[{index}] duplicates an earlier shape's charset and grouping"
1053 )));
1054 }
1055 shape_signatures.push(signature);
1056 if !shape.entropy_floor.is_finite() || !(0.0..=8.0).contains(&shape.entropy_floor) {
1057 issues.push(QualityIssue::Error(format!(
1058 "entropy_shapes[{index}].entropy_floor must be finite and in [0.0, 8.0], found {}",
1059 shape.entropy_floor
1060 )));
1061 }
1062 if shape.special_min_length == 0 {
1063 issues.push(QualityIssue::Error(format!(
1064 "entropy_shapes[{index}].special_min_length must be greater than 0"
1065 )));
1066 }
1067 if shape.require_mixed_case && shape.charset == crate::spec::ShapeCharset::LowerAlnum {
1068 issues.push(QualityIssue::Error(format!(
1069 "entropy_shapes[{index}].require_mixed_case is impossible with charset lower-alnum"
1070 )));
1071 }
1072 if shape.require_non_hex_alpha && shape.charset == crate::spec::ShapeCharset::Hex {
1073 issues.push(QualityIssue::Error(format!(
1074 "entropy_shapes[{index}].require_non_hex_alpha is impossible with charset hex"
1075 )));
1076 }
1077 if shape.require_group_alpha_digit && shape.grouping.is_none() {
1078 issues.push(QualityIssue::Error(format!(
1079 "entropy_shapes[{index}].require_group_alpha_digit requires grouping"
1080 )));
1081 }
1082 if let Some(grouping) = shape.grouping {
1083 if grouping.group_count == 0 || grouping.group_length == 0 {
1084 issues.push(QualityIssue::Error(format!(
1085 "entropy_shapes[{index}] grouping.group_count and group_length must both be greater than 0"
1086 )));
1087 continue;
1088 }
1089 let derived_length = grouping
1090 .group_count
1091 .checked_mul(grouping.group_length)
1092 .and_then(|length| {
1093 length.checked_add(
1094 grouping
1095 .group_count
1096 .saturating_sub(1)
1097 .saturating_mul(grouping.separator.len_utf8()),
1098 )
1099 });
1100 let Some(derived_length) = derived_length else {
1101 issues.push(QualityIssue::Error(format!(
1102 "entropy_shapes[{index}] grouping overflows the derived candidate length"
1103 )));
1104 continue;
1105 };
1106 if shape.special_min_length > derived_length {
1107 issues.push(QualityIssue::Error(format!(
1108 "entropy_shapes[{index}].special_min_length must be in 1..={derived_length}, found {}",
1109 shape.special_min_length
1110 )));
1111 }
1112 }
1113 }
1114}
1115
1116fn validate_entropy_floor(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
1117 if spec.entropy_floor.is_empty() {
1118 return;
1119 }
1120 let last = spec.entropy_floor.len() - 1;
1121 let mut previous_max = 0usize;
1122 for (index, bucket) in spec.entropy_floor.iter().enumerate() {
1123 if !bucket.floor.is_finite() || !(0.0..=8.0).contains(&bucket.floor) {
1124 issues.push(QualityIssue::Error(format!(
1125 "entropy_floor bucket {index} floor must be finite and in [0.0, 8.0], found {}",
1126 bucket.floor
1127 )));
1128 }
1129 if index < last && bucket.max_len.is_none() {
1130 issues.push(QualityIssue::Error(format!(
1131 "entropy_floor bucket {index} is an early catch-all; only the final bucket may omit max_len"
1132 )));
1133 }
1134 if index == last && bucket.max_len.is_some() {
1135 issues.push(QualityIssue::Error(
1136 "entropy_floor final bucket must omit max_len so longer candidates cannot bypass the floor"
1137 .into(),
1138 ));
1139 }
1140 if let Some(max_len) = bucket.max_len {
1141 if max_len <= previous_max {
1142 issues.push(QualityIssue::Error(format!(
1143 "entropy_floor max_len values must strictly increase from a positive length; found {max_len} after {previous_max}"
1144 )));
1145 }
1146 previous_max = max_len;
1147 }
1148 }
1149}
1150
1151fn validate_credential_shape(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
1152 if let Some(shape) = &spec.credential_shape {
1153 if let Err(error) = shape.validate(&spec.id) {
1154 issues.push(QualityIssue::Error(error));
1155 }
1156 }
1157}
1158
1159fn validate_detector_allowlists(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
1160 for (field, patterns) in [
1161 ("allowlist_paths", &spec.allowlist_paths),
1162 ("allowlist_values", &spec.allowlist_values),
1163 (
1164 "source_admission.path_patterns",
1165 &spec.source_admission.path_patterns,
1166 ),
1167 ] {
1168 let mut first_index_by_pattern = HashMap::new();
1169 for (index, pattern) in patterns.iter().enumerate() {
1170 if pattern.trim().is_empty() {
1171 issues.push(QualityIssue::Error(format!(
1172 "detector {:?} {field}[{index}] must not be empty or whitespace-only",
1173 spec.id
1174 )));
1175 continue;
1176 }
1177 match first_index_by_pattern.entry(pattern.as_str()) {
1178 Entry::Occupied(first) => issues.push(QualityIssue::Error(format!(
1179 "detector {:?} {field}[{index}] duplicates {field}[{}]",
1180 spec.id,
1181 first.get()
1182 ))),
1183 Entry::Vacant(slot) => {
1184 slot.insert(index);
1185 }
1186 }
1187 if let Err(error) = regex::Regex::new(pattern) {
1188 issues.push(QualityIssue::Error(format!(
1189 "detector {:?} {field}[{index}] is not a valid regex ({pattern:?}): {error}",
1190 spec.id
1191 )));
1192 }
1193 }
1194 }
1195
1196 let mut first_index_by_stopword = HashMap::new();
1197 for (index, stopword) in spec.stopwords.iter().enumerate() {
1198 if stopword.trim().is_empty() {
1199 issues.push(QualityIssue::Error(format!(
1200 "detector {:?} stopwords[{index}] must not be empty or whitespace-only",
1201 spec.id
1202 )));
1203 continue;
1204 }
1205 let normalized = stopword.to_ascii_lowercase();
1206 match first_index_by_stopword.entry(normalized) {
1207 Entry::Occupied(first) => issues.push(QualityIssue::Error(format!(
1208 "detector {:?} stopwords[{index}] duplicates stopwords[{}] under case-insensitive matching",
1209 spec.id,
1210 first.get()
1211 ))),
1212 Entry::Vacant(slot) => {
1213 slot.insert(index);
1214 }
1215 }
1216 }
1217 let mut first_marker_index = HashMap::new();
1218 for (index, marker) in spec.public_identifier_assignment_markers.iter().enumerate() {
1219 if marker.is_empty()
1220 || !marker.is_ascii()
1221 || marker.bytes().any(|byte| byte.is_ascii_lowercase())
1222 {
1223 issues.push(QualityIssue::Error(format!(
1224 "detector {:?} public_identifier_assignment_markers[{index}] must be non-empty uppercase ASCII because runtime matching is allocation-free ASCII-insensitive",
1225 spec.id
1226 )));
1227 continue;
1228 }
1229 match first_marker_index.entry(marker.as_str()) {
1230 Entry::Occupied(first) => issues.push(QualityIssue::Error(format!(
1231 "detector {:?} public_identifier_assignment_markers[{index}] duplicates public_identifier_assignment_markers[{}]",
1232 spec.id,
1233 first.get()
1234 ))),
1235 Entry::Vacant(slot) => {
1236 slot.insert(index);
1237 }
1238 }
1239 }
1240 let mut source_types = HashSet::new();
1241 for (index, source_type) in spec.source_admission.source_types.iter().enumerate() {
1242 if source_type.trim().is_empty() {
1243 issues.push(QualityIssue::Error(format!(
1244 "detector {:?} source_admission.source_types[{index}] must not be empty",
1245 spec.id
1246 )));
1247 } else if !source_types.insert(source_type) {
1248 issues.push(QualityIssue::Error(format!(
1249 "detector {:?} source_admission.source_types[{index}] is duplicated",
1250 spec.id
1251 )));
1252 }
1253 }
1254 let mut extensions = HashSet::new();
1255 for (index, extension) in spec.source_admission.file_extensions.iter().enumerate() {
1256 if extension.is_empty()
1257 || !extension.is_ascii()
1258 || extension.starts_with('.')
1259 || extension.bytes().any(|byte| byte.is_ascii_uppercase())
1260 {
1261 issues.push(QualityIssue::Error(format!(
1262 "detector {:?} source_admission.file_extensions[{index}] must be lowercase ASCII without a leading dot",
1263 spec.id
1264 )));
1265 } else if !extensions.insert(extension) {
1266 issues.push(QualityIssue::Error(format!(
1267 "detector {:?} source_admission.file_extensions[{index}] is duplicated",
1268 spec.id
1269 )));
1270 }
1271 }
1272}
1273
1274fn validate_patterns_present(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
1275 match spec.kind {
1276 DetectorKind::Regex => {
1278 if spec.patterns.is_empty() {
1279 issues.push(QualityIssue::Error("no patterns defined".into()));
1280 }
1281 }
1282 DetectorKind::Phase2Generic => {
1287 if spec.keywords.is_empty() {
1288 issues.push(QualityIssue::Error(
1289 "phase2-generic detector must define keywords (its only pre-filter)".into(),
1290 ));
1291 }
1292 }
1293 }
1294}
1295
1296fn validate_regexes<'a>(
1297 spec: &'a DetectorSpec,
1298 issues: &mut Vec<QualityIssue>,
1299 regex_cache: &mut RegexAstCache<'a>,
1300) {
1301 for (i, pat) in spec.patterns.iter().enumerate() {
1302 validate_regex_definition(RegexKind::Pattern, i, &pat.regex, issues, regex_cache);
1303 }
1304}
1305
1306fn validate_keywords(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
1307 if spec.keywords.is_empty() {
1308 issues.push(QualityIssue::Warning(
1309 "no keywords defined - pattern may produce false positives".into(),
1310 ));
1311 return;
1312 }
1313 for (index, keyword) in spec.keywords.iter().enumerate() {
1314 if keyword.is_empty() {
1315 issues.push(QualityIssue::Error(format!(
1316 "keyword {index} is empty; remove it or declare a non-empty detector-owned context literal"
1317 )));
1318 }
1319 }
1320}
1321
1322fn validate_pattern_groups<'a>(
1323 spec: &'a DetectorSpec,
1324 issues: &mut Vec<QualityIssue>,
1325 regex_cache: &mut RegexAstCache<'a>,
1326) {
1327 for (i, pat) in spec.patterns.iter().enumerate() {
1328 let Some(group) = pat.group else {
1329 continue;
1330 };
1331 let Ok(ast) = regex_cache.parse(&pat.regex) else {
1332 continue; };
1334 let captures = ast_captures_len(ast);
1335 if group >= captures {
1336 issues.push(QualityIssue::Error(format!(
1337 "pattern {i} capture group {group} is out of range; regex has {} capture groups \
1338 (valid group indexes are 0..{})",
1339 captures.saturating_sub(1),
1340 captures.saturating_sub(1)
1341 )));
1342 }
1343 }
1344}
1345
1346fn validate_pattern_specificity<'a>(
1347 spec: &'a DetectorSpec,
1348 issues: &mut Vec<QualityIssue>,
1349 regex_cache: &mut RegexAstCache<'a>,
1350) {
1351 for (i, pat) in spec.patterns.iter().enumerate() {
1352 let has_prefix = has_literal_prefix(regex_cache, &pat.regex, 3);
1353 let has_group = pat.group.is_some();
1354 let is_pure_charclass = is_pure_character_class(regex_cache, &pat.regex);
1355
1356 if is_pure_charclass && !has_group {
1357 issues.push(QualityIssue::Error(format!(
1358 "pattern {} is a pure character class ({}) - too broad without context anchoring. \
1359 Use a capture group or add a literal prefix.",
1360 i, pat.regex
1361 )));
1362 } else if !has_prefix && !has_group && spec.keywords.is_empty() {
1363 issues.push(QualityIssue::Warning(format!(
1364 "pattern {} has no literal prefix and no capture group - may false-positive",
1365 i
1366 )));
1367 }
1368 }
1369}
1370
1371fn validate_companions<'a>(
1372 spec: &'a DetectorSpec,
1373 issues: &mut Vec<QualityIssue>,
1374 regex_cache: &mut RegexAstCache<'a>,
1375) {
1376 for (i, companion) in spec.companions.iter().enumerate() {
1377 if companion.name.trim().is_empty() {
1378 issues.push(QualityIssue::Error(format!(
1379 "companion {} name must not be empty",
1380 i
1381 )));
1382 }
1383 if companion.within_lines > MAX_COMPANION_WITHIN_LINES {
1384 issues.push(QualityIssue::Error(format!(
1385 "companion {} within_lines={} exceeds {} search-window limit",
1386 i, companion.within_lines, MAX_COMPANION_WITHIN_LINES
1387 )));
1388 }
1389 if let Some(within_bytes) = companion.within_bytes {
1390 if within_bytes == 0 || within_bytes > MAX_COMPANION_WITHIN_BYTES {
1391 issues.push(QualityIssue::Error(format!(
1392 "companion {i} within_bytes={within_bytes} must be in 1..={MAX_COMPANION_WITHIN_BYTES}"
1393 )));
1394 }
1395 }
1396 if companion.scope == EvidenceScope::SameLine && companion.within_lines != 0 {
1397 issues.push(QualityIssue::Error(format!(
1398 "companion {i} scope=same-line requires within_lines=0, found {}",
1399 companion.within_lines
1400 )));
1401 }
1402 if companion.required && companion.requirement != EvidenceRequirement::Reinforcing {
1403 issues.push(QualityIssue::Error(format!(
1404 "companion {i} mixes schema-v2 required=true with typed requirement={:?}; \
1405 remove required and keep only the typed requirement",
1406 companion.requirement
1407 )));
1408 }
1409 if let Some(group) = companion.capture_group {
1410 if let Ok(regex) = regex::Regex::new(&companion.regex) {
1411 if group >= regex.captures_len() {
1413 issues.push(QualityIssue::Error(format!(
1414 "companion {i} capture_group={group} does not exist; regex exposes groups 0..{}",
1415 regex.captures_len().saturating_sub(1)
1416 )));
1417 }
1418 }
1419 }
1420 validate_regex_definition(
1421 RegexKind::Companion,
1422 i,
1423 &companion.regex,
1424 issues,
1425 regex_cache,
1426 );
1427 if is_pure_character_class(regex_cache, &companion.regex) {
1433 if companion.within_lines <= TIGHT_COMPANION_RADIUS {
1434 issues.push(QualityIssue::Warning(format!(
1435 "companion {} regex '{}' is a pure character class; \
1436 allowed because within_lines={} ≤ {} (positional anchoring).",
1437 i, companion.regex, companion.within_lines, TIGHT_COMPANION_RADIUS
1438 )));
1439 } else {
1440 issues.push(QualityIssue::Error(format!(
1441 "companion {} regex '{}' is a pure character class with within_lines={} \
1442 (> {}) - the wide search radius needs a literal context anchor",
1443 i, companion.regex, companion.within_lines, TIGHT_COMPANION_RADIUS
1444 )));
1445 }
1446 } else if !has_substantial_literal(regex_cache, &companion.regex, 3) {
1447 issues.push(QualityIssue::Warning(format!(
1448 "companion {} regex '{}' is too broad - may produce false positives. \
1449 Add a context anchor like 'KEY_NAME='.",
1450 i, companion.regex
1451 )));
1452 }
1453 }
1454}
1455
1456fn validate_detector_relations(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
1457 let mut first_by_target: HashMap<&str, (usize, DetectorRelationKind)> = HashMap::new();
1458 for (index, relation) in spec.detector_relations.iter().enumerate() {
1459 let target = relation.detector_id.trim();
1460 if target.is_empty() {
1461 issues.push(QualityIssue::Error(format!(
1462 "detector relation {index} target detector_id must not be empty"
1463 )));
1464 }
1465 if target == spec.id {
1466 issues.push(QualityIssue::Error(format!(
1467 "detector relation {index} cannot target its owning detector {:?}",
1468 spec.id
1469 )));
1470 }
1471 if relation.within_lines > MAX_COMPANION_WITHIN_LINES {
1472 issues.push(QualityIssue::Error(format!(
1473 "detector relation {index} within_lines={} exceeds {} search-window limit",
1474 relation.within_lines, MAX_COMPANION_WITHIN_LINES
1475 )));
1476 }
1477 if let Some(within_bytes) = relation.within_bytes {
1478 if within_bytes > MAX_COMPANION_WITHIN_BYTES {
1479 issues.push(QualityIssue::Error(format!(
1480 "detector relation {index} within_bytes={within_bytes} must be in 0..={MAX_COMPANION_WITHIN_BYTES}"
1481 )));
1482 }
1483 }
1484 if let Some((first_index, first_kind)) =
1485 first_by_target.insert(target, (index, relation.kind))
1486 {
1487 let detail = if first_kind == relation.kind {
1488 "duplicates"
1489 } else {
1490 "contradicts"
1491 };
1492 issues.push(QualityIssue::Error(format!(
1493 "detector relation {index} {detail} relation {first_index} for target {target:?}; \
1494 declare one operation per detector pair"
1495 )));
1496 }
1497 }
1498}
1499
1500const TIGHT_COMPANION_RADIUS: usize = 5;
1503
1504#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1505enum RegexKind {
1506 Pattern,
1507 Companion,
1508}
1509
1510impl RegexKind {
1511 fn label(self) -> &'static str {
1512 match self {
1513 Self::Pattern => "pattern",
1514 Self::Companion => "companion",
1515 }
1516 }
1517}
1518
1519fn validate_regex_definition<'a>(
1520 kind: RegexKind,
1521 index: usize,
1522 regex: &'a str,
1523 issues: &mut Vec<QualityIssue>,
1524 regex_cache: &mut RegexAstCache<'a>,
1525) {
1526 let kind = kind.label();
1527 if regex.is_empty() {
1532 issues.push(QualityIssue::Error(format!(
1533 "{kind} {index} regex is empty; an empty pattern matches at every position \
1534 (a catastrophic false-positive flood), define a real anchor or remove the pattern"
1535 )));
1536 return;
1537 }
1538 if regex.len() > MAX_REGEX_PATTERN_LEN {
1539 issues.push(QualityIssue::Error(format!(
1540 "{kind} {index} regex is too large ({} bytes > {} byte limit)",
1541 regex.len(),
1542 MAX_REGEX_PATTERN_LEN
1543 )));
1544 return;
1545 }
1546
1547 match regex_cache.parse(regex) {
1548 Ok(ast) => validate_regex_complexity(kind, index, ast, issues),
1549 Err(error) => issues.push(QualityIssue::Error(format!(
1550 "{kind} {index} regex does not compile: {error}"
1551 ))),
1552 }
1553}
1554
1555mod regex_ast;
1556mod regex_complexity;
1557mod verify;
1558
1559use regex_ast::{
1560 ast_captures_len, has_literal_prefix, has_substantial_literal, is_pure_character_class,
1561 RegexAstCache,
1562};
1563use regex_complexity::validate_regex_complexity;
1564use verify::validate_verify_spec;