import { exists, readTextFile } from "@tauri-apps/plugin-fs"; import type { SessionMetadata } from "./api"; type FindingSeverity = "high" | "medium"; export interface PrivacyScanFinding { rule: string; severity: FindingSeverity; path: string; snippet: string; } export interface PrivacyScanResult { scannedPaths: string[]; findings: PrivacyScanFinding[]; highSeverityCount: number; mediumSeverityCount: number; } type ScanRule = { name: string; severity: FindingSeverity; pattern: RegExp; }; const TEXT_SCAN_LIMIT_CHARS = 250_000; const MAX_FINDINGS = 12; const SCAN_RULES: ScanRule[] = [ { name: "OpenAI API key", severity: "high", pattern: /\bsk-[A-Za-z0-9]{20,}\b/g, }, { name: "AWS access key", severity: "high", pattern: /\bAKIA[0-9A-Z]{16}\b/g, }, { name: "GitHub personal access token", severity: "high", pattern: /\bghp_[A-Za-z0-9]{30,}\b/g, }, { name: "GitHub fine-grained token", severity: "high", pattern: /\bgithub_pat_[A-Za-z0-9_]{60,}\b/g, }, { name: "Slack token", severity: "high", pattern: /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/g, }, { name: "Google API key", severity: "high", pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g, }, { name: "Private key block", severity: "high", pattern: /-----BEGIN (?:RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----/g, }, { name: "Token assignment", severity: "medium", pattern: /\b(?:api[_-]?key|secret|token|password)\b\s*[:=]\s*['"]?[A-Za-z0-9._\-+/]{10,}/gi, }, ]; function uniquePaths(paths: string[]): string[] { const next: string[] = []; const seen = new Set(); for (const path of paths) { if (!path) continue; if (seen.has(path)) continue; seen.add(path); next.push(path); } return next; } function buildCandidateTextPaths(metadata: SessionMetadata): string[] { const eventsPath = metadata.events_path; const sessionDir = (() => { const slash = Math.max(eventsPath.lastIndexOf("/"), eventsPath.lastIndexOf("\\")); return slash >= 0 ? eventsPath.slice(0, slash) : ""; })(); const candidates = [ eventsPath, eventsPath.replace("events.ndjson", "terminal.cast"), `${sessionDir}/steps.json`, `${sessionDir}/transcript.json`, `${sessionDir}/metadata.json`, ]; return uniquePaths(candidates); } function snippetForMatch(text: string, startIndex: number, endIndex: number): string { const sliceStart = Math.max(0, startIndex - 28); const sliceEnd = Math.min(text.length, endIndex + 28); const prefix = sliceStart > 0 ? "..." : ""; const suffix = sliceEnd < text.length ? "..." : ""; return `${prefix}${text.slice(sliceStart, sliceEnd).replace(/\s+/g, " ").trim()}${suffix}`; } function findInText(path: string, text: string): PrivacyScanFinding[] { const findings: PrivacyScanFinding[] = []; for (const rule of SCAN_RULES) { rule.pattern.lastIndex = 0; let match: RegExpExecArray | null; while ((match = rule.pattern.exec(text)) !== null) { findings.push({ rule: rule.name, severity: rule.severity, path, snippet: snippetForMatch(text, match.index, match.index + match[0].length), }); if (findings.length >= MAX_FINDINGS) { return findings; } } } return findings; } async function readPathSafely(path: string): Promise { try { if (!(await exists(path))) { return null; } const content = await readTextFile(path); if (content.length > TEXT_SCAN_LIMIT_CHARS) { return content.slice(0, TEXT_SCAN_LIMIT_CHARS); } return content; } catch { return null; } } export async function scanSessionForSensitiveData( metadata: SessionMetadata ): Promise { const scannedPaths: string[] = []; const findings: PrivacyScanFinding[] = []; const candidates = buildCandidateTextPaths(metadata); for (const path of candidates) { if (findings.length >= MAX_FINDINGS) break; const content = await readPathSafely(path); if (content == null) continue; scannedPaths.push(path); const pathFindings = findInText(path, content); for (const finding of pathFindings) { findings.push(finding); if (findings.length >= MAX_FINDINGS) break; } } const highSeverityCount = findings.filter((finding) => finding.severity === "high").length; const mediumSeverityCount = findings.length - highSeverityCount; return { scannedPaths, findings, highSeverityCount, mediumSeverityCount, }; }