v0.33.8
This commit is contained in:
1 parent
6b937aaf85
commit
87c8393d86
79 files changed
+6727
-297
No files matched your search
@@ -33,12 +33,14 @@ fn redact_regex(pattern: &'static str) -> &'static regex::Regex {
|
||||
static AUTH: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static QUERY: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static JSON: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static ESCAPED_JSON: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static KV: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static PEM: OnceLock<regex::Regex> = OnceLock::new();
|
||||
match pattern {
|
||||
"auth" => AUTH.get_or_init(|| regex::Regex::new(r"(?i)(authorization\s*[:=]\s*)(?:bearer\s+)?[^\s,;]+" ).expect("redaction regex")),
|
||||
"query" => QUERY.get_or_init(|| regex::Regex::new(r"(?i)([?&](?:token|access_token|api_token|password|backup_password|secret|credential|credentials|key_secret|application_secret|consumer_key)=)[^&\s]+" ).expect("redaction regex")),
|
||||
"json" => JSON.get_or_init(|| regex::Regex::new(r#"(?i)(\"(?:authorization|proxy_authorization|api_token|access_token|application_secret|consumer_key|key_secret|backup_password|password|credential|credentials|secret|token|private_key|master_key)\"\s*:\s*\")[^\"]*(\")"#).expect("redaction regex")),
|
||||
"json" => JSON.get_or_init(|| regex::Regex::new(r#"(?i)("(?:authorization|proxy_authorization|api_token|access_token|application_secret|consumer_key|key_secret|backup_password|password|credential|credentials|secret|token|private_key|master_key)"\s*:\s*")(?:\\.|[^"\\])*(")"#).expect("redaction regex")),
|
||||
"escaped_json" => ESCAPED_JSON.get_or_init(|| regex::Regex::new(r#"(?i)(\\"(?:authorization|proxy_authorization|api_token|access_token|application_secret|consumer_key|key_secret|backup_password|password|credential|credentials|secret|token|private_key|master_key)\\"\s*:\s*\\")(?:[^"\\]|\\\\(?:\\\\|\\"|[^"\\]))*(\\")"#).expect("redaction regex")),
|
||||
"kv" => KV.get_or_init(|| regex::Regex::new(r#"(?i)((?:authorization|proxy[_-]?authorization|api[_-]?token|access[_-]?token|application[_-]?secret|consumer[_-]?key|key[_-]?secret|backup[_-]?password|password|credential|credentials|secret|token|private[_-]?key|master[_-]?key)\s*[=:]\s*)[^\s,;"']+"#).expect("redaction regex")),
|
||||
"pem" => PEM.get_or_init(|| regex::Regex::new(r"(?s)-----BEGIN [^-\r\n]*PRIVATE KEY-----.*?-----END [^-\r\n]*PRIVATE KEY-----" ).expect("redaction regex")),
|
||||
_ => unreachable!(),
|
||||
@@ -76,6 +78,12 @@ pub fn redact_text(input: &str) -> String {
|
||||
value = redact_regex("query")
|
||||
.replace_all(&value, "$1[REDACTED]")
|
||||
.into_owned();
|
||||
// Debug-formatted fields can contain a JSON string with escaped quote
|
||||
// delimiters. Redact that form before plain JSON, retaining quote escapes
|
||||
// and consuming escaped quotes inside a secret instead of leaking its tail.
|
||||
value = redact_regex("escaped_json")
|
||||
.replace_all(&value, "$1[REDACTED]$2")
|
||||
.into_owned();
|
||||
value = redact_regex("json")
|
||||
.replace_all(&value, "$1[REDACTED]$2")
|
||||
.into_owned();
|
||||
|
||||
+13
-6
@@ -136,14 +136,21 @@ impl Logs {
|
||||
self.settings.load().capture_country
|
||||
}
|
||||
pub fn push(&self, entry: LogEntry) {
|
||||
if let Ok(mut entries) = self.entries.try_lock() {
|
||||
if entries.len() == HISTORY_CAPACITY {
|
||||
entries.pop_front();
|
||||
}
|
||||
entries.push_back(entry.clone());
|
||||
// String cloning and eviction cleanup do not need the history mutex.
|
||||
let history_entry = entry.clone();
|
||||
let evicted = if let Ok(mut entries) = self.entries.try_lock() {
|
||||
let evicted = if entries.len() == HISTORY_CAPACITY {
|
||||
entries.pop_front()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
entries.push_back(history_entry);
|
||||
evicted
|
||||
} else {
|
||||
self.dropped.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
None
|
||||
};
|
||||
drop(evicted);
|
||||
let _ = self.live.send(entry.clone());
|
||||
self.push_sink(entry);
|
||||
}
|
||||
|
||||
@@ -209,6 +209,41 @@ mod tests {
|
||||
assert!(!text.contains("pem-data"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_redaction_preserves_json_and_debug_escaping_without_leaking_secret_suffixes() {
|
||||
for secret in ["pem-data", "left\"right", "left\\right", "ending\\", "left\\\"right", "first\nsecond"] {
|
||||
let plain = serde_json::to_string(&serde_json::json!({
|
||||
"private_key": secret, "visible": "keep"
|
||||
})).unwrap();
|
||||
let redacted = redact_text(&plain);
|
||||
let value: Value = serde_json::from_str(&redacted).unwrap();
|
||||
assert_eq!(value["private_key"], "[REDACTED]");
|
||||
assert_eq!(value["visible"], "keep");
|
||||
|
||||
// EventVisitor::record_debug uses this exact formatting operation.
|
||||
let debug = format!("{plain:?}");
|
||||
let debug_redacted = redact_text(&debug);
|
||||
let decoded: String = serde_json::from_str(&debug_redacted).unwrap();
|
||||
let value: Value = serde_json::from_str(&decoded).unwrap();
|
||||
assert_eq!(value["private_key"], "[REDACTED]");
|
||||
assert_eq!(value["visible"], "keep");
|
||||
assert_eq!(
|
||||
redact_text(&format!("before {debug} after")),
|
||||
format!("before {debug_redacted} after")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_redaction_removes_private_pem_and_preserves_public_diagnostics() {
|
||||
for label in ["PRIVATE KEY", "RSA PRIVATE KEY", "EC PRIVATE KEY", "ENCRYPTED PRIVATE KEY"] {
|
||||
let input = format!("before -----BEGIN {label}-----\nsynthetic-secret\n-----END {label}----- after");
|
||||
assert_eq!(redact_text(&input), "before [REDACTED PRIVATE KEY] after");
|
||||
}
|
||||
let public = "-----BEGIN CERTIFICATE-----\npublic-test-data\n-----END CERTIFICATE-----";
|
||||
assert_eq!(redact_text(public), public);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_json_is_one_record_and_reserved_fields_cannot_be_overwritten() {
|
||||
let event = ErrorEvent {
|
||||
@@ -291,4 +326,100 @@ mod tests {
|
||||
LIVE_WINDOW + 80
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_history_evicts_oldest_keeps_exact_fields_and_does_not_count_eviction_as_drop() {
|
||||
fn history_entry(id: usize) -> LogEntry {
|
||||
let mut item = entry();
|
||||
item.id = id as u64;
|
||||
item.time_ms += id as u64;
|
||||
item.path = format!("/history/{id}");
|
||||
item.authority = Some("app.example:443".into());
|
||||
item.matched_by = Some("host");
|
||||
item.upstream_error = Some("diagnostic with \"quotes\" and UTF-8: €".into());
|
||||
item
|
||||
}
|
||||
let logs = Logs::default();
|
||||
for id in 0..HISTORY_CAPACITY + 3 {
|
||||
logs.push(history_entry(id));
|
||||
}
|
||||
let retained = logs.recent_limit(HISTORY_CAPACITY + 1);
|
||||
assert_eq!(retained.len(), HISTORY_CAPACITY);
|
||||
assert_eq!(
|
||||
retained.iter().map(|item| item.id).collect::<Vec<_>>(),
|
||||
(3..HISTORY_CAPACITY + 3).rev().map(|id| id as u64).collect::<Vec<_>>()
|
||||
);
|
||||
for item in [&retained[0], &retained[HISTORY_CAPACITY / 2], retained.last().unwrap()] {
|
||||
assert_eq!(
|
||||
serde_json::to_value(item).unwrap(),
|
||||
serde_json::to_value(history_entry(item.id as usize)).unwrap()
|
||||
);
|
||||
}
|
||||
assert_eq!(logs.recent_limit(0).len(), 0);
|
||||
assert_eq!(logs.recent_limit(2)[0].id, (HISTORY_CAPACITY + 2) as u64);
|
||||
assert_eq!(logs.dropped.load(Ordering::Relaxed), 0);
|
||||
let stats = logs.stats();
|
||||
assert_eq!(stats.queue_capacity, SINK_QUEUE);
|
||||
assert_eq!(stats.queue_dropped, 0, "disabled sinks must not enqueue");
|
||||
assert_eq!(stats.sent, 0);
|
||||
assert_eq!(stats.errors, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn history_contention_counts_one_drop_but_preserves_live_and_file_delivery() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("access.jsonl");
|
||||
let mut settings = Settings::default();
|
||||
settings.file.enabled = true;
|
||||
settings.file.path = path.to_string_lossy().into_owned();
|
||||
let shutdown = CancellationToken::new();
|
||||
let logs = Logs::start(settings, shutdown.clone());
|
||||
let mut live = logs.subscribe();
|
||||
let mut sentinel = entry();
|
||||
sentinel.id = 6;
|
||||
let mut history = logs.entries.lock().unwrap();
|
||||
history.push_back(sentinel.clone());
|
||||
|
||||
let mut item = entry();
|
||||
item.path = "/contention/€\"quoted\"".into();
|
||||
item.authority = Some("app.example:443".into());
|
||||
item.matched_by = Some("host");
|
||||
let expected = serde_json::to_value(&item).unwrap();
|
||||
let producer = logs.clone();
|
||||
let (finished, completion) = std::sync::mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
producer.push(item);
|
||||
finished.send(()).unwrap();
|
||||
});
|
||||
// Release the guard before joining even on failure, so an accidental
|
||||
// blocking-lock regression fails this test instead of hanging the suite.
|
||||
let completed = completion.recv_timeout(Duration::from_secs(2));
|
||||
drop(history);
|
||||
worker.join().unwrap();
|
||||
completed.expect("history contention must not block live/sink delivery");
|
||||
|
||||
assert_eq!(logs.dropped.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(serde_json::to_value(logs.recent()).unwrap(), json!([sentinel]));
|
||||
assert_eq!(serde_json::to_value(live.try_recv().unwrap()).unwrap(), expected);
|
||||
assert!(matches!(live.try_recv(), Err(broadcast::error::TryRecvError::Empty)));
|
||||
|
||||
let persisted = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if let Ok(content) = tokio::fs::read_to_string(&path).await {
|
||||
if content.ends_with('\n') && logs.stats().sent == 1 {
|
||||
break content;
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}).await.expect("contended history entry must still reach the configured file sink");
|
||||
shutdown.cancel();
|
||||
assert_eq!(persisted.lines().count(), 1);
|
||||
assert_eq!(serde_json::from_str::<Value>(persisted.trim_end()).unwrap(), expected);
|
||||
let stats = logs.stats();
|
||||
assert_eq!(stats.queue_capacity, SINK_QUEUE);
|
||||
assert_eq!(stats.queue_dropped, 0);
|
||||
assert_eq!(stats.sent, 1);
|
||||
assert_eq!(stats.errors, 0);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user