mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* 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> * fix(cso): bound per-entry report and evidence lists with omitted counts Lifting the manifest cap alone moved the failure into the audit: report.json listed one transformation per transformed entry plus one coverage gap or exclusion per omitted entry (1.97 MB for 5,000 nested paths), and sensitive-evidence.json grew per scanner hit (1.68 MB), both under the 1 MiB private-state cap that saveReport and the start stdout share. - boundedList (state.ts) keeps the leading entries that fit a byte budget, measured as the pretty-printed JSON they add at their nesting depth, and ends the list with one summary carrying the omitted count. - report.json snapshot lists share one 256 KiB budget (gaps 128 KiB, transformations 96 KiB, exclusions 32 KiB); coverage counts and status are computed before trimming, so they stay exact. - sensitive-evidence.json keeps 512 KiB of per-file evidence plus an omitted-count record. - Snapshot coverage and publicSnapshotPath no longer scan the whole manifest per entry. Test: 5,000 long nested paths, mostly excluded or withheld, 999 files with scanner hits, driven through start, inspect, submit, finish and recheck. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(cso): admit Git path listings above 1 MiB during snapshot capture With the manifest cap lifted, the next file-count wall was the shared 1 MiB process-output cap on Git's path listings: ls-files/ls-tree --name-only failed at roughly 15-20k files (23,749-file grafana/grafana fails there) and the comparison-base ls-tree near 8-10k. runProcess lets raw callers, whose output is never printed or persisted, opt in to up to 16 MiB; git() forwards an optional cap, and the four snapshot listings pass SNAPSHOT_MANIFEST_LIMIT. Every other caller keeps the MAX_OUTPUT clamp. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(cso): explain the capacity refusals and file the follow-ups docs/troubleshooting.md#cso-capacity covers the snapshot-list cap, the 64 MiB source cap and the tracked-symlink refusal. TODOS.md records the symlink design call, the #2993 aggregate cap, the full-manifest inspect output and repair-bundle transformations. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore(release): v1.91.42.0 /cso lifts its file-count ceiling Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
277 lines
16 KiB
TypeScript
277 lines
16 KiB
TypeScript
// /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 { boundedList, newRun, readJson } from '../lib/cso/state';
|
|
import { runProcess } from '../lib/cso/process';
|
|
|
|
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, string | Buffer>): 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';
|
|
// Long nested paths where most files are excluded or withheld and many carry
|
|
// scanner hits: the shape that overflowed report.json and sensitive-evidence.json.
|
|
function hostileFiles(count: number): Record<string, string | Buffer> {
|
|
const files: Record<string, string | Buffer> = { 'src/users.ts': USERS };
|
|
for (let i = 1; i < count; i++) {
|
|
const dir = `packages/very-long-workspace-package-name-${i % 50}/src/components/deeply/nested/feature-area-${i % 17}`;
|
|
switch (i % 5) {
|
|
case 0:
|
|
files[`${dir}/module_file_number_${i}.ts`] = Array.from({ length: 12 }, (_, k) => `${KEY} = "Sup3rS3cretValue${i}x${k}"\n`).join('');
|
|
break;
|
|
case 1:
|
|
files[`node_modules/${dir}/dependency_file_number_${i}.js`] = `module.exports=${i}\n`;
|
|
break;
|
|
case 2:
|
|
files[`.claude/${dir}/agent_config_file_number_${i}.md`] = `cfg ${i}\n`;
|
|
break;
|
|
case 3:
|
|
files[`${dir}/binary_asset_number_${i}.bin`] = Buffer.from(`a\0b${i}`);
|
|
break;
|
|
default:
|
|
files[`${dir}/certificate_number_${i}.pem`] = `not a key ${i}\n`;
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
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 bounded per-entry lists', () => {
|
|
test('keeps leading items, appends one omitted-count summary, and leaves short lists untouched', () => {
|
|
const items = Array.from({ length: 1000 }, (_, i) => `entry-${i}`);
|
|
expect(boundedList(3, (i) => items[i], { bytes: 4096 }, 2, (n) => `${n} omitted`)).toEqual(items.slice(0, 3));
|
|
const budget = { bytes: 2048 },
|
|
kept = boundedList(items.length, (i) => items[i], budget, 2, (n) => `${n} omitted`);
|
|
expect(kept.length).toBeLessThan(items.length);
|
|
expect(kept.at(-1)).toBe(`${items.length - kept.length + 1} omitted`);
|
|
expect(kept.slice(0, -1)).toEqual(items.slice(0, kept.length - 1));
|
|
expect(budget.bytes).toBeGreaterThanOrEqual(0);
|
|
});
|
|
});
|
|
|
|
describe('CSO above the old file-count ceiling', () => {
|
|
test('a 2,500-file repository still captures and round-trips', async () => {
|
|
const files: Record<string, string> = {};
|
|
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('Git path listings over 1 MiB are admitted, with and without a comparison base', async () => {
|
|
const files: Record<string, string> = {},
|
|
dir = Array.from({ length: 6 }, (_, i) => `workspace-segment-with-a-long-descriptive-name-${i}`).join('/');
|
|
for (let i = 1; i <= 5000; i++) files[`${dir}/file_number_${i}.ts`] = `export const v${i} = ${i};\n`;
|
|
const repo = makeRepo('long-listing', files);
|
|
expect(Buffer.byteLength(git(repo, 'ls-files', '-z'))).toBeGreaterThan(MIB);
|
|
const plain = await capture(repo, newRun(repo).dir);
|
|
expect(plain.entries).toHaveLength(5000);
|
|
const based = await capture(repo, newRun(repo).dir, 'HEAD');
|
|
expect(based.entries).toHaveLength(5000);
|
|
expect(based.changedPaths).toEqual([]);
|
|
}, 180_000);
|
|
|
|
test('only raw callers can opt in to output above MAX_OUTPUT', async () => {
|
|
const cwd = fs.mkdtempSync(path.join(root, 'raw-')),
|
|
script = ['-c', 'head -c 1200000 /dev/zero | tr "\\0" a'],
|
|
env = { PATH: '/usr/bin:/bin' };
|
|
expect((await runProcess('/bin/sh', script, { cwd, env, maxBytes: 2 * MIB })).truncated).toBe(true);
|
|
expect((await runProcess('/bin/sh', script, { cwd, env, raw: true })).truncated).toBe(true);
|
|
const raw = await runProcess('/bin/sh', script, { cwd, env, raw: true, maxBytes: 2 * MIB });
|
|
expect(raw.truncated).toBe(false);
|
|
expect(raw.stdout).toHaveLength(1_200_000);
|
|
});
|
|
|
|
test('5,000 long nested paths, mostly excluded or withheld with many scanner hits, run start to final report and recheck', () => {
|
|
const repo = makeRepo('hostile-5000', hostileFiles(5000)),
|
|
started = cso(repo, ['start', '--repo', repo, '--scope', 'auth', '--offline']);
|
|
expect(started.stderr).toBe('');
|
|
expect(started.status).toBe(0);
|
|
expect(Buffer.byteLength(started.stdout)).toBeLessThan(MIB);
|
|
const run = JSON.parse(started.stdout),
|
|
dir = runDir(run);
|
|
|
|
const rawManifest = fs.readFileSync(path.join(dir, 'snapshot.json'), 'utf8'),
|
|
manifest = readSnapshotManifest(dir);
|
|
expect(Buffer.byteLength(rawManifest)).toBeGreaterThan(MIB);
|
|
expect(rawManifest).toBe(JSON.stringify(manifest) + '\n');
|
|
expect(manifest.entries).toHaveLength(5000);
|
|
|
|
const evidence = readJson(path.join(dir, 'sensitive-evidence.json'));
|
|
expect(fs.statSync(path.join(dir, 'sensitive-evidence.json')).size).toBeLessThan(MIB);
|
|
expect(evidence.at(-1).note).toContain(`${evidence.at(-1).omitted} more files with sensitive-pattern findings are not listed`);
|
|
expect(evidence.length - 1 + evidence.at(-1).omitted).toBe(999);
|
|
|
|
const initial = readJson(path.join(dir, 'report.json')),
|
|
snapshot = initial.coverage.find((item: any) => item.domain === 'snapshot-inputs'),
|
|
transformations = initial.source.transformations;
|
|
expect(fs.statSync(path.join(dir, 'report.json')).size).toBeLessThan(400 * 1024);
|
|
expect(snapshot.evidence).toEqual(['1000 sanitized execution inputs captured; 3000 explicit non-executable exclusions; 1000 unread in-scope inputs; 0 tracked deletions']);
|
|
expect(snapshot.status).toBe('partial');
|
|
const omittedCount = (line: string) => Number(line.match(/^(\d+) more/)?.[1]);
|
|
expect(snapshot.gaps.length - 1 + omittedCount(snapshot.gaps.at(-1))).toBe(1000);
|
|
expect(snapshot.gaps.at(-1)).toContain('more unread or deleted in-scope inputs are not listed to keep report.json within its 1 MiB bound');
|
|
expect(snapshot.exclusions.length - 1 + omittedCount(snapshot.exclusions.at(-1))).toBe(3000);
|
|
expect(transformations.at(-1).path).toBe('[not listed]');
|
|
expect(transformations.length - 1 + omittedCount(transformations.at(-1).handling)).toBe(4999);
|
|
expect(transformations.length).toBeGreaterThan(100);
|
|
expect(initial.events[0].message).toBe('Captured 5000 source entries; 4999 transformations disclosed');
|
|
|
|
const inspected = cso(repo, ['inspect', run.runId]);
|
|
expect(inspected.status).toBe(0);
|
|
expect(JSON.parse(inspected.stdout).manifest.entries).toHaveLength(5000);
|
|
|
|
expect(cso(repo, ['submit', run.runId, writeInput('hostile-evidence.json', completeEvidence(initial, [finding]))]).status).toBe(0);
|
|
const finished = cso(repo, ['finish', run.runId]);
|
|
expect(finished.stderr).toBe('');
|
|
expect(finished.status).toBe(0);
|
|
const final = readJson(path.join(dir, 'report.json'));
|
|
expect(final.status).toBe('finished');
|
|
expect(final.findings).toHaveLength(1);
|
|
expect(fs.statSync(path.join(dir, 'report.json')).size).toBeLessThan(MIB);
|
|
|
|
const rechecked = cso(repo, ['recheck', final.findings[0].id, '--repo', repo]);
|
|
expect(rechecked.stderr).toBe('');
|
|
expect(rechecked.status).toBe(0);
|
|
const child = JSON.parse(rechecked.stdout);
|
|
expect(child.parent).toEqual({ runId: run.runId, findingId: final.findings[0].id, kind: 'recheck' });
|
|
expect(readSnapshotManifest(runDir({ ...child, repoId: run.repoId })).entries).toHaveLength(5000);
|
|
}, 300_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);
|
|
});
|