pub mod conformance;
pub mod corpus;
pub mod rules;
use lazy_static::lazy_static;
use regex::Regex;
use std::collections::BTreeMap;
pub const MAX_RENDER_FIELD: usize = 256;
const TRUNCATION_MARKER: &str = "(truncated)";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Sink {
Stdout,
Json,
Sarif,
Junit,
Markdown,
Otel,
}
impl Sink {
pub fn as_str(self) -> &'static str {
match self {
Sink::Stdout => "stdout",
Sink::Json => "json",
Sink::Sarif => "sarif",
Sink::Junit => "junit",
Sink::Markdown => "markdown",
Sink::Otel => "otel",
}
}
pub fn encoding(self) -> &'static str {
match self {
Sink::Stdout => "terminal_safe",
Sink::Json | Sink::Sarif => "json_serializer",
Sink::Junit => "xml_escape",
Sink::Markdown => "markdown_neutralize",
Sink::Otel => "attribute_value",
}
}
pub const ALL: [Sink; 6] = [
Sink::Stdout,
Sink::Json,
Sink::Sarif,
Sink::Junit,
Sink::Markdown,
Sink::Otel,
];
}
#[derive(Debug, Clone, Default)]
pub struct RedactOutcome {
pub text: String,
pub fired: BTreeMap<String, u64>,
pub secret_hits: u64,
pub pii_hits: u64,
}
pub fn redact(input: &str) -> RedactOutcome {
let mut text = input.to_string();
let mut fired: BTreeMap<String, u64> = BTreeMap::new();
let mut secret_hits = 0u64;
let mut pii_hits = 0u64;
for rule in rules::RULES.iter() {
let count = rule.re.find_iter(&text).count() as u64;
if count == 0 {
continue;
}
*fired.entry(rule.name.to_string()).or_insert(0) += count;
match rule.class {
"secret" => secret_hits += count,
"pii" => pii_hits += count,
_ => {}
}
let placeholder = format!("<redacted:{}>", rule.name);
text = rule
.re
.replace_all(&text, placeholder.as_str())
.into_owned();
}
RedactOutcome {
text,
fired,
secret_hits,
pii_hits,
}
}
lazy_static! {
static ref ANSI_RE: Regex =
Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)").unwrap();
static ref LONE_ESC: Regex = Regex::new(r"\x1b").unwrap();
static ref CONTROL_RE: Regex =
Regex::new(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\u{80}-\u{9f}\u{202a}-\u{202e}\u{2066}-\u{2069}]").unwrap();
}
pub fn strip_control(input: &str) -> String {
let no_ansi = ANSI_RE.replace_all(input, "");
let no_esc = LONE_ESC.replace_all(&no_ansi, "\u{fffd}");
CONTROL_RE.replace_all(&no_esc, "\u{fffd}").into_owned()
}
pub fn has_residual_control(s: &str) -> bool {
s.chars().any(|c| {
c == '\u{7f}'
|| ('\u{00}'..='\u{08}').contains(&c)
|| c == '\u{0b}'
|| c == '\u{0c}'
|| ('\u{0e}'..='\u{1f}').contains(&c)
|| ('\u{80}'..='\u{9f}').contains(&c)
|| ('\u{202a}'..='\u{202e}').contains(&c)
|| ('\u{2066}'..='\u{2069}').contains(&c)
})
}
fn bound(text: &str, max_len: usize) -> String {
if text.chars().count() <= max_len {
return text.to_string();
}
let truncated: String = text.chars().take(max_len).collect();
format!("{truncated}{TRUNCATION_MARKER}")
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn markdown_neutralize(s: &str) -> String {
s.replace('`', "\\`")
.replace("](", "\\]\\(")
.replace("![", "\\!\\[")
.replace('<', "<")
.replace('>', ">")
.replace("javascript:", "javascript\\:")
}
fn encode(sink: Sink, text: &str) -> String {
match sink {
Sink::Stdout | Sink::Otel | Sink::Json | Sink::Sarif => text.to_string(),
Sink::Junit => xml_escape(text),
Sink::Markdown => markdown_neutralize(text),
}
}
pub fn render_safe_with_outcome(
sink: Sink,
input: &str,
max_len: usize,
) -> (String, RedactOutcome) {
let stripped = strip_control(input);
let redacted = redact(&stripped);
let bounded = bound(&redacted.text, max_len);
(encode(sink, &bounded), redacted)
}
pub fn render_safe(sink: Sink, input: &str, max_len: usize) -> String {
render_safe_with_outcome(sink, input, max_len).0
}
pub const UNTRUSTED_FIELDS: &[&str] = &[
"prompt",
"response",
"output",
"error",
"rationale",
"message",
"expected",
"actual",
"diff",
"tool_output",
"stdout",
"stderr",
];
fn is_untrusted_key(key: &str) -> bool {
UNTRUSTED_FIELDS.contains(&key)
}
pub fn render_details_safe(
sink: Sink,
value: &serde_json::Value,
max_len: usize,
) -> serde_json::Value {
render_details_inner(sink, value, max_len, false)
}
fn render_details_inner(
sink: Sink,
value: &serde_json::Value,
max_len: usize,
in_untrusted: bool,
) -> serde_json::Value {
use serde_json::Value;
match value {
Value::String(s) if in_untrusted => Value::String(render_safe(sink, s, max_len)),
Value::Array(items) => Value::Array(
items
.iter()
.map(|v| render_details_inner(sink, v, max_len, in_untrusted))
.collect(),
),
Value::Object(map) => {
let mut out = serde_json::Map::with_capacity(map.len());
for (k, v) in map {
let child_untrusted = in_untrusted || is_untrusted_key(k);
out.insert(
k.clone(),
render_details_inner(sink, v, max_len, child_untrusted),
);
}
Value::Object(out)
}
other => other.clone(),
}
}
#[doc(hidden)]
pub fn render_truncate_first_unsafe(sink: Sink, input: &str, max_len: usize) -> String {
let bounded = bound(input, max_len);
let stripped = strip_control(&bounded);
let redacted = redact(&stripped);
encode(sink, &redacted.text)
}
#[cfg(test)]
mod tests {
use super::*;
fn has_control(s: &str) -> bool {
s.contains('\u{1b}') || s.contains('\u{07}') || has_residual_control(s)
}
#[test]
fn redacts_secret_shapes_value_free() {
let token = format!("ghp_{}", "A".repeat(36));
let out = redact(&format!("here is {token} ok"));
assert!(out.text.contains("<redacted:github-token>"));
assert!(!out.text.contains(&token));
assert_eq!(out.secret_hits, 1);
}
#[test]
fn strips_terminal_control() {
let s = "\u{1b}[31mRED\u{1b}[0m\u{07}\u{202e}rev";
let out = strip_control(s);
assert!(!has_control(&out));
assert!(out.contains("RED"));
}
#[test]
fn render_safe_never_leaks_across_sinks() {
let secret = format!("ghp_{}", "B".repeat(36));
let input = format!("\u{1b}[31m{secret}\u{1b}[0m alice@example.com");
for sink in Sink::ALL {
let out = render_safe(sink, &input, MAX_RENDER_FIELD);
assert!(!out.contains(&secret), "{} leaked secret", sink.as_str());
assert!(
!out.contains("alice@example.com"),
"{} leaked pii",
sink.as_str()
);
assert!(!has_control(&out), "{} leaked control", sink.as_str());
}
}
#[test]
fn redact_before_truncate_does_not_leak_but_wrong_order_does() {
let secret = format!("ghp_{}", "C".repeat(36));
let input = format!("{} {secret}", "x".repeat(239));
let safe = render_safe(Sink::Stdout, &input, MAX_RENDER_FIELD);
assert!(
!safe.contains("ghp_"),
"redact-before-truncate must not leak"
);
let unsafe_out = render_truncate_first_unsafe(Sink::Stdout, &input, MAX_RENDER_FIELD);
assert!(
unsafe_out.contains("ghp_"),
"truncate-first is expected to leak"
);
}
#[test]
fn benign_near_matches_survive() {
let benign =
"uuid 123e4567-e89b-12d3-a456-426614174000 sha256:deadbeef path /usr/bin/assay";
let out = redact(benign);
assert!(
!out.text.contains("<redacted:"),
"benign text over-redacted: {}",
out.text
);
}
#[test]
fn sink_encodings_are_distinct_where_expected() {
assert_eq!(Sink::Junit.encoding(), "xml_escape");
assert_eq!(Sink::Markdown.encoding(), "markdown_neutralize");
assert_eq!(Sink::Sarif.encoding(), "json_serializer");
}
#[test]
fn details_walker_redacts_untrusted_keeps_owned_byte_stable() {
let secret = format!("ghp_{}", "D".repeat(36));
let email = "alice@example.com";
let details = serde_json::json!({
"prompt": format!("ask {secret}"),
"assertions": [{ "message": format!("got {email}") }, { "passed": true }],
"expected": "uuid 123e4567-e89b-12d3-a456-426614174000",
"skip": { "fingerprint": "abc123def456", "reason": "fingerprint_match" },
"score_pct": 42,
});
let safe = render_details_safe(Sink::Json, &details, usize::MAX);
let blob = safe.to_string();
assert!(!blob.contains(&secret), "prompt secret leaked");
assert!(!blob.contains(email), "nested assertion pii leaked");
assert!(blob.contains("<redacted:"), "no redaction markers fired");
assert_eq!(
safe["expected"],
serde_json::json!("uuid 123e4567-e89b-12d3-a456-426614174000")
);
assert_eq!(
safe["skip"]["fingerprint"],
serde_json::json!("abc123def456")
);
assert_eq!(
safe["skip"]["reason"],
serde_json::json!("fingerprint_match")
);
assert_eq!(safe["score_pct"], serde_json::json!(42));
assert_eq!(safe["assertions"][1]["passed"], serde_json::json!(true));
}
#[test]
fn details_walker_record_sink_keeps_full_length_but_strips_secret() {
let secret = format!("ghp_{}", "E".repeat(36));
let long = format!("{} {secret}", "z".repeat(400));
let details = serde_json::json!({ "response": long });
let safe = render_details_safe(Sink::Json, &details, usize::MAX);
let rendered = safe["response"].as_str().unwrap();
assert!(!rendered.contains(&secret), "record sink leaked secret");
assert!(rendered.contains("<redacted:github-token>"));
assert!(
!rendered.contains("(truncated)"),
"record sink must not truncate"
);
assert!(rendered.len() > 400, "record sink preserved full length");
}
#[test]
fn structured_sinks_return_unescaped_value_text() {
let v = r#"path "C:\tmp" <ok>"#;
assert_eq!(render_safe(Sink::Json, v, MAX_RENDER_FIELD), v);
assert!(render_safe(Sink::Junit, v, MAX_RENDER_FIELD).contains("<ok>"));
}
}