mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 15:57:33 +01:00
fix(cso): withhold excluded credential files from retained history; name failed witness assertions ASSERTION_FAILED
This commit is contained in:
1 parent
c205e226a1
commit
abae830649
7 files changed
+100
-7
No files matched your search
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**/cso's retained Git history no longer brings back credential files the snapshot leaves out, and a repair whose external assertions fail is now reported as `ASSERTION_FAILED` instead of a misleading `INCOMPATIBLE_INPUT`.** The helper keeps `.env`, `master.key`, keys and agent configuration out of the readable tree, but `history.txt` captured `git log -p` in full, so a committed `.env` came back through its diff whenever the value wasn't credential-shaped enough for the redactor. Paid qualification run 39186328569 caught this when the private corpus's planted `.env` value showed up in the judge's helper state.
|
||||
|
||||
### Fixed
|
||||
- **`history.txt`:** every diff section that touches a path the snapshot excludes (`exclusion()`, the same rule the readable tree uses) is replaced with `diff withheld: <path> (<reason>)`. This covers either side of a rename and any diff header that can't be parsed. Commit preambles and other files' diffs stay.
|
||||
- **`verify` with an authenticated witness:** a receipt or observation mismatch is still `INCOMPATIBLE_INPUT`. Receipts that bind correctly but record failed external assertions now raise `ASSERTION_FAILED`, with the phase and the booted, legitimate and security outcome. Nothing new is certified either way. In run 39186328569, three repairs that rejected the attack with a different status than the oracle expected surfaced as `INCOMPATIBLE_INPUT`.
|
||||
|
||||
## [1.91.147.0] - 2026-10-19
|
||||
|
||||
**The free-suite duration seeds are current again.** This is the first refresh from the weekly recorder. It adds 14 Linux and 6 Windows test files that the shard packers had been placing at the 99th-percentile estimate.
|
||||
|
||||
@@ -57,6 +57,29 @@ export function gitDiffHeaderPaths(line: string): [string, string] | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace every diff section touching a path `withheld` names with one marker line, so a file the snapshot keeps
|
||||
* out of the readable tree cannot come back through retained history. An unparseable diff header is withheld too.
|
||||
*/
|
||||
export function withholdHistory(raw: string, withheld: (path: string) => string | undefined): string {
|
||||
const output: string[] = [];
|
||||
let skipping = false;
|
||||
for (const line of raw.split('\n')) {
|
||||
if (line.startsWith('commit ')) skipping = false;
|
||||
else if (line.startsWith('diff --git ')) {
|
||||
const paths = gitDiffHeaderPaths(line)?.map((value) => value.replace(/^[ab]\//, '')),
|
||||
reason = paths ? paths.map(withheld).find(Boolean) : 'unparseable diff header';
|
||||
skipping = Boolean(reason);
|
||||
if (reason) {
|
||||
output.push(`diff withheld: ${paths?.[1] ?? 'unknown path'} (${reason})`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!skipping) output.push(line);
|
||||
}
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
/** Return only exact path hunks, keeping one commit preamble per matching commit. */
|
||||
export function historyForPath(raw: string, path: string): string | undefined {
|
||||
const expected = new Set([`a/${path}`, `b/${path}`]),
|
||||
|
||||
+2
-1
@@ -14,6 +14,7 @@ import {
|
||||
snapshotPathId,
|
||||
MAX_OUTPUT,
|
||||
} from './contracts';
|
||||
import { withholdHistory } from './history';
|
||||
import { childEnvironment, executable, git, redact, runProcess } from './process';
|
||||
import { boundedList, readJson, secureDirectory, writeHelperJson, writeJson } from './state';
|
||||
import { scan } from '../redact-engine';
|
||||
@@ -895,7 +896,7 @@ export async function capture(
|
||||
home,
|
||||
);
|
||||
guard();
|
||||
const safe = redact(raw);
|
||||
const safe = redact(withholdHistory(raw, exclusion));
|
||||
atomicWriteSync(join(runDir, 'history.txt'), safe, { mode: 0o600 });
|
||||
writeJson(join(runDir, 'history-status.json'), {
|
||||
status: 'captured',
|
||||
|
||||
+10
-3
@@ -1219,14 +1219,21 @@ export function certify(params: {
|
||||
beforeReceipt.observationHash !== witnessObservationHash(beforeObservation) ||
|
||||
afterReceipt.observationHash !== witnessObservationHash(afterObservation) ||
|
||||
params.before.existingTests !== beforeReceipt.diagnosticTestsPassed ||
|
||||
params.after.existingTests !== afterReceipt.diagnosticTestsPassed ||
|
||||
!beforeReceipt.externalAssertionsPassed ||
|
||||
!afterReceipt.externalAssertionsPassed
|
||||
params.after.existingTests !== afterReceipt.diagnosticTestsPassed
|
||||
)
|
||||
throw new CsoError(
|
||||
'INCOMPATIBLE_INPUT',
|
||||
'Authenticated assertion witness receipts do not match the verifier observations',
|
||||
);
|
||||
for (const [phase, receipt] of [
|
||||
['before', beforeReceipt],
|
||||
['after', afterReceipt],
|
||||
] as const)
|
||||
if (!receipt.externalAssertionsPassed)
|
||||
throw new CsoError(
|
||||
'ASSERTION_FAILED',
|
||||
`External assertions failed in the ${phase} phase: booted ${params[phase].booted}, legitimate ${params[phase].legitimate}, security ${params[phase].security}`,
|
||||
);
|
||||
const stableExecutions = (receipt: AssertionWitnessReceipt) =>
|
||||
receipt.executions.map(({ outputHash: _, ...execution }) => execution);
|
||||
if (canonical(stableExecutions(beforeReceipt)) !== canonical(stableExecutions(afterReceipt)))
|
||||
|
||||
@@ -18,8 +18,8 @@ const finding:any={title:'Cross-tenant read',rootCause:'Tenant query omits calle
|
||||
const request=(before:string,after:string)=>({findingId:'a'.repeat(32),runtimeProfile:'node',port:3456,start:{executable:'/usr/local/bin/node',args:['app.js']},legitimate:[{name:'health',path:'/health',method:'GET',expected:{status:200,includes:'ok'}}],security:{name:'tenant isolation',path:'/user?id=2',method:'GET',expected:{status:403},vulnerable:{status:200,includes:'tenant-b'}},existingTests:[{executable:'/usr/local/bin/node',args:['test.js']}],testFiles:['test.js'],fixtures:{'tenant.json':'{}'},boundaryFiles:['app.js'],changes:[{path:'app.js',beforeSha256:sha256(before),after,effect:'source'}],review:{reviewer:'independent-reviewer',independent:true,rootCauseRepaired:true,featurePreserved:true,boundaryMocks:false,rationale:'Predicate now binds the caller tenant and health behavior is preserved',reviewedPatchHash:''}});
|
||||
// Structural signed fixture for bundle-reader mutation tests. Production
|
||||
// issuance uses AssertionWitnessSession in verifyRepair instead.
|
||||
function authenticatedBundleFixture(certified:ReturnType<typeof certify>,params:any,outcomes?:Record<'before'|'after',{executedTests:number;passingTests:number}>):any{
|
||||
const verification=structuredClone(certified.manifest),identityRequest=params.identityRequest??params.request,pair=crypto.generateKeyPairSync('ed25519'),publicKey=pair.publicKey.export({format:'der',type:'spki'}).toString('hex'),keyId=sha256(Buffer.from(publicKey,'hex')),createdAt=verification.createdAt,runner={testToolchain:params.testToolchain,startPlanHash:verification.startPlanHash,testPlanHash:verification.testPlanHash,commandsHash:sha256(canonical(params.request.existingTests)),minimumPassingTestsHash:sha256(canonical(params.request.existingTests.map(()=>1)))},make=(phase:'before'|'after')=>{const observation=verification[phase],counts=outcomes?.[phase]??{executedTests:1,passingTests:1},binding:any={schemaVersion:1,protocol:'gstack-cso-assertion-witness-v1',nonce:sha256(`${phase}:${createdAt}`),phase,issuedAt:createdAt,expiresAt:new Date(Date.parse(createdAt)+300_000).toISOString(),runId:params.runId,findingId:identityRequest.findingId,policyHash:verification.policyHash,auditPolicyHash:verification.auditPolicyHash,runtime:{image:params.runtime.image,verifierImage:params.verifier.image,platform:params.runtime.platform,profile:params.runtime.id},runner,sourceHash:phase==='before'?verification.beforeSourceHash:verification.afterSourceHash,dependencyHash:phase==='before'?verification.beforeDependencies:verification.afterDependencies,configurationHash:phase==='before'?verification.beforeConfiguration:verification.afterConfiguration,requestHash:verification.requestHash,patchHash:verification.patchHash,harnessHash:verification.harnessHash,assertionHash:sha256(canonical({legitimate:identityRequest.legitimate,security:identityRequest.security})),fixturesHash:verification.fixturesHash},executions=params.request.existingTests.map((command:any)=>({commandHash:sha256(canonical(command)),exitCode:0,outputHash:sha256(`structural signed fixture ${phase}`),minimumPassingTests:1,...counts,reportedPassed:counts.passingTests>=1})),unsigned:any={schemaVersion:1,binding,keyId,publicKey,observationHash:witnessObservationHash(observation),externalAssertionsPassed:true,diagnosticTestsPassed:executions.every((execution:any)=>execution.reportedPassed),executions};return{...unsigned,signature:crypto.sign(null,Buffer.from(canonical(unsigned)),pair.privateKey).toString('hex')}};const witness={before:make('before'),after:make('after')};verification.assertionAssurance='authenticated_out_of_process';verification.testCompletionAssurance='self_reported';verification.witnessHash=assertionWitnessPairHash(witness);verification.result='runtime_tested';verification.id='';const id=verificationIdentity(verification);verification.id=id;
|
||||
function authenticatedBundleFixture(certified:ReturnType<typeof certify>,params:any,outcomes?:Record<'before'|'after',{executedTests:number;passingTests:number}>,externalAssertionsPassed:Record<'before'|'after',boolean>={before:true,after:true}):any{
|
||||
const verification=structuredClone(certified.manifest),identityRequest=params.identityRequest??params.request,pair=crypto.generateKeyPairSync('ed25519'),publicKey=pair.publicKey.export({format:'der',type:'spki'}).toString('hex'),keyId=sha256(Buffer.from(publicKey,'hex')),createdAt=verification.createdAt,runner={testToolchain:params.testToolchain,startPlanHash:verification.startPlanHash,testPlanHash:verification.testPlanHash,commandsHash:sha256(canonical(params.request.existingTests)),minimumPassingTestsHash:sha256(canonical(params.request.existingTests.map(()=>1)))},make=(phase:'before'|'after')=>{const observation=verification[phase],counts=outcomes?.[phase]??{executedTests:1,passingTests:1},binding:any={schemaVersion:1,protocol:'gstack-cso-assertion-witness-v1',nonce:sha256(`${phase}:${createdAt}`),phase,issuedAt:createdAt,expiresAt:new Date(Date.parse(createdAt)+300_000).toISOString(),runId:params.runId,findingId:identityRequest.findingId,policyHash:verification.policyHash,auditPolicyHash:verification.auditPolicyHash,runtime:{image:params.runtime.image,verifierImage:params.verifier.image,platform:params.runtime.platform,profile:params.runtime.id},runner,sourceHash:phase==='before'?verification.beforeSourceHash:verification.afterSourceHash,dependencyHash:phase==='before'?verification.beforeDependencies:verification.afterDependencies,configurationHash:phase==='before'?verification.beforeConfiguration:verification.afterConfiguration,requestHash:verification.requestHash,patchHash:verification.patchHash,harnessHash:verification.harnessHash,assertionHash:sha256(canonical({legitimate:identityRequest.legitimate,security:identityRequest.security})),fixturesHash:verification.fixturesHash},executions=params.request.existingTests.map((command:any)=>({commandHash:sha256(canonical(command)),exitCode:0,outputHash:sha256(`structural signed fixture ${phase}`),minimumPassingTests:1,...counts,reportedPassed:counts.passingTests>=1})),unsigned:any={schemaVersion:1,binding,keyId,publicKey,observationHash:witnessObservationHash(observation),externalAssertionsPassed:externalAssertionsPassed[phase],diagnosticTestsPassed:executions.every((execution:any)=>execution.reportedPassed),executions};return{...unsigned,signature:crypto.sign(null,Buffer.from(canonical(unsigned)),pair.privateKey).toString('hex')}};const witness={before:make('before'),after:make('after')};verification.assertionAssurance='authenticated_out_of_process';verification.testCompletionAssurance='self_reported';verification.witnessHash=assertionWitnessPairHash(witness);verification.result='runtime_tested';verification.id='';const id=verificationIdentity(verification);verification.id=id;
|
||||
return{schemaVersion:3,runId:params.runId,id,createdAt,expiresAt:new Date(Date.parse(createdAt)+30*86400_000).toISOString(),requiredInputs:{sourceHash:params.manifest.executionHash,originalHash:params.manifest.originalHash,runtimeImage:params.runtime.image,platform:params.runtime.platform,archives:params.archives,...(params.dependencyClosures?{dependencyClosures:params.dependencyClosures}:{})},request:identityRequest,verification,transformations:params.manifest.entries.filter((entry:any)=>entry.transformation),...(params.preparation?{preparation:params.preparation}:{}),witness};
|
||||
}
|
||||
|
||||
@@ -170,6 +170,14 @@ describe('tested repair certificate gate',()=>{
|
||||
const params:any={runId:'run',manifest:{originalHash:'d'.repeat(64),executionHash:treeHash(before),entries:[]},request:req,runtime:{id:'node',image:'runtime@sha256:'+'a'.repeat(64),platform:'linux/amd64'},verifier:{image:'runtime@sha256:'+'a'.repeat(64)},before:{booted:true,legitimate:true,security:'intended_failure',existingTests:true,output:'before',inputHash:''},after:{booted:true,legitimate:true,security:'pass',existingTests:true,output:'after',inputHash:''},beforeRoot:before,afterRoot:after,policyHash:'c'.repeat(64),auditPolicyHash:'e'.repeat(64),archives:[],testToolchain:'runtime'},unsigned=certify(params),witness=authenticatedBundleFixture(unsigned,params,{before:{executedTests:2,passingTests:2},after:{executedTests:2,passingTests:1}}).witness;
|
||||
expect(()=>certify({...params,witness})).toThrow('existing-test execution count or outcome');
|
||||
});
|
||||
test('a witness whose external assertions failed is an assertion failure, and a mismatched observation stays incompatible input',()=>{
|
||||
const root=tmp(),before=path.join(root,'before'),after=path.join(root,'after');fs.mkdirSync(before);fs.writeFileSync(path.join(before,'app.js'),'vulnerable\n');fs.writeFileSync(path.join(before,'test.js'),'one registered test\n');const raw:any=request('vulnerable\n','fixed\n');raw.review.reviewedPatchHash=patchHash(raw);const req=validateVerificationRequest(raw);preparePatchedSource(before,after,req);
|
||||
const params:any={runId:'run',manifest:{originalHash:'d'.repeat(64),executionHash:treeHash(before),entries:[]},request:req,runtime:{id:'node',image:'runtime@sha256:'+'a'.repeat(64),platform:'linux/amd64'},verifier:{image:'runtime@sha256:'+'a'.repeat(64)},before:{booted:true,legitimate:true,security:'intended_failure',existingTests:true,output:'before',inputHash:''},after:{booted:true,legitimate:true,security:'inconclusive',existingTests:true,output:'after',inputHash:''},beforeRoot:before,afterRoot:after,policyHash:'c'.repeat(64),auditPolicyHash:'e'.repeat(64),archives:[],testToolchain:'runtime'},unsigned=certify(params);
|
||||
const failed=authenticatedBundleFixture(unsigned,params,undefined,{before:true,after:false}).witness;
|
||||
expect(()=>certify({...params,witness:failed})).toThrow(expect.objectContaining({code:'ASSERTION_FAILED',message:'External assertions failed in the after phase: booted true, legitimate true, security inconclusive'}));
|
||||
const mismatched=authenticatedBundleFixture({...unsigned,manifest:{...unsigned.manifest,after:{...unsigned.manifest.after,output:'a different observation'}}},params,undefined,{before:true,after:false}).witness;
|
||||
expect(()=>certify({...params,witness:mismatched})).toThrow(expect.objectContaining({code:'INCOMPATIBLE_INPUT'}));
|
||||
});
|
||||
test('canonical Python starts use direct prepared launchers and reject root framework shadows',()=>{
|
||||
const pin=(name:string)=>`${name}==1.0.0 --hash=sha256:${'a'.repeat(64)}\n`;
|
||||
const flask=tmp();fs.writeFileSync(path.join(flask,'requirements.txt'),pin('Flask'));fs.writeFileSync(path.join(flask,'app.py'),'from flask import Flask\napp = Flask(__name__)\n');const flaskCommand=canonicalStartPlan(flask,'python',3456).command;expect(flaskCommand.executable).toBe('/work/.venv/bin/python');expect(flaskCommand.args.slice(0,3)).toEqual(['-I','-S','-c']);expect(flaskCommand.args[3]).toContain('from flask.cli import main');expect(flaskCommand.args.slice(4)).toEqual(['--app','app:app','run','--host','127.0.0.1','--port','3456']);fs.writeFileSync(path.join(flask,'flask.py'),'raise RuntimeError("shadow")\n');expect(()=>canonicalStartPlan(flask,'python',3456)).toThrow('root import shadow');
|
||||
|
||||
@@ -92,6 +92,20 @@ describe('CSO Git metadata hardening',()=>{
|
||||
expect(history).not.toContain('replacement-history');
|
||||
});
|
||||
|
||||
test.skipIf(process.platform==='win32')('retained history withholds diffs of credential files the snapshot excludes',async()=>{
|
||||
const {repo,runDir,git}=fixture();
|
||||
fs.writeFileSync(path.join(repo,'.env'),'UPSTREAM_TOKEN=planted-history-value\n');fs.writeFileSync(path.join(repo,'tracked.ts'),'export const tracked = false\n');
|
||||
git('add','.env','tracked.ts');git('commit','-qm','configure upstream');
|
||||
|
||||
await capture(repo,runDir);
|
||||
const history=fs.readFileSync(path.join(runDir,'history.txt'),'utf8');
|
||||
|
||||
expect(history).not.toContain('planted-history-value');
|
||||
expect(history).toContain('diff withheld: .env (credential or execution configuration)');
|
||||
expect(history).toContain('+export const tracked = false');
|
||||
expect(history).toContain('Subject: configure upstream');
|
||||
});
|
||||
|
||||
test.skipIf(process.platform==='win32')('rejects repository config includes before Git can consume them',async()=>{
|
||||
const {root,repo,runDir}=fixture(),included=path.join(root,'included.conf');
|
||||
fs.writeFileSync(included,'[core]\n\tworktree = /tmp/cso-decoy\n');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe,expect,test } from 'bun:test';
|
||||
import { gitDiffHeaderPaths,historyForPath } from '../lib/cso/history';
|
||||
import { gitDiffHeaderPaths,historyForPath,withholdHistory } from '../lib/cso/history';
|
||||
|
||||
describe('CSO retained Git history path filtering',()=>{
|
||||
test('matches exact paths without leaking a prefix sibling hunk',()=>{
|
||||
@@ -25,6 +25,38 @@ diff --git a/foo-extra b/foo-extra
|
||||
expect(historyForPath(raw,'café file.ts')).toContain('+unicode-only');
|
||||
});
|
||||
|
||||
test('withholds every diff section touching an excluded path, keeping commit preambles and other files',()=>{
|
||||
const raw=`commit ${'c'.repeat(40)}
|
||||
Subject: add config
|
||||
diff --git a/.env b/.env
|
||||
--- /dev/null
|
||||
+++ b/.env
|
||||
+PLANTED_VALUE=excluded-file-body
|
||||
diff --git a/app.ts b/app.ts
|
||||
--- a/app.ts
|
||||
+++ b/app.ts
|
||||
+kept-line
|
||||
commit ${'d'.repeat(40)}
|
||||
Subject: rename
|
||||
diff --git a/config/old.txt b/config/master.key
|
||||
rename from config/old.txt
|
||||
rename to config/master.key
|
||||
+renamed-into-excluded
|
||||
diff --git "a/foo b/foo
|
||||
+unparseable-body
|
||||
`;
|
||||
const kept=withholdHistory(raw,(path)=>/(^|\/)(\.env|master\.key)$/.test(path)?'credential or execution configuration':undefined);
|
||||
expect(kept).not.toContain('excluded-file-body');
|
||||
expect(kept).not.toContain('renamed-into-excluded');
|
||||
expect(kept).not.toContain('unparseable-body');
|
||||
expect(kept).toContain('diff withheld: .env (credential or execution configuration)');
|
||||
expect(kept).toContain('diff withheld: config/master.key (credential or execution configuration)');
|
||||
expect(kept).toContain('diff withheld: unknown path (unparseable diff header)');
|
||||
expect(kept).toContain('+kept-line');
|
||||
expect(kept).toContain('Subject: add config');
|
||||
expect(kept).toContain('Subject: rename');
|
||||
});
|
||||
|
||||
test('rejects malformed quoted headers instead of broadening the match',()=>{
|
||||
expect(gitDiffHeaderPaths('diff --git "a/foo b/foo')).toBeUndefined();
|
||||
expect(historyForPath('diff --git "a/foo b/foo\n+secret\n','foo')).toBeUndefined();
|
||||
|
||||
Reference in new issue
Block a user