#[must_use]
pub fn redact_sql(sql: &str) -> String {
let stage1 = redact_keyword_secrets(sql);
redact_url_passwords(&stage1)
}
fn redact_keyword_secrets(sql: &str) -> String {
let chars: Vec<char> = sql.chars().collect();
let lower: Vec<char> = sql.to_lowercase().chars().collect();
if lower.len() != chars.len() {
return sql.to_string();
}
let mut out = String::with_capacity(sql.len());
let mut i = 0usize;
while i < chars.len() {
if let Some(after_kw) = match_keyword(&lower, i, "identified by") {
push_range(&mut out, &chars, i, after_kw);
let mut j = skip_ws(&chars, after_kw, &mut out);
if let Some(after_pw) = match_keyword(&lower, j, "password") {
push_range(&mut out, &chars, j, after_pw);
j = skip_ws(&chars, after_pw, &mut out);
}
i = redact_secret_operand(&chars, j, &mut out);
continue;
}
if let Some(after_kw) = match_keyword(&lower, i, "password") {
push_range(&mut out, &chars, i, after_kw);
let j = skip_ws(&chars, after_kw, &mut out);
i = redact_secret_operand(&chars, j, &mut out);
continue;
}
out.push(chars[i]);
i += 1;
}
out
}
fn match_keyword(lower: &[char], i: usize, keyword: &str) -> Option<usize> {
let kw: Vec<char> = keyword.chars().collect();
if !is_word_boundary_before(lower, i) {
return None;
}
let mut li = i;
let mut ki = 0usize;
while ki < kw.len() {
if kw[ki] == ' ' {
if li >= lower.len() || !lower[li].is_whitespace() {
return None;
}
while li < lower.len() && lower[li].is_whitespace() {
li += 1;
}
ki += 1;
continue;
}
if li >= lower.len() || lower[li] != kw[ki] {
return None;
}
li += 1;
ki += 1;
}
if is_word_boundary_after(lower, li) {
Some(li)
} else {
None
}
}
fn is_word_boundary_before(lower: &[char], i: usize) -> bool {
if i == 0 {
return true;
}
!is_ident_char(lower[i - 1])
}
fn is_word_boundary_after(lower: &[char], i: usize) -> bool {
if i >= lower.len() {
return true;
}
!is_ident_char(lower[i])
}
fn is_ident_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn push_range(out: &mut String, chars: &[char], start: usize, end: usize) {
for &c in &chars[start..end] {
out.push(c);
}
}
fn skip_ws(chars: &[char], mut i: usize, out: &mut String) -> usize {
while i < chars.len() && chars[i].is_whitespace() {
out.push(chars[i]);
i += 1;
}
i
}
fn redact_secret_operand(chars: &[char], mut i: usize, out: &mut String) -> usize {
if i < chars.len() && chars[i] == '=' {
out.push('=');
i = skip_ws(chars, i + 1, out);
}
if i >= chars.len() {
return i;
}
let c = chars[i];
if c == '\'' || c == '"' {
let quote = c;
out.push(quote);
out.push('*');
out.push('*');
out.push('*');
out.push(quote);
let mut j = i + 1;
while j < chars.len() {
if chars[j] == quote {
if j + 1 < chars.len() && chars[j + 1] == quote {
j += 2; continue;
}
j += 1; break;
}
j += 1;
}
j
} else if is_ident_char(c) {
out.push_str("***");
let mut j = i;
while j < chars.len() && is_ident_char(chars[j]) {
j += 1;
}
j
} else {
i
}
}
fn redact_url_passwords(sql: &str) -> String {
let chars: Vec<char> = sql.chars().collect();
let mut out = String::with_capacity(sql.len());
let mut i = 0usize;
while i < chars.len() {
if starts_with_at(&chars, i, "://") {
out.push_str("://");
let auth_start = i + 3;
let mut end = auth_start;
while end < chars.len()
&& !matches!(chars[end], '/' | '?' | '#')
&& !chars[end].is_whitespace()
{
end += 1;
}
let at = (auth_start..end).find(|&k| chars[k] == '@');
if let Some(at) = at {
let colon = (auth_start..at).find(|&k| chars[k] == ':');
if let Some(colon) = colon {
push_range(&mut out, &chars, auth_start, colon + 1);
out.push_str("***");
push_range(&mut out, &chars, at, end);
i = end;
continue;
}
}
push_range(&mut out, &chars, auth_start, end);
i = end;
continue;
}
out.push(chars[i]);
i += 1;
}
out
}
fn starts_with_at(chars: &[char], i: usize, needle: &str) -> bool {
let n: Vec<char> = needle.chars().collect();
if i + n.len() > chars.len() {
return false;
}
chars[i..i + n.len()] == n[..]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redacts_postgres_password_single_quote() {
let out = redact_sql("CREATE ROLE bob PASSWORD 'hunter2'");
assert!(!out.contains("hunter2"), "secret leaked: {out}");
assert!(out.contains("***"), "no redaction marker: {out}");
assert_eq!(out, "CREATE ROLE bob PASSWORD '***'");
}
#[test]
fn redacts_password_double_quote() {
let out = redact_sql(r#"ALTER ROLE x PASSWORD "s3cret""#);
assert!(!out.contains("s3cret"));
assert_eq!(out, r#"ALTER ROLE x PASSWORD "***""#);
}
#[test]
fn redacts_oracle_identified_by_bareword() {
let out = redact_sql("ALTER USER scott IDENTIFIED BY tiger");
assert!(!out.contains("tiger"), "secret leaked: {out}");
assert_eq!(out, "ALTER USER scott IDENTIFIED BY ***");
}
#[test]
fn redacts_identified_by_quoted() {
let out = redact_sql("CREATE USER u IDENTIFIED BY 'p@ss'");
assert!(!out.contains("p@ss"));
assert_eq!(out, "CREATE USER u IDENTIFIED BY '***'");
}
#[test]
fn redacts_password_with_doubled_quote_escape() {
let out = redact_sql("CREATE ROLE bob PASSWORD 'hun''ter2'");
assert!(!out.contains("hun"), "secret head leaked: {out}");
assert!(
!out.contains("ter2"),
"secret tail leaked past escape: {out}"
);
assert_eq!(out, "CREATE ROLE bob PASSWORD '***'");
}
#[test]
fn redacts_mssql_with_password_equals() {
let out = redact_sql("CREATE LOGIN foo WITH PASSWORD = 'secret'");
assert!(!out.contains("secret"), "secret leaked: {out}");
assert_eq!(out, "CREATE LOGIN foo WITH PASSWORD = '***'");
let alter = redact_sql("ALTER LOGIN foo WITH PASSWORD = 'h@x'");
assert!(!alter.contains("h@x"), "secret leaked: {alter}");
assert_eq!(alter, "ALTER LOGIN foo WITH PASSWORD = '***'");
let tight = redact_sql("CREATE USER u WITH PASSWORD=tiger");
assert!(!tight.contains("tiger"), "secret leaked: {tight}");
assert_eq!(tight, "CREATE USER u WITH PASSWORD=***");
}
#[test]
fn redacts_mysql_identified_by_password_hash() {
let out = redact_sql("ALTER USER u IDENTIFIED BY PASSWORD '*ABCDEF0123'");
assert!(!out.contains("ABCDEF0123"), "hash leaked: {out}");
assert_eq!(out, "ALTER USER u IDENTIFIED BY PASSWORD '***'");
}
#[test]
fn redacts_embedded_connection_url_password() {
let out = redact_sql("SELECT dblink('postgres://u:secret@h/db')");
assert!(!out.contains("secret"), "url password leaked: {out}");
assert!(
out.contains("postgres://u:***@h/db"),
"url not redacted: {out}"
);
}
#[test]
fn url_without_password_is_unchanged_authority() {
let out = redact_sql("SELECT dblink('postgres://u@h/db')");
assert_eq!(out, "SELECT dblink('postgres://u@h/db')");
}
#[test]
fn keyword_inside_identifier_is_not_redacted() {
let out = redact_sql("SELECT password_reset FROM users");
assert_eq!(out, "SELECT password_reset FROM users");
let out2 = redact_sql("SELECT mypassword FROM t");
assert_eq!(out2, "SELECT mypassword FROM t");
}
#[test]
fn case_insensitive_keyword_match() {
let out = redact_sql("create role bob password 'hunter2'");
assert!(!out.contains("hunter2"));
let out2 = redact_sql("ALTER user x identified by SECRETWORD");
assert!(!out2.contains("SECRETWORD"), "secret leaked: {out2}");
}
#[test]
fn plain_select_passes_through_byte_identical() {
let sql = "SELECT id, name FROM users WHERE age > 30 ORDER BY id";
assert_eq!(redact_sql(sql), sql);
}
#[test]
fn empty_input_is_empty() {
assert_eq!(redact_sql(""), "");
}
#[test]
fn documented_false_negative_unknown_idiom_passes_through() {
let sql = "EXEC set_secret @value = 'topsecret'";
let out = redact_sql(sql);
assert_eq!(
out, sql,
"unknown idiom is a documented false-negative, must pass through unchanged"
);
}
#[test]
fn documented_false_negative_secret_nested_in_outer_literal() {
let sql = "SELECT 'CREATE ROLE bob PASSWORD ''hunter2''' AS note";
let out = redact_sql(sql);
assert!(
out.contains("***"),
"scanner still emits a redaction marker: {out}"
);
}
}