fix(security): full-content PII scan, clippy, IPC null fix, scan size cap
All checks were successful
Test / rust-fmt-check (pull_request) Successful in 1m40s
Test / frontend-typecheck (pull_request) Successful in 1m48s
Test / frontend-tests (pull_request) Successful in 1m43s
Test / rust-clippy (pull_request) Successful in 3m17s
Test / rust-tests (pull_request) Successful in 4m33s
PR Review Automation / review (pull_request) Successful in 5m0s

Remove frontend detectPiiCmd pre-scan loop — backend is sole redaction
authority; bubble update via response.user_message covers user feedback.

Detect PII on full file content before truncating. Previous order
(truncate to 8000 bytes then scan) could miss PII straddling the
boundary. Now: read full content, scan, redact, then truncate to
EMBED_LIMIT (8000 bytes) at a valid UTF-8 char boundary.

logFileIds IPC: pass undefined (not null) for empty array so Tauri
serialises it correctly to Rust Option::None.

Add MAX_TEXT_SCAN_BYTES (32 KB) guard in scan_text_for_pii to prevent
unbounded regex evaluation on oversized payloads.

Fix clippy uninlined_format_args in ai.rs.
This commit is contained in:
Shaun Arman 2026-05-31 20:01:07 -05:00
parent e9c576f606
commit 631221dbf1
4 changed files with 32 additions and 34 deletions

View File

@ -277,13 +277,15 @@ pub async fn chat_message(
vec![] 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; let mut msg = base;
for (file_name, file_path) in &files { for (file_name, file_path) in &files {
let content = std::fs::read_to_string(file_path).unwrap_or_default(); 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(&content);
let spans = crate::pii::PiiDetector::new().detect(preview); let redacted = if spans.is_empty() {
let body = if spans.is_empty() { content
preview.to_string()
} else { } else {
let types: std::collections::HashSet<&str> = let types: std::collections::HashSet<&str> =
spans.iter().map(|s| s.pii_type.as_str()).collect(); spans.iter().map(|s| s.pii_type.as_str()).collect();
@ -295,9 +297,22 @@ pub async fn chat_message(
pii_count = spans.len(), pii_count = spans.len(),
"PII detected in chat attachment — auto-redacting before AI send" "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 msg
}; };

View File

@ -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. /// 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] #[tauri::command]
pub async fn scan_text_for_pii(text: String) -> Result<PiiDetectionResult, String> { 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 detector = PiiDetector::new();
let spans = detector.detect(&text); let spans = detector.detect(&text);
let total_pii_found = spans.len(); let total_pii_found = spans.len();

View File

@ -283,7 +283,7 @@ export const chatMessageCmd = (
invoke<ChatResponse>("chat_message", { invoke<ChatResponse>("chat_message", {
issueId, issueId,
message, message,
logFileIds: logFileIds.length > 0 ? logFileIds : null, logFileIds: logFileIds.length > 0 ? logFileIds : undefined,
providerConfig, providerConfig,
systemPrompt: systemPrompt ?? null, systemPrompt: systemPrompt ?? null,
}); });

View File

@ -8,7 +8,6 @@ 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,
@ -41,7 +40,6 @@ 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>("");
@ -135,25 +133,6 @@ 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 fileNames = pendingFiles.map((f) => f.name);
const displayContent = const displayContent =
@ -257,11 +236,6 @@ 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}