mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(crash-reporting): stop destroying user crash notes (#15252)
This commit is contained in:
@@ -138,7 +138,7 @@ describe('registerCrashReportingHandlers', () => {
|
||||
_resetRendererErrorReportDedupeForTests()
|
||||
})
|
||||
|
||||
it('copies the latest pending diagnostic text to the clipboard', async () => {
|
||||
it('copies the requested captured report to the clipboard', async () => {
|
||||
const latest = report()
|
||||
registerCrashReportingHandlers({
|
||||
getById: vi.fn(async () => latest),
|
||||
@@ -151,6 +151,7 @@ describe('registerCrashReportingHandlers', () => {
|
||||
} as never)
|
||||
|
||||
const result = await handlers.get('crashReports:copyLatestDiagnostics')?.(null, {
|
||||
reportId: latest.id,
|
||||
notes: 'extra /Users/alice/project'
|
||||
})
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import {
|
||||
formatCrashReportText,
|
||||
isReactErrorBoundaryReport,
|
||||
MAX_USER_NOTES_LENGTH,
|
||||
type CrashReportDiagnosticBundle,
|
||||
type CrashReportRecord
|
||||
} from '../../../../shared/crash-reporting'
|
||||
@@ -277,13 +278,23 @@ export function CrashReportDialogSurface({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
rows={4}
|
||||
placeholder={getNotesPlaceholder(report)}
|
||||
className="min-h-24 w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
rows={4}
|
||||
// Keep the UI and formatter on the same input budget.
|
||||
maxLength={MAX_USER_NOTES_LENGTH}
|
||||
placeholder={getNotesPlaceholder(report)}
|
||||
className="min-h-24 w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="text-right text-[11px] tabular-nums text-muted-foreground"
|
||||
>
|
||||
{notes.length.toLocaleString()} / {MAX_USER_NOTES_LENGTH.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 rounded-md border border-border/70 bg-muted/20 p-3">
|
||||
<Checkbox
|
||||
id="crash-report-attach-diagnostics"
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import type {
|
||||
CrashReportBreadcrumb,
|
||||
CrashReportBreadcrumbInput,
|
||||
CrashReportDetailValue
|
||||
} from './crash-reporting'
|
||||
|
||||
const MAX_STRING_DETAIL_LENGTH = 240
|
||||
const MAX_STACK_DETAIL_LENGTH = 4_000
|
||||
const MAX_BREADCRUMB_NAME_LENGTH = 80
|
||||
const MAX_BREADCRUMBS = 30
|
||||
|
||||
const SECRET_PATTERNS = [
|
||||
/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g,
|
||||
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
|
||||
/\bglpat-[A-Za-z0-9_-]{20,}\b/g,
|
||||
/\bsk-[A-Za-z0-9_-]{20,}\b/g,
|
||||
/\bxox[baprs]-[A-Za-z0-9-]{10,}/g,
|
||||
/\bAKIA[0-9A-Z]{16}\b/g,
|
||||
/\bBearer\s+[A-Za-z0-9._~+/-]{20,}/gi,
|
||||
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g
|
||||
]
|
||||
const CREDENTIAL_URL_PATTERN = /\b[A-Za-z0-9._%+-]+:[A-Za-z0-9._%+-]+@(?=[^/\s]+)/g
|
||||
const SECRET_ASSIGNMENT_PATTERN =
|
||||
/\b(token|access[_-]?token|refresh[_-]?token|api[_-]?key|client[_-]?secret|secret|password|account[_-]?key)\s*[:=]\s*(?:"[^"\r\n]*"|'[^'\r\n]*'|[^&\s,;]+)/gi
|
||||
|
||||
// Quoted paths retain spaces; unquoted paths stop at whitespace to preserve prose.
|
||||
const PATH_PATTERNS = [
|
||||
/(["'`])\/[A-Za-z0-9._-]+\/(?:(?!\1)[^<>\n\r])+\1/g,
|
||||
/(["'`])[A-Za-z]:\\(?:(?!\1)[^<>\n\r])+\1/gi,
|
||||
/(["'`])\\\\[^\\\s"'`<>\n\r)]+\\(?:(?!\1)[^<>\n\r])+\1/gi,
|
||||
/(?<![A-Za-z0-9./])\/[A-Za-z0-9._-]+\/(?:\\ |[^\s"'`<>)]*)/g,
|
||||
/(?<![A-Za-z0-9])[A-Za-z]:\\(?:\\ |[^\s"'`<>\n\r)]*)/gi,
|
||||
/\\\\[^\\\s"'`<>\n\r)]+\\(?:\\ |[^\s"'`<>\n\r)]*)/gi,
|
||||
/%(?:USERPROFILE|APPDATA|LOCALAPPDATA|HOMEDRIVE|HOMEPATH)%[^\s"'`<>)]*/gi
|
||||
]
|
||||
|
||||
export function sanitizeCrashReportString(
|
||||
value: string,
|
||||
maxLength = MAX_STRING_DETAIL_LENGTH
|
||||
): string {
|
||||
let sanitized = value
|
||||
for (const pattern of PATH_PATTERNS) {
|
||||
sanitized = sanitized.replace(pattern, '[redacted-path]')
|
||||
}
|
||||
sanitized = sanitized.replace(CREDENTIAL_URL_PATTERN, '[redacted-credential]@')
|
||||
sanitized = sanitized.replace(SECRET_ASSIGNMENT_PATTERN, (_match, key: string) => {
|
||||
return `${key}=[redacted]`
|
||||
})
|
||||
for (const pattern of SECRET_PATTERNS) {
|
||||
sanitized = sanitized.replace(pattern, '[redacted-secret]')
|
||||
}
|
||||
return sanitized.length > maxLength ? `${sanitized.slice(0, maxLength)}...` : sanitized
|
||||
}
|
||||
|
||||
export function sanitizeCrashReportDetails(
|
||||
details: Record<string, unknown>
|
||||
): Record<string, CrashReportDetailValue> {
|
||||
const sanitized: Record<string, CrashReportDetailValue> = {}
|
||||
for (const [key, value] of Object.entries(details)) {
|
||||
if (typeof value === 'string') {
|
||||
const normalizedKey = key.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
||||
if (/(?:^|_)path$/i.test(normalizedKey)) {
|
||||
sanitized[key] = '[redacted-path]'
|
||||
} else {
|
||||
const maxLength =
|
||||
/(?:^|_)(?:stack|component_stack|error_stack|minidump_check_message)$/i.test(
|
||||
normalizedKey
|
||||
)
|
||||
? MAX_STACK_DETAIL_LENGTH
|
||||
: MAX_STRING_DETAIL_LENGTH
|
||||
sanitized[key] = sanitizeCrashReportString(value, maxLength)
|
||||
}
|
||||
} else if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
sanitized[key] = value
|
||||
} else if (typeof value === 'boolean' || value === null) {
|
||||
sanitized[key] = value
|
||||
}
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
export function sanitizeCrashReportBreadcrumbs(
|
||||
breadcrumbs: CrashReportBreadcrumbInput[] | undefined
|
||||
): CrashReportBreadcrumb[] | undefined {
|
||||
if (!breadcrumbs || breadcrumbs.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const sanitized = breadcrumbs
|
||||
.slice(-MAX_BREADCRUMBS)
|
||||
.map((breadcrumb): CrashReportBreadcrumb | null => {
|
||||
if (!breadcrumb.name.trim() || !breadcrumb.createdAt.trim()) {
|
||||
return null
|
||||
}
|
||||
const data = breadcrumb.data ? sanitizeCrashReportDetails(breadcrumb.data) : {}
|
||||
const origin = breadcrumb.origin
|
||||
? sanitizeCrashReportString(breadcrumb.origin).slice(0, 80)
|
||||
: ''
|
||||
return {
|
||||
createdAt: sanitizeCrashReportString(breadcrumb.createdAt),
|
||||
name: sanitizeCrashReportString(breadcrumb.name).slice(0, MAX_BREADCRUMB_NAME_LENGTH),
|
||||
...(Object.keys(data).length > 0 ? { data } : {}),
|
||||
...(origin ? { origin } : {})
|
||||
}
|
||||
})
|
||||
.filter((breadcrumb): breadcrumb is CrashReportBreadcrumb => breadcrumb !== null)
|
||||
|
||||
return sanitized.length > 0 ? sanitized : undefined
|
||||
}
|
||||
@@ -3,19 +3,57 @@ import {
|
||||
formatCrashReportText,
|
||||
formatUncapturedCrashReportText,
|
||||
isCrashReportReason,
|
||||
MAX_USER_NOTES_LENGTH,
|
||||
sanitizeCrashReportBreadcrumbs,
|
||||
sanitizeCrashReportDetails,
|
||||
sanitizeCrashReportString,
|
||||
type CrashReportRecord
|
||||
} from './crash-reporting'
|
||||
|
||||
function notesReport(overrides: Partial<CrashReportRecord> = {}): CrashReportRecord {
|
||||
return {
|
||||
id: 'crash-notes',
|
||||
createdAt: '2026-08-16T01:00:00.000Z',
|
||||
status: 'pending',
|
||||
source: 'renderer',
|
||||
processType: 'renderer',
|
||||
reason: 'crashed',
|
||||
exitCode: 5,
|
||||
appVersion: '1.4.184',
|
||||
platform: 'win32',
|
||||
osRelease: '10.0.26200',
|
||||
arch: 'x64',
|
||||
electronVersion: '41.0.0',
|
||||
chromeVersion: '141.0.0',
|
||||
details: {},
|
||||
breadcrumbs: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/** Notes are emitted indented inside the fence; mirror that when asserting. */
|
||||
function indentNote(note: string): string {
|
||||
return note
|
||||
.split('\n')
|
||||
.map((line) => ` ${line}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
describe('crash-reporting shared helpers', () => {
|
||||
it('redacts paths and common secret-shaped strings', () => {
|
||||
const text =
|
||||
'file /Users/alice/My Project/.env /tmp/build log C:\\Users\\bob\\My Project token=abc123 ghp_abcdefghijklmnopqrstuvwxyz'
|
||||
'file "/Users/alice/My Project/.env" /tmp/build log "C:\\Users\\bob\\My Project" token=abc123 ghp_abcdefghijklmnopqrstuvwxyz'
|
||||
|
||||
expect(sanitizeCrashReportString(text)).toBe(
|
||||
'file [redacted-path] [redacted-path] [redacted-path] token=[redacted] [redacted-secret]'
|
||||
'file [redacted-path] [redacted-path] log [redacted-path] token=[redacted] [redacted-secret]'
|
||||
)
|
||||
})
|
||||
|
||||
it('redacts credential URLs and secret assignments without hiding their labels', () => {
|
||||
const value = 'https://alice:hunter2@example.com client_secret: "secret with spaces"'
|
||||
|
||||
expect(sanitizeCrashReportString(value)).toBe(
|
||||
'https://[redacted-credential]@example.com client_secret=[redacted]'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -35,6 +73,7 @@ describe('crash-reporting shared helpers', () => {
|
||||
crashed: true,
|
||||
missing: null,
|
||||
error_stack: longStack,
|
||||
minidumpPath: '/Users/alice/Library/Application Support/Orca/reports/abc.dmp',
|
||||
nested: { nope: true },
|
||||
infinite: Number.POSITIVE_INFINITY
|
||||
})
|
||||
@@ -43,7 +82,8 @@ describe('crash-reporting shared helpers', () => {
|
||||
code: 9,
|
||||
crashed: true,
|
||||
missing: null,
|
||||
error_stack: expect.stringContaining('[redacted-path]')
|
||||
error_stack: expect.stringContaining('[redacted-path]'),
|
||||
minidumpPath: '[redacted-path]'
|
||||
})
|
||||
expect(
|
||||
String(sanitizeCrashReportDetails({ error_stack: longStack }).error_stack).length
|
||||
@@ -75,6 +115,7 @@ describe('crash-reporting shared helpers', () => {
|
||||
Array.from({ length: 32 }, (_, index) => ({
|
||||
createdAt: `2026-05-16T01:${String(index).padStart(2, '0')}:00.000Z`,
|
||||
name: `event_${index}`,
|
||||
origin: 'renderer:42',
|
||||
data: {
|
||||
path: '/Users/alice/project',
|
||||
ok: true,
|
||||
@@ -85,9 +126,12 @@ describe('crash-reporting shared helpers', () => {
|
||||
|
||||
expect(breadcrumbs).toHaveLength(30)
|
||||
expect(breadcrumbs?.[0].name).toBe('event_2')
|
||||
expect(breadcrumbs?.[0].data).toEqual({
|
||||
path: '[redacted-path]',
|
||||
ok: true
|
||||
expect(breadcrumbs?.[0]).toMatchObject({
|
||||
origin: 'renderer:42',
|
||||
data: {
|
||||
path: '[redacted-path]',
|
||||
ok: true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -281,4 +325,178 @@ describe('crash-reporting shared helpers', () => {
|
||||
expect(text).toContain('Status: not uploaded')
|
||||
expect(text).toContain('[redacted-path]')
|
||||
})
|
||||
|
||||
it('keeps a user note longer than the 240-char detail cap intact', () => {
|
||||
// A real 1.4.184 note was cut mid-word by the telemetry detail budget.
|
||||
const note =
|
||||
`My phone is connected. ${'The Claude terminal never came back. '.repeat(20)}`.trim()
|
||||
|
||||
const text = formatCrashReportText(notesReport(), note)
|
||||
|
||||
expect(note.length).toBeGreaterThan(240)
|
||||
expect(text).toContain(`--- begin user notes ---\n${indentNote(note)}\n--- end user notes ---`)
|
||||
expect(text).not.toContain('...')
|
||||
})
|
||||
|
||||
it('still redacts paths and secrets far past the old 240-char cap', () => {
|
||||
const note = [
|
||||
'a'.repeat(1_000),
|
||||
'it broke at /Users/alice/secret-project',
|
||||
'my token was ghp_abcdefghijklmnopqrstuvwxyz',
|
||||
'b'.repeat(1_000)
|
||||
].join('\n')
|
||||
|
||||
const text = formatCrashReportText(notesReport(), note)
|
||||
|
||||
expect(text).toContain('it broke at [redacted-path]')
|
||||
expect(text).toContain('my token was [redacted-secret]')
|
||||
expect(text).not.toContain('alice')
|
||||
expect(text).not.toContain('ghp_abcdefghijklmnopqrstuvwxyz')
|
||||
})
|
||||
|
||||
it('bounds an oversized user note to the advertised limit', () => {
|
||||
const text = formatCrashReportText(notesReport(), 'z'.repeat(40_000))
|
||||
const expected = `${'z'.repeat(MAX_USER_NOTES_LENGTH - 3)}...`
|
||||
|
||||
expect(text).toContain(expected)
|
||||
expect(text).not.toContain('z'.repeat(MAX_USER_NOTES_LENGTH - 2))
|
||||
})
|
||||
|
||||
it('keeps user notes when the report is truncated to the endpoint cap', () => {
|
||||
// Tail truncation must remove reproducible machine data before user notes.
|
||||
const text = formatCrashReportText(
|
||||
notesReport({
|
||||
details: Object.fromEntries(
|
||||
Array.from({ length: 400 }, (_, index) => [`detail_${index}`, 'x'.repeat(240)])
|
||||
)
|
||||
}),
|
||||
'the sidebar went blank'
|
||||
)
|
||||
|
||||
expect(text.length).toBeLessThanOrEqual(64_000)
|
||||
expect(text).toContain('[Crash report truncated to fit feedback endpoint limits.]')
|
||||
expect(text).toContain('--- begin user notes ---\n the sidebar went blank')
|
||||
})
|
||||
|
||||
it('redacts path tokens without deleting surrounding prose', () => {
|
||||
const note = [
|
||||
'On 8/16/2026 the app froze right after I opened a worktree.',
|
||||
'Steps: open View/Layout then Window/Zoom and it crashes on run 3/4.',
|
||||
'The log is at /opt/orca/logs/app.log and the repo is /Users/alice/x but this survives.'
|
||||
].join(' ')
|
||||
|
||||
const text = formatCrashReportText(notesReport(), note)
|
||||
|
||||
expect(text).toContain('On 8/16/2026 the app froze')
|
||||
expect(text).toContain('open View/Layout then Window/Zoom and it crashes on run 3/4.')
|
||||
expect(text).toContain(
|
||||
'The log is at [redacted-path] and the repo is [redacted-path] but this survives.'
|
||||
)
|
||||
expect(text).not.toContain('/opt/orca/logs/app.log')
|
||||
expect(text).not.toContain('alice')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['POSIX', '/home/alice/orca/app.log then recovered.'],
|
||||
['Windows', 'C:\\Users\\alice\\Orca\\app.log then recovered.'],
|
||||
['UNC', '\\\\server\\share\\Orca\\app.log then recovered.']
|
||||
])('stops unquoted %s paths at prose boundaries', (_platform, value) => {
|
||||
expect(sanitizeCrashReportString(value)).toBe('[redacted-path] then recovered.')
|
||||
})
|
||||
|
||||
it('redacts the secret shapes a full-page notes box can now hold', () => {
|
||||
const note = [
|
||||
'pat github_pat_11AAAAAAA0abcdefghijklmnopqrstuvwxyz012345',
|
||||
// Assembling the fixture avoids GitHub push-protection false positives.
|
||||
`slack ${['xoxb', '0'.repeat(11), 'fixture', 'not-a-real-token'].join('-')}`,
|
||||
`gitlab ${['glpat', 'a'.repeat(24)].join('-')}`,
|
||||
'aws AKIAIOSFODNN7EXAMPLE',
|
||||
'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.body.sig',
|
||||
'authorization: bearer eyJhbGciOiJIUzI1NiJ9.lowercase.signature',
|
||||
'client_secret: "secret with spaces"',
|
||||
'-----BEGIN OPENSSH PRIVATE KEY-----',
|
||||
'b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAAB',
|
||||
'-----END OPENSSH PRIVATE KEY-----',
|
||||
'log at %USERPROFILE%\\Documents\\payroll.xlsx'
|
||||
].join('\n')
|
||||
|
||||
const text = formatCrashReportText(notesReport(), note)
|
||||
|
||||
expect(text).not.toContain('github_pat_11AAAAAAA0')
|
||||
expect(text).not.toContain('not-a-real-token')
|
||||
expect(text).not.toContain('glpat-')
|
||||
expect(text).not.toContain('AKIAIOSFODNN7EXAMPLE')
|
||||
expect(text).not.toContain('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')
|
||||
expect(text).not.toContain('eyJhbGciOiJIUzI1NiJ9.lowercase.signature')
|
||||
expect(text).not.toContain('secret with spaces')
|
||||
expect(text).toContain('client_secret=[redacted]')
|
||||
expect(text).not.toContain('b3BlbnNzaC1rZXktdjEA')
|
||||
expect(text).not.toContain('payroll.xlsx')
|
||||
})
|
||||
|
||||
it('redacts an incomplete private-key paste', () => {
|
||||
const text = formatCrashReportText(
|
||||
notesReport(),
|
||||
'-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA'
|
||||
)
|
||||
|
||||
expect(text).not.toContain('b3BlbnNzaC1rZXktdjEAAAAA')
|
||||
expect(text).toContain('[redacted-secret]')
|
||||
})
|
||||
|
||||
it('bounds sanitizer work on a padded paste instead of freezing the dialog', () => {
|
||||
// The raw-input clamp prevents path regexes from scanning an unbounded paste.
|
||||
const note = `/Users/a${' '.repeat(200_000)}end`
|
||||
const startedAt = Date.now()
|
||||
|
||||
const text = formatCrashReportText(notesReport(), note)
|
||||
|
||||
expect(Date.now() - startedAt).toBeLessThan(1_000)
|
||||
expect(text.length).toBeLessThan(64_000)
|
||||
})
|
||||
|
||||
it('clamps raw notes before trimming', () => {
|
||||
const text = formatCrashReportText(
|
||||
notesReport(),
|
||||
`${' '.repeat(20_000)}content beyond the raw-input limit`
|
||||
)
|
||||
|
||||
expect(text).not.toContain('content beyond the raw-input limit')
|
||||
expect(text).not.toContain('User notes:')
|
||||
})
|
||||
|
||||
it('places Help-menu notes before machine-generated fields', () => {
|
||||
const text = formatUncapturedCrashReportText(
|
||||
{
|
||||
createdAt: '2026-05-16T01:00:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
platform: 'darwin',
|
||||
osRelease: '25.0.0',
|
||||
arch: 'arm64',
|
||||
electronVersion: '41.0.0',
|
||||
chromeVersion: '141.0.0'
|
||||
},
|
||||
'the terminal font looks wrong'
|
||||
)
|
||||
|
||||
expect(text.startsWith('[Crash Report]')).toBe(true)
|
||||
expect(text).toContain('- captured_crash_report: false')
|
||||
expect(text).toContain('--- begin user notes ---\n the terminal font looks wrong')
|
||||
expect(text.indexOf('--- begin user notes ---')).toBeLessThan(text.indexOf('Details:'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('user note section fencing', () => {
|
||||
it('stops a note from forging a machine-generated section', () => {
|
||||
const text = formatCrashReportText(
|
||||
notesReport({ details: { captured_crash_report: true } }),
|
||||
'here is what I saw\n\nDetails:\n- captured_crash_report: false'
|
||||
)
|
||||
// Only the generated Details heading may remain line-parser-visible.
|
||||
expect(text.match(/^Details:$/gm)).toHaveLength(1)
|
||||
expect(text).not.toMatch(/^- captured_crash_report: false$/m)
|
||||
expect(text).toContain(' Details:')
|
||||
expect(text).toContain(' - captured_crash_report: false')
|
||||
expect(text).toMatch(/^- captured_crash_report: true$/m)
|
||||
})
|
||||
})
|
||||
|
||||
+42
-100
@@ -6,6 +6,13 @@ import { appendMinidumpSignatureLines } from './crash-report-signature-lines'
|
||||
import { formatCrashReportExitCode } from './crash-report-exit-code'
|
||||
import { appendBoundaryAttributionLines } from './crash-report-attribution-lines'
|
||||
import type { CrashReportAttribution } from './react-update-depth-attribution'
|
||||
import { sanitizeCrashReportString } from './crash-report-redaction'
|
||||
|
||||
export {
|
||||
sanitizeCrashReportBreadcrumbs,
|
||||
sanitizeCrashReportDetails,
|
||||
sanitizeCrashReportString
|
||||
} from './crash-report-redaction'
|
||||
|
||||
export type { CrashReportDiagnosticBundle } from './crash-reporting-diagnostic-bundle'
|
||||
|
||||
@@ -124,27 +131,14 @@ export type CrashReportCopyDiagnosticsArgs = {
|
||||
submissionFailure?: CrashReportCopySubmissionFailure
|
||||
}
|
||||
|
||||
const MAX_STRING_DETAIL_LENGTH = 240
|
||||
const MAX_STACK_DETAIL_LENGTH = 4_000
|
||||
const MAX_BREADCRUMB_NAME_LENGTH = 80
|
||||
const MAX_BREADCRUMBS = 30
|
||||
// User notes need a prose budget, separate from 240-character telemetry values.
|
||||
export const MAX_USER_NOTES_LENGTH = 8_000
|
||||
// Bound redaction work while allowing redacted input to contract into the output budget.
|
||||
const MAX_USER_NOTES_SANITIZE_LENGTH = MAX_USER_NOTES_LENGTH * 2
|
||||
const USER_NOTES_TRUNCATION_SUFFIX = '...'
|
||||
const MAX_FORMATTED_REPORT_LENGTH = 64_000
|
||||
const FORMATTED_REPORT_TRUNCATION_SUFFIX =
|
||||
'\n\n[Crash report truncated to fit feedback endpoint limits.]'
|
||||
const SECRET_PATTERNS = [
|
||||
/\b(gh[pousr]_[A-Za-z0-9_]{20,})\b/g,
|
||||
/\b(sk-[A-Za-z0-9_-]{20,})\b/g,
|
||||
/\b([A-Za-z0-9._%+-]+:[A-Za-z0-9._%+-]+@)(?=[^/\s]+)/g,
|
||||
/\b(token|api[_-]?key|secret|password)=([^&\s]+)/gi
|
||||
]
|
||||
|
||||
const PATH_PATTERNS = [
|
||||
/\/(?:Users|home)\/(?:(?!\s+(?:\/|[A-Za-z]:\\|\\\\|gh[pousr]_|sk-|(?:token|api[_-]?key|secret|password)=))[^"'`<>\n\r)])+/gi,
|
||||
/\/(?:Applications|Library|System|Volumes|etc|media|mnt|opt|private|root|srv|tmp|usr|var)\/(?:(?!\s+(?:\/|[A-Za-z]:\\|\\\\|gh[pousr]_|sk-|(?:token|api[_-]?key|secret|password)=))[^"'`<>\n\r)])+/gi,
|
||||
/\/[A-Za-z0-9._ -]+\/(?:(?!\s+(?:\/|[A-Za-z]:\\|\\\\|gh[pousr]_|sk-|(?:token|api[_-]?key|secret|password)=))[^"'`<>\n\r)])+/gi,
|
||||
/[A-Za-z]:\\(?:(?!\s+(?:\/|[A-Za-z]:\\|\\\\|gh[pousr]_|sk-|(?:token|api[_-]?key|secret|password)=))[^"'`<>\n\r)])+/gi,
|
||||
/\\\\[^\\\s"'`<>\n\r)]+\\(?:(?!\s+(?:\/|[A-Za-z]:\\|\\\\|gh[pousr]_|sk-|(?:token|api[_-]?key|secret|password)=))[^"'`<>\n\r)])+/gi
|
||||
]
|
||||
export function isCrashReportReason(reason: string): boolean {
|
||||
return [
|
||||
'abnormal-exit',
|
||||
@@ -165,74 +159,34 @@ export function isReactErrorBoundaryReport(report: CrashReportRecord): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
export function sanitizeCrashReportString(
|
||||
value: string,
|
||||
maxLength = MAX_STRING_DETAIL_LENGTH
|
||||
): string {
|
||||
let sanitized = value
|
||||
for (const pattern of PATH_PATTERNS) {
|
||||
sanitized = sanitized.replace(pattern, '[redacted-path]')
|
||||
}
|
||||
for (const pattern of SECRET_PATTERNS) {
|
||||
sanitized = sanitized.replace(pattern, (match, key?: string) => {
|
||||
if (key && /^(token|api[_-]?key|secret|password)$/i.test(key)) {
|
||||
return `${key}=[redacted]`
|
||||
}
|
||||
return match.includes('@') ? '[redacted-credential]@' : '[redacted-secret]'
|
||||
})
|
||||
}
|
||||
return sanitized.length > maxLength ? `${sanitized.slice(0, maxLength)}...` : sanitized
|
||||
}
|
||||
// Notes lead so endpoint truncation removes reproducible machine data first.
|
||||
const USER_NOTES_BEGIN = '--- begin user notes ---'
|
||||
const USER_NOTES_END = '--- end user notes ---'
|
||||
|
||||
function maxDetailStringLengthForKey(key: string): number {
|
||||
const normalizedKey = key.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
||||
return /(?:^|_)(?:stack|component_stack|error_stack|minidump_check_message)$/i.test(normalizedKey)
|
||||
? MAX_STACK_DETAIL_LENGTH
|
||||
: MAX_STRING_DETAIL_LENGTH
|
||||
}
|
||||
|
||||
export function sanitizeCrashReportDetails(
|
||||
details: Record<string, unknown>
|
||||
): Record<string, CrashReportDetailValue> {
|
||||
const sanitized: Record<string, CrashReportDetailValue> = {}
|
||||
for (const [key, value] of Object.entries(details)) {
|
||||
if (typeof value === 'string') {
|
||||
sanitized[key] = sanitizeCrashReportString(value, maxDetailStringLengthForKey(key))
|
||||
} else if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
sanitized[key] = value
|
||||
} else if (typeof value === 'boolean' || value === null) {
|
||||
sanitized[key] = value
|
||||
}
|
||||
function appendUserNotesLines(lines: string[], notes: string | undefined): void {
|
||||
if (!notes) {
|
||||
return
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
export function sanitizeCrashReportBreadcrumbs(
|
||||
breadcrumbs: CrashReportBreadcrumbInput[] | undefined
|
||||
): CrashReportBreadcrumb[] | undefined {
|
||||
if (!breadcrumbs || breadcrumbs.length === 0) {
|
||||
return undefined
|
||||
const inputWasClamped = notes.length > MAX_USER_NOTES_SANITIZE_LENGTH
|
||||
const boundedNotes = notes.slice(0, MAX_USER_NOTES_SANITIZE_LENGTH).trim()
|
||||
if (!boundedNotes) {
|
||||
return
|
||||
}
|
||||
const sanitized = breadcrumbs
|
||||
.slice(-MAX_BREADCRUMBS)
|
||||
.map((breadcrumb): CrashReportBreadcrumb | null => {
|
||||
if (!breadcrumb.name.trim() || !breadcrumb.createdAt.trim()) {
|
||||
return null
|
||||
}
|
||||
const data = breadcrumb.data ? sanitizeCrashReportDetails(breadcrumb.data) : {}
|
||||
const origin = breadcrumb.origin
|
||||
? sanitizeCrashReportString(breadcrumb.origin).slice(0, 80)
|
||||
: ''
|
||||
return {
|
||||
createdAt: sanitizeCrashReportString(breadcrumb.createdAt),
|
||||
name: sanitizeCrashReportString(breadcrumb.name).slice(0, MAX_BREADCRUMB_NAME_LENGTH),
|
||||
...(Object.keys(data).length > 0 ? { data } : {}),
|
||||
...(origin ? { origin } : {})
|
||||
}
|
||||
})
|
||||
.filter((breadcrumb): breadcrumb is CrashReportBreadcrumb => breadcrumb !== null)
|
||||
|
||||
return sanitized.length > 0 ? sanitized : undefined
|
||||
const sanitized = sanitizeCrashReportString(boundedNotes, MAX_USER_NOTES_SANITIZE_LENGTH)
|
||||
const wasTruncated = inputWasClamped || sanitized.length > MAX_USER_NOTES_LENGTH
|
||||
const formattedNotes = wasTruncated
|
||||
? `${sanitized
|
||||
.slice(0, MAX_USER_NOTES_LENGTH - USER_NOTES_TRUNCATION_SUFFIX.length)
|
||||
.trimEnd()}${USER_NOTES_TRUNCATION_SUFFIX}`
|
||||
: sanitized
|
||||
// Indentation prevents user text from impersonating line-oriented machine sections.
|
||||
lines.push(
|
||||
'',
|
||||
'User notes:',
|
||||
USER_NOTES_BEGIN,
|
||||
...formattedNotes.split('\n').map((line) => ` ${line}`),
|
||||
USER_NOTES_END
|
||||
)
|
||||
}
|
||||
|
||||
export function formatCrashReportText(
|
||||
@@ -256,6 +210,7 @@ export function formatCrashReportText(
|
||||
`Chrome: ${report.chromeVersion}`
|
||||
]
|
||||
|
||||
appendUserNotesLines(lines, notes)
|
||||
appendMinidumpSignatureLines(lines, report.details)
|
||||
appendBoundaryAttributionLines(lines, report.details)
|
||||
appendDiagnosticBundleLines(lines, diagnosticBundle, sanitizeCrashReportString)
|
||||
@@ -280,11 +235,6 @@ export function formatCrashReportText(
|
||||
}
|
||||
}
|
||||
|
||||
const trimmedNotes = notes?.trim()
|
||||
if (trimmedNotes) {
|
||||
lines.push('', 'User notes:', sanitizeCrashReportString(trimmedNotes))
|
||||
}
|
||||
|
||||
return truncateFormattedCrashReport(lines.join('\n'))
|
||||
}
|
||||
|
||||
@@ -306,20 +256,13 @@ export function formatUncapturedCrashReportText(
|
||||
`App version: ${context.appVersion}`,
|
||||
`Platform: ${context.platform} ${context.osRelease} ${context.arch}`,
|
||||
`Electron: ${context.electronVersion}`,
|
||||
`Chrome: ${context.chromeVersion}`,
|
||||
'',
|
||||
'Details:',
|
||||
'- captured_crash_report: false',
|
||||
'- report_source: help_menu'
|
||||
`Chrome: ${context.chromeVersion}`
|
||||
]
|
||||
|
||||
appendUserNotesLines(lines, notes)
|
||||
lines.push('', 'Details:', '- captured_crash_report: false', '- report_source: help_menu')
|
||||
appendDiagnosticBundleLines(lines, diagnosticBundle, sanitizeCrashReportString)
|
||||
|
||||
const trimmedNotes = notes?.trim()
|
||||
if (trimmedNotes) {
|
||||
lines.push('', 'User notes:', sanitizeCrashReportString(trimmedNotes))
|
||||
}
|
||||
|
||||
return truncateFormattedCrashReport(lines.join('\n'))
|
||||
}
|
||||
|
||||
@@ -327,8 +270,7 @@ function truncateFormattedCrashReport(text: string): string {
|
||||
if (text.length <= MAX_FORMATTED_REPORT_LENGTH) {
|
||||
return text
|
||||
}
|
||||
// Why: the feedback endpoint accepts larger crash bodies and handles
|
||||
// Slack-specific attachments server-side. Keep local reports below that API cap.
|
||||
// The report cap leaves Slack-specific attachment handling to the feedback service.
|
||||
const budget = MAX_FORMATTED_REPORT_LENGTH - FORMATTED_REPORT_TRUNCATION_SUFFIX.length
|
||||
return `${text.slice(0, Math.max(0, budget)).trimEnd()}${FORMATTED_REPORT_TRUNCATION_SUFFIX}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user