From 25d66fc7ab18799f4766ab467777fbd26747d86c Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 8 Oct 2026 06:21:59 +0000 Subject: [PATCH] fix(cso): give snapshot.json its own 16 MiB cap and actionable capacity errors /cso refused any repository with more than about 3,100 tracked files before an audit started: the snapshot manifest was pretty-printed and both its writer and every reader capped it at the shared 1 MiB private-state limit (#3068, manifest half of #2993). - snapshot.json is written compactly and capped by SNAPSHOT_MANIFEST_LIMIT (16 MiB), a constant local to the snapshot module. - readJson takes a per-artifact cap; readSnapshotManifest is the only reader of snapshot.json and passes the snapshot cap through recovery and the private-file size check. MAX_STATE_FILE and MAX_OUTPUT stay 1 MiB. - The manifest-cap and 64 MiB source-cap errors name the bound cap, the measured value, what counts toward it, the next step and #2993. A single file that would cross 64 MiB now hits the same message before it is read. - Older pretty-printed manifests still load (recheck, inspect). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/cso/cli.ts | 28 +++--- lib/cso/snapshot.ts | 42 +++++++-- lib/cso/state.ts | 11 +-- test/cso-file-ceiling.test.ts | 156 ++++++++++++++++++++++++++++++++++ 4 files changed, 211 insertions(+), 26 deletions(-) create mode 100644 test/cso-file-ceiling.test.ts diff --git a/lib/cso/cli.ts b/lib/cso/cli.ts index f27b1db8d..f3842a9b5 100644 --- a/lib/cso/cli.ts +++ b/lib/cso/cli.ts @@ -34,7 +34,7 @@ import { validateFinding, validateVerificationRequest, } from './contracts'; -import { capture, containedFile, assertSnapshot } from './snapshot'; +import { capture, containedFile, assertSnapshot, readSnapshotManifest } from './snapshot'; import { assertStateOutside, event, @@ -1057,7 +1057,7 @@ function originalBoundary(report: RunReportV3): { throw new CsoError('INVALID_SCHEMA', 'Recheck evidence requires a linked original finding'); const dir = runDirectory(report.parent.runId), original = loadReport(dir), - manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest, + manifest = readSnapshotManifest(dir), finding = original.findings.find((item) => item.id === report.parent!.findingId); if (report.repoId !== original.repoId) throw new CsoError('INCOMPATIBLE_INPUT', 'Recheck repository identity differs from the original audit'); @@ -1293,7 +1293,7 @@ function submit(args: string[]) { const input = rawInput as SubmissionV3; return withLock(dir, () => { const report = loadReport(dir), - manifest = readJson(join(dir, 'snapshot.json')); + manifest = readSnapshotManifest(dir); assertSnapshot(dir, manifest); requireReportingTime(report); if (report.status !== 'running') @@ -1399,7 +1399,7 @@ function finish(args: string[]) { if (args.length) throw new CsoError('INVALID_ARGUMENT', 'finish takes only a run ID'); return withLock(dir, () => { const report = loadReport(dir), - manifest = readJson(join(dir, 'snapshot.json')); + manifest = readSnapshotManifest(dir); assertSnapshot(dir, manifest); for (const c of report.coverage) if ( @@ -1475,7 +1475,7 @@ async function inspect(args: string[]) { const { dir } = run(args); if (args.length) throw new CsoError('INVALID_ARGUMENT', 'inspect takes one run ID'); const report = loadReport(dir), - manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest; + manifest = readSnapshotManifest(dir); assertSnapshot(dir, manifest); const rawSensitive = readJson(join(dir, 'sensitive-evidence.json')), sensitiveEvidence = Array.isArray(rawSensitive) @@ -1505,7 +1505,7 @@ async function inspect(args: string[]) { async function read(args: string[]) { const { dir } = run(args); if (args.length !== 1) throw new CsoError('INVALID_ARGUMENT', 'read requires one path or opaque handle'); - const manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest; + const manifest = readSnapshotManifest(dir); assertSnapshot(dir, manifest); const selected = resolveSnapshotPath(manifest, args[0], true), full = containedFile(join(dir, 'readable'), selected.path), @@ -1514,7 +1514,7 @@ async function read(args: string[]) { } async function history(args: string[]) { const { dir } = run(args), - manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest; + manifest = readSnapshotManifest(dir); assertSnapshot(dir, manifest); let selected: string | undefined; if (args.length) selected = resolveSnapshotPath(manifest, args.shift(), false, 'History path', true).path; @@ -1537,7 +1537,7 @@ function resume(args: string[]) { if (args.length) throw new CsoError('INVALID_ARGUMENT', 'resume takes one run ID'); return withLock(dir, () => { const report = loadReport(dir), - manifest = readJson(join(dir, 'snapshot.json')); + manifest = readSnapshotManifest(dir); if (report.status === 'finished') throw new CsoError('INVALID_SCHEMA', 'A finished audit cannot be resumed'); assertSnapshot(dir, manifest); @@ -1671,7 +1671,7 @@ async function scanner(args: string[], sarif = false) { id, runId: report.runId, runDir: dir, - manifest: readJson(join(dir, 'snapshot.json')), + manifest: readSnapshotManifest(dir), policy: report.policy, executionDeadline: Date.parse(report.deadline) - 60_000, platform: platform(), @@ -1822,7 +1822,7 @@ async function recheck(args: string[], dependencies: CsoCliDependencies) { throw new CsoError('INVALID_SCHEMA', 'Recheck requires a finished original audit'); // Keep the original immutable while the fresh snapshot is captured and // until its child lineage report has been durably published. - const oldManifest = readJson(join(originalDir, 'snapshot.json')); + const oldManifest = readSnapshotManifest(originalDir); const preserveBase = original.policy.diff || Boolean(original.source.baseCommit), report = await start( [ @@ -1922,7 +1922,7 @@ function testPlan(args: string[]) { if (args.length !== 1 || !['node', 'bun', 'python', 'rails'].includes(args[0])) throw new CsoError('INVALID_ARGUMENT', 'test-plan requires one supported stack'); const stack = args[0] as 'node' | 'bun' | 'python' | 'rails', - manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest; + manifest = readSnapshotManifest(dir); assertSnapshot(dir, manifest); const preparation = inspectPreparation(join(dir, 'snapshot'), stack); if (preparation.status !== 'ready') @@ -1947,7 +1947,7 @@ function runtimePlan(args: string[]) { const port = Number(rawPort); if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new CsoError('INVALID_ARGUMENT', '--port must be an integer from 1024 to 65535'); - const manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest; + const manifest = readSnapshotManifest(dir); assertSnapshot(dir, manifest); const preparation = inspectPreparation(join(dir, 'snapshot'), stack); if (preparation.status !== 'ready') @@ -2180,7 +2180,7 @@ async function verify(args: string[], dependencies: CsoCliDependencies) { request = validateVerificationRequest(raw); return await withLock(dir, async () => { const report = loadReport(dir), - manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest; + manifest = readSnapshotManifest(dir); assertSnapshot(dir, manifest); requireTime(report); if (report.policy.mode !== 'comprehensive' || report.status !== 'running') @@ -2493,7 +2493,7 @@ async function replay(args: string[], dependencies: CsoCliDependencies) { throw new CsoError('INCOMPATIBLE_INPUT', 'Supplied source does not match the bundle input hashes'); }; if (fs.existsSync(retained)) { - manifest = readJson(join(stored.dir, 'snapshot.json')); + manifest = readSnapshotManifest(stored.dir); const expiresAt = typeof manifest?.expiresAt === 'string' ? Date.parse(manifest.expiresAt) : Number.NaN; if (!Number.isFinite(expiresAt) || new Date(expiresAt).toISOString() !== manifest.expiresAt) diff --git a/lib/cso/snapshot.ts b/lib/cso/snapshot.ts index a173ff1fc..55d581fcc 100644 --- a/lib/cso/snapshot.ts +++ b/lib/cso/snapshot.ts @@ -15,7 +15,7 @@ import { MAX_OUTPUT, } from './contracts'; import { childEnvironment, executable, git, redact, runProcess } from './process'; -import { secureDirectory, writeHelperJson, writeJson } from './state'; +import { readJson, secureDirectory, writeHelperJson, writeJson } from './state'; import { scan } from '../redact-engine'; import { atomicWriteSync } from '../fs-atomic'; @@ -52,6 +52,21 @@ function omittedPath(path: string): boolean { const SECRET_FILE = /(?:^|\/)(?:\.env(?:\..*)?|\.npmrc|\.yarnrc(?:\.yml)?|\.pypirc|pip\.conf|credentials(?:\.yml(?:\.enc)?)?|master\.key|id_(?:rsa|ed25519)|.*\.(?:pem|p12|pfx|key)|AGENTS\.md|CLAUDE\.md|GEMINI\.md|bunfig\.toml)$/i; const SOURCE_LIMIT = 64 * 1024 * 1024; +/** snapshot.json holds one entry per source file, so it gets its own cap; other private state stays at 1 MiB. */ +export const SNAPSHOT_MANIFEST_LIMIT = 16 * 1024 * 1024; +const CAPACITY_ISSUE = 'https://github.com/garrytan/gstack/issues/2993'; +const COUNTED_SOURCE = + 'every tracked or nonignored untracked file outside dependency and VCS directories (node_modules, .git, .venv, vendor/bundle and similar)'; +const mib = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(1)} MiB`; +export function snapshotManifestCapMessage(entries: number, bytes: number): string { + return `Snapshot manifest cap exceeded: ${entries} source entries need a ${mib(bytes)} (${bytes} byte) manifest; the snapshot manifest cap is ${mib(SNAPSHOT_MANIFEST_LIMIT)}. One entry is recorded for ${COUNTED_SOURCE}. Next step: run /cso on a smaller checkout of the code you want audited, and add your entry count to ${CAPACITY_ISSUE}. No supported workaround raises this cap.`; +} +export function sourceCapMessage(bytes: number, files: number): string { + return `64 MiB source cap exceeded: ${files} source files hold about ${mib(bytes)} (${bytes} bytes); the source admission cap is ${mib(SOURCE_LIMIT)}. The full size of ${COUNTED_SOURCE} counts, including files over 1 MiB whose payloads are withheld from the audit. Next step: run /cso on a smaller checkout of the code you want audited, and add your measured size to ${CAPACITY_ISSUE}. No supported workaround raises this cap.`; +} +export function readSnapshotManifest(runDir: string): SnapshotManifest { + return readJson(join(runDir, 'snapshot.json'), SNAPSHOT_MANIFEST_LIMIT) as SnapshotManifest; +} const SNAPSHOT_ENTRY_LIMIT = 100_000; const GIT_POINTER_LIMIT = 8192; export interface SnapshotCaptureLimits { @@ -611,10 +626,23 @@ export async function capture( manifest.baseCommit = (await git(repo, ['rev-parse', '--verify', `${base}^{commit}`], home)).trim(); guard(); } + const sourceCapError = () => { + let measured = 0, + files = 0; + for (const candidate of list) { + if (noReadPath(candidate)) continue; + try { + measured += fs.lstatSync(join(repo, candidate)).size; + files++; + } catch {} + } + return new CsoError('MISSING_INPUT', sourceCapMessage(Math.max(measured, total), files)); + }; for (const path of list) { guard(); + let size: number; try { - fs.lstatSync(join(repo, path)); + size = fs.lstatSync(join(repo, path)).size; } catch (error: any) { if (error?.code === 'ENOENT') { absentPaths.add(path); @@ -640,11 +668,11 @@ export async function capture( guard(); continue; } + if (total + size > SOURCE_LIMIT) throw sourceCapError(); const { data, mode } = readStable(repo, path, SOURCE_LIMIT); total += data.length; guard(); - if (total > SOURCE_LIMIT) - throw new CsoError('MISSING_INPUT', 'Source exceeds the 64 MiB snapshot admission limit'); + if (total > SOURCE_LIMIT) throw sourceCapError(); const entry: SnapshotEntry = { path, pathId: snapshotPathId(repo, path), @@ -862,11 +890,11 @@ export async function capture( // The manifest contains helper-computed identities and source pathnames but // never source payloads. Persist it exactly in private state: generic // content redaction would silently break the path/hash identity relation. - const serialized = JSON.stringify(manifest, null, 2); - if (Buffer.byteLength(serialized) + 1 > MAX_OUTPUT) + const serialized = JSON.stringify(manifest); + if (Buffer.byteLength(serialized) + 1 > SNAPSHOT_MANIFEST_LIMIT) throw new CsoError( 'MISSING_INPUT', - 'Snapshot manifest exceeds the 1 MiB private-state admission limit', + snapshotManifestCapMessage(entries.length, Buffer.byteLength(serialized) + 1), ); atomicWriteSync(join(runDir, 'snapshot.json'), serialized + '\n', { mode: 0o600 }); return manifest; diff --git a/lib/cso/state.ts b/lib/cso/state.ts index 017f95b67..d73b22bfa 100644 --- a/lib/cso/state.ts +++ b/lib/cso/state.ts @@ -814,7 +814,7 @@ export function writeJsonExclusive(path: string, value: unknown): void { throw new CsoError('PERSISTENCE_FAILED', 'Private immutable artifact could not be written'); } } -function readPrivateJson(path: string): unknown { +function readPrivateJson(path: string, maxBytes: number): unknown { let fd: number | undefined; try { const before = exactLstat(path); @@ -823,7 +823,7 @@ function readPrivateJson(path: string): unknown { before.isSymbolicLink() || before.nlink !== 1 || before.size <= 0 || - before.size > MAX_STATE_FILE || + before.size > maxBytes || (process.getuid && before.uid !== process.getuid()) || (process.platform !== 'win32' && (before.mode & 0o077) !== 0) ) @@ -857,11 +857,12 @@ function readPrivateJson(path: string): unknown { } catch {} } } -export function readJson(path: string): any { +/** maxBytes is a per-artifact cap; only the snapshot manifest passes a larger one. */ +export function readJson(path: string, maxBytes = MAX_STATE_FILE): any { try { secureDirectory(dirname(path)); - recoverAtomicNoReplaceJson(path, { label: 'Private immutable artifact', maxBytes: MAX_STATE_FILE }); - return readPrivateJson(path); + recoverAtomicNoReplaceJson(path, { label: 'Private immutable artifact', maxBytes }); + return readPrivateJson(path, maxBytes); } catch (e) { if (e instanceof CsoError) throw e; throw new CsoError('MISSING_INPUT', 'Private state file is missing or invalid'); diff --git a/test/cso-file-ceiling.test.ts b/test/cso-file-ceiling.test.ts new file mode 100644 index 000000000..a1cdd3f3d --- /dev/null +++ b/test/cso-file-ceiling.test.ts @@ -0,0 +1,156 @@ +// /cso file-count ceiling (#3068, manifest half of #2993): snapshot.json has its own +// 16 MiB cap and compact form, every per-entry list in a 1 MiB private artifact is +// bounded with an omitted count, and both capacity errors say what to do next. +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { MAX_OUTPUT } from '../lib/cso/contracts'; +import { + SNAPSHOT_MANIFEST_LIMIT, + capture, + readSnapshotManifest, + snapshotManifestCapMessage, + sourceCapMessage, +} from '../lib/cso/snapshot'; +import { newRun, readJson } from '../lib/cso/state'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const launcher = path.join(ROOT, 'bin', process.platform === 'win32' ? 'gstack-cso-launcher.exe' : 'gstack-cso-launcher'); +const MIB = 1024 * 1024; +const ISSUE = 'https://github.com/garrytan/gstack/issues/2993'; +const KEY = ['pass', 'word'].join(''); +let root = ''; +let state = ''; +const originalHome = process.env.GSTACK_HOME; + +function git(repo: string, ...args: string[]): string { + const result = spawnSync('/usr/bin/git', ['-C', repo, ...args], { encoding: 'utf8', env: { HOME: root, PATH: '/usr/bin:/bin' }, timeout: 60_000 }); + if (result.status) throw new Error(result.stderr); + return result.stdout; +} +function cso(repo: string, args: string[]) { + return spawnSync(launcher, args, { cwd: repo, encoding: 'utf8', env: { HOME: root, GSTACK_HOME: state, PATH: '/usr/bin:/bin' }, timeout: 120_000, maxBuffer: 64 * MIB }); +} +function makeRepo(name: string, files: Record): string { + const repo = path.join(root, name); + fs.mkdirSync(repo); + git(repo, 'init', '-q'); + git(repo, 'config', 'user.email', 'fixture@example.test'); + git(repo, 'config', 'user.name', 'Fixture'); + for (const [file, body] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(repo, file)), { recursive: true }); + fs.writeFileSync(path.join(repo, file), body); + } + git(repo, 'add', '-A'); + git(repo, 'commit', '-qm', 'fixture'); + return repo; +} +function runDir(run: { repoId: string; runId: string }): string { + return path.join(state, 'security', 'cso', run.repoId, run.runId); +} +function writeInput(name: string, value: unknown): string { + const file = path.join(root, name); + fs.writeFileSync(file, JSON.stringify(value)); + return file; +} +const application = { actors: ['tenant user'], assets: ['tenant records'], entrypoints: ['GET /users/:id'], tenantBoundaries: ['tenant id'], sensitiveOperations: ['record read'], invariants: ['tenant isolation'] }; +const finding = { title: 'Cross-tenant user read', rootCause: 'Tenant query omits caller tenant predicate', location: { path: 'src/users.ts', line: 1, symbol: 'tenantQuery' }, advisoryIds: [], severity: 'high', confidence: 'high', confidenceRationale: 'The caller-to-query trace and missing tenant predicate directly support the finding', evidence: 'supported', attackerControl: 'Authenticated caller chooses the record ID', impact: 'Another tenant record is returned', scenario: 'A tenant supplies a known record ID owned by another tenant and receives that record', trace: ['GET /users/:id', 'tenantQuery', 'findUnique by id'], references: ['src/users.ts:1', 'OWASP API1:2023'], recommendation: 'Bind the lookup predicate to the authenticated tenant identifier', challenge: { reviewer: 'independent-2', independent: true, mode: 'independent_agent', callers: 'Authenticated route forwards the ID', controls: 'Authentication does not bind tenant', counterevidence: 'Opaque IDs reduce guessing but do not authorize', conclusion: 'The authorization invariant is absent' } }; +function completeEvidence(report: any, findings: unknown[]) { + return { application, findings, coverage: report.coverage.filter((item: any) => !['snapshot-inputs', 'history-inputs'].includes(item.domain)).map((item: any) => ({ ...item, status: 'assessed', method: 'fresh caller and boundary trace', gaps: [], evidence: ['current captured source'] })), gaps: [] }; +} +const USERS = 'export const tenantQuery = (id:string) => db.user.findUnique({where:{id}})\n'; +beforeAll(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cso-ceiling-')); + state = path.join(root, 'state'); + process.env.GSTACK_HOME = state; +}); +afterAll(() => { + if (originalHome === undefined) delete process.env.GSTACK_HOME; + else process.env.GSTACK_HOME = originalHome; + fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('CSO snapshot manifest cap', () => { + test('the snapshot cap is local to snapshot.json; shared limits stay 1 MiB', () => { + expect(MAX_OUTPUT).toBe(MIB); + expect(SNAPSHOT_MANIFEST_LIMIT).toBe(16 * MIB); + const dir = fs.mkdtempSync(path.join(root, 'caps-')); + fs.chmodSync(dir, 0o700); + const big = JSON.stringify({ padding: 'x'.repeat(MIB + 16) }); + for (const name of ['report.json', 'snapshot.json']) fs.writeFileSync(path.join(dir, name), big, { mode: 0o600 }); + expect(() => readJson(path.join(dir, 'report.json'))).toThrow('Invalid private state file'); + expect(() => readJson(path.join(dir, 'snapshot.json'))).toThrow('Invalid private state file'); + expect(readSnapshotManifest(dir)).toEqual(JSON.parse(big)); + fs.writeFileSync(path.join(dir, 'snapshot.json'), JSON.stringify({ padding: 'x'.repeat(16 * MIB) }), { mode: 0o600 }); + expect(() => readSnapshotManifest(dir)).toThrow('Invalid private state file'); + }); + + test('every snapshot.json read in the helper goes through readSnapshotManifest', () => { + const files = fs.readdirSync(path.join(ROOT, 'lib', 'cso')).filter((file) => file.endsWith('.ts')); + for (const file of files) { + const source = fs.readFileSync(path.join(ROOT, 'lib', 'cso', file), 'utf8'); + expect(source).not.toMatch(/readJson\(\s*join\([^)]*'snapshot\.json'\)\s*\)/); + } + const cli = fs.readFileSync(path.join(ROOT, 'lib', 'cso', 'cli.ts'), 'utf8'); + expect(cli.match(/readSnapshotManifest\(/g)?.length).toBeGreaterThanOrEqual(13); + const snapshot = fs.readFileSync(path.join(ROOT, 'lib', 'cso', 'snapshot.ts'), 'utf8'); + expect(snapshot).toContain("readJson(join(runDir, 'snapshot.json'), SNAPSHOT_MANIFEST_LIMIT)"); + expect(snapshot).toContain('> SNAPSHOT_MANIFEST_LIMIT'); + expect(snapshot).not.toContain('JSON.stringify(manifest, null, 2)'); + }); + + test('both capacity errors name the bound cap, measured value, what counts, next step and #2993', () => { + const manifest = snapshotManifestCapMessage(61_234, 17_000_000); + expect(manifest).toStartWith('Snapshot manifest cap exceeded:'); + for (const part of ['61234 source entries', '17000000 byte', 'cap is 16.0 MiB', 'One entry is recorded for every tracked', 'Next step:', ISSUE, 'No supported workaround']) + expect(manifest).toContain(part); + const source = sourceCapMessage(346_199_207, 7_561); + expect(source).toStartWith('64 MiB source cap exceeded:'); + for (const part of ['7561 source files', '346199207 bytes', 'cap is 64.0 MiB', 'files over 1 MiB whose payloads are withheld', 'Next step:', ISSUE, 'No supported workaround']) + expect(source).toContain(part); + }); + + test('the 64 MiB source cap still fails closed, with the actionable message on stderr', () => { + const repo = makeRepo('over-source-cap', { 'src/users.ts': USERS, 'assets/a.bin': Buffer.alloc(40 * MIB, 1), 'assets/b.bin': Buffer.alloc(25 * MIB, 1) }); + const result = cso(repo, ['start', '--repo', repo, '--offline']); + expect(result.status).not.toBe(0); + expect(result.stdout).toBe(''); + const error = JSON.parse(result.stderr).error; + expect(error.code).toBe('MISSING_INPUT'); + expect(error.message).toStartWith('64 MiB source cap exceeded: 3 source files hold about 65.0 MiB'); + expect(error.message).toContain(ISSUE); + expect(error.message).toContain('No supported workaround'); + }, 120_000); +}); + +describe('CSO above the old file-count ceiling', () => { + test('a 2,500-file repository still captures and round-trips', async () => { + const files: Record = {}; + for (let i = 1; i <= 2500; i++) files[`src/pkg/sub/file_number_${i}.ts`] = `export const v${i} = ${i};\n`; + const repo = makeRepo('plain-2500', files), + run = newRun(repo), + manifest = await capture(repo, run.dir); + expect(manifest.entries).toHaveLength(2500); + expect(readSnapshotManifest(run.dir)).toEqual(manifest); + }, 120_000); + + test('a retained pretty-printed snapshot.json from an older helper still loads in recheck', () => { + const repo = makeRepo('legacy-pretty', { 'src/users.ts': USERS, 'src/other.ts': 'export const unrelated = true\n' }), + run = JSON.parse(cso(repo, ['start', '--repo', repo, '--scope', 'auth', '--offline']).stdout), + dir = runDir(run), + initial = readJson(path.join(dir, 'report.json')); + expect(cso(repo, ['submit', run.runId, writeInput('legacy-evidence.json', completeEvidence(initial, [finding]))]).status).toBe(0); + expect(cso(repo, ['finish', run.runId]).status).toBe(0); + const file = path.join(dir, 'snapshot.json'); + fs.writeFileSync(file, JSON.stringify(JSON.parse(fs.readFileSync(file, 'utf8')), null, 2) + '\n'); + expect(fs.readFileSync(file, 'utf8')).toContain('\n "entries": ['); + expect(cso(repo, ['inspect', run.runId]).status).toBe(0); + const findingId = readJson(path.join(dir, 'report.json')).findings[0].id, + rechecked = cso(repo, ['recheck', findingId, '--run', run.runId, '--repo', repo]); + expect(rechecked.stderr).toBe(''); + expect(rechecked.status).toBe(0); + expect(JSON.parse(rechecked.stdout).parent.runId).toBe(run.runId); + }, 120_000); +});