Skip to main content

termivar_scanner/web_runtime/
secret_exposure.rs

1//! Bounded, value-free detection of secret-shaped material in complete text bodies.
2//!
3//! This module deliberately owns no transport or report authority. Callers must
4//! establish that a response is complete, uncoded, committed, and otherwise
5//! eligible before passing its bytes here. The detector never retains a matched
6//! value, a fragment of one, or a digest derived from one.
7
8use 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
34/// Largest complete textual response accepted by this detector.
35pub const MAX_SECRET_EXPOSURE_BODY_BYTES: usize = 128 * 1024;
36/// Maximum response bytes admitted to detector work across one assessment.
37pub const MAX_SECRET_EXPOSURE_TOTAL_BODY_BYTES: u64 = 4 * 1024 * 1024;
38/// Maximum value-free matched subject/class observations retained per run.
39pub const MAX_SECRET_EXPOSURE_RETAINED_OBSERVATIONS: usize = 32;
40/// Maximum committed response outcomes accepted from the parent assessment.
41pub const MAX_SECRET_EXPOSURE_RESPONSES: usize = 1_024;
42/// Largest encoded payload accepted inside one PEM private-key block.
43pub(crate) const MAX_SECRET_EXPOSURE_PEM_PAYLOAD_BYTES: usize = 64 * 1024;
44/// Largest bearer value considered by the explicit authorization detector.
45pub(crate) const MAX_SECRET_EXPOSURE_TOKEN_BYTES: usize = 512;
46/// Maximum byte distance between the two members of an AWS credential pair.
47pub(crate) const MAX_SECRET_EXPOSURE_PAIR_WINDOW_BYTES: usize = 4 * 1024;
48/// Maximum line-number distance between the two members of an AWS pair.
49pub(crate) const MAX_SECRET_EXPOSURE_PAIR_WINDOW_LINES: usize = 16;
50/// Maximum total matched occurrences retained as value-free counters.
51pub 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/// Closed public meaning of a detector match.
87#[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/// One class and its bounded occurrence count. No matched value is retained.
167#[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/// Complete value-free result for one accepted textual body.
184#[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/// Value-free refusal from the bounded detector.
214#[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
278/// Inspects one already-qualified complete textual body.
279///
280/// Successful output contains only byte and occurrence counts. A refusal never
281/// includes input bytes, candidate fragments, or a digest of either.
282pub(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            // DER private-key encodings begin with an ASN.1 SEQUENCE (0x30),
387            // whose base64 representation begins with `M`.
388            Self::Pkcs8 | Self::Rsa | Self::Ec => payload_prefix.first() == Some(&b'M'),
389            // Base64 for the OpenSSH binary magic `openssh-key-v1`.
390            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            // A token-byte run cannot contain a second boundary-qualified
596            // Stripe candidate. Consume it once so repeated `sk_live_` text
597            // inside a single long token cannot rescan the suffix quadratically.
598            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(&quote) {
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/// Closed reason one selected response was or was not evaluated.
947#[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/// One aggregate outcome row retained by the selected audit.
1003#[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/// One value-free committed subject/class observation.
1020#[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/// Complete selected-run audit. It intentionally contains no matched bytes,
1054/// fragments, reversible encoding, or digest of secret material.
1055#[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/// Assessment-owned shared admission state for the sealed ordinary-response
1137/// observer. The state reserves bounds only; report truth is reconstructed from
1138/// committed receipt evidence by `CommittedSecretExposureLedger`.
1139#[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                        // Reserve detector work before UTF-8 and detector parsing. A
1208                        // repeatedly malformed or over-complex body must not obtain a
1209                        // fresh 4 MiB allowance merely because interpretation refused it.
1210                        // The public audit continues to report only bytes that were
1211                        // successfully interpreted, not this private admission counter.
1212                        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        // The fixed detector catalogue is intentionally finite. A zero-match
1258        // result therefore cannot prove that the complete body contains no
1259        // other low-entropy secret. Every selected response uses value-free
1260        // lineage instead of the ordinary public whole-body digest and body-
1261        // derived projection, so the digest cannot become a dictionary oracle.
1262        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
1341/// Validates the value-free per-response record used as replacement lineage
1342/// when the ordinary unkeyed body digest is deliberately suppressed.
1343pub(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
1392/// Projects one value-free body identity for an already planned active-review
1393/// response. Selecting S04 suppresses the ordinary unkeyed body digest for
1394/// these responses without treating probe/reflection material as a detector
1395/// finding or changing the active review's semantic projection.
1396pub(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
1506/// Reconstructs the exact direct response facts that may authorize the
1507/// value-free replacement lineage. This deliberately excludes other receipt
1508/// evidence, including rate-limit and semantic projections.
1509pub(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/// The only POST exception is the closed, scanner-owned three-operation GraphQL
1540/// route. This does not admit POSTs to the passive secret detector or to the
1541/// ordinary complete-response observer.
1542#[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/// OpenAPI's separately planned GET is not passive detector input. Only its
1595/// closed candidate/replay route can mint this value-free replacement lineage.
1596#[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/// The separately planned REST GET is not passive detector input. This wrapper
1645/// admits only the existing scanner-owned read-only candidate/replay route.
1646#[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]); // true-positive, false-positive, false-negative, true-negative
2863        }
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}