Skip to main content

systemprompt_security/policy/secrets/
recovery.rs

1//! Located secret findings for repairing provider-bound prompt text.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use std::ops::Range;
7
8use systemprompt_identifiers::SecretPatternId;
9
10use super::super::GovernedInput;
11use super::patterns::field_matches;
12use super::{SecretScanner, SignatureExemptions, selected_match};
13
14pub const REDACTION_MARKER: &str = "[REDACTED_BY_GOVERNANCE]";
15pub const MAX_RECOVERY_FINDINGS: usize = 4096;
16
17/// Index into the ordered string surfaces returned by `GovernedInput::strings`.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct SecretSource {
20    pub part_index: usize,
21}
22
23/// Credential-free match metadata with UTF-8 byte offsets into its source.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct SecretFinding {
26    pub source: SecretSource,
27    pub span: Range<usize>,
28    pub pattern_id: SecretPatternId,
29}
30
31pub(super) fn secret_findings(
32    scanner: &SecretScanner,
33    input: &GovernedInput,
34) -> Vec<SecretFinding> {
35    let strings = input.strings();
36    let exemptions = SignatureExemptions::from_strings(&strings);
37    strings
38        .iter()
39        .enumerate()
40        .flat_map(|(part_index, found)| {
41            let source = SecretSource { part_index };
42            let value = found.value;
43            let patterns = scanner.patterns.iter().flat_map(move |pattern| {
44                if !field_matches(&found.path, pattern.definition.field.as_deref()) {
45                    return Vec::new().into_iter();
46                }
47                pattern
48                    .regex
49                    .captures_iter(value)
50                    .filter_map(move |captures| {
51                        let matched = selected_match(pattern, &captures)?;
52                        Some(SecretFinding {
53                            source,
54                            span: if pattern.definition.redact_whole_value {
55                                0..value.len()
56                            } else {
57                                matched.range()
58                            },
59                            pattern_id: pattern.definition.id.clone(),
60                        })
61                    })
62                    .collect::<Vec<_>>()
63                    .into_iter()
64            });
65            let tokens = (!exemptions.exempts_entropy(&found.path))
66                .then(|| {
67                    super::entropy::high_entropy_spans(value, &scanner.entropy).map(
68                        move |(span, _)| SecretFinding {
69                            source,
70                            span,
71                            pattern_id: SecretPatternId::high_entropy(),
72                        },
73                    )
74                })
75                .into_iter()
76                .flatten();
77            patterns.chain(tokens)
78        })
79        .take(MAX_RECOVERY_FINDINGS + 1)
80        .collect()
81}
82
83#[must_use]
84pub fn redact_spans(value: &str, spans: impl IntoIterator<Item = Range<usize>>) -> Option<String> {
85    let mut spans: Vec<_> = spans.into_iter().collect();
86    spans.sort_unstable_by_key(|span| (span.start, span.end));
87    let mut out = String::new();
88    let mut cursor = 0;
89    for span in spans {
90        if span.start >= span.end || value.get(span.clone()).is_none() {
91            return None;
92        }
93        if span.end <= cursor {
94            continue;
95        }
96        if span.start >= cursor {
97            out.push_str(value.get(cursor..span.start)?);
98            out.push_str(REDACTION_MARKER);
99        }
100        cursor = span.end;
101    }
102    out.push_str(value.get(cursor..)?);
103    Some(out)
104}