feat: add sensitive data redaction to export (Phase C)
Server-side regex redaction masks IPs, emails, bearer/API tokens, and UNC paths in exported session content. Redaction runs post-generation and post-variable-resolution with fail-closed error handling. Frontend gets a "Mask Sensitive Data" toggle in the export preview modal with a summary of what was redacted. 24 unit tests passing, frontend build clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import apiClient from './client'
|
||||
import type { Session, SessionCreate, SessionUpdate, SessionExport, SaveAsTreeRequest, SaveAsTreeResponse, SessionComplete } from '@/types'
|
||||
import type { Session, SessionCreate, SessionUpdate, SessionExport, SaveAsTreeRequest, SaveAsTreeResponse, SessionComplete, RedactionSummary } from '@/types'
|
||||
|
||||
export interface SessionListParams {
|
||||
page?: number
|
||||
@@ -54,6 +54,28 @@ export const sessionsApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async exportWithMeta(
|
||||
id: string,
|
||||
options: SessionExport
|
||||
): Promise<{
|
||||
content: string
|
||||
redactionMode: 'none' | 'mask'
|
||||
redactionSummary: RedactionSummary | null
|
||||
}> {
|
||||
const response = await apiClient.post<string>(`/sessions/${id}/export`, options)
|
||||
const redactionMode = (response.headers['x-redaction-mode'] as 'none' | 'mask') || 'none'
|
||||
let redactionSummary: RedactionSummary | null = null
|
||||
const summaryHeader = response.headers['x-redaction-summary']
|
||||
if (summaryHeader) {
|
||||
try {
|
||||
redactionSummary = JSON.parse(summaryHeader)
|
||||
} catch {
|
||||
// Ignore malformed header
|
||||
}
|
||||
}
|
||||
return { content: response.data, redactionMode, redactionSummary }
|
||||
},
|
||||
|
||||
async updateScratchpad(id: string, content: string): Promise<Session> {
|
||||
const response = await apiClient.patch<Session>(`/sessions/${id}/scratchpad`, { scratchpad: content })
|
||||
return response.data
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
||||
import { Copy, Download, Check, RotateCcw } from 'lucide-react'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { RedactionSummary } from '@/types'
|
||||
|
||||
interface ExportPreviewModalProps {
|
||||
isOpen: boolean
|
||||
@@ -12,6 +13,9 @@ interface ExportPreviewModalProps {
|
||||
onDownload: (content: string) => void
|
||||
includeSummary?: boolean
|
||||
onToggleSummary?: (include: boolean) => void
|
||||
redactionEnabled?: boolean
|
||||
onToggleRedaction?: (enabled: boolean) => void
|
||||
redactionSummary?: RedactionSummary | null
|
||||
}
|
||||
|
||||
export function ExportPreviewModal({
|
||||
@@ -23,6 +27,9 @@ export function ExportPreviewModal({
|
||||
onDownload,
|
||||
includeSummary = false,
|
||||
onToggleSummary,
|
||||
redactionEnabled = false,
|
||||
onToggleRedaction,
|
||||
redactionSummary,
|
||||
}: ExportPreviewModalProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [editedContent, setEditedContent] = useState(content)
|
||||
@@ -71,17 +78,43 @@ export function ExportPreviewModal({
|
||||
<span className="ml-2 text-xs text-yellow-400">(edited)</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{onToggleSummary && (
|
||||
<label className="flex items-center gap-2 text-sm text-white/60 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeSummary}
|
||||
onChange={(e) => onToggleSummary(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-white/20 bg-black/50"
|
||||
/>
|
||||
Include Summary
|
||||
</label>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="flex items-center gap-3">
|
||||
{onToggleSummary && (
|
||||
<label className="flex items-center gap-2 text-sm text-white/60 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeSummary}
|
||||
onChange={(e) => onToggleSummary(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-white/20 bg-black/50"
|
||||
/>
|
||||
Include Summary
|
||||
</label>
|
||||
)}
|
||||
{onToggleRedaction && (
|
||||
<label className="flex items-center gap-2 text-sm text-white/60 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={redactionEnabled}
|
||||
onChange={(e) => onToggleRedaction(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-white/20 bg-black/50"
|
||||
/>
|
||||
Mask Sensitive Data
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{redactionEnabled && redactionSummary && redactionSummary.total > 0 && (
|
||||
<p className="text-xs text-blue-400">
|
||||
Masked: {[
|
||||
redactionSummary.ips > 0 && `${redactionSummary.ips} IP${redactionSummary.ips !== 1 ? 's' : ''}`,
|
||||
redactionSummary.emails > 0 && `${redactionSummary.emails} email${redactionSummary.emails !== 1 ? 's' : ''}`,
|
||||
redactionSummary.tokens > 0 && `${redactionSummary.tokens} token${redactionSummary.tokens !== 1 ? 's' : ''}`,
|
||||
redactionSummary.unc_paths > 0 && `${redactionSummary.unc_paths} UNC path${redactionSummary.unc_paths !== 1 ? 's' : ''}`,
|
||||
].filter(Boolean).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{redactionEnabled && redactionSummary && redactionSummary.total === 0 && (
|
||||
<p className="text-xs text-white/40">No sensitive data detected</p>
|
||||
)}
|
||||
{isModified && (
|
||||
<button
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ExportPreviewModal } from '@/components/session/ExportPreviewModal'
|
||||
import { SaveSessionAsTreeModal } from '@/components/session/SaveSessionAsTreeModal'
|
||||
import { StepRatingModal } from '@/components/session/StepRatingModal'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import type { Session, SessionExport, SaveAsTreeRequest, Step } from '@/types'
|
||||
import type { Session, SessionExport, SaveAsTreeRequest, Step, RedactionSummary } from '@/types'
|
||||
import { hasRatedSession, markSessionRated } from '@/lib/sessionRatings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
@@ -34,6 +34,8 @@ export function SessionDetailPage() {
|
||||
const [maxStepIndex, setMaxStepIndex] = useState<number | null>(null)
|
||||
const [detailLevel, setDetailLevel] = useState<'standard' | 'full'>('standard')
|
||||
const [includeSummary, setIncludeSummary] = useState(false)
|
||||
const [redactionMode, setRedactionMode] = useState<'none' | 'mask'>('none')
|
||||
const [redactionSummary, setRedactionSummary] = useState<RedactionSummary | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
@@ -91,17 +93,22 @@ export function SessionDetailPage() {
|
||||
return `session-${session.ticket_number || session.id}.${ext}`
|
||||
}
|
||||
|
||||
const buildExportOptions = (overrides?: Partial<SessionExport>): SessionExport => ({
|
||||
format: exportFormat,
|
||||
include_timestamps: true,
|
||||
include_tree_info: true,
|
||||
...(maxStepIndex !== null && { max_step_index: maxStepIndex }),
|
||||
detail_level: detailLevel,
|
||||
include_summary: includeSummary,
|
||||
redaction_mode: redactionMode,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const fetchExportContent = async () => {
|
||||
if (!session) return null
|
||||
const options: SessionExport = {
|
||||
format: exportFormat,
|
||||
include_timestamps: true,
|
||||
include_tree_info: true,
|
||||
...(maxStepIndex !== null && { max_step_index: maxStepIndex }),
|
||||
detail_level: detailLevel,
|
||||
include_summary: includeSummary,
|
||||
}
|
||||
return await sessionsApi.export(session.id, options)
|
||||
const result = await sessionsApi.exportWithMeta(session.id, buildExportOptions())
|
||||
setRedactionSummary(result.redactionSummary)
|
||||
return result.content
|
||||
}
|
||||
|
||||
const handlePreview = async () => {
|
||||
@@ -141,15 +148,7 @@ export function SessionDetailPage() {
|
||||
const handleCopyForTicket = async () => {
|
||||
if (!session) return
|
||||
try {
|
||||
const options: SessionExport = {
|
||||
format: 'psa',
|
||||
include_timestamps: true,
|
||||
include_tree_info: true,
|
||||
...(maxStepIndex !== null && { max_step_index: maxStepIndex }),
|
||||
detail_level: detailLevel,
|
||||
include_summary: includeSummary,
|
||||
}
|
||||
const content = await sessionsApi.export(session.id, options)
|
||||
const content = await sessionsApi.export(session.id, buildExportOptions({ format: 'psa' }))
|
||||
if (content) {
|
||||
await navigator.clipboard.writeText(content)
|
||||
setCopiedPsa(true)
|
||||
@@ -178,20 +177,11 @@ export function SessionDetailPage() {
|
||||
const handleToggleSummary = async (include: boolean) => {
|
||||
setIncludeSummary(include)
|
||||
if (!session) return
|
||||
const options: SessionExport = {
|
||||
format: exportFormat,
|
||||
include_timestamps: true,
|
||||
include_tree_info: true,
|
||||
...(maxStepIndex !== null && { max_step_index: maxStepIndex }),
|
||||
detail_level: detailLevel,
|
||||
include_summary: include,
|
||||
}
|
||||
try {
|
||||
const content = await sessionsApi.export(session.id, options)
|
||||
if (content) {
|
||||
setExportContent(content)
|
||||
toast.success(include ? 'Summary added' : 'Summary removed')
|
||||
}
|
||||
const result = await sessionsApi.exportWithMeta(session.id, buildExportOptions({ include_summary: include }))
|
||||
setExportContent(result.content)
|
||||
setRedactionSummary(result.redactionSummary)
|
||||
toast.success(include ? 'Summary added' : 'Summary removed')
|
||||
} catch (err) {
|
||||
console.error('Failed to re-fetch export:', err)
|
||||
toast.error('Failed to update export')
|
||||
@@ -199,6 +189,22 @@ export function SessionDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleRedaction = async (enabled: boolean) => {
|
||||
const mode = enabled ? 'mask' as const : 'none' as const
|
||||
setRedactionMode(mode)
|
||||
if (!session) return
|
||||
try {
|
||||
const result = await sessionsApi.exportWithMeta(session.id, buildExportOptions({ redaction_mode: mode }))
|
||||
setExportContent(result.content)
|
||||
setRedactionSummary(result.redactionSummary)
|
||||
toast.success(enabled ? 'Sensitive data masked' : 'Redaction removed')
|
||||
} catch (err) {
|
||||
console.error('Failed to re-fetch export:', err)
|
||||
toast.error('Failed to update export')
|
||||
setRedactionMode(enabled ? 'none' : 'mask')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveAsTree = async (data: SaveAsTreeRequest) => {
|
||||
if (!session) return
|
||||
setIsSavingTree(true)
|
||||
@@ -564,6 +570,9 @@ export function SessionDetailPage() {
|
||||
onDownload={handleDownload}
|
||||
includeSummary={includeSummary}
|
||||
onToggleSummary={handleToggleSummary}
|
||||
redactionEnabled={redactionMode === 'mask'}
|
||||
onToggleRedaction={handleToggleRedaction}
|
||||
redactionSummary={redactionSummary}
|
||||
/>
|
||||
|
||||
{/* Save as Tree Modal */}
|
||||
|
||||
@@ -85,6 +85,15 @@ export interface SessionExport {
|
||||
max_step_index?: number
|
||||
include_summary?: boolean
|
||||
detail_level?: 'standard' | 'full'
|
||||
redaction_mode?: 'none' | 'mask'
|
||||
}
|
||||
|
||||
export interface RedactionSummary {
|
||||
ips: number
|
||||
emails: number
|
||||
tokens: number
|
||||
unc_paths: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface SessionComplete {
|
||||
|
||||
Reference in New Issue
Block a user