1use super::{CanonicalHexKeyMaterialSpec, DetectorKind, DetectorSpec, VerifySpec};
4use regex_syntax::ast;
5use serde::Serialize;
6use std::collections::{hash_map::Entry, HashMap};
7
8const MAX_REGEX_PATTERN_LEN: usize = 4096;
9const MAX_COMPANION_WITHIN_LINES: usize = 100;
10const MIN_HTTP_STATUS: u16 = 100;
11const MAX_HTTP_STATUS: u16 = 599;
12#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30pub enum QualityIssue {
31 Error(String),
32 Warning(String),
33}
34
35pub fn validate_detector(spec: &DetectorSpec) -> Vec<QualityIssue> {
58 let mut issues = Vec::new();
59 let mut regex_cache = RegexAstCache::default();
60 validate_identity(spec, &mut issues);
61 validate_patterns_present(spec, &mut issues);
62 validate_regexes(spec, &mut issues, &mut regex_cache);
63 validate_required_literals(spec, &mut issues);
64 validate_pattern_groups(spec, &mut issues, &mut regex_cache);
65 validate_keywords(spec, &mut issues);
66 validate_simdsieve_prefixes(spec, &mut issues);
67 validate_offline_validators(spec, &mut issues);
68 validate_decode_transforms(spec, &mut issues);
69 validate_pattern_specificity(spec, &mut issues, &mut regex_cache);
70 validate_companions(spec, &mut issues, &mut regex_cache);
71 validate_verify_spec(spec, &mut issues);
72 validate_thresholds(spec, &mut issues);
73 validate_entropy_floor(spec, &mut issues);
74 validate_decoded_hex_key_material_lengths(spec, &mut issues);
75 validate_canonical_hex_key_material(spec, &mut issues);
76 validate_credential_shape(spec, &mut issues);
77 validate_generic_assignment_suffixes(spec, &mut issues);
78 validate_detector_allowlists(spec, &mut issues);
79 issues
80}
81fn validate_generic_assignment_suffixes(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
82 for (field, suffixes) in [
83 ("generic_vendor_suffixes", &spec.generic_vendor_suffixes),
84 (
85 "generic_assignment_tail_suffixes",
86 &spec.generic_assignment_tail_suffixes,
87 ),
88 ] {
89 if !suffixes.is_empty() && spec.kind != crate::DetectorKind::Phase2Generic {
90 issues.push(QualityIssue::Error(format!(
91 "{field} is only valid for a phase2-generic detector"
92 )));
93 }
94 let mut seen = std::collections::BTreeSet::new();
95 for suffix in suffixes {
96 if suffix.is_empty()
97 || suffix != &suffix.to_ascii_lowercase()
98 || !suffix.bytes().all(|byte| byte.is_ascii_alphanumeric())
99 {
100 issues.push(QualityIssue::Error(format!(
101 "{field} entry {suffix:?} must be non-empty lowercase ASCII alphanumeric"
102 )));
103 } else if !seen.insert(suffix.as_str()) {
104 issues.push(QualityIssue::Error(format!(
105 "{field} contains duplicate suffix {suffix:?}"
106 )));
107 }
108 }
109 }
110}
111
112fn validate_decode_transforms(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
113 for issue in spec.decode_transforms.validate() {
114 issues.push(QualityIssue::Error(format!("decode_transforms.{issue}")));
115 }
116}
117
118fn validate_required_literals(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
119 for (index, pattern) in spec.patterns.iter().enumerate() {
120 if let Err(reason) = pattern.validate_required_literals() {
121 issues.push(QualityIssue::Error(format!(
122 "patterns[{index}].required_literals: {reason}"
123 )));
124 }
125 }
126}
127
128fn validate_offline_validators(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
129 let mut claimed_prefixes = std::collections::HashSet::new();
130 for (index, validator) in spec.validators.iter().enumerate() {
131 let prefixes = validator.prefixes();
132 if prefixes.is_empty() {
133 issues.push(QualityIssue::Error(format!(
134 "validators[{index}].prefixes must not be empty"
135 )));
136 }
137 for prefix in prefixes {
138 if prefix.is_empty() || !prefix.is_ascii() {
139 issues.push(QualityIssue::Error(format!(
140 "validators[{index}] prefix {prefix:?} must be non-empty ASCII"
141 )));
142 }
143 if !claimed_prefixes.insert(prefix) {
144 issues.push(QualityIssue::Error(format!(
145 "detector validators claim prefix {prefix:?} more than once"
146 )));
147 }
148 }
149
150 if let Some(floor) = validator.confidence_floor() {
151 if !floor.is_finite() || !(0.0..=1.0).contains(&floor) {
152 issues.push(QualityIssue::Error(format!(
153 "validators[{index}].confidence_floor must be finite and in [0.0, 1.0], found {floor}"
154 )));
155 }
156 }
157
158 match validator {
159 crate::DetectorValidatorSpec::Crc32Base62 {
160 entropy_len,
161 checksum_len,
162 ..
163 } => {
164 if *entropy_len == 0 || *checksum_len == 0 {
165 issues.push(QualityIssue::Error(format!(
166 "validators[{index}] CRC32 entropy_len and checksum_len must both be greater than zero"
167 )));
168 }
169 }
170 crate::DetectorValidatorSpec::GithubFineGrainedCrc32 {
171 left_len,
172 right_len,
173 checksum_len,
174 ..
175 } => {
176 if *left_len == 0 || *checksum_len == 0 || *right_len <= *checksum_len {
177 issues.push(QualityIssue::Error(format!(
178 "validators[{index}] fine-grained lengths require left_len > 0 and right_len > checksum_len > 0"
179 )));
180 }
181 }
182 crate::DetectorValidatorSpec::Base64Payload {
183 min_encoded_len,
184 max_encoded_len,
185 min_decoded_len,
186 ..
187 } => {
188 if *min_encoded_len == 0
189 || *max_encoded_len < *min_encoded_len
190 || *min_decoded_len == 0
191 {
192 issues.push(QualityIssue::Error(format!(
193 "validators[{index}] base64 lengths require 0 < min_encoded_len <= max_encoded_len and min_decoded_len > 0"
194 )));
195 }
196 }
197 crate::DetectorValidatorSpec::PatternShape { .. } => {
198 if spec.patterns.is_empty() {
199 issues.push(QualityIssue::Error(format!(
200 "validators[{index}] pattern-shape requires at least one detector pattern"
201 )));
202 }
203 }
204 }
205 }
206}
207
208fn validate_identity(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
209 if spec.id.is_empty() {
210 issues.push(QualityIssue::Error(
211 "detector.id must not be empty; assign a stable detector identifier".to_string(),
212 ));
213 } else if spec.id.trim() != spec.id {
214 issues.push(QualityIssue::Error(
215 "detector.id must not contain leading or trailing whitespace; remove the padding"
216 .to_string(),
217 ));
218 }
219}
220
221fn validate_decoded_hex_key_material_lengths(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
222 if spec.decoded_hex_key_material_lengths.is_empty() {
223 return;
224 }
225 if spec.kind != DetectorKind::Phase2Generic {
226 issues.push(QualityIssue::Error(
227 "decoded_hex_key_material_lengths is only valid for kind = \"phase2-generic\"".into(),
228 ));
229 }
230 let mut seen = std::collections::HashSet::new();
231 for &length in &spec.decoded_hex_key_material_lengths {
232 if length < 16 || length % 2 != 0 {
233 issues.push(QualityIssue::Error(format!(
234 "decoded_hex_key_material_lengths value {length} must be an even character count of at least 16"
235 )));
236 }
237 if !seen.insert(length) {
238 issues.push(QualityIssue::Error(format!(
239 "decoded_hex_key_material_lengths contains duplicate length {length}"
240 )));
241 }
242 }
243}
244
245fn validate_canonical_hex_key_material(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
246 if spec.canonical_hex_key_material.is_empty() {
247 return;
248 }
249 let generic_policy = spec.kind == DetectorKind::Phase2Generic;
250 let has_assignment_scope = |policy: &CanonicalHexKeyMaterialSpec| {
251 !policy.keywords.is_empty()
252 || !policy.suffixes.is_empty()
253 || !policy.excluded_keywords.is_empty()
254 };
255 if !generic_policy
256 && spec
257 .canonical_hex_key_material
258 .iter()
259 .any(has_assignment_scope)
260 {
261 issues.push(QualityIssue::Error(
262 "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(),
263 ));
264 }
265
266 let owned_keywords: std::collections::HashSet<String> = spec
267 .keywords
268 .iter()
269 .filter_map(|keyword| normalize_detector_keyword(keyword))
270 .collect();
271 let mut seen_pairs = std::collections::HashSet::new();
272 let mut seen_regex_lengths = std::collections::HashSet::new();
273 for (policy_index, policy) in spec.canonical_hex_key_material.iter().enumerate() {
274 if policy.lengths.is_empty() {
275 issues.push(QualityIssue::Error(format!(
276 "canonical_hex_key_material[{policy_index}].lengths must not be empty"
277 )));
278 }
279 if generic_policy && policy.keywords.is_empty() && policy.suffixes.is_empty() {
280 issues.push(QualityIssue::Error(format!(
281 "phase2-generic canonical_hex_key_material[{policy_index}] must declare keywords or suffixes"
282 )));
283 }
284 let mut seen_lengths = std::collections::HashSet::new();
285 for &length in &policy.lengths {
286 if length < 16 || length % 2 != 0 {
287 issues.push(QualityIssue::Error(format!(
288 "canonical_hex_key_material[{policy_index}] length {length} must be an even character count of at least 16"
289 )));
290 }
291 if !seen_lengths.insert(length) {
292 issues.push(QualityIssue::Error(format!(
293 "canonical_hex_key_material[{policy_index}] contains duplicate length {length}"
294 )));
295 }
296 if !generic_policy && !seen_regex_lengths.insert(length) {
297 issues.push(QualityIssue::Error(format!(
298 "canonical_hex_key_material repeats regex-detector length {length} across policies"
299 )));
300 }
301 }
302 let mut seen_keywords = std::collections::HashSet::new();
303 for keyword in &policy.keywords {
304 let Some(normalized) = normalize_detector_keyword(keyword) else {
305 issues.push(QualityIssue::Error(format!(
306 "canonical_hex_key_material[{policy_index}] keyword {keyword:?} must contain ASCII alphanumerics with only `_`, `-`, or `.` separators"
307 )));
308 continue;
309 };
310 if !seen_keywords.insert(normalized.clone()) {
311 issues.push(QualityIssue::Error(format!(
312 "canonical_hex_key_material[{policy_index}] contains duplicate normalized keyword {normalized:?}"
313 )));
314 }
315 if !owned_keywords.contains(&normalized) {
316 issues.push(QualityIssue::Error(format!(
317 "canonical_hex_key_material[{policy_index}] keyword {keyword:?} must also appear in detector.keywords"
318 )));
319 }
320 for &length in &policy.lengths {
321 if !seen_pairs.insert((normalized.clone(), length)) {
322 issues.push(QualityIssue::Error(format!(
323 "canonical_hex_key_material repeats keyword {keyword:?} at length {length} across policies"
324 )));
325 }
326 }
327 }
328 let mut seen_suffixes = std::collections::HashSet::new();
329 for suffix in &policy.suffixes {
330 let Some(normalized) = normalize_detector_keyword(suffix) else {
331 issues.push(QualityIssue::Error(format!(
332 "canonical_hex_key_material[{policy_index}] suffix {suffix:?} must contain ASCII alphanumerics with only `_`, `-`, or `.` separators"
333 )));
334 continue;
335 };
336 if normalized.is_empty() {
337 issues.push(QualityIssue::Error(format!(
338 "canonical_hex_key_material[{policy_index}] suffix {suffix:?} must not be empty"
339 )));
340 }
341 if !seen_suffixes.insert(normalized) {
342 issues.push(QualityIssue::Error(format!(
343 "canonical_hex_key_material[{policy_index}] contains duplicate normalized suffix {suffix:?}"
344 )));
345 }
346 }
347 let mut seen_exclusions = std::collections::HashSet::new();
348 for excluded in &policy.excluded_keywords {
349 let Some(normalized) = normalize_detector_keyword(excluded) else {
350 issues.push(QualityIssue::Error(format!(
351 "canonical_hex_key_material[{policy_index}] excluded keyword {excluded:?} must contain ASCII alphanumerics with only `_`, `-`, or `.` separators"
352 )));
353 continue;
354 };
355 if !seen_exclusions.insert(normalized) {
356 issues.push(QualityIssue::Error(format!(
357 "canonical_hex_key_material[{policy_index}] contains duplicate excluded keyword {excluded:?}"
358 )));
359 }
360 }
361 }
362}
363
364fn normalize_detector_keyword(keyword: &str) -> Option<String> {
365 let mut normalized = String::with_capacity(keyword.len());
366 for byte in keyword.bytes() {
367 if byte.is_ascii_alphanumeric() {
368 normalized.push(byte.to_ascii_lowercase() as char);
369 } else if !matches!(byte, b'_' | b'-' | b'.') {
370 return None;
371 }
372 }
373 (!normalized.is_empty()).then_some(normalized)
374}
375
376fn validate_simdsieve_prefixes(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
377 let mut seen = std::collections::HashSet::new();
378 for (index, prefix) in spec.simdsieve_prefixes.iter().enumerate() {
379 if prefix.is_empty() {
380 issues.push(QualityIssue::Error(format!(
381 "simdsieve_prefixes[{index}] must not be empty"
382 )));
383 } else if !prefix.is_ascii() {
384 issues.push(QualityIssue::Error(format!(
385 "simdsieve_prefixes[{index}] must be ASCII because simdsieve performs byte-prefix matching"
386 )));
387 }
388 if !seen.insert(prefix) {
389 issues.push(QualityIssue::Error(format!(
390 "simdsieve_prefixes contains duplicate literal {prefix:?}"
391 )));
392 }
393 }
394}
395
396fn validate_thresholds(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
403 if !(0.0..=1.0).contains(&spec.ml.weight) {
404 issues.push(QualityIssue::Error(format!(
405 "ml.weight {} is out of range; detector model weight must be finite and in [0.0, 1.0]",
406 spec.ml.weight
407 )));
408 }
409 if spec.ml.context_radius_lines > 64 {
410 issues.push(QualityIssue::Error(format!(
411 "ml.context_radius_lines {} exceeds the bounded maximum of 64",
412 spec.ml.context_radius_lines
413 )));
414 }
415 let owns_entropy = spec.owns_entropy_policy();
416 match spec.match_confidence {
417 None => issues.push(QualityIssue::Error(
418 "detector must declare match_confidence; scanner-wide match scoring defaults are not permitted"
419 .into(),
420 )),
421 Some(confidence) => {
422 if let Err(error) = confidence.validate() {
423 issues.push(QualityIssue::Error(format!(
424 "match_confidence is invalid: {error}"
425 )));
426 }
427 if owns_entropy {
428 if confidence.named_anchor_floor.is_some() {
429 issues.push(QualityIssue::Error(
430 "generic entropy owners must omit match_confidence.named_anchor_floor because their regex candidates do not receive the named-detector lift"
431 .into(),
432 ));
433 }
434 if confidence.low_promise_confidence.is_none() {
435 issues.push(QualityIssue::Error(
436 "generic entropy owners must declare match_confidence.low_promise_confidence"
437 .into(),
438 ));
439 }
440 } else {
441 if confidence.named_anchor_floor.is_none() {
442 issues.push(QualityIssue::Error(
443 "named detectors must declare match_confidence.named_anchor_floor"
444 .into(),
445 ));
446 }
447 if confidence.low_promise_confidence.is_some() {
448 issues.push(QualityIssue::Error(
449 "named detectors must omit match_confidence.low_promise_confidence because the promise gate cannot replace service-owned evidence"
450 .into(),
451 ));
452 }
453 }
454 }
455 }
456 if owns_entropy && spec.ml.entropy_mode == crate::DetectorMlMode::Disabled {
457 issues.push(QualityIssue::Error(
458 "an active entropy-policy owner must declare a non-disabled ml.entropy_mode"
459 .to_string(),
460 ));
461 }
462 if !owns_entropy && spec.ml.entropy_mode != crate::DetectorMlMode::Disabled {
463 issues.push(QualityIssue::Error(
464 "ml.entropy_mode is only valid for a detector that owns entropy policy".to_string(),
465 ));
466 }
467 for (name, value) in [
468 ("min_len", spec.min_len),
469 ("max_len", spec.max_len),
470 ("keyword_free_min_len", spec.keyword_free_min_len),
471 ] {
472 if value == Some(0) {
473 issues.push(QualityIssue::Error(format!(
474 "{name} must be greater than 0 when present; use omission to inherit the path default"
475 )));
476 }
477 }
478 if let (Some(min_len), Some(max_len)) = (spec.min_len, spec.max_len) {
479 if min_len > max_len {
480 issues.push(QualityIssue::Error(format!(
481 "min_len {min_len} exceeds max_len {max_len}"
482 )));
483 }
484 }
485 if spec.max_len.is_some_and(|max_len| max_len < 8) {
486 issues.push(QualityIssue::Error(
487 "max_len must be at least the generic assignment path minimum of 8".to_string(),
488 ));
489 }
490 if spec.max_len.is_some() && !spec.owns_entropy_policy() {
491 issues.push(QualityIssue::Error(
492 "max_len is only valid for detectors that own generic entropy policy".to_string(),
493 ));
494 }
495 if let Some(mc) = spec.min_confidence {
496 if !(0.0..=1.0).contains(&mc) {
497 issues.push(QualityIssue::Error(format!(
498 "min_confidence {mc} is out of range; confidence is a probability in [0.0, 1.0] \
499 (outside it silently breaks the gate: < 0 always passes, > 1 never fires, NaN is undefined)"
500 )));
501 }
502 }
503 if let Some(bound) = spec.bpe_max_bytes_per_token {
504 if !bound.is_finite() || bound <= 0.0 {
505 issues.push(QualityIssue::Error(format!(
506 "bpe_max_bytes_per_token {bound} must be finite and greater than 0; \
507 zero or a negative value suppresses every candidate and NaN/inf makes the gate undefined"
508 )));
509 }
510 }
511 if spec.bpe_enabled == Some(false) && spec.bpe_max_bytes_per_token.is_some() {
512 issues.push(QualityIssue::Error(
513 "bpe_enabled = false conflicts with bpe_max_bytes_per_token; remove the ceiling when token efficiency is disabled"
514 .into(),
515 ));
516 }
517 if !spec.entropy_roles.is_empty() && !spec.owns_entropy_policy() {
518 issues.push(QualityIssue::Error(
519 "entropy_roles require a detector that owns a complete entropy policy".into(),
520 ));
521 }
522 let mut entropy_roles = std::collections::HashSet::new();
523 for role in &spec.entropy_roles {
524 if !entropy_roles.insert(*role) {
525 issues.push(QualityIssue::Error(format!(
526 "entropy_roles contains duplicate role {:?}",
527 role.as_str()
528 )));
529 }
530 }
531 for (name, value) in [
532 ("entropy_high", spec.entropy_high),
533 ("entropy_low", spec.entropy_low),
534 ("entropy_very_high", spec.entropy_very_high),
535 (
536 "sensitive_path_entropy_very_high",
537 spec.sensitive_path_entropy_very_high,
538 ),
539 ] {
540 let Some(score) = value else {
541 continue;
542 };
543 if !score.is_finite() || !(0.0..=8.0).contains(&score) {
544 issues.push(QualityIssue::Error(format!(
545 "{name} must be a finite Shannon entropy score in [0.0, 8.0], found {score}"
546 )));
547 }
548 }
549 if let (Some(low), Some(high)) = (spec.entropy_low, spec.entropy_high) {
550 if low > high {
551 issues.push(QualityIssue::Error(format!(
552 "entropy_low {low} must not exceed entropy_high {high}"
553 )));
554 }
555 }
556 if let (Some(high), Some(very_high)) = (spec.entropy_high, spec.entropy_very_high) {
557 if high > very_high {
558 issues.push(QualityIssue::Error(format!(
559 "entropy_high {high} must not exceed entropy_very_high {very_high}"
560 )));
561 }
562 }
563 if let Some(plausibility) = spec.plausibility {
564 for (name, score) in [
565 (
566 "plausibility.mixed_alnum_floor",
567 plausibility.mixed_alnum_floor,
568 ),
569 (
570 "plausibility.symbolic_entropy_floor",
571 plausibility.symbolic_entropy_floor,
572 ),
573 (
574 "plausibility.second_half_entropy_floor",
575 plausibility.second_half_entropy_floor,
576 ),
577 (
578 "plausibility.isolated_mixed_entropy_floor",
579 plausibility.isolated_mixed_entropy_floor,
580 ),
581 (
582 "plausibility.leading_slash_base64_entropy_floor",
583 plausibility.leading_slash_base64_entropy_floor,
584 ),
585 ] {
586 if !score.is_finite() || !(0.0..=8.0).contains(&score) {
587 issues.push(QualityIssue::Error(format!(
588 "{name} must be a finite Shannon entropy score in [0.0, 8.0], found {score}"
589 )));
590 }
591 }
592 if let Some(margin) = plausibility.keyword_free_operator_margin {
593 if !margin.is_finite() || !(0.0..=8.0).contains(&margin) {
594 issues.push(QualityIssue::Error(format!(
595 "plausibility.keyword_free_operator_margin must be finite and in [0.0, 8.0], found {margin}"
596 )));
597 }
598 }
599 if plausibility.mixed_alnum_min_len == 0 {
600 issues.push(QualityIssue::Error(
601 "plausibility.mixed_alnum_min_len must be greater than zero".into(),
602 ));
603 }
604 for (name, length) in [
605 (
606 "plausibility.second_half_min_len",
607 plausibility.second_half_min_len,
608 ),
609 (
610 "plausibility.unique_chars_min_len",
611 plausibility.unique_chars_min_len,
612 ),
613 (
614 "plausibility.min_unique_chars",
615 plausibility.min_unique_chars,
616 ),
617 (
618 "plausibility.unanchored_hex_max_len",
619 plausibility.unanchored_hex_max_len,
620 ),
621 (
622 "plausibility.identical_char_max_len",
623 plausibility.identical_char_max_len,
624 ),
625 (
626 "plausibility.structured_dotted_min_len",
627 plausibility.structured_dotted_min_len,
628 ),
629 (
630 "plausibility.isolated_symbolic_min_len",
631 plausibility.isolated_symbolic_min_len,
632 ),
633 (
634 "plausibility.isolated_symbolic_min_symbols",
635 plausibility.isolated_symbolic_min_symbols,
636 ),
637 (
638 "plausibility.isolated_alpha_only_min_symbols",
639 plausibility.isolated_alpha_only_min_symbols,
640 ),
641 (
642 "plausibility.source_type_name_max_len",
643 plausibility.source_type_name_max_len,
644 ),
645 (
646 "plausibility.source_type_name_min_uppercase",
647 plausibility.source_type_name_min_uppercase,
648 ),
649 (
650 "plausibility.url_path_high_entropy_min_len",
651 plausibility.url_path_high_entropy_min_len,
652 ),
653 (
654 "plausibility.isolated_colon_left_min_len",
655 plausibility.isolated_colon_left_min_len,
656 ),
657 (
658 "plausibility.isolated_colon_right_min_len",
659 plausibility.isolated_colon_right_min_len,
660 ),
661 (
662 "plausibility.leading_slash_base64_min_len",
663 plausibility.leading_slash_base64_min_len,
664 ),
665 ] {
666 if length == 0 {
667 issues.push(QualityIssue::Error(format!(
668 "{name} must be greater than zero"
669 )));
670 }
671 }
672 if !plausibility.isolated_alpha_only_min_alpha_ratio.is_finite()
673 || !(0.0..=1.0).contains(&plausibility.isolated_alpha_only_min_alpha_ratio)
674 || plausibility.isolated_alpha_only_min_alpha_ratio == 0.0
675 {
676 issues.push(QualityIssue::Error(format!(
677 "plausibility.isolated_alpha_only_min_alpha_ratio must be finite and in (0.0, 1.0], found {}",
678 plausibility.isolated_alpha_only_min_alpha_ratio
679 )));
680 }
681 if !plausibility.min_alnum_ratio.is_finite()
682 || !(0.0..=1.0).contains(&plausibility.min_alnum_ratio)
683 || plausibility.min_alnum_ratio == 0.0
684 {
685 issues.push(QualityIssue::Error(format!(
686 "plausibility.min_alnum_ratio must be finite and in (0.0, 1.0], found {}",
687 plausibility.min_alnum_ratio
688 )));
689 }
690 if plausibility.source_type_name_min_uppercase > plausibility.source_type_name_max_len {
691 issues.push(QualityIssue::Error(format!(
692 "plausibility.source_type_name_min_uppercase ({}) must not exceed plausibility.source_type_name_max_len ({})",
693 plausibility.source_type_name_min_uppercase,
694 plausibility.source_type_name_max_len
695 )));
696 }
697 if plausibility.min_unique_chars > plausibility.unique_chars_min_len {
698 issues.push(QualityIssue::Error(format!(
699 "plausibility.min_unique_chars ({}) must not exceed plausibility.unique_chars_min_len ({})",
700 plausibility.min_unique_chars, plausibility.unique_chars_min_len
701 )));
702 }
703 }
704 if let (Some(very_high), Some(sensitive)) = (
705 spec.entropy_very_high,
706 spec.sensitive_path_entropy_very_high,
707 ) {
708 if sensitive > very_high {
709 issues.push(QualityIssue::Error(format!(
710 "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"
711 )));
712 }
713 }
714 let entropy_owner = spec.owns_entropy_policy();
715 let has_weak_pattern = spec.patterns.iter().any(|pattern| pattern.weak_anchor);
716 if spec.weak_anchor && has_weak_pattern {
717 issues.push(QualityIssue::Error(
718 "detector weak_anchor=true already applies to every pattern; remove redundant pattern weak_anchor flags"
719 .into(),
720 ));
721 }
722 if spec.weak_anchor || has_weak_pattern {
723 if spec.entropy_high.is_none() {
724 issues.push(QualityIssue::Error(
725 "weak_anchor detectors and patterns must declare entropy_high in their own detector TOML".into(),
726 ));
727 }
728 if spec.entropy_floor.is_empty() {
729 issues.push(QualityIssue::Error(
730 "weak_anchor detectors and patterns must declare entropy_floor in their own detector TOML"
731 .into(),
732 ));
733 }
734 }
735 if entropy_owner {
736 for (field, present) in [
737 ("entropy_high", spec.entropy_high.is_some()),
738 ("entropy_low", spec.entropy_low.is_some()),
739 ("entropy_very_high", spec.entropy_very_high.is_some()),
740 (
741 "sensitive_path_entropy_very_high",
742 spec.sensitive_path_entropy_very_high.is_some(),
743 ),
744 ("[detector.plausibility]", spec.plausibility.is_some()),
745 ("keyword_free_min_len", spec.keyword_free_min_len.is_some()),
746 ("min_len", spec.min_len.is_some()),
747 ("max_len", spec.max_len.is_some()),
748 (
749 "entropy_policy_priority",
750 spec.entropy_policy_priority.is_some(),
751 ),
752 ] {
753 if !present {
754 issues.push(QualityIssue::Error(format!(
755 "active entropy owner must declare {field} in its detector TOML; runtime fallback policy is forbidden"
756 )));
757 }
758 }
759 if spec.entropy_shapes.is_empty() {
760 issues.push(QualityIssue::Error(
761 "active entropy owner must declare detector.entropy_shapes in its detector TOML"
762 .into(),
763 ));
764 }
765 if spec.entropy_floor.is_empty() {
766 issues.push(QualityIssue::Error(
767 "active entropy owner must declare entropy_floor in its detector TOML".into(),
768 ));
769 }
770 if spec.bpe_enabled.is_none() {
771 issues.push(QualityIssue::Error(
772 "active entropy owner must declare bpe_enabled in its detector TOML".into(),
773 ));
774 }
775 if spec.bpe_enabled != Some(false) && spec.bpe_max_bytes_per_token.is_none() {
776 issues.push(QualityIssue::Error(
777 "active entropy owner must declare bpe_max_bytes_per_token or bpe_enabled = false in its detector TOML"
778 .into(),
779 ));
780 }
781 }
782 let owns_keyword_free = spec
783 .entropy_roles
784 .contains(&crate::EntropyDetectionRole::KeywordFree);
785 let keyword_free_operator_margin = spec
786 .plausibility
787 .and_then(|policy| policy.keyword_free_operator_margin);
788 match (owns_keyword_free, keyword_free_operator_margin) {
789 (true, None) => issues.push(QualityIssue::Error(
790 "the detector claiming entropy role `keyword-free` must declare plausibility.keyword_free_operator_margin"
791 .into(),
792 )),
793 (false, Some(_)) => issues.push(QualityIssue::Error(
794 "plausibility.keyword_free_operator_margin is valid only on the detector claiming entropy role `keyword-free`"
795 .into(),
796 )),
797 _ => {}
798 }
799 if entropy_owner && spec.entropy_fallback.is_none() {
800 issues.push(QualityIssue::Error(
801 "active entropy owner must declare entropy_fallback metadata; omission would make synthetic finding identity ambiguous".into(),
802 ));
803 }
804 if entropy_owner && spec.entropy_fallback_confidence.is_none() {
805 issues.push(QualityIssue::Error(
806 "active entropy owner must declare entropy_fallback_confidence; omission would leave detector confidence in scanner literals".into(),
807 ));
808 }
809 if entropy_owner && spec.generic_assignment_confidence.is_none() {
810 issues.push(QualityIssue::Error(
811 "active entropy owner must declare generic_assignment_confidence; omission would leave generic assignment scoring in scanner literals".into(),
812 ));
813 }
814 if let Some(confidence) = spec.entropy_fallback_confidence {
815 if !entropy_owner {
816 issues.push(QualityIssue::Error(
817 "entropy_fallback_confidence requires an active detector-owned entropy policy"
818 .into(),
819 ));
820 }
821 if let Err(error) = confidence.validate() {
822 issues.push(QualityIssue::Error(format!(
823 "entropy_fallback_confidence is invalid: {error}"
824 )));
825 }
826 }
827 if let Some(confidence) = spec.generic_assignment_confidence {
828 if !entropy_owner {
829 issues.push(QualityIssue::Error(
830 "generic_assignment_confidence requires an active detector-owned entropy policy"
831 .into(),
832 ));
833 }
834 if let Err(error) = confidence.validate() {
835 issues.push(QualityIssue::Error(format!(
836 "generic_assignment_confidence is invalid: {error}"
837 )));
838 }
839 }
840 if let Some(metadata) = &spec.entropy_fallback {
841 if !entropy_owner {
842 issues.push(QualityIssue::Error(
843 "entropy_fallback requires an active detector-owned entropy policy".into(),
844 ));
845 }
846 if !metadata.id.strip_prefix("entropy-").is_some_and(|suffix| {
847 !suffix.is_empty()
848 && suffix
849 .bytes()
850 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
851 }) {
852 issues.push(QualityIssue::Error(format!(
853 "entropy_fallback.id {:?} must use a lowercase entropy- namespace id",
854 metadata.id
855 )));
856 }
857 if metadata.name.trim().is_empty() {
858 issues.push(QualityIssue::Error(
859 "entropy_fallback.name must not be empty".into(),
860 ));
861 }
862 if metadata.service.trim().is_empty() {
863 issues.push(QualityIssue::Error(
864 "entropy_fallback.service must not be empty".into(),
865 ));
866 }
867 }
868 if !spec.entropy_shapes.is_empty() && !entropy_owner {
869 issues.push(QualityIssue::Error(
870 "entropy_shapes require an active detector-owned entropy policy".into(),
871 ));
872 }
873 if spec.entropy_shapes.len() > 1 {
874 issues.push(QualityIssue::Error(format!(
875 "active entropy policy accepts exactly one detector.entropy_shapes entry, found {}",
876 spec.entropy_shapes.len()
877 )));
878 }
879 let mut shape_signatures: Vec<(crate::spec::ShapeCharset, Option<(usize, usize, char)>)> =
880 Vec::new();
881 for (index, shape) in spec.entropy_shapes.iter().enumerate() {
882 let signature = (
883 shape.charset,
884 shape
885 .grouping
886 .map(|g| (g.group_count, g.group_length, g.separator)),
887 );
888 if shape_signatures.contains(&signature) {
889 issues.push(QualityIssue::Error(format!(
890 "entropy_shapes[{index}] duplicates an earlier shape's charset and grouping"
891 )));
892 }
893 shape_signatures.push(signature);
894 if !shape.entropy_floor.is_finite() || !(0.0..=8.0).contains(&shape.entropy_floor) {
895 issues.push(QualityIssue::Error(format!(
896 "entropy_shapes[{index}].entropy_floor must be finite and in [0.0, 8.0], found {}",
897 shape.entropy_floor
898 )));
899 }
900 if shape.special_min_length == 0 {
901 issues.push(QualityIssue::Error(format!(
902 "entropy_shapes[{index}].special_min_length must be greater than 0"
903 )));
904 }
905 if shape.require_mixed_case && shape.charset == crate::spec::ShapeCharset::LowerAlnum {
906 issues.push(QualityIssue::Error(format!(
907 "entropy_shapes[{index}].require_mixed_case is impossible with charset lower-alnum"
908 )));
909 }
910 if shape.require_non_hex_alpha && shape.charset == crate::spec::ShapeCharset::Hex {
911 issues.push(QualityIssue::Error(format!(
912 "entropy_shapes[{index}].require_non_hex_alpha is impossible with charset hex"
913 )));
914 }
915 if shape.require_group_alpha_digit && shape.grouping.is_none() {
916 issues.push(QualityIssue::Error(format!(
917 "entropy_shapes[{index}].require_group_alpha_digit requires grouping"
918 )));
919 }
920 if let Some(grouping) = shape.grouping {
921 if grouping.group_count == 0 || grouping.group_length == 0 {
922 issues.push(QualityIssue::Error(format!(
923 "entropy_shapes[{index}] grouping.group_count and group_length must both be greater than 0"
924 )));
925 continue;
926 }
927 let derived_length = grouping
928 .group_count
929 .checked_mul(grouping.group_length)
930 .and_then(|length| {
931 length.checked_add(
932 grouping
933 .group_count
934 .saturating_sub(1)
935 .saturating_mul(grouping.separator.len_utf8()),
936 )
937 });
938 let Some(derived_length) = derived_length else {
939 issues.push(QualityIssue::Error(format!(
940 "entropy_shapes[{index}] grouping overflows the derived candidate length"
941 )));
942 continue;
943 };
944 if shape.special_min_length > derived_length {
945 issues.push(QualityIssue::Error(format!(
946 "entropy_shapes[{index}].special_min_length must be in 1..={derived_length}, found {}",
947 shape.special_min_length
948 )));
949 }
950 }
951 }
952}
953
954fn validate_entropy_floor(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
955 if spec.entropy_floor.is_empty() {
956 return;
957 }
958 let last = spec.entropy_floor.len() - 1;
959 let mut previous_max = 0usize;
960 for (index, bucket) in spec.entropy_floor.iter().enumerate() {
961 if !bucket.floor.is_finite() || !(0.0..=8.0).contains(&bucket.floor) {
962 issues.push(QualityIssue::Error(format!(
963 "entropy_floor bucket {index} floor must be finite and in [0.0, 8.0], found {}",
964 bucket.floor
965 )));
966 }
967 if index < last && bucket.max_len.is_none() {
968 issues.push(QualityIssue::Error(format!(
969 "entropy_floor bucket {index} is an early catch-all; only the final bucket may omit max_len"
970 )));
971 }
972 if index == last && bucket.max_len.is_some() {
973 issues.push(QualityIssue::Error(
974 "entropy_floor final bucket must omit max_len so longer candidates cannot bypass the floor"
975 .into(),
976 ));
977 }
978 if let Some(max_len) = bucket.max_len {
979 if max_len <= previous_max {
980 issues.push(QualityIssue::Error(format!(
981 "entropy_floor max_len values must strictly increase from a positive length; found {max_len} after {previous_max}"
982 )));
983 }
984 previous_max = max_len;
985 }
986 }
987}
988
989fn validate_credential_shape(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
990 if let Some(shape) = &spec.credential_shape {
991 if let Err(error) = shape.validate(&spec.id) {
992 issues.push(QualityIssue::Error(error));
993 }
994 }
995}
996
997fn validate_detector_allowlists(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
998 for (field, patterns) in [
999 ("allowlist_paths", &spec.allowlist_paths),
1000 ("allowlist_values", &spec.allowlist_values),
1001 ] {
1002 let mut first_index_by_pattern = HashMap::new();
1003 for (index, pattern) in patterns.iter().enumerate() {
1004 if pattern.trim().is_empty() {
1005 issues.push(QualityIssue::Error(format!(
1006 "detector {:?} {field}[{index}] must not be empty or whitespace-only",
1007 spec.id
1008 )));
1009 continue;
1010 }
1011 match first_index_by_pattern.entry(pattern.as_str()) {
1012 Entry::Occupied(first) => issues.push(QualityIssue::Error(format!(
1013 "detector {:?} {field}[{index}] duplicates {field}[{}]",
1014 spec.id,
1015 first.get()
1016 ))),
1017 Entry::Vacant(slot) => {
1018 slot.insert(index);
1019 }
1020 }
1021 if let Err(error) = regex::Regex::new(pattern) {
1022 issues.push(QualityIssue::Error(format!(
1023 "detector {:?} {field}[{index}] is not a valid regex ({pattern:?}): {error}",
1024 spec.id
1025 )));
1026 }
1027 }
1028 }
1029
1030 let mut first_index_by_stopword = HashMap::new();
1031 for (index, stopword) in spec.stopwords.iter().enumerate() {
1032 if stopword.trim().is_empty() {
1033 issues.push(QualityIssue::Error(format!(
1034 "detector {:?} stopwords[{index}] must not be empty or whitespace-only",
1035 spec.id
1036 )));
1037 continue;
1038 }
1039 let normalized = stopword.to_ascii_lowercase();
1040 match first_index_by_stopword.entry(normalized) {
1041 Entry::Occupied(first) => issues.push(QualityIssue::Error(format!(
1042 "detector {:?} stopwords[{index}] duplicates stopwords[{}] under case-insensitive matching",
1043 spec.id,
1044 first.get()
1045 ))),
1046 Entry::Vacant(slot) => {
1047 slot.insert(index);
1048 }
1049 }
1050 }
1051 let mut first_marker_index = HashMap::new();
1052 for (index, marker) in spec.public_identifier_assignment_markers.iter().enumerate() {
1053 if marker.is_empty()
1054 || !marker.is_ascii()
1055 || marker.bytes().any(|byte| byte.is_ascii_lowercase())
1056 {
1057 issues.push(QualityIssue::Error(format!(
1058 "detector {:?} public_identifier_assignment_markers[{index}] must be non-empty uppercase ASCII because runtime matching is allocation-free ASCII-insensitive",
1059 spec.id
1060 )));
1061 continue;
1062 }
1063 match first_marker_index.entry(marker.as_str()) {
1064 Entry::Occupied(first) => issues.push(QualityIssue::Error(format!(
1065 "detector {:?} public_identifier_assignment_markers[{index}] duplicates public_identifier_assignment_markers[{}]",
1066 spec.id,
1067 first.get()
1068 ))),
1069 Entry::Vacant(slot) => {
1070 slot.insert(index);
1071 }
1072 }
1073 }
1074}
1075
1076fn validate_patterns_present(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
1077 match spec.kind {
1078 DetectorKind::Regex => {
1080 if spec.patterns.is_empty() {
1081 issues.push(QualityIssue::Error("no patterns defined".into()));
1082 }
1083 }
1084 DetectorKind::Phase2Generic => {
1089 if spec.keywords.is_empty() {
1090 issues.push(QualityIssue::Error(
1091 "phase2-generic detector must define keywords (its only pre-filter)".into(),
1092 ));
1093 }
1094 }
1095 }
1096}
1097
1098fn validate_regexes<'a>(
1099 spec: &'a DetectorSpec,
1100 issues: &mut Vec<QualityIssue>,
1101 regex_cache: &mut RegexAstCache<'a>,
1102) {
1103 for (i, pat) in spec.patterns.iter().enumerate() {
1104 validate_regex_definition(RegexKind::Pattern, i, &pat.regex, issues, regex_cache);
1105 }
1106}
1107
1108fn validate_keywords(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
1109 if spec.keywords.is_empty() {
1110 issues.push(QualityIssue::Warning(
1111 "no keywords defined - pattern may produce false positives".into(),
1112 ));
1113 return;
1114 }
1115 for (index, keyword) in spec.keywords.iter().enumerate() {
1116 if keyword.is_empty() {
1117 issues.push(QualityIssue::Error(format!(
1118 "keyword {index} is empty; remove it or declare a non-empty detector-owned context literal"
1119 )));
1120 }
1121 }
1122}
1123
1124fn validate_pattern_groups<'a>(
1125 spec: &'a DetectorSpec,
1126 issues: &mut Vec<QualityIssue>,
1127 regex_cache: &mut RegexAstCache<'a>,
1128) {
1129 for (i, pat) in spec.patterns.iter().enumerate() {
1130 let Some(group) = pat.group else {
1131 continue;
1132 };
1133 let Ok(ast) = regex_cache.parse(&pat.regex) else {
1134 continue; };
1136 let captures = ast_captures_len(ast);
1137 if group >= captures {
1138 issues.push(QualityIssue::Error(format!(
1139 "pattern {i} capture group {group} is out of range; regex has {} capture groups \
1140 (valid group indexes are 0..{})",
1141 captures.saturating_sub(1),
1142 captures.saturating_sub(1)
1143 )));
1144 }
1145 }
1146}
1147
1148fn validate_pattern_specificity<'a>(
1149 spec: &'a DetectorSpec,
1150 issues: &mut Vec<QualityIssue>,
1151 regex_cache: &mut RegexAstCache<'a>,
1152) {
1153 for (i, pat) in spec.patterns.iter().enumerate() {
1154 let has_prefix = has_literal_prefix(regex_cache, &pat.regex, 3);
1155 let has_group = pat.group.is_some();
1156 let is_pure_charclass = is_pure_character_class(regex_cache, &pat.regex);
1157
1158 if is_pure_charclass && !has_group {
1159 issues.push(QualityIssue::Error(format!(
1160 "pattern {} is a pure character class ({}) - too broad without context anchoring. \
1161 Use a capture group or add a literal prefix.",
1162 i, pat.regex
1163 )));
1164 } else if !has_prefix && !has_group && spec.keywords.is_empty() {
1165 issues.push(QualityIssue::Warning(format!(
1166 "pattern {} has no literal prefix and no capture group - may false-positive",
1167 i
1168 )));
1169 }
1170 }
1171}
1172
1173fn validate_companions<'a>(
1174 spec: &'a DetectorSpec,
1175 issues: &mut Vec<QualityIssue>,
1176 regex_cache: &mut RegexAstCache<'a>,
1177) {
1178 for (i, companion) in spec.companions.iter().enumerate() {
1179 if companion.name.trim().is_empty() {
1180 issues.push(QualityIssue::Error(format!(
1181 "companion {} name must not be empty",
1182 i
1183 )));
1184 }
1185 if companion.within_lines > MAX_COMPANION_WITHIN_LINES {
1186 issues.push(QualityIssue::Error(format!(
1187 "companion {} within_lines={} exceeds {} search-window limit",
1188 i, companion.within_lines, MAX_COMPANION_WITHIN_LINES
1189 )));
1190 }
1191 validate_regex_definition(
1192 RegexKind::Companion,
1193 i,
1194 &companion.regex,
1195 issues,
1196 regex_cache,
1197 );
1198 if is_pure_character_class(regex_cache, &companion.regex) {
1204 if companion.within_lines <= TIGHT_COMPANION_RADIUS {
1205 issues.push(QualityIssue::Warning(format!(
1206 "companion {} regex '{}' is a pure character class; \
1207 allowed because within_lines={} ≤ {} (positional anchoring).",
1208 i, companion.regex, companion.within_lines, TIGHT_COMPANION_RADIUS
1209 )));
1210 } else {
1211 issues.push(QualityIssue::Error(format!(
1212 "companion {} regex '{}' is a pure character class with within_lines={} \
1213 (> {}) - the wide search radius needs a literal context anchor",
1214 i, companion.regex, companion.within_lines, TIGHT_COMPANION_RADIUS
1215 )));
1216 }
1217 } else if !has_substantial_literal(regex_cache, &companion.regex, 3) {
1218 issues.push(QualityIssue::Warning(format!(
1219 "companion {} regex '{}' is too broad - may produce false positives. \
1220 Add a context anchor like 'KEY_NAME='.",
1221 i, companion.regex
1222 )));
1223 }
1224 }
1225}
1226
1227const TIGHT_COMPANION_RADIUS: usize = 5;
1230
1231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1232enum RegexKind {
1233 Pattern,
1234 Companion,
1235}
1236
1237impl RegexKind {
1238 fn label(self) -> &'static str {
1239 match self {
1240 Self::Pattern => "pattern",
1241 Self::Companion => "companion",
1242 }
1243 }
1244}
1245
1246#[derive(Default)]
1247struct RegexAstCache<'a> {
1248 parsed: HashMap<&'a str, Result<ast::Ast, String>>,
1249}
1250
1251impl<'a> RegexAstCache<'a> {
1252 fn parse(&mut self, regex: &'a str) -> Result<&ast::Ast, &str> {
1253 let parsed = match self.parsed.entry(regex) {
1254 Entry::Occupied(entry) => entry.into_mut(),
1255 Entry::Vacant(entry) => entry.insert(
1256 ast::parse::Parser::new()
1257 .parse(regex)
1258 .map_err(|error| error.to_string()),
1259 ),
1260 };
1261 parsed.as_ref().map_err(String::as_str)
1262 }
1263}
1264
1265fn validate_regex_definition<'a>(
1266 kind: RegexKind,
1267 index: usize,
1268 regex: &'a str,
1269 issues: &mut Vec<QualityIssue>,
1270 regex_cache: &mut RegexAstCache<'a>,
1271) {
1272 let kind = kind.label();
1273 if regex.is_empty() {
1278 issues.push(QualityIssue::Error(format!(
1279 "{kind} {index} regex is empty; an empty pattern matches at every position \
1280 (a catastrophic false-positive flood), define a real anchor or remove the pattern"
1281 )));
1282 return;
1283 }
1284 if regex.len() > MAX_REGEX_PATTERN_LEN {
1285 issues.push(QualityIssue::Error(format!(
1286 "{kind} {index} regex is too large ({} bytes > {} byte limit)",
1287 regex.len(),
1288 MAX_REGEX_PATTERN_LEN
1289 )));
1290 return;
1291 }
1292
1293 match regex_cache.parse(regex) {
1294 Ok(ast) => validate_regex_complexity(kind, index, ast, issues),
1295 Err(error) => issues.push(QualityIssue::Error(format!(
1296 "{kind} {index} regex does not compile: {error}"
1297 ))),
1298 }
1299}
1300
1301fn has_substantial_literal<'a>(
1302 regex_cache: &mut RegexAstCache<'a>,
1303 pattern: &'a str,
1304 min_len: usize,
1305) -> bool {
1306 match regex_cache.parse(pattern) {
1307 Ok(ast) => ast_literal_runs(ast).max >= min_len,
1308 Err(_) => false, }
1310}
1311
1312fn validate_verify_spec(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
1313 if let Some(ref verify) = spec.verify {
1314 validate_verify_urls(spec, verify, issues);
1315 validate_verify_success_statuses(verify, issues);
1316 validate_provider_evidence(verify, issues);
1317 issues.extend(
1318 crate::json_selector::validate_detector_response_selectors(spec)
1319 .into_iter()
1320 .map(QualityIssue::Error),
1321 );
1322 check_oob_consistency(verify, issues);
1323 }
1324 check_reserved_companion_names(spec, issues);
1325}
1326
1327fn validate_provider_evidence(verify: &VerifySpec, issues: &mut Vec<QualityIssue>) {
1328 let mut roles = std::collections::HashSet::new();
1329 for (index, field) in verify.metadata.iter().enumerate() {
1330 let Some(role) = super::ProviderEvidenceRole::from_metadata_name(&field.name) else {
1331 issues.push(QualityIssue::Error(format!(
1332 "verify.metadata[{index}].name {:?} is not a supported provider evidence role; use a reviewed provider-neutral role such as account_id, email, scope, team_id, or user_id",
1333 field.name
1334 )));
1335 continue;
1336 };
1337 if !roles.insert(role) {
1338 issues.push(QualityIssue::Error(format!(
1339 "verify.metadata[{index}] repeats provider evidence role {:?}; each report role must have one detector-owned selector",
1340 role.as_str()
1341 )));
1342 }
1343 }
1344}
1345
1346fn validate_verify_success_statuses(verify: &VerifySpec, issues: &mut Vec<QualityIssue>) {
1347 if let Some(success) = &verify.success {
1348 validate_success_status("verify.success", success, issues);
1349 }
1350 for (step_index, step) in verify.steps.iter().enumerate() {
1351 validate_success_status(
1352 &format!("verify.steps[{step_index}].success"),
1353 &step.success,
1354 issues,
1355 );
1356 }
1357}
1358
1359fn validate_success_status(
1360 scope: &str,
1361 success: &super::SuccessSpec,
1362 issues: &mut Vec<QualityIssue>,
1363) {
1364 validate_http_status(scope, "status", success.status, issues);
1365 validate_http_status(scope, "status_not", success.status_not, issues);
1366}
1367
1368fn validate_http_status(
1369 scope: &str,
1370 field: &str,
1371 status: Option<u16>,
1372 issues: &mut Vec<QualityIssue>,
1373) {
1374 let Some(status) = status else {
1375 return;
1376 };
1377 if !(MIN_HTTP_STATUS..=MAX_HTTP_STATUS).contains(&status) {
1378 issues.push(QualityIssue::Error(format!(
1379 "{scope}.{field}={status} is outside valid HTTP status range {MIN_HTTP_STATUS}..={MAX_HTTP_STATUS}"
1380 )));
1381 }
1382}
1383
1384fn validate_verify_urls(
1385 detector: &DetectorSpec,
1386 verify: &VerifySpec,
1387 issues: &mut Vec<QualityIssue>,
1388) {
1389 for (index, domain) in verify.allowed_domains.iter().enumerate() {
1390 if crate::verification_domain::normalize_allowlist_entry(domain).is_none() {
1391 issues.push(QualityIssue::Error(format!(
1392 "verify.allowed_domains[{index}] is not a bare domain or host-only URL: {domain:?}"
1393 )));
1394 }
1395 }
1396
1397 if verify.steps.is_empty() {
1398 if let Some(url) = verify.url.as_deref() {
1399 validate_selected_verify_url("verify.url", url, &detector.service, verify, issues);
1400 } else {
1401 issues.push(QualityIssue::Error(
1402 "verify spec has no steps and no default URL".into(),
1403 ));
1404 }
1405 } else {
1406 for (index, step) in verify.steps.iter().enumerate() {
1407 validate_selected_verify_url(
1408 &format!("verify.steps[{index}].url"),
1409 &step.url,
1410 &detector.service,
1411 verify,
1412 issues,
1413 );
1414 }
1415 }
1416}
1417
1418fn validate_selected_verify_url(
1419 field: &str,
1420 raw_url: &str,
1421 detector_service: &str,
1422 verify: &VerifySpec,
1423 issues: &mut Vec<QualityIssue>,
1424) {
1425 validate_url(raw_url, issues);
1426 check_url_exfil_risk(raw_url, &verify.allowed_domains, issues);
1427 if url_authority_is_templated(raw_url) {
1428 return;
1429 }
1430 let parsed = match url::Url::parse(raw_url) {
1431 Ok(parsed) => parsed,
1432 Err(error) => {
1433 issues.push(QualityIssue::Error(format!(
1434 "{field} is not a valid absolute URL: {error}"
1435 )));
1436 return;
1437 }
1438 };
1439 let Some(host) = parsed.host_str() else {
1440 issues.push(QualityIssue::Error(format!(
1441 "{field} has no host; use an absolute service URL"
1442 )));
1443 return;
1444 };
1445 let Some(allowlist) =
1446 crate::verification_domain::effective_allowlist(verify, Some(detector_service))
1447 else {
1448 issues.push(QualityIssue::Error(format!(
1449 "{field} host {host:?} has no domain policy; set verify.service to a known service or declare verify.allowed_domains"
1450 )));
1451 return;
1452 };
1453 if !crate::verification_domain::host_is_allowed(host, &allowlist) {
1454 let policy_service = if verify.service.trim().is_empty() {
1455 detector_service
1456 } else {
1457 verify.service.as_str()
1458 };
1459 issues.push(QualityIssue::Error(format!(
1460 "{field} host {host:?} is outside verify.allowed_domains for service {:?} (allowed: {})",
1461 policy_service,
1462 allowlist.join(", ")
1463 )));
1464 }
1465}
1466
1467fn url_authority_is_templated(raw_url: &str) -> bool {
1468 let trimmed = raw_url.trim();
1469 let authority = trimmed
1470 .split_once("://")
1471 .map_or(trimmed, |(_, remainder)| remainder)
1472 .split(['/', '?', '#'])
1473 .next()
1474 .unwrap_or_default(); authority.contains(['{', '}'])
1476}
1477
1478const RESERVED_COMPANION_NAMES: &[&str] =
1485 &["__keyhog_oob_url", "__keyhog_oob_host", "__keyhog_oob_id"];
1486
1487fn check_reserved_companion_names(spec: &DetectorSpec, issues: &mut Vec<QualityIssue>) {
1488 for (i, c) in spec.companions.iter().enumerate() {
1489 if RESERVED_COMPANION_NAMES.contains(&c.name.as_str()) {
1490 issues.push(QualityIssue::Error(format!(
1491 "companion {} name '{}' is reserved for the OOB interpolator. \
1492 Pick a different name; this collision would corrupt verify templates.",
1493 i, c.name,
1494 )));
1495 }
1496 }
1497}
1498
1499fn check_oob_consistency(verify: &VerifySpec, issues: &mut Vec<QualityIssue>) {
1512 let mut interactsh_referenced = false;
1513 visit_verify_template_fields(verify, |value| {
1514 if value.contains("{{interactsh") {
1515 interactsh_referenced = true;
1516 }
1517 });
1518 let oob_configured = verify.oob.is_some();
1519 if oob_configured && !verify.steps.is_empty() {
1520 issues.push(QualityIssue::Error(
1521 "verify.oob cannot be combined with multi-step verification: the \
1522 runtime must bind each interactsh callback to a concrete request \
1523 step, and this detector shape cannot be evaluated honestly. Use a \
1524 single request verifier for the OOB probe or split the detector."
1525 .into(),
1526 ));
1527 }
1528 match (oob_configured, interactsh_referenced) {
1529 (true, false) => issues.push(QualityIssue::Error(
1530 "verify.oob is set but no `{{interactsh}}` / `{{interactsh.host}}` / \
1531 `{{interactsh.url}}` / `{{interactsh.id}}` token appears in any verify \
1532 template - the OOB callback URL has nowhere to land, so the wait_for \
1533 would always time out. Either embed an interactsh token in the body, \
1534 URL, or a header - or remove the [detector.verify.oob] block."
1535 .into(),
1536 )),
1537 (false, true) => issues.push(QualityIssue::Error(
1538 "an `{{interactsh*}}` token is referenced in a verify template but no \
1539 [detector.verify.oob] block is set - the token will resolve to an empty \
1540 string at runtime and ship a malformed request to the service. Either \
1541 add a [detector.verify.oob] block or remove the token."
1542 .into(),
1543 )),
1544 _ => {}
1545 }
1546}
1547
1548fn visit_verify_template_fields(verify: &VerifySpec, mut visit: impl FnMut(&str)) {
1549 if let Some(ref url) = verify.url {
1550 visit(url);
1551 }
1552 if let Some(ref body) = verify.body {
1553 visit(body);
1554 }
1555 for header in &verify.headers {
1556 visit(&header.value);
1557 }
1558 for step in &verify.steps {
1559 visit(&step.url);
1560 if let Some(ref body) = step.body {
1561 visit(body);
1562 }
1563 for header in &step.headers {
1564 visit(&header.value);
1565 }
1566 }
1567}
1568
1569fn check_url_exfil_risk(url: &str, allowed_domains: &[String], issues: &mut Vec<QualityIssue>) {
1576 let trimmed = url.trim();
1581 let after_scheme = trimmed
1582 .strip_prefix("https://")
1583 .or_else(|| trimmed.strip_prefix("http://"))
1584 .unwrap_or(trimmed); let host_starts_with_template =
1586 after_scheme.starts_with("{{") || after_scheme.starts_with("{") || trimmed == "{{match}}";
1587 if host_starts_with_template && allowed_domains.is_empty() {
1588 issues.push(QualityIssue::Error(
1589 "verify URL host is templated and no `allowed_domains` is set - \
1590 attacker-controlled interpolation could exfil credentials. \
1591 Either hardcode the authoritative host in the URL or set \
1592 `allowed_domains` explicitly. See kimi-wave3 §1."
1593 .into(),
1594 ));
1595 }
1596 if url.contains('{') && !url.contains("{{") {
1599 issues.push(QualityIssue::Error(
1600 "verify URL uses single-brace `{var}` template syntax which the \
1601 interpolator does NOT honor (only `{{var}}` works); the URL will \
1602 be sent to a literal-string host. Use `{{companion.var}}`."
1603 .into(),
1604 ));
1605 }
1606}
1607
1608fn validate_url(url: &str, issues: &mut Vec<QualityIssue>) {
1609 if url.is_empty() {
1610 issues.push(QualityIssue::Error("verify URL is empty".into()));
1611 }
1612 if url.starts_with("http://") && !is_loopback_http_host(url) {
1613 issues.push(QualityIssue::Warning(
1614 "verify URL uses HTTP instead of HTTPS".into(),
1615 ));
1616 }
1617}
1618
1619fn is_loopback_http_host(url: &str) -> bool {
1624 let Some(after_scheme) = url.strip_prefix("http://") else {
1625 return false;
1626 };
1627 let authority = after_scheme
1628 .split(['/', '?', '#'])
1629 .next()
1630 .map_or(after_scheme, |authority| authority);
1631 let host_port = authority
1632 .rsplit_once('@')
1633 .map_or(authority, |(_, host)| host);
1634 let host = if let Some(rest) = host_port.strip_prefix('[') {
1635 match rest.split_once(']') {
1637 Some((inner, _)) => inner,
1638 None => return false,
1639 }
1640 } else {
1641 host_port.split(':').next().map_or(host_port, |host| host)
1642 };
1643 matches!(host, "localhost" | "127.0.0.1" | "::1")
1644}
1645
1646fn has_literal_prefix<'a>(
1647 regex_cache: &mut RegexAstCache<'a>,
1648 pattern: &'a str,
1649 min_len: usize,
1650) -> bool {
1651 match regex_cache.parse(pattern) {
1652 Ok(ast) => ast_literal_runs(ast).prefix >= min_len,
1653 Err(_) => false, }
1655}
1656
1657fn ast_captures_len(ast: &ast::Ast) -> usize {
1658 ast_max_capture_index(ast)
1659 .map(|index| index as usize + 1)
1660 .unwrap_or(1) }
1662
1663fn ast_max_capture_index(ast: &ast::Ast) -> Option<u32> {
1664 let mut max_capture = None;
1665 let mut stack = vec![ast];
1666 while let Some(node) = stack.pop() {
1667 match node {
1668 ast::Ast::Group(group) => {
1669 max_capture = max_capture.max(group.capture_index());
1670 stack.push(&group.ast);
1671 }
1672 ast::Ast::Concat(concat) => stack.extend(concat.asts.iter()),
1673 ast::Ast::Alternation(alternation) => stack.extend(alternation.asts.iter()),
1674 ast::Ast::Repetition(repetition) => stack.push(&repetition.ast),
1675 ast::Ast::Empty(_)
1676 | ast::Ast::Flags(_)
1677 | ast::Ast::Literal(_)
1678 | ast::Ast::Dot(_)
1679 | ast::Ast::Assertion(_)
1680 | ast::Ast::ClassUnicode(_)
1681 | ast::Ast::ClassPerl(_)
1682 | ast::Ast::ClassBracketed(_) => {}
1683 }
1684 }
1685 max_capture
1686}
1687
1688#[derive(Clone, Copy)]
1689struct LiteralRunStats {
1690 prefix: usize,
1691 suffix: usize,
1692 max: usize,
1693 all_literal: bool,
1694}
1695
1696impl LiteralRunStats {
1697 fn empty() -> Self {
1698 Self {
1699 prefix: 0,
1700 suffix: 0,
1701 max: 0,
1702 all_literal: true,
1703 }
1704 }
1705
1706 fn literal(len: usize) -> Self {
1707 Self {
1708 prefix: len,
1709 suffix: len,
1710 max: len,
1711 all_literal: true,
1712 }
1713 }
1714}
1715
1716fn ast_literal_runs(ast: &ast::Ast) -> LiteralRunStats {
1717 enum LiteralFrame<'a> {
1718 Visit(&'a ast::Ast),
1719 FinishConcat(usize),
1720 FinishAlternation(usize),
1721 FinishRepetition(&'a ast::RepetitionKind),
1722 }
1723
1724 let mut frames = vec![LiteralFrame::Visit(ast)];
1725 let mut results = Vec::new();
1726 while let Some(frame) = frames.pop() {
1727 match frame {
1728 LiteralFrame::Visit(node) => match node {
1729 ast::Ast::Literal(_) => results.push(LiteralRunStats::literal(1)),
1730 ast::Ast::Empty(_) | ast::Ast::Flags(_) | ast::Ast::Assertion(_) => {
1731 results.push(LiteralRunStats::empty());
1732 }
1733 ast::Ast::Group(group) => frames.push(LiteralFrame::Visit(&group.ast)),
1734 ast::Ast::Concat(concat) => {
1735 frames.push(LiteralFrame::FinishConcat(concat.asts.len()));
1736 for child in concat.asts.iter().rev() {
1737 frames.push(LiteralFrame::Visit(child));
1738 }
1739 }
1740 ast::Ast::Alternation(alternation) => {
1741 frames.push(LiteralFrame::FinishAlternation(alternation.asts.len()));
1742 for child in alternation.asts.iter().rev() {
1743 frames.push(LiteralFrame::Visit(child));
1744 }
1745 }
1746 ast::Ast::Repetition(repetition) => {
1747 frames.push(LiteralFrame::FinishRepetition(&repetition.op.kind));
1748 frames.push(LiteralFrame::Visit(&repetition.ast));
1749 }
1750 ast::Ast::Dot(_)
1751 | ast::Ast::ClassUnicode(_)
1752 | ast::Ast::ClassPerl(_)
1753 | ast::Ast::ClassBracketed(_) => results.push(LiteralRunStats {
1754 prefix: 0,
1755 suffix: 0,
1756 max: 0,
1757 all_literal: false,
1758 }),
1759 },
1760 LiteralFrame::FinishConcat(child_count) => {
1761 let children = results.split_off(results.len() - child_count);
1762 let combined = children
1763 .into_iter()
1764 .fold(LiteralRunStats::empty(), combine_literal_runs);
1765 results.push(combined);
1766 }
1767 LiteralFrame::FinishAlternation(child_count) => {
1768 let children = results.split_off(results.len() - child_count);
1769 let max = match children.into_iter().map(|child| child.max).max() {
1770 Some(max) => max,
1771 None => 0,
1772 };
1773 results.push(LiteralRunStats {
1774 max,
1775 prefix: 0,
1776 suffix: 0,
1777 all_literal: false,
1778 });
1779 }
1780 LiteralFrame::FinishRepetition(kind) => {
1781 let inner = match results.pop() {
1782 Some(inner) => inner,
1783 None => LiteralRunStats::empty(),
1784 };
1785 results.push(repeated_literal_runs(
1786 inner,
1787 repetition_min(kind),
1788 repetition_is_exact(kind),
1789 ));
1790 }
1791 }
1792 }
1793 match results.pop() {
1794 Some(stats) => stats,
1795 None => LiteralRunStats::empty(),
1796 }
1797}
1798
1799fn combine_literal_runs(left: LiteralRunStats, right: LiteralRunStats) -> LiteralRunStats {
1800 LiteralRunStats {
1801 prefix: if left.all_literal {
1802 left.prefix.saturating_add(right.prefix)
1803 } else {
1804 left.prefix
1805 },
1806 suffix: if right.all_literal {
1807 left.suffix.saturating_add(right.suffix)
1808 } else {
1809 right.suffix
1810 },
1811 max: left
1812 .max
1813 .max(right.max)
1814 .max(left.suffix.saturating_add(right.prefix)),
1815 all_literal: left.all_literal && right.all_literal,
1816 }
1817}
1818
1819fn repeated_literal_runs(
1820 inner: LiteralRunStats,
1821 min_repetitions: usize,
1822 exact_repetition: bool,
1823) -> LiteralRunStats {
1824 if min_repetitions == 0 {
1825 return LiteralRunStats {
1826 prefix: 0,
1827 suffix: 0,
1828 max: inner.max,
1829 all_literal: false,
1830 };
1831 }
1832
1833 if inner.all_literal {
1834 let repeated_len = inner.max.saturating_mul(min_repetitions);
1835 return LiteralRunStats {
1836 prefix: repeated_len,
1837 suffix: repeated_len,
1838 max: repeated_len,
1839 all_literal: exact_repetition,
1840 };
1841 }
1842
1843 LiteralRunStats {
1844 prefix: inner.prefix,
1845 suffix: inner.suffix,
1846 max: inner.max,
1847 all_literal: false,
1848 }
1849}
1850
1851fn repetition_min(kind: &ast::RepetitionKind) -> usize {
1852 match kind {
1853 ast::RepetitionKind::ZeroOrOne | ast::RepetitionKind::ZeroOrMore => 0,
1854 ast::RepetitionKind::OneOrMore => 1,
1855 ast::RepetitionKind::Range(ast::RepetitionRange::Exactly(min))
1856 | ast::RepetitionKind::Range(ast::RepetitionRange::AtLeast(min))
1857 | ast::RepetitionKind::Range(ast::RepetitionRange::Bounded(min, _)) => *min as usize,
1858 }
1859}
1860
1861fn repetition_is_exact(kind: &ast::RepetitionKind) -> bool {
1862 matches!(
1863 kind,
1864 ast::RepetitionKind::Range(ast::RepetitionRange::Exactly(_))
1865 )
1866}
1867
1868fn is_pure_character_class<'a>(regex_cache: &mut RegexAstCache<'a>, pattern: &'a str) -> bool {
1869 match regex_cache.parse(pattern) {
1870 Ok(ast) => pure_character_class_ast(ast).is_some(),
1871 Err(_) => false, }
1873}
1874
1875fn pure_character_class_ast(ast: &ast::Ast) -> Option<()> {
1876 enum PureFrame<'a> {
1877 Visit(&'a ast::Ast),
1878 FinishAllNonempty(usize),
1879 }
1880
1881 let mut frames = vec![PureFrame::Visit(ast)];
1882 let mut results = Vec::new();
1883 while let Some(frame) = frames.pop() {
1884 match frame {
1885 PureFrame::Visit(node) => match node {
1886 ast::Ast::ClassBracketed(_) => results.push(Some(())),
1887 ast::Ast::Group(group) => frames.push(PureFrame::Visit(&group.ast)),
1888 ast::Ast::Repetition(repetition) => frames.push(PureFrame::Visit(&repetition.ast)),
1889 ast::Ast::Alternation(alternation) => {
1890 frames.push(PureFrame::FinishAllNonempty(alternation.asts.len()));
1891 for child in alternation.asts.iter().rev() {
1892 frames.push(PureFrame::Visit(child));
1893 }
1894 }
1895 ast::Ast::Concat(concat) => {
1896 let children = concat
1897 .asts
1898 .iter()
1899 .filter(|child| !is_regex_metadata_node(child))
1900 .collect::<Vec<_>>();
1901 frames.push(PureFrame::FinishAllNonempty(children.len()));
1902 for child in children.into_iter().rev() {
1903 frames.push(PureFrame::Visit(child));
1904 }
1905 }
1906 ast::Ast::Empty(_) | ast::Ast::Flags(_) | ast::Ast::Assertion(_) => {
1907 results.push(None);
1908 }
1909 ast::Ast::Literal(_)
1910 | ast::Ast::Dot(_)
1911 | ast::Ast::ClassUnicode(_)
1912 | ast::Ast::ClassPerl(_) => results.push(None),
1913 },
1914 PureFrame::FinishAllNonempty(child_count) => {
1915 if child_count == 0 {
1916 results.push(None);
1917 continue;
1918 }
1919 let children = results.split_off(results.len() - child_count);
1920 results.push(
1921 children
1922 .into_iter()
1923 .all(|child| child.is_some())
1924 .then_some(()),
1925 );
1926 }
1927 }
1928 }
1929 results.pop().flatten()
1930}
1931
1932fn is_regex_metadata_node(ast: &ast::Ast) -> bool {
1933 matches!(
1934 ast,
1935 ast::Ast::Empty(_) | ast::Ast::Flags(_) | ast::Ast::Assertion(_)
1936 )
1937}
1938
1939mod regex_complexity;
1940use regex_complexity::validate_regex_complexity;