fix(security): block PII in chat attachments and typed messages #59
@ -277,13 +277,15 @@ pub async fn chat_message(
|
||||
vec![]
|
||||
};
|
||||
|
||||
// 8 KB embed limit; detect + redact on full content so PII at the boundary is caught.
|
||||
const EMBED_LIMIT: usize = 8_000;
|
||||
|
||||
let mut msg = base;
|
||||
for (file_name, file_path) in &files {
|
||||
let content = std::fs::read_to_string(file_path).unwrap_or_default();
|
||||
let preview = &content[..content.len().min(8000)];
|
||||
let spans = crate::pii::PiiDetector::new().detect(preview);
|
||||
let body = if spans.is_empty() {
|
||||
preview.to_string()
|
||||
let spans = crate::pii::PiiDetector::new().detect(&content);
|
||||
let redacted = if spans.is_empty() {
|
||||
content
|
||||
} else {
|
||||
let types: std::collections::HashSet<&str> =
|
||||
spans.iter().map(|s| s.pii_type.as_str()).collect();
|
||||
@ -295,9 +297,22 @@ pub async fn chat_message(
|
||||
pii_count = spans.len(),
|
||||
"PII detected in chat attachment — auto-redacting before AI send"
|
||||
);
|
||||
crate::pii::apply_redactions(preview, &spans)
|
||||
crate::pii::apply_redactions(&content, &spans)
|
||||
};
|
||||
msg.push_str(&format!("\n\n--- Attached: {} ---\n{}", file_name, body));
|
||||
// Truncate after redaction so the cut never lands inside a PII span.
|
||||
let embed_end = if redacted.len() > EMBED_LIMIT {
|
||||
let mut e = EMBED_LIMIT;
|
||||
while !redacted.is_char_boundary(e) {
|
||||
e -= 1;
|
||||
}
|
||||
e
|
||||
} else {
|
||||
redacted.len()
|
||||
};
|
||||
msg.push_str(&format!(
|
||||
"\n\n--- Attached: {file_name} ---\n{}",
|
||||
&redacted[..embed_end]
|
||||
));
|
||||
}
|
||||
msg
|
||||
};
|
||||
|
||||
@ -450,10 +450,19 @@ pub async fn detect_pii(
|
||||
})
|
||||
}
|
||||
|
||||
/// Maximum text size accepted by scan_text_for_pii to prevent DoS on large payloads.
|
||||
const MAX_TEXT_SCAN_BYTES: usize = 32 * 1024; // 32 KB
|
||||
|
||||
/// Scan arbitrary text for PII without creating any database records.
|
||||
/// Used to warn users before sending typed chat messages to AI providers.
|
||||
/// Used by the backend before sending typed chat messages to AI providers.
|
||||
#[tauri::command]
|
||||
pub async fn scan_text_for_pii(text: String) -> Result<PiiDetectionResult, String> {
|
||||
if text.len() > MAX_TEXT_SCAN_BYTES {
|
||||
return Err(format!(
|
||||
"Text too large for inline PII scan ({} bytes; limit {MAX_TEXT_SCAN_BYTES})",
|
||||
text.len()
|
||||
));
|
||||
}
|
||||
let detector = PiiDetector::new();
|
||||
let spans = detector.detect(&text);
|
||||
let total_pii_found = spans.len();
|
||||
|
||||
@ -283,7 +283,7 @@ export const chatMessageCmd = (
|
||||
invoke<ChatResponse>("chat_message", {
|
||||
issueId,
|
||||
message,
|
||||
logFileIds: logFileIds.length > 0 ? logFileIds : null,
|
||||
logFileIds: logFileIds.length > 0 ? logFileIds : undefined,
|
||||
providerConfig,
|
||||
systemPrompt: systemPrompt ?? null,
|
||||
});
|
||||
|
||||
@ -8,7 +8,6 @@ import { useSessionStore } from "@/stores/sessionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import {
|
||||
chatMessageCmd,
|
||||
detectPiiCmd,
|
||||
getIssueCmd,
|
||||
getIssueMessagesCmd,
|
||||
uploadLogFileCmd,
|
||||
@ -41,7 +40,6 @@ 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>("");
|
||||
@ -135,25 +133,6 @@ 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 =
|
||||
@ -257,11 +236,6 @@ 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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user