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{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{202a}'..='\u{202e}').contains(&c)
145 || ('\u{2066}'..='\u{2069}').contains(&c)
146 })
147}
148
149fn bound(text: &str, max_len: usize) -> String {
150 if text.chars().count() <= max_len {
151 return text.to_string();
152 }
153 let truncated: String = text.chars().take(max_len).collect();
154 format!("{truncated}{TRUNCATION_MARKER}")
155}
156
157fn xml_escape(s: &str) -> String {
158 s.replace('&', "&")
159 .replace('<', "<")
160 .replace('>', ">")
161 .replace('"', """)
162 .replace('\'', "'")
163}
164
165fn markdown_neutralize(s: &str) -> String {
166 s.replace('`', "\\`")
167 .replace("](", "\\]\\(")
168 .replace("![", "\\!\\[")
169 .replace('<', "<")
170 .replace('>', ">")
171 .replace("javascript:", "javascript\\:")
172}
173
174fn encode(sink: Sink, text: &str) -> String {
181 match sink {
182 Sink::Stdout | Sink::Otel | Sink::Json | Sink::Sarif => text.to_string(),
183 Sink::Junit => xml_escape(text),
184 Sink::Markdown => markdown_neutralize(text),
185 }
186}
187
188pub fn render_safe_with_outcome(
192 sink: Sink,
193 input: &str,
194 max_len: usize,
195) -> (String, RedactOutcome) {
196 let stripped = strip_control(input);
197 let redacted = redact(&stripped);
198 let bounded = bound(&redacted.text, max_len);
199 (encode(sink, &bounded), redacted)
200}
201
202pub fn render_safe(sink: Sink, input: &str, max_len: usize) -> String {
204 render_safe_with_outcome(sink, input, max_len).0
205}
206
207pub const UNTRUSTED_FIELDS: &[&str] = &[
214 "prompt",
215 "response",
216 "output",
217 "error",
218 "rationale",
219 "message",
220 "expected",
221 "actual",
222 "diff",
223 "tool_output",
224 "stdout",
225 "stderr",
226];
227
228fn is_untrusted_key(key: &str) -> bool {
229 UNTRUSTED_FIELDS.contains(&key)
230}
231
232pub fn render_details_safe(
240 sink: Sink,
241 value: &serde_json::Value,
242 max_len: usize,
243) -> serde_json::Value {
244 render_details_inner(sink, value, max_len, false)
245}
246
247fn render_details_inner(
248 sink: Sink,
249 value: &serde_json::Value,
250 max_len: usize,
251 in_untrusted: bool,
252) -> serde_json::Value {
253 use serde_json::Value;
254 match value {
255 Value::String(s) if in_untrusted => Value::String(render_safe(sink, s, max_len)),
256 Value::Array(items) => Value::Array(
257 items
258 .iter()
259 .map(|v| render_details_inner(sink, v, max_len, in_untrusted))
260 .collect(),
261 ),
262 Value::Object(map) => {
263 let mut out = serde_json::Map::with_capacity(map.len());
264 for (k, v) in map {
265 let child_untrusted = in_untrusted || is_untrusted_key(k);
266 out.insert(
267 k.clone(),
268 render_details_inner(sink, v, max_len, child_untrusted),
269 );
270 }
271 Value::Object(out)
272 }
273 other => other.clone(),
274 }
275}
276
277#[doc(hidden)]
280pub fn render_truncate_first_unsafe(sink: Sink, input: &str, max_len: usize) -> String {
281 let bounded = bound(input, max_len);
282 let stripped = strip_control(&bounded);
283 let redacted = redact(&stripped);
284 encode(sink, &redacted.text)
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 fn has_control(s: &str) -> bool {
292 s.contains('\u{1b}') || s.contains('\u{07}') || has_residual_control(s)
293 }
294
295 #[test]
296 fn redacts_secret_shapes_value_free() {
297 let token = format!("ghp_{}", "A".repeat(36));
298 let out = redact(&format!("here is {token} ok"));
299 assert!(out.text.contains("<redacted:github-token>"));
300 assert!(!out.text.contains(&token));
301 assert_eq!(out.secret_hits, 1);
302 }
303
304 #[test]
305 fn strips_terminal_control() {
306 let s = "\u{1b}[31mRED\u{1b}[0m\u{07}\u{202e}rev";
307 let out = strip_control(s);
308 assert!(!has_control(&out));
309 assert!(out.contains("RED"));
310 }
311
312 #[test]
313 fn render_safe_never_leaks_across_sinks() {
314 let secret = format!("ghp_{}", "B".repeat(36));
315 let input = format!("\u{1b}[31m{secret}\u{1b}[0m alice@example.com");
316 for sink in Sink::ALL {
317 let out = render_safe(sink, &input, MAX_RENDER_FIELD);
318 assert!(!out.contains(&secret), "{} leaked secret", sink.as_str());
319 assert!(
320 !out.contains("alice@example.com"),
321 "{} leaked pii",
322 sink.as_str()
323 );
324 assert!(!has_control(&out), "{} leaked control", sink.as_str());
325 }
326 }
327
328 #[test]
329 fn redact_before_truncate_does_not_leak_but_wrong_order_does() {
330 let secret = format!("ghp_{}", "C".repeat(36));
333 let input = format!("{} {secret}", "x".repeat(239));
334 let safe = render_safe(Sink::Stdout, &input, MAX_RENDER_FIELD);
335 assert!(
336 !safe.contains("ghp_"),
337 "redact-before-truncate must not leak"
338 );
339 let unsafe_out = render_truncate_first_unsafe(Sink::Stdout, &input, MAX_RENDER_FIELD);
340 assert!(
341 unsafe_out.contains("ghp_"),
342 "truncate-first is expected to leak"
343 );
344 }
345
346 #[test]
347 fn benign_near_matches_survive() {
348 let benign =
349 "uuid 123e4567-e89b-12d3-a456-426614174000 sha256:deadbeef path /usr/bin/assay";
350 let out = redact(benign);
351 assert!(
352 !out.text.contains("<redacted:"),
353 "benign text over-redacted: {}",
354 out.text
355 );
356 }
357
358 #[test]
359 fn sink_encodings_are_distinct_where_expected() {
360 assert_eq!(Sink::Junit.encoding(), "xml_escape");
361 assert_eq!(Sink::Markdown.encoding(), "markdown_neutralize");
362 assert_eq!(Sink::Sarif.encoding(), "json_serializer");
364 }
365
366 #[test]
367 fn details_walker_redacts_untrusted_keeps_owned_byte_stable() {
368 let secret = format!("ghp_{}", "D".repeat(36));
369 let email = "alice@example.com";
370 let details = serde_json::json!({
371 "prompt": format!("ask {secret}"),
372 "assertions": [{ "message": format!("got {email}") }, { "passed": true }],
373 "expected": "uuid 123e4567-e89b-12d3-a456-426614174000",
374 "skip": { "fingerprint": "abc123def456", "reason": "fingerprint_match" },
375 "score_pct": 42,
376 });
377 let safe = render_details_safe(Sink::Json, &details, usize::MAX);
378 let blob = safe.to_string();
379 assert!(!blob.contains(&secret), "prompt secret leaked");
381 assert!(!blob.contains(email), "nested assertion pii leaked");
382 assert!(blob.contains("<redacted:"), "no redaction markers fired");
383 assert_eq!(
385 safe["expected"],
386 serde_json::json!("uuid 123e4567-e89b-12d3-a456-426614174000")
387 );
388 assert_eq!(
390 safe["skip"]["fingerprint"],
391 serde_json::json!("abc123def456")
392 );
393 assert_eq!(
394 safe["skip"]["reason"],
395 serde_json::json!("fingerprint_match")
396 );
397 assert_eq!(safe["score_pct"], serde_json::json!(42));
398 assert_eq!(safe["assertions"][1]["passed"], serde_json::json!(true));
400 }
401
402 #[test]
403 fn details_walker_record_sink_keeps_full_length_but_strips_secret() {
404 let secret = format!("ghp_{}", "E".repeat(36));
406 let long = format!("{} {secret}", "z".repeat(400));
407 let details = serde_json::json!({ "response": long });
408 let safe = render_details_safe(Sink::Json, &details, usize::MAX);
409 let rendered = safe["response"].as_str().unwrap();
410 assert!(!rendered.contains(&secret), "record sink leaked secret");
411 assert!(rendered.contains("<redacted:github-token>"));
412 assert!(
413 !rendered.contains("(truncated)"),
414 "record sink must not truncate"
415 );
416 assert!(rendered.len() > 400, "record sink preserved full length");
417 }
418
419 #[test]
420 fn structured_sinks_return_unescaped_value_text() {
421 let v = r#"path "C:\tmp" <ok>"#;
424 assert_eq!(render_safe(Sink::Json, v, MAX_RENDER_FIELD), v);
425 assert!(render_safe(Sink::Junit, v, MAX_RENDER_FIELD).contains("<ok>"));
426 }
427}