1use std::{
9 collections::BTreeMap,
10 error::Error,
11 fmt, str,
12 sync::{Arc, Mutex},
13};
14
15use termivar_core::{
16 ConfidenceScore, DerivationAlgorithm, EntityId, Evidence, EvidenceDerivation, EvidenceId,
17 EvidenceKind, EvidenceOrigin, EvidenceSource, EvidenceValue, HttpEvidencePredicate,
18 KnowledgePredicate, PredicateDescriptor,
19};
20
21use crate::{
22 DecisionEvidenceReceipt, DecisionExecutionStage, HttpEvidenceError, HttpProbeMethod,
23 KnowledgeBase, KnowledgeWrite, HTTP_EVIDENCE_EXECUTOR_ID,
24};
25
26use super::{
27 assessment_item::{
28 AssessmentCapabilityDescriptor, AssessmentItemProjectionError, AssessmentItemTarget,
29 AssessmentProjectionContext,
30 },
31 WebAssessmentSubject, BOOTSTRAP_ACTION_ID, BOOTSTRAP_CASE_ID, BOOTSTRAP_HYPOTHESIS_ID,
32};
33
34pub const MAX_SECRET_EXPOSURE_BODY_BYTES: usize = 128 * 1024;
36pub const MAX_SECRET_EXPOSURE_TOTAL_BODY_BYTES: u64 = 4 * 1024 * 1024;
38pub const MAX_SECRET_EXPOSURE_RETAINED_OBSERVATIONS: usize = 32;
40pub const MAX_SECRET_EXPOSURE_RESPONSES: usize = 1_024;
42pub(crate) const MAX_SECRET_EXPOSURE_PEM_PAYLOAD_BYTES: usize = 64 * 1024;
44pub(crate) const MAX_SECRET_EXPOSURE_TOKEN_BYTES: usize = 512;
46pub(crate) const MAX_SECRET_EXPOSURE_PAIR_WINDOW_BYTES: usize = 4 * 1024;
48pub(crate) const MAX_SECRET_EXPOSURE_PAIR_WINDOW_LINES: usize = 16;
50pub const MAX_SECRET_EXPOSURE_OCCURRENCES: u16 = 64;
52
53pub const SECRET_EXPOSURE_AUDIT_SCHEMA: &str = "security.passive-secret-exposure-audit/v1";
54pub const SECRET_EXPOSURE_POLICY_ID: &str = "termivar.passive-secret-exposure/v1";
55pub const SECRET_EXPOSURE_CATALOGUE_ID: &str = "termivar.high-specificity-secret-detectors";
56pub const SECRET_EXPOSURE_CATALOGUE_REVISION: &str = "v1";
57pub const SECRET_EXPOSURE_REPRESENTATION: &str = "complete-uncoded-response-body/v1";
58pub(crate) const SECRET_EXPOSURE_NAMESPACE: &str = "web.secret-exposure";
59const SECRET_EXPOSURE_CATEGORY: &str = "passive-secret-exposure";
60const SECRET_EXPOSURE_SOURCE_METHOD: &str = "bounded-value-free-secret-detection";
61const SECRET_EXPOSURE_ALGORITHM: &str = "web.secret-exposure.fixed-catalogue";
62const SECRET_EXPOSURE_ALGORITHM_VERSION: u32 = 1;
63const ACTIVE_REVIEW_PRIVACY_SOURCE_METHOD: &str = "active-review-body-digest-privacy-guard";
64const ACTIVE_REVIEW_PRIVACY_ALGORITHM: &str =
65 "web.secret-exposure.active-review-body-digest-privacy-guard";
66const ACTIVE_REVIEW_PRIVACY_ALGORITHM_VERSION: u32 = 1;
67
68const RESPONSE_SEQUENCE: &str = "response_sequence";
69const RESPONSE_OUTCOME: &str = "response_outcome";
70pub(crate) const RESPONSE_BODY_LINEAGE: &str = "response_body_lineage";
71pub(crate) const ACTIVE_REVIEW_BODY_LINEAGE: &str = "active_review_body_lineage";
72const INSPECTED_BODY_BYTES: &str = "inspected_body_bytes";
73const MATCH_OCCURRENCE_COUNT: &str = "match_occurrence_count";
74const OMITTED_OBSERVATION_COUNT: &str = "omitted_observation_count";
75const BODY_DERIVED_PROJECTION_SUPPRESSED: &str = "body_derived_projection_suppressed";
76
77const MIN_PEM_PAYLOAD_BYTES: usize = 64;
78const MIN_STRIPE_PAYLOAD_BYTES: usize = 16;
79const MAX_STRIPE_PAYLOAD_BYTES: usize = 128;
80const MIN_BEARER_TOKEN_BYTES: usize = 24;
81
82const AWS_ACCESS_KEY_ID_NAMES: [&[u8]; 2] = [b"AWS_ACCESS_KEY_ID", b"aws_access_key_id"];
83const AWS_SECRET_ACCESS_KEY_NAMES: [&[u8]; 2] =
84 [b"AWS_SECRET_ACCESS_KEY", b"aws_secret_access_key"];
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
88#[non_exhaustive]
89pub enum SecretExposureDetectorClass {
90 PemPrivateKeyBlock,
91 AwsAccessKeyPair,
92 StripeLiveSecret,
93 AuthorizationBearerAssignment,
94}
95
96impl SecretExposureDetectorClass {
97 pub const fn as_str(self) -> &'static str {
98 match self {
99 Self::PemPrivateKeyBlock => "pem_private_key_block",
100 Self::AwsAccessKeyPair => "aws_access_key_pair",
101 Self::StripeLiveSecret => "stripe_live_secret",
102 Self::AuthorizationBearerAssignment => "authorization_bearer_assignment",
103 }
104 }
105
106 pub const fn capability_id(self) -> &'static str {
107 match self {
108 Self::PemPrivateKeyBlock => "exposure.response-private-key-material@1",
109 Self::AwsAccessKeyPair => "exposure.response-aws-access-key-pair@1",
110 Self::StripeLiveSecret => "exposure.response-stripe-live-secret@1",
111 Self::AuthorizationBearerAssignment => "exposure.response-bearer-authorization@1",
112 }
113 }
114
115 const fn predicate_name(self) -> &'static str {
116 match self {
117 Self::PemPrivateKeyBlock => "pem_private_key_block_count",
118 Self::AwsAccessKeyPair => "aws_access_key_pair_count",
119 Self::StripeLiveSecret => "stripe_live_secret_count",
120 Self::AuthorizationBearerAssignment => "bearer_authorization_assignment_count",
121 }
122 }
123
124 fn parse_predicate_name(value: &str) -> Option<Self> {
125 Self::ALL
126 .into_iter()
127 .find(|class| class.predicate_name() == value)
128 }
129
130 const ALL: [Self; 4] = [
131 Self::PemPrivateKeyBlock,
132 Self::AwsAccessKeyPair,
133 Self::StripeLiveSecret,
134 Self::AuthorizationBearerAssignment,
135 ];
136
137 const fn capability(self) -> &'static AssessmentCapabilityDescriptor {
138 match self {
139 Self::PemPrivateKeyBlock => &PRIVATE_KEY_CAPABILITY,
140 Self::AwsAccessKeyPair => &AWS_ACCESS_KEY_PAIR_CAPABILITY,
141 Self::StripeLiveSecret => &STRIPE_LIVE_SECRET_CAPABILITY,
142 Self::AuthorizationBearerAssignment => &BEARER_AUTHORIZATION_CAPABILITY,
143 }
144 }
145
146 const fn index(self) -> usize {
147 match self {
148 Self::PemPrivateKeyBlock => 0,
149 Self::AwsAccessKeyPair => 1,
150 Self::StripeLiveSecret => 2,
151 Self::AuthorizationBearerAssignment => 3,
152 }
153 }
154
155 const fn from_index(index: usize) -> Self {
156 match index {
157 0 => Self::PemPrivateKeyBlock,
158 1 => Self::AwsAccessKeyPair,
159 2 => Self::StripeLiveSecret,
160 3 => Self::AuthorizationBearerAssignment,
161 _ => unreachable!(),
162 }
163 }
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub(crate) struct SecretExposureClassCount {
169 class: SecretExposureDetectorClass,
170 occurrence_count: u16,
171}
172
173impl SecretExposureClassCount {
174 pub(crate) const fn class(self) -> SecretExposureDetectorClass {
175 self.class
176 }
177
178 pub(crate) const fn occurrence_count(self) -> u16 {
179 self.occurrence_count
180 }
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
185pub(crate) struct SecretExposureScan {
186 inspected_body_bytes: usize,
187 occurrence_count: u16,
188 class_counts: Vec<SecretExposureClassCount>,
189}
190
191impl SecretExposureScan {
192 pub(crate) const fn inspected_body_bytes(&self) -> usize {
193 self.inspected_body_bytes
194 }
195
196 pub(crate) const fn occurrence_count(&self) -> u16 {
197 self.occurrence_count
198 }
199
200 pub(crate) fn class_counts(&self) -> &[SecretExposureClassCount] {
201 &self.class_counts
202 }
203
204 #[cfg(test)]
205 pub(crate) fn count_for(&self, class: SecretExposureDetectorClass) -> u16 {
206 self.class_counts
207 .iter()
208 .find(|entry| entry.class == class)
209 .map_or(0, |entry| entry.occurrence_count)
210 }
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub(crate) enum SecretExposureScanError {
216 BodyLimitExceeded {
217 body_bytes: usize,
218 max_body_bytes: usize,
219 },
220 InvalidUtf8 {
221 body_bytes: usize,
222 },
223 PemPayloadLimitExceeded {
224 body_bytes: usize,
225 max_payload_bytes: usize,
226 },
227 TokenLimitExceeded {
228 body_bytes: usize,
229 max_token_bytes: usize,
230 },
231 OccurrenceLimitExceeded {
232 body_bytes: usize,
233 max_occurrences: u16,
234 },
235}
236
237impl fmt::Display for SecretExposureScanError {
238 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
239 match self {
240 Self::BodyLimitExceeded {
241 body_bytes,
242 max_body_bytes,
243 } => write!(
244 formatter,
245 "secret-exposure body byte count {body_bytes} exceeds limit {max_body_bytes}"
246 ),
247 Self::InvalidUtf8 { body_bytes } => write!(
248 formatter,
249 "secret-exposure body with {body_bytes} bytes is not valid UTF-8"
250 ),
251 Self::PemPayloadLimitExceeded {
252 body_bytes,
253 max_payload_bytes,
254 } => write!(
255 formatter,
256 "secret-exposure PEM candidate in {body_bytes}-byte body exceeds payload limit {max_payload_bytes}"
257 ),
258 Self::TokenLimitExceeded {
259 body_bytes,
260 max_token_bytes,
261 } => write!(
262 formatter,
263 "secret-exposure token candidate in {body_bytes}-byte body exceeds token limit {max_token_bytes}"
264 ),
265 Self::OccurrenceLimitExceeded {
266 body_bytes,
267 max_occurrences,
268 } => write!(
269 formatter,
270 "secret-exposure body with {body_bytes} bytes exceeds occurrence limit {max_occurrences}"
271 ),
272 }
273 }
274}
275
276impl Error for SecretExposureScanError {}
277
278pub(crate) fn scan_complete_text_body(
283 body: &[u8],
284) -> Result<SecretExposureScan, SecretExposureScanError> {
285 if body.len() > MAX_SECRET_EXPOSURE_BODY_BYTES {
286 return Err(SecretExposureScanError::BodyLimitExceeded {
287 body_bytes: body.len(),
288 max_body_bytes: MAX_SECRET_EXPOSURE_BODY_BYTES,
289 });
290 }
291 if str::from_utf8(body).is_err() {
292 return Err(SecretExposureScanError::InvalidUtf8 {
293 body_bytes: body.len(),
294 });
295 }
296
297 let mut counts = MatchCounts::new(body.len());
298 scan_pem_private_keys(body, &mut counts)?;
299 scan_aws_pairs(body, &mut counts)?;
300 scan_stripe_live_secrets(body, &mut counts)?;
301 scan_authorization_bearer_assignments(body, &mut counts)?;
302 Ok(counts.finish())
303}
304
305struct MatchCounts {
306 body_bytes: usize,
307 total: u16,
308 by_class: [u16; 4],
309}
310
311impl MatchCounts {
312 const fn new(body_bytes: usize) -> Self {
313 Self {
314 body_bytes,
315 total: 0,
316 by_class: [0; 4],
317 }
318 }
319
320 fn record(
321 &mut self,
322 class: SecretExposureDetectorClass,
323 ) -> Result<(), SecretExposureScanError> {
324 if self.total >= MAX_SECRET_EXPOSURE_OCCURRENCES {
325 return Err(SecretExposureScanError::OccurrenceLimitExceeded {
326 body_bytes: self.body_bytes,
327 max_occurrences: MAX_SECRET_EXPOSURE_OCCURRENCES,
328 });
329 }
330 self.total += 1;
331 self.by_class[class.index()] += 1;
332 Ok(())
333 }
334
335 fn finish(self) -> SecretExposureScan {
336 let class_counts = self
337 .by_class
338 .into_iter()
339 .enumerate()
340 .filter_map(|(index, occurrence_count)| {
341 (occurrence_count != 0).then_some(SecretExposureClassCount {
342 class: SecretExposureDetectorClass::from_index(index),
343 occurrence_count,
344 })
345 })
346 .collect();
347 SecretExposureScan {
348 inspected_body_bytes: self.body_bytes,
349 occurrence_count: self.total,
350 class_counts,
351 }
352 }
353}
354
355#[derive(Clone, Copy)]
356enum PemLabel {
357 Pkcs8,
358 Rsa,
359 Ec,
360 OpenSsh,
361}
362
363impl PemLabel {
364 const ALL: [Self; 4] = [Self::Pkcs8, Self::Rsa, Self::Ec, Self::OpenSsh];
365
366 const fn begin(self) -> &'static [u8] {
367 match self {
368 Self::Pkcs8 => b"-----BEGIN PRIVATE KEY-----",
369 Self::Rsa => b"-----BEGIN RSA PRIVATE KEY-----",
370 Self::Ec => b"-----BEGIN EC PRIVATE KEY-----",
371 Self::OpenSsh => b"-----BEGIN OPENSSH PRIVATE KEY-----",
372 }
373 }
374
375 const fn end(self) -> &'static [u8] {
376 match self {
377 Self::Pkcs8 => b"-----END PRIVATE KEY-----",
378 Self::Rsa => b"-----END RSA PRIVATE KEY-----",
379 Self::Ec => b"-----END EC PRIVATE KEY-----",
380 Self::OpenSsh => b"-----END OPENSSH PRIVATE KEY-----",
381 }
382 }
383
384 fn prefix_is_plausible(self, payload_prefix: &[u8]) -> bool {
385 match self {
386 Self::Pkcs8 | Self::Rsa | Self::Ec => payload_prefix.first() == Some(&b'M'),
389 Self::OpenSsh => payload_prefix.starts_with(b"b3BlbnNzaC1rZXktdjE"),
391 }
392 }
393}
394
395fn scan_pem_private_keys(
396 body: &[u8],
397 counts: &mut MatchCounts,
398) -> Result<(), SecretExposureScanError> {
399 let mut cursor = 0;
400 while let Some((line, next)) = next_line(body, cursor) {
401 let trimmed = trim_ascii_horizontal(line);
402 let Some(label) = PemLabel::ALL
403 .into_iter()
404 .find(|label| trimmed == label.begin())
405 else {
406 cursor = next;
407 continue;
408 };
409
410 let mut payload_bytes = 0usize;
411 let mut payload_prefix = [0u8; 24];
412 let mut prefix_len = 0usize;
413 let mut padding_started = false;
414 let mut padding_bytes = 0usize;
415 let mut valid = true;
416 let mut block_cursor = next;
417 let mut closed_at = None;
418
419 while let Some((candidate_line, candidate_next)) = next_line(body, block_cursor) {
420 let candidate = trim_ascii_horizontal(candidate_line);
421 if candidate == label.end() {
422 closed_at = Some(candidate_next);
423 break;
424 }
425 if PemLabel::ALL
426 .into_iter()
427 .any(|other| candidate == other.begin() || candidate == other.end())
428 || candidate.is_empty()
429 {
430 valid = false;
431 break;
432 }
433 for &byte in candidate {
434 if payload_bytes >= MAX_SECRET_EXPOSURE_PEM_PAYLOAD_BYTES {
435 return Err(SecretExposureScanError::PemPayloadLimitExceeded {
436 body_bytes: body.len(),
437 max_payload_bytes: MAX_SECRET_EXPOSURE_PEM_PAYLOAD_BYTES,
438 });
439 }
440 if byte == b'=' {
441 padding_started = true;
442 padding_bytes += 1;
443 if padding_bytes > 2 {
444 valid = false;
445 break;
446 }
447 } else if is_base64_byte(byte) && !padding_started {
448 if prefix_len < payload_prefix.len() {
449 payload_prefix[prefix_len] = byte;
450 prefix_len += 1;
451 }
452 } else {
453 valid = false;
454 break;
455 }
456 payload_bytes += 1;
457 }
458 if !valid {
459 break;
460 }
461 block_cursor = candidate_next;
462 }
463
464 match closed_at {
465 Some(closed_at)
466 if valid
467 && payload_bytes >= MIN_PEM_PAYLOAD_BYTES
468 && payload_bytes.is_multiple_of(4)
469 && label.prefix_is_plausible(&payload_prefix[..prefix_len]) =>
470 {
471 counts.record(SecretExposureDetectorClass::PemPrivateKeyBlock)?;
472 cursor = closed_at;
473 },
474 _ => cursor = next,
475 }
476 }
477 Ok(())
478}
479
480#[derive(Clone, Copy)]
481struct AssignmentLocation {
482 byte_offset: usize,
483 line_number: usize,
484}
485
486fn scan_aws_pairs(body: &[u8], counts: &mut MatchCounts) -> Result<(), SecretExposureScanError> {
487 let mut unmatched_ids = Vec::<AssignmentLocation>::new();
488 let mut unmatched_secrets = Vec::<AssignmentLocation>::new();
489 let mut cursor = 0;
490 let mut line_number = 0;
491
492 while let Some((line, next)) = next_line(body, cursor) {
493 let mut events = Vec::<(usize, bool)>::new();
494 for name in AWS_ACCESS_KEY_ID_NAMES {
495 for_each_assignment_value(line, name, |position, value| {
496 if valid_aws_access_key_id(value) {
497 events.push((position, true));
498 }
499 });
500 }
501 for name in AWS_SECRET_ACCESS_KEY_NAMES {
502 for_each_assignment_value(line, name, |position, value| {
503 if valid_aws_secret_access_key(value) {
504 events.push((position, false));
505 }
506 });
507 }
508 events.sort_unstable_by_key(|event| event.0);
509
510 for (position, is_id) in events {
511 let location = AssignmentLocation {
512 byte_offset: cursor.saturating_add(position),
513 line_number,
514 };
515 discard_out_of_window(&mut unmatched_ids, location);
516 discard_out_of_window(&mut unmatched_secrets, location);
517 let counterparts = if is_id {
518 &mut unmatched_secrets
519 } else {
520 &mut unmatched_ids
521 };
522 if let Some(index) = counterparts
523 .iter()
524 .position(|other| locations_share_pair_window(*other, location))
525 {
526 counterparts.remove(index);
527 counts.record(SecretExposureDetectorClass::AwsAccessKeyPair)?;
528 } else if is_id {
529 unmatched_ids.push(location);
530 } else {
531 unmatched_secrets.push(location);
532 }
533 }
534
535 cursor = next;
536 line_number = line_number.saturating_add(1);
537 }
538 Ok(())
539}
540
541fn discard_out_of_window(locations: &mut Vec<AssignmentLocation>, current: AssignmentLocation) {
542 locations.retain(|candidate| locations_share_pair_window(*candidate, current));
543}
544
545fn locations_share_pair_window(left: AssignmentLocation, right: AssignmentLocation) -> bool {
546 left.byte_offset.abs_diff(right.byte_offset) <= MAX_SECRET_EXPOSURE_PAIR_WINDOW_BYTES
547 && left.line_number.abs_diff(right.line_number) <= MAX_SECRET_EXPOSURE_PAIR_WINDOW_LINES
548}
549
550fn valid_aws_access_key_id(value: &[u8]) -> bool {
551 value.len() == 20
552 && (value.starts_with(b"AKIA") || value.starts_with(b"ASIA"))
553 && value
554 .iter()
555 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
556 && !equals_ascii_case_insensitive(value, b"AKIAIOSFODNN7EXAMPLE")
557 && !looks_like_placeholder(value)
558 && has_minimum_byte_variety(value)
559}
560
561fn valid_aws_secret_access_key(value: &[u8]) -> bool {
562 value.len() == 40
563 && value
564 .iter()
565 .all(|byte| is_base64_byte(*byte) || *byte == b'=')
566 && !looks_like_placeholder(value)
567 && has_minimum_byte_variety(value)
568}
569
570fn scan_stripe_live_secrets(
571 body: &[u8],
572 counts: &mut MatchCounts,
573) -> Result<(), SecretExposureScanError> {
574 for prefix in [b"sk_live_".as_slice(), b"rk_live_".as_slice()] {
575 let mut cursor = 0;
576 while let Some(relative) = find_subslice(&body[cursor..], prefix) {
577 let start = cursor + relative;
578 if !has_token_boundary_before(body, start, is_stripe_token_byte) {
579 cursor = start.saturating_add(1);
580 continue;
581 }
582 let payload_start = start + prefix.len();
583 let mut end = payload_start;
584 while end < body.len() && is_stripe_token_byte(body[end]) {
585 end += 1;
586 }
587 let payload = &body[payload_start..end];
588 if has_token_boundary_after(body, end, is_stripe_token_byte)
589 && (MIN_STRIPE_PAYLOAD_BYTES..=MAX_STRIPE_PAYLOAD_BYTES).contains(&payload.len())
590 && !looks_like_placeholder(payload)
591 && has_minimum_byte_variety(payload)
592 {
593 counts.record(SecretExposureDetectorClass::StripeLiveSecret)?;
594 }
595 cursor = end.max(start.saturating_add(1));
599 }
600 }
601 Ok(())
602}
603
604fn scan_authorization_bearer_assignments(
605 body: &[u8],
606 counts: &mut MatchCounts,
607) -> Result<(), SecretExposureScanError> {
608 let mut cursor = 0;
609 while let Some((line, next)) = next_line(body, cursor) {
610 for_each_assignment_value_case_insensitive(line, b"authorization", |_, value| {
611 let value = trim_ascii_horizontal(value);
612 let Some(after_scheme) = strip_ascii_case_insensitive_prefix(value, b"Bearer") else {
613 return Ok(());
614 };
615 if after_scheme
616 .first()
617 .is_none_or(|byte| !byte.is_ascii_whitespace())
618 {
619 return Ok(());
620 }
621 let token = trim_ascii_horizontal(after_scheme);
622 if token.len() > MAX_SECRET_EXPOSURE_TOKEN_BYTES {
623 return Err(SecretExposureScanError::TokenLimitExceeded {
624 body_bytes: body.len(),
625 max_token_bytes: MAX_SECRET_EXPOSURE_TOKEN_BYTES,
626 });
627 }
628 if token.len() >= MIN_BEARER_TOKEN_BYTES
629 && valid_token68(token)
630 && !looks_like_placeholder(token)
631 && has_minimum_byte_variety(token)
632 {
633 counts.record(SecretExposureDetectorClass::AuthorizationBearerAssignment)?;
634 }
635 Ok(())
636 })?;
637 cursor = next;
638 }
639 Ok(())
640}
641
642fn next_line(body: &[u8], cursor: usize) -> Option<(&[u8], usize)> {
643 if cursor >= body.len() {
644 return None;
645 }
646 let relative_end = body[cursor..]
647 .iter()
648 .position(|byte| *byte == b'\n')
649 .unwrap_or(body.len() - cursor);
650 let end = cursor + relative_end;
651 let next = if end < body.len() { end + 1 } else { end };
652 Some((&body[cursor..end], next))
653}
654
655fn trim_ascii_horizontal(mut value: &[u8]) -> &[u8] {
656 while value
657 .first()
658 .is_some_and(|byte| matches!(byte, b' ' | b'\t' | b'\r'))
659 {
660 value = &value[1..];
661 }
662 while value
663 .last()
664 .is_some_and(|byte| matches!(byte, b' ' | b'\t' | b'\r'))
665 {
666 value = &value[..value.len() - 1];
667 }
668 value
669}
670
671#[derive(Default)]
672struct AssignmentEndCursor {
673 cached: Option<usize>,
674 search_from: usize,
675 exhausted: bool,
676 inspected_bytes: usize,
677}
678
679impl AssignmentEndCursor {
680 fn next_at_or_after(
681 &mut self,
682 line: &[u8],
683 start: usize,
684 matches: impl Fn(u8) -> bool,
685 ) -> Option<usize> {
686 if self.cached.is_some_and(|index| index >= start) {
687 return self.cached;
688 }
689 if self.exhausted {
690 return None;
691 }
692 let from = self.search_from.max(start);
693 for (relative, byte) in line[from..].iter().copied().enumerate() {
694 self.inspected_bytes = self.inspected_bytes.saturating_add(1);
695 if matches(byte) {
696 let index = from + relative;
697 self.cached = Some(index);
698 self.search_from = index.saturating_add(1);
699 return Some(index);
700 }
701 }
702 self.cached = None;
703 self.search_from = line.len();
704 self.exhausted = true;
705 None
706 }
707}
708
709#[derive(Default)]
710struct AssignmentValueEndCursors {
711 single_quote: AssignmentEndCursor,
712 double_quote: AssignmentEndCursor,
713 spaced: AssignmentEndCursor,
714 compact: AssignmentEndCursor,
715}
716
717impl AssignmentValueEndCursors {
718 fn quoted(&mut self, line: &[u8], start: usize, quote: u8) -> Option<usize> {
719 let cursor = if quote == b'\'' {
720 &mut self.single_quote
721 } else {
722 &mut self.double_quote
723 };
724 cursor.next_at_or_after(line, start, |byte| byte == quote)
725 }
726
727 fn unquoted(&mut self, line: &[u8], start: usize, retain_spaces: bool) -> usize {
728 let cursor = if retain_spaces {
729 &mut self.spaced
730 } else {
731 &mut self.compact
732 };
733 cursor
734 .next_at_or_after(line, start, |byte| {
735 if retain_spaces {
736 matches!(byte, b';' | b'#')
737 } else {
738 matches!(byte, b' ' | b'\t' | b',' | b';' | b'#' | b'}')
739 }
740 })
741 .unwrap_or(line.len())
742 }
743
744 fn inspected_bytes(&self) -> usize {
745 self.single_quote
746 .inspected_bytes
747 .saturating_add(self.double_quote.inspected_bytes)
748 .saturating_add(self.spaced.inspected_bytes)
749 .saturating_add(self.compact.inspected_bytes)
750 }
751}
752
753fn for_each_assignment_value(line: &[u8], name: &[u8], mut visit: impl FnMut(usize, &[u8])) {
754 let mut cursor = 0;
755 let mut ends = AssignmentValueEndCursors::default();
756 while let Some(relative) = find_subslice(&line[cursor..], name) {
757 let position = cursor + relative;
758 if let Some(value) = parse_assignment_value(line, position, name.len(), false, &mut ends) {
759 visit(position, value);
760 }
761 cursor = position.saturating_add(1);
762 }
763}
764
765fn for_each_assignment_value_case_insensitive<E>(
766 line: &[u8],
767 name: &[u8],
768 mut visit: impl FnMut(usize, &[u8]) -> Result<(), E>,
769) -> Result<usize, E> {
770 let mut cursor = 0;
771 let mut ends = AssignmentValueEndCursors::default();
772 while cursor + name.len() <= line.len() {
773 if equals_ascii_case_insensitive(&line[cursor..cursor + name.len()], name) {
774 if let Some(value) = parse_assignment_value(line, cursor, name.len(), true, &mut ends) {
775 visit(cursor, value)?;
776 }
777 }
778 cursor += 1;
779 }
780 Ok(ends.inspected_bytes())
781}
782
783fn parse_assignment_value<'a>(
784 line: &'a [u8],
785 name_start: usize,
786 name_len: usize,
787 retain_unquoted_spaces: bool,
788 ends: &mut AssignmentValueEndCursors,
789) -> Option<&'a [u8]> {
790 let name_end = name_start.checked_add(name_len)?;
791 let before = name_start.checked_sub(1).and_then(|index| line.get(index));
792 if before.is_some_and(|byte| !is_assignment_boundary(*byte)) {
793 return None;
794 }
795
796 let opening_name_quote = before.copied().filter(|byte| matches!(byte, b'\'' | b'"'));
797 let mut cursor = name_end;
798 if let Some(quote) = opening_name_quote {
799 if line.get(cursor) != Some("e) {
800 return None;
801 }
802 cursor += 1;
803 } else if line
804 .get(cursor)
805 .is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
806 {
807 return None;
808 }
809 while line
810 .get(cursor)
811 .is_some_and(|byte| matches!(byte, b' ' | b'\t'))
812 {
813 cursor += 1;
814 }
815 if !line
816 .get(cursor)
817 .is_some_and(|byte| matches!(byte, b':' | b'='))
818 {
819 return None;
820 }
821 cursor += 1;
822 while line
823 .get(cursor)
824 .is_some_and(|byte| matches!(byte, b' ' | b'\t'))
825 {
826 cursor += 1;
827 }
828
829 let value_quote = line
830 .get(cursor)
831 .copied()
832 .filter(|byte| matches!(byte, b'\'' | b'"'));
833 if value_quote.is_some() {
834 cursor += 1;
835 }
836 let value_start = cursor;
837 let value_end = if let Some(quote) = value_quote {
838 ends.quoted(line, value_start, quote)?
839 } else {
840 ends.unquoted(line, value_start, retain_unquoted_spaces)
841 };
842 let value = trim_ascii_horizontal(&line[value_start..value_end]);
843 if value.is_empty() {
844 return None;
845 }
846 Some(value)
847}
848
849fn is_assignment_boundary(byte: u8) -> bool {
850 byte.is_ascii_whitespace() || matches!(byte, b'{' | b'[' | b',' | b';' | b'\'' | b'"')
851}
852
853fn valid_token68(token: &[u8]) -> bool {
854 let mut padding = false;
855 let mut padding_bytes = 0;
856 for byte in token {
857 if *byte == b'=' {
858 padding = true;
859 padding_bytes += 1;
860 if padding_bytes > 2 {
861 return false;
862 }
863 } else if padding || !is_token68_byte(*byte) {
864 return false;
865 }
866 }
867 true
868}
869
870fn is_token68_byte(byte: u8) -> bool {
871 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'+' | b'/')
872}
873
874fn is_stripe_token_byte(byte: u8) -> bool {
875 byte.is_ascii_alphanumeric() || byte == b'_'
876}
877
878fn is_base64_byte(byte: u8) -> bool {
879 byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/')
880}
881
882fn has_token_boundary_before(body: &[u8], start: usize, member: fn(u8) -> bool) -> bool {
883 start == 0 || !member(body[start - 1])
884}
885
886fn has_token_boundary_after(body: &[u8], end: usize, member: fn(u8) -> bool) -> bool {
887 end == body.len() || !member(body[end])
888}
889
890fn has_minimum_byte_variety(value: &[u8]) -> bool {
891 let mut distinct = [0u8; 4];
892 let mut count = 0usize;
893 for byte in value {
894 if !distinct[..count].contains(byte) {
895 distinct[count] = *byte;
896 count += 1;
897 if count == distinct.len() {
898 return true;
899 }
900 }
901 }
902 false
903}
904
905const PRIVATE_KEY_CAPABILITY: AssessmentCapabilityDescriptor =
906 AssessmentCapabilityDescriptor::informational(
907 "exposure.response-private-key-material@1",
908 "Private-key material shape was returned",
909 "passive-secret-exposure",
910 "A complete eligible response contained a structurally plausible private-key block; validity, ownership, and usability were not tested.",
911 950_000,
912 "exposure.remediation.remove-private-key-material@1",
913 "Remove private-key material from served content, rotate affected keys through the responsible operator, and verify the authorized response no longer contains it.",
914 );
915const AWS_ACCESS_KEY_PAIR_CAPABILITY: AssessmentCapabilityDescriptor =
916 AssessmentCapabilityDescriptor::informational(
917 "exposure.response-aws-access-key-pair@1",
918 "Paired AWS credential assignments were returned",
919 "passive-secret-exposure",
920 "A complete eligible response contained nearby access-key-ID and secret-access-key assignment shapes; provider validity and permissions were not tested.",
921 900_000,
922 "exposure.remediation.remove-aws-credential-pair@1",
923 "Remove credential assignments from served content, review and rotate them through the responsible operator, and verify the authorized response no longer contains them.",
924 );
925const STRIPE_LIVE_SECRET_CAPABILITY: AssessmentCapabilityDescriptor =
926 AssessmentCapabilityDescriptor::informational(
927 "exposure.response-stripe-live-secret@1",
928 "Stripe live secret-key shape was returned",
929 "passive-secret-exposure",
930 "A complete eligible response contained a Stripe live secret or restricted-key shape; provider validity, account ownership, and permissions were not tested.",
931 900_000,
932 "exposure.remediation.remove-stripe-live-secret@1",
933 "Remove live secret-key material from served content, review and rotate it through the responsible operator, and verify the authorized response no longer contains it.",
934 );
935const BEARER_AUTHORIZATION_CAPABILITY: AssessmentCapabilityDescriptor =
936 AssessmentCapabilityDescriptor::informational(
937 "exposure.response-bearer-authorization@1",
938 "Bearer authorization assignment was returned",
939 "passive-secret-exposure",
940 "A complete eligible response contained an explicit Authorization Bearer assignment; token validity, intended audience, and permissions were not tested.",
941 850_000,
942 "exposure.remediation.remove-bearer-authorization@1",
943 "Remove bearer authorization material from served content, invalidate it where appropriate, and verify the authorized response no longer contains it.",
944 );
945
946#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
948#[non_exhaustive]
949pub enum SecretExposureResponseOutcome {
950 Evaluated,
951 MethodNotGet,
952 StatusNot200,
953 IncompleteBody,
954 RequestContextIneligible,
955 ContentCoded,
956 ContentLengthInconsistent,
957 UnsupportedMediaType,
958 InvalidUtf8,
959 ResponseByteLimitExceeded,
960 TotalByteLimitExceeded,
961 DetectorLimitExceeded,
962}
963
964impl SecretExposureResponseOutcome {
965 pub const fn as_str(self) -> &'static str {
966 match self {
967 Self::Evaluated => "evaluated",
968 Self::MethodNotGet => "method_not_get",
969 Self::StatusNot200 => "status_not_200",
970 Self::IncompleteBody => "incomplete_body",
971 Self::RequestContextIneligible => "request_context_ineligible",
972 Self::ContentCoded => "content_coded",
973 Self::ContentLengthInconsistent => "content_length_inconsistent",
974 Self::UnsupportedMediaType => "unsupported_media_type",
975 Self::InvalidUtf8 => "invalid_utf8",
976 Self::ResponseByteLimitExceeded => "response_byte_limit_exceeded",
977 Self::TotalByteLimitExceeded => "total_byte_limit_exceeded",
978 Self::DetectorLimitExceeded => "detector_limit_exceeded",
979 }
980 }
981
982 fn parse(value: &str) -> Option<Self> {
983 Self::ALL.into_iter().find(|item| item.as_str() == value)
984 }
985
986 const ALL: [Self; 12] = [
987 Self::Evaluated,
988 Self::MethodNotGet,
989 Self::StatusNot200,
990 Self::IncompleteBody,
991 Self::RequestContextIneligible,
992 Self::ContentCoded,
993 Self::ContentLengthInconsistent,
994 Self::UnsupportedMediaType,
995 Self::InvalidUtf8,
996 Self::ResponseByteLimitExceeded,
997 Self::TotalByteLimitExceeded,
998 Self::DetectorLimitExceeded,
999 ];
1000}
1001
1002#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1004pub struct SecretExposureOutcomeCount {
1005 outcome: SecretExposureResponseOutcome,
1006 count: u32,
1007}
1008
1009impl SecretExposureOutcomeCount {
1010 pub const fn outcome(self) -> SecretExposureResponseOutcome {
1011 self.outcome
1012 }
1013
1014 pub const fn count(self) -> u32 {
1015 self.count
1016 }
1017}
1018
1019#[derive(Clone, PartialEq, Eq)]
1021pub struct SecretExposureObservation {
1022 subject: EntityId,
1023 detector_class: SecretExposureDetectorClass,
1024 occurrence_count: u16,
1025 evidence_ids: Vec<EvidenceId>,
1026}
1027
1028impl fmt::Debug for SecretExposureObservation {
1029 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1030 formatter
1031 .debug_struct("SecretExposureObservation")
1032 .field("detector_class", &self.detector_class)
1033 .field("occurrence_count", &self.occurrence_count)
1034 .field("evidence_count", &self.evidence_ids.len())
1035 .finish()
1036 }
1037}
1038
1039impl SecretExposureObservation {
1040 pub const fn detector_class(&self) -> SecretExposureDetectorClass {
1041 self.detector_class
1042 }
1043
1044 pub const fn occurrence_count(&self) -> u16 {
1045 self.occurrence_count
1046 }
1047
1048 pub fn evidence_ids(&self) -> &[EvidenceId] {
1049 &self.evidence_ids
1050 }
1051}
1052
1053#[derive(Clone, PartialEq, Eq)]
1056pub struct WebAssessmentSecretExposureAudit {
1057 outcomes: Vec<SecretExposureOutcomeCount>,
1058 observations: Vec<SecretExposureObservation>,
1059 response_count: u32,
1060 evaluated_response_count: u32,
1061 interpreted_byte_count: u64,
1062 body_derived_projection_suppressed_response_count: u32,
1063 omitted_observation_count: u32,
1064 match_occurrence_count: u64,
1065}
1066
1067impl fmt::Debug for WebAssessmentSecretExposureAudit {
1068 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1069 formatter
1070 .debug_struct("WebAssessmentSecretExposureAudit")
1071 .field("outcomes", &self.outcomes)
1072 .field("response_count", &self.response_count)
1073 .field("evaluated_response_count", &self.evaluated_response_count)
1074 .field("interpreted_byte_count", &self.interpreted_byte_count)
1075 .field(
1076 "body_derived_projection_suppressed_response_count",
1077 &self.body_derived_projection_suppressed_response_count,
1078 )
1079 .field("observation_count", &self.observations.len())
1080 .field("omitted_observation_count", &self.omitted_observation_count)
1081 .field("match_occurrence_count", &self.match_occurrence_count)
1082 .finish()
1083 }
1084}
1085
1086impl WebAssessmentSecretExposureAudit {
1087 pub fn outcomes(&self) -> &[SecretExposureOutcomeCount] {
1088 &self.outcomes
1089 }
1090
1091 pub fn observations(&self) -> &[SecretExposureObservation] {
1092 &self.observations
1093 }
1094
1095 pub const fn response_count(&self) -> u32 {
1096 self.response_count
1097 }
1098
1099 pub const fn evaluated_response_count(&self) -> u32 {
1100 self.evaluated_response_count
1101 }
1102
1103 pub const fn not_evaluated_response_count(&self) -> u32 {
1104 self.response_count - self.evaluated_response_count
1105 }
1106
1107 pub const fn interpreted_byte_count(&self) -> u64 {
1108 self.interpreted_byte_count
1109 }
1110
1111 pub const fn body_derived_projection_suppressed_response_count(&self) -> u32 {
1112 self.body_derived_projection_suppressed_response_count
1113 }
1114
1115 pub const fn observation_count(&self) -> usize {
1116 self.observations.len()
1117 }
1118
1119 pub const fn omitted_observation_count(&self) -> u32 {
1120 self.omitted_observation_count
1121 }
1122
1123 pub const fn match_occurrence_count(&self) -> u64 {
1124 self.match_occurrence_count
1125 }
1126}
1127
1128#[derive(Default)]
1129struct SecretExposureCollectorState {
1130 response_count: u32,
1131 admitted_inspection_byte_count: u64,
1132 interpreted_byte_count: u64,
1133 retained_observation_count: usize,
1134}
1135
1136#[derive(Clone, Default)]
1140pub(crate) struct SecretExposureCollector {
1141 state: Arc<Mutex<SecretExposureCollectorState>>,
1142}
1143
1144impl fmt::Debug for SecretExposureCollector {
1145 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1146 formatter
1147 .debug_struct("SecretExposureCollector")
1148 .finish_non_exhaustive()
1149 }
1150}
1151
1152impl SecretExposureCollector {
1153 pub(crate) fn observe(
1154 &self,
1155 observation: &crate::http_evidence::CompleteHttpResponseObservation<'_>,
1156 parents: Vec<EvidenceId>,
1157 ) -> Result<Vec<Evidence>, HttpEvidenceError> {
1158 let mut state =
1159 self.state
1160 .lock()
1161 .map_err(|_| HttpEvidenceError::AssessmentObserverInvariant {
1162 invariant: "secret-exposure collector lock",
1163 })?;
1164 if usize::try_from(state.response_count).unwrap_or(usize::MAX)
1165 >= MAX_SECRET_EXPOSURE_RESPONSES
1166 {
1167 return Err(HttpEvidenceError::AssessmentObserverInvariant {
1168 invariant: "secret-exposure response limit",
1169 });
1170 }
1171 state.response_count = state.response_count.saturating_add(1);
1172 let response_sequence = state.response_count;
1173
1174 let mut scan = None;
1175 let mut inspected_body_bytes = 0_u64;
1176 let outcome = if observation.method() != HttpProbeMethod::Get {
1177 SecretExposureResponseOutcome::MethodNotGet
1178 } else if observation.status() != 200 {
1179 SecretExposureResponseOutcome::StatusNot200
1180 } else if observation.complete_body().is_none() {
1181 SecretExposureResponseOutcome::IncompleteBody
1182 } else if !observation.request_headers_empty()
1183 || !observation.response_final_url_matches_request()
1184 {
1185 SecretExposureResponseOutcome::RequestContextIneligible
1186 } else if !observation.response_content_encoding_absent() {
1187 SecretExposureResponseOutcome::ContentCoded
1188 } else if !observation.response_content_length_consistent() {
1189 SecretExposureResponseOutcome::ContentLengthInconsistent
1190 } else if !supported_media_type(observation.media_type()) {
1191 SecretExposureResponseOutcome::UnsupportedMediaType
1192 } else {
1193 let body = observation.complete_body().expect("checked complete body");
1194 if u64::try_from(body.len()).unwrap_or(u64::MAX) != observation.response_body_bytes() {
1195 SecretExposureResponseOutcome::ContentLengthInconsistent
1196 } else if body.len() > MAX_SECRET_EXPOSURE_BODY_BYTES {
1197 SecretExposureResponseOutcome::ResponseByteLimitExceeded
1198 } else {
1199 let admitted_bytes = u64::try_from(body.len()).unwrap_or(u64::MAX);
1200 match state
1201 .admitted_inspection_byte_count
1202 .checked_add(admitted_bytes)
1203 .filter(|total| *total <= MAX_SECRET_EXPOSURE_TOTAL_BODY_BYTES)
1204 {
1205 None => SecretExposureResponseOutcome::TotalByteLimitExceeded,
1206 Some(total) => {
1207 state.admitted_inspection_byte_count = total;
1213 match scan_complete_text_body(body) {
1214 Ok(result) => {
1215 inspected_body_bytes = result.inspected_body_bytes() as u64;
1216 state.interpreted_byte_count = state
1217 .interpreted_byte_count
1218 .checked_add(inspected_body_bytes)
1219 .expect("admitted detector bytes bound interpreted bytes");
1220 scan = Some(result);
1221 SecretExposureResponseOutcome::Evaluated
1222 },
1223 Err(SecretExposureScanError::InvalidUtf8 { .. }) => {
1224 SecretExposureResponseOutcome::InvalidUtf8
1225 },
1226 Err(SecretExposureScanError::BodyLimitExceeded { .. }) => {
1227 SecretExposureResponseOutcome::ResponseByteLimitExceeded
1228 },
1229 Err(
1230 SecretExposureScanError::PemPayloadLimitExceeded { .. }
1231 | SecretExposureScanError::TokenLimitExceeded { .. }
1232 | SecretExposureScanError::OccurrenceLimitExceeded { .. },
1233 ) => SecretExposureResponseOutcome::DetectorLimitExceeded,
1234 }
1235 },
1236 }
1237 }
1238 };
1239
1240 let mut retained = Vec::new();
1241 let mut omitted = 0_u64;
1242 if let Some(result) = scan.as_ref() {
1243 for row in result.class_counts() {
1244 if state.retained_observation_count < MAX_SECRET_EXPOSURE_RETAINED_OBSERVATIONS {
1245 state.retained_observation_count += 1;
1246 retained.push(*row);
1247 } else {
1248 omitted = omitted.saturating_add(1);
1249 }
1250 }
1251 }
1252 drop(state);
1253
1254 let occurrence_count = scan
1255 .as_ref()
1256 .map_or(0, SecretExposureScan::occurrence_count);
1257 let suppress_public_body_digest = true;
1263 let mut records = vec![
1264 (
1265 RESPONSE_SEQUENCE,
1266 EvidenceValue::Unsigned(u64::from(response_sequence)),
1267 ),
1268 (
1269 RESPONSE_OUTCOME,
1270 EvidenceValue::Text(outcome.as_str().to_owned()),
1271 ),
1272 (
1273 INSPECTED_BODY_BYTES,
1274 EvidenceValue::Unsigned(inspected_body_bytes),
1275 ),
1276 (
1277 MATCH_OCCURRENCE_COUNT,
1278 EvidenceValue::Unsigned(u64::from(occurrence_count)),
1279 ),
1280 (OMITTED_OBSERVATION_COUNT, EvidenceValue::Unsigned(omitted)),
1281 (
1282 BODY_DERIVED_PROJECTION_SUPPRESSED,
1283 EvidenceValue::Boolean(suppress_public_body_digest),
1284 ),
1285 ];
1286 records.extend(retained.iter().map(|row| {
1287 (
1288 row.class().predicate_name(),
1289 EvidenceValue::Unsigned(u64::from(row.occurrence_count())),
1290 )
1291 }));
1292 if suppress_public_body_digest {
1293 records.push((
1294 RESPONSE_BODY_LINEAGE,
1295 EvidenceValue::Text(outcome.as_str().to_owned()),
1296 ));
1297 }
1298 let derivation = EvidenceDerivation::new(
1299 parents,
1300 DerivationAlgorithm::new(SECRET_EXPOSURE_ALGORITHM, SECRET_EXPOSURE_ALGORITHM_VERSION)?,
1301 )?;
1302 let source = EvidenceSource::new(HTTP_EVIDENCE_EXECUTOR_ID, SECRET_EXPOSURE_SOURCE_METHOD)?
1303 .with_correlation_id(observation.case_id())?;
1304 records
1305 .into_iter()
1306 .map(|(name, value)| {
1307 Ok(Evidence::new(
1308 observation.subject().clone(),
1309 EvidenceKind::Custom(SECRET_EXPOSURE_CATEGORY.to_owned()),
1310 KnowledgePredicate::new(SECRET_EXPOSURE_NAMESPACE, name)?,
1311 value,
1312 source.clone(),
1313 observation.reliability(),
1314 )
1315 .derived_from(derivation.clone()))
1316 })
1317 .collect()
1318 }
1319}
1320
1321fn supported_media_type(media_type: Option<&str>) -> bool {
1322 let Some(media_type) = media_type else {
1323 return false;
1324 };
1325 media_type.starts_with("text/")
1326 || matches!(
1327 media_type,
1328 "application/json"
1329 | "application/ld+json"
1330 | "application/javascript"
1331 | "application/x-javascript"
1332 | "application/xml"
1333 | "application/xhtml+xml"
1334 | "application/x-www-form-urlencoded"
1335 | "image/svg+xml"
1336 )
1337 || media_type.ends_with("+json")
1338 || media_type.ends_with("+xml")
1339}
1340
1341pub(crate) fn is_secret_exposure_response_lineage(
1344 evidence: &Evidence,
1345 expected_subject: &EntityId,
1346 expected_correlation_id: &str,
1347 expected_parent_ids: &[EvidenceId],
1348) -> bool {
1349 let EvidenceValue::Text(outcome) = evidence.value() else {
1350 return false;
1351 };
1352 let Some(derivation) = evidence.origin().derivation() else {
1353 return false;
1354 };
1355 let mut actual_parents = derivation.parents().to_vec();
1356 actual_parents.sort();
1357 let mut expected_parents = expected_parent_ids.to_vec();
1358 expected_parents.sort();
1359 evidence.subject() == expected_subject
1360 && evidence.kind() == &EvidenceKind::Custom(SECRET_EXPOSURE_CATEGORY.to_owned())
1361 && evidence.predicate().namespace() == SECRET_EXPOSURE_NAMESPACE
1362 && evidence.predicate().name() == RESPONSE_BODY_LINEAGE
1363 && evidence.source().component() == HTTP_EVIDENCE_EXECUTOR_ID
1364 && evidence.source().method() == SECRET_EXPOSURE_SOURCE_METHOD
1365 && evidence.source().correlation_id() == Some(expected_correlation_id)
1366 && SecretExposureResponseOutcome::parse(outcome).is_some()
1367 && derivation.algorithm().name() == SECRET_EXPOSURE_ALGORITHM
1368 && derivation.algorithm().version() == SECRET_EXPOSURE_ALGORITHM_VERSION
1369 && !expected_parents.is_empty()
1370 && !expected_parents.windows(2).any(|pair| pair[0] == pair[1])
1371 && actual_parents == expected_parents
1372}
1373
1374pub(crate) fn secret_exposure_response_lineage_id(
1375 evidence: &[Evidence],
1376 expected_subject: &EntityId,
1377 expected_correlation_id: &str,
1378 expected_parent_ids: &[EvidenceId],
1379) -> Option<EvidenceId> {
1380 let mut matching = evidence.iter().filter(|item| {
1381 is_secret_exposure_response_lineage(
1382 item,
1383 expected_subject,
1384 expected_correlation_id,
1385 expected_parent_ids,
1386 )
1387 });
1388 let id = matching.next()?.id().clone();
1389 matching.next().is_none().then_some(id)
1390}
1391
1392pub(crate) fn project_active_review_body_privacy_lineage(
1397 observation: &crate::http_evidence::CompleteHttpResponseObservation<'_>,
1398) -> Result<Evidence, HttpEvidenceError> {
1399 let parents = secret_exposure_observation_parent_ids(observation)?;
1400 project_active_review_privacy_parts(
1401 observation.subject(),
1402 observation.executor_id(),
1403 observation.case_id(),
1404 observation.reliability(),
1405 parents,
1406 )
1407}
1408
1409fn project_active_review_privacy_parts(
1410 subject: &EntityId,
1411 executor_id: &str,
1412 case_id: &str,
1413 reliability: ConfidenceScore,
1414 parents: Vec<EvidenceId>,
1415) -> Result<Evidence, HttpEvidenceError> {
1416 let derivation = EvidenceDerivation::new(
1417 parents,
1418 DerivationAlgorithm::new(
1419 ACTIVE_REVIEW_PRIVACY_ALGORITHM,
1420 ACTIVE_REVIEW_PRIVACY_ALGORITHM_VERSION,
1421 )?,
1422 )?;
1423 let source = EvidenceSource::new(executor_id, ACTIVE_REVIEW_PRIVACY_SOURCE_METHOD)?
1424 .with_correlation_id(case_id)?;
1425 Ok(Evidence::new(
1426 subject.clone(),
1427 EvidenceKind::Custom(SECRET_EXPOSURE_CATEGORY.to_owned()),
1428 KnowledgePredicate::new(SECRET_EXPOSURE_NAMESPACE, ACTIVE_REVIEW_BODY_LINEAGE)?,
1429 EvidenceValue::Boolean(true),
1430 source,
1431 reliability,
1432 )
1433 .derived_from(derivation))
1434}
1435
1436pub(crate) fn is_active_review_body_privacy_lineage(
1437 evidence: &Evidence,
1438 expected_subject: &EntityId,
1439 expected_correlation_id: &str,
1440 expected_executor_id: &str,
1441 expected_parent_ids: &[EvidenceId],
1442) -> bool {
1443 let Some(derivation) = evidence.origin().derivation() else {
1444 return false;
1445 };
1446 let mut actual_parents = derivation.parents().to_vec();
1447 actual_parents.sort();
1448 let mut expected_parents = expected_parent_ids.to_vec();
1449 expected_parents.sort();
1450 evidence.subject() == expected_subject
1451 && evidence.kind() == &EvidenceKind::Custom(SECRET_EXPOSURE_CATEGORY.to_owned())
1452 && evidence.predicate().namespace() == SECRET_EXPOSURE_NAMESPACE
1453 && evidence.predicate().name() == ACTIVE_REVIEW_BODY_LINEAGE
1454 && evidence.value() == &EvidenceValue::Boolean(true)
1455 && evidence.source().component() == expected_executor_id
1456 && evidence.source().method() == ACTIVE_REVIEW_PRIVACY_SOURCE_METHOD
1457 && evidence.source().correlation_id() == Some(expected_correlation_id)
1458 && derivation.algorithm().name() == ACTIVE_REVIEW_PRIVACY_ALGORITHM
1459 && derivation.algorithm().version() == ACTIVE_REVIEW_PRIVACY_ALGORITHM_VERSION
1460 && !expected_parents.is_empty()
1461 && !expected_parents.windows(2).any(|pair| pair[0] == pair[1])
1462 && actual_parents == expected_parents
1463}
1464
1465pub(crate) fn secret_exposure_observation_parent_ids(
1466 observation: &crate::http_evidence::CompleteHttpResponseObservation<'_>,
1467) -> Result<Vec<EvidenceId>, HttpEvidenceError> {
1468 let mut parents = [
1469 (
1470 observation.request_method_evidence_id(),
1471 "secret-exposure-request-method-evidence",
1472 ),
1473 (
1474 observation.request_url_evidence_id(),
1475 "secret-exposure-request-url-evidence",
1476 ),
1477 (
1478 observation.response_status_evidence_id(),
1479 "secret-exposure-response-status-evidence",
1480 ),
1481 (
1482 observation.response_final_url_evidence_id(),
1483 "secret-exposure-response-final-url-evidence",
1484 ),
1485 (
1486 observation.response_body_bytes_evidence_id(),
1487 "secret-exposure-response-body-byte-count-evidence",
1488 ),
1489 (
1490 observation.response_body_truncated_evidence_id(),
1491 "secret-exposure-response-body-truncated-evidence",
1492 ),
1493 ]
1494 .into_iter()
1495 .map(|(id, invariant)| {
1496 id.cloned()
1497 .ok_or(HttpEvidenceError::AssessmentObserverInvariant { invariant })
1498 })
1499 .collect::<Result<Vec<_>, _>>()?;
1500 if let Some(media) = observation.response_media_type_evidence_id() {
1501 parents.push(media.clone());
1502 }
1503 Ok(parents)
1504}
1505
1506pub(crate) fn secret_exposure_response_parent_ids(
1510 evidence: &[Evidence],
1511 expected_subject: &EntityId,
1512 expected_correlation_id: &str,
1513) -> Option<Vec<EvidenceId>> {
1514 secret_exposure_response_parent_ids_for_executor(
1515 evidence,
1516 expected_subject,
1517 expected_correlation_id,
1518 HTTP_EVIDENCE_EXECUTOR_ID,
1519 )
1520}
1521
1522pub(crate) fn secret_exposure_response_parent_ids_for_executor(
1523 evidence: &[Evidence],
1524 expected_subject: &EntityId,
1525 expected_correlation_id: &str,
1526 expected_executor_id: &str,
1527) -> Option<Vec<EvidenceId>> {
1528 response_parent_ids(
1529 evidence,
1530 expected_subject,
1531 expected_correlation_id,
1532 ResponseParentContext {
1533 executor_id: expected_executor_id,
1534 graphql_post: false,
1535 },
1536 )
1537}
1538
1539#[cfg(feature = "graphql-review")]
1543pub(crate) fn graphql_response_privacy_parent_ids(
1544 evidence: &[Evidence],
1545 case: &crate::VerificationCase,
1546 stage: DecisionExecutionStage,
1547 executor_id: &str,
1548) -> Option<Vec<EvidenceId>> {
1549 if !super::graphql_runtime::graphql_privacy_route_is_valid(case, stage, executor_id) {
1550 return None;
1551 }
1552 response_parent_ids(
1553 evidence,
1554 case.subject(),
1555 case.id(),
1556 ResponseParentContext {
1557 executor_id,
1558 graphql_post: true,
1559 },
1560 )
1561}
1562
1563#[cfg(feature = "graphql-review")]
1564pub(crate) fn project_graphql_body_privacy_lineage(
1565 decision: &crate::DecisionExecutionRequest,
1566 evidence: &[Evidence],
1567 executor_id: &str,
1568 reliability: ConfidenceScore,
1569) -> Result<Evidence, HttpEvidenceError> {
1570 let parents = graphql_response_privacy_parent_ids(
1571 evidence,
1572 decision.case(),
1573 decision.stage(),
1574 executor_id,
1575 )
1576 .ok_or(HttpEvidenceError::AssessmentObserverInvariant {
1577 invariant: "GraphQL privacy lineage requires closed POST transport provenance",
1578 })?;
1579 project_active_review_privacy_parts(
1580 decision.case().subject(),
1581 executor_id,
1582 decision.case().id(),
1583 reliability,
1584 parents,
1585 )
1586}
1587
1588#[derive(Clone, Copy)]
1589struct ResponseParentContext<'a> {
1590 executor_id: &'a str,
1591 graphql_post: bool,
1592}
1593
1594#[cfg(feature = "openapi-review")]
1597pub(crate) fn openapi_response_privacy_parent_ids(
1598 evidence: &[Evidence],
1599 case: &crate::VerificationCase,
1600 stage: DecisionExecutionStage,
1601 executor_id: &str,
1602) -> Option<Vec<EvidenceId>> {
1603 if !super::openapi_runtime::openapi_privacy_route_is_valid(case, stage, executor_id)
1604 || !evidence.iter().any(|item| {
1605 item.predicate() == &HttpEvidencePredicate::REQUEST_METHOD.into_knowledge()
1606 && item.value() == &EvidenceValue::Text("GET".to_owned())
1607 })
1608 {
1609 return None;
1610 }
1611 secret_exposure_response_parent_ids_for_executor(
1612 evidence,
1613 case.subject(),
1614 case.id(),
1615 executor_id,
1616 )
1617}
1618
1619#[cfg(feature = "openapi-review")]
1620pub(crate) fn project_openapi_body_privacy_lineage(
1621 decision: &crate::DecisionExecutionRequest,
1622 evidence: &[Evidence],
1623 executor_id: &str,
1624 reliability: ConfidenceScore,
1625) -> Result<Evidence, HttpEvidenceError> {
1626 let parents = openapi_response_privacy_parent_ids(
1627 evidence,
1628 decision.case(),
1629 decision.stage(),
1630 executor_id,
1631 )
1632 .ok_or(HttpEvidenceError::AssessmentObserverInvariant {
1633 invariant: "OpenAPI privacy lineage requires closed GET transport provenance",
1634 })?;
1635 project_active_review_privacy_parts(
1636 decision.case().subject(),
1637 executor_id,
1638 decision.case().id(),
1639 reliability,
1640 parents,
1641 )
1642}
1643
1644#[cfg(feature = "rest-review")]
1647pub(crate) fn rest_response_privacy_parent_ids(
1648 evidence: &[Evidence],
1649 case: &crate::VerificationCase,
1650 stage: DecisionExecutionStage,
1651 executor: &str,
1652) -> Option<Vec<EvidenceId>> {
1653 if !super::rest_runtime::rest_privacy_route_is_valid(case, stage, executor)
1654 || !evidence.iter().any(|item| {
1655 item.predicate() == &HttpEvidencePredicate::REQUEST_METHOD.into_knowledge()
1656 && item.value() == &EvidenceValue::Text("GET".to_owned())
1657 })
1658 {
1659 return None;
1660 }
1661 secret_exposure_response_parent_ids_for_executor(evidence, case.subject(), case.id(), executor)
1662}
1663
1664#[cfg(feature = "rest-review")]
1665pub(crate) fn project_rest_body_privacy_lineage(
1666 decision: &crate::DecisionExecutionRequest,
1667 evidence: &[Evidence],
1668 executor: &str,
1669 reliability: ConfidenceScore,
1670) -> Result<Evidence, HttpEvidenceError> {
1671 let parents =
1672 rest_response_privacy_parent_ids(evidence, decision.case(), decision.stage(), executor)
1673 .ok_or(HttpEvidenceError::AssessmentObserverInvariant {
1674 invariant: "REST privacy lineage requires closed GET transport provenance",
1675 })?;
1676 project_active_review_privacy_parts(
1677 decision.case().subject(),
1678 executor,
1679 decision.case().id(),
1680 reliability,
1681 parents,
1682 )
1683}
1684
1685fn response_parent_ids(
1686 evidence: &[Evidence],
1687 expected_subject: &EntityId,
1688 expected_correlation_id: &str,
1689 context: ResponseParentContext<'_>,
1690) -> Option<Vec<EvidenceId>> {
1691 let required = [
1692 (
1693 HttpEvidencePredicate::REQUEST_METHOD,
1694 EvidenceKind::Http,
1695 "request-method",
1696 ),
1697 (
1698 HttpEvidencePredicate::REQUEST_URL,
1699 EvidenceKind::Http,
1700 "request-url",
1701 ),
1702 (
1703 HttpEvidencePredicate::RESPONSE_STATUS,
1704 EvidenceKind::Http,
1705 "response-status",
1706 ),
1707 (
1708 HttpEvidencePredicate::RESPONSE_FINAL_URL,
1709 EvidenceKind::Http,
1710 "response-final-url",
1711 ),
1712 (
1713 HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED,
1714 EvidenceKind::Content,
1715 "response-body-size",
1716 ),
1717 (
1718 HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED,
1719 EvidenceKind::Content,
1720 "response-body-truncation",
1721 ),
1722 ];
1723 let mut parent_ids = Vec::with_capacity(required.len() + 1);
1724 for (descriptor, kind, source_method) in required {
1725 let predicate = descriptor.into_knowledge();
1726 let mut matches = evidence
1727 .iter()
1728 .filter(|item| item.predicate() == &predicate);
1729 let item = matches.next()?;
1730 if matches.next().is_some()
1731 || !valid_secret_exposure_response_parent(
1732 item,
1733 descriptor,
1734 &kind,
1735 source_method,
1736 expected_subject,
1737 expected_correlation_id,
1738 context,
1739 )
1740 {
1741 return None;
1742 }
1743 parent_ids.push(item.id().clone());
1744 }
1745
1746 let media_predicate = HttpEvidencePredicate::RESPONSE_MEDIA_TYPE.into_knowledge();
1747 let mut media = evidence
1748 .iter()
1749 .filter(|item| item.predicate() == &media_predicate);
1750 if let Some(item) = media.next() {
1751 if media.next().is_some()
1752 || !valid_secret_exposure_response_parent(
1753 item,
1754 HttpEvidencePredicate::RESPONSE_MEDIA_TYPE,
1755 &EvidenceKind::Http,
1756 "response-media-type",
1757 expected_subject,
1758 expected_correlation_id,
1759 context,
1760 )
1761 {
1762 return None;
1763 }
1764 parent_ids.push(item.id().clone());
1765 }
1766 parent_ids.sort();
1767 (!parent_ids.windows(2).any(|pair| pair[0] == pair[1])).then_some(parent_ids)
1768}
1769
1770fn valid_secret_exposure_response_parent(
1771 evidence: &Evidence,
1772 descriptor: PredicateDescriptor,
1773 expected_kind: &EvidenceKind,
1774 expected_source_method: &str,
1775 expected_subject: &EntityId,
1776 expected_correlation_id: &str,
1777 context: ResponseParentContext<'_>,
1778) -> bool {
1779 if evidence.subject() != expected_subject
1780 || evidence.kind() != expected_kind
1781 || evidence.source().component() != context.executor_id
1782 || evidence.source().method() != expected_source_method
1783 || evidence.source().correlation_id() != Some(expected_correlation_id)
1784 || !matches!(evidence.origin(), EvidenceOrigin::Direct)
1785 {
1786 return false;
1787 }
1788 match descriptor {
1789 HttpEvidencePredicate::REQUEST_METHOD => {
1790 matches!(evidence.value(), EvidenceValue::Text(value) if
1791 if context.graphql_post { value == "POST" } else { matches!(value.as_str(), "GET" | "HEAD") })
1792 },
1793 HttpEvidencePredicate::REQUEST_URL | HttpEvidencePredicate::RESPONSE_FINAL_URL => {
1794 matches!(evidence.value(), EvidenceValue::Text(value) if !value.is_empty())
1795 },
1796 HttpEvidencePredicate::RESPONSE_STATUS => {
1797 matches!(evidence.value(), EvidenceValue::Unsigned(value) if (100..=599).contains(value))
1798 },
1799 HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED => {
1800 matches!(evidence.value(), EvidenceValue::Unsigned(_))
1801 },
1802 HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED => {
1803 matches!(evidence.value(), EvidenceValue::Boolean(_))
1804 },
1805 HttpEvidencePredicate::RESPONSE_MEDIA_TYPE => {
1806 matches!(evidence.value(), EvidenceValue::Text(value) if !value.is_empty())
1807 },
1808 _ => false,
1809 }
1810}
1811
1812#[derive(Default, PartialEq, Eq)]
1813pub(crate) struct CommittedSecretExposureLedger {
1814 receipts: BTreeMap<(EntityId, String, DecisionExecutionStage), Vec<EvidenceId>>,
1815 outcomes: BTreeMap<SecretExposureResponseOutcome, u32>,
1816 observations: Vec<SecretExposureObservation>,
1817 response_count: u32,
1818 interpreted_byte_count: u64,
1819 body_derived_projection_suppressed_response_count: u32,
1820 omitted_observation_count: u32,
1821 match_occurrence_count: u64,
1822}
1823
1824impl fmt::Debug for CommittedSecretExposureLedger {
1825 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1826 formatter
1827 .debug_struct("CommittedSecretExposureLedger")
1828 .field("response_count", &self.response_count)
1829 .field("observation_count", &self.observations.len())
1830 .finish()
1831 }
1832}
1833
1834impl CommittedSecretExposureLedger {
1835 pub(crate) fn ingest_receipt(
1836 &mut self,
1837 receipt: &DecisionEvidenceReceipt,
1838 knowledge: &KnowledgeBase,
1839 expected_subject: &WebAssessmentSubject,
1840 ) -> Result<(), ()> {
1841 if receipt.executor_id() != HTTP_EVIDENCE_EXECUTOR_ID
1842 || receipt.stage() != DecisionExecutionStage::Passive
1843 || receipt.case().id() != BOOTSTRAP_CASE_ID
1844 || receipt.case().action_id() != BOOTSTRAP_ACTION_ID
1845 || receipt.case().hypothesis_id() != BOOTSTRAP_HYPOTHESIS_ID
1846 || receipt.case().payload_strategy().is_some()
1847 || !receipt.case().applies_hypothesis_transition()
1848 || receipt.case().subject().as_str() != format!("endpoint:{}", expected_subject.url())
1849 || receipt.evidence().len() != receipt.writes().len()
1850 {
1851 return Err(());
1852 }
1853 for (evidence, write) in receipt.write_set() {
1854 if !matches!(write, KnowledgeWrite::Inserted | KnowledgeWrite::Unchanged)
1855 || evidence.subject() != receipt.case().subject()
1856 || knowledge.evidence(evidence.id()).as_ref() != Some(evidence)
1857 {
1858 return Err(());
1859 }
1860 }
1861 let secret = receipt
1862 .evidence()
1863 .iter()
1864 .filter(|item| item.predicate().namespace() == SECRET_EXPOSURE_NAMESPACE)
1865 .collect::<Vec<_>>();
1866 if secret.len() < 6 || secret.len() > 11 {
1867 return Err(());
1868 }
1869 let lineage = secret
1870 .iter()
1871 .copied()
1872 .filter(|item| item.predicate().name() == RESPONSE_BODY_LINEAGE)
1873 .collect::<Vec<_>>();
1874 if lineage.len() > 1 {
1875 return Err(());
1876 }
1877 let detail = secret
1878 .iter()
1879 .copied()
1880 .filter(|item| item.predicate().name() != RESPONSE_BODY_LINEAGE)
1881 .collect::<Vec<_>>();
1882 if detail.len() < 6 || detail.len() > 10 {
1883 return Err(());
1884 }
1885 let key = (
1886 receipt.case().subject().clone(),
1887 receipt.case().id().to_owned(),
1888 receipt.stage(),
1889 );
1890 let evidence_ids = secret
1891 .iter()
1892 .map(|item| item.id().clone())
1893 .collect::<Vec<_>>();
1894 if let Some(existing) = self.receipts.get(&key) {
1895 return if existing == &evidence_ids {
1896 Ok(())
1897 } else {
1898 Err(())
1899 };
1900 }
1901
1902 let expected_parents = secret_exposure_response_parent_ids(
1903 receipt.evidence(),
1904 receipt.case().subject(),
1905 receipt.case().id(),
1906 )
1907 .ok_or(())?;
1908 let first = *detail.first().ok_or(())?;
1909 let EvidenceOrigin::Derived(first_derivation) = first.origin() else {
1910 return Err(());
1911 };
1912 let mut canonical_parents = first_derivation.parents().to_vec();
1913 canonical_parents.sort();
1914 if canonical_parents != expected_parents
1915 || first_derivation.algorithm().name() != SECRET_EXPOSURE_ALGORITHM
1916 || first_derivation.algorithm().version() != SECRET_EXPOSURE_ALGORITHM_VERSION
1917 {
1918 return Err(());
1919 }
1920 for item in &secret {
1921 let EvidenceOrigin::Derived(derivation) = item.origin() else {
1922 return Err(());
1923 };
1924 if item.subject() != receipt.case().subject()
1925 || item.kind() != &EvidenceKind::Custom(SECRET_EXPOSURE_CATEGORY.to_owned())
1926 || item.source().component() != HTTP_EVIDENCE_EXECUTOR_ID
1927 || item.source().method() != SECRET_EXPOSURE_SOURCE_METHOD
1928 || item.source().correlation_id() != Some(receipt.case().id())
1929 || derivation.algorithm().name() != SECRET_EXPOSURE_ALGORITHM
1930 || derivation.algorithm().version() != SECRET_EXPOSURE_ALGORITHM_VERSION
1931 || {
1932 let mut parents = derivation.parents().to_vec();
1933 parents.sort();
1934 parents != canonical_parents
1935 }
1936 {
1937 return Err(());
1938 }
1939 }
1940
1941 let sequence = expect_unsigned(detail[0], RESPONSE_SEQUENCE)?;
1942 if sequence != u64::from(self.response_count.saturating_add(1)) {
1943 return Err(());
1944 }
1945 let outcome = expect_text(detail[1], RESPONSE_OUTCOME)
1946 .and_then(SecretExposureResponseOutcome::parse)
1947 .ok_or(())?;
1948 let interpreted = expect_unsigned(detail[2], INSPECTED_BODY_BYTES)?;
1949 let occurrences = expect_unsigned(detail[3], MATCH_OCCURRENCE_COUNT)?;
1950 let omitted = expect_unsigned(detail[4], OMITTED_OBSERVATION_COUNT)?;
1951 let suppression_declared = expect_boolean(detail[5], BODY_DERIVED_PROJECTION_SUPPRESSED)?;
1952 if occurrences > u64::from(MAX_SECRET_EXPOSURE_OCCURRENCES)
1953 || omitted > 4
1954 || (outcome == SecretExposureResponseOutcome::Evaluated
1955 && interpreted > MAX_SECRET_EXPOSURE_BODY_BYTES as u64)
1956 || (outcome != SecretExposureResponseOutcome::Evaluated && interpreted != 0)
1957 {
1958 return Err(());
1959 }
1960 let mut class_occurrences = 0_u64;
1961 let mut seen_classes = BTreeMap::new();
1962 let mut pending_observations = Vec::new();
1963 for item in &detail[6..] {
1964 let class = SecretExposureDetectorClass::parse_predicate_name(item.predicate().name())
1965 .ok_or(())?;
1966 let count = expect_unsigned(item, class.predicate_name())?;
1967 if count == 0 || count > u64::from(MAX_SECRET_EXPOSURE_OCCURRENCES) {
1968 return Err(());
1969 }
1970 if seen_classes.insert(class, ()).is_some() {
1971 return Err(());
1972 }
1973 class_occurrences = class_occurrences.checked_add(count).ok_or(())?;
1974 pending_observations.push(SecretExposureObservation {
1975 subject: receipt.case().subject().clone(),
1976 detector_class: class,
1977 occurrence_count: u16::try_from(count).map_err(|_| ())?,
1978 evidence_ids: vec![item.id().clone()],
1979 });
1980 }
1981 if outcome != SecretExposureResponseOutcome::Evaluated
1982 && (occurrences != 0 || omitted != 0 || !pending_observations.is_empty())
1983 {
1984 return Err(());
1985 }
1986 if !suppression_declared {
1987 return Err(());
1988 }
1989 match lineage.as_slice() {
1990 [item]
1991 if is_secret_exposure_response_lineage(
1992 item,
1993 receipt.case().subject(),
1994 receipt.case().id(),
1995 &expected_parents,
1996 ) => {},
1997 _ => return Err(()),
1998 }
1999 if omitted == 0 && class_occurrences != occurrences {
2000 return Err(());
2001 }
2002 if omitted != 0 && class_occurrences >= occurrences {
2003 return Err(());
2004 }
2005 let next_interpreted = self
2006 .interpreted_byte_count
2007 .checked_add(interpreted)
2008 .filter(|total| *total <= MAX_SECRET_EXPOSURE_TOTAL_BODY_BYTES)
2009 .ok_or(())?;
2010 if self
2011 .observations
2012 .len()
2013 .checked_add(pending_observations.len())
2014 .is_none_or(|count| count > MAX_SECRET_EXPOSURE_RETAINED_OBSERVATIONS)
2015 {
2016 return Err(());
2017 }
2018 let next_omitted = self
2019 .omitted_observation_count
2020 .checked_add(u32::try_from(omitted).map_err(|_| ())?)
2021 .ok_or(())?;
2022 let next_occurrences = self
2023 .match_occurrence_count
2024 .checked_add(occurrences)
2025 .ok_or(())?;
2026 let next_suppressed = self
2027 .body_derived_projection_suppressed_response_count
2028 .checked_add(1)
2029 .ok_or(())?;
2030
2031 self.response_count = self.response_count.saturating_add(1);
2032 self.interpreted_byte_count = next_interpreted;
2033 self.body_derived_projection_suppressed_response_count = next_suppressed;
2034 self.omitted_observation_count = next_omitted;
2035 self.match_occurrence_count = next_occurrences;
2036 *self.outcomes.entry(outcome).or_default() += 1;
2037 self.observations.extend(pending_observations);
2038 self.receipts.insert(key, evidence_ids);
2039 Ok(())
2040 }
2041
2042 pub(crate) fn audit(&self) -> WebAssessmentSecretExposureAudit {
2043 let outcomes = SecretExposureResponseOutcome::ALL
2044 .into_iter()
2045 .filter_map(|outcome| {
2046 self.outcomes
2047 .get(&outcome)
2048 .copied()
2049 .map(|count| SecretExposureOutcomeCount { outcome, count })
2050 })
2051 .collect();
2052 WebAssessmentSecretExposureAudit {
2053 outcomes,
2054 observations: self.observations.clone(),
2055 response_count: self.response_count,
2056 evaluated_response_count: self
2057 .outcomes
2058 .get(&SecretExposureResponseOutcome::Evaluated)
2059 .copied()
2060 .unwrap_or(0),
2061 interpreted_byte_count: self.interpreted_byte_count,
2062 body_derived_projection_suppressed_response_count: self
2063 .body_derived_projection_suppressed_response_count,
2064 omitted_observation_count: self.omitted_observation_count,
2065 match_occurrence_count: self.match_occurrence_count,
2066 }
2067 }
2068}
2069
2070fn expect_boolean(item: &Evidence, name: &str) -> Result<bool, ()> {
2071 if item.predicate().name() != name {
2072 return Err(());
2073 }
2074 match item.value() {
2075 EvidenceValue::Boolean(value) => Ok(*value),
2076 _ => Err(()),
2077 }
2078}
2079
2080fn expect_unsigned(item: &Evidence, name: &str) -> Result<u64, ()> {
2081 if item.predicate().name() != name {
2082 return Err(());
2083 }
2084 match item.value() {
2085 EvidenceValue::Unsigned(value) => Ok(*value),
2086 _ => Err(()),
2087 }
2088}
2089
2090fn expect_text<'a>(item: &'a Evidence, name: &str) -> Option<&'a str> {
2091 if item.predicate().name() != name {
2092 return None;
2093 }
2094 match item.value() {
2095 EvidenceValue::Text(value) => Some(value),
2096 _ => None,
2097 }
2098}
2099
2100pub(crate) fn project_secret_exposure_items(
2101 context: &mut AssessmentProjectionContext,
2102 knowledge: &KnowledgeBase,
2103 ledger: &CommittedSecretExposureLedger,
2104) -> Result<(), AssessmentItemProjectionError> {
2105 let target = AssessmentItemTarget::subject();
2106 for observation in &ledger.observations {
2107 for evidence_id in &observation.evidence_ids {
2108 context.register_evidence(knowledge, evidence_id)?;
2109 }
2110 context.project_observation(
2111 observation.detector_class.capability(),
2112 knowledge,
2113 &observation.subject,
2114 &target,
2115 &observation.evidence_ids,
2116 )?;
2117 }
2118 Ok(())
2119}
2120
2121fn looks_like_placeholder(value: &[u8]) -> bool {
2122 [
2123 b"example".as_slice(),
2124 b"placeholder".as_slice(),
2125 b"redacted".as_slice(),
2126 b"changeme".as_slice(),
2127 b"replace_me".as_slice(),
2128 b"replace-me".as_slice(),
2129 b"your_".as_slice(),
2130 b"your-".as_slice(),
2131 b"sample".as_slice(),
2132 ]
2133 .into_iter()
2134 .any(|needle| contains_ascii_case_insensitive(value, needle))
2135}
2136
2137fn strip_ascii_case_insensitive_prefix<'a>(value: &'a [u8], prefix: &[u8]) -> Option<&'a [u8]> {
2138 (value.len() >= prefix.len() && equals_ascii_case_insensitive(&value[..prefix.len()], prefix))
2139 .then_some(&value[prefix.len()..])
2140}
2141
2142fn contains_ascii_case_insensitive(haystack: &[u8], needle: &[u8]) -> bool {
2143 !needle.is_empty()
2144 && haystack
2145 .windows(needle.len())
2146 .any(|candidate| equals_ascii_case_insensitive(candidate, needle))
2147}
2148
2149fn equals_ascii_case_insensitive(left: &[u8], right: &[u8]) -> bool {
2150 left.len() == right.len()
2151 && left
2152 .iter()
2153 .zip(right)
2154 .all(|(left, right)| left.eq_ignore_ascii_case(right))
2155}
2156
2157fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
2158 (!needle.is_empty() && needle.len() <= haystack.len())
2159 .then(|| {
2160 haystack
2161 .windows(needle.len())
2162 .position(|window| window == needle)
2163 })
2164 .flatten()
2165}
2166
2167#[cfg(test)]
2168mod tests {
2169 use super::*;
2170 use crate::http_evidence::{
2171 complete_http_response_observation_for_test, passive_response_projection_for_test,
2172 CompleteHttpResponseObservationTestInput,
2173 };
2174 use termivar_core::ConfidenceScore;
2175 use url::Url;
2176
2177 const AWS_ID: &str = "AKIAZ9Y8X7W6V5U4T3S2";
2178 const AWS_SECRET: &str = "aB3dE5fG7hI9jK1mN3pQ5rS7tU9vW1xY3zA5bC7d";
2179 const STRIPE_PAYLOAD: &str = "Ab3dEf5hIj7kLm9nPq2rSt4v";
2180 const BEARER_TOKEN: &str = "eyJhbGciOiJIUzI1NiJ9.AbCdEfGhIjKlMnOp.QrStUvWxYz12";
2181
2182 fn count(body: &[u8], class: SecretExposureDetectorClass) -> u16 {
2183 scan_complete_text_body(body)
2184 .expect("synthetic text body should scan")
2185 .count_for(class)
2186 }
2187
2188 fn observe_body(
2189 collector: &SecretExposureCollector,
2190 subject: &EntityId,
2191 url: &Url,
2192 body: &[u8],
2193 ) -> Vec<Evidence> {
2194 let passive = passive_response_projection_for_test(&[]);
2195 let parents = (0..6).map(|_| EvidenceId::new()).collect::<Vec<_>>();
2196 let observation =
2197 complete_http_response_observation_for_test(CompleteHttpResponseObservationTestInput {
2198 case_id: BOOTSTRAP_CASE_ID,
2199 action_id: BOOTSTRAP_ACTION_ID,
2200 executor_id: HTTP_EVIDENCE_EXECUTOR_ID,
2201 hypothesis_id: BOOTSTRAP_HYPOTHESIS_ID,
2202 has_payload_strategy: false,
2203 payload_strategy: None,
2204 applies_hypothesis_transition: true,
2205 stage: DecisionExecutionStage::Passive,
2206 subject,
2207 method: HttpProbeMethod::Get,
2208 requested_url: url,
2209 status: 200,
2210 media_type: Some("text/plain"),
2211 reliability: ConfidenceScore::MAX,
2212 complete_body: Some(body),
2213 request_method_evidence_id: Some(&parents[0]),
2214 request_url_evidence_id: Some(&parents[1]),
2215 response_status_evidence_id: Some(&parents[2]),
2216 response_final_url_evidence_id: Some(&parents[3]),
2217 response_media_type_evidence_id: Some(&parents[4]),
2218 response_body_truncated_evidence_id: Some(&parents[5]),
2219 response_body_digest_evidence_id: None,
2220 passive_response_projection: &passive,
2221 review_response_projection: None,
2222 });
2223 collector
2224 .observe(&observation, parents.clone())
2225 .expect("bounded synthetic response should project")
2226 }
2227
2228 fn projected_outcome(evidence: &[Evidence]) -> &str {
2229 let row = evidence
2230 .iter()
2231 .find(|item| item.predicate().name() == RESPONSE_OUTCOME)
2232 .expect("projection contains response outcome");
2233 let EvidenceValue::Text(outcome) = row.value() else {
2234 panic!("response outcome must be text")
2235 };
2236 outcome
2237 }
2238
2239 fn pem(label: PemLabel, payload: &str) -> String {
2240 format!(
2241 "{}\n{}\n{}\n",
2242 str::from_utf8(label.begin()).expect("fixed marker is UTF-8"),
2243 payload,
2244 str::from_utf8(label.end()).expect("fixed marker is UTF-8")
2245 )
2246 }
2247
2248 #[test]
2249 fn detector_class_tokens_and_output_order_are_stable() {
2250 let body = format!(
2251 "Authorization: Bearer {BEARER_TOKEN}\nstripe=sk_live_{STRIPE_PAYLOAD}\nAWS_ACCESS_KEY_ID={AWS_ID}\nAWS_SECRET_ACCESS_KEY={AWS_SECRET}\n{}",
2252 pem(PemLabel::Pkcs8, &format!("M{}", "A".repeat(63)))
2253 );
2254 let scan = scan_complete_text_body(body.as_bytes()).expect("fixture should scan");
2255 let actual = scan
2256 .class_counts()
2257 .iter()
2258 .map(|entry| (entry.class().as_str(), entry.occurrence_count()))
2259 .collect::<Vec<_>>();
2260 assert_eq!(
2261 actual,
2262 vec![
2263 ("pem_private_key_block", 1),
2264 ("aws_access_key_pair", 1),
2265 ("stripe_live_secret", 1),
2266 ("authorization_bearer_assignment", 1),
2267 ]
2268 );
2269 assert_eq!(scan.occurrence_count(), 4);
2270 assert_eq!(scan.inspected_body_bytes(), body.len());
2271 }
2272
2273 #[test]
2274 fn body_limit_accepts_limit_and_rejects_limit_plus_one() {
2275 let accepted = vec![b' '; MAX_SECRET_EXPOSURE_BODY_BYTES];
2276 let scan = scan_complete_text_body(&accepted).expect("exact body limit should scan");
2277 assert_eq!(scan.inspected_body_bytes(), accepted.len());
2278 assert_eq!(scan.occurrence_count(), 0);
2279
2280 let rejected = vec![b' '; MAX_SECRET_EXPOSURE_BODY_BYTES + 1];
2281 assert_eq!(
2282 scan_complete_text_body(&rejected),
2283 Err(SecretExposureScanError::BodyLimitExceeded {
2284 body_bytes: rejected.len(),
2285 max_body_bytes: MAX_SECRET_EXPOSURE_BODY_BYTES,
2286 })
2287 );
2288 }
2289
2290 #[test]
2291 fn invalid_utf8_is_value_free_refusal() {
2292 let body = b"private-canary-\xff";
2293 let error = scan_complete_text_body(body).expect_err("invalid UTF-8 must be refused");
2294 assert_eq!(
2295 error,
2296 SecretExposureScanError::InvalidUtf8 {
2297 body_bytes: body.len()
2298 }
2299 );
2300 assert!(!format!("{error:?} {error}").contains("private-canary"));
2301 }
2302
2303 #[test]
2304 fn admitted_refusals_share_the_four_mib_detector_work_ceiling() {
2305 let collector = SecretExposureCollector::default();
2306 let url = Url::parse("https://example.test/").unwrap();
2307 let subject = EntityId::new("endpoint:https://example.test/").unwrap();
2308
2309 let mut invalid_utf8 = vec![b' '; MAX_SECRET_EXPOSURE_BODY_BYTES];
2310 *invalid_utf8.last_mut().unwrap() = 0xff;
2311 let mut detector_limited = format!(
2312 "Authorization: Bearer {}",
2313 "A".repeat(MAX_SECRET_EXPOSURE_TOKEN_BYTES + 1)
2314 )
2315 .into_bytes();
2316 detector_limited.resize(MAX_SECRET_EXPOSURE_BODY_BYTES, b' ');
2317
2318 for _ in 0..16 {
2319 assert_eq!(
2320 projected_outcome(&observe_body(&collector, &subject, &url, &invalid_utf8)),
2321 SecretExposureResponseOutcome::InvalidUtf8.as_str()
2322 );
2323 }
2324 for _ in 0..16 {
2325 assert_eq!(
2326 projected_outcome(&observe_body(&collector, &subject, &url, &detector_limited)),
2327 SecretExposureResponseOutcome::DetectorLimitExceeded.as_str()
2328 );
2329 }
2330
2331 let state = collector.state.lock().unwrap();
2332 assert_eq!(
2333 state.admitted_inspection_byte_count,
2334 MAX_SECRET_EXPOSURE_TOTAL_BODY_BYTES
2335 );
2336 assert_eq!(state.interpreted_byte_count, 0);
2337 drop(state);
2338
2339 assert_eq!(
2340 projected_outcome(&observe_body(&collector, &subject, &url, b"safe")),
2341 SecretExposureResponseOutcome::TotalByteLimitExceeded.as_str()
2342 );
2343 let state = collector.state.lock().unwrap();
2344 assert_eq!(
2345 state.admitted_inspection_byte_count,
2346 MAX_SECRET_EXPOSURE_TOTAL_BODY_BYTES
2347 );
2348 assert_eq!(state.interpreted_byte_count, 0);
2349 }
2350
2351 #[test]
2352 fn pem_private_key_labels_require_matched_structural_blocks() {
2353 let fixtures = [
2354 (PemLabel::Pkcs8, format!("M{}", "A1b".repeat(21))),
2355 (PemLabel::Rsa, format!("M{}", "C3d".repeat(21))),
2356 (PemLabel::Ec, format!("M{}", "E5f".repeat(21))),
2357 (
2358 PemLabel::OpenSsh,
2359 format!("b3BlbnNzaC1rZXktdjE{}", "G7h".repeat(15)),
2360 ),
2361 ];
2362 for (label, payload) in fixtures {
2363 assert_eq!(
2364 count(
2365 pem(label, &payload).as_bytes(),
2366 SecretExposureDetectorClass::PemPrivateKeyBlock
2367 ),
2368 1
2369 );
2370 }
2371
2372 let near_misses = [
2373 "-----BEGIN PUBLIC KEY-----\nM".to_owned()
2374 + &"A".repeat(63)
2375 + "\n-----END PUBLIC KEY-----\n",
2376 "-----BEGIN PRIVATE KEY-----\nM".to_owned() + &"A".repeat(63),
2377 pem(PemLabel::Pkcs8, &format!("A{}", "B".repeat(63))),
2378 "-----BEGIN PRIVATE KEY-----\nMIIA!not-base64\n-----END PRIVATE KEY-----\n".to_owned(),
2379 "-----BEGIN PRIVATE KEY-----\nMIIA\n-----END RSA PRIVATE KEY-----\n".to_owned(),
2380 ];
2381 for body in near_misses {
2382 assert_eq!(
2383 count(
2384 body.as_bytes(),
2385 SecretExposureDetectorClass::PemPrivateKeyBlock
2386 ),
2387 0,
2388 "near miss must abstain"
2389 );
2390 }
2391 }
2392
2393 #[test]
2394 fn pem_payload_limit_accepts_limit_and_rejects_limit_plus_one() {
2395 let accepted_payload =
2396 format!("M{}", "A".repeat(MAX_SECRET_EXPOSURE_PEM_PAYLOAD_BYTES - 1));
2397 assert_eq!(
2398 count(
2399 pem(PemLabel::Pkcs8, &accepted_payload).as_bytes(),
2400 SecretExposureDetectorClass::PemPrivateKeyBlock
2401 ),
2402 1
2403 );
2404
2405 let rejected_payload = format!("M{}", "A".repeat(MAX_SECRET_EXPOSURE_PEM_PAYLOAD_BYTES));
2406 let body = pem(PemLabel::Pkcs8, &rejected_payload);
2407 assert_eq!(
2408 scan_complete_text_body(body.as_bytes()),
2409 Err(SecretExposureScanError::PemPayloadLimitExceeded {
2410 body_bytes: body.len(),
2411 max_payload_bytes: MAX_SECRET_EXPOSURE_PEM_PAYLOAD_BYTES,
2412 })
2413 );
2414 }
2415
2416 #[test]
2417 fn aws_requires_one_to_one_paired_assignments() {
2418 let forward = format!("AWS_ACCESS_KEY_ID={AWS_ID}\nAWS_SECRET_ACCESS_KEY={AWS_SECRET}\n");
2419 let reverse =
2420 format!("aws_secret_access_key: '{AWS_SECRET}'\naws_access_key_id: '{AWS_ID}'\n");
2421 let same_line = format!(
2422 "{{\"aws_access_key_id\":\"{AWS_ID}\",\"aws_secret_access_key\":\"{AWS_SECRET}\"}}"
2423 );
2424 for body in [forward, reverse, same_line] {
2425 assert_eq!(
2426 count(
2427 body.as_bytes(),
2428 SecretExposureDetectorClass::AwsAccessKeyPair
2429 ),
2430 1
2431 );
2432 }
2433
2434 assert_eq!(
2435 count(
2436 format!("AWS_ACCESS_KEY_ID={AWS_ID}").as_bytes(),
2437 SecretExposureDetectorClass::AwsAccessKeyPair
2438 ),
2439 0
2440 );
2441 assert_eq!(
2442 count(
2443 format!("AWS_SECRET_ACCESS_KEY={AWS_SECRET}").as_bytes(),
2444 SecretExposureDetectorClass::AwsAccessKeyPair
2445 ),
2446 0
2447 );
2448 }
2449
2450 #[test]
2451 fn aws_rejects_documentation_placeholders_and_malformed_values() {
2452 let documentation = concat!(
2453 "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE\n",
2454 "AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\n"
2455 );
2456 let placeholder = concat!(
2457 "AWS_ACCESS_KEY_ID=AKIAYOURACCESSKEY12\n",
2458 "AWS_SECRET_ACCESS_KEY=YOUR_SECRET_ACCESS_KEY_PLACEHOLDER_12345\n"
2459 );
2460 let low_variety = concat!(
2461 "AWS_ACCESS_KEY_ID=AKIAAAAAAAAAAAAAAAAA\n",
2462 "AWS_SECRET_ACCESS_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n"
2463 );
2464 for body in [documentation, placeholder, low_variety] {
2465 assert_eq!(
2466 count(
2467 body.as_bytes(),
2468 SecretExposureDetectorClass::AwsAccessKeyPair
2469 ),
2470 0
2471 );
2472 }
2473 }
2474
2475 #[test]
2476 fn aws_pair_window_has_byte_and_line_limit_plus_one_boundaries() {
2477 let first = format!("AWS_ACCESS_KEY_ID={AWS_ID}\n");
2478 let second = format!("AWS_SECRET_ACCESS_KEY={AWS_SECRET}");
2479 let id_position = 0usize;
2480 let exact_padding = MAX_SECRET_EXPOSURE_PAIR_WINDOW_BYTES - first.len() - 1;
2481 let exact = format!("{first}{}\n{second}", " ".repeat(exact_padding));
2482 let secret_position = first.len() + exact_padding + 1;
2483 assert_eq!(
2484 secret_position.abs_diff(id_position),
2485 MAX_SECRET_EXPOSURE_PAIR_WINDOW_BYTES
2486 );
2487 assert_eq!(
2488 count(
2489 exact.as_bytes(),
2490 SecretExposureDetectorClass::AwsAccessKeyPair
2491 ),
2492 1
2493 );
2494
2495 let over = format!("{first}{}\n{second}", " ".repeat(exact_padding + 1));
2496 assert_eq!(
2497 count(
2498 over.as_bytes(),
2499 SecretExposureDetectorClass::AwsAccessKeyPair
2500 ),
2501 0
2502 );
2503
2504 let at_line_limit = format!(
2505 "AWS_ACCESS_KEY_ID={AWS_ID}\n{}AWS_SECRET_ACCESS_KEY={AWS_SECRET}",
2506 "\n".repeat(MAX_SECRET_EXPOSURE_PAIR_WINDOW_LINES - 1)
2507 );
2508 assert_eq!(
2509 count(
2510 at_line_limit.as_bytes(),
2511 SecretExposureDetectorClass::AwsAccessKeyPair
2512 ),
2513 1
2514 );
2515 let over_line_limit = format!(
2516 "AWS_ACCESS_KEY_ID={AWS_ID}\n{}AWS_SECRET_ACCESS_KEY={AWS_SECRET}",
2517 "\n".repeat(MAX_SECRET_EXPOSURE_PAIR_WINDOW_LINES)
2518 );
2519 assert_eq!(
2520 count(
2521 over_line_limit.as_bytes(),
2522 SecretExposureDetectorClass::AwsAccessKeyPair
2523 ),
2524 0
2525 );
2526 }
2527
2528 #[test]
2529 fn stripe_live_secret_accepts_secret_and_restricted_but_not_public_or_test_keys() {
2530 let body = format!(
2531 "sk_live_{STRIPE_PAYLOAD}\nrk_live_{STRIPE_PAYLOAD}\npk_live_{STRIPE_PAYLOAD}\nsk_test_{STRIPE_PAYLOAD}\nrk_test_{STRIPE_PAYLOAD}\n"
2532 );
2533 assert_eq!(
2534 count(
2535 body.as_bytes(),
2536 SecretExposureDetectorClass::StripeLiveSecret
2537 ),
2538 2
2539 );
2540 assert_eq!(
2541 count(
2542 format!("prefixsk_live_{STRIPE_PAYLOAD}").as_bytes(),
2543 SecretExposureDetectorClass::StripeLiveSecret
2544 ),
2545 0
2546 );
2547 assert_eq!(
2548 count(
2549 b"key=sk_live_EXAMPLE_PLACEHOLDER_123456",
2550 SecretExposureDetectorClass::StripeLiveSecret
2551 ),
2552 0
2553 );
2554 }
2555
2556 #[test]
2557 fn stripe_payload_boundaries_are_exact() {
2558 let minimum = format!("sk_live_{}", "Ab3d".repeat(MIN_STRIPE_PAYLOAD_BYTES / 4));
2559 let maximum = format!("sk_live_{}", "Ab3d".repeat(MAX_STRIPE_PAYLOAD_BYTES / 4));
2560 let too_short = format!(
2561 "sk_live_{}",
2562 "Ab3d".repeat(MIN_STRIPE_PAYLOAD_BYTES / 4 - 1)
2563 );
2564 let too_long = format!("sk_live_{}Z", "Ab3d".repeat(MAX_STRIPE_PAYLOAD_BYTES / 4));
2565 assert_eq!(
2566 count(
2567 minimum.as_bytes(),
2568 SecretExposureDetectorClass::StripeLiveSecret
2569 ),
2570 1
2571 );
2572 assert_eq!(
2573 count(
2574 maximum.as_bytes(),
2575 SecretExposureDetectorClass::StripeLiveSecret
2576 ),
2577 1
2578 );
2579 assert_eq!(
2580 count(
2581 too_short.as_bytes(),
2582 SecretExposureDetectorClass::StripeLiveSecret
2583 ),
2584 0
2585 );
2586 assert_eq!(
2587 count(
2588 too_long.as_bytes(),
2589 SecretExposureDetectorClass::StripeLiveSecret
2590 ),
2591 0
2592 );
2593 }
2594
2595 #[test]
2596 fn stripe_scanner_consumes_one_adversarial_token_run_once() {
2597 let mut body = b"sk_live_".to_vec();
2598 while body.len() + b"sk_live_".len() <= MAX_SECRET_EXPOSURE_BODY_BYTES {
2599 body.extend_from_slice(b"sk_live_");
2600 }
2601 body.resize(MAX_SECRET_EXPOSURE_BODY_BYTES, b'A');
2602
2603 let scan = scan_complete_text_body(&body).expect("bounded repeated token run scans");
2604 assert_eq!(scan.inspected_body_bytes(), MAX_SECRET_EXPOSURE_BODY_BYTES);
2605 assert_eq!(
2606 scan.count_for(SecretExposureDetectorClass::StripeLiveSecret),
2607 0
2608 );
2609 }
2610
2611 #[test]
2612 fn bearer_requires_explicit_authorization_assignment() {
2613 let header = format!("Authorization: Bearer {BEARER_TOKEN}");
2614 let json = format!("{{\"authorization\":\"bearer {BEARER_TOKEN}\"}}");
2615 for body in [header, json] {
2616 assert_eq!(
2617 count(
2618 body.as_bytes(),
2619 SecretExposureDetectorClass::AuthorizationBearerAssignment
2620 ),
2621 1
2622 );
2623 }
2624 for body in [
2625 format!("token={BEARER_TOKEN}"),
2626 format!("Bearer {BEARER_TOKEN}"),
2627 format!("Authorization: Basic {BEARER_TOKEN}"),
2628 "Authorization: Bearer YOUR_TOKEN_PLACEHOLDER_123456".to_owned(),
2629 ] {
2630 assert_eq!(
2631 count(
2632 body.as_bytes(),
2633 SecretExposureDetectorClass::AuthorizationBearerAssignment
2634 ),
2635 0
2636 );
2637 }
2638 }
2639
2640 #[test]
2641 fn bearer_token_limit_accepts_limit_and_rejects_limit_plus_one() {
2642 let token = "Ab3d".repeat(MAX_SECRET_EXPOSURE_TOKEN_BYTES / 4);
2643 let accepted = format!("Authorization: Bearer {token}");
2644 assert_eq!(
2645 count(
2646 accepted.as_bytes(),
2647 SecretExposureDetectorClass::AuthorizationBearerAssignment
2648 ),
2649 1
2650 );
2651
2652 let rejected = format!("Authorization: Bearer {token}Z");
2653 assert_eq!(
2654 scan_complete_text_body(rejected.as_bytes()),
2655 Err(SecretExposureScanError::TokenLimitExceeded {
2656 body_bytes: rejected.len(),
2657 max_token_bytes: MAX_SECRET_EXPOSURE_TOKEN_BYTES,
2658 })
2659 );
2660 }
2661
2662 #[test]
2663 fn assignment_endpoint_scans_are_linear_and_preserve_later_candidates() {
2664 let mut body = b"Authorization: invalid".to_vec();
2665 while body.len() + 24 <= MAX_SECRET_EXPOSURE_BODY_BYTES {
2666 body.extend_from_slice(b" authorization: invalid");
2667 }
2668 body.resize(MAX_SECRET_EXPOSURE_BODY_BYTES, b'x');
2669
2670 let mut visits = 0usize;
2671 let inspected = for_each_assignment_value_case_insensitive(
2672 &body,
2673 b"authorization",
2674 |_, _| -> Result<(), ()> {
2675 visits += 1;
2676 Ok(())
2677 },
2678 )
2679 .unwrap();
2680 assert!(
2681 visits > 1,
2682 "adversarial invalid candidates remain observable"
2683 );
2684 assert!(
2685 inspected <= body.len(),
2686 "the spaced endpoint cursor scans each line byte at most once"
2687 );
2688 assert_eq!(
2689 count(
2690 &body,
2691 SecretExposureDetectorClass::AuthorizationBearerAssignment
2692 ),
2693 0
2694 );
2695
2696 let later = format!("authorization=Basic ignored authorization=Bearer {BEARER_TOKEN}");
2697 assert_eq!(
2698 count(
2699 later.as_bytes(),
2700 SecretExposureDetectorClass::AuthorizationBearerAssignment
2701 ),
2702 1,
2703 "a later delimiterless assignment remains visible"
2704 );
2705 }
2706
2707 #[test]
2708 fn bearer_assignment_occurrence_limit_is_exact() {
2709 let assignment = format!("Authorization=Bearer {BEARER_TOKEN};");
2710 let accepted = assignment.repeat(usize::from(MAX_SECRET_EXPOSURE_OCCURRENCES));
2711 assert_eq!(
2712 count(
2713 accepted.as_bytes(),
2714 SecretExposureDetectorClass::AuthorizationBearerAssignment
2715 ),
2716 MAX_SECRET_EXPOSURE_OCCURRENCES
2717 );
2718 let rejected = format!("{accepted}{assignment}");
2719 assert_eq!(
2720 scan_complete_text_body(rejected.as_bytes()),
2721 Err(SecretExposureScanError::OccurrenceLimitExceeded {
2722 body_bytes: rejected.len(),
2723 max_occurrences: MAX_SECRET_EXPOSURE_OCCURRENCES,
2724 })
2725 );
2726 }
2727
2728 #[test]
2729 fn occurrence_limit_accepts_limit_and_rejects_limit_plus_one() {
2730 let token = format!("sk_live_{STRIPE_PAYLOAD}");
2731 let accepted =
2732 std::iter::repeat_n(token.as_str(), usize::from(MAX_SECRET_EXPOSURE_OCCURRENCES))
2733 .collect::<Vec<_>>()
2734 .join("\n");
2735 let scan = scan_complete_text_body(accepted.as_bytes()).expect("exact occurrence limit");
2736 assert_eq!(scan.occurrence_count(), MAX_SECRET_EXPOSURE_OCCURRENCES);
2737
2738 let rejected = format!("{accepted}\n{token}");
2739 assert_eq!(
2740 scan_complete_text_body(rejected.as_bytes()),
2741 Err(SecretExposureScanError::OccurrenceLimitExceeded {
2742 body_bytes: rejected.len(),
2743 max_occurrences: MAX_SECRET_EXPOSURE_OCCURRENCES,
2744 })
2745 );
2746 }
2747
2748 #[test]
2749 fn successful_and_failed_outputs_never_retain_canary_values() {
2750 let canary = format!("sk_live_{STRIPE_PAYLOAD}");
2751 let scan = scan_complete_text_body(canary.as_bytes()).expect("canary shape should match");
2752 assert!(!format!("{scan:?}").contains(STRIPE_PAYLOAD));
2753
2754 let oversized = format!(
2755 "Authorization: Bearer {}",
2756 format!("{STRIPE_PAYLOAD}Z").repeat(32)
2757 );
2758 let error = scan_complete_text_body(oversized.as_bytes())
2759 .expect_err("oversized bearer candidate should be refused");
2760 let rendered = format!("{error:?} {error}");
2761 assert!(!rendered.contains(STRIPE_PAYLOAD));
2762 assert!(!rendered.contains("Authorization"));
2763 }
2764
2765 #[test]
2766 fn labelled_corpus_has_literal_per_detector_precision_and_recall_oracles() {
2767 #[derive(Clone)]
2768 struct CorpusCase {
2769 name: &'static str,
2770 body: Vec<u8>,
2771 expected: BTreeMap<SecretExposureDetectorClass, bool>,
2772 }
2773
2774 fn expected(
2775 classes: &[SecretExposureDetectorClass],
2776 ) -> BTreeMap<SecretExposureDetectorClass, bool> {
2777 SecretExposureDetectorClass::ALL
2778 .into_iter()
2779 .map(|class| (class, classes.contains(&class)))
2780 .collect()
2781 }
2782
2783 let pem_body = pem(PemLabel::Pkcs8, &format!("M{}", "A1b".repeat(21)));
2784 let cases = vec![
2785 CorpusCase {
2786 name: "synthetic-pkcs8-private-key",
2787 body: pem_body.into_bytes(),
2788 expected: expected(&[SecretExposureDetectorClass::PemPrivateKeyBlock]),
2789 },
2790 CorpusCase {
2791 name: "public-key-near-miss",
2792 body: format!(
2793 "-----BEGIN PUBLIC KEY-----\nM{}\n-----END PUBLIC KEY-----\n",
2794 "A1b".repeat(21)
2795 )
2796 .into_bytes(),
2797 expected: expected(&[]),
2798 },
2799 CorpusCase {
2800 name: "synthetic-paired-aws-assignments",
2801 body: format!("AWS_ACCESS_KEY_ID={AWS_ID}\nAWS_SECRET_ACCESS_KEY={AWS_SECRET}\n")
2802 .into_bytes(),
2803 expected: expected(&[SecretExposureDetectorClass::AwsAccessKeyPair]),
2804 },
2805 CorpusCase {
2806 name: "aws-public-identifier-alone",
2807 body: format!("AWS_ACCESS_KEY_ID={AWS_ID}\n").into_bytes(),
2808 expected: expected(&[]),
2809 },
2810 CorpusCase {
2811 name: "aws-documentation-example",
2812 body: concat!(
2813 "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE\n",
2814 "AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\n"
2815 )
2816 .as_bytes()
2817 .to_vec(),
2818 expected: expected(&[]),
2819 },
2820 CorpusCase {
2821 name: "synthetic-stripe-live-secret",
2822 body: format!("key=sk_live_{STRIPE_PAYLOAD}\n").into_bytes(),
2823 expected: expected(&[SecretExposureDetectorClass::StripeLiveSecret]),
2824 },
2825 CorpusCase {
2826 name: "stripe-publishable-and-test-identifiers",
2827 body: format!(
2828 "pk_live_{STRIPE_PAYLOAD}\nsk_test_{STRIPE_PAYLOAD}\nrk_test_{STRIPE_PAYLOAD}\n"
2829 )
2830 .into_bytes(),
2831 expected: expected(&[]),
2832 },
2833 CorpusCase {
2834 name: "escaped-stripe-near-miss",
2835 body: format!("sk\\u005flive\\u005f{STRIPE_PAYLOAD}\n").into_bytes(),
2836 expected: expected(&[]),
2837 },
2838 CorpusCase {
2839 name: "synthetic-explicit-bearer-assignment",
2840 body: format!("Authorization: Bearer {BEARER_TOKEN}\n").into_bytes(),
2841 expected: expected(&[SecretExposureDetectorClass::AuthorizationBearerAssignment]),
2842 },
2843 CorpusCase {
2844 name: "unbound-bearer-looking-text",
2845 body: format!("Bearer {BEARER_TOKEN}\n").into_bytes(),
2846 expected: expected(&[]),
2847 },
2848 CorpusCase {
2849 name: "split-stripe-near-miss",
2850 body: format!("sk_live_<span>{STRIPE_PAYLOAD}</span>\n").into_bytes(),
2851 expected: expected(&[]),
2852 },
2853 CorpusCase {
2854 name: "binary-abstention",
2855 body: b"prefix\xffsuffix".to_vec(),
2856 expected: expected(&[]),
2857 },
2858 ];
2859
2860 let mut matrix = BTreeMap::new();
2861 for class in SecretExposureDetectorClass::ALL {
2862 matrix.insert(class, [0_u16; 4]); }
2864 for case in &cases {
2865 let observed = match scan_complete_text_body(&case.body) {
2866 Ok(scan) => scan
2867 .class_counts()
2868 .iter()
2869 .map(|row| row.class())
2870 .collect::<Vec<_>>(),
2871 Err(SecretExposureScanError::InvalidUtf8 { .. })
2872 if case.name == "binary-abstention" =>
2873 {
2874 Vec::new()
2875 },
2876 Err(error) => panic!(
2877 "labelled corpus case {} failed unexpectedly: {error}",
2878 case.name
2879 ),
2880 };
2881 for class in SecretExposureDetectorClass::ALL {
2882 let predicted = observed.contains(&class);
2883 let expected = case.expected[&class];
2884 let bucket = match (predicted, expected) {
2885 (true, true) => 0,
2886 (true, false) => 1,
2887 (false, true) => 2,
2888 (false, false) => 3,
2889 };
2890 matrix.get_mut(&class).expect("fixed detector class")[bucket] += 1;
2891 }
2892 }
2893
2894 let literal_expected = [
2895 (
2896 SecretExposureDetectorClass::PemPrivateKeyBlock,
2897 [1, 0, 0, 11],
2898 ),
2899 (SecretExposureDetectorClass::AwsAccessKeyPair, [1, 0, 0, 11]),
2900 (SecretExposureDetectorClass::StripeLiveSecret, [1, 0, 0, 11]),
2901 (
2902 SecretExposureDetectorClass::AuthorizationBearerAssignment,
2903 [1, 0, 0, 11],
2904 ),
2905 ];
2906 for (class, expected_matrix) in literal_expected {
2907 let actual = matrix[&class];
2908 println!(
2909 "detector={} tp={} fp={} fn={} tn={}",
2910 class.as_str(),
2911 actual[0],
2912 actual[1],
2913 actual[2],
2914 actual[3]
2915 );
2916 assert_eq!(actual, expected_matrix);
2917 }
2918 }
2919}