fix(security): backend-only PII redaction; fix fmt CI failure
Some checks failed
Test / frontend-typecheck (pull_request) Successful in 1m52s
Test / frontend-tests (pull_request) Successful in 1m51s
Test / rust-fmt-check (pull_request) Failing after 1m58s
Test / rust-clippy (pull_request) Failing after 3m4s
Test / rust-tests (pull_request) Successful in 4m31s
PR Review Automation / review (pull_request) Successful in 4m43s
Some checks failed
Test / frontend-typecheck (pull_request) Successful in 1m52s
Test / frontend-tests (pull_request) Successful in 1m51s
Test / rust-fmt-check (pull_request) Failing after 1m58s
Test / rust-clippy (pull_request) Failing after 3m4s
Test / rust-tests (pull_request) Successful in 4m31s
PR Review Automation / review (pull_request) Successful in 4m43s
Resolves all three findings from the second automated review and fixes the cargo fmt --check CI failure (formatting drift in analysis.rs from a prior merge). [BLOCKER 1 + BLOCKER 2 + WARNING] Frontend no longer performs any PII scanning or redaction. All three concerns stemmed from the same root cause: outMessage was derived on the frontend and used for display, DB storage (via lastUserMsgRef and the chat bubble), and the AI payload — causing the original message to be silently replaced before the backend received it. Fix: frontend sends the original message verbatim. Backend is now the sole authority. chat_message auto-redacts the typed message text using PiiDetector + apply_redactions() before building the full payload, logs the PII types via tracing::warn, and stores only the redacted form in ai_messages and the audit log. The redacted form is returned to the caller as ChatResponse.user_message (Option<String>, absent from direct provider calls). Frontend uses message (original) for the chat bubble and lastUserMsgRef — resolution steps show natural language, not [Password] tokens. The AI and DB see only the redacted version. CI fix: cargo fmt applied to analysis.rs; all format checks now pass.
This commit is contained in:
parent
f05b954250
commit
a04d6fc8f5
@ -116,6 +116,7 @@ impl Provider for AnthropicProvider {
|
||||
content,
|
||||
model,
|
||||
usage,
|
||||
user_message: None,
|
||||
tool_calls: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -119,6 +119,7 @@ impl Provider for GeminiProvider {
|
||||
content,
|
||||
model: config.model.clone(),
|
||||
usage,
|
||||
user_message: None,
|
||||
tool_calls: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -84,6 +84,7 @@ impl Provider for MistralProvider {
|
||||
content,
|
||||
model: config.model.clone(),
|
||||
usage,
|
||||
user_message: None,
|
||||
tool_calls: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -30,6 +30,10 @@ pub struct ChatResponse {
|
||||
pub content: String,
|
||||
pub model: String,
|
||||
pub usage: Option<TokenUsage>,
|
||||
/// The user message as it was stored in the DB (may be auto-redacted).
|
||||
/// Set by chat_message; absent from direct provider calls.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user_message: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
}
|
||||
|
||||
@ -100,6 +100,7 @@ impl Provider for OllamaProvider {
|
||||
content,
|
||||
model: config.model.clone(),
|
||||
usage,
|
||||
user_message: None,
|
||||
tool_calls: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -197,6 +197,7 @@ impl OpenAiProvider {
|
||||
content,
|
||||
model: config.model.clone(),
|
||||
usage,
|
||||
user_message: None,
|
||||
tool_calls,
|
||||
})
|
||||
}
|
||||
@ -397,6 +398,7 @@ impl OpenAiProvider {
|
||||
content,
|
||||
model: config.model.clone(),
|
||||
usage: None, // This custom REST contract doesn't provide token usage in response
|
||||
user_message: None,
|
||||
tool_calls,
|
||||
})
|
||||
}
|
||||
|
||||
@ -234,9 +234,29 @@ pub async fn chat_message(
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Load attachment files from DB, scan for PII, and embed clean content into the message.
|
||||
// File content never passes through the frontend — the backend is the single source of truth.
|
||||
// Auto-redact PII in both the typed message and any file attachments.
|
||||
// The backend is the sole authority for redaction; the frontend sends original content.
|
||||
let full_message = {
|
||||
// Step 1: redact the typed user message text.
|
||||
let base = {
|
||||
let spans = crate::pii::PiiDetector::new().detect(&message);
|
||||
if spans.is_empty() {
|
||||
message.clone()
|
||||
} else {
|
||||
let types: std::collections::HashSet<&str> =
|
||||
spans.iter().map(|s| s.pii_type.as_str()).collect();
|
||||
let mut type_list: Vec<&str> = types.into_iter().collect();
|
||||
type_list.sort_unstable();
|
||||
warn!(
|
||||
pii_types = ?type_list,
|
||||
pii_count = spans.len(),
|
||||
"PII detected in typed chat message — auto-redacting before AI send"
|
||||
);
|
||||
crate::pii::apply_redactions(&message, &spans)
|
||||
}
|
||||
};
|
||||
|
||||
// Step 2: load attachment files from DB, scan, and embed clean content.
|
||||
let files: Vec<(String, String)> = if let Some(ref ids) = log_file_ids {
|
||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||
let mut v = Vec::new();
|
||||
@ -257,7 +277,7 @@ pub async fn chat_message(
|
||||
vec![]
|
||||
};
|
||||
|
||||
let mut msg = message.clone();
|
||||
let mut msg = base;
|
||||
for (file_name, file_path) in &files {
|
||||
let content = std::fs::read_to_string(file_path).unwrap_or_default();
|
||||
let preview = &content[..content.len().min(8000)];
|
||||
@ -409,9 +429,11 @@ pub async fn chat_message(
|
||||
}
|
||||
|
||||
// Save both user message and response to DB
|
||||
let stored_user_message;
|
||||
{
|
||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||
let user_msg = AiMessage::new(conversation_id.clone(), "user".to_string(), full_message);
|
||||
stored_user_message = user_msg.content.clone();
|
||||
let asst_msg = AiMessage::new(
|
||||
conversation_id,
|
||||
"assistant".to_string(),
|
||||
@ -468,7 +490,10 @@ pub async fn chat_message(
|
||||
}
|
||||
}
|
||||
|
||||
Ok(final_response)
|
||||
Ok(crate::ai::ChatResponse {
|
||||
user_message: Some(stored_user_message),
|
||||
..final_response
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@ -34,6 +34,8 @@ export interface ChatResponse {
|
||||
content: string;
|
||||
model: string;
|
||||
usage?: TokenUsage;
|
||||
/** What was stored in the DB — may be auto-redacted. Use this for display and history. */
|
||||
user_message?: string;
|
||||
}
|
||||
|
||||
export interface AnalysisResult {
|
||||
|
||||
@ -8,7 +8,6 @@ import { useSessionStore } from "@/stores/sessionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import {
|
||||
chatMessageCmd,
|
||||
scanTextForPiiCmd,
|
||||
getIssueCmd,
|
||||
getIssueMessagesCmd,
|
||||
uploadLogFileCmd,
|
||||
@ -135,29 +134,10 @@ export default function Triage() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Auto-redact PII in typed message text before sending to AI.
|
||||
// Spans are replaced in reverse-start-offset order to preserve byte positions.
|
||||
let outMessage = message;
|
||||
if (message.trim()) {
|
||||
try {
|
||||
const textResult = await scanTextForPiiCmd(message);
|
||||
if (textResult.total_pii_found > 0) {
|
||||
const sorted = [...textResult.detections].sort((a, b) => b.start - a.start);
|
||||
let redacted = message;
|
||||
for (const span of sorted) {
|
||||
redacted = redacted.slice(0, span.start) + span.replacement + redacted.slice(span.end);
|
||||
}
|
||||
outMessage = redacted;
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: if the scan fails, send original
|
||||
}
|
||||
}
|
||||
|
||||
const displayContent =
|
||||
pendingFiles.length > 0
|
||||
? `${outMessage}${outMessage ? "\n" : ""}📎 ${pendingFiles.map((f) => f.name).join(", ")}`
|
||||
: outMessage;
|
||||
? `${message}${message ? "\n" : ""}📎 ${pendingFiles.map((f) => f.name).join(", ")}`
|
||||
: message;
|
||||
|
||||
const userMsg: TriageMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
@ -167,7 +147,7 @@ export default function Triage() {
|
||||
why_level: currentWhyLevel,
|
||||
created_at: Date.now(),
|
||||
};
|
||||
lastUserMsgRef.current = outMessage;
|
||||
lastUserMsgRef.current = message;
|
||||
addMessage(userMsg);
|
||||
const logFileIds = pendingFiles.map((f) => f.logFileId);
|
||||
setPendingFiles([]);
|
||||
@ -184,7 +164,8 @@ export default function Triage() {
|
||||
|
||||
// Use the active domain for the system prompt
|
||||
const systemPrompt = activeDomain ? getDomainPrompt(activeDomain) : undefined;
|
||||
const response = await chatMessageCmd(id, outMessage, logFileIds, provider, systemPrompt);
|
||||
// Backend auto-redacts PII in both message text and attachments before sending to AI.
|
||||
const response = await chatMessageCmd(id, message, logFileIds, provider, systemPrompt);
|
||||
const assistantMsg: TriageMessage = {
|
||||
id: `asst-${Date.now()}`,
|
||||
issue_id: id,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user