mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 16:34:00 +01:00
fix(cso): close final verification gaps
This commit is contained in:
1 parent
ad6f0aa212
commit
88ca7874d0
11 files changed
+113
-11
No files matched your search
+2
-1
@@ -85,6 +85,7 @@ gstack-cso record-review <run> <request.json> --producer <identity>
|
||||
gstack-cso verify <run> <request.json>
|
||||
gstack-cso finish <run>
|
||||
gstack-cso import-v2 <report.json>
|
||||
gstack-cso inspect-v2 <import-id>
|
||||
```
|
||||
|
||||
**Private control files.** Use `umask 077`, a mode-`0700` directory outside the audited repository, and mode-`0600` JSON. Pass absolute paths; remove each control file immediately after ingestion.
|
||||
@@ -164,6 +165,6 @@ The private state namespace is `security/cso/<repo>/<run>` under the existing st
|
||||
|
||||
Give the run ID and the useful next operation. `resume` uses the retained snapshot and original policy; it never substitutes current HEAD or silently replenishes an exhausted budget. `replay` requires matching recorded source/runtime/dependency inputs. After snapshot expiry, missing matching supplied source returns **MISSING_INPUT**. A fresh audit is an explicit new run.
|
||||
|
||||
`recheck` creates a fresh current-source snapshot linked to the old finding. Establish closure only from new evidence covering the same root cause and security boundary. A resolved claim must identify fresh caller evidence and the original boundary by snapshot path/handle and line; the helper records the current source hash or proves that the original boundary path is absent. A partial or incompatible audit, changed title, absent warning, or proposed bundle cannot resolve an old finding. Stable v3 identity uses root cause/location and advisory identities rather than generated titles. Import v2 reports read-only: legacy `VERIFIED` means **legacy review evidence**, never reproduced, runtime-tested, or tested.
|
||||
`recheck` creates a fresh current-source snapshot linked to the old finding. Establish closure only from new evidence covering the same root cause and security boundary. A resolved claim must identify fresh caller evidence and the original boundary by snapshot path/handle and line; the helper records the current source hash or proves that the original boundary path is absent. A partial or incompatible audit, changed title, absent warning, or proposed bundle cannot resolve an old finding. Stable v3 identity uses root cause/location and advisory identities rather than generated titles. Import v2 reports read-only, retain the returned import ID, and use `inspect-v2` to read the preserved redacted report later. Legacy `VERIFIED` means **legacy review evidence**, never reproduced, runtime-tested, or tested.
|
||||
|
||||
For cancellation, deadline, or agent loss, let the helper's independent watchdog clean exact run-owned resources and preserve recoverable partial state. Never issue global Docker prune or remove resources identified only by a broad name. End with the actual completion status, supported results, exact prerequisites, and saved artifact paths returned by the helper.
|
||||
+2
-1
@@ -85,6 +85,7 @@ gstack-cso record-review <run> <request.json> --producer <identity>
|
||||
gstack-cso verify <run> <request.json>
|
||||
gstack-cso finish <run>
|
||||
gstack-cso import-v2 <report.json>
|
||||
gstack-cso inspect-v2 <import-id>
|
||||
```
|
||||
|
||||
**Private control files.** Use `umask 077`, a mode-`0700` directory outside the audited repository, and mode-`0600` JSON. Pass absolute paths; remove each control file immediately after ingestion.
|
||||
@@ -156,6 +157,6 @@ The private state namespace is `security/cso/<repo>/<run>` under the existing st
|
||||
|
||||
Give the run ID and the useful next operation. `resume` uses the retained snapshot and original policy; it never substitutes current HEAD or silently replenishes an exhausted budget. `replay` requires matching recorded source/runtime/dependency inputs. After snapshot expiry, missing matching supplied source returns **MISSING_INPUT**. A fresh audit is an explicit new run.
|
||||
|
||||
`recheck` creates a fresh current-source snapshot linked to the old finding. Establish closure only from new evidence covering the same root cause and security boundary. A resolved claim must identify fresh caller evidence and the original boundary by snapshot path/handle and line; the helper records the current source hash or proves that the original boundary path is absent. A partial or incompatible audit, changed title, absent warning, or proposed bundle cannot resolve an old finding. Stable v3 identity uses root cause/location and advisory identities rather than generated titles. Import v2 reports read-only: legacy `VERIFIED` means **legacy review evidence**, never reproduced, runtime-tested, or tested.
|
||||
`recheck` creates a fresh current-source snapshot linked to the old finding. Establish closure only from new evidence covering the same root cause and security boundary. A resolved claim must identify fresh caller evidence and the original boundary by snapshot path/handle and line; the helper records the current source hash or proves that the original boundary path is absent. A partial or incompatible audit, changed title, absent warning, or proposed bundle cannot resolve an old finding. Stable v3 identity uses root cause/location and advisory identities rather than generated titles. Import v2 reports read-only, retain the returned import ID, and use `inspect-v2` to read the preserved redacted report later. Legacy `VERIFIED` means **legacy review evidence**, never reproduced, runtime-tested, or tested.
|
||||
|
||||
For cancellation, deadline, or agent loss, let the helper's independent watchdog clean exact run-owned resources and preserve recoverable partial state. Never issue global Docker prune or remove resources identified only by a broad name. End with the actual completion status, supported results, exact prerequisites, and saved artifact paths returned by the helper.
|
||||
+4
-2
@@ -72,6 +72,7 @@ Usage:
|
||||
gstack-cso replay BUNDLE_ID [--source MATCHING_SOURCE]
|
||||
gstack-cso recheck FINDING --repo PATH [--run RUN]
|
||||
gstack-cso import-v2 REPORT.json
|
||||
gstack-cso inspect-v2 IMPORT_ID
|
||||
gstack-cso schema
|
||||
|
||||
Static runs never execute application code. Target and scanner execution is Docker-only and requires a qualified immutable catalog.`;
|
||||
@@ -330,7 +331,8 @@ async function read(args:string[]){const {dir}=run(args);if(args.length!==1)thro
|
||||
async function history(args:string[]){const {dir}=run(args),manifest=readJson(join(dir,'snapshot.json')) as SnapshotManifest;assertSnapshot(dir,manifest);let selected:string|undefined;if(args.length)selected=resolveSnapshotPath(manifest,args.shift(),false,'History path',true).path;if(args.length)throw new CsoError('INVALID_ARGUMENT','history accepts at most one path or opaque handle');const status=readJson(join(dir,'history-status.json'));if(status.status!=='captured'||!fs.existsSync(join(dir,'history.txt')))throw new CsoError('MISSING_INPUT',status.gap||'Historical evidence was not retained');const raw=fs.readFileSync(join(dir,'history.txt'),'utf8');if(!selected){emit(raw);return;}
|
||||
const displayPath=publicSnapshotPath(manifest,selected).displayPath??selected,retained=historyForPath(raw,displayPath);emit(retained??`No retained patch hunks for ${displayPath}`);}
|
||||
function resume(args:string[]){const {dir}=run(args);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'));if(report.status==='finished')throw new CsoError('INVALID_SCHEMA','A finished audit cannot be resumed');assertSnapshot(dir,manifest);for(const message of recoveryEvents(dir))if(!report.events.some(e=>e.kind==='watchdog-recovery'&&e.message===message))event(report,'watchdog-recovery',message);if(Date.now()>=Date.parse(report.deadline)){report.status='interrupted';event(report,'deadline','Original budget is exhausted; resume did not replenish it');saveReport(dir,report);throw new CsoError('DEADLINE','Original run budget is exhausted');}report.status='running';event(report,'resume','Continued retained snapshot under original policy');saveReport(dir,report);return {runId:report.runId,deadline:report.deadline,policy:report.policy,recovery:recoveryEvents(dir)};});}
|
||||
function importV2(args:string[]){if(args.length!==1)throw new CsoError('INVALID_ARGUMENT','import-v2 requires one report file');const legacy=sanitizeForJson(importLegacy(readInput(args[0]))) as ReturnType<typeof importLegacy>,id=sha256(JSON.stringify(legacy)),dir=secureDirectory(join(privateRoot(),'legacy-imports'));writeJson(join(dir,`${id}.json`),legacy);return {id,path:`legacy-imports/${id}.json`,warning:legacy.warning};}
|
||||
function importV2(args:string[]){if(args.length!==1)throw new CsoError('INVALID_ARGUMENT','import-v2 requires one report file');const legacy=sanitizeForJson(importLegacy(readInput(args[0]))) as ReturnType<typeof importLegacy>,id=sha256(JSON.stringify(legacy)),dir=secureDirectory(join(privateRoot(),'legacy-imports'));writeJson(join(dir,`${id}.json`),legacy);return {id,path:`legacy-imports/${id}.json`,warning:legacy.warning,report:legacy};}
|
||||
function inspectV2(args:string[]){if(args.length!==1||!/^[a-f0-9]{64}$/.test(args[0]??''))throw new CsoError('INVALID_ARGUMENT','inspect-v2 requires the 64-character import ID');const id=args[0],file=join(secureDirectory(join(privateRoot(),'legacy-imports')),`${id}.json`);if(!fs.existsSync(file))throw new CsoError('MISSING_INPUT','Legacy report import was not found or expired');const report=readJson(file);if(report?.schemaVersion!==2||report?.readOnly!==true||!Array.isArray(report?.findings))throw new CsoError('INCOMPATIBLE_INPUT','Stored legacy report is incompatible');if(sha256(JSON.stringify(report))!==id)throw new CsoError('INCOMPATIBLE_INPUT','Stored legacy report identity is inconsistent');return{id,report};}
|
||||
async function scanner(args:string[],sarif=false){
|
||||
const {dir}=run(args);
|
||||
if(sarif?args.length!==1:(args.length<1||args.length>2||!SCANNER_IDS.includes(args[0] as ScannerId)))throw new CsoError('INVALID_ARGUMENT',sarif?'import-sarif needs one file':'scan requires a supported scanner ID and optional request JSON');
|
||||
@@ -497,7 +499,7 @@ async function replay(args:string[],dependencies:CsoCliDependencies){const repla
|
||||
export async function dispatchCsoCommand(command:string,args:string[],dependencies:CsoCliDependencies):Promise<unknown>{
|
||||
args=[...args];if(!['start','recheck','replay','doctor','provision-images'].includes(command)){const started=Date.now();retention(started,{deadlineMs:started+RETENTION_MAINTENANCE_MS,maxEntries:RETENTION_MAX_ENTRIES});}
|
||||
let result:unknown;
|
||||
switch(command){case'start':result=await start(args,dependencies);break;case'doctor':result=await doctor(args,dependencies);break;case'provision-images':result=await provisionImages(args,dependencies);break;case'resume':result=resume(args);break;case'inspect':await inspect(args);return;case'read':await read(args);return;case'history':await history(args);return;case'submit':result=submit(args);break;case'finish':result=finish(args);break;case'import-v2':result=importV2(args);break;case'scan':result=await scanner(args);break;case'scanner-outcome':result=scannerOutcome(args);break;case'import-sarif':result=await scanner(args,true);break;case'record-review':result=recordReview(args);break;case'test-plan':result=testPlan(args);break;case'runtime-plan':result=runtimePlan(args);break;case'recheck':result=await recheck(args,dependencies);break;
|
||||
switch(command){case'start':result=await start(args,dependencies);break;case'doctor':result=await doctor(args,dependencies);break;case'provision-images':result=await provisionImages(args,dependencies);break;case'resume':result=resume(args);break;case'inspect':await inspect(args);return;case'read':await read(args);return;case'history':await history(args);return;case'submit':result=submit(args);break;case'finish':result=finish(args);break;case'import-v2':result=importV2(args);break;case'inspect-v2':result=inspectV2(args);break;case'scan':result=await scanner(args);break;case'scanner-outcome':result=scannerOutcome(args);break;case'import-sarif':result=await scanner(args,true);break;case'record-review':result=recordReview(args);break;case'test-plan':result=testPlan(args);break;case'runtime-plan':result=runtimePlan(args);break;case'recheck':result=await recheck(args,dependencies);break;
|
||||
case'verify':result=await verify(args,dependencies);break;case'replay':result=await replay(args,dependencies);break;case'patch-hash':if(args.length!==1)throw new CsoError('INVALID_ARGUMENT','patch-hash requires one request JSON file');result={patchHash:patchHash(validateVerificationRequest(readInput(args[0])))};break;default:throw new CsoError('INVALID_ARGUMENT',`Unknown command: ${command}`);}
|
||||
return result;
|
||||
}
|
||||
|
||||
+26
-1
@@ -3,6 +3,8 @@ import { createHash } from 'node:crypto';
|
||||
|
||||
export const ABI = 3;
|
||||
export const MAX_OUTPUT = 1024 * 1024;
|
||||
const UNSAFE_STRING_CONTROLS=/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/u;
|
||||
const UNSAFE_PROPERTY_CONTROLS=/[\x00-\x1f\x7f-\x9f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/u;
|
||||
export type Completeness = 'complete' | 'partial' | 'not assessed';
|
||||
export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'informational';
|
||||
export type ErrorCode = 'INVALID_ARGUMENT' | 'INVALID_SCHEMA' | 'MISSING_INPUT' | 'SNAPSHOT_RACE' |
|
||||
@@ -150,7 +152,7 @@ function boolean(value:unknown,name:string):boolean{
|
||||
if(typeof value!=='boolean')throw new CsoError('INVALID_SCHEMA',`${name} must be a boolean`);return value;
|
||||
}
|
||||
export function string(value: unknown, name: string, max = 8192): string {
|
||||
if (typeof value !== 'string' || !value.trim() || value.length > max || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u.test(value)) throw new CsoError('INVALID_SCHEMA', `${name} must be a nonempty string without unsafe control characters (maximum ${max})`);
|
||||
if (typeof value !== 'string' || !value.trim() || value.length > max || UNSAFE_STRING_CONTROLS.test(value)) throw new CsoError('INVALID_SCHEMA', `${name} must be a nonempty string without unsafe control characters (maximum ${max})`);
|
||||
return value;
|
||||
}
|
||||
export function strings(value: unknown, name: string): string[] {
|
||||
@@ -343,6 +345,28 @@ export function renderReport(report: RunReportV3): string {
|
||||
]), '',
|
||||
].join('\n');
|
||||
}
|
||||
type LegacyJson = null | boolean | number | string | LegacyJson[] | { [key:string]: LegacyJson };
|
||||
function legacyJson(value:unknown,depth=0,seen=new WeakSet<object>()):LegacyJson{
|
||||
if(depth>32)throw new CsoError('INVALID_SCHEMA','Legacy report nesting is too deep');
|
||||
if(value===null||typeof value==='boolean')return value;
|
||||
if(typeof value==='number'){if(!Number.isFinite(value))throw new CsoError('INVALID_SCHEMA','Legacy report numbers must be finite');return value;}
|
||||
if(typeof value==='string'){
|
||||
if(value.length>MAX_OUTPUT||UNSAFE_STRING_CONTROLS.test(value))throw new CsoError('INVALID_SCHEMA','Legacy report strings must be bounded and free of unsafe control characters');
|
||||
return value;
|
||||
}
|
||||
if(!value||typeof value!=='object')throw new CsoError('INVALID_SCHEMA','Legacy report contains a non-JSON value');
|
||||
if(seen.has(value))throw new CsoError('INVALID_SCHEMA','Legacy report cannot be cyclic');seen.add(value);
|
||||
try{
|
||||
if(Array.isArray(value)){
|
||||
if(value.length>10_000)throw new CsoError('INVALID_SCHEMA','Legacy report array is too large');
|
||||
return value.map(item=>legacyJson(item,depth+1,seen));
|
||||
}
|
||||
const entries=Object.entries(value as Record<string,unknown>);if(entries.length>10_000)throw new CsoError('INVALID_SCHEMA','Legacy report object is too large');
|
||||
const out:Record<string,LegacyJson>=Object.create(null);
|
||||
for(const [key,item] of entries){if(key.length>1024||['__proto__','prototype','constructor'].includes(key)||UNSAFE_PROPERTY_CONTROLS.test(key))throw new CsoError('INVALID_SCHEMA','Legacy report contains an unsafe property');out[key]=legacyJson(item,depth+1,seen);}
|
||||
return out;
|
||||
}finally{seen.delete(value);}
|
||||
}
|
||||
export function importLegacy(input: unknown): { schemaVersion: 2; readOnly: true; findings: any[]; warning: string } {
|
||||
const v = object(input,'legacy report');
|
||||
if (!Array.isArray(v.findings) || ![2,'2','2.0','2.0.0'].includes(v.schemaVersion ?? v.schema_version ?? v.version)) throw new CsoError('INVALID_SCHEMA','Expected a v2 report with findings');
|
||||
@@ -352,5 +376,6 @@ export function importLegacy(input: unknown): { schemaVersion: 2; readOnly: true
|
||||
status:typeof f.status==='string'?string(f.status,'legacy status'): 'unknown',
|
||||
...(typeof f.severity==='string'?{severity:string(f.severity,'legacy severity')}:{ }),
|
||||
...(typeof f.description==='string'?{description:string(f.description,'legacy description')}:{ }),
|
||||
legacy:legacyJson(f),
|
||||
evidence:'legacy_review', reproduction:'not_attempted', repair:'not_attempted', closure:'unknown'};}) };
|
||||
}
|
||||
+1
-1
@@ -39,7 +39,7 @@ export function redact(value: string): string {
|
||||
const HASH_KEYS=new Set(['planSha256','planHash','originalHash','executionHash','snapshotHash','sourceHash','beforeSha256','afterSha256','patchHash','reviewedPatchHash','harnessHash','fixturesHash','policyHash','auditPolicyHash','originalSourceHash','transformationsHash','archivesHash','inputHash','beforeSourceHash','afterSourceHash','beforeDependencies','afterDependencies','beforeConfiguration','afterConfiguration','requestHash','startPlanHash','testPlanHash','preparationHash','preparedManifestHash','preparedDependencyHash','sourceProjectionHash','executionEnvironmentHash','databaseHash','receiptHash','dependencyClosureHash','closureHash','acquisitionReceiptHash','registryResponseSha256','sha256','versionOutputSha256','isolationPolicyHash','contentSha256','sbomDigest','provenanceDigest','dependencyHash','configurationHash','assertionHash','commandsHash','minimumPassingTestsHash','commandHash','outputHash','observationHash','witnessHash','keyId']);
|
||||
function safeMetadata(value:string,key:string):boolean{
|
||||
if(HASH_KEYS.has(key)&&/^[a-f0-9]{64}$/.test(value))return true;
|
||||
if(['id','fingerprint','findingId','verificationId','reproductionAttemptId','artifactId','bundleId','pathId'].includes(key)&&/^[a-f0-9]{32}$/.test(value))return true;
|
||||
if(['id','fingerprint','findingId','verificationId','reproductionAttemptId','artifactId','reviewArtifactId','bundleId','pathId'].includes(key)&&/^[a-f0-9]{32}$/.test(value))return true;
|
||||
if(key==='path'&&/^@cso-path\/\/[a-f0-9]{32}$/.test(value))return true;
|
||||
if(key==='repoId'&&/^[a-f0-9]{24}$/.test(value))return true;
|
||||
if(key==='runId'&&/^\d{13}-[a-f0-9]{16}$/.test(value))return true;
|
||||
|
||||
@@ -18,6 +18,20 @@ describe('compiled CSO command workflow',()=>{
|
||||
test('package-manager launcher symlinks resolve the trusted sibling core',()=>{const npmBin=path.join(root,'npm-bin');fs.mkdirSync(npmBin,{recursive:true});const link=path.join(npmBin,'gstack-cso');fs.symlinkSync(launcher,link);const r=command(['--version'],{},link);expect(r.status).toBe(0);expect(JSON.parse(r.stdout)).toEqual({version:'3.0.0',abi:3});});
|
||||
test.skipIf(process.platform==='win32')('compiled core starts in the canonical trusted launcher directory',()=>{const install=path.join(root,'trusted launcher'),hostile=path.join(repo,'hostile cwd');fs.mkdirSync(install);fs.mkdirSync(hostile);const publicLauncher=path.join(install,'gstack-cso-launcher'),fixtureCore=path.join(install,'gstack-cso-core'),source=path.join(root,'cwd-fixture.c');fs.writeFileSync(source,'#include <stdio.h>\n#include <unistd.h>\n#include <limits.h>\nint main(void){char cwd[PATH_MAX];if(!getcwd(cwd,sizeof cwd))return 2;puts(cwd);return 0;}\n');const built=spawnSync('/usr/bin/cc',[source,'-o',fixtureCore],{encoding:'utf8',timeout:30_000});expect(built.status).toBe(0);const digest=createHash('sha256').update(fs.readFileSync(fixtureCore)).digest('hex'),launcherBuild=spawnSync('/usr/bin/cc',['-std=c11','-D_POSIX_C_SOURCE=200809L','-O2',`-DGSTACK_CSO_CORE_SHA256=\"${digest}\"`,path.join(ROOT,'lib/cso/launcher.c'),'-o',publicLauncher],{encoding:'utf8',timeout:30_000});expect(launcherBuild.status).toBe(0);fs.writeFileSync(path.join(install,'.gstack-cso-generation'),`${digest}\n`);const r=spawnSync(publicLauncher,[],{cwd:hostile,encoding:'utf8',env:{...process.env,HOME:root,GSTACK_HOME:state},timeout:30_000});expect(r.status).toBe(0);expect(r.stdout.trim()).toBe(fs.realpathSync(install));expect(r.stdout.trim()).not.toBe(fs.realpathSync(hostile));});
|
||||
test('schema advertises only caller-owned verification input',()=>{const r=command(['schema']);expect(r.status).toBe(0);const schema=JSON.parse(r.stdout);expect(schema.verification).not.toHaveProperty('result');expect(schema.helperOwned).toContain('repair result and label');});
|
||||
test('v2 imports remain readable and immutable without upgrading legacy proof',()=>{
|
||||
const source=path.join(root,'legacy-v2.json'),legacy={schemaVersion:2,findings:[{status:'VERIFIED',title:'Historical authorization finding',description:`Captured credential ${CREDENTIAL_CANARY}`,fingerprint:'legacy-fingerprint',location:{path:'src/legacy.ts',line:17,symbol:'legacyAuthorization'},impact:'Cross-tenant disclosure',recommendation:'Bind the lookup to the authenticated tenant',repair:'VERIFIED'}]};
|
||||
fs.writeFileSync(source,JSON.stringify(legacy));
|
||||
const imported=command(['import-v2',source]);expect(imported.status).toBe(0);expect(imported.stdout+imported.stderr).not.toContain(CREDENTIAL_CANARY);const result=JSON.parse(imported.stdout),finding=result.report.findings[0];
|
||||
expect(result.id).toMatch(/^[a-f0-9]{64}$/);expect(result.warning).toContain('does not establish reproduction, tested repair, or closure');expect(result.report).toMatchObject({schemaVersion:2,readOnly:true});
|
||||
expect(finding).toMatchObject({status:'VERIFIED',evidence:'legacy_review',reproduction:'not_attempted',repair:'not_attempted',closure:'unknown',legacy:{fingerprint:'legacy-fingerprint',location:{path:'src/legacy.ts',line:17,symbol:'legacyAuthorization'},impact:'Cross-tenant disclosure',recommendation:'Bind the lookup to the authenticated tenant',repair:'VERIFIED'}});
|
||||
fs.writeFileSync(source,JSON.stringify({schemaVersion:2,findings:[]}));
|
||||
const inspected=command(['inspect-v2',result.id]);expect(inspected.status).toBe(0);expect(inspected.stdout+inspected.stderr).not.toContain(CREDENTIAL_CANARY);expect(JSON.parse(inspected.stdout)).toEqual({id:result.id,report:result.report});
|
||||
const stored=path.join(state,'security','cso','legacy-imports',`${result.id}.json`);expect(fs.existsSync(stored)).toBe(true);expect(fs.readFileSync(stored,'utf8')).not.toContain(CREDENTIAL_CANARY);
|
||||
const invalid=command(['inspect-v2','../legacy-v2.json']);expect(invalid.status).not.toBe(0);expect(invalid.stderr).toContain('64-character import ID');
|
||||
const missing=command(['inspect-v2','0'.repeat(64)]);expect(missing.status).not.toBe(0);expect(missing.stderr).toContain('MISSING_INPUT');
|
||||
const beforeUnsafe=fs.readdirSync(path.dirname(stored)).length,unsafeKey=`severity\u202eHIGH`;fs.writeFileSync(source,JSON.stringify({schemaVersion:2,findings:[{status:'OPEN',title:'unsafe',nested:{[unsafeKey]:'forged'}}]}));const unsafe=command(['import-v2',source]);expect(unsafe.status).not.toBe(0);expect(unsafe.stderr).toContain('unsafe property');expect(unsafe.stdout+unsafe.stderr).not.toContain(unsafeKey);expect(fs.readdirSync(path.dirname(stored))).toHaveLength(beforeUnsafe);
|
||||
const changed=JSON.parse(fs.readFileSync(stored,'utf8'));changed.findings[0].status='CHANGED';fs.writeFileSync(stored,JSON.stringify(changed,null,2)+'\n');const tampered=command(['inspect-v2',result.id]);expect(tampered.status).not.toBe(0);expect(tampered.stderr).toContain('identity is inconsistent');
|
||||
});
|
||||
test('wallet-shaped source paths remain addressable through stable opaque handles',()=>{
|
||||
const walletRepo=path.join(root,'wallet-path-repo'),wallet='0x1234567890abcdef1234567890abcdef12345678',sourcePath=`src/${wallet}.ts`;
|
||||
fs.mkdirSync(path.join(walletRepo,'src'),{recursive:true});
|
||||
|
||||
@@ -31,7 +31,8 @@ describe('CSO evidence contracts',()=>{
|
||||
test('readable reports include substantiating detail and explicit proof assurances',()=>{const supported=validateFinding({...finding,title:'Cross tenant\n# FORGED SECTION',scenario:'Caller sends `id`\n- forged bullet'});supported.repair='tested';supported.verificationId='a'.repeat(32);supported.verificationAssurance={assertions:'authenticated_out_of_process',testCompletion:'authenticated_out_of_process',review:'self_attested'};const report:any={schemaVersion:3,runId:'run',repoId:'repo',createdAt:'2026-01-01',deadline:'2026-01-01',status:'finished',completeness:'complete',policy:{mode:'daily',scope:'auth',diff:false,base:'main',offline:true,budgetSeconds:600,maxWorkers:3,maxRepairs:3},source:{root:'/x',snapshotHash:'s',originalHash:'o'},application:{actors:['tenant user'],assets:['records'],entrypoints:['GET /users/:id'],tenantBoundaries:['tenant_id'],sensitiveOperations:['read'],invariants:['tenant isolation']},coverage:[{domain:'auth',scope:'all',status:'assessed',method:'caller trace',gaps:[],exclusions:[],evidence:['src/users.ts:4']}],findings:[supported],gaps:[],events:[]},markdown=renderReport(report);expect(markdown).toContain('Application model:');expect(markdown).toContain('Attacker scenario:');expect(markdown).toContain('Supporting references:');expect(markdown).toContain('Repair recommendation:');expect(markdown).toContain(`Bundle: bundles/${'a'.repeat(32)}.json. Assertion assurance: authenticated\\_out\\_of\\_process. Test completion assurance: authenticated\\_out\\_of\\_process. Review assurance: self\\_attested.`);expect(markdown).not.toContain('\n# FORGED SECTION');expect(markdown).not.toContain('\n- forged bullet');});
|
||||
test('readable reports reject and strip bidirectional or terminal control forgery',()=>{expect(()=>validateFinding({...finding,title:'Safe \u202e [HIGH forged]'})).toThrow('unsafe control characters');const supported=validateFinding(finding);supported.title='Safe \u202e [HIGH forged] \u0085';const report:any={schemaVersion:3,runId:'run',repoId:'repo',createdAt:'2026-01-01',deadline:'2026-01-01',status:'finished',completeness:'complete',policy:{mode:'daily',scope:'auth',diff:false,base:'main',offline:true,budgetSeconds:600,maxWorkers:3,maxRepairs:3},source:{root:'/x',snapshotHash:'s',originalHash:'o'},application:{actors:[],assets:[],entrypoints:[],tenantBoundaries:[],sensitiveOperations:[],invariants:[]},coverage:[{domain:'auth',scope:'all',status:'assessed',method:'trace',gaps:[],exclusions:[],evidence:['route']}],findings:[supported],gaps:[],events:[]},markdown=renderReport(report);expect(markdown).not.toMatch(/[\u202e\u0085]/u);expect(markdown).toContain('Safe \\[HIGH forged\\]');});
|
||||
test('optional scanner failure is operational evidence, not a second assessment domain',()=>{const report:any={coverage:[{domain:'dependencies',scope:'all',status:'assessed',method:'manual reachability analysis',gaps:[],exclusions:[],evidence:['lock and callers']},{domain:'scanner:osv',scope:'all',status:'not_assessed',method:'qualified scanner',gaps:['catalog unavailable'],exclusions:[],evidence:[],tool:{name:'osv',version:'unavailable',freshness:'unknown',outcome:'not_assessed'}}],gaps:[]};expect(completeness(report)).toBe('complete');});
|
||||
test('v2 VERIFIED imports as read-only legacy evidence, never proof',()=>{const v=importLegacy({schemaVersion:2,findings:[{status:'VERIFIED',title:'old'}]});expect(v.readOnly).toBe(true);expect(v.findings[0]).toMatchObject({evidence:'legacy_review',reproduction:'not_attempted',repair:'not_attempted',closure:'unknown'});});
|
||||
test('v2 VERIFIED imports as readable legacy evidence, never proof',()=>{const original={status:'VERIFIED',title:'old',fingerprint:'legacy-fingerprint',location:{path:'src/old.ts',line:7},impact:'historical impact',recommendation:'historical repair',repair:'VERIFIED'};const v=importLegacy({schemaVersion:2,findings:[original]});expect(v.readOnly).toBe(true);expect(v.findings[0]).toMatchObject({evidence:'legacy_review',reproduction:'not_attempted',repair:'not_attempted',closure:'unknown',legacy:original});});
|
||||
test('legacy reports reject display controls in preserved values and property names',()=>{for(const finding of [{status:'OPEN',title:'old',nested:{value:'line\u2028forgery'}},{status:'OPEN',title:'old',nested:{['severity\u202eHIGH']:'forged'}}])expect(()=>importLegacy({schemaVersion:2,findings:[finding]})).toThrow(/unsafe (?:control characters|property)/);});
|
||||
test('Docker admission is machine-wide and supervised slots survive owner deadlines until cleanup',()=>{const endpoint=`unix:///tmp/cso-${process.pid}-${Date.now()}.sock`,old=process.env.GSTACK_HOME,a=admit(endpoint,'a',Date.now()-1);markSupervised(a);process.env.GSTACK_HOME=tmp();const b=admit(endpoint,'b',Date.now()+60_000);try{expect(()=>admit(endpoint,'c',Date.now()+60_000)).toThrow('Two reproduction groups');}finally{release(a);release(b);if(old===undefined)delete process.env.GSTACK_HOME;else process.env.GSTACK_HOME=old;}});
|
||||
test('wallet-shaped random lease capabilities remain byte-identical protocol metadata',()=>{const endpoint=`unix:///tmp/cso-wallet-token-${process.pid}-${Date.now()}.sock`,token='1abcdefabcdefabcdefabcdefabcdefa',random=crypto.randomBytes,patched=spyOn(crypto,'randomBytes').mockImplementation(((size:number)=>size===16?Buffer.from(token,'hex'):random(size)) as typeof crypto.randomBytes);let lease:ReturnType<typeof admit>|undefined;try{lease=admit(endpoint,'wallet-token',Date.now()+60_000);expect(lease.token).toBe(token);expect(JSON.parse(fs.readFileSync(path.join(lease.path,'lease.json'),'utf8')).token).toBe(token);expect(fs.readFileSync(path.join(lease.path,'lease.token'),'utf8').trim()).toBe(token);markSupervised(lease);expect(JSON.parse(fs.readFileSync(path.join(lease.path,'lease.json'),'utf8')).token).toBe(token);}finally{patched.mockRestore();if(lease)release(lease);}});
|
||||
test('a paused initializer cannot have its incomplete slot reclaimed or overwritten',()=>{
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('CSO v3 always-loaded policy', () => {
|
||||
expect(text).toContain(flag);
|
||||
}
|
||||
expect(text).toContain('mutually exclusive');
|
||||
for (const command of ['start', 'doctor', 'resume', 'replay', 'recheck', 'inspect', 'read', 'history', 'scan', 'import-sarif', 'submit', 'verify', 'finish', 'import-v2', 'schema']) {
|
||||
for (const command of ['start', 'doctor', 'resume', 'replay', 'recheck', 'inspect', 'read', 'history', 'scan', 'import-sarif', 'submit', 'verify', 'finish', 'import-v2', 'inspect-v2', 'schema']) {
|
||||
expect(text).toContain(`gstack-cso ${command}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -41,8 +41,9 @@ function fixture(){
|
||||
|
||||
describe('CSO snapshot source-disappearance races',()=>{
|
||||
// Bun on Windows does not patch the node:fs binding imported by snapshot.ts,
|
||||
// so these syscall-choreography fixtures cannot activate there. Production
|
||||
// snapshot and helper behavior remains covered by the native Windows suites.
|
||||
// so these syscall-choreography fixtures cannot activate there. The native
|
||||
// Windows launcher suite instead mutates real source after capture history is
|
||||
// recorded and requires start to fail closed without a snapshot or report.
|
||||
test.skipIf(process.platform==='win32')('rejects a tracked path that disappears at its capture lstat and is restored before final membership validation',async()=>{
|
||||
const {repo,runDir,tracked,trackedIdentity,parked}=fixture(),lstat=fs.lstatSync;
|
||||
let trackedLstats=0,injected=false,failure:unknown;
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('private state and process output',()=>{
|
||||
test('alternating stdout and stderr chunks cannot split a secret marker',async()=>{const dir=tmp(),r=await runProcess('/bin/sh',['-c',"printf g; sleep .02; printf hp_abcdefghijklmnop >&2; sleep .02; printf qrstuvwxyz1234567890"],{cwd:dir,env:{PATH:'/usr/bin:/bin'}});expect(r.stdout+r.stderr).not.toContain('abcdefghijklmnop');expect(r.stdout.toLowerCase()).toContain('redacted');});
|
||||
test('marker-only private keys withhold the whole payload',async()=>{const dir=tmp();expect(runProcess('/bin/sh',['-c',"printf '%s' \"$1\"",'sh',['-----BEGIN ','PRIVATE KEY-----\nraw\n-----END ','PRIVATE KEY-----'].join('')],{cwd:dir,env:{PATH:'/usr/bin:/bin'}})).rejects.toMatchObject({code:'REDACTION_FAILED'});});
|
||||
test('untrusted hash-like keys do not bypass structured redaction',()=>{const dir=tmp(),file=path.join(dir,'value.json');writeJson(file,{payloadHash:['ghp_','abcdefghijklmnopqrstuvwxyz1234567890'].join('')});expect(fs.readFileSync(file,'utf8')).not.toContain('ghp_');});
|
||||
test('only schema-bound helper metadata bypasses content redaction',()=>{const token='1abcdefabcdefabcdefabcdefabcdefa',hash='a'.repeat(64),untrusted=sanitizeForJson({id:token,findingId:token,sourceHash:hash}) as any;expect(untrusted.id).not.toBe(token);expect(untrusted.findingId).not.toBe(token);const trusted=sanitizeHelperForJson({id:token,findingId:token,artifactId:token,bundleId:token,sourceHash:hash,preparedManifestHash:hash}) as any;expect(trusted).toEqual({id:token,findingId:token,artifactId:token,bundleId:token,sourceHash:hash,preparedManifestHash:hash});const dir=tmp(),file=path.join(dir,'claim.json');writeHelperJson(file,{findingId:token,outcome:'open'});expect(readJson(file)).toEqual({findingId:token,outcome:'open'});});
|
||||
test('only schema-bound helper metadata bypasses content redaction',()=>{const token='1abcdefabcdefabcdefabcdefabcdefa',hash='a'.repeat(64),untrusted=sanitizeForJson({id:token,findingId:token,sourceHash:hash}) as any;expect(untrusted.id).not.toBe(token);expect(untrusted.findingId).not.toBe(token);const trusted=sanitizeHelperForJson({id:token,findingId:token,artifactId:token,reviewArtifactId:token,bundleId:token,sourceHash:hash,preparedManifestHash:hash}) as any;expect(trusted).toEqual({id:token,findingId:token,artifactId:token,reviewArtifactId:token,bundleId:token,sourceHash:hash,preparedManifestHash:hash});const dir=tmp(),file=path.join(dir,'claim.json');writeHelperJson(file,{findingId:token,outcome:'open'});expect(readJson(file)).toEqual({findingId:token,outcome:'open'});});
|
||||
test('oversized report growth is rejected before replacing the last readable report',()=>{const dir=tmp(),report:any={schemaVersion:3,runId:'1'.repeat(13)+'-'+'a'.repeat(16),repoId:'b'.repeat(24),createdAt:new Date().toISOString(),deadline:new Date(Date.now()+60_000).toISOString(),status:'running',completeness:'not assessed',policy:{mode:'daily',scope:'default',diff:false,base:'origin/main',offline:true,budgetSeconds:600,maxWorkers:3,maxRepairs:3},source:{root:'/source',snapshotHash:'c'.repeat(64),originalHash:'d'.repeat(64)},application:{actors:[],assets:[],entrypoints:[],tenantBoundaries:[],sensitiveOperations:[],invariants:[]},coverage:[],findings:[],gaps:[],events:[]};saveReport(dir,report);const original=fs.readFileSync(path.join(dir,'report.json'),'utf8');report.events=Array.from({length:4000},(_,i)=>({at:new Date().toISOString(),kind:'evidence',message:`${i}:`+'x'.repeat(300)}));expect(()=>saveReport(dir,report)).toThrow('previous artifact was preserved');expect(fs.readFileSync(path.join(dir,'report.json'),'utf8')).toBe(original);expect(loadReport(dir).status).toBe('running');});
|
||||
test('a fully published dead legacy owner migrates to the immutable lease protocol',()=>{const dir=tmp(),lock=path.join(dir,'.mutation-lock');fs.mkdirSync(lock);fs.writeFileSync(path.join(lock,'owner.json'),JSON.stringify({pid:2147483647,token:'old',createdAt:0,expiresAt:0}));expect(withLock(dir,()=>42)).toBe(42);expect(fs.lstatSync(lock).isFile()).toBe(true);expect(JSON.parse(fs.readFileSync(lock,'utf8')).protocol).toBe('immutable-lease-set-v3');expect(fs.readdirSync(path.join(dir,'.mutation-lock-leases'))).toEqual([]);});
|
||||
test('a dead legacy migration claim is recovered after an interrupted migration',()=>{const dir=tmp(),lock=path.join(dir,'.mutation-lock'),claim=path.join(lock,'.v3-migration'),token='b'.repeat(32);fs.mkdirSync(lock);fs.writeFileSync(path.join(lock,'owner.json'),JSON.stringify({pid:2147483647,token:'old',createdAt:0}));fs.writeFileSync(claim,JSON.stringify({pid:2147483647,token,createdAt:0})+'\n',{mode:0o600,flag:'wx'});expect(withLock(dir,()=>42)).toBe(42);expect(fs.lstatSync(lock).isFile()).toBe(true);expect(fs.readdirSync(path.join(dir,'.mutation-lock-leases'))).toEqual([]);expect(fs.readdirSync(dir).some(name=>name.includes('.legacy-'))).toBe(false);});
|
||||
|
||||
@@ -91,6 +91,22 @@ function expectSuccessfulProcess(result: ReturnType<typeof spawnSync>, label: st
|
||||
})}`);
|
||||
}
|
||||
|
||||
function filesNamed(root: string, name: string): string[] {
|
||||
const found: string[] = [];
|
||||
const walk = (directory: string) => {
|
||||
let entries: fs.Dirent[];
|
||||
try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { return; }
|
||||
for (const entry of entries) {
|
||||
const candidate = path.join(directory, entry.name);
|
||||
if (entry.isSymbolicLink()) continue;
|
||||
if (entry.isDirectory()) walk(candidate);
|
||||
else if (entry.isFile() && entry.name === name) found.push(candidate);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return found;
|
||||
}
|
||||
|
||||
describe('CSO native Windows build contract', () => {
|
||||
test('Windows builds use MSVC with a static CRT and no Bun-hosted public launcher', () => {
|
||||
const build = fs.readFileSync(path.join(ROOT, 'scripts/build-cso.sh'), 'utf8');
|
||||
@@ -285,4 +301,45 @@ describe('CSO native Windows build contract', () => {
|
||||
const doctor=spawnSync(actual,['doctor','--repo',repository],{cwd:repository,encoding:'utf8',env,timeout:30_000});expect(doctor.status).toBe(0);expect(JSON.parse(doctor.stdout).downloads).toBe(false);
|
||||
const started=spawnSync(actual,['start','--repo',repository,'--offline'],{cwd:repository,encoding:'utf8',env,timeout:30_000});expectSuccessfulProcess(started,'gstack-cso start');expect(JSON.parse(started.stdout).schemaVersion).toBe(3);expect(fs.existsSync(path.join(profile,'.gstack','security','cso'))).toBe(true);
|
||||
});
|
||||
|
||||
test('the actual helper rejects source mutation during snapshot capture without certifying a report', async () => {
|
||||
const repository=path.join(temporary,'racing repository'),profile=path.join(temporary,'race profile'),padding=path.join(repository,'padding'),target=path.join(repository,'zzzz-race-target.js');
|
||||
fs.mkdirSync(repository);fs.mkdirSync(profile);fs.mkdirSync(padding);
|
||||
const git='C:\\Program Files\\Git\\cmd\\git.exe',gitEnv={...process.env,HOME:profile};
|
||||
for(const args of [['init','-q'],['config','user.email','fixture@example.test'],['config','user.name','Fixture']] as string[][]){const result=spawnSync(git,args,{cwd:repository,encoding:'utf8',env:gitEnv,timeout:10_000});expect(result.status).toBe(0);}
|
||||
const sourceBytes=128*1024,stable=Buffer.alloc(sourceBytes,0x61),changed=Buffer.alloc(sourceBytes,0x62);
|
||||
fs.writeFileSync(target,stable);
|
||||
for(let index=0;index<192;index++)fs.writeFileSync(path.join(padding,`${String(index).padStart(4,'0')}.js`),stable);
|
||||
for(const args of [['add',path.basename(target)],['commit','-qm','fixture']] as string[][]){const result=spawnSync(git,args,{cwd:repository,encoding:'utf8',env:gitEnv,timeout:30_000});expect(result.status).toBe(0);}
|
||||
|
||||
const actual=path.join(ROOT,'bin','gstack-cso-launcher.exe'),env={...process.env,HOME:'',GSTACK_HOME:'',CLAUDE_PLUGIN_ROOT:'',CLAUDE_PLUGIN_DATA:'',USERPROFILE:profile,PATH:temporary},state=path.join(profile,'.gstack','security','cso');
|
||||
const child=spawn(actual,['start','--repo',repository,'--offline'],{cwd:repository,env,stdio:['ignore','pipe','pipe']});
|
||||
let stdout='',stderr='',closed=false;
|
||||
child.stdout.on('data',chunk=>{stdout=(stdout+String(chunk)).slice(-8192);});
|
||||
child.stderr.on('data',chunk=>{stderr=(stderr+String(chunk)).slice(-8192);});
|
||||
const terminal=new Promise<{code:number|null;error?:string}>(resolve=>{
|
||||
child.once('error',error=>{closed=true;resolve({code:null,error:error.message});});
|
||||
child.once('close',code=>{closed=true;resolve({code});});
|
||||
});
|
||||
const markerDeadline=Date.now()+30_000;let mutations=0,outcome:{code:number|null;error?:string}|undefined;
|
||||
try{
|
||||
while(!closed&&!filesNamed(state,'history-status.json').length&&Date.now()<markerDeadline)await Bun.sleep(2);
|
||||
if(!filesNamed(state,'history-status.json').length)throw new Error('gstack-cso exited or timed out before reaching the bounded snapshot mutation point');
|
||||
while(!closed&&Date.now()<markerDeadline+15_000){
|
||||
try{fs.writeFileSync(target,changed);mutations++;}catch(error:any){if(!['EBUSY','EACCES','EPERM'].includes(error?.code))throw error;}
|
||||
await Bun.sleep(1);
|
||||
}
|
||||
if(!closed)throw new Error('gstack-cso did not finish after the injected snapshot mutation');
|
||||
outcome=await terminal;
|
||||
}finally{
|
||||
if(!closed){spawnSync('taskkill',['/PID',String(child.pid),'/T','/F'],{encoding:'utf8',timeout:10_000});await Promise.race([terminal,Bun.sleep(10_000)]);}
|
||||
}
|
||||
expect(mutations).toBeGreaterThan(0);
|
||||
expect(outcome?.error).toBeUndefined();
|
||||
expect(outcome?.code).not.toBe(0);
|
||||
expect(stderr).toContain('SNAPSHOT_RACE');
|
||||
expect(stdout).toBe('');
|
||||
expect(filesNamed(state,'snapshot.json')).toEqual([]);
|
||||
expect(filesNamed(state,'report.json')).toEqual([]);
|
||||
}, 90_000);
|
||||
});
|
||||
Reference in new issue
Block a user