Skip to main content

systemprompt_security/policy/secrets/
recovery.rs

1//! Located secret findings for repairing provider-bound prompt text.
2//!
3//! [`secret_findings`] reports every credential the scanner would deny on, as
4//! byte spans into the governed strings, and [`redact_spans`] applies them.
5//! A finding never carries the credential itself, so the list is safe to log
6//! and to render into a deny message. Collection stops one past
7//! [`MAX_RECOVERY_FINDINGS`]: a prompt with more credentials than that is
8//! not repaired, and the caller only needs to know the cap was exceeded.
9//!
10//! Copyright (c) systemprompt.io — Business Source License 1.1.
11//! See <https://systemprompt.io> for licensing details.
12
13use std::ops::Range;
14
15use systemprompt_identifiers::SecretPatternId;
16
17use super::super::GovernedInput;
18use super::patterns::HIGH_ENTROPY_PATTERN;
19use super::{COMPILED, EntropyConfig, SECRET_PATTERNS, SignatureExemptions};
20
21pub const REDACTION_MARKER: &str = "[REDACTED_BY_GOVERNANCE]";
22pub const MAX_RECOVERY_FINDINGS: usize = 4096;
23
24/// Index into the ordered string surfaces returned by `GovernedInput::strings`.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct SecretSource {
27    pub part_index: usize,
28}
29
30/// Credential-free match metadata; `span` uses UTF-8 byte offsets within its
31/// source.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct SecretFinding {
34    pub source: SecretSource,
35    pub span: Range<usize>,
36    pub pattern_id: SecretPatternId,
37}
38
39#[must_use]
40pub fn secret_findings(input: &GovernedInput, entropy: &EntropyConfig) -> Vec<SecretFinding> {
41    let strings = input.strings();
42    let exemptions = SignatureExemptions::from_strings(&strings);
43    strings
44        .iter()
45        .enumerate()
46        .flat_map(|(part_index, found)| {
47            let source = SecretSource { part_index };
48            let value = found.value;
49            let patterns = COMPILED.iter().flat_map(move |(index, regex)| {
50                let pattern = &SECRET_PATTERNS[*index];
51                regex.find_iter(value).map(move |hit| SecretFinding {
52                    source,
53                    span: if pattern.redact_whole_value {
54                        0..value.len()
55                    } else {
56                        hit.range()
57                    },
58                    pattern_id: SecretPatternId::new(pattern.id),
59                })
60            });
61            let tokens = (!exemptions.exempts_entropy(&found.path))
62                .then(|| {
63                    super::entropy::high_entropy_spans(value, entropy).map(move |(span, _)| {
64                        SecretFinding {
65                            source,
66                            span,
67                            pattern_id: SecretPatternId::new(HIGH_ENTROPY_PATTERN.id),
68                        }
69                    })
70                })
71                .into_iter()
72                .flatten();
73            patterns.chain(tokens)
74        })
75        .take(MAX_RECOVERY_FINDINGS + 1)
76        .collect()
77}
78
79#[must_use]
80pub fn redact_spans(value: &str, spans: impl IntoIterator<Item = Range<usize>>) -> Option<String> {
81    let mut spans: Vec<_> = spans.into_iter().collect();
82    spans.sort_unstable_by_key(|span| (span.start, span.end));
83    let mut out = String::new();
84    let mut cursor = 0;
85    for span in spans {
86        if span.start >= span.end || value.get(span.clone()).is_none() {
87            return None;
88        }
89        if span.end <= cursor {
90            continue;
91        }
92        if span.start >= cursor {
93            out.push_str(value.get(cursor..span.start)?);
94            out.push_str(REDACTION_MARKER);
95        }
96        cursor = span.end;
97    }
98    out.push_str(value.get(cursor..)?);
99    Some(out)
100}