mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-19 16:13:56 +00:00
Merge pull request #67 from lfl1337/fix/security-hardening-2026-04
fix: security hardening from external audit
This commit is contained in:
commit
774d1917d4
15 changed files with 372 additions and 85 deletions
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
93
src/fs-utils.ts
Normal 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'}`)
|
||||
}
|
||||
}
|
||||
|
|
@ -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`)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[] = []
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = ''
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = ''
|
||||
|
|
|
|||
1
tests/fixtures/security/proto-bash.jsonl
vendored
Normal file
1
tests/fixtures/security/proto-bash.jsonl
vendored
Normal 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}}}
|
||||
1
tests/fixtures/security/proto-model.jsonl
vendored
Normal file
1
tests/fixtures/security/proto-model.jsonl
vendored
Normal 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}}}
|
||||
1
tests/fixtures/security/proto-tool.jsonl
vendored
Normal file
1
tests/fixtures/security/proto-tool.jsonl
vendored
Normal 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
63
tests/fs-utils.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
47
tests/security/menubar-injection.test.ts
Normal file
47
tests/security/menubar-injection.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
77
tests/security/prototype-pollution.test.ts
Normal file
77
tests/security/prototype-pollution.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue