393 lines
14 KiB
Rust
393 lines
14 KiB
Rust
use regex::Regex;
|
|
use std::sync::LazyLock;
|
|
|
|
/// Patterns for sensitive data that should be redacted from logs and event payloads.
|
|
static REDACTION_PATTERNS: LazyLock<Vec<(String, Regex)>> = LazyLock::new(|| {
|
|
vec![
|
|
(
|
|
"api_key".to_string(),
|
|
Regex::new(r#"(?i)(api[_-]?key|apikey)\s*[:=]\s*["']?([A-Za-z0-9\-_]{20,})["']?"#)
|
|
.unwrap(),
|
|
),
|
|
(
|
|
"bearer_token".to_string(),
|
|
Regex::new(
|
|
r#"(?i)(bearer|token|authorization)\s*[:=]\s*["']?([A-Za-z0-9\-_\.]{20,})["']?"#,
|
|
)
|
|
.unwrap(),
|
|
),
|
|
(
|
|
"password".to_string(),
|
|
Regex::new(r#"(?i)(password|passwd|pwd)\s*[:=]\s*["']?(\S{8,})["']?"#).unwrap(),
|
|
),
|
|
(
|
|
"private_key".to_string(),
|
|
Regex::new(r#"-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----"#).unwrap(),
|
|
),
|
|
(
|
|
"connection_string".to_string(),
|
|
Regex::new(r#"(?i)(mongodb|postgres|mysql|redis|amqp)://\S+"#).unwrap(),
|
|
),
|
|
(
|
|
"jwt_token".to_string(),
|
|
Regex::new(r#"eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}"#).unwrap(),
|
|
),
|
|
]
|
|
});
|
|
|
|
/// Redact sensitive information from a string.
|
|
///
|
|
/// Replaces matched patterns with `<REDACTED:{label}>` to prevent accidental
|
|
/// leakage of secrets through logs, event payloads, or error messages.
|
|
pub fn redact_secrets(input: &str) -> String {
|
|
let mut result = input.to_string();
|
|
for (label, pattern) in REDACTION_PATTERNS.iter() {
|
|
result = pattern
|
|
.replace_all(&result, format!("<REDACTED:{}>", label))
|
|
.to_string();
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Truncate a string to a maximum byte length, appending "..." if truncated.
|
|
pub fn truncate_bytes(input: &str, max_bytes: usize) -> String {
|
|
let bytes = input.as_bytes();
|
|
if bytes.len() <= max_bytes {
|
|
input.to_string()
|
|
} else {
|
|
let truncated = String::from_utf8_lossy(&bytes[..max_bytes.saturating_sub(3)]);
|
|
format!("{}...", truncated)
|
|
}
|
|
}
|
|
|
|
/// Data classification for storage rules.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum DataClass {
|
|
/// Source code files — retained for the project lifetime.
|
|
SourceCode,
|
|
/// LLM prompts and system instructions — confidential, short retention.
|
|
Prompt,
|
|
/// Model-generated output — confidential, short retention.
|
|
ModelOutput,
|
|
/// Tool call arguments — confidential, may contain secrets.
|
|
ToolArguments,
|
|
/// Tool call results — confidential, may contain file content.
|
|
ToolResult,
|
|
/// User approval decisions — audit trail, long retention.
|
|
ApprovalData,
|
|
/// API keys, tokens, credentials — secret, never persisted in logs.
|
|
Credentials,
|
|
/// File mutation content (diffs, snapshots) — internal, medium retention.
|
|
MutationContent,
|
|
/// Operational traces (heartbeats, health checks) — internal, short retention.
|
|
OperationalTrace,
|
|
/// Domain events — internal, medium retention.
|
|
DomainEvent,
|
|
/// UI events (Lookout replay) — internal, short retention.
|
|
UiEvent,
|
|
/// Execution logs — confidential, short retention.
|
|
ExecutionLog,
|
|
/// Agent run records — internal, medium retention.
|
|
AgentRun,
|
|
/// Documentation output — source code, project lifetime.
|
|
Documentation,
|
|
}
|
|
|
|
/// Retention configuration per data class.
|
|
#[derive(Debug, Clone)]
|
|
pub struct RetentionPolicy {
|
|
/// Maximum number of days to retain processed events.
|
|
pub event_retention_days: u32,
|
|
/// Maximum number of days to retain completed agent runs.
|
|
pub run_retention_days: u32,
|
|
/// Maximum number of days to retain audit logs.
|
|
pub audit_retention_days: u32,
|
|
/// Maximum size of event payloads in bytes (larger payloads are truncated).
|
|
pub max_event_payload_bytes: usize,
|
|
/// Maximum number of days to retain execution logs.
|
|
pub execution_log_retention_days: u32,
|
|
/// Maximum number of days to retain UI replay events.
|
|
pub ui_event_retention_days: u32,
|
|
/// Maximum number of days to retain documentation.
|
|
pub documentation_retention_days: u32,
|
|
}
|
|
|
|
impl Default for RetentionPolicy {
|
|
fn default() -> Self {
|
|
Self {
|
|
event_retention_days: 30,
|
|
run_retention_days: 90,
|
|
audit_retention_days: 60,
|
|
max_event_payload_bytes: 64 * 1024, // 64 KB
|
|
execution_log_retention_days: 14,
|
|
ui_event_retention_days: 7,
|
|
documentation_retention_days: 365,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl RetentionPolicy {
|
|
/// Create a retention policy from environment variables with sensible defaults.
|
|
pub fn from_env() -> Self {
|
|
Self {
|
|
event_retention_days: read_env_u32("SHOAL_EVENT_RETENTION_DAYS", 30),
|
|
run_retention_days: read_env_u32("SHOAL_RUN_RETENTION_DAYS", 90),
|
|
audit_retention_days: read_env_u32("SHOAL_AUDIT_RETENTION_DAYS", 60),
|
|
max_event_payload_bytes: read_env_usize("SHOAL_MAX_EVENT_PAYLOAD_BYTES", 64 * 1024),
|
|
execution_log_retention_days: read_env_u32("SHOAL_EXEC_LOG_RETENTION_DAYS", 14),
|
|
ui_event_retention_days: read_env_u32("SHOAL_UI_EVENT_RETENTION_DAYS", 7),
|
|
documentation_retention_days: read_env_u32("SHOAL_DOCS_RETENTION_DAYS", 365),
|
|
}
|
|
}
|
|
|
|
/// Returns the retention period in days for a given data class.
|
|
pub fn retention_days(&self, class: DataClass) -> u32 {
|
|
match class {
|
|
DataClass::SourceCode | DataClass::Documentation => {
|
|
self.documentation_retention_days
|
|
}
|
|
DataClass::Prompt | DataClass::ModelOutput => 7,
|
|
DataClass::ToolArguments | DataClass::ToolResult => {
|
|
self.execution_log_retention_days
|
|
}
|
|
DataClass::ApprovalData | DataClass::AgentRun => self.run_retention_days,
|
|
DataClass::Credentials => 0, // never retained
|
|
DataClass::MutationContent => self.run_retention_days,
|
|
DataClass::OperationalTrace => 3,
|
|
DataClass::DomainEvent => self.event_retention_days,
|
|
DataClass::UiEvent => self.ui_event_retention_days,
|
|
DataClass::ExecutionLog => self.execution_log_retention_days,
|
|
}
|
|
}
|
|
|
|
/// Whether the data class should be redacted before persistence.
|
|
pub fn should_redact(&self, class: DataClass) -> bool {
|
|
matches!(
|
|
class,
|
|
DataClass::ToolArguments
|
|
| DataClass::ToolResult
|
|
| DataClass::Credentials
|
|
| DataClass::Prompt
|
|
| DataClass::ModelOutput
|
|
| DataClass::ExecutionLog
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Applied retention policy service. Wraps a `RetentionPolicy` and provides
|
|
/// methods to enforce redaction and truncation on data before it reaches any
|
|
/// store (domain events, UI events, execution logs, agent runs, etc.).
|
|
pub struct DataRetentionService {
|
|
policy: RetentionPolicy,
|
|
}
|
|
|
|
impl DataRetentionService {
|
|
pub fn new(policy: RetentionPolicy) -> Self {
|
|
Self { policy }
|
|
}
|
|
|
|
pub fn from_env() -> Self {
|
|
Self::new(RetentionPolicy::from_env())
|
|
}
|
|
|
|
/// Returns a reference to the underlying policy.
|
|
pub fn policy(&self) -> &RetentionPolicy {
|
|
&self.policy
|
|
}
|
|
|
|
/// Apply redaction and truncation to a payload string based on its data class.
|
|
pub fn prepare_for_storage(&self, data: &str, class: DataClass) -> String {
|
|
let result = if self.policy.should_redact(class) {
|
|
redact_secrets(data)
|
|
} else {
|
|
data.to_string()
|
|
};
|
|
let max_bytes = match class {
|
|
DataClass::DomainEvent | DataClass::UiEvent => self.policy.max_event_payload_bytes,
|
|
DataClass::ExecutionLog => 32 * 1024, // 32 KB
|
|
DataClass::AgentRun => 128 * 1024, // 128 KB
|
|
_ => usize::MAX,
|
|
};
|
|
truncate_bytes(&result, max_bytes)
|
|
}
|
|
|
|
/// Prepare a JSON value for storage, redacting sensitive fields.
|
|
pub fn prepare_json_for_storage(
|
|
&self,
|
|
value: &serde_json::Value,
|
|
class: DataClass,
|
|
) -> serde_json::Value {
|
|
if !self.policy.should_redact(class) {
|
|
return value.clone();
|
|
}
|
|
redact_json_secrets(value)
|
|
}
|
|
|
|
/// Returns true if the given data class should be retained at all.
|
|
/// Credentials, for example, should never be persisted.
|
|
pub fn should_retain(&self, class: DataClass) -> bool {
|
|
self.policy.retention_days(class) > 0
|
|
}
|
|
}
|
|
|
|
/// Recursively redact sensitive fields in a JSON value.
|
|
fn redact_json_secrets(value: &serde_json::Value) -> serde_json::Value {
|
|
match value {
|
|
serde_json::Value::String(s) => serde_json::Value::String(redact_secrets(s)),
|
|
serde_json::Value::Object(map) => {
|
|
let redacted: serde_json::Map<String, serde_json::Value> = map
|
|
.iter()
|
|
.map(|(k, v)| {
|
|
let new_v = if is_sensitive_json_field(k) {
|
|
serde_json::Value::String("<REDACTED>".to_string())
|
|
} else {
|
|
redact_json_secrets(v)
|
|
};
|
|
(k.clone(), new_v)
|
|
})
|
|
.collect();
|
|
serde_json::Value::Object(redacted)
|
|
}
|
|
serde_json::Value::Array(arr) => {
|
|
serde_json::Value::Array(arr.iter().map(redact_json_secrets).collect())
|
|
}
|
|
other => other.clone(),
|
|
}
|
|
}
|
|
|
|
fn is_sensitive_json_field(name: &str) -> bool {
|
|
matches!(
|
|
name.to_lowercase().as_str(),
|
|
"password" | "secret" | "api_key" | "apikey" | "token" | "authorization"
|
|
| "private_key" | "credentials" | "env" | "environment" | "bearer"
|
|
)
|
|
}
|
|
|
|
fn read_env_u32(key: &str, default: u32) -> u32 {
|
|
std::env::var(key)
|
|
.ok()
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(default)
|
|
}
|
|
|
|
fn read_env_usize(key: &str, default: usize) -> usize {
|
|
std::env::var(key)
|
|
.ok()
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(default)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_redact_api_key() {
|
|
let input = r#"{"api_key": "sk-abcdefghijklmnopqrstuvwxyz123456"}"#;
|
|
let redacted = redact_secrets(input);
|
|
assert!(redacted.contains("<REDACTED:api_key>"));
|
|
assert!(!redacted.contains("sk-abcdefghijklmnopqrstuvwxyz123456"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_redact_bearer_token() {
|
|
let input = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
|
|
let redacted = redact_secrets(input);
|
|
assert!(redacted.contains("<REDACTED:bearer_token>"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_redact_password() {
|
|
let input = r#"password = "supersecret123""#;
|
|
let redacted = redact_secrets(input);
|
|
assert!(redacted.contains("<REDACTED:password>"));
|
|
assert!(!redacted.contains("supersecret123"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_redact_private_key() {
|
|
let input = "-----BEGIN RSA PRIVATE KEY-----";
|
|
let redacted = redact_secrets(input);
|
|
assert!(redacted.contains("<REDACTED:private_key>"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_no_redaction_for_clean_text() {
|
|
let input = "This is a normal log message with no secrets.";
|
|
let redacted = redact_secrets(input);
|
|
assert_eq!(input, redacted);
|
|
}
|
|
|
|
#[test]
|
|
fn test_truncate_bytes() {
|
|
assert_eq!(truncate_bytes("hello", 10), "hello");
|
|
assert_eq!(truncate_bytes("hello world", 8), "hello...");
|
|
assert_eq!(truncate_bytes("", 5), "");
|
|
}
|
|
|
|
#[test]
|
|
fn test_retention_policy_defaults() {
|
|
let policy = RetentionPolicy::default();
|
|
assert_eq!(policy.event_retention_days, 30);
|
|
assert_eq!(policy.run_retention_days, 90);
|
|
assert_eq!(policy.audit_retention_days, 60);
|
|
assert_eq!(policy.max_event_payload_bytes, 64 * 1024);
|
|
assert_eq!(policy.execution_log_retention_days, 14);
|
|
assert_eq!(policy.ui_event_retention_days, 7);
|
|
assert_eq!(policy.documentation_retention_days, 365);
|
|
}
|
|
|
|
#[test]
|
|
fn test_data_retention_service_redacts_tool_args() {
|
|
let svc = DataRetentionService::from_env();
|
|
let input = r#"{"api_key": "sk-abcdefghijklmnopqrstuvwxyz123456"}"#;
|
|
let result = svc.prepare_for_storage(input, DataClass::ToolArguments);
|
|
assert!(result.contains("<REDACTED:api_key>"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_data_retention_service_no_redact_for_source_code() {
|
|
let svc = DataRetentionService::from_env();
|
|
let input = "fn main() { let api_key = \"test\"; }";
|
|
let result = svc.prepare_for_storage(input, DataClass::SourceCode);
|
|
assert_eq!(input, result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_data_retention_service_credentials_never_retained() {
|
|
let svc = DataRetentionService::from_env();
|
|
assert!(!svc.should_retain(DataClass::Credentials));
|
|
}
|
|
|
|
#[test]
|
|
fn test_redact_json_secrets() {
|
|
let value = serde_json::json!({
|
|
"name": "test",
|
|
"api_key": "sk-abcdefghijklmnopqrstuvwxyz123456",
|
|
"nested": {
|
|
"password": "hunter2"
|
|
}
|
|
});
|
|
let redacted = redact_json_secrets(&value);
|
|
assert_eq!(redacted["name"], "test");
|
|
assert_eq!(redacted["api_key"], "<REDACTED>");
|
|
assert_eq!(redacted["nested"]["password"], "<REDACTED>");
|
|
}
|
|
|
|
#[test]
|
|
fn test_redact_connection_string() {
|
|
let input = "Connecting to postgres://user:pass@localhost/db";
|
|
let redacted = redact_secrets(input);
|
|
assert!(redacted.contains("<REDACTED:connection_string>"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_retention_days_by_class() {
|
|
let policy = RetentionPolicy::default();
|
|
assert_eq!(policy.retention_days(DataClass::Credentials), 0);
|
|
assert_eq!(policy.retention_days(DataClass::OperationalTrace), 3);
|
|
assert_eq!(policy.retention_days(DataClass::Prompt), 7);
|
|
assert_eq!(policy.retention_days(DataClass::DomainEvent), 30);
|
|
assert_eq!(policy.retention_days(DataClass::AgentRun), 90);
|
|
}
|
|
}
|