1pub mod conformance;
16pub mod corpus;
17pub mod rules;
18
19use lazy_static::lazy_static;
20use regex::Regex;
21use std::collections::BTreeMap;
22
23pub const MAX_RENDER_FIELD: usize = 256;
25const TRUNCATION_MARKER: &str = "(truncated)";
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Sink {
30 Stdout,
31 Json,
32 Sarif,
33 Junit,
34 Markdown,
35 Otel,
36}
37
38impl Sink {
39 pub fn as_str(self) -> &'static str {
40 match self {
41 Sink::Stdout => "stdout",
42 Sink::Json => "json",
43 Sink::Sarif => "sarif",
44 Sink::Junit => "junit",
45 Sink::Markdown => "markdown",
46 Sink::Otel => "otel",
47 }
48 }
49
50 pub fn encoding(self) -> &'static str {
55 match self {
56 Sink::Stdout => "terminal_safe",
57 Sink::Json | Sink::Sarif => "json_serializer",
58 Sink::Junit => "xml_escape",
59 Sink::Markdown => "markdown_neutralize",
60 Sink::Otel => "attribute_value",
61 }
62 }
63
64 pub const ALL: [Sink; 6] = [
65 Sink::Stdout,
66 Sink::Json,
67 Sink::Sarif,
68 Sink::Junit,
69 Sink::Markdown,
70 Sink::Otel,
71 ];
72}
73
74#[derive(Debug, Clone, Default)]
76pub struct RedactOutcome {
77 pub text: String,
78 pub fired: BTreeMap<String, u64>,
79 pub secret_hits: u64,
80 pub pii_hits: u64,
81}
82
83pub fn redact(input: &str) -> RedactOutcome {
86 let mut text = input.to_string();
87 let mut fired: BTreeMap<String, u64> = BTreeMap::new();
88 let mut secret_hits = 0u64;
89 let mut pii_hits = 0u64;
90 for rule in rules::RULES.iter() {
91 let count = rule.re.find_iter(&text).count() as u64;
92 if count == 0 {
93 continue;
94 }
95 *fired.entry(rule.name.to_string()).or_insert(0) += count;
96 match rule.class {
97 "secret" => secret_hits += count,
98 "pii" => pii_hits += count,
99 _ => {}
100 }
101 let placeholder = format!("<redacted:{}>", rule.name);
102 text = rule
103 .re
104 .replace_all(&text, placeholder.as_str())
105 .into_owned();
106 }
107 RedactOutcome {
108 text,
109 fired,
110 secret_hits,
111 pii_hits,
112 }
113}
114
115lazy_static! {
116 static ref ANSI_RE: Regex =
118 Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)").unwrap();
119 static ref LONE_ESC: Regex = Regex::new(r"\x1b").unwrap();
121 static ref CONTROL_RE: Regex =
123 Regex::new(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\u{80}-\u{9f}\u{202a}-\u{202e}\u{2066}-\u{2069}]").unwrap();
124}
125
126pub fn strip_control(input: &str) -> String {
129 let no_ansi = ANSI_RE.replace_all(input, "");
130 let no_esc = LONE_ESC.replace_all(&no_ansi, "\u{fffd}");
131 CONTROL_RE.replace_all(&no_esc, "\u{fffd}").into_owned()
132}
133
134pub fn has_residual_control(s: &str) -> bool {
138 s.chars().any(|c| {
139 c == '\u{7f}'
140 || ('\u{00}'..='\u{08}').contains(&c)
141 || c == '\u{0b}'
142 || c == '\u{0c}'
143 || ('\u{0e}'..='\u{1f}').contains(&c)
144 || ('\u{80}'..='\u{9f}').contains(&c)
145 || ('\u{202a}'..='\u{202e}').contains(&c)
146 || ('\u{2066}'..='\u{2069}').contains(&c)
147 })
148}
149
150fn bound(text: &str, max_len: usize) -> String {
151 if text.chars().count() <= max_len {
152 return text.to_string();
153 }
154 let truncated: String = text.chars().take(max_len).collect();
155 format!("{truncated}{TRUNCATION_MARKER}")
156}
157
158fn xml_escape(s: &str) -> String {
159 s.replace('&', "&")
160 .replace('<', "<")
161 .replace('>', ">")
162 .replace('"', """)
163 .replace('\'', "'")
164}
165
166fn markdown_neutralize(s: &str) -> String {
167 s.replace('`', "\\`")
168 .replace("](", "\\]\\(")
169 .replace("![", "\\!\\[")
170 .replace('<', "<")
171 .replace('>', ">")
172 .replace("javascript:", "javascript\\:")
173}
174
175fn encode(sink: Sink, text: &str) -> String {
182 match sink {
183 Sink::Stdout | Sink::Otel | Sink::Json | Sink::Sarif => text.to_string(),
184 Sink::Junit => xml_escape(text),
185 Sink::Markdown => markdown_neutralize(text),
186 }
187}
188
189pub fn render_safe_with_outcome(
193 sink: Sink,
194 input: &str,
195 max_len: usize,
196) -> (String, RedactOutcome) {
197 let stripped = strip_control(input);
198 let redacted = redact(&stripped);
199 let bounded = bound(&redacted.text, max_len);
200 (encode(sink, &bounded), redacted)
201}
202
203pub fn render_safe(sink: Sink, input: &str, max_len: usize) -> String {
205 render_safe_with_outcome(sink, input, max_len).0
206}
207
208pub const UNTRUSTED_FIELDS: &[&str] = &[
215 "prompt",
216 "response",
217 "output",
218 "error",
219 "rationale",
220 "message",
221 "expected",
222 "actual",
223 "diff",
224 "tool_output",
225 "stdout",
226 "stderr",
227];
228
229fn is_untrusted_key(key: &str) -> bool {
230 UNTRUSTED_FIELDS.contains(&key)
231}
232
233pub fn render_details_safe(
241 sink: Sink,
242 value: &serde_json::Value,
243 max_len: usize,
244) -> serde_json::Value {
245 render_details_inner(sink, value, max_len, false)
246}
247
248fn render_details_inner(
249 sink: Sink,
250 value: &serde_json::Value,
251 max_len: usize,
252 in_untrusted: bool,
253) -> serde_json::Value {
254 use serde_json::Value;
255 match value {
256 Value::String(s) if in_untrusted => Value::String(render_safe(sink, s, max_len)),
257 Value::Array(items) => Value::Array(
258 items
259 .iter()
260 .map(|v| render_details_inner(sink, v, max_len, in_untrusted))
261 .collect(),
262 ),
263 Value::Object(map) => {
264 let mut out = serde_json::Map::with_capacity(map.len());
265 for (k, v) in map {
266 let child_untrusted = in_untrusted || is_untrusted_key(k);
267 out.insert(
268 k.clone(),
269 render_details_inner(sink, v, max_len, child_untrusted),
270 );
271 }
272 Value::Object(out)
273 }
274 other => other.clone(),
275 }
276}
277
278#[doc(hidden)]
281pub fn render_truncate_first_unsafe(sink: Sink, input: &str, max_len: usize) -> String {
282 let bounded = bound(input, max_len);
283 let stripped = strip_control(&bounded);
284 let redacted = redact(&stripped);
285 encode(sink, &redacted.text)
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 fn has_control(s: &str) -> bool {
293 s.contains('\u{1b}') || s.contains('\u{07}') || has_residual_control(s)
294 }
295
296 #[test]
297 fn redacts_secret_shapes_value_free() {
298 let token = format!("ghp_{}", "A".repeat(36));
299 let out = redact(&format!("here is {token} ok"));
300 assert!(out.text.contains("<redacted:github-token>"));
301 assert!(!out.text.contains(&token));
302 assert_eq!(out.secret_hits, 1);
303 }
304
305 #[test]
306 fn strips_terminal_control() {
307 let s = "\u{1b}[31mRED\u{1b}[0m\u{07}\u{202e}rev";
308 let out = strip_control(s);
309 assert!(!has_control(&out));
310 assert!(out.contains("RED"));
311 }
312
313 #[test]
314 fn render_safe_never_leaks_across_sinks() {
315 let secret = format!("ghp_{}", "B".repeat(36));
316 let input = format!("\u{1b}[31m{secret}\u{1b}[0m alice@example.com");
317 for sink in Sink::ALL {
318 let out = render_safe(sink, &input, MAX_RENDER_FIELD);
319 assert!(!out.contains(&secret), "{} leaked secret", sink.as_str());
320 assert!(
321 !out.contains("alice@example.com"),
322 "{} leaked pii",
323 sink.as_str()
324 );
325 assert!(!has_control(&out), "{} leaked control", sink.as_str());
326 }
327 }
328
329 #[test]
330 fn redact_before_truncate_does_not_leak_but_wrong_order_does() {
331 let secret = format!("ghp_{}", "C".repeat(36));
334 let input = format!("{} {secret}", "x".repeat(239));
335 let safe = render_safe(Sink::Stdout, &input, MAX_RENDER_FIELD);
336 assert!(
337 !safe.contains("ghp_"),
338 "redact-before-truncate must not leak"
339 );
340 let unsafe_out = render_truncate_first_unsafe(Sink::Stdout, &input, MAX_RENDER_FIELD);
341 assert!(
342 unsafe_out.contains("ghp_"),
343 "truncate-first is expected to leak"
344 );
345 }
346
347 #[test]
348 fn benign_near_matches_survive() {
349 let benign =
350 "uuid 123e4567-e89b-12d3-a456-426614174000 sha256:deadbeef path /usr/bin/assay";
351 let out = redact(benign);
352 assert!(
353 !out.text.contains("<redacted:"),
354 "benign text over-redacted: {}",
355 out.text
356 );
357 }
358
359 #[test]
360 fn sink_encodings_are_distinct_where_expected() {
361 assert_eq!(Sink::Junit.encoding(), "xml_escape");
362 assert_eq!(Sink::Markdown.encoding(), "markdown_neutralize");
363 assert_eq!(Sink::Sarif.encoding(), "json_serializer");
365 }
366
367 #[test]
368 fn details_walker_redacts_untrusted_keeps_owned_byte_stable() {
369 let secret = format!("ghp_{}", "D".repeat(36));
370 let email = "alice@example.com";
371 let details = serde_json::json!({
372 "prompt": format!("ask {secret}"),
373 "assertions": [{ "message": format!("got {email}") }, { "passed": true }],
374 "expected": "uuid 123e4567-e89b-12d3-a456-426614174000",
375 "skip": { "fingerprint": "abc123def456", "reason": "fingerprint_match" },
376 "score_pct": 42,
377 });
378 let safe = render_details_safe(Sink::Json, &details, usize::MAX);
379 let blob = safe.to_string();
380 assert!(!blob.contains(&secret), "prompt secret leaked");
382 assert!(!blob.contains(email), "nested assertion pii leaked");
383 assert!(blob.contains("<redacted:"), "no redaction markers fired");
384 assert_eq!(
386 safe["expected"],
387 serde_json::json!("uuid 123e4567-e89b-12d3-a456-426614174000")
388 );
389 assert_eq!(
391 safe["skip"]["fingerprint"],
392 serde_json::json!("abc123def456")
393 );
394 assert_eq!(
395 safe["skip"]["reason"],
396 serde_json::json!("fingerprint_match")
397 );
398 assert_eq!(safe["score_pct"], serde_json::json!(42));
399 assert_eq!(safe["assertions"][1]["passed"], serde_json::json!(true));
401 }
402
403 #[test]
404 fn details_walker_record_sink_keeps_full_length_but_strips_secret() {
405 let secret = format!("ghp_{}", "E".repeat(36));
407 let long = format!("{} {secret}", "z".repeat(400));
408 let details = serde_json::json!({ "response": long });
409 let safe = render_details_safe(Sink::Json, &details, usize::MAX);
410 let rendered = safe["response"].as_str().unwrap();
411 assert!(!rendered.contains(&secret), "record sink leaked secret");
412 assert!(rendered.contains("<redacted:github-token>"));
413 assert!(
414 !rendered.contains("(truncated)"),
415 "record sink must not truncate"
416 );
417 assert!(rendered.len() > 400, "record sink preserved full length");
418 }
419
420 #[test]
421 fn structured_sinks_return_unescaped_value_text() {
422 let v = r#"path "C:\tmp" <ok>"#;
425 assert_eq!(render_safe(Sink::Json, v, MAX_RENDER_FIELD), v);
426 assert!(render_safe(Sink::Junit, v, MAX_RENDER_FIELD).contains("<ok>"));
427 }
428}