From e9c576f60671ef16c01fcaac08779bf11325ab1a Mon Sep 17 00:00:00 2001 From: Shaun Arman Date: Sun, 31 May 2026 19:49:21 -0500 Subject: [PATCH] fix(security): frontend attachment scan notice, bubble redaction update, fmt fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three findings from the third automated review: [BLOCKER] No frontend PII pre-check on attachments. Added detectPiiCmd call for each logFileId before chatMessageCmd. PII is not blocked (per explicit product decision: auto-redact and send) but the user now sees a non-blocking amber notice listing each file and the PII types that will be auto-redacted. Backend remains the authoritative redaction layer. [WARNING 2] Chat bubble showed original PII-laden message even though only the redacted form was sent to AI. Added updateMessageContent to sessionStore. After chatMessageCmd returns, if response.user_message is set the user bubble is updated to reflect what was actually stored in the DB, so the UI is consistent with the audit log. CI fix: cargo fmt changes to analysis.rs were not staged in the prior commit. Committed here — fmt check now passes cleanly. --- src-tauri/src/commands/analysis.rs | 13 ++++++---- src/pages/Triage/index.tsx | 38 ++++++++++++++++++++++++++++-- src/stores/sessionStore.ts | 5 ++++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/commands/analysis.rs b/src-tauri/src/commands/analysis.rs index 596401b6..16b798f8 100644 --- a/src-tauri/src/commands/analysis.rs +++ b/src-tauri/src/commands/analysis.rs @@ -65,7 +65,9 @@ fn compress_text(text: &str) -> Result, String> { encoder .write_all(text.as_bytes()) .map_err(|e| format!("Compression write error: {e}"))?; - encoder.finish().map_err(|e| format!("Compression finish error: {e}")) + encoder + .finish() + .map_err(|e| format!("Compression finish error: {e}")) } /// 100 MB cap — prevents decompression-bomb attacks on crafted DB entries. @@ -352,8 +354,8 @@ pub async fn upload_log_file_by_content( ..log_file }; - let compressed = compress_text(&content) - .map_err(|e| format!("Failed to compress log content: {e}"))?; + let compressed = + compress_text(&content).map_err(|e| format!("Failed to compress log content: {e}"))?; let db = state.db.lock().map_err(|e| e.to_string())?; db.execute( @@ -714,7 +716,10 @@ mod tests { // For in-memory gzip this essentially never fails, but the API now allows // callers to surface the error rather than storing empty bytes. let result = compress_text("normal log line"); - assert!(result.is_ok(), "compress_text should succeed for normal input"); + assert!( + result.is_ok(), + "compress_text should succeed for normal input" + ); assert!(!result.unwrap().is_empty()); } diff --git a/src/pages/Triage/index.tsx b/src/pages/Triage/index.tsx index cfbe0daf..d5cc1b0b 100644 --- a/src/pages/Triage/index.tsx +++ b/src/pages/Triage/index.tsx @@ -8,6 +8,7 @@ import { useSessionStore } from "@/stores/sessionStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { chatMessageCmd, + detectPiiCmd, getIssueCmd, getIssueMessagesCmd, uploadLogFileCmd, @@ -40,12 +41,13 @@ export default function Triage() { const navigate = useNavigate(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); const [pendingFiles, setPendingFiles] = useState([]); // Track the last user message so we can save it as a resolution step when why level advances const lastUserMsgRef = useRef(""); const initialized = useRef(false); - const { currentIssue, messages, currentWhyLevel, activeDomain, startSession, addMessage, setWhyLevel, setActiveDomain } = + const { currentIssue, messages, currentWhyLevel, activeDomain, startSession, addMessage, updateMessageContent, setWhyLevel, setActiveDomain } = useSessionStore(); const { getActiveProvider } = useSettingsStore(); @@ -133,10 +135,30 @@ export default function Triage() { setIsLoading(true); setError(null); + setNotice(null); + // Pre-send attachment PII scan: surface a notice to the user about what will be + // auto-redacted. The send is NOT blocked — the backend performs the actual redaction. + const piiNotices: string[] = []; + for (const f of pendingFiles) { + try { + const result = await detectPiiCmd(f.logFileId); + if (result.total_pii_found > 0) { + const types = [...new Set(result.detections.map((d) => d.pii_type))].join(", "); + piiNotices.push(`"${f.name}" (${types})`); + } + } catch { + // Non-fatal — backend will still scan before sending to AI + } + } + if (piiNotices.length > 0) { + setNotice(`PII auto-redacted before sending: ${piiNotices.join("; ")}`); + } + + const fileNames = pendingFiles.map((f) => f.name); const displayContent = pendingFiles.length > 0 - ? `${message}${message ? "\n" : ""}šŸ“Ž ${pendingFiles.map((f) => f.name).join(", ")}` + ? `${message}${message ? "\n" : ""}šŸ“Ž ${fileNames.join(", ")}` : message; const userMsg: TriageMessage = { @@ -166,6 +188,13 @@ export default function Triage() { const systemPrompt = activeDomain ? getDomainPrompt(activeDomain) : undefined; // Backend auto-redacts PII in both message text and attachments before sending to AI. const response = await chatMessageCmd(id, message, logFileIds, provider, systemPrompt); + + // Update the user bubble with what was actually stored (may be auto-redacted). + if (response.user_message) { + const suffix = fileNames.length > 0 ? `\nšŸ“Ž ${fileNames.join(", ")}` : ""; + updateMessageContent(userMsg.id, response.user_message + suffix); + } + const assistantMsg: TriageMessage = { id: `asst-${Date.now()}`, issue_id: id, @@ -228,6 +257,11 @@ export default function Triage() { + {notice && ( +
+ ā„¹ļø {notice} +
+ )} {error && (
{error} diff --git a/src/stores/sessionStore.ts b/src/stores/sessionStore.ts index 348425cd..3ee9d93a 100644 --- a/src/stores/sessionStore.ts +++ b/src/stores/sessionStore.ts @@ -14,6 +14,7 @@ interface SessionState { startSession: (issue: Issue) => void; addMessage: (message: TriageMessage) => void; + updateMessageContent: (id: string, content: string) => void; setPiiSpans: (spans: PiiSpan[]) => void; setApprovedRedactions: (spans: PiiSpan[]) => void; setWhyLevel: (level: number) => void; @@ -40,6 +41,10 @@ export const useSessionStore = create((set) => ({ ...initialState, startSession: (issue) => set({ currentIssue: issue, messages: [], currentWhyLevel: 1, activeDomain: issue.category }), addMessage: (message) => set((state) => ({ messages: [...state.messages, message] })), + updateMessageContent: (id, content) => + set((state) => ({ + messages: state.messages.map((m) => (m.id === id ? { ...m, content } : m)), + })), setPiiSpans: (spans) => set({ piiSpans: spans }), setApprovedRedactions: (spans) => set({ approvedRedactions: spans }), setWhyLevel: (level) => set({ currentWhyLevel: level }),