diff --git a/cso/SKILL.md b/cso/SKILL.md index e423da3fd..e13d5bf82 100644 --- a/cso/SKILL.md +++ b/cso/SKILL.md @@ -85,6 +85,7 @@ gstack-cso record-review --producer gstack-cso verify gstack-cso finish gstack-cso import-v2 +gstack-cso inspect-v2 ``` **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//` 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. diff --git a/cso/SKILL.md.tmpl b/cso/SKILL.md.tmpl index bdf910fea..66f9b2469 100644 --- a/cso/SKILL.md.tmpl +++ b/cso/SKILL.md.tmpl @@ -85,6 +85,7 @@ gstack-cso record-review --producer gstack-cso verify gstack-cso finish gstack-cso import-v2 +gstack-cso inspect-v2 ``` **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//` 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. diff --git a/lib/cso/cli.ts b/lib/cso/cli.ts index 8e0eb08da..a6b07bed8 100644 --- a/lib/cso/cli.ts +++ b/lib/cso/cli.ts @@ -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,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,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{ 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; } diff --git a/lib/cso/contracts.ts b/lib/cso/contracts.ts index cea3ee894..d383f6c7c 100644 --- a/lib/cso/contracts.ts +++ b/lib/cso/contracts.ts @@ -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()):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);if(entries.length>10_000)throw new CsoError('INVALID_SCHEMA','Legacy report object is too large'); + const out:Record=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'};}) }; } diff --git a/lib/cso/process.ts b/lib/cso/process.ts index 789f7e97b..ff488ed74 100644 --- a/lib/cso/process.ts +++ b/lib/cso/process.ts @@ -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; diff --git a/test/cso-cli.test.ts b/test/cso-cli.test.ts index 343970994..c3d57acc2 100644 --- a/test/cso-cli.test.ts +++ b/test/cso-cli.test.ts @@ -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 \n#include \n#include \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}); diff --git a/test/cso-contracts.test.ts b/test/cso-contracts.test.ts index d19995a5c..666c01588 100644 --- a/test/cso-contracts.test.ts +++ b/test/cso-contracts.test.ts @@ -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|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',()=>{ diff --git a/test/cso-preserved.test.ts b/test/cso-preserved.test.ts index 24c758baa..044e58ffa 100644 --- a/test/cso-preserved.test.ts +++ b/test/cso-preserved.test.ts @@ -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}`); } }); diff --git a/test/cso-snapshot-disappearance-race.test.ts b/test/cso-snapshot-disappearance-race.test.ts index afd688806..866488790 100644 --- a/test/cso-snapshot-disappearance-race.test.ts +++ b/test/cso-snapshot-disappearance-race.test.ts @@ -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; diff --git a/test/cso-snapshot-state.test.ts b/test/cso-snapshot-state.test.ts index 88bc437f1..90d2b5a2e 100644 --- a/test/cso-snapshot-state.test.ts +++ b/test/cso-snapshot-state.test.ts @@ -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);}); diff --git a/test/cso-windows-launcher.test.ts b/test/cso-windows-launcher.test.ts index 8ad447527..efb92ec6c 100644 --- a/test/cso-windows-launcher.test.ts +++ b/test/cso-windows-launcher.test.ts @@ -91,6 +91,22 @@ function expectSuccessfulProcess(result: ReturnType, 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()