use crate::core::error::DecapodError;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
use std::sync::LazyLock;
#[derive(Debug, Serialize, Deserialize)]
pub struct TraceEvent {
pub trace_id: String,
pub ts: String,
pub actor: String,
pub op: String,
pub request: Value,
pub response: Value,
}
static SECRET_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
vec![
(
Regex::new(r"(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[0-9A-Z]{16}")
.unwrap(),
"[AWS_KEY_REDACTED]",
),
(
Regex::new(r#"(?i)aws[^=]*=\s*['"]?[0-9a-zA-Z/+=]{40}['"]?"#).unwrap(),
"[AWS_SECRET_REDACTED]",
),
(
Regex::new(r"(ghp|gho|ghu|ghs|ghr)_[a-zA-Z0-9_]{36,255}").unwrap(),
"[GITHUB_TOKEN_REDACTED]",
),
(
Regex::new(r"(?i)bearer\s+[a-zA-Z0-9_\-\.]{20,}").unwrap(),
"[BEARER_REDACTED]",
),
(
Regex::new(r"-----BEGIN (?:RSA |DSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |DSA |EC |OPENSSH )?PRIVATE KEY-----").unwrap(),
"[PEM_KEY_REDACTED]",
),
(
Regex::new(r"-----BEGIN (?:RSA |DSA |EC |OPENSSH )?PRIVATE KEY-----").unwrap(),
"[PEM_KEY_REDACTED]",
),
(
Regex::new(r#"(?i)(postgres|mysql|mongodb|redis)://[^\s'"]+:[^\s'"]+@[^\s'"]+"#)
.unwrap(),
"[CONNECTION_STRING_REDACTED]",
),
(
Regex::new(
r#"(?i)(api[_-]?key|apikey|api_secret|secret[_-]?key)['"]?\s*[:=]\s*['"]?[a-zA-Z0-9_\-]{20,}['"]?"#,
)
.unwrap(),
"[API_KEY_REDACTED]",
),
(
Regex::new(r#"(?i)(password|passwd|pwd)['"]?\s*[:=]\s*['"]?[^\s'"]{8,}['"]?"#)
.unwrap(),
"[PASSWORD_REDACTED]",
),
]
});
pub fn redact_string(input: &str) -> String {
let mut result = input.to_string();
for (pattern, replacement) in SECRET_PATTERNS.iter() {
result = pattern.replace_all(&result, *replacement).to_string();
}
result
}
pub fn redact(value: Value) -> Value {
match value {
Value::Object(map) => {
let mut redacted_map = Map::new();
for (key, val) in map {
let lower_key = key.to_lowercase();
if lower_key.contains("token")
|| lower_key.contains("secret")
|| lower_key.contains("password")
|| lower_key.contains("api_key")
|| lower_key.contains("authorization")
{
redacted_map.insert(key, Value::String("[REDACTED]".to_string()));
} else {
redacted_map.insert(key, redact(val));
}
}
Value::Object(redacted_map)
}
Value::Array(vec) => Value::Array(vec.into_iter().map(redact).collect()),
Value::String(s) => Value::String(redact_string(&s)),
other => other,
}
}
pub fn append_trace(project_root: &Path, event: TraceEvent) -> Result<(), DecapodError> {
let trace_path = project_root.join(".decapod/data/traces.jsonl");
if let Some(parent) = trace_path.parent() {
std::fs::create_dir_all(parent).map_err(DecapodError::IoError)?;
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&trace_path)
.map_err(DecapodError::IoError)?;
let redacted_event = TraceEvent {
trace_id: event.trace_id,
ts: event.ts,
actor: event.actor,
op: event.op,
request: redact(event.request),
response: redact(event.response),
};
let json = serde_json::to_string(&redacted_event)
.map_err(|e| DecapodError::ValidationError(e.to_string()))?;
writeln!(file, "{}", json).map_err(DecapodError::IoError)?;
Ok(())
}
pub fn get_last_traces(project_root: &Path, n: usize) -> Result<Vec<String>, DecapodError> {
let trace_path = project_root.join(".decapod/data/traces.jsonl");
if !trace_path.exists() {
return Ok(vec![]);
}
let content = std::fs::read_to_string(trace_path).map_err(DecapodError::IoError)?;
let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
let start = if lines.len() > n { lines.len() - n } else { 0 };
Ok(lines[start..].to_vec())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_redact_aws_key() {
let input = "my key is AKIAIOSFODNN7EXAMPLE ok";
let result = redact_string(input);
assert!(result.contains("[AWS_KEY_REDACTED]"));
assert!(!result.contains("AKIAIOSFODNN7EXAMPLE"));
}
#[test]
fn test_redact_github_token() {
let input = "token=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
let result = redact_string(input);
assert!(result.contains("[GITHUB_TOKEN_REDACTED]"));
assert!(!result.contains("ghp_"));
}
#[test]
fn test_redact_bearer_token() {
let input = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.sig";
let result = redact_string(input);
assert!(result.contains("[BEARER_REDACTED]"));
}
#[test]
fn test_redact_pem_key() {
let input =
"-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA\n-----END RSA PRIVATE KEY-----";
let result = redact_string(input);
assert!(result.contains("[PEM_KEY_REDACTED]"));
assert!(!result.contains("MIIEpAIBAAKCAQEA"));
}
#[test]
fn test_redact_connection_string() {
let input = "DATABASE_URL=postgres://user:s3cret@host:5432/db";
let result = redact_string(input);
assert!(result.contains("[CONNECTION_STRING_REDACTED]"));
assert!(!result.contains("s3cret"));
}
#[test]
fn test_redact_password_assignment() {
let input = r#"password="my_super_secret_pass""#;
let result = redact_string(input);
assert!(result.contains("[PASSWORD_REDACTED]"));
}
#[test]
fn test_redact_json_value() {
let val = serde_json::json!({
"command": "export AWS_KEY=AKIAIOSFODNN7EXAMPLE",
"my_token": "should_be_fully_redacted",
"safe_field": "no secrets here"
});
let redacted = redact(val);
let obj = redacted.as_object().unwrap();
assert_eq!(obj["my_token"], "[REDACTED]");
let cmd = obj["command"].as_str().unwrap();
assert!(cmd.contains("[AWS_KEY_REDACTED]"));
assert_eq!(obj["safe_field"], "no secrets here");
}
#[test]
fn test_no_false_positive_on_safe_strings() {
let input = "this is a normal log message with no secrets";
assert_eq!(redact_string(input), input);
}
}