Merge pull request #67 from lfl1337/fix/security-hardening-2026-04

fix: security hardening from external audit
This commit is contained in:
AgentSeal 2026-04-17 14:06:32 +02:00 committed by GitHub
commit 774d1917d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 372 additions and 85 deletions

View file

@ -78,8 +78,12 @@ const program = new Command()
.name('codeburn')
.description('See where your AI coding tokens go - by task, tool, model, and project')
.version(version)
.option('--verbose', 'print warnings to stderr on read failures and skipped files')
program.hook('preAction', async () => {
program.hook('preAction', async (thisCommand) => {
if (thisCommand.opts<{ verbose?: boolean }>().verbose) {
process.env['CODEBURN_VERBOSE'] = '1'
}
await loadCurrency()
})

View file

@ -1,8 +1,10 @@
import { readdir, readFile } from 'fs/promises'
import { readdir } from 'fs/promises'
import { existsSync } from 'fs'
import { join } from 'path'
import { homedir } from 'os'
import { readSessionFile } from './fs-utils.js'
const CHARS_PER_TOKEN = 4
const SYSTEM_BASE_TOKENS = 10400
const TOOL_TOKENS_OVERHEAD = 400
@ -23,9 +25,9 @@ function estimateTokens(text: string): number {
async function readConfigFile(path: string): Promise<Record<string, unknown> | null> {
if (!existsSync(path)) return null
try {
return JSON.parse(await readFile(path, 'utf-8'))
} catch { return null }
const raw = await readSessionFile(path)
if (raw === null) return null
try { return JSON.parse(raw) } catch { return null }
}
async function countMcpTools(projectPath?: string): Promise<number> {
@ -91,10 +93,9 @@ async function scanMemoryFiles(projectPath?: string): Promise<Array<{ name: stri
for (const { path, name } of paths) {
if (!existsSync(path)) continue
try {
const content = await readFile(path, 'utf-8')
files.push({ name, tokens: estimateTokens(content) })
} catch { continue }
const content = await readSessionFile(path)
if (content === null) continue
files.push({ name, tokens: estimateTokens(content) })
}
return files
@ -130,17 +131,19 @@ export async function estimateBudgetsByProject(projectPaths: Map<string, string>
}
export async function discoverProjectCwd(sessionDir: string): Promise<string | null> {
let files: string[]
try {
const files = (await readdir(sessionDir)).filter(f => f.endsWith('.jsonl'))
if (files.length === 0) return null
const content = await readFile(join(sessionDir, files[0]), 'utf-8')
for (const line of content.split('\n')) {
if (!line.trim()) continue
try {
const entry = JSON.parse(line)
if (entry.cwd && typeof entry.cwd === 'string') return entry.cwd
} catch { continue }
}
files = (await readdir(sessionDir)).filter(f => f.endsWith('.jsonl'))
} catch { return null }
if (files.length === 0) return null
const content = await readSessionFile(join(sessionDir, files[0]))
if (content === null) return null
for (const line of content.split('\n')) {
if (!line.trim()) continue
try {
const entry = JSON.parse(line)
if (entry.cwd && typeof entry.cwd === 'string') return entry.cwd
} catch { continue }
}
return null
}

93
src/fs-utils.ts Normal file
View file

@ -0,0 +1,93 @@
import { readFile, stat } from 'fs/promises'
import { readFileSync, statSync, createReadStream } from 'fs'
import { createInterface } from 'readline'
// Hard cap well below V8's 512 MB string limit even with split('\n') doubling.
// Stream threshold chosen as empirical breakeven between readFile+split peak
// memory and createReadStream+readline overhead for typical session files.
export const MAX_SESSION_FILE_BYTES = 128 * 1024 * 1024
export const STREAM_THRESHOLD_BYTES = 8 * 1024 * 1024
function verbose(): boolean {
return process.env.CODEBURN_VERBOSE === '1'
}
function warn(msg: string): void {
if (verbose()) process.stderr.write(`codeburn: ${msg}\n`)
}
async function readViaStream(filePath: string): Promise<string> {
const chunks: string[] = []
const stream = createReadStream(filePath, { encoding: 'utf-8' })
const rl = createInterface({ input: stream, crlfDelay: Infinity })
for await (const line of rl) chunks.push(line)
return chunks.join('\n')
}
export async function readSessionFile(filePath: string): Promise<string | null> {
let size: number
try {
size = (await stat(filePath)).size
} catch (err) {
warn(`stat failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
return null
}
if (size > MAX_SESSION_FILE_BYTES) {
warn(`skipped oversize file ${filePath} (${size} bytes > cap ${MAX_SESSION_FILE_BYTES})`)
return null
}
try {
if (size >= STREAM_THRESHOLD_BYTES) return await readViaStream(filePath)
return await readFile(filePath, 'utf-8')
} catch (err) {
warn(`read failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
return null
}
}
export function readSessionFileSync(filePath: string): string | null {
let size: number
try {
size = statSync(filePath).size
} catch (err) {
warn(`stat failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
return null
}
if (size > MAX_SESSION_FILE_BYTES) {
warn(`skipped oversize file ${filePath} (${size} bytes > cap ${MAX_SESSION_FILE_BYTES})`)
return null
}
try {
return readFileSync(filePath, 'utf-8')
} catch (err) {
warn(`read failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
return null
}
}
export async function* readSessionLines(filePath: string): AsyncGenerator<string> {
let size: number
try {
size = (await stat(filePath)).size
} catch (err) {
warn(`stat failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
return
}
if (size > MAX_SESSION_FILE_BYTES) {
warn(`skipped oversize file ${filePath} (${size} bytes > cap ${MAX_SESSION_FILE_BYTES})`)
return
}
const stream = createReadStream(filePath, { encoding: 'utf-8' })
const rl = createInterface({ input: stream, crlfDelay: Infinity })
try {
for await (const line of rl) yield line
} catch (err) {
warn(`stream read failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
}
}

View file

@ -10,6 +10,16 @@ const PLUGIN_REFRESH = '5m'
const SWIFTBAR_PREFERENCES_DOMAIN = 'com.ameba.SwiftBar'
const SWIFTBAR_PLUGIN_DIRECTORY_KEY = 'PluginDirectory'
const MENUBAR_LABEL_MAX_LENGTH = 14
const MENUBAR_LABEL_ALLOWLIST = /[^A-Za-z0-9 ._/-]/g
// SwiftBar/xbar parse `|` as the metadata separator and interpret ANSI escapes
// on some paths. Replace anything outside a conservative allowlist with `?`
// and truncate before padEnd.
function sanitizeMenubarLabel(name: string): string {
return name.replace(MENUBAR_LABEL_ALLOWLIST, '?').slice(0, MENUBAR_LABEL_MAX_LENGTH)
}
function getSwiftBarPluginDir(): string {
return join(homedir(), 'Library', 'Application Support', 'SwiftBar', 'plugins')
}
@ -138,7 +148,7 @@ export function renderMenubarFormat(
lines.push(`Activity - Today | size=12 color=#FF8C42`)
for (const cat of today.categories.slice(0, 8)) {
const bar = miniBar(cat.cost, maxCat)
const name = cat.name.padEnd(14)
const name = sanitizeMenubarLabel(cat.name).padEnd(14)
lines.push(`${bar} ${name} ${formatCost(cat.cost).padStart(8)} ${String(cat.turns).padStart(4)} turns | font=Menlo size=11`)
}
lines.push('---')
@ -148,7 +158,7 @@ export function renderMenubarFormat(
for (const model of today.models.slice(0, 5)) {
if (model.name === '<synthetic>') continue
const bar = miniBar(model.cost, maxModel)
const name = model.name.padEnd(14)
const name = sanitizeMenubarLabel(model.name).padEnd(14)
lines.push(`${bar} ${name} ${formatCost(model.cost).padStart(8)} ${String(model.calls).padStart(5)} calls | font=Menlo size=11`)
}
@ -164,7 +174,7 @@ export function renderMenubarFormat(
lines.push(`--Activity | size=12 color=#FF8C42`)
for (const cat of week.categories.slice(0, 8)) {
const bar = miniBar(cat.cost, weekMaxCat)
const name = cat.name.padEnd(14)
const name = sanitizeMenubarLabel(cat.name).padEnd(14)
lines.push(`--${bar} ${name} ${formatCost(cat.cost).padStart(8)} ${String(cat.turns).padStart(4)} turns | font=Menlo size=11`)
}
lines.push(`-----`)
@ -172,7 +182,7 @@ export function renderMenubarFormat(
for (const model of week.models.slice(0, 5)) {
if (model.name === '<synthetic>') continue
const bar = miniBar(model.cost, weekMaxModel)
const name = model.name.padEnd(14)
const name = sanitizeMenubarLabel(model.name).padEnd(14)
lines.push(`--${bar} ${name} ${formatCost(model.cost).padStart(8)} ${String(model.calls).padStart(5)} calls | font=Menlo size=11`)
}
@ -182,7 +192,7 @@ export function renderMenubarFormat(
lines.push(`--Activity | size=12 color=#FF8C42`)
for (const cat of thirtyDays.categories.slice(0, 8)) {
const bar = miniBar(cat.cost, tdMaxCat)
const name = cat.name.padEnd(14)
const name = sanitizeMenubarLabel(cat.name).padEnd(14)
lines.push(`--${bar} ${name} ${formatCost(cat.cost).padStart(8)} ${String(cat.turns).padStart(4)} turns | font=Menlo size=11`)
}
lines.push(`-----`)
@ -190,7 +200,7 @@ export function renderMenubarFormat(
for (const model of thirtyDays.models.slice(0, 5)) {
if (model.name === '<synthetic>') continue
const bar = miniBar(model.cost, tdMaxModel)
const name = model.name.padEnd(14)
const name = sanitizeMenubarLabel(model.name).padEnd(14)
lines.push(`--${bar} ${name} ${formatCost(model.cost).padStart(8)} ${String(model.calls).padStart(5)} calls | font=Menlo size=11`)
}
@ -200,7 +210,7 @@ export function renderMenubarFormat(
lines.push(`--Activity | size=12 color=#FF8C42`)
for (const cat of month.categories.slice(0, 8)) {
const bar = miniBar(cat.cost, monthMaxCat)
const name = cat.name.padEnd(14)
const name = sanitizeMenubarLabel(cat.name).padEnd(14)
lines.push(`--${bar} ${name} ${formatCost(cat.cost).padStart(8)} ${String(cat.turns).padStart(4)} turns | font=Menlo size=11`)
}
lines.push(`-----`)
@ -208,7 +218,7 @@ export function renderMenubarFormat(
for (const model of month.models.slice(0, 5)) {
if (model.name === '<synthetic>') continue
const bar = miniBar(model.cost, monthMaxModel)
const name = model.name.padEnd(14)
const name = sanitizeMenubarLabel(model.name).padEnd(14)
lines.push(`--${bar} ${name} ${formatCost(model.cost).padStart(8)} ${String(model.calls).padStart(5)} calls | font=Menlo size=11`)
}

View file

@ -1,9 +1,10 @@
import chalk from 'chalk'
import { readdir, readFile, stat } from 'fs/promises'
import { existsSync, readFileSync, statSync } from 'fs'
import { readdir, stat } from 'fs/promises'
import { existsSync, statSync } from 'fs'
import { basename, join } from 'path'
import { homedir } from 'os'
import { readSessionFile, readSessionFileSync } from './fs-utils.js'
import { discoverAllSessions } from './providers/index.js'
import type { DateRange, ProjectSummary } from './types.js'
import { formatCost } from './currency.js'
@ -227,10 +228,8 @@ export async function scanJsonlFile(
dateRange: DateRange | undefined,
recentCutoffMs = Date.now() - RECENT_WINDOW_MS,
): Promise<ScanFileResult> {
let content: string
try {
content = await readFile(filePath, 'utf-8')
} catch { return { calls: [], cwds: [], apiCalls: [], userMessages: [] } }
const content = await readSessionFile(filePath)
if (content === null) return { calls: [], cwds: [], apiCalls: [], userMessages: [] }
const calls: ToolCall[] = []
const cwds: string[] = []
@ -328,7 +327,9 @@ async function scanSessions(dateRange?: DateRange): Promise<ScanData> {
// ============================================================================
function readJsonFile(path: string): Record<string, unknown> | null {
try { return JSON.parse(readFileSync(path, 'utf-8')) } catch { return null }
const raw = readSessionFileSync(path)
if (raw === null) return null
try { return JSON.parse(raw) } catch { return null }
}
function shortHomePath(absPath: string): string {
@ -571,8 +572,8 @@ export function detectMissingClaudeignore(projectCwds: Set<string>): WasteFindin
function expandImports(filePath: string, seen: Set<string>, depth: number): { totalLines: number; importedFiles: number } {
if (depth > MAX_IMPORT_DEPTH || seen.has(filePath)) return { totalLines: 0, importedFiles: 0 }
seen.add(filePath)
let content: string
try { content = readFileSync(filePath, 'utf-8') } catch { return { totalLines: 0, importedFiles: 0 } }
const content = readSessionFileSync(filePath)
if (content === null) return { totalLines: 0, importedFiles: 0 }
let totalLines = content.split('\n').length
let importedFiles = 0
@ -865,11 +866,10 @@ function readShellProfileLimit(): number | null {
for (const profile of SHELL_PROFILES) {
const path = join(homedir(), profile)
if (!existsSync(path)) continue
try {
const content = readFileSync(path, 'utf-8')
const match = content.match(/^\s*export\s+BASH_MAX_OUTPUT_LENGTH\s*=\s*['"]?(\d+)['"]?/m)
if (match) return parseInt(match[1], 10)
} catch { continue }
const content = readSessionFileSync(path)
if (content === null) continue
const match = content.match(/^\s*export\s+BASH_MAX_OUTPUT_LENGTH\s*=\s*['"]?(\d+)['"]?/m)
if (match) return parseInt(match[1], 10)
}
return null
}

View file

@ -1,5 +1,6 @@
import { readdir, readFile } from 'fs/promises'
import { readdir } from 'fs/promises'
import { basename, join } from 'path'
import { readSessionFile } from './fs-utils.js'
import { calculateCost, getShortModelName } from './models.js'
import { discoverAllSessions, getProvider } from './providers/index.js'
import type { ParsedProviderCall } from './providers/types.js'
@ -168,10 +169,10 @@ function buildSessionSummary(
project: string,
turns: ClassifiedTurn[],
): SessionSummary {
const modelBreakdown: SessionSummary['modelBreakdown'] = {}
const toolBreakdown: SessionSummary['toolBreakdown'] = {}
const mcpBreakdown: SessionSummary['mcpBreakdown'] = {}
const bashBreakdown: SessionSummary['bashBreakdown'] = {}
const modelBreakdown: SessionSummary['modelBreakdown'] = Object.create(null)
const toolBreakdown: SessionSummary['toolBreakdown'] = Object.create(null)
const mcpBreakdown: SessionSummary['mcpBreakdown'] = Object.create(null)
const bashBreakdown: SessionSummary['bashBreakdown'] = Object.create(null)
const categoryBreakdown: SessionSummary['categoryBreakdown'] = {} as SessionSummary['categoryBreakdown']
let totalCost = 0
@ -265,12 +266,8 @@ async function parseSessionFile(
seenMsgIds: Set<string>,
dateRange?: DateRange,
): Promise<SessionSummary | null> {
let content: string
try {
content = await readFile(filePath, 'utf-8')
} catch {
return null
}
const content = await readSessionFile(filePath)
if (content === null) return null
const lines = content.split('\n').filter(l => l.trim())
const entries: JournalEntry[] = []

View file

@ -1,7 +1,8 @@
import { readdir, readFile, stat } from 'fs/promises'
import { readdir, stat } from 'fs/promises'
import { basename, join } from 'path'
import { homedir } from 'os'
import { readSessionFile } from '../fs-utils.js'
import { calculateCost } from '../models.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
@ -65,10 +66,11 @@ function sanitizeProject(cwd: string): string {
}
async function readFirstLine(filePath: string): Promise<CodexEntry | null> {
const content = await readSessionFile(filePath)
if (content === null) return null
const line = content.split('\n')[0]
if (!line?.trim()) return null
try {
const content = await readFile(filePath, 'utf-8')
const line = content.split('\n')[0]
if (!line?.trim()) return null
return JSON.parse(line) as CodexEntry
} catch {
return null
@ -139,13 +141,8 @@ function resolveModel(info: CodexEntry['payload'], sessionModel?: string): strin
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
let content: string
try {
content = await readFile(source.path, 'utf-8')
} catch {
return
}
const content = await readSessionFile(source.path)
if (content === null) return
const lines = content.split('\n').filter(l => l.trim())
let sessionModel: string | undefined
let sessionId = ''

View file

@ -1,7 +1,8 @@
import { readdir, readFile, stat } from 'fs/promises'
import { readdir, stat } from 'fs/promises'
import { basename, dirname, join } from 'path'
import { homedir } from 'os'
import { readSessionFile } from '../fs-utils.js'
import { calculateCost } from '../models.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
@ -85,13 +86,8 @@ function parseCwd(yaml: string): string | null {
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
let content: string
try {
content = await readFile(source.path, 'utf-8')
} catch {
return
}
const content = await readSessionFile(source.path)
if (content === null) return
const sessionId = basename(dirname(source.path))
const lines = content.split('\n').filter(l => l.trim())
let currentModel = ''
@ -177,11 +173,11 @@ async function discoverSessionsInDir(sessionStateDir: string): Promise<SessionSo
if (!s?.isFile()) continue
let project = sessionId
try {
const yaml = await readFile(join(sessionStateDir, sessionId, 'workspace.yaml'), 'utf-8')
const yaml = await readSessionFile(join(sessionStateDir, sessionId, 'workspace.yaml'))
if (yaml !== null) {
const cwd = parseCwd(yaml)
if (cwd) project = basename(cwd)
} catch {}
}
sources.push({ path: eventsPath, project, provider: 'copilot' })
}

View file

@ -1,7 +1,8 @@
import { readdir, readFile, stat } from 'fs/promises'
import { readdir, stat } from 'fs/promises'
import { basename, join } from 'path'
import { homedir } from 'os'
import { readSessionFile } from '../fs-utils.js'
import { calculateCost } from '../models.js'
import { extractBashCommands } from '../bash-utils.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
@ -56,10 +57,11 @@ function getPiSessionsDir(override?: string): string {
}
async function readFirstEntry(filePath: string): Promise<PiEntry | null> {
const content = await readSessionFile(filePath)
if (content === null) return null
const line = content.split('\n')[0]
if (!line?.trim()) return null
try {
const content = await readFile(filePath, 'utf-8')
const line = content.split('\n')[0]
if (!line?.trim()) return null
return JSON.parse(line) as PiEntry
} catch {
return null
@ -108,13 +110,8 @@ async function discoverSessionsInDir(sessionsDir: string): Promise<SessionSource
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
let content: string
try {
content = await readFile(source.path, 'utf-8')
} catch {
return
}
const content = await readSessionFile(source.path)
if (content === null) return
const lines = content.split('\n').filter(l => l.trim())
let sessionId = basename(source.path, '.jsonl')
let pendingUserMessage = ''

View file

@ -0,0 +1 @@
{"type":"assistant","sessionId":"security-test","timestamp":"2026-04-16T00:00:00Z","message":{"id":"pwn-bash","type":"message","role":"assistant","model":"claude-opus-4-6","content":[{"type":"tool_use","id":"b1","name":"Bash","input":{"command":"/x/__proto__"}}],"usage":{"input_tokens":1,"output_tokens":1}}}

View file

@ -0,0 +1 @@
{"type":"assistant","sessionId":"security-test","timestamp":"2026-04-16T00:00:00Z","message":{"id":"pwn-model","type":"message","role":"assistant","model":"__proto__","content":[{"type":"text","text":"x"}],"usage":{"input_tokens":1,"output_tokens":1}}}

View file

@ -0,0 +1 @@
{"type":"assistant","sessionId":"security-test","timestamp":"2026-04-16T00:00:00Z","message":{"id":"pwn-tool","type":"message","role":"assistant","model":"claude-opus-4-6","content":[{"type":"tool_use","id":"t1","name":"__proto__","input":{}}],"usage":{"input_tokens":1,"output_tokens":1}}}

63
tests/fs-utils.test.ts Normal file
View file

@ -0,0 +1,63 @@
import { describe, it, expect, afterEach, vi } from 'vitest'
import { mkdtemp, writeFile, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import {
MAX_SESSION_FILE_BYTES,
STREAM_THRESHOLD_BYTES,
readSessionFile,
} from '../src/fs-utils.js'
describe('readSessionFile', () => {
const tmpDirs: string[] = []
afterEach(async () => {
delete process.env.CODEBURN_VERBOSE
while (tmpDirs.length > 0) {
const d = tmpDirs.pop()
if (d) await rm(d, { recursive: true, force: true })
}
})
async function tmpPath(content: string | Buffer): Promise<string> {
const base = await mkdtemp(join(tmpdir(), 'codeburn-fs-'))
tmpDirs.push(base)
const p = join(base, 'x.jsonl')
await writeFile(p, content)
return p
}
it('returns content for small files via readFile fast path', async () => {
const p = await tmpPath('hello\nworld\n')
expect(await readSessionFile(p)).toBe('hello\nworld\n')
})
it('returns content for files at the stream threshold via stream path', async () => {
const p = await tmpPath(Buffer.alloc(STREAM_THRESHOLD_BYTES, 'a'))
const got = await readSessionFile(p)
expect(got).not.toBeNull()
expect(got!.length).toBe(STREAM_THRESHOLD_BYTES)
})
it('returns null and skips files over the cap', async () => {
const p = await tmpPath(Buffer.alloc(MAX_SESSION_FILE_BYTES + 1, 'b'))
expect(await readSessionFile(p)).toBeNull()
})
it('emits stderr warning under CODEBURN_VERBOSE=1 for skipped file', async () => {
process.env.CODEBURN_VERBOSE = '1'
const p = await tmpPath(Buffer.alloc(MAX_SESSION_FILE_BYTES + 1, 'c'))
const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
await readSessionFile(p)
expect(spy).toHaveBeenCalled()
const msg = (spy.mock.calls[0][0] as string)
expect(msg).toContain('codeburn')
expect(msg).toContain('oversize')
spy.mockRestore()
})
it('returns null on stat failure without throwing', async () => {
expect(await readSessionFile('/nonexistent/path/x.jsonl')).toBeNull()
})
})

View file

@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest'
import { renderMenubarFormat, type PeriodData } from '../../src/menubar.js'
const ESC = '\u001b'
function period(name: string): PeriodData {
return {
label: 'x',
cost: 0.01,
calls: 1,
inputTokens: 1,
outputTokens: 1,
cacheReadTokens: 0,
cacheWriteTokens: 0,
categories: [{ name, cost: 0.01, turns: 1, editTurns: 0, oneShotTurns: 1 }],
models: [{ name, cost: 0.01, calls: 1 }],
}
}
function linesWithToken(output: string, token: string): string[] {
return output.split('\n').filter(l => l.includes(token))
}
describe('MEDIUM-2 menubar directive separator injection', () => {
it('strips pipe separators from model names', () => {
const p = period('foo | href=https://attacker.example/pwn')
const out = renderMenubarFormat(p, p, p, p)
for (const line of linesWithToken(out, 'foo')) {
expect(line.split('|').length).toBeLessThanOrEqual(2)
}
})
it('strips ANSI escapes from model names', () => {
const p = period(`foo${ESC}[31mMODEL${ESC}[0m`)
const out = renderMenubarFormat(p, p, p, p)
expect(out).not.toContain(ESC)
})
it('strips pipe separators from category names', () => {
const p = period('cat | color=red')
const out = renderMenubarFormat(p, p, p, p)
for (const line of linesWithToken(out, 'cat')) {
expect(line.split('|').length).toBeLessThanOrEqual(2)
}
})
})

View file

@ -0,0 +1,77 @@
import { describe, it, expect, afterEach, beforeEach } from 'vitest'
import { mkdtemp, mkdir, cp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { parseAllSessions } from '../../src/parser.js'
import type { DateRange } from '../../src/types.js'
// Fixtures carry timestamp 2026-04-16T00:00:00Z. The range below must stay
// wide enough to include that date; if the fixtures move, move FIXTURE_DAY too.
const FIXTURE_DAY = Date.UTC(2026, 3, 16) // month index 3 = April (Date.UTC is 0-indexed)
const RANGE_BEFORE_MS = FIXTURE_DAY - 24 * 60 * 60 * 1000
const RANGE_AFTER_MS = FIXTURE_DAY + 24 * 60 * 60 * 1000
const PROJECT_NAME = 'codeburn-poc-testing'
function makeRange(offsetMs: number): DateRange {
return {
start: new Date(RANGE_BEFORE_MS + offsetMs),
end: new Date(RANGE_AFTER_MS + offsetMs),
}
}
// Hermeticity note: the Claude provider also scans a fixed Desktop sessions
// dir independent of CLAUDE_CONFIG_DIR. The narrow dateRange above excludes
// any real sessions in practice, but these tests are not strictly isolated
// on a machine with April 2026 Claude Desktop activity. A stricter fix
// belongs in a follow-up to discoverSessions itself.
describe('HIGH-1 prototype pollution via unchecked bracket-assign', () => {
const tmpDirs: string[] = []
let originalConfigDir: string | undefined
beforeEach(() => {
originalConfigDir = process.env['CLAUDE_CONFIG_DIR']
})
afterEach(async () => {
delete (Object.prototype as Record<string, unknown>).calls
if (originalConfigDir === undefined) {
delete process.env['CLAUDE_CONFIG_DIR']
} else {
process.env['CLAUDE_CONFIG_DIR'] = originalConfigDir
}
while (tmpDirs.length > 0) {
const d = tmpDirs.pop()
if (d) await rm(d, { recursive: true, force: true })
}
})
async function setupPoc(fixture: string): Promise<string> {
const base = await mkdtemp(join(tmpdir(), 'codeburn-sec-'))
tmpDirs.push(base)
const projectDir = join(base, 'projects', PROJECT_NAME)
await mkdir(projectDir, { recursive: true })
await cp(join(__dirname, '..', 'fixtures', 'security', fixture), join(projectDir, 'pwn.jsonl'))
process.env['CLAUDE_CONFIG_DIR'] = base
return base
}
it('does not pollute Object.prototype when session contains tool_use name "__proto__"', async () => {
await setupPoc('proto-tool.jsonl')
await expect(parseAllSessions(makeRange(0), 'claude')).resolves.not.toThrow()
expect(({} as Record<string, unknown>).calls).toBeUndefined()
})
it('does not pollute Object.prototype when bash command basename is "__proto__"', async () => {
await setupPoc('proto-bash.jsonl')
await expect(parseAllSessions(makeRange(1), 'claude')).resolves.not.toThrow()
expect(({} as Record<string, unknown>).calls).toBeUndefined()
})
it('does not pollute Object.prototype when model name is "__proto__"', async () => {
await setupPoc('proto-model.jsonl')
await expect(parseAllSessions(makeRange(2), 'claude')).resolves.not.toThrow()
expect(({} as Record<string, unknown>).calls).toBeUndefined()
})
})