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
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:
parent
a04d6fc8f5
commit
e9c576f606
@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 }),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user