mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-16 18:05:31 +02:00
* feat(cso): add verified audits and replayable repair bundles * fix(cso): harden qualification and setup boundaries * fix(cso): assemble security canaries at runtime * fix(cso): bound release proof and maintenance work Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(cso): require complete evaluation reports Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(cso): replay expired snapshots from supplied source Co-Authored-By: OpenAI Codex <noreply@openai.com> * test(cso): synchronize DNS cancellation assertion Co-Authored-By: OpenAI Codex <noreply@openai.com> * chore(ship): exempt repository owner from liveness proof Co-Authored-By: OpenAI Codex <noreply@openai.com> * test(cso): make recheck retention overlap deterministic Co-Authored-By: OpenAI Codex <noreply@openai.com> * chore: bump version and changelog (v1.85.0.0) Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(cso): pass native release gates Co-Authored-By: OpenAI Codex <noreply@openai.com> * chore: move release to v1.86.0.0 Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(cso): resolve rechecks by finding Co-Authored-By: OpenAI Codex <noreply@openai.com> * chore: move release to v1.87.0.0 Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(cso): pass macOS and Windows release gates Normalize BSD wc output, compare Windows paths by filesystem identity, preserve portable snapshot race coverage, and narrow POSIX-only Windows fixtures. Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(cso): harden native verification gates * fix(cso): refine Windows native diagnostics * test(cso): isolate Windows Git startup failure * test(cso): stabilize Windows native diagnostics * fix(cso): support hardened Git on Windows * fix(cso): close final verification gaps * test(cso): bound cold Docker fixture setup * fix(cso): restore cross-platform free-suite gates --------- Co-authored-by: OpenAI Codex <noreply@openai.com>
288 lines
33 KiB
TypeScript
288 lines
33 KiB
TypeScript
import * as fs from 'node:fs';
|
|
import { createHash } from 'node:crypto';
|
|
import { dirname, isAbsolute, join, resolve, relative, sep } from 'node:path';
|
|
import { CsoError, SnapshotManifest, SnapshotEntry, SnapshotPathIdentity, canonical, sha256, relativePath, snapshotOriginalIdentity, snapshotPathHandle, snapshotPathId, MAX_OUTPUT } from './contracts';
|
|
import { childEnvironment, executable, git, redact, runProcess } from './process';
|
|
import { secureDirectory, writeHelperJson, writeJson } from './state';
|
|
import { scan } from '../redact-engine';
|
|
import { atomicWriteSync } from '../fs-atomic';
|
|
|
|
const NO_READ_COMPONENTS=new Set(['.git','.hg','.svn','node_modules','.venv','venv','__pycache__','.bundle','.cache','.context','.gstack']);
|
|
const OMIT_COMPONENTS=new Set([...NO_READ_COMPONENTS,'.claude','.agents','.codex','.cursor']);
|
|
function containsDirectory(path:string,components:Set<string>,sequences:string[][]=[]):boolean{
|
|
const parts=path.split('/');if(parts.slice(0,-1).some(part=>components.has(part)))return true;
|
|
return sequences.some(sequence=>parts.slice(0,-1).some((_,index)=>sequence.every((part,offset)=>parts[index+offset]===part)));
|
|
}
|
|
function noReadPath(path:string):boolean{return containsDirectory(path,NO_READ_COMPONENTS,[['vendor','bundle']]);}
|
|
function omittedPath(path:string):boolean{return containsDirectory(path,OMIT_COMPONENTS,[['vendor','bundle'],['.github','agents']]);}
|
|
const SECRET_FILE = /(?:^|\/)(?:\.env(?:\..*)?|\.npmrc|\.yarnrc(?:\.yml)?|\.pypirc|pip\.conf|credentials(?:\.yml(?:\.enc)?)?|master\.key|id_(?:rsa|ed25519)|.*\.(?:pem|p12|pfx|key)|AGENTS\.md|CLAUDE\.md|GEMINI\.md|bunfig\.toml)$/i;
|
|
const SOURCE_LIMIT=64*1024*1024;
|
|
const SNAPSHOT_ENTRY_LIMIT=100_000;
|
|
const GIT_POINTER_LIMIT=8192;
|
|
export interface SnapshotCaptureLimits { deadlineMs?:number; maxEntries?:number }
|
|
|
|
type BoundPathIdentity={path:string;kind:'directory'|'file';dev:number;ino:number;mode:number;size:number;mtimeMs:number;ctimeMs:number;contentHash?:string};
|
|
type RepositoryIdentity={root:BoundPathIdentity;metadata:BoundPathIdentity[]};
|
|
function boundPath(path:string,label:string,maxBytes=GIT_POINTER_LIMIT):{identity:BoundPathIdentity;content?:string}{
|
|
let named:fs.Stats;try{named=fs.lstatSync(path);}catch{throw new CsoError('SNAPSHOT_RACE',`${label} disappeared during snapshot capture`);}
|
|
if(named.isSymbolicLink())throw new CsoError('UNSAFE_PATH',`${label} cannot be a symlink`);
|
|
if(named.isDirectory())return{identity:{path,kind:'directory',dev:named.dev,ino:named.ino,mode:named.mode,size:named.size,mtimeMs:named.mtimeMs,ctimeMs:named.ctimeMs}};
|
|
if(!named.isFile()||named.nlink!==1||named.size>maxBytes)throw new CsoError('UNSAFE_PATH',`${label} must be a bounded regular file or directory`);
|
|
let fd:number|undefined;try{
|
|
fd=fs.openSync(path,fs.constants.O_RDONLY|(fs.constants.O_NOFOLLOW??0)|(fs.constants.O_NONBLOCK??0));const opened=fs.fstatSync(fd);
|
|
if(!opened.isFile()||opened.nlink!==1||opened.dev!==named.dev||opened.ino!==named.ino||opened.mode!==named.mode||opened.size!==named.size)throw new CsoError('SNAPSHOT_RACE',`${label} changed before it could be read`);
|
|
const buffer=Buffer.alloc(maxBytes+1);let bytes=0,count=0;while(bytes<buffer.length&&(count=fs.readSync(fd,buffer,bytes,buffer.length-bytes,null))>0)bytes+=count;
|
|
const after=fs.fstatSync(fd),current=fs.lstatSync(path);if(bytes>maxBytes)throw new CsoError('UNSAFE_PATH',`${label} exceeds its bounded size limit`);
|
|
if(current.isSymbolicLink()||!current.isFile()||current.nlink!==1||current.dev!==opened.dev||current.ino!==opened.ino||current.mode!==opened.mode||after.size!==opened.size||after.mtimeMs!==opened.mtimeMs||after.ctimeMs!==opened.ctimeMs)throw new CsoError('SNAPSHOT_RACE',`${label} changed while it was read`);
|
|
const body=buffer.subarray(0,bytes);return{identity:{path,kind:'file',dev:after.dev,ino:after.ino,mode:after.mode,size:after.size,mtimeMs:after.mtimeMs,ctimeMs:after.ctimeMs,contentHash:sha256(body)},content:body.toString('utf8')};
|
|
}catch(error){if(error instanceof CsoError)throw error;const code=(error as NodeJS.ErrnoException).code;if(['ELOOP','ENOENT','ENOTDIR','ENXIO'].includes(code??''))throw new CsoError('SNAPSHOT_RACE',`${label} changed before it could be opened`);throw new CsoError('UNSAFE_PATH',`${label} could not be read as a bounded regular file`);}finally{if(fd!==undefined)fs.closeSync(fd);}
|
|
}
|
|
function sameIdentity(expected:BoundPathIdentity,current:BoundPathIdentity):boolean{return expected.path===current.path&&expected.kind===current.kind&&expected.dev===current.dev&&expected.ino===current.ino&&expected.mode===current.mode&&expected.size===current.size&&expected.mtimeMs===current.mtimeMs&&expected.ctimeMs===current.ctimeMs&&expected.contentHash===current.contentHash;}
|
|
function repositoryIdentity(repo:string):RepositoryIdentity{
|
|
const root=boundPath(repo,'Audited repository root').identity;if(root.kind!=='directory')throw new CsoError('MISSING_INPUT','Audited repository root is not a directory');
|
|
const markerPath=join(repo,'.git'),marker=boundPath(markerPath,'Repository .git marker'),metadata=[marker.identity];let gitDir:string;
|
|
if(marker.identity.kind==='directory')gitDir=fs.realpathSync(markerPath);
|
|
else{const value=marker.content??'',match=value.match(/^gitdir:\s*(.+?)\s*$/);if(!match||value.includes('\0')||value.split(/\r?\n/).filter(Boolean).length!==1)throw new CsoError('UNSAFE_PATH','Repository .git pointer is invalid');gitDir=fs.realpathSync(resolve(dirname(markerPath),match[1]));}
|
|
const gitDirIdentity=boundPath(gitDir,'Repository Git directory').identity;if(gitDirIdentity.kind!=='directory')throw new CsoError('UNSAFE_PATH','Repository Git directory is not a directory');metadata.push(gitDirIdentity);
|
|
const commonMarker=join(gitDir,'commondir');let commonDir=gitDir;
|
|
if(fs.existsSync(commonMarker)){const marker=boundPath(commonMarker,'Repository common Git directory pointer');if(marker.identity.kind!=='file')throw new CsoError('UNSAFE_PATH','Repository common Git directory pointer is invalid');metadata.push(marker.identity);const value=(marker.content??'').trim();if(!value||value.includes('\0')||value.includes('\n')||value.includes('\r'))throw new CsoError('UNSAFE_PATH','Repository common Git directory pointer is invalid');commonDir=fs.realpathSync(resolve(gitDir,value));}
|
|
const commonIdentity=boundPath(commonDir,'Repository common Git directory').identity;if(commonIdentity.kind!=='directory')throw new CsoError('UNSAFE_PATH','Repository common Git directory is not a directory');metadata.push(commonIdentity);
|
|
const unique=[...new Map(metadata.map(item=>[item.path,item])).values()];const identity={root,metadata:unique};assertRepositoryIdentity(identity);return identity;
|
|
}
|
|
function assertRepositoryIdentity(expected:RepositoryIdentity):void{
|
|
const compare=(item:BoundPathIdentity,label:string)=>{const current=boundPath(item.path,label,item.kind==='file'?Math.max(GIT_POINTER_LIMIT,item.size):GIT_POINTER_LIMIT).identity;if(!sameIdentity(item,current))throw new CsoError('SNAPSHOT_RACE',`${label} changed during snapshot capture`);};
|
|
compare(expected.root,'Audited repository root');for(const item of expected.metadata)compare(item,'Repository Git metadata identity');compare(expected.root,'Audited repository root');
|
|
}
|
|
function snapshotAdmission(limits:SnapshotCaptureLimits={}){
|
|
const deadlineMs=limits.deadlineMs??Date.now()+9*60_000,maxEntries=Math.min(limits.maxEntries??SNAPSHOT_ENTRY_LIMIT,SNAPSHOT_ENTRY_LIMIT);
|
|
if(!Number.isSafeInteger(deadlineMs)||!Number.isSafeInteger(maxEntries)||maxEntries<1)throw new CsoError('INVALID_ARGUMENT','Invalid snapshot admission limits');
|
|
const time=()=>{if(Date.now()>=deadlineMs)throw new CsoError('DEADLINE','Snapshot capture exhausted the investigation budget before a report could be created');};
|
|
const count=(entries:number)=>{if(entries>maxEntries)throw new CsoError('MISSING_INPUT',`Source tree exceeds the ${maxEntries}-entry snapshot admission limit`);};
|
|
return{time,count};
|
|
}
|
|
export function exclusion(path: string): string | undefined {
|
|
if (omittedPath(path)) return 'host dependencies, metadata, state, or agent configuration';
|
|
if (SECRET_FILE.test(path)) return 'credential or execution configuration';
|
|
}
|
|
export function containedFile(root: string, path: string): string {
|
|
const rel = relativePath(path), full = join(root,rel); let cursor = root;
|
|
for (const part of rel.split('/')) {
|
|
cursor = join(cursor,part);
|
|
try {
|
|
if (fs.lstatSync(cursor).isSymbolicLink()) throw new CsoError('UNSAFE_PATH',`Symlink is not an execution input: ${rel}`);
|
|
} catch (error) {
|
|
if (error instanceof CsoError) throw error;
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
}
|
|
}
|
|
if (!full.startsWith(root + sep)) throw new CsoError('UNSAFE_PATH','Path escaped snapshot');
|
|
return full;
|
|
}
|
|
type DirectoryIdentity={path:string;dev:number;ino:number;mode:number};
|
|
function inside(root:string,candidate:string):boolean{const relation=relative(root,candidate);return relation===''||(relation!=='..'&&!relation.startsWith(`..${sep}`)&&!isAbsolute(relation));}
|
|
function directoryIdentities(root:string,path:string):DirectoryIdentity[]{
|
|
const rel=relativePath(path),parts=rel.split('/'),identities:DirectoryIdentity[]=[];let cursor=root;
|
|
for(const part of ['',...parts.slice(0,-1)]){
|
|
if(part)cursor=join(cursor,part);
|
|
let stat:fs.Stats;try{stat=fs.lstatSync(cursor);}catch{throw new CsoError('SNAPSHOT_RACE',`Source ancestor changed while opening: ${rel}`);}
|
|
if(stat.isSymbolicLink()||!stat.isDirectory())throw new CsoError('UNSAFE_PATH',`Symlink or non-directory source ancestor: ${rel}`);
|
|
identities.push({path:cursor,dev:stat.dev,ino:stat.ino,mode:stat.mode});
|
|
}
|
|
return identities;
|
|
}
|
|
function assertDirectoryIdentities(identities:DirectoryIdentity[],path:string):void{
|
|
for(const expected of identities){let current:fs.Stats;try{current=fs.lstatSync(expected.path);}catch{throw new CsoError('SNAPSHOT_RACE',`Source ancestor changed while reading: ${path}`);}
|
|
if(current.isSymbolicLink()||!current.isDirectory()||current.dev!==expected.dev||current.ino!==expected.ino||current.mode!==expected.mode)throw new CsoError('SNAPSHOT_RACE',`Source ancestor changed while reading: ${path}`);
|
|
}
|
|
}
|
|
/** Validate the resolved inode after open so an ancestor-symlink swap cannot escape root. */
|
|
export function assertOpenedFileContained(root:string,full:string,fd:number,opened:fs.Stats):void{
|
|
if(process.platform==='linux'){
|
|
let actual:string,current:fs.Stats;try{actual=fs.readlinkSync(`/proc/self/fd/${fd}`);current=fs.fstatSync(fd);}catch{throw new CsoError('SNAPSHOT_RACE','Opened source identity could not be resolved');}
|
|
if(current.nlink!==1||current.dev!==opened.dev||current.ino!==opened.ino||current.mode!==opened.mode)throw new CsoError('SNAPSHOT_RACE','Opened source identity changed during containment validation');
|
|
if(!isAbsolute(actual)||!inside(root,actual))throw new CsoError('UNSAFE_PATH','Opened source escaped the audited root');
|
|
return;
|
|
}
|
|
let resolved:string,current:fs.Stats;try{resolved=fs.realpathSync(full);current=fs.lstatSync(resolved);}catch{throw new CsoError('SNAPSHOT_RACE','Opened source identity changed during containment validation');}
|
|
if(!inside(root,resolved))throw new CsoError('UNSAFE_PATH','Opened source escaped the audited root');
|
|
if(current.isSymbolicLink()||!current.isFile()||current.nlink!==1||current.dev!==opened.dev||current.ino!==opened.ino||current.mode!==opened.mode||current.size!==opened.size)throw new CsoError('SNAPSHOT_RACE','Opened source identity changed during containment validation');
|
|
}
|
|
function readStable(root: string, path: string, maxBytes=MAX_OUTPUT): {data:Buffer; mode:number} {
|
|
const ancestors=directoryIdentities(root,path),full = containedFile(root,path), named=fs.lstatSync(full);
|
|
// Prove the pathname is a regular single-link file before open. Opening a
|
|
// FIFO or device merely to discover its type can block or trigger host I/O.
|
|
if(named.isSymbolicLink()||!named.isFile()||named.nlink!==1)throw new CsoError('UNSAFE_PATH',`Special or hard-linked source file: ${path}`);
|
|
let fd:number;try{fd=fs.openSync(full,fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW??0) | (fs.constants.O_NONBLOCK??0));}catch(error){const code=(error as NodeJS.ErrnoException).code;if(['ELOOP','ENOENT','ENOTDIR','ENXIO'].includes(code??''))throw new CsoError('SNAPSHOT_RACE',`Source changed before it could be opened: ${path}`);throw new CsoError('UNSAFE_PATH',`Source could not be opened as a regular file: ${path}`);}
|
|
try {
|
|
const before = fs.fstatSync(fd);
|
|
if (!before.isFile() || before.nlink !== 1 || before.dev!==named.dev || before.ino!==named.ino || before.mode!==named.mode || before.size!==named.size) throw new CsoError('UNSAFE_PATH',`Special or hard-linked source file: ${path}`);
|
|
assertOpenedFileContained(root,full,fd,before);assertDirectoryIdentities(ancestors,path);
|
|
if (before.size > maxBytes) throw new CsoError('MISSING_INPUT',`Source file exceeds the ${maxBytes}-byte snapshot admission limit: ${path}`);
|
|
const buffer=Buffer.alloc(Math.min(maxBytes+1,before.size+1));let bytes=0,count=0;while(bytes<buffer.length&&(count=fs.readSync(fd,buffer,bytes,buffer.length-bytes,null))>0)bytes+=count;const data=buffer.subarray(0,bytes),after = fs.fstatSync(fd), current = fs.lstatSync(full);
|
|
if (!current.isFile()||current.nlink!==1||before.ino !== current.ino || before.dev !== current.dev || before.mode!==current.mode || before.size !== after.size || before.size!==bytes || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs)
|
|
throw new CsoError('SNAPSHOT_RACE',`Source changed while reading: ${path}`);
|
|
assertOpenedFileContained(root,full,fd,after);assertDirectoryIdentities(ancestors,path);
|
|
return {data,mode:before.mode & 0o777};
|
|
} finally { fs.closeSync(fd); }
|
|
}
|
|
async function resolveHeadCommit(repo:string,home:string):Promise<string|undefined>{
|
|
try{return(await git(repo,['rev-parse','--verify','HEAD^{commit}'],home)).trim();}
|
|
catch(error){
|
|
// A symbolic HEAD whose target does not exist is the normal unborn-branch
|
|
// state. A detached/malformed HEAD or a ref to a non-commit remains an
|
|
// input error instead of being silently treated as an empty history.
|
|
try{await git(repo,['symbolic-ref','--quiet','HEAD'],home);}catch{throw error;}
|
|
try{await git(repo,['rev-parse','--verify','HEAD'],home);}catch{return undefined;}
|
|
throw error;
|
|
}
|
|
}
|
|
async function paths(repo: string, home: string,headCommit:string|undefined,admission:ReturnType<typeof snapshotAdmission>): Promise<string[]> {
|
|
// The index omits staged deletions. Union the pinned HEAD tree so every
|
|
// tracked deletion is represented even when no comparison base was asked
|
|
// for, while still collecting nonignored untracked source.
|
|
admission.time();const [working,head]=await Promise.all([
|
|
git(repo,['ls-files','--cached','--others','--exclude-standard','-z'],home),
|
|
headCommit?git(repo,['ls-tree','-r','-z','--name-only','--full-tree',headCommit,'--'],home):Promise.resolve(''),
|
|
]),seen=new Set<string>();admission.time();
|
|
for(const data of [working,head])for(const value of data.split('\0')){admission.time();if(!value)continue;seen.add(relativePath(value));admission.count(seen.size);}
|
|
const result=[...seen].sort();admission.time();return result;
|
|
}
|
|
async function rejectSpecialFiles(repo:string,home:string,admission:ReturnType<typeof snapshotAdmission>):Promise<void>{
|
|
// Git intentionally omits untracked FIFOs and devices from ls-files. Walk
|
|
// pathnames without opening payloads, then ask Git which special names are
|
|
// ignored so a nonignored FIFO cannot silently disappear from the snapshot.
|
|
admission.time();const ignoredRaw=await git(repo,['ls-files','--others','--ignored','--exclude-standard','--directory','-z'],home),ignoredDirectories=new Set<string>();admission.time();
|
|
for(const value of ignoredRaw.split('\0')){admission.time();if(!value)continue;ignoredDirectories.add(relativePath(value.replace(/\/$/,'')));admission.count(ignoredDirectories.size);}
|
|
const ignoredDirectory=(path:string)=>{let candidate=path;for(;;){if(ignoredDirectories.has(candidate))return true;const slash=candidate.lastIndexOf('/');if(slash<0)return false;candidate=candidate.slice(0,slash);}};
|
|
const special:string[]=[];let visited=0;
|
|
const walk=(at:string,prefix='')=>{const directory=fs.opendirSync(at);try{let item:fs.Dirent|null;while((item=directory.readSync())!==null){
|
|
admission.time();const path=relativePath(prefix?`${prefix}/${item.name}`:item.name);if(noReadPath(path)||ignoredDirectory(path))continue;admission.count(++visited);
|
|
const full=join(at,item.name),stat=fs.lstatSync(full);if(stat.isDirectory()){walk(full,path);continue;}if(!stat.isFile())special.push(path);
|
|
}}finally{directory.closeSync();}};walk(repo);
|
|
if(!special.length)return;
|
|
const nullPath=process.platform==='win32'?'NUL':'/dev/null',result=await runProcess(executable('git'),['--no-optional-locks','-c','core.fsmonitor=false','-c',`core.hooksPath=${nullPath}`,'-c',`core.attributesFile=${nullPath}`,'-c','core.pager=cat','-C',repo,'check-ignore','--no-index','-z','--stdin'],{cwd:home,env:childEnvironment(home),raw:true,input:`${special.join('\0')}\0`,timeoutMs:15_000});
|
|
if(![0,1].includes(result.code)||result.timedOut||result.truncated)throw new CsoError('MISSING_INPUT','Could not determine whether special source paths are ignored');
|
|
admission.time();const ignored=new Set(result.stdout.split('\0').filter(Boolean).map(relativePath)),unsafe=special.find(path=>!ignored.has(path));
|
|
if(unsafe)throw new CsoError('UNSAFE_PATH',`Symlink or special source file: ${unsafe}`);
|
|
}
|
|
export async function capture(repo: string, runDir: string, base?: string, requiredAncestor?:string, limits:SnapshotCaptureLimits={}): Promise<SnapshotManifest> {
|
|
repo = fs.realpathSync(repo);
|
|
const state=fs.realpathSync(runDir),relation=relative(repo,state);if(relation===''||(!relation.startsWith(`..${sep}`)&&relation!=='..'&&!isAbsolute(relation)))throw new CsoError('UNSAFE_PATH','Security state must be outside the audited repository');
|
|
const repository=repositoryIdentity(repo),home = secureDirectory(join(runDir,'home')), snapshot = secureDirectory(join(runDir,'snapshot')), readable = secureDirectory(join(runDir,'readable')),admission=snapshotAdmission(limits),guard=()=>{admission.time();assertRepositoryIdentity(repository);};
|
|
try {
|
|
const entries: SnapshotEntry[] = [], gitHashes=new Map<string,string>(), gitModes=new Map<string,string>(), sensitiveEvidence:any[]=[], absentPaths=new Set<string>(); let total = 0;guard();
|
|
const objectFormat=(await git(repo,['rev-parse','--show-object-format'],home)).trim();guard();
|
|
if(!['sha1','sha256'].includes(objectFormat))throw new CsoError('INCOMPATIBLE_INPUT','Unsupported Git object format');
|
|
const headCommit = await resolveHeadCommit(repo,home);guard();
|
|
const list = await paths(repo,home,headCommit,admission);guard();await rejectSpecialFiles(repo,home,admission);guard();
|
|
const manifest: SnapshotManifest = {version:3,root:repo,createdAt:new Date().toISOString(),expiresAt:new Date(Date.now()+7*86400_000).toISOString(),entries,...(headCommit?{headCommit}:{}),originalHash:'',executionHash:''};
|
|
if (base) {
|
|
if (!/^[A-Za-z0-9_.\/-]+$/.test(base) || base.startsWith('-')) throw new CsoError('INVALID_ARGUMENT','Invalid comparison base');
|
|
manifest.baseCommit = (await git(repo,['rev-parse','--verify',`${base}^{commit}`],home)).trim();guard();
|
|
}
|
|
for (const path of list) {
|
|
guard();
|
|
try{fs.lstatSync(join(repo,path));}catch(error:any){if(error?.code==='ENOENT'){absentPaths.add(path);continue;}throw error;} // tracked deletions are represented by absence and the diff manifest
|
|
// Host dependency trees aren't copied or read. Their omission is still explicit.
|
|
if (noReadPath(path)) {
|
|
const stat = fs.lstatSync(containedFile(repo,path));
|
|
if (stat.isSymbolicLink() || !stat.isFile()) throw new CsoError('UNSAFE_PATH',`Special source input: ${path}`);
|
|
gitModes.set(path,(stat.mode&0o111)?'100755':'100644');
|
|
const reason=exclusion(path)??'host dependency input';
|
|
entries.push({path,pathId:snapshotPathId(repo,path),originalHash:'not-read',bytes:stat.size,mode:stat.mode & 0o777,transformation:`excluded: ${reason}`});guard();continue;
|
|
}
|
|
const {data,mode} = readStable(repo,path,SOURCE_LIMIT); total += data.length;
|
|
guard();
|
|
if (total > SOURCE_LIMIT) throw new CsoError('MISSING_INPUT','Source exceeds the 64 MiB snapshot admission limit');
|
|
const entry: SnapshotEntry = {path,pathId:snapshotPathId(repo,path),originalHash:sha256(data),bytes:data.length,mode}; entries.push(entry);
|
|
gitHashes.set(path,createHash(objectFormat).update(`blob ${data.length}\0`).update(data).digest('hex'));
|
|
gitModes.set(path,(mode&0o111)?'100755':'100644');
|
|
if(data.length>MAX_OUTPUT){entry.transformation='withheld: exceeds the 1 MiB redacting-reader limit';continue;}
|
|
let sanitized: string;
|
|
try {
|
|
sanitized = new TextDecoder('utf-8',{fatal:true}).decode(data);
|
|
if (sanitized.includes('\0')) throw new Error('binary');
|
|
const findings=scan(sanitized,{maxBytes:MAX_OUTPUT}).findings;
|
|
if(findings.length)sensitiveEvidence.push({path:snapshotPathHandle(entry.pathId),findings:findings.map(f=>({id:f.id,tier:f.tier,line:f.line,col:f.col}))});
|
|
sanitized = redact(sanitized);
|
|
} catch { entry.transformation = exclusion(path)?`excluded: ${exclusion(path)}; payload withheld because redaction could not safely preserve it`:'withheld: binary or redaction failed'; continue; }
|
|
const out = containedFile(readable,path); secureDirectory(dirname(out)); fs.writeFileSync(out,sanitized,{mode:0o600});
|
|
const reason = exclusion(path);
|
|
if (reason) { entry.transformation = `excluded: ${reason}`; continue; }
|
|
if (sha256(sanitized) !== entry.originalHash) entry.transformation = 'secret spans redacted';
|
|
const target = containedFile(snapshot,path); secureDirectory(dirname(target)); fs.writeFileSync(target,sanitized,{mode});
|
|
// writeFile's creation mode is filtered through the caller's umask. The
|
|
// skill deliberately starts with umask 077, while the manifest binds the
|
|
// original mode because executable bits are part of the application
|
|
// input. Restore the exact recorded mode after creation; the snapshot's
|
|
// owned 0700 ancestors still keep every retained source file private.
|
|
fs.chmodSync(target,mode);
|
|
entry.executionHash = sha256(sanitized);
|
|
}
|
|
const deletedPaths:SnapshotPathIdentity[]=[...absentPaths].sort().map(path=>({path,pathId:snapshotPathId(repo,path)}));if(deletedPaths.length)manifest.deletedPaths=deletedPaths;
|
|
const assertAbsent=()=>{for(const path of absentPaths){admission.time();try{fs.lstatSync(containedFile(repo,path));}catch(error:any){if(error?.code==='ENOENT')continue;throw error;}throw new CsoError('SNAPSHOT_RACE',`Deleted source path reappeared during snapshot capture: ${path}`);}};
|
|
const assertEntriesStable=(message:string)=>{for(const e of entries){guard();if(e.originalHash==='not-read'){const current=fs.lstatSync(containedFile(repo,e.path));if(current.isSymbolicLink()||!current.isFile()||current.nlink!==1||current.size!==e.bytes||(current.mode&0o777)!==e.mode)throw new CsoError('SNAPSHOT_RACE',`${message}: ${e.path}`);}else{const current=readStable(repo,e.path,SOURCE_LIMIT);if(sha256(current.data)!==e.originalHash||current.mode!==e.mode)throw new CsoError('SNAPSHOT_RACE',`${message}: ${e.path}`);}guard();}};
|
|
if (canonical(list) !== canonical(await paths(repo,home,headCommit,admission))) throw new CsoError('SNAPSHOT_RACE','Source file membership changed during snapshot');guard();assertAbsent();assertEntriesStable('Source changed during capture');
|
|
manifest.originalHash = snapshotOriginalIdentity(entries,deletedPaths);
|
|
manifest.executionHash = sha256(canonical(entries.filter(e => e.executionHash).map(e => [e.path,e.executionHash,e.mode])));
|
|
if(manifest.baseCommit){
|
|
const tree=await git(repo,['ls-tree','-r','-z','--full-tree',manifest.baseCommit,'--'],home),baseFiles=new Map<string,{hash:string,mode:string}>();guard();
|
|
for(const row of tree.split('\0').filter(Boolean)){admission.time();const match=row.match(/^(\d+) (?:blob|commit) ([a-f0-9]+)\t(.+)$/s);if(match)baseFiles.set(relativePath(match[3]),{mode:match[1],hash:match[2]});admission.count(baseFiles.size);}
|
|
const differs=(path:string):boolean=>{const baseEntry=baseFiles.get(path),hash=gitHashes.get(path),mode=gitModes.get(path);return !baseEntry||hash!==baseEntry.hash||mode!==baseEntry.mode;};
|
|
manifest.changedPaths=[...new Set([...list.filter(differs),...baseFiles.keys()].filter(path=>differs(path)||!gitModes.has(path)))].sort();
|
|
}
|
|
try{
|
|
if(!headCommit){atomicWriteSync(join(runDir,'history.txt'),'',{mode:0o600});writeJson(join(runDir,'history-status.json'),{status:'captured',range:'unborn HEAD',commits:0,bytes:0});}
|
|
else{
|
|
const range=manifest.baseCommit?`${manifest.baseCommit}..${headCommit}`:headCommit;
|
|
admission.time();const raw=await git(repo,['-c','core.quotePath=false','log','--no-ext-diff','--no-textconv','--max-count=100','--format=commit %H%nAuthor: %an%nDate: %aI%nSubject: %s','--unified=3','-p',range,'--'],home);guard();const safe=redact(raw);
|
|
atomicWriteSync(join(runDir,'history.txt'),safe,{mode:0o600});writeJson(join(runDir,'history-status.json'),{status:'captured',range,commits:'at most 100',bytes:Buffer.byteLength(safe)});
|
|
}
|
|
}catch(error){if(error instanceof CsoError&&['DEADLINE','SNAPSHOT_RACE','UNSAFE_PATH'].includes(error.code))throw error;writeJson(join(runDir,'history-status.json'),{status:'not_assessed',gap:error instanceof CsoError?error.message:'Historical evidence could not be safely retained'});}
|
|
if((await resolveHeadCommit(repo,home))!==headCommit)throw new CsoError('SNAPSHOT_RACE','HEAD changed during snapshot capture');guard();
|
|
if(base&&manifest.baseCommit&&(await git(repo,['rev-parse','--verify',`${base}^{commit}`],home)).trim()!==manifest.baseCommit)throw new CsoError('SNAPSHOT_RACE','Comparison base changed during snapshot capture');guard();
|
|
if(requiredAncestor){
|
|
if(!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(requiredAncestor))throw new CsoError('INCOMPATIBLE_INPUT','Original audit commit identity is invalid');
|
|
if(!headCommit)throw new CsoError('INCOMPATIBLE_INPUT','Captured current source has no commit descended from the original audit');
|
|
try{await git(repo,['merge-base','--is-ancestor',requiredAncestor,headCommit],home);}
|
|
catch{throw new CsoError('INCOMPATIBLE_INPUT','Captured current source is not a descendant of the original audited commit');}
|
|
guard();
|
|
}
|
|
// Finish with a complete source check. Nothing below this block reads the
|
|
// audited repository, so a late nonignored file or restored deletion cannot
|
|
// fall between the final inventory and manifest publication.
|
|
await rejectSpecialFiles(repo,home,admission);guard();assertEntriesStable('Source changed before snapshot persistence');
|
|
if(canonical(list)!==canonical(await paths(repo,home,headCommit,admission)))throw new CsoError('SNAPSHOT_RACE','Source file membership changed before snapshot persistence');guard();assertAbsent();
|
|
admission.time();writeHelperJson(join(runDir,'sensitive-evidence.json'),sensitiveEvidence);
|
|
// The manifest contains helper-computed identities and source pathnames but
|
|
// never source payloads. Persist it exactly in private state: generic
|
|
// content redaction would silently break the path/hash identity relation.
|
|
const serialized=JSON.stringify(manifest,null,2);if(Buffer.byteLength(serialized)+1>MAX_OUTPUT)throw new CsoError('MISSING_INPUT','Snapshot manifest exceeds the 1 MiB private-state admission limit');atomicWriteSync(join(runDir,'snapshot.json'),serialized+'\n',{mode:0o600}); return manifest;
|
|
} catch(e) {
|
|
fs.rmSync(snapshot,{recursive:true,force:true}); fs.rmSync(readable,{recursive:true,force:true});fs.rmSync(home,{recursive:true,force:true}); throw e;
|
|
}
|
|
}
|
|
export function assertSnapshot(runDir: string, manifest: SnapshotManifest): void {
|
|
const root=join(runDir,'snapshot');
|
|
if (manifest.version!==3||typeof manifest.root!=='string'||!isAbsolute(manifest.root)||!Array.isArray(manifest.entries)||(manifest.deletedPaths!==undefined&&!Array.isArray(manifest.deletedPaths))||(manifest.headCommit!==undefined&&!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(manifest.headCommit))||(manifest.baseCommit!==undefined&&!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(manifest.baseCommit))||!/^\d{4}-\d\d-\d\dT/.test(manifest.expiresAt)||Date.parse(manifest.expiresAt) <= Date.now() || !fs.existsSync(root)) throw new CsoError('MISSING_INPUT','Retained source expired or invalid; supply source with exactly matching required hashes');
|
|
const rootStat=fs.lstatSync(root);if(rootStat.isSymbolicLink()||!rootStat.isDirectory()||(process.getuid&&rootStat.uid!==process.getuid()))throw new CsoError('UNSAFE_PATH','Retained snapshot root is not a private owned directory');
|
|
const listed=():string[]=>{const out:string[]=[];const walk=(at:string,relativeRoot='')=>{for(const item of fs.readdirSync(at,{withFileTypes:true})){const rel=relativeRoot?`${relativeRoot}/${item.name}`:item.name,full=join(at,item.name),stat=fs.lstatSync(full);if(stat.isSymbolicLink()||(!stat.isDirectory()&&!stat.isFile()))throw new CsoError('UNSAFE_PATH',`Special file entered retained snapshot: ${rel}`);if(stat.isDirectory())walk(full,rel);else out.push(relativePath(rel));}};walk(root);return out.sort();};
|
|
const entries=manifest.entries.map(e=>{if(!e||typeof e!=='object')throw new CsoError('INCOMPATIBLE_INPUT','Snapshot manifest contains an invalid entry');const path=relativePath(e.path);if(!/^[a-f0-9]{32}$/.test(e.pathId)||e.pathId!==snapshotPathId(manifest.root,path)||!(/^[a-f0-9]{64}$/.test(e.originalHash)||e.originalHash==='not-read')||!Number.isSafeInteger(e.bytes)||e.bytes<0||!Number.isInteger(e.mode)||e.mode<0||e.mode>0o777||(e.transformation!==undefined&&typeof e.transformation!=='string'))throw new CsoError('INCOMPATIBLE_INPUT','Snapshot manifest contains an invalid source entry');if(e.executionHash!==undefined&&!/^[a-f0-9]{64}$/.test(e.executionHash))throw new CsoError('INCOMPATIBLE_INPUT','Snapshot manifest contains an invalid execution entry');if(e.originalHash==='not-read'&&(e.executionHash!==undefined||!e.transformation))throw new CsoError('INCOMPATIBLE_INPUT','Unread source cannot be represented as an execution input');return{...e,path};});
|
|
const deleted=(manifest.deletedPaths??[]).map(item=>{if(!item||typeof item!=='object'||Object.keys(item).some(key=>!['path','pathId'].includes(key)))throw new CsoError('INCOMPATIBLE_INPUT','Snapshot manifest contains an invalid deleted path');const path=relativePath(item.path);if(!/^[a-f0-9]{32}$/.test(item.pathId)||item.pathId!==snapshotPathId(manifest.root,path))throw new CsoError('INCOMPATIBLE_INPUT','Snapshot manifest contains an invalid deleted path');return{path,pathId:item.pathId};});
|
|
const presentPaths=new Set(entries.map(e=>e.path)),presentIds=new Set(entries.map(e=>e.pathId)),deletedPaths=new Set(deleted.map(item=>item.path)),deletedIds=new Set(deleted.map(item=>item.pathId));
|
|
if(presentPaths.size!==entries.length||presentIds.size!==entries.length||deletedPaths.size!==deleted.length||deletedIds.size!==deleted.length||deleted.some(item=>presentPaths.has(item.path)||presentIds.has(item.pathId))||canonical(deleted.map(item=>item.path))!==canonical([...deletedPaths].sort())||!/^([a-f0-9]{64})$/.test(manifest.originalHash)||snapshotOriginalIdentity(entries,deleted)!==manifest.originalHash)throw new CsoError('INCOMPATIBLE_INPUT','Snapshot original identity is inconsistent');
|
|
if(manifest.changedPaths!==undefined){if(!Array.isArray(manifest.changedPaths))throw new CsoError('INCOMPATIBLE_INPUT','Snapshot changed paths are invalid');const changed=manifest.changedPaths.map(relativePath);if(new Set(changed).size!==changed.length||canonical(changed)!==canonical([...changed].sort()))throw new CsoError('INCOMPATIBLE_INPUT','Snapshot changed paths are invalid');}
|
|
// Capture already records entries in Git's deterministic code-unit path
|
|
// order. Preserve that manifest order here: localeCompare can reorder an
|
|
// uppercase path such as README.md after lowercase source files, producing
|
|
// a different execution identity from the one written at capture time.
|
|
const expected=entries.filter(e=>e.executionHash);
|
|
if(!/^([a-f0-9]{64})$/.test(manifest.executionHash)||sha256(canonical(expected.map(e=>[e.path,e.executionHash,e.mode])))!==manifest.executionHash)throw new CsoError('INCOMPATIBLE_INPUT','Snapshot execution identity is inconsistent');
|
|
const before=listed();if(canonical(before)!==canonical(expected.map(e=>e.path)))throw new CsoError('INCOMPATIBLE_INPUT','Retained snapshot membership changed');
|
|
for (const e of expected) {
|
|
const current=readStable(root,e.path);
|
|
if (sha256(current.data) !== e.executionHash || current.mode!==e.mode) throw new CsoError('INCOMPATIBLE_INPUT',`Retained snapshot changed: ${e.path}`);
|
|
}
|
|
if(canonical(before)!==canonical(listed()))throw new CsoError('SNAPSHOT_RACE','Retained snapshot membership changed during validation');
|
|
}
|