fix(security): block PII in chat attachments and typed messages #59

Merged
sarman merged 6 commits from fix/pii-detection-bypass into master 2026-06-01 01:20:26 +00:00
3 changed files with 50 additions and 6 deletions
Showing only changes of commit e9c576f606 - Show all commits

View File

@ -65,7 +65,9 @@ fn compress_text(text: &str) -> Result<Vec<u8>, 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());
}

View File

@ -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<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
// Track the last user message so we can save it as a resolution step when why level advances
const lastUserMsgRef = useRef<string>("");
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() {
<TriageProgress currentLevel={Math.min(currentWhyLevel, 5)} />
</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 && (
<div className="mx-6 mt-3 p-3 bg-destructive/10 border border-destructive/20 rounded-md text-sm text-destructive">
{error}

View File

@ -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<SessionState>((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 }),