fix(security): enforce PII redaction before AI log transmission
analyze_logs() was reading the original log file from disk and sending its
full contents to external AI providers, completely bypassing the redaction
pipeline. The redacted flag in log_files and the .redacted file on disk were
written by apply_redactions() but never consulted on the read path.
Fix: query the redacted column alongside file_path. If the file has not been
redacted, return an error to the caller before any AI provider call is made.
When redacted, read from {path}.redacted instead of the original.
Adds redacted_path_for() helper and two unit tests covering the rejection
and happy-path cases.
This commit is contained in:
parent
0a25ca7692
commit
abab5c3153
@ -13,22 +13,27 @@ pub async fn analyze_logs(
|
|||||||
provider_config: ProviderConfig,
|
provider_config: ProviderConfig,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<AnalysisResult, String> {
|
) -> Result<AnalysisResult, String> {
|
||||||
// Load log file contents
|
// Load log file contents — only redacted files may be sent to an AI provider
|
||||||
let mut log_contents = String::new();
|
let mut log_contents = String::new();
|
||||||
{
|
{
|
||||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
for file_id in &log_file_ids {
|
for file_id in &log_file_ids {
|
||||||
let mut stmt = db
|
let mut stmt = db
|
||||||
.prepare("SELECT file_name, file_path FROM log_files WHERE id = ?1")
|
.prepare("SELECT file_name, file_path, redacted FROM log_files WHERE id = ?1")
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if let Ok((name, path)) = stmt.query_row([file_id], |row| {
|
if let Ok((name, path, redacted)) = stmt.query_row([file_id], |row| {
|
||||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
Ok((
|
||||||
|
row.get::<_, String>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, i32>(2)? != 0,
|
||||||
|
))
|
||||||
}) {
|
}) {
|
||||||
|
let redacted_path = redacted_path_for(&name, &path, redacted)?;
|
||||||
log_contents.push_str(&format!("--- {name} ---\n"));
|
log_contents.push_str(&format!("--- {name} ---\n"));
|
||||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
if let Ok(content) = std::fs::read_to_string(&redacted_path) {
|
||||||
log_contents.push_str(&content);
|
log_contents.push_str(&content);
|
||||||
} else {
|
} else {
|
||||||
log_contents.push_str("[Could not read file]\n");
|
log_contents.push_str("[Could not read redacted file]\n");
|
||||||
}
|
}
|
||||||
log_contents.push('\n');
|
log_contents.push('\n');
|
||||||
}
|
}
|
||||||
@ -103,6 +108,17 @@ pub async fn analyze_logs(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the path to the `.redacted` file, or an error if the file has not been redacted.
|
||||||
|
fn redacted_path_for(name: &str, path: &str, redacted: bool) -> Result<String, String> {
|
||||||
|
if !redacted {
|
||||||
|
return Err(format!(
|
||||||
|
"Log file '{name}' has not been scanned and redacted. \
|
||||||
|
Run PII detection and apply redactions before sending to AI."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(format!("{path}.redacted"))
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_section(text: &str, header: &str) -> Option<String> {
|
fn extract_section(text: &str, header: &str) -> Option<String> {
|
||||||
let start = text.find(header)?;
|
let start = text.find(header)?;
|
||||||
let after = &text[start + header.len()..];
|
let after = &text[start + header.len()..];
|
||||||
@ -384,6 +400,19 @@ mod tests {
|
|||||||
assert_eq!(list, vec!["Item one", "Item two"]);
|
assert_eq!(list, vec!["Item one", "Item two"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_redacted_path_rejects_unredacted_file() {
|
||||||
|
let err = redacted_path_for("app.log", "/data/app.log", false).unwrap_err();
|
||||||
|
assert!(err.contains("app.log"));
|
||||||
|
assert!(err.contains("redacted"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_redacted_path_returns_dotredacted_suffix() {
|
||||||
|
let path = redacted_path_for("app.log", "/data/app.log", true).unwrap();
|
||||||
|
assert_eq!(path, "/data/app.log.redacted");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_extract_list_missing_header() {
|
fn test_extract_list_missing_header() {
|
||||||
let text = "No findings here";
|
let text = "No findings here";
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user