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,
|
content,
|
||||||
model,
|
model,
|
||||||
usage,
|
usage,
|
||||||
|
user_message: None,
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -119,6 +119,7 @@ impl Provider for GeminiProvider {
|
|||||||
content,
|
content,
|
||||||
model: config.model.clone(),
|
model: config.model.clone(),
|
||||||
usage,
|
usage,
|
||||||
|
user_message: None,
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -84,6 +84,7 @@ impl Provider for MistralProvider {
|
|||||||
content,
|
content,
|
||||||
model: config.model.clone(),
|
model: config.model.clone(),
|
||||||
usage,
|
usage,
|
||||||
|
user_message: None,
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,6 +30,10 @@ pub struct ChatResponse {
|
|||||||
pub content: String,
|
pub content: String,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
pub usage: Option<TokenUsage>,
|
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")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub tool_calls: Option<Vec<ToolCall>>,
|
pub tool_calls: Option<Vec<ToolCall>>,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -100,6 +100,7 @@ impl Provider for OllamaProvider {
|
|||||||
content,
|
content,
|
||||||
model: config.model.clone(),
|
model: config.model.clone(),
|
||||||
usage,
|
usage,
|
||||||
|
user_message: None,
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -197,6 +197,7 @@ impl OpenAiProvider {
|
|||||||
content,
|
content,
|
||||||
model: config.model.clone(),
|
model: config.model.clone(),
|
||||||
usage,
|
usage,
|
||||||
|
user_message: None,
|
||||||
tool_calls,
|
tool_calls,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -397,6 +398,7 @@ impl OpenAiProvider {
|
|||||||
content,
|
content,
|
||||||
model: config.model.clone(),
|
model: config.model.clone(),
|
||||||
usage: None, // This custom REST contract doesn't provide token usage in response
|
usage: None, // This custom REST contract doesn't provide token usage in response
|
||||||
|
user_message: None,
|
||||||
tool_calls,
|
tool_calls,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -234,9 +234,29 @@ pub async fn chat_message(
|
|||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load attachment files from DB, scan for PII, and embed clean content into the message.
|
// Auto-redact PII in both the typed message and any file attachments.
|
||||||
// File content never passes through the frontend — the backend is the single source of truth.
|
// The backend is the sole authority for redaction; the frontend sends original content.
|
||||||
let full_message = {
|
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 files: Vec<(String, String)> = if let Some(ref ids) = log_file_ids {
|
||||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
let mut v = Vec::new();
|
let mut v = Vec::new();
|
||||||
@ -257,7 +277,7 @@ pub async fn chat_message(
|
|||||||
vec![]
|
vec![]
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut msg = message.clone();
|
let mut msg = base;
|
||||||
for (file_name, file_path) in &files {
|
for (file_name, file_path) in &files {
|
||||||
let content = std::fs::read_to_string(file_path).unwrap_or_default();
|
let content = std::fs::read_to_string(file_path).unwrap_or_default();
|
||||||
let preview = &content[..content.len().min(8000)];
|
let preview = &content[..content.len().min(8000)];
|
||||||
@ -409,9 +429,11 @@ pub async fn chat_message(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save both user message and response to DB
|
// Save both user message and response to DB
|
||||||
|
let stored_user_message;
|
||||||
{
|
{
|
||||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
let user_msg = AiMessage::new(conversation_id.clone(), "user".to_string(), full_message);
|
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(
|
let asst_msg = AiMessage::new(
|
||||||
conversation_id,
|
conversation_id,
|
||||||
"assistant".to_string(),
|
"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]
|
#[tauri::command]
|
||||||
|
|||||||
@ -34,6 +34,8 @@ export interface ChatResponse {
|
|||||||
content: string;
|
content: string;
|
||||||
model: string;
|
model: string;
|
||||||
usage?: TokenUsage;
|
usage?: TokenUsage;
|
||||||
|
/** What was stored in the DB — may be auto-redacted. Use this for display and history. */
|
||||||
|
user_message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AnalysisResult {
|
export interface AnalysisResult {
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import { useSessionStore } from "@/stores/sessionStore";
|
|||||||
import { useSettingsStore } from "@/stores/settingsStore";
|
import { useSettingsStore } from "@/stores/settingsStore";
|
||||||
import {
|
import {
|
||||||
chatMessageCmd,
|
chatMessageCmd,
|
||||||
scanTextForPiiCmd,
|
|
||||||
getIssueCmd,
|
getIssueCmd,
|
||||||
getIssueMessagesCmd,
|
getIssueMessagesCmd,
|
||||||
uploadLogFileCmd,
|
uploadLogFileCmd,
|
||||||
@ -135,29 +134,10 @@ export default function Triage() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
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 =
|
const displayContent =
|
||||||
pendingFiles.length > 0
|
pendingFiles.length > 0
|
||||||
? `${outMessage}${outMessage ? "\n" : ""}📎 ${pendingFiles.map((f) => f.name).join(", ")}`
|
? `${message}${message ? "\n" : ""}📎 ${pendingFiles.map((f) => f.name).join(", ")}`
|
||||||
: outMessage;
|
: message;
|
||||||
|
|
||||||
const userMsg: TriageMessage = {
|
const userMsg: TriageMessage = {
|
||||||
id: `user-${Date.now()}`,
|
id: `user-${Date.now()}`,
|
||||||
@ -167,7 +147,7 @@ export default function Triage() {
|
|||||||
why_level: currentWhyLevel,
|
why_level: currentWhyLevel,
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
};
|
};
|
||||||
lastUserMsgRef.current = outMessage;
|
lastUserMsgRef.current = message;
|
||||||
addMessage(userMsg);
|
addMessage(userMsg);
|
||||||
const logFileIds = pendingFiles.map((f) => f.logFileId);
|
const logFileIds = pendingFiles.map((f) => f.logFileId);
|
||||||
setPendingFiles([]);
|
setPendingFiles([]);
|
||||||
@ -184,7 +164,8 @@ export default function Triage() {
|
|||||||
|
|
||||||
// Use the active domain for the system prompt
|
// Use the active domain for the system prompt
|
||||||
const systemPrompt = activeDomain ? getDomainPrompt(activeDomain) : undefined;
|
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 = {
|
const assistantMsg: TriageMessage = {
|
||||||
id: `asst-${Date.now()}`,
|
id: `asst-${Date.now()}`,
|
||||||
issue_id: id,
|
issue_id: id,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user