From 589e3a3a9541ea9e79048c10e07b1f519e081173 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 11 Sep 2026 18:24:01 +0000 Subject: [PATCH] fix(cso): resolve rechecks by finding Co-Authored-By: OpenAI Codex --- cso/SKILL.md | 2 +- cso/SKILL.md.tmpl | 2 +- lib/cso/cli.ts | 25 ++++++++++++++++++++++--- test/cso-cli-recheck.test.ts | 9 +++++++++ 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/cso/SKILL.md b/cso/SKILL.md index 26a17d8e7..e423da3fd 100644 --- a/cso/SKILL.md +++ b/cso/SKILL.md @@ -71,7 +71,7 @@ gstack-cso start --repo [scope/diff/base/budget/offline flags] [--compreh gstack-cso doctor --repo gstack-cso resume gstack-cso replay [--source ] -gstack-cso recheck --run --repo +gstack-cso recheck --repo [--run ] gstack-cso inspect gstack-cso read gstack-cso history [path-or-handle] diff --git a/cso/SKILL.md.tmpl b/cso/SKILL.md.tmpl index 26f5840b0..bdf910fea 100644 --- a/cso/SKILL.md.tmpl +++ b/cso/SKILL.md.tmpl @@ -71,7 +71,7 @@ gstack-cso start --repo [scope/diff/base/budget/offline flags] [--compreh gstack-cso doctor --repo gstack-cso resume gstack-cso replay [--source ] -gstack-cso recheck --run --repo +gstack-cso recheck --repo [--run ] gstack-cso inspect gstack-cso read gstack-cso history [path-or-handle] diff --git a/lib/cso/cli.ts b/lib/cso/cli.ts index fda6f1bdd..8e0eb08da 100644 --- a/lib/cso/cli.ts +++ b/lib/cso/cli.ts @@ -70,7 +70,7 @@ Usage: gstack-cso patch-hash REQUEST.json gstack-cso finish RUN gstack-cso replay BUNDLE_ID [--source MATCHING_SOURCE] - gstack-cso recheck FINDING --run RUN --repo PATH + gstack-cso recheck FINDING --repo PATH [--run RUN] gstack-cso import-v2 REPORT.json gstack-cso schema @@ -363,10 +363,29 @@ async function scanner(args:string[],sarif=false){ }); } function scannerOutcome(args:string[]){const {dir}=run(args);if(args.length!==1||!/^[a-z0-9-]{1,40}-[a-f0-9]{16}-[a-f0-9]{16}$/.test(args[0]))throw new CsoError('INVALID_ARGUMENT','scanner-outcome requires one immutable scanner artifact ID');return readJson(join(dir,'scanner-outcomes',`${args[0]}.json`));} -async function recheck(args:string[],dependencies:CsoCliDependencies){const startedAt=new Date();if(!args.length)throw new CsoError('INVALID_ARGUMENT','recheck requires a finding ID');retention(startedAt.getTime(),{deadlineMs:startedAt.getTime()+RETENTION_MAINTENANCE_MS,maxEntries:RETENTION_MAX_ENTRIES});const findingId=args.shift()!,runId=need(args,'--run'),originalDir=runDirectory(runId);return await withLock(originalDir,async()=>{const original=loadReport(originalDir),finding=original.findings.find(f=>f.id===findingId);if(!finding)throw new CsoError('MISSING_INPUT','Original finding does not exist');if(original.status!=='finished')throw new CsoError('INVALID_SCHEMA','Recheck requires a finished original audit'); +function recheckOriginalDirectory(repo:string,findingId:string,requestedRun?:string):{dir:string;runId:string}{ + const currentRepoId=repoId(repo),root=privateRoot(),repoDir=join(root,currentRepoId),runPattern=/^\d{13}-[a-f0-9]{16}$/; + if(requestedRun){ + if(!runPattern.test(requestedRun))throw new CsoError('INVALID_ARGUMENT','Run identifier must be the ID returned by start'); + const dir=join(repoDir,requestedRun);if(!fs.existsSync(dir))throw new CsoError('MISSING_INPUT','Original run was not found for the current repository or has expired'); + return{dir:secureDirectory(dir),runId:requestedRun}; + } + if(!fs.existsSync(repoDir))throw new CsoError('MISSING_INPUT','No finished original audit contains this finding in the current repository'); + const matches:{dir:string;runId:string}[]=[],directory=fs.opendirSync(secureDirectory(repoDir));let visited=0; + try{let entry:fs.Dirent|null;while((entry=directory.readSync())!==null){ + if(++visited>REPLAY_LOOKUP_MAX_ENTRIES)throw new CsoError('INSUFFICIENT_CAPACITY',`Recheck lookup exceeded ${REPLAY_LOOKUP_MAX_ENTRIES} private state entries`); + if(!entry.isDirectory()||!runPattern.test(entry.name))continue;const dir=join(repoDir,entry.name),reportPath=join(dir,'report.json');if(!fs.existsSync(reportPath))continue;const report=loadReport(dir); + if(report.runId!==entry.name||report.repoId!==currentRepoId)throw new CsoError('INCOMPATIBLE_INPUT','Retained original audit identity does not match its repository state path'); + if(report.status==='finished'&&!report.parent&&report.findings.some(f=>f.id===findingId))matches.push({dir,runId:entry.name}); + }}finally{directory.closeSync();} + if(!matches.length)throw new CsoError('MISSING_INPUT','No finished original audit contains this finding in the current repository'); + if(matches.length>1)throw new CsoError('INVALID_ARGUMENT',`Finding matches ${matches.length} finished original audits; use --run RUN to select one`); + return matches[0]; +} +async function recheck(args:string[],dependencies:CsoCliDependencies){const startedAt=new Date();if(!args.length)throw new CsoError('INVALID_ARGUMENT','recheck requires a finding ID');const findingId=args.shift()!;if(!/^[a-f0-9]{32}$/.test(findingId))throw new CsoError('INVALID_ARGUMENT','Finding identifier must be the 32-character ID reported by CSO');const repo=callerPath(need(args,'--repo')),requestedRun=args.includes('--run')?need(args,'--run'):undefined;if(args.length)throw new CsoError('INVALID_ARGUMENT',`Unknown recheck argument: ${args[0]}`);if(!fs.existsSync(repo)||!fs.statSync(repo).isDirectory())throw new CsoError('MISSING_INPUT','Repository directory does not exist');assertStateOutside(repo);retention(startedAt.getTime(),{deadlineMs:startedAt.getTime()+RETENTION_MAINTENANCE_MS,maxEntries:RETENTION_MAX_ENTRIES});const selected=recheckOriginalDirectory(repo,findingId,requestedRun),runId=selected.runId,originalDir=selected.dir;return await withLock(originalDir,async()=>{const original=loadReport(originalDir);if(original.runId!==runId||original.repoId!==repoId(repo))throw new CsoError('INCOMPATIBLE_INPUT','Retained original audit identity does not match its repository state path');const finding=original.findings.find(f=>f.id===findingId);if(!finding)throw new CsoError('MISSING_INPUT','Original finding does not exist');if(original.status!=='finished'||original.parent)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 repo=callerPath(need(args,'--repo'));if(args.length)throw new CsoError('INVALID_ARGUMENT',`Unknown recheck argument: ${args[0]}`);if(repoId(repo)!==original.repoId)throw new CsoError('INCOMPATIBLE_INPUT','Recheck must use the original audited repository');const oldManifest=readJson(join(originalDir,'snapshot.json')); + const oldManifest=readJson(join(originalDir,'snapshot.json')); const preserveBase=original.policy.diff||Boolean(original.source.baseCommit),report=await start(['--repo',repo,...(original.policy.mode==='comprehensive'?['--comprehensive']:[]),...(original.policy.diff?['--diff']:[]),...(preserveBase?['--base',original.policy.base]:[]),'--budget',String(original.policy.budgetSeconds),...(original.policy.offline?['--offline']:[]),...(original.policy.scope==='default'?[]:original.policy.scope.startsWith('domain:')?['--scope',original.policy.scope.slice(7)]:[`--${original.policy.scope}`])],dependencies,{runId,findingId,kind:'recheck'},oldManifest.headCommit,startedAt); return {runId:report.runId,parent:report.parent};});} diff --git a/test/cso-cli-recheck.test.ts b/test/cso-cli-recheck.test.ts index cd24b5625..eba8e4c30 100644 --- a/test/cso-cli-recheck.test.ts +++ b/test/cso-cli-recheck.test.ts @@ -17,10 +17,19 @@ function recheckEvidence(source='src/users.ts'){return[ {kind:'caller',path:source,line:1,observation:'Fresh caller trace reaches the authorization predicate'}, {kind:'security_boundary',path:source,line:1,observation:'Fresh boundary trace confirms cross-tenant access is denied'}, ];} +function finishedOriginal(name:string,candidate:any){ + const run=JSON.parse(command(['start','--repo',repo,'--scope','auth','--offline']).stdout),initial=JSON.parse(fs.readFileSync(reportPath(run),'utf8')); + expect(command(['submit',run.runId,writeInput(`${name}.json`,completeEvidence(initial,[candidate]))]).status).toBe(0); + expect(command(['finish',run.runId]).status).toBe(0); + return{run,findingId:JSON.parse(fs.readFileSync(reportPath(run),'utf8')).findings[0].id}; +} beforeAll(()=>{root=fs.mkdtempSync(path.join(os.tmpdir(),'cso-cli-recheck-'));repo=path.join(root,'repo');state=path.join(root,'state');fs.mkdirSync(repo);git('init','-q');git('config','user.email','fixture@example.test');git('config','user.name','Fixture');fs.mkdirSync(path.join(repo,'src'));fs.writeFileSync(path.join(repo,'src','users.ts'),'export const tenantQuery = (id:string) => db.user.findUnique({where:{id}})\n');fs.writeFileSync(path.join(repo,'src','other.ts'),'export const unrelated = true\n');git('add','src/users.ts','src/other.ts');git('commit','-qm','fixture');}); afterAll(()=>fs.rmSync(root,{recursive:true,force:true})); describe('CSO recheck persistence',()=>{ + test('resolves a unique finished original audit from the current repository',()=>{const candidate={...finding,rootCause:'Unique fixture query omits its tenant predicate',location:{...finding.location,symbol:'uniqueTenantQuery'}},original=finishedOriginal('unique-original',candidate),rechecked=command(['recheck',original.findingId,'--repo',repo]);expect(rechecked.status).toBe(0);expect(rechecked.stderr).toBe('');const child=JSON.parse(rechecked.stdout);expect(child.parent).toEqual({runId:original.run.runId,findingId:original.findingId,kind:'recheck'});},30_000); + test('reports a missing original finding without requiring an internal run ID',()=>{const missing=command(['recheck','0'.repeat(32),'--repo',repo]);expect(missing.status).not.toBe(0);expect(missing.stderr).toContain('MISSING_INPUT');expect(missing.stderr).toContain('No finished original audit contains this finding');},30_000); + test('rejects ambiguous originals and accepts --run as an explicit disambiguator',()=>{const candidate={...finding,rootCause:'Ambiguous fixture query omits its tenant predicate',location:{...finding.location,symbol:'ambiguousTenantQuery'}},first=finishedOriginal('ambiguous-original-first',candidate),second=finishedOriginal('ambiguous-original-second',candidate);expect(second.findingId).toBe(first.findingId);const ambiguous=command(['recheck',first.findingId,'--repo',repo]);expect(ambiguous.status).not.toBe(0);expect(ambiguous.stderr).toContain('INVALID_ARGUMENT');expect(ambiguous.stderr).toContain('--run RUN');const selected=command(['recheck',first.findingId,'--repo',repo,'--run',second.run.runId]);expect(selected.status).toBe(0);expect(JSON.parse(selected.stdout).parent.runId).toBe(second.run.runId);},30_000); test('requires an immutable finished original audit',()=>{const run=JSON.parse(command(['start','--repo',repo,'--scope','auth','--offline']).stdout),report=JSON.parse(fs.readFileSync(reportPath(run),'utf8'));expect(command(['submit',run.runId,writeInput('unfinished.json',completeEvidence(report,[finding]))]).status).toBe(0);const id=JSON.parse(fs.readFileSync(reportPath(run),'utf8')).findings[0].id,recheck=command(['recheck',id,'--run',run.runId,'--repo',repo]);expect(recheck.status).not.toBe(0);expect(recheck.stderr).toContain('finished original audit');},30_000); test('resolved closure rejects free-form or unrelated boundary evidence',()=>{const original=JSON.parse(command(['start','--repo',repo,'--scope','auth','--offline']).stdout),originalPath=reportPath(original),initial=JSON.parse(fs.readFileSync(originalPath,'utf8'));expect(command(['submit',original.runId,writeInput('bound-original.json',completeEvidence(initial,[finding]))]).status).toBe(0);expect(command(['finish',original.runId]).status).toBe(0);const findingId=JSON.parse(fs.readFileSync(originalPath,'utf8')).findings[0].id,started=JSON.parse(command(['recheck',findingId,'--run',original.runId,'--repo',repo]).stdout); const freeForm=command(['submit',started.runId,writeInput('free-form-claim.json',{recheck:{findingId,outcome:'resolved',evidence:['caller words','boundary words'],rootCause:finding.rootCause}})]);expect(freeForm.status).not.toBe(0);expect(freeForm.stderr).toContain('recheck evidence[0]');