fix(security): frontend attachment scan notice, bubble redaction update, fmt fix
Some checks failed
Test / rust-fmt-check (pull_request) Successful in 2m3s
Test / frontend-tests (pull_request) Successful in 1m56s
Test / frontend-typecheck (pull_request) Successful in 1m58s
Test / rust-clippy (pull_request) Failing after 3m0s
Test / rust-tests (pull_request) Successful in 4m22s
PR Review Automation / review (pull_request) Successful in 4m35s

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.
This commit is contained in:
Shaun Arman 2026-05-31 19:49:21 -05:00
parent a04d6fc8f5
commit e9c576f606
3 changed files with 50 additions and 6 deletions

View File

@ -65,7 +65,9 @@ fn compress_text(text: &str) -> Result<Vec<u8>, String> {
encoder encoder
.write_all(text.as_bytes()) .write_all(text.as_bytes())
.map_err(|e| format!("Compression write error: {e}"))?; .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. /// 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 ..log_file
}; };
let compressed = compress_text(&content) let compressed =
.map_err(|e| format!("Failed to compress log content: {e}"))?; compress_text(&content).map_err(|e| format!("Failed to compress log content: {e}"))?;
let db = state.db.lock().map_err(|e| e.to_string())?; let db = state.db.lock().map_err(|e| e.to_string())?;
db.execute( db.execute(
@ -714,7 +716,10 @@ mod tests {
// For in-memory gzip this essentially never fails, but the API now allows // For in-memory gzip this essentially never fails, but the API now allows
// callers to surface the error rather than storing empty bytes. // callers to surface the error rather than storing empty bytes.
let result = compress_text("normal log line"); 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()); assert!(!result.unwrap().is_empty());
} }

View File

@ -8,6 +8,7 @@ import { useSessionStore } from "@/stores/sessionStore";
import { useSettingsStore } from "@/stores/settingsStore"; import { useSettingsStore } from "@/stores/settingsStore";
import { import {
chatMessageCmd, chatMessageCmd,
detectPiiCmd,
getIssueCmd, getIssueCmd,
getIssueMessagesCmd, getIssueMessagesCmd,
uploadLogFileCmd, uploadLogFileCmd,
@ -40,12 +41,13 @@ export default function Triage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]); const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
// Track the last user message so we can save it as a resolution step when why level advances // Track the last user message so we can save it as a resolution step when why level advances
const lastUserMsgRef = useRef<string>(""); const lastUserMsgRef = useRef<string>("");
const initialized = useRef(false); const initialized = useRef(false);
const { currentIssue, messages, currentWhyLevel, activeDomain, startSession, addMessage, setWhyLevel, setActiveDomain } = const { currentIssue, messages, currentWhyLevel, activeDomain, startSession, addMessage, updateMessageContent, setWhyLevel, setActiveDomain } =
useSessionStore(); useSessionStore();
const { getActiveProvider } = useSettingsStore(); const { getActiveProvider } = useSettingsStore();
@ -133,10 +135,30 @@ export default function Triage() {
setIsLoading(true); setIsLoading(true);
setError(null); 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 = const displayContent =
pendingFiles.length > 0 pendingFiles.length > 0
? `${message}${message ? "\n" : ""}📎 ${pendingFiles.map((f) => f.name).join(", ")}` ? `${message}${message ? "\n" : ""}📎 ${fileNames.join(", ")}`
: message; : message;
const userMsg: TriageMessage = { const userMsg: TriageMessage = {
@ -166,6 +188,13 @@ export default function Triage() {
const systemPrompt = activeDomain ? getDomainPrompt(activeDomain) : undefined; const systemPrompt = activeDomain ? getDomainPrompt(activeDomain) : undefined;
// Backend auto-redacts PII in both message text and attachments before sending to AI. // Backend auto-redacts PII in both message text and attachments before sending to AI.
const response = await chatMessageCmd(id, message, logFileIds, provider, systemPrompt); 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 = { const assistantMsg: TriageMessage = {
id: `asst-${Date.now()}`, id: `asst-${Date.now()}`,
issue_id: id, issue_id: id,
@ -228,6 +257,11 @@ export default function Triage() {
<TriageProgress currentLevel={Math.min(currentWhyLevel, 5)} /> <TriageProgress currentLevel={Math.min(currentWhyLevel, 5)} />
</div> </div>
{notice && (
<div className="mx-6 mt-3 p-3 bg-amber-50 border border-amber-200 rounded-md text-sm text-amber-700">
{notice}
</div>
)}
{error && ( {error && (
<div className="mx-6 mt-3 p-3 bg-destructive/10 border border-destructive/20 rounded-md text-sm text-destructive"> <div className="mx-6 mt-3 p-3 bg-destructive/10 border border-destructive/20 rounded-md text-sm text-destructive">
{error} {error}

View File

@ -14,6 +14,7 @@ interface SessionState {
startSession: (issue: Issue) => void; startSession: (issue: Issue) => void;
addMessage: (message: TriageMessage) => void; addMessage: (message: TriageMessage) => void;
updateMessageContent: (id: string, content: string) => void;
setPiiSpans: (spans: PiiSpan[]) => void; setPiiSpans: (spans: PiiSpan[]) => void;
setApprovedRedactions: (spans: PiiSpan[]) => void; setApprovedRedactions: (spans: PiiSpan[]) => void;
setWhyLevel: (level: number) => void; setWhyLevel: (level: number) => void;
@ -40,6 +41,10 @@ export const useSessionStore = create<SessionState>((set) => ({
...initialState, ...initialState,
startSession: (issue) => set({ currentIssue: issue, messages: [], currentWhyLevel: 1, activeDomain: issue.category }), startSession: (issue) => set({ currentIssue: issue, messages: [], currentWhyLevel: 1, activeDomain: issue.category }),
addMessage: (message) => set((state) => ({ messages: [...state.messages, message] })), 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 }), setPiiSpans: (spans) => set({ piiSpans: spans }),
setApprovedRedactions: (spans) => set({ approvedRedactions: spans }), setApprovedRedactions: (spans) => set({ approvedRedactions: spans }),
setWhyLevel: (level) => set({ currentWhyLevel: level }), setWhyLevel: (level) => set({ currentWhyLevel: level }),