mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-02 17:40:02 +02:00
style(cso): format lib/cso TypeScript with pinned Prettier
Mechanical reformat only. Minified transpile output is byte-identical for 21 of 22 files; witness.ts differs only in three regex flag orders (/mi -> /im), which JavaScript canonicalizes. Source-text assertions over lib/cso now compare whitespace-insensitively with the same tokens.
This commit is contained in:
1 parent
dcaea52800
commit
d93d61f7ba
27 files changed
+17942
-4164
No files matched your search
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"printWidth": 110,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"semi": true
|
||||
}
|
||||
+421
-113
@@ -6,160 +6,468 @@ import { CsoError, sha256 } from './contracts';
|
||||
import { discardAtomicNoReplaceTemp, recoverAtomicNoReplaceJson, secureDirectory } from './state';
|
||||
import { atomicWriteSync } from '../fs-atomic';
|
||||
|
||||
export const GROUP_LIMITS = { cpu: 2, memoryMiB: 4096, pids: 256, writableMiB: 2048, outputBytes: 1024 * 1024 } as const;
|
||||
export const GROUP_LIMITS = {
|
||||
cpu: 2,
|
||||
memoryMiB: 4096,
|
||||
pids: 256,
|
||||
writableMiB: 2048,
|
||||
outputBytes: 1024 * 1024,
|
||||
} as const;
|
||||
export const ROLE_LIMITS = {
|
||||
anchor: {cpu:.05,memoryMiB:64,pids:8,writableMiB:16},
|
||||
app: {cpu:.85,memoryMiB:2304,pids:96,writableMiB:1264},
|
||||
verifier: {cpu:.55,memoryMiB:512,pids:32,writableMiB:256},
|
||||
tests: {cpu:.55,memoryMiB:1280,pids:64,writableMiB:1024},
|
||||
postgres: {cpu:.25,memoryMiB:1024,pids:96,writableMiB:512},
|
||||
browser: {cpu:.30,memoryMiB:512,pids:16,writableMiB:256},
|
||||
anchor: { cpu: 0.05, memoryMiB: 64, pids: 8, writableMiB: 16 },
|
||||
app: { cpu: 0.85, memoryMiB: 2304, pids: 96, writableMiB: 1264 },
|
||||
verifier: { cpu: 0.55, memoryMiB: 512, pids: 32, writableMiB: 256 },
|
||||
tests: { cpu: 0.55, memoryMiB: 1280, pids: 64, writableMiB: 1024 },
|
||||
postgres: { cpu: 0.25, memoryMiB: 1024, pids: 96, writableMiB: 512 },
|
||||
browser: { cpu: 0.3, memoryMiB: 512, pids: 16, writableMiB: 256 },
|
||||
} as const;
|
||||
export type Role = keyof typeof ROLE_LIMITS;
|
||||
export interface Lease { endpoint: string; slot: number; path: string; runId: string; ownerPid: number; expiresAt: number; token:string; supervised:boolean }
|
||||
function alive(pid: number): boolean { try { process.kill(pid,0); return true; } catch { return false; } }
|
||||
function processIdentity(pid:number):string|undefined{if(process.platform!=='linux')return;try{const raw=fs.readFileSync(`/proc/${pid}/stat`,'utf8'),tail=raw.slice(raw.lastIndexOf(')')+2).trim().split(/\s+/);return /^\d+$/.test(tail[19]??'')?`linux:${tail[19]}`:undefined;}catch{return;}}
|
||||
function sameDirectory(left:fs.Stats,right:fs.Stats):boolean{return left.dev===right.dev&&left.ino===right.ino&&left.uid===right.uid&&left.mode===right.mode;}
|
||||
function sameFile(left:fs.Stats,right:fs.Stats):boolean{return left.dev===right.dev&&left.ino===right.ino&&left.uid===right.uid&&left.mode===right.mode&&left.nlink===right.nlink;}
|
||||
function privateDirectory(path:string,label:string):fs.Stats{const stat=fs.lstatSync(path);if(!stat.isDirectory()||stat.isSymbolicLink()||(process.getuid&&stat.uid!==process.getuid())||(stat.mode&0o077)!==0)throw new CsoError('UNSAFE_PATH',`${label} is not a private owned directory`);return stat;}
|
||||
function privateFile(path:string,label:string):fs.Stats{const stat=fs.lstatSync(path);if(!stat.isFile()||stat.isSymbolicLink()||stat.nlink!==1||(process.getuid&&stat.uid!==process.getuid())||(stat.mode&0o077)!==0||stat.size>1024*1024)throw new CsoError('UNSAFE_PATH',`${label} is not a private regular file`);return stat;}
|
||||
type Claim={path:string;token:string;identity:fs.Stats;pid:number;processIdentity:string|null};
|
||||
type ClaimOwner={pid:number;processIdentity:string|null;token:string;createdAt:number};
|
||||
function validateClaimOwner(value:unknown,expectedToken?:string,publisherPid?:number):ClaimOwner{
|
||||
if(!value||typeof value!=='object'||Array.isArray(value))throw new CsoError('INCOMPATIBLE_INPUT','Reproduction recovery owner is invalid');
|
||||
const owner=value as Record<string,unknown>;
|
||||
if(Object.keys(owner).sort().join(',')!=='createdAt,pid,processIdentity,token'||!Number.isSafeInteger(owner.pid)||Number(owner.pid)<=1||
|
||||
typeof owner.token!=='string'||!/^[a-f0-9]{32}$/.test(owner.token)||(expectedToken!==undefined&&owner.token!==expectedToken)||
|
||||
!Number.isFinite(owner.createdAt)||Number(owner.createdAt)<0||!(owner.processIdentity===null||(typeof owner.processIdentity==='string'&&/^linux:\d+$/.test(owner.processIdentity)))||
|
||||
(publisherPid!==undefined&&Number(owner.pid)!==publisherPid))throw new CsoError('INCOMPATIBLE_INPUT','Reproduction recovery owner is invalid');
|
||||
return{pid:Number(owner.pid),processIdentity:owner.processIdentity as string|null,token:owner.token,createdAt:Number(owner.createdAt)};
|
||||
export interface Lease {
|
||||
endpoint: string;
|
||||
slot: number;
|
||||
path: string;
|
||||
runId: string;
|
||||
ownerPid: number;
|
||||
expiresAt: number;
|
||||
token: string;
|
||||
supervised: boolean;
|
||||
}
|
||||
function inspectClaim(path:string,expectedToken?:string):Claim{
|
||||
const before=privateFile(path,'Reproduction recovery claim');if(before.size<=0||before.size>4096)throw new CsoError('UNSAFE_PATH','Reproduction recovery claim has an invalid size');
|
||||
let owner:any;try{owner=JSON.parse(fs.readFileSync(path,'utf8'));}catch{throw new CsoError('INCOMPATIBLE_INPUT','Reproduction recovery owner is invalid');}
|
||||
const after=privateFile(path,'Reproduction recovery claim');if(!sameFile(before,after))throw new CsoError('INCOMPATIBLE_INPUT','Reproduction recovery owner is invalid');
|
||||
owner=validateClaimOwner(owner,expectedToken);
|
||||
return{path,token:owner.token,identity:after,pid:owner.pid,processIdentity:owner.processIdentity};
|
||||
function alive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function releaseClaim(claim:Claim):void{
|
||||
const current=inspectClaim(claim.path,claim.token);if(!sameFile(current.identity,claim.identity))throw new CsoError('PERSISTENCE_FAILED','Reproduction recovery ownership changed');
|
||||
const final=privateFile(claim.path,'Reproduction recovery claim');if(!sameFile(final,claim.identity))throw new CsoError('PERSISTENCE_FAILED','Reproduction recovery ownership changed');
|
||||
function processIdentity(pid: number): string | undefined {
|
||||
if (process.platform !== 'linux') return;
|
||||
try {
|
||||
const raw = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'),
|
||||
tail = raw
|
||||
.slice(raw.lastIndexOf(')') + 2)
|
||||
.trim()
|
||||
.split(/\s+/);
|
||||
return /^\d+$/.test(tail[19] ?? '') ? `linux:${tail[19]}` : undefined;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
function sameDirectory(left: fs.Stats, right: fs.Stats): boolean {
|
||||
return (
|
||||
left.dev === right.dev && left.ino === right.ino && left.uid === right.uid && left.mode === right.mode
|
||||
);
|
||||
}
|
||||
function sameFile(left: fs.Stats, right: fs.Stats): boolean {
|
||||
return (
|
||||
left.dev === right.dev &&
|
||||
left.ino === right.ino &&
|
||||
left.uid === right.uid &&
|
||||
left.mode === right.mode &&
|
||||
left.nlink === right.nlink
|
||||
);
|
||||
}
|
||||
function privateDirectory(path: string, label: string): fs.Stats {
|
||||
const stat = fs.lstatSync(path);
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
(process.getuid && stat.uid !== process.getuid()) ||
|
||||
(stat.mode & 0o077) !== 0
|
||||
)
|
||||
throw new CsoError('UNSAFE_PATH', `${label} is not a private owned directory`);
|
||||
return stat;
|
||||
}
|
||||
function privateFile(path: string, label: string): fs.Stats {
|
||||
const stat = fs.lstatSync(path);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.nlink !== 1 ||
|
||||
(process.getuid && stat.uid !== process.getuid()) ||
|
||||
(stat.mode & 0o077) !== 0 ||
|
||||
stat.size > 1024 * 1024
|
||||
)
|
||||
throw new CsoError('UNSAFE_PATH', `${label} is not a private regular file`);
|
||||
return stat;
|
||||
}
|
||||
type Claim = { path: string; token: string; identity: fs.Stats; pid: number; processIdentity: string | null };
|
||||
type ClaimOwner = { pid: number; processIdentity: string | null; token: string; createdAt: number };
|
||||
function validateClaimOwner(value: unknown, expectedToken?: string, publisherPid?: number): ClaimOwner {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
throw new CsoError('INCOMPATIBLE_INPUT', 'Reproduction recovery owner is invalid');
|
||||
const owner = value as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(owner).sort().join(',') !== 'createdAt,pid,processIdentity,token' ||
|
||||
!Number.isSafeInteger(owner.pid) ||
|
||||
Number(owner.pid) <= 1 ||
|
||||
typeof owner.token !== 'string' ||
|
||||
!/^[a-f0-9]{32}$/.test(owner.token) ||
|
||||
(expectedToken !== undefined && owner.token !== expectedToken) ||
|
||||
!Number.isFinite(owner.createdAt) ||
|
||||
Number(owner.createdAt) < 0 ||
|
||||
!(
|
||||
owner.processIdentity === null ||
|
||||
(typeof owner.processIdentity === 'string' && /^linux:\d+$/.test(owner.processIdentity))
|
||||
) ||
|
||||
(publisherPid !== undefined && Number(owner.pid) !== publisherPid)
|
||||
)
|
||||
throw new CsoError('INCOMPATIBLE_INPUT', 'Reproduction recovery owner is invalid');
|
||||
return {
|
||||
pid: Number(owner.pid),
|
||||
processIdentity: owner.processIdentity as string | null,
|
||||
token: owner.token,
|
||||
createdAt: Number(owner.createdAt),
|
||||
};
|
||||
}
|
||||
function inspectClaim(path: string, expectedToken?: string): Claim {
|
||||
const before = privateFile(path, 'Reproduction recovery claim');
|
||||
if (before.size <= 0 || before.size > 4096)
|
||||
throw new CsoError('UNSAFE_PATH', 'Reproduction recovery claim has an invalid size');
|
||||
let owner: any;
|
||||
try {
|
||||
owner = JSON.parse(fs.readFileSync(path, 'utf8'));
|
||||
} catch {
|
||||
throw new CsoError('INCOMPATIBLE_INPUT', 'Reproduction recovery owner is invalid');
|
||||
}
|
||||
const after = privateFile(path, 'Reproduction recovery claim');
|
||||
if (!sameFile(before, after))
|
||||
throw new CsoError('INCOMPATIBLE_INPUT', 'Reproduction recovery owner is invalid');
|
||||
owner = validateClaimOwner(owner, expectedToken);
|
||||
return {
|
||||
path,
|
||||
token: owner.token,
|
||||
identity: after,
|
||||
pid: owner.pid,
|
||||
processIdentity: owner.processIdentity,
|
||||
};
|
||||
}
|
||||
function releaseClaim(claim: Claim): void {
|
||||
const current = inspectClaim(claim.path, claim.token);
|
||||
if (!sameFile(current.identity, claim.identity))
|
||||
throw new CsoError('PERSISTENCE_FAILED', 'Reproduction recovery ownership changed');
|
||||
const final = privateFile(claim.path, 'Reproduction recovery claim');
|
||||
if (!sameFile(final, claim.identity))
|
||||
throw new CsoError('PERSISTENCE_FAILED', 'Reproduction recovery ownership changed');
|
||||
fs.unlinkSync(claim.path);
|
||||
}
|
||||
function acquireClaim(parent:string,expected:fs.Stats):Claim{
|
||||
const path=join(parent,'.recovery'),assertParent=()=>{const current=privateDirectory(parent,'Reproduction lease slot');if(!sameDirectory(expected,current))throw new CsoError('INSUFFICIENT_CAPACITY','Reproduction lease changed during recovery');};
|
||||
const recoverPublications=()=>{
|
||||
const pattern=/^\.recovery\.tmp\.(\d{1,10})\.[a-f0-9]{8}$/;
|
||||
for(const name of fs.readdirSync(parent)){
|
||||
const match=name.match(pattern);if(!match)continue;
|
||||
const publisherPid=Number(match[1]),temporary=join(parent,name),options={label:'Reproduction recovery claim',maxBytes:4096,
|
||||
validate:(value:unknown,pid:number)=>{validateClaimOwner(value,undefined,pid);}};
|
||||
assertParent();if(fs.existsSync(path))recoverAtomicNoReplaceJson(path,options);if(fs.existsSync(temporary))discardAtomicNoReplaceTemp(temporary,publisherPid,options);assertParent();
|
||||
function acquireClaim(parent: string, expected: fs.Stats): Claim {
|
||||
const path = join(parent, '.recovery'),
|
||||
assertParent = () => {
|
||||
const current = privateDirectory(parent, 'Reproduction lease slot');
|
||||
if (!sameDirectory(expected, current))
|
||||
throw new CsoError('INSUFFICIENT_CAPACITY', 'Reproduction lease changed during recovery');
|
||||
};
|
||||
const recoverPublications = () => {
|
||||
const pattern = /^\.recovery\.tmp\.(\d{1,10})\.[a-f0-9]{8}$/;
|
||||
for (const name of fs.readdirSync(parent)) {
|
||||
const match = name.match(pattern);
|
||||
if (!match) continue;
|
||||
const publisherPid = Number(match[1]),
|
||||
temporary = join(parent, name),
|
||||
options = {
|
||||
label: 'Reproduction recovery claim',
|
||||
maxBytes: 4096,
|
||||
validate: (value: unknown, pid: number) => {
|
||||
validateClaimOwner(value, undefined, pid);
|
||||
},
|
||||
};
|
||||
assertParent();
|
||||
if (fs.existsSync(path)) recoverAtomicNoReplaceJson(path, options);
|
||||
if (fs.existsSync(temporary)) discardAtomicNoReplaceTemp(temporary, publisherPid, options);
|
||||
assertParent();
|
||||
}
|
||||
};
|
||||
for(let attempt=0;attempt<64;attempt++){
|
||||
assertParent();recoverPublications();const token=randomBytes(16).toString('hex');
|
||||
try{
|
||||
atomicWriteSync(path,JSON.stringify({pid:process.pid,processIdentity:processIdentity(process.pid)??null,token,createdAt:Date.now()})+'\n',{mode:0o600,noReplace:true});
|
||||
const claim=inspectClaim(path,token);try{assertParent();}catch(error){try{releaseClaim(claim);}catch{}throw error;}return claim;
|
||||
}catch(error:any){
|
||||
if(error instanceof CsoError)throw error;
|
||||
if(error?.code!=='EEXIST')throw new CsoError('PERSISTENCE_FAILED','Reproduction recovery claim could not be created');
|
||||
for (let attempt = 0; attempt < 64; attempt++) {
|
||||
assertParent();
|
||||
recoverPublications();
|
||||
const token = randomBytes(16).toString('hex');
|
||||
try {
|
||||
atomicWriteSync(
|
||||
path,
|
||||
JSON.stringify({
|
||||
pid: process.pid,
|
||||
processIdentity: processIdentity(process.pid) ?? null,
|
||||
token,
|
||||
createdAt: Date.now(),
|
||||
}) + '\n',
|
||||
{ mode: 0o600, noReplace: true },
|
||||
);
|
||||
const claim = inspectClaim(path, token);
|
||||
try {
|
||||
assertParent();
|
||||
} catch (error) {
|
||||
try {
|
||||
releaseClaim(claim);
|
||||
} catch {}
|
||||
throw error;
|
||||
}
|
||||
return claim;
|
||||
} catch (error: any) {
|
||||
if (error instanceof CsoError) throw error;
|
||||
if (error?.code !== 'EEXIST')
|
||||
throw new CsoError('PERSISTENCE_FAILED', 'Reproduction recovery claim could not be created');
|
||||
}
|
||||
assertParent();
|
||||
const observed = inspectClaim(path),
|
||||
isAlive = alive(observed.pid),
|
||||
identity = isAlive ? processIdentity(observed.pid) : undefined;
|
||||
if (
|
||||
isAlive &&
|
||||
!(
|
||||
typeof observed.processIdentity === 'string' &&
|
||||
identity !== undefined &&
|
||||
identity !== observed.processIdentity
|
||||
)
|
||||
)
|
||||
throw new CsoError('INSUFFICIENT_CAPACITY', 'Another helper is recovering the reproduction lease');
|
||||
try {
|
||||
releaseClaim(observed);
|
||||
} catch (error) {
|
||||
if (error instanceof CsoError && error.code === 'PERSISTENCE_FAILED') continue;
|
||||
throw error;
|
||||
}
|
||||
assertParent();const observed=inspectClaim(path),isAlive=alive(observed.pid),identity=isAlive?processIdentity(observed.pid):undefined;
|
||||
if(isAlive&&!(typeof observed.processIdentity==='string'&&identity!==undefined&&identity!==observed.processIdentity))throw new CsoError('INSUFFICIENT_CAPACITY','Another helper is recovering the reproduction lease');
|
||||
try{releaseClaim(observed);}catch(error){if(error instanceof CsoError&&error.code==='PERSISTENCE_FAILED')continue;throw error;}
|
||||
}
|
||||
throw new CsoError('INSUFFICIENT_CAPACITY','Reproduction recovery claim changed repeatedly');
|
||||
throw new CsoError('INSUFFICIENT_CAPACITY', 'Reproduction recovery claim changed repeatedly');
|
||||
}
|
||||
/** One host-user pool shared by every workspace/state root on this machine. */
|
||||
export function machinePoolRoot():string{
|
||||
const uid=process.getuid?.()??userInfo().uid;
|
||||
return secureDirectory(join(fs.realpathSync(tmpdir()),`gstack-cso-pool-${uid}`));
|
||||
export function machinePoolRoot(): string {
|
||||
const uid = process.getuid?.() ?? userInfo().uid;
|
||||
return secureDirectory(join(fs.realpathSync(tmpdir()), `gstack-cso-pool-${uid}`));
|
||||
}
|
||||
function reclaimSlot(path:string,pool:string,slot:number,observed:fs.Stats,expectedToken?:string):boolean{
|
||||
let claim:Claim;try{claim=acquireClaim(path,observed);}catch(error){if(error instanceof CsoError&&error.code==='INSUFFICIENT_CAPACITY')return false;throw error;}
|
||||
try{const current=privateDirectory(path,'Reproduction lease slot');if(!sameDirectory(observed,current)){releaseClaim(claim);return false;}if(expectedToken){privateFile(join(path,'lease.json'),'Reproduction lease');const lease=JSON.parse(fs.readFileSync(join(path,'lease.json'),'utf8'));if(lease.token!==expectedToken){releaseClaim(claim);return false;}}
|
||||
const tomb=join(pool,`.slot-${slot}.stale-${process.pid}-${randomBytes(8).toString('hex')}`);fs.renameSync(path,tomb);const moved=privateDirectory(tomb,'Reproduction lease tomb');if(!sameDirectory(observed,moved))throw new CsoError('SNAPSHOT_RACE','Reproduction lease changed while quarantined');fs.mkdirSync(path,{mode:0o700});releaseClaim({...claim,path:join(tomb,'.recovery')});for(const name of fs.readdirSync(tomb)){if(!['lease.json','lease.token'].includes(name)&&!/^lease\.json\.tmp\.\d+\.[a-f0-9]{8}$/.test(name)&&!/^\.recovery\.tmp\.\d+\.[a-f0-9]{8}$/.test(name))throw new CsoError('UNSAFE_PATH','Stale reproduction lease contains an unexpected object');privateFile(join(tomb,name),'Stale reproduction lease file');fs.unlinkSync(join(tomb,name));}fs.rmdirSync(tomb);return true;
|
||||
}catch(error){if(error instanceof CsoError)throw error;return false;}
|
||||
function reclaimSlot(
|
||||
path: string,
|
||||
pool: string,
|
||||
slot: number,
|
||||
observed: fs.Stats,
|
||||
expectedToken?: string,
|
||||
): boolean {
|
||||
let claim: Claim;
|
||||
try {
|
||||
claim = acquireClaim(path, observed);
|
||||
} catch (error) {
|
||||
if (error instanceof CsoError && error.code === 'INSUFFICIENT_CAPACITY') return false;
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const current = privateDirectory(path, 'Reproduction lease slot');
|
||||
if (!sameDirectory(observed, current)) {
|
||||
releaseClaim(claim);
|
||||
return false;
|
||||
}
|
||||
if (expectedToken) {
|
||||
privateFile(join(path, 'lease.json'), 'Reproduction lease');
|
||||
const lease = JSON.parse(fs.readFileSync(join(path, 'lease.json'), 'utf8'));
|
||||
if (lease.token !== expectedToken) {
|
||||
releaseClaim(claim);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const tomb = join(pool, `.slot-${slot}.stale-${process.pid}-${randomBytes(8).toString('hex')}`);
|
||||
fs.renameSync(path, tomb);
|
||||
const moved = privateDirectory(tomb, 'Reproduction lease tomb');
|
||||
if (!sameDirectory(observed, moved))
|
||||
throw new CsoError('SNAPSHOT_RACE', 'Reproduction lease changed while quarantined');
|
||||
fs.mkdirSync(path, { mode: 0o700 });
|
||||
releaseClaim({ ...claim, path: join(tomb, '.recovery') });
|
||||
for (const name of fs.readdirSync(tomb)) {
|
||||
if (
|
||||
!['lease.json', 'lease.token'].includes(name) &&
|
||||
!/^lease\.json\.tmp\.\d+\.[a-f0-9]{8}$/.test(name) &&
|
||||
!/^\.recovery\.tmp\.\d+\.[a-f0-9]{8}$/.test(name)
|
||||
)
|
||||
throw new CsoError('UNSAFE_PATH', 'Stale reproduction lease contains an unexpected object');
|
||||
privateFile(join(tomb, name), 'Stale reproduction lease file');
|
||||
fs.unlinkSync(join(tomb, name));
|
||||
}
|
||||
fs.rmdirSync(tomb);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof CsoError) throw error;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function slotControl(pool:string,slot:number):{path:string;stat:fs.Stats}{
|
||||
const path=join(pool,`.slot-${slot}.control`);
|
||||
try{fs.mkdirSync(path,{mode:0o700});}catch(error:any){if(error?.code!=='EEXIST')throw new CsoError('PERSISTENCE_FAILED','Reproduction slot control directory could not be created');}
|
||||
const stat=privateDirectory(path,'Reproduction slot control directory');for(const name of fs.readdirSync(path))if(name!=='.recovery'&&!/^\.recovery\.tmp\.\d+\.[a-f0-9]{8}$/.test(name))throw new CsoError('UNSAFE_PATH','Reproduction slot control directory contains an unexpected object');return{path,stat};
|
||||
function slotControl(pool: string, slot: number): { path: string; stat: fs.Stats } {
|
||||
const path = join(pool, `.slot-${slot}.control`);
|
||||
try {
|
||||
fs.mkdirSync(path, { mode: 0o700 });
|
||||
} catch (error: any) {
|
||||
if (error?.code !== 'EEXIST')
|
||||
throw new CsoError('PERSISTENCE_FAILED', 'Reproduction slot control directory could not be created');
|
||||
}
|
||||
const stat = privateDirectory(path, 'Reproduction slot control directory');
|
||||
for (const name of fs.readdirSync(path))
|
||||
if (name !== '.recovery' && !/^\.recovery\.tmp\.\d+\.[a-f0-9]{8}$/.test(name))
|
||||
throw new CsoError('UNSAFE_PATH', 'Reproduction slot control directory contains an unexpected object');
|
||||
return { path, stat };
|
||||
}
|
||||
function writeLease(lease:Lease):void{
|
||||
const keys=Object.keys(lease).sort().join(','),expected='endpoint,expiresAt,ownerPid,path,runId,slot,supervised,token';
|
||||
if(keys!==expected||!/^unix:\/\/[/.A-Za-z0-9_-]+$/.test(lease.endpoint)||![0,1].includes(lease.slot)||
|
||||
!/^[A-Za-z0-9_.-]{1,100}$/.test(lease.runId)||lease.ownerPid!==process.pid||!Number.isSafeInteger(lease.expiresAt)||
|
||||
!/^[a-f0-9]{32}$/.test(lease.token)||typeof lease.supervised!=='boolean')
|
||||
throw new CsoError('PERSISTENCE_FAILED','Reproduction lease metadata is invalid');
|
||||
const expectedPath=join(machinePoolRoot(),sha256(lease.endpoint).slice(0,24),`slot-${lease.slot}`);
|
||||
if(lease.path!==expectedPath)throw new CsoError('PERSISTENCE_FAILED','Reproduction lease path is invalid');
|
||||
function writeLease(lease: Lease): void {
|
||||
const keys = Object.keys(lease).sort().join(','),
|
||||
expected = 'endpoint,expiresAt,ownerPid,path,runId,slot,supervised,token';
|
||||
if (
|
||||
keys !== expected ||
|
||||
!/^unix:\/\/[/.A-Za-z0-9_-]+$/.test(lease.endpoint) ||
|
||||
![0, 1].includes(lease.slot) ||
|
||||
!/^[A-Za-z0-9_.-]{1,100}$/.test(lease.runId) ||
|
||||
lease.ownerPid !== process.pid ||
|
||||
!Number.isSafeInteger(lease.expiresAt) ||
|
||||
!/^[a-f0-9]{32}$/.test(lease.token) ||
|
||||
typeof lease.supervised !== 'boolean'
|
||||
)
|
||||
throw new CsoError('PERSISTENCE_FAILED', 'Reproduction lease metadata is invalid');
|
||||
const expectedPath = join(machinePoolRoot(), sha256(lease.endpoint).slice(0, 24), `slot-${lease.slot}`);
|
||||
if (lease.path !== expectedPath)
|
||||
throw new CsoError('PERSISTENCE_FAILED', 'Reproduction lease path is invalid');
|
||||
// This exact helper-owned schema contains only control metadata. In
|
||||
// particular, its random capability may resemble a wallet address and must
|
||||
// remain byte-identical to lease.token; untrusted reports still use writeJson.
|
||||
atomicWriteSync(join(lease.path,'lease.json'),JSON.stringify(lease)+'\n',{mode:0o600});
|
||||
atomicWriteSync(join(lease.path, 'lease.json'), JSON.stringify(lease) + '\n', { mode: 0o600 });
|
||||
}
|
||||
export function admit(endpoint: string, runId: string, deadline: number): Lease {
|
||||
if (!/^unix:\/\/[/.A-Za-z0-9_-]+$/.test(endpoint)) throw new CsoError('ISOLATION_FAILED','Only a pinned local Unix Docker endpoint is admitted on this host');
|
||||
const pool = secureDirectory(join(machinePoolRoot(),sha256(endpoint).slice(0,24)));
|
||||
for (let slot=0;slot<2;slot++) {
|
||||
const path=join(pool,`slot-${slot}`),control=slotControl(pool,slot);let mutation:Claim;
|
||||
try{mutation=acquireClaim(control.path,control.stat);}catch(error){if(error instanceof CsoError&&error.code==='INSUFFICIENT_CAPACITY')continue;throw error;}
|
||||
try{
|
||||
if (!/^unix:\/\/[/.A-Za-z0-9_-]+$/.test(endpoint))
|
||||
throw new CsoError(
|
||||
'ISOLATION_FAILED',
|
||||
'Only a pinned local Unix Docker endpoint is admitted on this host',
|
||||
);
|
||||
const pool = secureDirectory(join(machinePoolRoot(), sha256(endpoint).slice(0, 24)));
|
||||
for (let slot = 0; slot < 2; slot++) {
|
||||
const path = join(pool, `slot-${slot}`),
|
||||
control = slotControl(pool, slot);
|
||||
let mutation: Claim;
|
||||
try {
|
||||
mutation = acquireClaim(control.path, control.stat);
|
||||
} catch (error) {
|
||||
if (error instanceof CsoError && error.code === 'INSUFFICIENT_CAPACITY') continue;
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
try {
|
||||
fs.mkdirSync(path,{mode:0o700});
|
||||
} catch(error:any) {
|
||||
if(error?.code!=='EEXIST')throw new CsoError('PERSISTENCE_FAILED','Reproduction lease slot could not be created');
|
||||
fs.mkdirSync(path, { mode: 0o700 });
|
||||
} catch (error: any) {
|
||||
if (error?.code !== 'EEXIST')
|
||||
throw new CsoError('PERSISTENCE_FAILED', 'Reproduction lease slot could not be created');
|
||||
try {
|
||||
const observed=privateDirectory(path,'Reproduction lease slot');
|
||||
const old = JSON.parse(fs.readFileSync(join(path,'lease.json'),'utf8'));
|
||||
const observed = privateDirectory(path, 'Reproduction lease slot');
|
||||
const old = JSON.parse(fs.readFileSync(join(path, 'lease.json'), 'utf8'));
|
||||
// A supervised lease is removed only after its watchdog or owner has
|
||||
// confirmed exact-resource cleanup. This preserves the two-group cap
|
||||
// through supervisor death and daemon outages.
|
||||
if (old.supervised === true || (typeof old.ownerPid === 'number' && alive(old.ownerPid))) continue;
|
||||
// Unsupervised stale slots cannot have created containers: supervision
|
||||
// is acknowledged before the anchor create call.
|
||||
if(!reclaimSlot(path,pool,slot,observed,typeof old.token==='string'?old.token:undefined))continue;
|
||||
} catch(recoveryError) {
|
||||
if(recoveryError instanceof CsoError)throw recoveryError;
|
||||
if (!reclaimSlot(path, pool, slot, observed, typeof old.token === 'string' ? old.token : undefined))
|
||||
continue;
|
||||
} catch (recoveryError) {
|
||||
if (recoveryError instanceof CsoError) throw recoveryError;
|
||||
// No live initializer can publish into this path while this stable
|
||||
// slot-control claim is held. Recover a crashed partial publication
|
||||
// only after the compatibility grace period.
|
||||
let stat:fs.Stats;try{stat=privateDirectory(path,'Reproduction lease slot');}catch(statError){if(statError instanceof CsoError)throw statError;continue;}
|
||||
if(Date.now()-stat.mtimeMs<=5000)continue;
|
||||
if(!reclaimSlot(path,pool,slot,stat))continue;
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = privateDirectory(path, 'Reproduction lease slot');
|
||||
} catch (statError) {
|
||||
if (statError instanceof CsoError) throw statError;
|
||||
continue;
|
||||
}
|
||||
if (Date.now() - stat.mtimeMs <= 5000) continue;
|
||||
if (!reclaimSlot(path, pool, slot, stat)) continue;
|
||||
}
|
||||
}
|
||||
// Both authenticated records become visible as one logical publication
|
||||
// when the stable slot-control claim is released.
|
||||
const lease:Lease={endpoint,slot,path,runId,ownerPid:process.pid,expiresAt:deadline,token:randomBytes(16).toString('hex'),supervised:false};writeLease(lease);fs.writeFileSync(join(path,'lease.token'),lease.token+'\n',{mode:0o600,flag:'wx'});return lease;
|
||||
}finally{releaseClaim(mutation);}
|
||||
const lease: Lease = {
|
||||
endpoint,
|
||||
slot,
|
||||
path,
|
||||
runId,
|
||||
ownerPid: process.pid,
|
||||
expiresAt: deadline,
|
||||
token: randomBytes(16).toString('hex'),
|
||||
supervised: false,
|
||||
};
|
||||
writeLease(lease);
|
||||
fs.writeFileSync(join(path, 'lease.token'), lease.token + '\n', { mode: 0o600, flag: 'wx' });
|
||||
return lease;
|
||||
} finally {
|
||||
releaseClaim(mutation);
|
||||
}
|
||||
}
|
||||
throw new CsoError('INSUFFICIENT_CAPACITY','Two reproduction groups are already admitted for this Docker endpoint');
|
||||
throw new CsoError(
|
||||
'INSUFFICIENT_CAPACITY',
|
||||
'Two reproduction groups are already admitted for this Docker endpoint',
|
||||
);
|
||||
}
|
||||
export function markSupervised(lease:Lease):void{
|
||||
const current=JSON.parse(fs.readFileSync(join(lease.path,'lease.json'),'utf8'));
|
||||
if(current.token!==lease.token||current.ownerPid!==lease.ownerPid)throw new CsoError('INSUFFICIENT_CAPACITY','Reproduction lease changed before watchdog supervision');
|
||||
lease.supervised=true;writeLease(lease);
|
||||
export function markSupervised(lease: Lease): void {
|
||||
const current = JSON.parse(fs.readFileSync(join(lease.path, 'lease.json'), 'utf8'));
|
||||
if (current.token !== lease.token || current.ownerPid !== lease.ownerPid)
|
||||
throw new CsoError('INSUFFICIENT_CAPACITY', 'Reproduction lease changed before watchdog supervision');
|
||||
lease.supervised = true;
|
||||
writeLease(lease);
|
||||
}
|
||||
export function release(lease: Lease): void {
|
||||
let observed:fs.Stats;try{observed=privateDirectory(lease.path,'Reproduction lease slot');}catch(error:any){if(error?.code==='ENOENT')throw new CsoError('PERSISTENCE_FAILED','Exact reproduction lease was already missing');throw error;}
|
||||
const claim=acquireClaim(lease.path,observed);
|
||||
try{
|
||||
const currentStat=privateDirectory(lease.path,'Reproduction lease slot');if(!sameDirectory(observed,currentStat))throw new CsoError('PERSISTENCE_FAILED','Reproduction lease changed before exact release');
|
||||
const names=fs.readdirSync(lease.path).filter(name=>name!=='.recovery'&&!/^\.recovery\.tmp\.\d+\.[a-f0-9]{8}$/.test(name)).sort();if(names.join('\0')!=='lease.json\0lease.token')throw new CsoError('PERSISTENCE_FAILED','Reproduction lease contents changed before exact release');
|
||||
privateFile(join(lease.path,'lease.json'),'Reproduction lease');privateFile(join(lease.path,'lease.token'),'Reproduction lease token');
|
||||
const current=JSON.parse(fs.readFileSync(join(lease.path,'lease.json'),'utf8')),token=fs.readFileSync(join(lease.path,'lease.token'),'utf8').trim();
|
||||
if(current.runId!==lease.runId||current.ownerPid!==lease.ownerPid||current.token!==lease.token||token!==lease.token)throw new CsoError('PERSISTENCE_FAILED','Reproduction lease ownership changed before exact release');
|
||||
fs.unlinkSync(join(lease.path,'lease.token'));fs.unlinkSync(join(lease.path,'lease.json'));releaseClaim(claim);fs.rmdirSync(lease.path);
|
||||
if(fs.existsSync(lease.path))throw new CsoError('PERSISTENCE_FAILED','Exact reproduction lease removal could not be proven');
|
||||
}catch(error){try{if(fs.existsSync(claim.path))releaseClaim(claim);}catch{}if(error instanceof CsoError)throw error;throw new CsoError('PERSISTENCE_FAILED','Exact reproduction lease removal failed');}
|
||||
let observed: fs.Stats;
|
||||
try {
|
||||
observed = privateDirectory(lease.path, 'Reproduction lease slot');
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'ENOENT')
|
||||
throw new CsoError('PERSISTENCE_FAILED', 'Exact reproduction lease was already missing');
|
||||
throw error;
|
||||
}
|
||||
const claim = acquireClaim(lease.path, observed);
|
||||
try {
|
||||
const currentStat = privateDirectory(lease.path, 'Reproduction lease slot');
|
||||
if (!sameDirectory(observed, currentStat))
|
||||
throw new CsoError('PERSISTENCE_FAILED', 'Reproduction lease changed before exact release');
|
||||
const names = fs
|
||||
.readdirSync(lease.path)
|
||||
.filter((name) => name !== '.recovery' && !/^\.recovery\.tmp\.\d+\.[a-f0-9]{8}$/.test(name))
|
||||
.sort();
|
||||
if (names.join('\0') !== 'lease.json\0lease.token')
|
||||
throw new CsoError('PERSISTENCE_FAILED', 'Reproduction lease contents changed before exact release');
|
||||
privateFile(join(lease.path, 'lease.json'), 'Reproduction lease');
|
||||
privateFile(join(lease.path, 'lease.token'), 'Reproduction lease token');
|
||||
const current = JSON.parse(fs.readFileSync(join(lease.path, 'lease.json'), 'utf8')),
|
||||
token = fs.readFileSync(join(lease.path, 'lease.token'), 'utf8').trim();
|
||||
if (
|
||||
current.runId !== lease.runId ||
|
||||
current.ownerPid !== lease.ownerPid ||
|
||||
current.token !== lease.token ||
|
||||
token !== lease.token
|
||||
)
|
||||
throw new CsoError('PERSISTENCE_FAILED', 'Reproduction lease ownership changed before exact release');
|
||||
fs.unlinkSync(join(lease.path, 'lease.token'));
|
||||
fs.unlinkSync(join(lease.path, 'lease.json'));
|
||||
releaseClaim(claim);
|
||||
fs.rmdirSync(lease.path);
|
||||
if (fs.existsSync(lease.path))
|
||||
throw new CsoError('PERSISTENCE_FAILED', 'Exact reproduction lease removal could not be proven');
|
||||
} catch (error) {
|
||||
try {
|
||||
if (fs.existsSync(claim.path)) releaseClaim(claim);
|
||||
} catch {}
|
||||
if (error instanceof CsoError) throw error;
|
||||
throw new CsoError('PERSISTENCE_FAILED', 'Exact reproduction lease removal failed');
|
||||
}
|
||||
}
|
||||
export function total(roles: Role[]) {
|
||||
const value = roles.reduce((a,r) => ({cpu:a.cpu+ROLE_LIMITS[r].cpu,memoryMiB:a.memoryMiB+ROLE_LIMITS[r].memoryMiB,pids:a.pids+ROLE_LIMITS[r].pids,writableMiB:a.writableMiB+ROLE_LIMITS[r].writableMiB}), {cpu:0,memoryMiB:0,pids:0,writableMiB:0});
|
||||
if (value.cpu > GROUP_LIMITS.cpu || value.memoryMiB > GROUP_LIMITS.memoryMiB || value.pids > GROUP_LIMITS.pids || value.writableMiB > GROUP_LIMITS.writableMiB)
|
||||
throw new CsoError('INSUFFICIENT_CAPACITY','Requested sidecars exceed the aggregate reproduction-group limit');
|
||||
const value = roles.reduce(
|
||||
(a, r) => ({
|
||||
cpu: a.cpu + ROLE_LIMITS[r].cpu,
|
||||
memoryMiB: a.memoryMiB + ROLE_LIMITS[r].memoryMiB,
|
||||
pids: a.pids + ROLE_LIMITS[r].pids,
|
||||
writableMiB: a.writableMiB + ROLE_LIMITS[r].writableMiB,
|
||||
}),
|
||||
{ cpu: 0, memoryMiB: 0, pids: 0, writableMiB: 0 },
|
||||
);
|
||||
if (
|
||||
value.cpu > GROUP_LIMITS.cpu ||
|
||||
value.memoryMiB > GROUP_LIMITS.memoryMiB ||
|
||||
value.pids > GROUP_LIMITS.pids ||
|
||||
value.writableMiB > GROUP_LIMITS.writableMiB
|
||||
)
|
||||
throw new CsoError(
|
||||
'INSUFFICIENT_CAPACITY',
|
||||
'Requested sidecars exceed the aggregate reproduction-group limit',
|
||||
);
|
||||
return value;
|
||||
}
|
||||
+54
-11
@@ -2,15 +2,58 @@ import * as fs from 'node:fs';
|
||||
import { CsoError } from './contracts';
|
||||
|
||||
/** Read one caller-supplied control file without following or blocking on a raced special file. */
|
||||
export function readBoundedStable(path:string,max:number,label:string):Buffer{
|
||||
let named:fs.Stats,fd:number|undefined;try{named=fs.lstatSync(path);}catch{throw new CsoError('MISSING_INPUT',`${label} does not exist`);}
|
||||
if(named.isSymbolicLink()||!named.isFile()||named.nlink!==1||named.size>max)throw new CsoError('MISSING_INPUT',`${label} must be one bounded regular file`);
|
||||
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 data=Buffer.alloc(max+1);let bytes=0,count=0;while(bytes<data.length&&(count=fs.readSync(fd,data,bytes,data.length-bytes,null))>0)bytes+=count;
|
||||
const after=fs.fstatSync(fd),current=fs.lstatSync(path);if(bytes>max)throw new CsoError('MISSING_INPUT',`${label} exceeds the ${max}-byte limit`);
|
||||
if(!current.isFile()||current.isSymbolicLink()||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`);
|
||||
return data.subarray(0,bytes);
|
||||
}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('MISSING_INPUT',`${label} is missing or unreadable`);}finally{if(fd!==undefined)fs.closeSync(fd);}
|
||||
export function readBoundedStable(path: string, max: number, label: string): Buffer {
|
||||
let named: fs.Stats, fd: number | undefined;
|
||||
try {
|
||||
named = fs.lstatSync(path);
|
||||
} catch {
|
||||
throw new CsoError('MISSING_INPUT', `${label} does not exist`);
|
||||
}
|
||||
if (named.isSymbolicLink() || !named.isFile() || named.nlink !== 1 || named.size > max)
|
||||
throw new CsoError('MISSING_INPUT', `${label} must be one bounded regular file`);
|
||||
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 data = Buffer.alloc(max + 1);
|
||||
let bytes = 0,
|
||||
count = 0;
|
||||
while (bytes < data.length && (count = fs.readSync(fd, data, bytes, data.length - bytes, null)) > 0)
|
||||
bytes += count;
|
||||
const after = fs.fstatSync(fd),
|
||||
current = fs.lstatSync(path);
|
||||
if (bytes > max) throw new CsoError('MISSING_INPUT', `${label} exceeds the ${max}-byte limit`);
|
||||
if (
|
||||
!current.isFile() ||
|
||||
current.isSymbolicLink() ||
|
||||
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`);
|
||||
return data.subarray(0, bytes);
|
||||
} 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('MISSING_INPUT', `${label} is missing or unreadable`);
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
+486
-169
File diff suppressed because it is too large.
Load diff
+2624
-398
File diff suppressed because it is too large.
Load diff
+856
-236
File diff suppressed because it is too large.
Load diff
+940
-230
File diff suppressed because it is too large.
Load diff
+87
-36
@@ -1,49 +1,100 @@
|
||||
/** Decode the two Git path tokens in a `diff --git` header. */
|
||||
function token(source:string,offset:number):{value:string;next:number}|undefined{
|
||||
if(source[offset]!=='"'){
|
||||
const end=source.indexOf(' ',offset),next=end<0?source.length:end;
|
||||
if(next===offset)return;
|
||||
return{value:source.slice(offset,next),next};
|
||||
function token(source: string, offset: number): { value: string; next: number } | undefined {
|
||||
if (source[offset] !== '"') {
|
||||
const end = source.indexOf(' ', offset),
|
||||
next = end < 0 ? source.length : end;
|
||||
if (next === offset) return;
|
||||
return { value: source.slice(offset, next), next };
|
||||
}
|
||||
const bytes:number[]=[];let at=offset+1;
|
||||
const append=(value:string)=>bytes.push(...new TextEncoder().encode(value));
|
||||
while(at<source.length){
|
||||
const value=source[at++];
|
||||
if(value==='"')return{value:new TextDecoder('utf-8',{fatal:true}).decode(Uint8Array.from(bytes)),next:at};
|
||||
if(value!=='\\'){append(value);continue;}
|
||||
if(at>=source.length)return;
|
||||
const escaped=source[at++],mapped:{[key:string]:string}={a:'\x07',b:'\b',f:'\f',n:'\n',r:'\r',t:'\t',v:'\v','\\':'\\','"':'"'};
|
||||
if(mapped[escaped]!==undefined){append(mapped[escaped]);continue;}
|
||||
if(/[0-7]/.test(escaped)&&/^[0-7]{2}/.test(source.slice(at,at+2))){bytes.push(Number.parseInt(escaped+source.slice(at,at+2),8));at+=2;continue;}
|
||||
const bytes: number[] = [];
|
||||
let at = offset + 1;
|
||||
const append = (value: string) => bytes.push(...new TextEncoder().encode(value));
|
||||
while (at < source.length) {
|
||||
const value = source[at++];
|
||||
if (value === '"')
|
||||
return { value: new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(bytes)), next: at };
|
||||
if (value !== '\\') {
|
||||
append(value);
|
||||
continue;
|
||||
}
|
||||
if (at >= source.length) return;
|
||||
const escaped = source[at++],
|
||||
mapped: { [key: string]: string } = {
|
||||
a: '\x07',
|
||||
b: '\b',
|
||||
f: '\f',
|
||||
n: '\n',
|
||||
r: '\r',
|
||||
t: '\t',
|
||||
v: '\v',
|
||||
'\\': '\\',
|
||||
'"': '"',
|
||||
};
|
||||
if (mapped[escaped] !== undefined) {
|
||||
append(mapped[escaped]);
|
||||
continue;
|
||||
}
|
||||
if (/[0-7]/.test(escaped) && /^[0-7]{2}/.test(source.slice(at, at + 2))) {
|
||||
bytes.push(Number.parseInt(escaped + source.slice(at, at + 2), 8));
|
||||
at += 2;
|
||||
continue;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function gitDiffHeaderPaths(line:string):[string,string]|undefined{
|
||||
const prefix='diff --git ';if(!line.startsWith(prefix))return;
|
||||
try{
|
||||
const left=token(line,prefix.length);if(!left||line[left.next]!==' ')return;
|
||||
const right=token(line,left.next+1);if(!right||right.next!==line.length)return;
|
||||
return[left.value,right.value];
|
||||
}catch{return;}
|
||||
export function gitDiffHeaderPaths(line: string): [string, string] | undefined {
|
||||
const prefix = 'diff --git ';
|
||||
if (!line.startsWith(prefix)) return;
|
||||
try {
|
||||
const left = token(line, prefix.length);
|
||||
if (!left || line[left.next] !== ' ') return;
|
||||
const right = token(line, left.next + 1);
|
||||
if (!right || right.next !== line.length) return;
|
||||
return [left.value, right.value];
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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}`]),output:string[]=[],lines=raw.split('\n');
|
||||
let preamble:string[]=[],section:string[]|undefined,include=false,preambleEmitted=false;
|
||||
const flush=()=>{
|
||||
if(section&&include){if(!preambleEmitted){output.push(...preamble);preambleEmitted=true;}output.push(...section);}
|
||||
section=undefined;include=false;
|
||||
};
|
||||
for(const line of lines){
|
||||
if(line.startsWith('commit ')){flush();preamble=[line];preambleEmitted=false;continue;}
|
||||
if(line.startsWith('diff --git ')){
|
||||
flush();section=[line];const paths=gitDiffHeaderPaths(line);include=Boolean(paths&&(expected.has(paths[0])||expected.has(paths[1])));continue;
|
||||
export function historyForPath(raw: string, path: string): string | undefined {
|
||||
const expected = new Set([`a/${path}`, `b/${path}`]),
|
||||
output: string[] = [],
|
||||
lines = raw.split('\n');
|
||||
let preamble: string[] = [],
|
||||
section: string[] | undefined,
|
||||
include = false,
|
||||
preambleEmitted = false;
|
||||
const flush = () => {
|
||||
if (section && include) {
|
||||
if (!preambleEmitted) {
|
||||
output.push(...preamble);
|
||||
preambleEmitted = true;
|
||||
}
|
||||
output.push(...section);
|
||||
}
|
||||
if(section)section.push(line);else preamble.push(line);
|
||||
section = undefined;
|
||||
include = false;
|
||||
};
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('commit ')) {
|
||||
flush();
|
||||
preamble = [line];
|
||||
preambleEmitted = false;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('diff --git ')) {
|
||||
flush();
|
||||
section = [line];
|
||||
const paths = gitDiffHeaderPaths(line);
|
||||
include = Boolean(paths && (expected.has(paths[0]) || expected.has(paths[1])));
|
||||
continue;
|
||||
}
|
||||
if (section) section.push(line);
|
||||
else preamble.push(line);
|
||||
}
|
||||
flush();
|
||||
while(output.at(-1)==='')output.pop();
|
||||
return output.length?output.join('\n'):undefined;
|
||||
while (output.at(-1) === '') output.pop();
|
||||
return output.length ? output.join('\n') : undefined;
|
||||
}
|
||||
+360
-122
@@ -8,175 +8,413 @@ import { validateScannerCatalog, type ScannerCatalog } from './scanner-catalog';
|
||||
import { secureDirectory } from './state';
|
||||
|
||||
export interface QualifiedCatalogImage {
|
||||
kind:'runtime'|'scanner';
|
||||
id:string;
|
||||
image:string;
|
||||
platform:RuntimePlatform;
|
||||
kind: 'runtime' | 'scanner';
|
||||
id: string;
|
||||
image: string;
|
||||
platform: RuntimePlatform;
|
||||
}
|
||||
export interface CatalogImageSession {
|
||||
readonly docker:{endpoint:string;version:string;security:string[]};
|
||||
present(entry:QualifiedCatalogImage,deadline?:number):Promise<boolean>;
|
||||
pull(entry:QualifiedCatalogImage,deadline?:number):Promise<void>;
|
||||
close():void;
|
||||
readonly docker: { endpoint: string; version: string; security: string[] };
|
||||
present(entry: QualifiedCatalogImage, deadline?: number): Promise<boolean>;
|
||||
pull(entry: QualifiedCatalogImage, deadline?: number): Promise<void>;
|
||||
close(): void;
|
||||
}
|
||||
/** Doctor performs concurrent, read-only checks inside its 30-second contract. */
|
||||
export const CATALOG_IMAGE_INSPECTION_BUDGET_MS=30_000;
|
||||
export const DEFAULT_CATALOG_IMAGE_BUDGET_MS=30_000;
|
||||
export const MIN_CATALOG_IMAGE_BUDGET_SECONDS=5;
|
||||
export const MAX_CATALOG_IMAGE_BUDGET_SECONDS=300;
|
||||
export const MAX_CATALOG_IMAGE_PROVISIONING_BUDGET_MS=60*60_000;
|
||||
const CATALOG_IMAGE_ADMISSION_BUDGET_MS=30_000;
|
||||
export interface CatalogImageProvisioningPolicy {perImageMs:number;aggregateMs:number;}
|
||||
export const CATALOG_IMAGE_INSPECTION_BUDGET_MS = 30_000;
|
||||
export const DEFAULT_CATALOG_IMAGE_BUDGET_MS = 30_000;
|
||||
export const MIN_CATALOG_IMAGE_BUDGET_SECONDS = 5;
|
||||
export const MAX_CATALOG_IMAGE_BUDGET_SECONDS = 300;
|
||||
export const MAX_CATALOG_IMAGE_PROVISIONING_BUDGET_MS = 60 * 60_000;
|
||||
const CATALOG_IMAGE_ADMISSION_BUDGET_MS = 30_000;
|
||||
export interface CatalogImageProvisioningPolicy {
|
||||
perImageMs: number;
|
||||
aggregateMs: number;
|
||||
}
|
||||
/**
|
||||
* Give every declared native-platform image a bounded opportunity to download.
|
||||
* The one-hour ceiling admits the current eleven-image catalog even at the
|
||||
* maximum configurable five-minute allowance.
|
||||
*/
|
||||
export function catalogImageProvisioningPolicy(imageCount:number,requestedSeconds?:string):CatalogImageProvisioningPolicy{
|
||||
if(!Number.isSafeInteger(imageCount)||imageCount<0)throw new CsoError('INVALID_ARGUMENT','Catalog image count is invalid');
|
||||
let seconds=DEFAULT_CATALOG_IMAGE_BUDGET_MS/1000;
|
||||
if(requestedSeconds!==undefined){
|
||||
if(!/^[0-9]+$/.test(requestedSeconds))throw new CsoError('INVALID_ARGUMENT','--per-image-seconds requires a whole number');
|
||||
seconds=Number(requestedSeconds);
|
||||
if(seconds<MIN_CATALOG_IMAGE_BUDGET_SECONDS||seconds>MAX_CATALOG_IMAGE_BUDGET_SECONDS)throw new CsoError('INVALID_ARGUMENT',`--per-image-seconds must be ${MIN_CATALOG_IMAGE_BUDGET_SECONDS}..${MAX_CATALOG_IMAGE_BUDGET_SECONDS}`);
|
||||
export function catalogImageProvisioningPolicy(
|
||||
imageCount: number,
|
||||
requestedSeconds?: string,
|
||||
): CatalogImageProvisioningPolicy {
|
||||
if (!Number.isSafeInteger(imageCount) || imageCount < 0)
|
||||
throw new CsoError('INVALID_ARGUMENT', 'Catalog image count is invalid');
|
||||
let seconds = DEFAULT_CATALOG_IMAGE_BUDGET_MS / 1000;
|
||||
if (requestedSeconds !== undefined) {
|
||||
if (!/^[0-9]+$/.test(requestedSeconds))
|
||||
throw new CsoError('INVALID_ARGUMENT', '--per-image-seconds requires a whole number');
|
||||
seconds = Number(requestedSeconds);
|
||||
if (seconds < MIN_CATALOG_IMAGE_BUDGET_SECONDS || seconds > MAX_CATALOG_IMAGE_BUDGET_SECONDS)
|
||||
throw new CsoError(
|
||||
'INVALID_ARGUMENT',
|
||||
`--per-image-seconds must be ${MIN_CATALOG_IMAGE_BUDGET_SECONDS}..${MAX_CATALOG_IMAGE_BUDGET_SECONDS}`,
|
||||
);
|
||||
}
|
||||
const perImageMs=seconds*1000,aggregateMs=CATALOG_IMAGE_ADMISSION_BUDGET_MS+imageCount*perImageMs;
|
||||
if(!Number.isSafeInteger(aggregateMs)||aggregateMs>MAX_CATALOG_IMAGE_PROVISIONING_BUDGET_MS)throw new CsoError('INCOMPATIBLE_INPUT','Qualified image catalog exceeds the bounded setup preload capacity');
|
||||
return{perImageMs,aggregateMs};
|
||||
const perImageMs = seconds * 1000,
|
||||
aggregateMs = CATALOG_IMAGE_ADMISSION_BUDGET_MS + imageCount * perImageMs;
|
||||
if (!Number.isSafeInteger(aggregateMs) || aggregateMs > MAX_CATALOG_IMAGE_PROVISIONING_BUDGET_MS)
|
||||
throw new CsoError(
|
||||
'INCOMPATIBLE_INPUT',
|
||||
'Qualified image catalog exceeds the bounded setup preload capacity',
|
||||
);
|
||||
return { perImageMs, aggregateMs };
|
||||
}
|
||||
export type CatalogImageSessionFactory=(deadline:number)=>Promise<CatalogImageSession>;
|
||||
export type CatalogImageSessionFactory = (deadline: number) => Promise<CatalogImageSession>;
|
||||
export interface CatalogImageAvailability extends QualifiedCatalogImage {
|
||||
status:'available'|'unavailable';
|
||||
reason?:string;
|
||||
status: 'available' | 'unavailable';
|
||||
reason?: string;
|
||||
}
|
||||
export interface CatalogImageInspection {
|
||||
docker:{status:'ready'|'missing';detail:unknown};
|
||||
images:CatalogImageAvailability[];
|
||||
docker: { status: 'ready' | 'missing'; detail: unknown };
|
||||
images: CatalogImageAvailability[];
|
||||
}
|
||||
export interface CatalogImageProvisionResult {
|
||||
schemaVersion:1;
|
||||
status:'complete'|'partial'|'not_available';
|
||||
downloads:true;
|
||||
platform:RuntimePlatform;
|
||||
requested:number;
|
||||
inspected:number;
|
||||
alreadyPresent:number;
|
||||
downloaded:number;
|
||||
deadlineReached:boolean;
|
||||
unavailable:CatalogImageAvailability[];
|
||||
summary:string;
|
||||
schemaVersion: 1;
|
||||
status: 'complete' | 'partial' | 'not_available';
|
||||
downloads: true;
|
||||
platform: RuntimePlatform;
|
||||
requested: number;
|
||||
inspected: number;
|
||||
alreadyPresent: number;
|
||||
downloaded: number;
|
||||
deadlineReached: boolean;
|
||||
unavailable: CatalogImageAvailability[];
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export function qualifiedCatalogImages(runtimeCatalog:RuntimeCatalog,scannerCatalog:ScannerCatalog,platform:RuntimePlatform):QualifiedCatalogImage[]{
|
||||
validateRuntimeCatalog(runtimeCatalog);validateScannerCatalog(scannerCatalog);
|
||||
const entries:QualifiedCatalogImage[]=[
|
||||
...runtimeCatalog.runtimes.filter(item=>item.platform===platform).map(item=>({kind:'runtime' as const,id:item.id,image:item.image,platform:item.platform})),
|
||||
...scannerCatalog.scanners.filter(item=>item.platform===platform).map(item=>({kind:'scanner' as const,id:item.id,image:item.image,platform:item.platform})),
|
||||
export function qualifiedCatalogImages(
|
||||
runtimeCatalog: RuntimeCatalog,
|
||||
scannerCatalog: ScannerCatalog,
|
||||
platform: RuntimePlatform,
|
||||
): QualifiedCatalogImage[] {
|
||||
validateRuntimeCatalog(runtimeCatalog);
|
||||
validateScannerCatalog(scannerCatalog);
|
||||
const entries: QualifiedCatalogImage[] = [
|
||||
...runtimeCatalog.runtimes
|
||||
.filter((item) => item.platform === platform)
|
||||
.map((item) => ({ kind: 'runtime' as const, id: item.id, image: item.image, platform: item.platform })),
|
||||
...scannerCatalog.scanners
|
||||
.filter((item) => item.platform === platform)
|
||||
.map((item) => ({ kind: 'scanner' as const, id: item.id, image: item.image, platform: item.platform })),
|
||||
];
|
||||
const identities=new Set<string>();
|
||||
for(const entry of entries){
|
||||
const identity=`${entry.kind}:${entry.id}`;
|
||||
if(identities.has(identity))throw new CsoError('INCOMPATIBLE_INPUT','Qualified image catalogs contain a duplicate identity');
|
||||
const identities = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
const identity = `${entry.kind}:${entry.id}`;
|
||||
if (identities.has(identity))
|
||||
throw new CsoError('INCOMPATIBLE_INPUT', 'Qualified image catalogs contain a duplicate identity');
|
||||
identities.add(identity);
|
||||
}
|
||||
return entries.sort((left,right)=>`${left.kind}:${left.id}`.localeCompare(`${right.kind}:${right.id}`));
|
||||
return entries.sort((left, right) => `${left.kind}:${left.id}`.localeCompare(`${right.kind}:${right.id}`));
|
||||
}
|
||||
|
||||
function controlledReason(error:unknown,fallback:string):string{
|
||||
return error instanceof CsoError?error.message:fallback;
|
||||
function controlledReason(error: unknown, fallback: string): string {
|
||||
return error instanceof CsoError ? error.message : fallback;
|
||||
}
|
||||
export async function inspectCatalogImages(entries:QualifiedCatalogImage[],open:CatalogImageSessionFactory,deadline=Date.now()+CATALOG_IMAGE_INSPECTION_BUDGET_MS):Promise<CatalogImageInspection>{
|
||||
let session:CatalogImageSession;
|
||||
try{session=await open(deadline);}catch(error){
|
||||
const detail=controlledReason(error,'Local Docker is unavailable for exact catalog image inspection');
|
||||
return{docker:{status:'missing',detail},images:entries.map(entry=>({...entry,status:'unavailable',reason:detail}))};
|
||||
export async function inspectCatalogImages(
|
||||
entries: QualifiedCatalogImage[],
|
||||
open: CatalogImageSessionFactory,
|
||||
deadline = Date.now() + CATALOG_IMAGE_INSPECTION_BUDGET_MS,
|
||||
): Promise<CatalogImageInspection> {
|
||||
let session: CatalogImageSession;
|
||||
try {
|
||||
session = await open(deadline);
|
||||
} catch (error) {
|
||||
const detail = controlledReason(error, 'Local Docker is unavailable for exact catalog image inspection');
|
||||
return {
|
||||
docker: { status: 'missing', detail },
|
||||
images: entries.map((entry) => ({ ...entry, status: 'unavailable', reason: detail })),
|
||||
};
|
||||
}
|
||||
try{
|
||||
try {
|
||||
// Read-only daemon lookups run together so doctor remains within its
|
||||
// 30-second contract even when a local Docker client is slow to fail.
|
||||
const images=await Promise.all(entries.map(async(entry):Promise<CatalogImageAvailability>=>{
|
||||
try{const present=await session.present(entry);if(Date.now()>=deadline)throw new CsoError('DEADLINE','Exact image inspection reached the aggregate image-provisioning deadline');return{...entry,status:present?'available':'unavailable',...(present?{}:{reason:'Exact qualified image is not present in the local Docker daemon'})};}
|
||||
catch(error){return{...entry,status:'unavailable',reason:controlledReason(error,'Exact qualified image could not be inspected safely')};}
|
||||
}));
|
||||
return{docker:{status:'ready',detail:session.docker},images};
|
||||
}finally{session.close();}
|
||||
const images = await Promise.all(
|
||||
entries.map(async (entry): Promise<CatalogImageAvailability> => {
|
||||
try {
|
||||
const present = await session.present(entry);
|
||||
if (Date.now() >= deadline)
|
||||
throw new CsoError(
|
||||
'DEADLINE',
|
||||
'Exact image inspection reached the aggregate image-provisioning deadline',
|
||||
);
|
||||
return {
|
||||
...entry,
|
||||
status: present ? 'available' : 'unavailable',
|
||||
...(present ? {} : { reason: 'Exact qualified image is not present in the local Docker daemon' }),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...entry,
|
||||
status: 'unavailable',
|
||||
reason: controlledReason(error, 'Exact qualified image could not be inspected safely'),
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { docker: { status: 'ready', detail: session.docker }, images };
|
||||
} finally {
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function provisionCatalogImages(entries:QualifiedCatalogImage[],platform:RuntimePlatform,open:CatalogImageSessionFactory,deadline=Date.now()+catalogImageProvisioningPolicy(entries.length).aggregateMs,perImageBudgetMs=DEFAULT_CATALOG_IMAGE_BUDGET_MS):Promise<CatalogImageProvisionResult>{
|
||||
if(!entries.length)return{schemaVersion:1,status:'complete',downloads:true,platform,requested:0,inspected:0,alreadyPresent:0,downloaded:0,deadlineReached:false,unavailable:[],summary:'No qualified CSO images are published for this platform; static audits remain available.'};
|
||||
if(!Number.isSafeInteger(perImageBudgetMs)||perImageBudgetMs<1||perImageBudgetMs>MAX_CATALOG_IMAGE_BUDGET_SECONDS*1000)throw new CsoError('INVALID_ARGUMENT','Catalog per-image budget is invalid');
|
||||
const deadlineReason='The bounded aggregate CSO image preload deadline was reached';
|
||||
if(Date.now()>=deadline){const unavailable=entries.map(entry=>({...entry,status:'unavailable' as const,reason:deadlineReason}));return{schemaVersion:1,status:'partial',downloads:true,platform,requested:entries.length,inspected:0,alreadyPresent:0,downloaded:0,deadlineReached:true,unavailable,summary:`Qualified CSO image preload partial: 0/${entries.length} available; ${deadlineReason.toLowerCase()}. Rerun setup to continue.`};}
|
||||
let session:CatalogImageSession;
|
||||
try{session=await open(deadline);}catch(error){
|
||||
const reason=controlledReason(error,'Local Docker is unavailable for qualified image provisioning'),unavailable=entries.map(entry=>({...entry,status:'unavailable' as const,reason}));
|
||||
const deadlineReached=error instanceof CsoError&&error.code==='DEADLINE';
|
||||
return{schemaVersion:1,status:deadlineReached?'partial':'not_available',downloads:true,platform,requested:entries.length,inspected:0,alreadyPresent:0,downloaded:0,deadlineReached,unavailable,summary:deadlineReached?`Qualified CSO image preload partial: 0/${entries.length} available; ${reason}. Rerun setup to continue.`:`Qualified CSO images were not preloaded: ${reason}. Rerun setup after the prerequisite is available.`};
|
||||
export async function provisionCatalogImages(
|
||||
entries: QualifiedCatalogImage[],
|
||||
platform: RuntimePlatform,
|
||||
open: CatalogImageSessionFactory,
|
||||
deadline = Date.now() + catalogImageProvisioningPolicy(entries.length).aggregateMs,
|
||||
perImageBudgetMs = DEFAULT_CATALOG_IMAGE_BUDGET_MS,
|
||||
): Promise<CatalogImageProvisionResult> {
|
||||
if (!entries.length)
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
status: 'complete',
|
||||
downloads: true,
|
||||
platform,
|
||||
requested: 0,
|
||||
inspected: 0,
|
||||
alreadyPresent: 0,
|
||||
downloaded: 0,
|
||||
deadlineReached: false,
|
||||
unavailable: [],
|
||||
summary: 'No qualified CSO images are published for this platform; static audits remain available.',
|
||||
};
|
||||
if (
|
||||
!Number.isSafeInteger(perImageBudgetMs) ||
|
||||
perImageBudgetMs < 1 ||
|
||||
perImageBudgetMs > MAX_CATALOG_IMAGE_BUDGET_SECONDS * 1000
|
||||
)
|
||||
throw new CsoError('INVALID_ARGUMENT', 'Catalog per-image budget is invalid');
|
||||
const deadlineReason = 'The bounded aggregate CSO image preload deadline was reached';
|
||||
if (Date.now() >= deadline) {
|
||||
const unavailable = entries.map((entry) => ({
|
||||
...entry,
|
||||
status: 'unavailable' as const,
|
||||
reason: deadlineReason,
|
||||
}));
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
status: 'partial',
|
||||
downloads: true,
|
||||
platform,
|
||||
requested: entries.length,
|
||||
inspected: 0,
|
||||
alreadyPresent: 0,
|
||||
downloaded: 0,
|
||||
deadlineReached: true,
|
||||
unavailable,
|
||||
summary: `Qualified CSO image preload partial: 0/${entries.length} available; ${deadlineReason.toLowerCase()}. Rerun setup to continue.`,
|
||||
};
|
||||
}
|
||||
let inspected=0,alreadyPresent=0,downloaded=0,pullBlocked='',deadlineReached=false,perImageTimeouts=0;const unavailable:CatalogImageAvailability[]=[];
|
||||
try{
|
||||
for(let index=0;index<entries.length;index++){
|
||||
const entry=entries[index];
|
||||
if(Date.now()>=deadline){deadlineReached=true;for(const remaining of entries.slice(index))unavailable.push({...remaining,status:'unavailable',reason:deadlineReason});break;}
|
||||
const imageDeadline=Math.min(deadline,Date.now()+perImageBudgetMs),perImageReason=`The ${Math.ceil(perImageBudgetMs/1000)}-second per-image CSO preload deadline was reached`;
|
||||
let present=false;
|
||||
try{
|
||||
present=await session.present(entry,imageDeadline);if(Date.now()>=imageDeadline)throw new CsoError('DEADLINE',imageDeadline===deadline?'Exact image inspection reached the aggregate image-provisioning deadline':perImageReason);inspected++;
|
||||
if(present){alreadyPresent++;continue;}
|
||||
}catch(error){
|
||||
if(error instanceof CsoError&&error.code==='DEADLINE'){
|
||||
if(Date.now()>=deadline){deadlineReached=true;unavailable.push({...entry,status:'unavailable',reason:error.message});for(const remaining of entries.slice(index+1))unavailable.push({...remaining,status:'unavailable',reason:deadlineReason});break;}
|
||||
perImageTimeouts++;unavailable.push({...entry,status:'unavailable',reason:perImageReason});continue;
|
||||
let session: CatalogImageSession;
|
||||
try {
|
||||
session = await open(deadline);
|
||||
} catch (error) {
|
||||
const reason = controlledReason(error, 'Local Docker is unavailable for qualified image provisioning'),
|
||||
unavailable = entries.map((entry) => ({ ...entry, status: 'unavailable' as const, reason }));
|
||||
const deadlineReached = error instanceof CsoError && error.code === 'DEADLINE';
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
status: deadlineReached ? 'partial' : 'not_available',
|
||||
downloads: true,
|
||||
platform,
|
||||
requested: entries.length,
|
||||
inspected: 0,
|
||||
alreadyPresent: 0,
|
||||
downloaded: 0,
|
||||
deadlineReached,
|
||||
unavailable,
|
||||
summary: deadlineReached
|
||||
? `Qualified CSO image preload partial: 0/${entries.length} available; ${reason}. Rerun setup to continue.`
|
||||
: `Qualified CSO images were not preloaded: ${reason}. Rerun setup after the prerequisite is available.`,
|
||||
};
|
||||
}
|
||||
let inspected = 0,
|
||||
alreadyPresent = 0,
|
||||
downloaded = 0,
|
||||
pullBlocked = '',
|
||||
deadlineReached = false,
|
||||
perImageTimeouts = 0;
|
||||
const unavailable: CatalogImageAvailability[] = [];
|
||||
try {
|
||||
for (let index = 0; index < entries.length; index++) {
|
||||
const entry = entries[index];
|
||||
if (Date.now() >= deadline) {
|
||||
deadlineReached = true;
|
||||
for (const remaining of entries.slice(index))
|
||||
unavailable.push({ ...remaining, status: 'unavailable', reason: deadlineReason });
|
||||
break;
|
||||
}
|
||||
const imageDeadline = Math.min(deadline, Date.now() + perImageBudgetMs),
|
||||
perImageReason = `The ${Math.ceil(perImageBudgetMs / 1000)}-second per-image CSO preload deadline was reached`;
|
||||
let present = false;
|
||||
try {
|
||||
present = await session.present(entry, imageDeadline);
|
||||
if (Date.now() >= imageDeadline)
|
||||
throw new CsoError(
|
||||
'DEADLINE',
|
||||
imageDeadline === deadline
|
||||
? 'Exact image inspection reached the aggregate image-provisioning deadline'
|
||||
: perImageReason,
|
||||
);
|
||||
inspected++;
|
||||
if (present) {
|
||||
alreadyPresent++;
|
||||
continue;
|
||||
}
|
||||
unavailable.push({...entry,status:'unavailable',reason:controlledReason(error,'Exact qualified image could not be inspected safely')});continue;
|
||||
} catch (error) {
|
||||
if (error instanceof CsoError && error.code === 'DEADLINE') {
|
||||
if (Date.now() >= deadline) {
|
||||
deadlineReached = true;
|
||||
unavailable.push({ ...entry, status: 'unavailable', reason: error.message });
|
||||
for (const remaining of entries.slice(index + 1))
|
||||
unavailable.push({ ...remaining, status: 'unavailable', reason: deadlineReason });
|
||||
break;
|
||||
}
|
||||
perImageTimeouts++;
|
||||
unavailable.push({ ...entry, status: 'unavailable', reason: perImageReason });
|
||||
continue;
|
||||
}
|
||||
unavailable.push({
|
||||
...entry,
|
||||
status: 'unavailable',
|
||||
reason: controlledReason(error, 'Exact qualified image could not be inspected safely'),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// A registry failure blocks further network attempts, but read-only local
|
||||
// inspection continues so the setup summary never calls a cached digest
|
||||
// unavailable merely because it sorts after the failed pull.
|
||||
if(pullBlocked){unavailable.push({...entry,status:'unavailable',reason:`Network provisioning stopped after an anonymous registry prerequisite failed: ${pullBlocked}`});continue;}
|
||||
if(Date.now()>=deadline){deadlineReached=true;unavailable.push({...entry,status:'unavailable',reason:deadlineReason});for(const remaining of entries.slice(index+1))unavailable.push({...remaining,status:'unavailable',reason:deadlineReason});break;}
|
||||
try{await session.pull(entry,imageDeadline);if(Date.now()>=imageDeadline)throw new CsoError('DEADLINE',imageDeadline===deadline?'Qualified image pull reached the aggregate preload deadline':perImageReason);downloaded++;}
|
||||
catch(error){
|
||||
if(error instanceof CsoError&&error.code==='DEADLINE'){
|
||||
if(Date.now()>=deadline){deadlineReached=true;unavailable.push({...entry,status:'unavailable',reason:error.message});for(const remaining of entries.slice(index+1))unavailable.push({...remaining,status:'unavailable',reason:deadlineReason});break;}
|
||||
perImageTimeouts++;unavailable.push({...entry,status:'unavailable',reason:perImageReason});continue;
|
||||
if (pullBlocked) {
|
||||
unavailable.push({
|
||||
...entry,
|
||||
status: 'unavailable',
|
||||
reason: `Network provisioning stopped after an anonymous registry prerequisite failed: ${pullBlocked}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
deadlineReached = true;
|
||||
unavailable.push({ ...entry, status: 'unavailable', reason: deadlineReason });
|
||||
for (const remaining of entries.slice(index + 1))
|
||||
unavailable.push({ ...remaining, status: 'unavailable', reason: deadlineReason });
|
||||
break;
|
||||
}
|
||||
try {
|
||||
await session.pull(entry, imageDeadline);
|
||||
if (Date.now() >= imageDeadline)
|
||||
throw new CsoError(
|
||||
'DEADLINE',
|
||||
imageDeadline === deadline
|
||||
? 'Qualified image pull reached the aggregate preload deadline'
|
||||
: perImageReason,
|
||||
);
|
||||
downloaded++;
|
||||
} catch (error) {
|
||||
if (error instanceof CsoError && error.code === 'DEADLINE') {
|
||||
if (Date.now() >= deadline) {
|
||||
deadlineReached = true;
|
||||
unavailable.push({ ...entry, status: 'unavailable', reason: error.message });
|
||||
for (const remaining of entries.slice(index + 1))
|
||||
unavailable.push({ ...remaining, status: 'unavailable', reason: deadlineReason });
|
||||
break;
|
||||
}
|
||||
perImageTimeouts++;
|
||||
unavailable.push({ ...entry, status: 'unavailable', reason: perImageReason });
|
||||
continue;
|
||||
}
|
||||
pullBlocked=controlledReason(error,'Qualified image provisioning failed');unavailable.push({...entry,status:'unavailable',reason:pullBlocked});
|
||||
pullBlocked = controlledReason(error, 'Qualified image provisioning failed');
|
||||
unavailable.push({ ...entry, status: 'unavailable', reason: pullBlocked });
|
||||
}
|
||||
}
|
||||
}finally{session.close();}
|
||||
const status=deadlineReached?'partial':unavailable.length?(alreadyPresent||downloaded?'partial':'not_available'):'complete';
|
||||
const summary=deadlineReached
|
||||
?`Qualified CSO image preload partial: ${alreadyPresent+downloaded}/${entries.length} available; inspected ${inspected}/${entries.length}; the bounded aggregate deadline was reached. Rerun setup to continue.`
|
||||
:perImageTimeouts
|
||||
?`Qualified CSO image preload ${status}: ${alreadyPresent+downloaded}/${entries.length} available; inspected ${inspected}/${entries.length}; ${perImageTimeouts} exceeded the ${Math.ceil(perImageBudgetMs/1000)}-second per-image deadline. Increase GSTACK_CSO_IMAGE_PULL_TIMEOUT_SECONDS within 5..300 or rerun setup to continue.`
|
||||
:unavailable.length
|
||||
?`Qualified CSO image preload ${status}: ${alreadyPresent+downloaded}/${entries.length} available; inspected ${inspected}/${entries.length}; ${unavailable.length} require local Docker and anonymous public registry access. Rerun setup after the prerequisite is available.`
|
||||
:`Qualified CSO images ready: ${entries.length} available (${downloaded} downloaded, ${alreadyPresent} already local).`;
|
||||
return{schemaVersion:1,status,downloads:true,platform,requested:entries.length,inspected,alreadyPresent,downloaded,deadlineReached,unavailable,summary};
|
||||
} finally {
|
||||
session.close();
|
||||
}
|
||||
const status = deadlineReached
|
||||
? 'partial'
|
||||
: unavailable.length
|
||||
? alreadyPresent || downloaded
|
||||
? 'partial'
|
||||
: 'not_available'
|
||||
: 'complete';
|
||||
const summary = deadlineReached
|
||||
? `Qualified CSO image preload partial: ${alreadyPresent + downloaded}/${entries.length} available; inspected ${inspected}/${entries.length}; the bounded aggregate deadline was reached. Rerun setup to continue.`
|
||||
: perImageTimeouts
|
||||
? `Qualified CSO image preload ${status}: ${alreadyPresent + downloaded}/${entries.length} available; inspected ${inspected}/${entries.length}; ${perImageTimeouts} exceeded the ${Math.ceil(perImageBudgetMs / 1000)}-second per-image deadline. Increase GSTACK_CSO_IMAGE_PULL_TIMEOUT_SECONDS within 5..300 or rerun setup to continue.`
|
||||
: unavailable.length
|
||||
? `Qualified CSO image preload ${status}: ${alreadyPresent + downloaded}/${entries.length} available; inspected ${inspected}/${entries.length}; ${unavailable.length} require local Docker and anonymous public registry access. Rerun setup after the prerequisite is available.`
|
||||
: `Qualified CSO images ready: ${entries.length} available (${downloaded} downloaded, ${alreadyPresent} already local).`;
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
status,
|
||||
downloads: true,
|
||||
platform,
|
||||
requested: entries.length,
|
||||
inspected,
|
||||
alreadyPresent,
|
||||
downloaded,
|
||||
deadlineReached,
|
||||
unavailable,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
export async function openLocalCatalogImageSession(env:Record<string,string|undefined>=process.env,deadline=Date.now()+CATALOG_IMAGE_INSPECTION_BUDGET_MS):Promise<CatalogImageSession>{
|
||||
let home='';
|
||||
try{
|
||||
home=secureDirectory(fs.mkdtempSync(join(fs.realpathSync(os.tmpdir()),'gstack-cso-images-')));
|
||||
export async function openLocalCatalogImageSession(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
deadline = Date.now() + CATALOG_IMAGE_INSPECTION_BUDGET_MS,
|
||||
): Promise<CatalogImageSession> {
|
||||
let home = '';
|
||||
try {
|
||||
home = secureDirectory(fs.mkdtempSync(join(fs.realpathSync(os.tmpdir()), 'gstack-cso-images-')));
|
||||
// Endpoint discovery and the daemon probe must not borrow the download
|
||||
// allowance. A slow or hostile local Docker endpoint gets the same bounded
|
||||
// admission window in doctor and setup; successful pulls keep the caller's
|
||||
// larger aggregate deadline below.
|
||||
const admissionDeadline=Math.min(deadline,Date.now()+CATALOG_IMAGE_ADMISSION_BUDGET_MS);
|
||||
const endpoint=await dockerEndpoint(home,env,admissionDeadline),config=secureDirectory(join(home,'docker-config'));
|
||||
const admissionDeadline = Math.min(deadline, Date.now() + CATALOG_IMAGE_ADMISSION_BUDGET_MS);
|
||||
const endpoint = await dockerEndpoint(home, env, admissionDeadline),
|
||||
config = secureDirectory(join(home, 'docker-config'));
|
||||
// dockerEnvironment pins both HOME and DOCKER_CONFIG here. An explicit
|
||||
// empty auth map prevents inherited credential stores/helpers from being
|
||||
// consulted during installation-time public pulls.
|
||||
fs.writeFileSync(join(config,'config.json'),'{"auths":{}}\n',{encoding:'utf8',mode:0o600,flag:'wx'});
|
||||
const probe=await dockerProbe(endpoint,home,admissionDeadline),docker={endpoint:endpoint.uri,...probe};
|
||||
let closed=false;
|
||||
return{
|
||||
fs.writeFileSync(join(config, 'config.json'), '{"auths":{}}\n', {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
flag: 'wx',
|
||||
});
|
||||
const probe = await dockerProbe(endpoint, home, admissionDeadline),
|
||||
docker = { endpoint: endpoint.uri, ...probe };
|
||||
let closed = false;
|
||||
return {
|
||||
docker,
|
||||
present:(entry,operationDeadline=deadline)=>{if(closed)throw new CsoError('ISOLATION_FAILED','Catalog image session is closed');return dockerExactImagePresent(endpoint,home,entry.image,entry.platform,Math.min(deadline,operationDeadline));},
|
||||
pull:(entry,operationDeadline=deadline)=>{if(closed)throw new CsoError('ISOLATION_FAILED','Catalog image session is closed');return dockerPullExactCatalogImage(endpoint,home,entry.image,entry.platform,Math.min(deadline,operationDeadline));},
|
||||
close:()=>{if(closed)return;closed=true;fs.rmSync(home,{recursive:true,force:true});},
|
||||
present: (entry, operationDeadline = deadline) => {
|
||||
if (closed) throw new CsoError('ISOLATION_FAILED', 'Catalog image session is closed');
|
||||
return dockerExactImagePresent(
|
||||
endpoint,
|
||||
home,
|
||||
entry.image,
|
||||
entry.platform,
|
||||
Math.min(deadline, operationDeadline),
|
||||
);
|
||||
},
|
||||
pull: (entry, operationDeadline = deadline) => {
|
||||
if (closed) throw new CsoError('ISOLATION_FAILED', 'Catalog image session is closed');
|
||||
return dockerPullExactCatalogImage(
|
||||
endpoint,
|
||||
home,
|
||||
entry.image,
|
||||
entry.platform,
|
||||
Math.min(deadline, operationDeadline),
|
||||
);
|
||||
},
|
||||
close: () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}catch(error){if(home)fs.rmSync(home,{recursive:true,force:true});throw error;}
|
||||
} catch (error) {
|
||||
if (home) fs.rmSync(home, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+715
-196
File diff suppressed because it is too large.
Load diff
+1264
-397
File diff suppressed because it is too large.
Load diff
+937
-297
File diff suppressed because it is too large.
Load diff
+875
-164
File diff suppressed because it is too large.
Load diff
+532
-149
@@ -1,218 +1,601 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { accessSync, closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync } from 'node:fs';
|
||||
import {
|
||||
accessSync,
|
||||
closeSync,
|
||||
constants,
|
||||
existsSync,
|
||||
fstatSync,
|
||||
lstatSync,
|
||||
openSync,
|
||||
readSync,
|
||||
realpathSync,
|
||||
statSync,
|
||||
} from 'node:fs';
|
||||
import { basename, dirname, join, isAbsolute, delimiter, resolve } from 'node:path';
|
||||
import { redactFindingSpans } from '../redact-engine';
|
||||
import { CsoError, MAX_OUTPUT } from './contracts';
|
||||
|
||||
const SOURCE_RUNTIME=/^bun(?:\.exe)?$/i.test(basename(process.execPath));
|
||||
const WINDOWS_GIT=process.platform==='win32'?(process.env.GSTACK_CSO_TRUSTED_GIT||(SOURCE_RUNTIME?Bun.which('git')??'':'')):'';
|
||||
const WINDOWS_SYSTEM=process.platform==='win32'?join(process.env.SystemRoot||'C:\\Windows','System32'):'';
|
||||
export const TRUSTED_DIRECTORIES = process.platform === 'win32'
|
||||
? [...new Set([WINDOWS_GIT?dirname(WINDOWS_GIT):'',WINDOWS_SYSTEM].filter(Boolean))]
|
||||
: ['/usr/local/bin','/usr/bin','/bin','/opt/homebrew/bin','/usr/local/sbin','/usr/sbin','/sbin'];
|
||||
const SOURCE_RUNTIME = /^bun(?:\.exe)?$/i.test(basename(process.execPath));
|
||||
const WINDOWS_GIT =
|
||||
process.platform === 'win32'
|
||||
? process.env.GSTACK_CSO_TRUSTED_GIT || (SOURCE_RUNTIME ? (Bun.which('git') ?? '') : '')
|
||||
: '';
|
||||
const WINDOWS_SYSTEM =
|
||||
process.platform === 'win32' ? join(process.env.SystemRoot || 'C:\\Windows', 'System32') : '';
|
||||
export const TRUSTED_DIRECTORIES =
|
||||
process.platform === 'win32'
|
||||
? [...new Set([WINDOWS_GIT ? dirname(WINDOWS_GIT) : '', WINDOWS_SYSTEM].filter(Boolean))]
|
||||
: ['/usr/local/bin', '/usr/bin', '/bin', '/opt/homebrew/bin', '/usr/local/sbin', '/usr/sbin', '/sbin'];
|
||||
export const TRUSTED_PATH = TRUSTED_DIRECTORIES.join(delimiter);
|
||||
export function executable(name: string): string {
|
||||
// Never consult the audited repository's PATH or executable overrides.
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(name)) throw new CsoError('INVALID_ARGUMENT','Invalid executable name');
|
||||
if(process.platform==='win32'&&name.toLowerCase()==='git'){
|
||||
try{if(!WINDOWS_GIT||!isAbsolute(WINDOWS_GIT)||basename(WINDOWS_GIT).toLowerCase()!=='git.exe')throw new Error();const stat=statSync(WINDOWS_GIT);if(!stat.isFile())throw new Error();return realpathSync(WINDOWS_GIT);}catch{throw new CsoError('TOOL_UNAVAILABLE','git.exe is not the trusted executable bound during gstack setup');}
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(name)) throw new CsoError('INVALID_ARGUMENT', 'Invalid executable name');
|
||||
if (process.platform === 'win32' && name.toLowerCase() === 'git') {
|
||||
try {
|
||||
if (!WINDOWS_GIT || !isAbsolute(WINDOWS_GIT) || basename(WINDOWS_GIT).toLowerCase() !== 'git.exe')
|
||||
throw new Error();
|
||||
const stat = statSync(WINDOWS_GIT);
|
||||
if (!stat.isFile()) throw new Error();
|
||||
return realpathSync(WINDOWS_GIT);
|
||||
} catch {
|
||||
throw new CsoError(
|
||||
'TOOL_UNAVAILABLE',
|
||||
'git.exe is not the trusted executable bound during gstack setup',
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const directory of TRUSTED_DIRECTORIES) {
|
||||
const candidates=process.platform==='win32'?[join(directory,`${name}.exe`),join(directory,`${name}.cmd`),join(directory,name)]:[join(directory,name)];
|
||||
for(const p of candidates){
|
||||
try { const stat=statSync(p);accessSync(p,constants.X_OK);if(stat.isFile()&&(process.platform==='win32'||(stat.mode&0o111)))return realpathSync(p); } catch {}
|
||||
const candidates =
|
||||
process.platform === 'win32'
|
||||
? [join(directory, `${name}.exe`), join(directory, `${name}.cmd`), join(directory, name)]
|
||||
: [join(directory, name)];
|
||||
for (const p of candidates) {
|
||||
try {
|
||||
const stat = statSync(p);
|
||||
accessSync(p, constants.X_OK);
|
||||
if (stat.isFile() && (process.platform === 'win32' || stat.mode & 0o111)) return realpathSync(p);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
throw new CsoError('TOOL_UNAVAILABLE', `${name} is not installed in a trusted system executable directory`);
|
||||
}
|
||||
export function childEnvironment(home: string): Record<string,string> {
|
||||
return { PATH: TRUSTED_PATH, HOME: home, LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', TZ: 'UTC',
|
||||
GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: process.platform==='win32'?'NUL':'/dev/null', GIT_TERMINAL_PROMPT: '0',
|
||||
GIT_OPTIONAL_LOCKS: '0', GIT_ATTR_NOSYSTEM: '1' };
|
||||
export function childEnvironment(home: string): Record<string, string> {
|
||||
return {
|
||||
PATH: TRUSTED_PATH,
|
||||
HOME: home,
|
||||
LANG: 'C.UTF-8',
|
||||
LC_ALL: 'C.UTF-8',
|
||||
TZ: 'UTC',
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null',
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
GIT_OPTIONAL_LOCKS: '0',
|
||||
GIT_ATTR_NOSYSTEM: '1',
|
||||
};
|
||||
}
|
||||
export function redact(value: string): string {
|
||||
// Scan the complete bounded stream, including across write/chunk boundaries.
|
||||
const output = redactFindingSpans(value, { maxBytes: MAX_OUTPUT });
|
||||
if (output === null) throw new CsoError('REDACTION_FAILED','Payload withheld because redaction could not safely locate every secret');
|
||||
if (output === null)
|
||||
throw new CsoError(
|
||||
'REDACTION_FAILED',
|
||||
'Payload withheld because redaction could not safely locate every secret',
|
||||
);
|
||||
return output;
|
||||
}
|
||||
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','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;
|
||||
if(key==='replayId'&&/^\d{13}-[a-f0-9]{16}$/.test(value))return true;
|
||||
if(['baseCommit','headCommit'].includes(key)&&/^[a-f0-9]{40,64}$/.test(value))return true;
|
||||
if(['createdAt','expiresAt','deadline','at','databaseUpdatedAt','qualifiedAt'].includes(key)&&/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{3})?Z$/.test(value))return true;
|
||||
if(key==='nonce'&&/^[a-f0-9]{64}$/.test(value))return true;
|
||||
if(key==='publicKey'&&/^[a-f0-9]{88}$/.test(value))return true;
|
||||
if(key==='signature'&&/^[a-f0-9]{128}$/.test(value))return true;
|
||||
if(key==='image'&&/^[a-z0-9./:_-]+@sha256:[a-f0-9]{64}$/.test(value))return true;
|
||||
if(key==='integrity'&&/^(?:sha256|sha512)-[A-Za-z0-9+/]+={0,2}$/.test(value))return true;
|
||||
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',
|
||||
'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;
|
||||
if (key === 'replayId' && /^\d{13}-[a-f0-9]{16}$/.test(value)) return true;
|
||||
if (['baseCommit', 'headCommit'].includes(key) && /^[a-f0-9]{40,64}$/.test(value)) return true;
|
||||
if (
|
||||
['createdAt', 'expiresAt', 'deadline', 'at', 'databaseUpdatedAt', 'qualifiedAt'].includes(key) &&
|
||||
/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{3})?Z$/.test(value)
|
||||
)
|
||||
return true;
|
||||
if (key === 'nonce' && /^[a-f0-9]{64}$/.test(value)) return true;
|
||||
if (key === 'publicKey' && /^[a-f0-9]{88}$/.test(value)) return true;
|
||||
if (key === 'signature' && /^[a-f0-9]{128}$/.test(value)) return true;
|
||||
if (key === 'image' && /^[a-z0-9./:_-]+@sha256:[a-f0-9]{64}$/.test(value)) return true;
|
||||
if (key === 'integrity' && /^(?:sha256|sha512)-[A-Za-z0-9+/]+={0,2}$/.test(value)) return true;
|
||||
return false;
|
||||
}
|
||||
function sanitizeJson(value:unknown,key:string,seen:WeakSet<object>,trustedMetadata:boolean):unknown{
|
||||
if(typeof value==='string'){
|
||||
if(trustedMetadata&&safeMetadata(value,key))return value;
|
||||
function sanitizeJson(value: unknown, key: string, seen: WeakSet<object>, trustedMetadata: boolean): unknown {
|
||||
if (typeof value === 'string') {
|
||||
if (trustedMetadata && safeMetadata(value, key)) return value;
|
||||
return redact(value);
|
||||
}
|
||||
if(value===null||typeof value!=='object')return value;
|
||||
if(seen.has(value as object))throw new CsoError('INVALID_SCHEMA','Cyclic JSON cannot be persisted');seen.add(value as object);
|
||||
if(Array.isArray(value)){const out=value.map(v=>sanitizeJson(v,key,seen,trustedMetadata));seen.delete(value);return out;}
|
||||
const out:Record<string,unknown>=Object.create(null);for(const [k,v] of Object.entries(value as Record<string,unknown>)){
|
||||
if(['__proto__','prototype','constructor'].includes(k))throw new CsoError('INVALID_SCHEMA','Unsafe JSON property');out[k]=sanitizeJson(v,k,seen,trustedMetadata);
|
||||
}seen.delete(value as object);return out;
|
||||
if (value === null || typeof value !== 'object') return value;
|
||||
if (seen.has(value as object)) throw new CsoError('INVALID_SCHEMA', 'Cyclic JSON cannot be persisted');
|
||||
seen.add(value as object);
|
||||
if (Array.isArray(value)) {
|
||||
const out = value.map((v) => sanitizeJson(v, key, seen, trustedMetadata));
|
||||
seen.delete(value);
|
||||
return out;
|
||||
}
|
||||
const out: Record<string, unknown> = Object.create(null);
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (['__proto__', 'prototype', 'constructor'].includes(k))
|
||||
throw new CsoError('INVALID_SCHEMA', 'Unsafe JSON property');
|
||||
out[k] = sanitizeJson(v, k, seen, trustedMetadata);
|
||||
}
|
||||
seen.delete(value as object);
|
||||
return out;
|
||||
}
|
||||
/** Redact untrusted JSON content. Key names never make an untrusted value exempt. */
|
||||
export function sanitizeForJson(value:unknown):unknown{return sanitizeJson(value,'',new WeakSet<object>(),false);}
|
||||
export function sanitizeForJson(value: unknown): unknown {
|
||||
return sanitizeJson(value, '', new WeakSet<object>(), false);
|
||||
}
|
||||
/** Preserve only validated helper identifiers/hashes while redacting all content-bearing fields. */
|
||||
export function sanitizeHelperForJson(value:unknown):unknown{return sanitizeJson(value,'',new WeakSet<object>(),true);}
|
||||
export interface ProcessResult { code: number; stdout: string; stderr: string; timedOut: boolean; truncated: boolean; capturedBytes:number }
|
||||
interface GitConfigIdentity { path:string; exists:boolean; dev?:number; ino?:number; mode?:number; size?:number; mtimeMs?:number; ctimeMs?:number; content?:string }
|
||||
const GIT_CONFIG_LIMIT=1024*1024;
|
||||
interface BoundedMetadataFile { dev:number;ino:number;mode:number;nlink:number;size:number;mtimeMs:number;ctimeMs:number;content:string }
|
||||
function sameMetadataFile(left:BoundedMetadataFile|ReturnType<typeof lstatSync>,right:BoundedMetadataFile|ReturnType<typeof lstatSync>):boolean{
|
||||
return left.dev===right.dev&&left.ino===right.ino&&left.mode===right.mode&&left.nlink===right.nlink&&left.size===right.size&&left.mtimeMs===right.mtimeMs&&left.ctimeMs===right.ctimeMs;
|
||||
export function sanitizeHelperForJson(value: unknown): unknown {
|
||||
return sanitizeJson(value, '', new WeakSet<object>(), true);
|
||||
}
|
||||
function boundedMetadataFile(path:string,maxBytes:number,label:string,optional=false):BoundedMetadataFile|undefined{
|
||||
let before:ReturnType<typeof lstatSync>;
|
||||
try{before=lstatSync(path);}catch(error:any){if(optional&&error?.code==='ENOENT')return;throw new CsoError(error?.code==='ENOENT'?'SNAPSHOT_RACE':'UNSAFE_PATH',`${label} is not a bounded regular file`);}
|
||||
if(before.isSymbolicLink()||!before.isFile()||before.nlink!==1||before.size>maxBytes)throw new CsoError('UNSAFE_PATH',`${label} is not a bounded regular file`);
|
||||
let fd:number|undefined;
|
||||
try{
|
||||
fd=openSync(path,constants.O_RDONLY|(constants.O_NOFOLLOW??0)|(constants.O_NONBLOCK??0));
|
||||
const opened=fstatSync(fd);
|
||||
if(!opened.isFile()||opened.nlink!==1||opened.size>maxBytes||!sameMetadataFile(before,opened))throw new CsoError('SNAPSHOT_RACE',`${label} changed while it was opened`);
|
||||
const buffer=Buffer.alloc(Math.min(maxBytes+1,opened.size+1));let bytes=0,count=0;
|
||||
while(bytes<buffer.length&&(count=readSync(fd,buffer,bytes,buffer.length-bytes,null))>0)bytes+=count;
|
||||
const final=fstatSync(fd),after=lstatSync(path);
|
||||
if(bytes!==opened.size||!final.isFile()||!after.isFile()||after.isSymbolicLink()||!sameMetadataFile(opened,final)||!sameMetadataFile(opened,after))
|
||||
throw new CsoError('SNAPSHOT_RACE',`${label} changed while it was read`);
|
||||
return{dev:opened.dev,ino:opened.ino,mode:opened.mode,nlink:opened.nlink,size:opened.size,mtimeMs:opened.mtimeMs,ctimeMs:opened.ctimeMs,content:buffer.subarray(0,bytes).toString('utf8')};
|
||||
}catch(error:any){
|
||||
if(error instanceof CsoError)throw error;
|
||||
if(['ENOENT','ELOOP','ENXIO'].includes(error?.code))throw new CsoError('SNAPSHOT_RACE',`${label} changed while it was opened`);
|
||||
throw new CsoError('UNSAFE_PATH',`${label} could not be read safely`);
|
||||
}finally{if(fd!==undefined)try{closeSync(fd);}catch{}}
|
||||
export interface ProcessResult {
|
||||
code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
timedOut: boolean;
|
||||
truncated: boolean;
|
||||
capturedBytes: number;
|
||||
}
|
||||
function boundedConfig(path:string):GitConfigIdentity{
|
||||
const file=boundedMetadataFile(path,GIT_CONFIG_LIMIT,'Repository Git configuration',true);
|
||||
if(!file)return{path,exists:false};
|
||||
const {content}=file;
|
||||
interface GitConfigIdentity {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
dev?: number;
|
||||
ino?: number;
|
||||
mode?: number;
|
||||
size?: number;
|
||||
mtimeMs?: number;
|
||||
ctimeMs?: number;
|
||||
content?: string;
|
||||
}
|
||||
const GIT_CONFIG_LIMIT = 1024 * 1024;
|
||||
interface BoundedMetadataFile {
|
||||
dev: number;
|
||||
ino: number;
|
||||
mode: number;
|
||||
nlink: number;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
ctimeMs: number;
|
||||
content: string;
|
||||
}
|
||||
function sameMetadataFile(
|
||||
left: BoundedMetadataFile | ReturnType<typeof lstatSync>,
|
||||
right: BoundedMetadataFile | ReturnType<typeof lstatSync>,
|
||||
): boolean {
|
||||
return (
|
||||
left.dev === right.dev &&
|
||||
left.ino === right.ino &&
|
||||
left.mode === right.mode &&
|
||||
left.nlink === right.nlink &&
|
||||
left.size === right.size &&
|
||||
left.mtimeMs === right.mtimeMs &&
|
||||
left.ctimeMs === right.ctimeMs
|
||||
);
|
||||
}
|
||||
function boundedMetadataFile(
|
||||
path: string,
|
||||
maxBytes: number,
|
||||
label: string,
|
||||
optional = false,
|
||||
): BoundedMetadataFile | undefined {
|
||||
let before: ReturnType<typeof lstatSync>;
|
||||
try {
|
||||
before = lstatSync(path);
|
||||
} catch (error: any) {
|
||||
if (optional && error?.code === 'ENOENT') return;
|
||||
throw new CsoError(
|
||||
error?.code === 'ENOENT' ? 'SNAPSHOT_RACE' : 'UNSAFE_PATH',
|
||||
`${label} is not a bounded regular file`,
|
||||
);
|
||||
}
|
||||
if (before.isSymbolicLink() || !before.isFile() || before.nlink !== 1 || before.size > maxBytes)
|
||||
throw new CsoError('UNSAFE_PATH', `${label} is not a bounded regular file`);
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
||||
const opened = fstatSync(fd);
|
||||
if (!opened.isFile() || opened.nlink !== 1 || opened.size > maxBytes || !sameMetadataFile(before, opened))
|
||||
throw new CsoError('SNAPSHOT_RACE', `${label} changed while it was opened`);
|
||||
const buffer = Buffer.alloc(Math.min(maxBytes + 1, opened.size + 1));
|
||||
let bytes = 0,
|
||||
count = 0;
|
||||
while (bytes < buffer.length && (count = readSync(fd, buffer, bytes, buffer.length - bytes, null)) > 0)
|
||||
bytes += count;
|
||||
const final = fstatSync(fd),
|
||||
after = lstatSync(path);
|
||||
if (
|
||||
bytes !== opened.size ||
|
||||
!final.isFile() ||
|
||||
!after.isFile() ||
|
||||
after.isSymbolicLink() ||
|
||||
!sameMetadataFile(opened, final) ||
|
||||
!sameMetadataFile(opened, after)
|
||||
)
|
||||
throw new CsoError('SNAPSHOT_RACE', `${label} changed while it was read`);
|
||||
return {
|
||||
dev: opened.dev,
|
||||
ino: opened.ino,
|
||||
mode: opened.mode,
|
||||
nlink: opened.nlink,
|
||||
size: opened.size,
|
||||
mtimeMs: opened.mtimeMs,
|
||||
ctimeMs: opened.ctimeMs,
|
||||
content: buffer.subarray(0, bytes).toString('utf8'),
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error instanceof CsoError) throw error;
|
||||
if (['ENOENT', 'ELOOP', 'ENXIO'].includes(error?.code))
|
||||
throw new CsoError('SNAPSHOT_RACE', `${label} changed while it was opened`);
|
||||
throw new CsoError('UNSAFE_PATH', `${label} could not be read safely`);
|
||||
} finally {
|
||||
if (fd !== undefined)
|
||||
try {
|
||||
closeSync(fd);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
function boundedConfig(path: string): GitConfigIdentity {
|
||||
const file = boundedMetadataFile(path, GIT_CONFIG_LIMIT, 'Repository Git configuration', true);
|
||||
if (!file) return { path, exists: false };
|
||||
const { content } = file;
|
||||
// There is no process-wide "--no-includes" switch for ordinary Git
|
||||
// commands. Reject include directives before spawning Git so repository
|
||||
// configuration cannot pull policy or executable settings from elsewhere.
|
||||
if(/^\s*\[\s*include(?:if)?(?=[\s."\]])/im.test(content))throw new CsoError('UNSAFE_PATH','Repository Git config includes are not allowed during a security snapshot');
|
||||
return{path,exists:true,dev:file.dev,ino:file.ino,mode:file.mode,size:file.size,mtimeMs:file.mtimeMs,ctimeMs:file.ctimeMs,content};
|
||||
if (/^\s*\[\s*include(?:if)?(?=[\s."\]])/im.test(content))
|
||||
throw new CsoError(
|
||||
'UNSAFE_PATH',
|
||||
'Repository Git config includes are not allowed during a security snapshot',
|
||||
);
|
||||
return {
|
||||
path,
|
||||
exists: true,
|
||||
dev: file.dev,
|
||||
ino: file.ino,
|
||||
mode: file.mode,
|
||||
size: file.size,
|
||||
mtimeMs: file.mtimeMs,
|
||||
ctimeMs: file.ctimeMs,
|
||||
content,
|
||||
};
|
||||
}
|
||||
function gitDirectories(repo:string):{gitDir:string;commonDir:string}{
|
||||
const marker=join(repo,'.git'),stat=lstatSync(marker);let gitDir:string;
|
||||
if(stat.isDirectory()&&!stat.isSymbolicLink())gitDir=realpathSync(marker);
|
||||
else if(stat.isFile()&&!stat.isSymbolicLink()&&stat.nlink===1&&stat.size<=8192){
|
||||
const value=boundedMetadataFile(marker,8192,'Repository .git pointer')!.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=realpathSync(resolve(dirname(marker),match[1]));
|
||||
}else throw new CsoError('UNSAFE_PATH','Repository .git metadata is not a regular directory or worktree pointer');
|
||||
const commonMarker=join(gitDir,'commondir'),commonFile=boundedMetadataFile(commonMarker,8192,'Repository common Git directory pointer',true);let commonDir=gitDir;
|
||||
if(commonFile){
|
||||
const value=commonFile.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=realpathSync(resolve(gitDir,value));
|
||||
function gitDirectories(repo: string): { gitDir: string; commonDir: string } {
|
||||
const marker = join(repo, '.git'),
|
||||
stat = lstatSync(marker);
|
||||
let gitDir: string;
|
||||
if (stat.isDirectory() && !stat.isSymbolicLink()) gitDir = realpathSync(marker);
|
||||
else if (stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1 && stat.size <= 8192) {
|
||||
const value = boundedMetadataFile(marker, 8192, 'Repository .git pointer')!.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 = realpathSync(resolve(dirname(marker), match[1]));
|
||||
} else
|
||||
throw new CsoError(
|
||||
'UNSAFE_PATH',
|
||||
'Repository .git metadata is not a regular directory or worktree pointer',
|
||||
);
|
||||
const commonMarker = join(gitDir, 'commondir'),
|
||||
commonFile = boundedMetadataFile(commonMarker, 8192, 'Repository common Git directory pointer', true);
|
||||
let commonDir = gitDir;
|
||||
if (commonFile) {
|
||||
const value = commonFile.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 = realpathSync(resolve(gitDir, value));
|
||||
}
|
||||
return{gitDir,commonDir};
|
||||
return { gitDir, commonDir };
|
||||
}
|
||||
function gitConfigIdentities(repo:string):GitConfigIdentity[]{
|
||||
const {gitDir,commonDir}=gitDirectories(repo);
|
||||
function gitConfigIdentities(repo: string): GitConfigIdentity[] {
|
||||
const { gitDir, commonDir } = gitDirectories(repo);
|
||||
// extensions.worktreeConfig makes config.worktree active in both linked and
|
||||
// main worktrees. Bind even its absence so it cannot appear after inspection
|
||||
// and feed Git an unchecked include or executable setting.
|
||||
return [join(commonDir,'config'),join(gitDir,'config.worktree')].map(boundedConfig);
|
||||
return [join(commonDir, 'config'), join(gitDir, 'config.worktree')].map(boundedConfig);
|
||||
}
|
||||
function assertGitConfigIdentities(expected:GitConfigIdentity[]):void{
|
||||
for(const item of expected){
|
||||
const current=boundedConfig(item.path);
|
||||
if(current.exists!==item.exists||current.dev!==item.dev||current.ino!==item.ino||current.mode!==item.mode||current.size!==item.size||current.mtimeMs!==item.mtimeMs||current.ctimeMs!==item.ctimeMs||current.content!==item.content)
|
||||
throw new CsoError('SNAPSHOT_RACE','Repository Git configuration changed during a metadata operation');
|
||||
function assertGitConfigIdentities(expected: GitConfigIdentity[]): void {
|
||||
for (const item of expected) {
|
||||
const current = boundedConfig(item.path);
|
||||
if (
|
||||
current.exists !== item.exists ||
|
||||
current.dev !== item.dev ||
|
||||
current.ino !== item.ino ||
|
||||
current.mode !== item.mode ||
|
||||
current.size !== item.size ||
|
||||
current.mtimeMs !== item.mtimeMs ||
|
||||
current.ctimeMs !== item.ctimeMs ||
|
||||
current.content !== item.content
|
||||
)
|
||||
throw new CsoError('SNAPSHOT_RACE', 'Repository Git configuration changed during a metadata operation');
|
||||
}
|
||||
}
|
||||
function hardenGit(file:string,args:string[]):{args:string[];configs?:GitConfigIdentity[]}{
|
||||
if(!/^(?:git|git\.exe)$/i.test(basename(file)))return{args};
|
||||
let trusted:string;try{trusted=executable('git');}catch{return{args};}
|
||||
if(realpathSync(file)!==trusted)return{args};
|
||||
const positions=args.flatMap((value,index)=>value==='-C'?[index]:[]);
|
||||
if(positions.length!==1||positions[0]+1>=args.length)throw new CsoError('INVALID_ARGUMENT','CSO Git operations require exactly one audited working directory');
|
||||
const position=positions[0],requested=args[position+1];
|
||||
if(!isAbsolute(requested))throw new CsoError('INVALID_ARGUMENT','CSO Git operations require an absolute audited working directory');
|
||||
const repo=realpathSync(requested),stat=statSync(repo);
|
||||
if(!stat.isDirectory())throw new CsoError('MISSING_INPUT','Audited Git working directory is not a directory');
|
||||
const configs=gitConfigIdentities(repo),nullPath=process.platform==='win32'?'NUL':'/dev/null',
|
||||
function hardenGit(file: string, args: string[]): { args: string[]; configs?: GitConfigIdentity[] } {
|
||||
if (!/^(?:git|git\.exe)$/i.test(basename(file))) return { args };
|
||||
let trusted: string;
|
||||
try {
|
||||
trusted = executable('git');
|
||||
} catch {
|
||||
return { args };
|
||||
}
|
||||
if (realpathSync(file) !== trusted) return { args };
|
||||
const positions = args.flatMap((value, index) => (value === '-C' ? [index] : []));
|
||||
if (positions.length !== 1 || positions[0] + 1 >= args.length)
|
||||
throw new CsoError(
|
||||
'INVALID_ARGUMENT',
|
||||
'CSO Git operations require exactly one audited working directory',
|
||||
);
|
||||
const position = positions[0],
|
||||
requested = args[position + 1];
|
||||
if (!isAbsolute(requested))
|
||||
throw new CsoError(
|
||||
'INVALID_ARGUMENT',
|
||||
'CSO Git operations require an absolute audited working directory',
|
||||
);
|
||||
const repo = realpathSync(requested),
|
||||
stat = statSync(repo);
|
||||
if (!stat.isDirectory())
|
||||
throw new CsoError('MISSING_INPUT', 'Audited Git working directory is not a directory');
|
||||
const configs = gitConfigIdentities(repo),
|
||||
nullPath = process.platform === 'win32' ? 'NUL' : '/dev/null',
|
||||
// Git for Windows accepts NUL for ordinary file-valued settings, but its
|
||||
// config include machinery treats NUL as a failing include. Its MSYS path
|
||||
// layer maps /dev/null correctly for this one directive.
|
||||
includeNullPath=process.platform==='win32'?'/dev/null':nullPath;
|
||||
const prefix=args.slice(0,position),command=args.slice(position+2);
|
||||
return{configs,args:[...prefix,
|
||||
'--no-replace-objects',
|
||||
'-c','core.fsmonitor=false','-c',`core.hooksPath=${nullPath}`,'-c',`core.attributesFile=${nullPath}`,
|
||||
'-c',`core.excludesFile=${nullPath}`,'-c','core.ignoreCase=false','-c','core.precomposeUnicode=false',
|
||||
'-c','core.untrackedCache=false','-c',`include.path=${includeNullPath}`,'-c','core.pager=cat',
|
||||
'-C',repo,`--work-tree=${repo}`,...command]};
|
||||
includeNullPath = process.platform === 'win32' ? '/dev/null' : nullPath;
|
||||
const prefix = args.slice(0, position),
|
||||
command = args.slice(position + 2);
|
||||
return {
|
||||
configs,
|
||||
args: [
|
||||
...prefix,
|
||||
'--no-replace-objects',
|
||||
'-c',
|
||||
'core.fsmonitor=false',
|
||||
'-c',
|
||||
`core.hooksPath=${nullPath}`,
|
||||
'-c',
|
||||
`core.attributesFile=${nullPath}`,
|
||||
'-c',
|
||||
`core.excludesFile=${nullPath}`,
|
||||
'-c',
|
||||
'core.ignoreCase=false',
|
||||
'-c',
|
||||
'core.precomposeUnicode=false',
|
||||
'-c',
|
||||
'core.untrackedCache=false',
|
||||
'-c',
|
||||
`include.path=${includeNullPath}`,
|
||||
'-c',
|
||||
'core.pager=cat',
|
||||
'-C',
|
||||
repo,
|
||||
`--work-tree=${repo}`,
|
||||
...command,
|
||||
],
|
||||
};
|
||||
}
|
||||
export async function runProcess(file: string, args: string[], opts: {
|
||||
cwd: string; env: Record<string,string>; timeoutMs?: number; maxBytes?: number; input?: string;
|
||||
raw?: boolean; // Only for inert Git framing or private helper/Docker control JSON that is validated before use. Never print or persist raw results.
|
||||
}): Promise<ProcessResult> {
|
||||
if (!isAbsolute(file) || !isAbsolute(opts.cwd) || !existsSync(opts.cwd)) throw new CsoError('INVALID_ARGUMENT','Children require absolute executables and an existing trusted working directory');
|
||||
if (!args.every(a => typeof a === 'string' && !a.includes('\0'))) throw new CsoError('INVALID_ARGUMENT','Invalid child argument');
|
||||
const hardened=hardenGit(file,args);args=hardened.args;
|
||||
export async function runProcess(
|
||||
file: string,
|
||||
args: string[],
|
||||
opts: {
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
maxBytes?: number;
|
||||
input?: string;
|
||||
raw?: boolean; // Only for inert Git framing or private helper/Docker control JSON that is validated before use. Never print or persist raw results.
|
||||
},
|
||||
): Promise<ProcessResult> {
|
||||
if (!isAbsolute(file) || !isAbsolute(opts.cwd) || !existsSync(opts.cwd))
|
||||
throw new CsoError(
|
||||
'INVALID_ARGUMENT',
|
||||
'Children require absolute executables and an existing trusted working directory',
|
||||
);
|
||||
if (!args.every((a) => typeof a === 'string' && !a.includes('\0')))
|
||||
throw new CsoError('INVALID_ARGUMENT', 'Invalid child argument');
|
||||
const hardened = hardenGit(file, args);
|
||||
args = hardened.args;
|
||||
const cap = Math.min(opts.maxBytes ?? MAX_OUTPUT, MAX_OUTPUT);
|
||||
return new Promise((resolve,reject) => {
|
||||
const child = spawn(file,args,{cwd:opts.cwd,env:opts.env,stdio:['pipe','pipe','pipe'],detached:process.platform !== 'win32'});
|
||||
const out: Buffer[] = [], err: Buffer[] = [], ordered:Buffer[]=[]; let bytes = 0, timedOut = false, truncated = false;
|
||||
const kill = () => { try { if (process.platform !== 'win32' && child.pid) process.kill(-child.pid,'SIGKILL'); else child.kill('SIGKILL'); } catch {} };
|
||||
const timer = setTimeout(() => { timedOut = true; kill(); }, Math.max(1,Math.min(opts.timeoutMs ?? 30_000,300_000)));
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(file, args, {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
detached: process.platform !== 'win32',
|
||||
});
|
||||
const out: Buffer[] = [],
|
||||
err: Buffer[] = [],
|
||||
ordered: Buffer[] = [];
|
||||
let bytes = 0,
|
||||
timedOut = false,
|
||||
truncated = false;
|
||||
const kill = () => {
|
||||
try {
|
||||
if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, 'SIGKILL');
|
||||
else child.kill('SIGKILL');
|
||||
} catch {}
|
||||
};
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
timedOut = true;
|
||||
kill();
|
||||
},
|
||||
Math.max(1, Math.min(opts.timeoutMs ?? 30_000, 300_000)),
|
||||
);
|
||||
const capture = (target: Buffer[]) => (chunk: Buffer) => {
|
||||
bytes += chunk.length;
|
||||
if (bytes > cap) { truncated = true; kill(); return; }
|
||||
target.push(chunk);ordered.push(chunk);
|
||||
if (bytes > cap) {
|
||||
truncated = true;
|
||||
kill();
|
||||
return;
|
||||
}
|
||||
target.push(chunk);
|
||||
ordered.push(chunk);
|
||||
};
|
||||
child.stdout.on('data',capture(out)); child.stderr.on('data',capture(err));
|
||||
child.on('error',() => { clearTimeout(timer); reject(new CsoError('TOOL_UNAVAILABLE','Trusted child process could not start')); });
|
||||
child.on('close',code => {
|
||||
child.stdout.on('data', capture(out));
|
||||
child.stderr.on('data', capture(err));
|
||||
child.on('error', () => {
|
||||
clearTimeout(timer);
|
||||
reject(new CsoError('TOOL_UNAVAILABLE', 'Trusted child process could not start'));
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
if(hardened.configs)assertGitConfigIdentities(hardened.configs);
|
||||
if (hardened.configs) assertGitConfigIdentities(hardened.configs);
|
||||
// Never expose a truncated tail: it might be the beginning of a secret.
|
||||
const stdout = truncated ? '[output withheld: size limit]' : Buffer.concat(out).toString('utf8');
|
||||
const stderr = truncated ? '' : Buffer.concat(err).toString('utf8');
|
||||
if(opts.raw){resolve({code:code ?? -1,stdout,stderr,timedOut,truncated,capturedBytes:bytes});return;}
|
||||
if (opts.raw) {
|
||||
resolve({ code: code ?? -1, stdout, stderr, timedOut, truncated, capturedBytes: bytes });
|
||||
return;
|
||||
}
|
||||
// A token may be split across stdout/stderr. Stream ordering is not
|
||||
// recoverable here, so scan both concatenation orders and withhold both
|
||||
// channels when either reveals a cross-stream sensitive span.
|
||||
const forward=stdout+stderr,reverse=stderr+stdout,chronological=Buffer.concat(ordered).toString('utf8');
|
||||
if([stdout,stderr,forward,reverse,chronological].some(value=>redact(value)!==value)){
|
||||
resolve({code:code ?? -1,stdout:'[sensitive process output redacted]',stderr:'',timedOut,truncated,capturedBytes:bytes});return;
|
||||
const forward = stdout + stderr,
|
||||
reverse = stderr + stdout,
|
||||
chronological = Buffer.concat(ordered).toString('utf8');
|
||||
if ([stdout, stderr, forward, reverse, chronological].some((value) => redact(value) !== value)) {
|
||||
resolve({
|
||||
code: code ?? -1,
|
||||
stdout: '[sensitive process output redacted]',
|
||||
stderr: '',
|
||||
timedOut,
|
||||
truncated,
|
||||
capturedBytes: bytes,
|
||||
});
|
||||
return;
|
||||
}
|
||||
resolve({code:code ?? -1,stdout,stderr,timedOut,truncated,capturedBytes:bytes});
|
||||
} catch (e) { reject(e); }
|
||||
resolve({ code: code ?? -1, stdout, stderr, timedOut, truncated, capturedBytes: bytes });
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
child.stdin.on('error',() => {}); child.stdin.end(opts.input);
|
||||
child.stdin.on('error', () => {});
|
||||
child.stdin.end(opts.input);
|
||||
});
|
||||
}
|
||||
export async function git(repo: string, args: string[], home: string): Promise<string> {
|
||||
const result = await runProcess(executable('git'),['--no-optional-locks','-C',repo,...args],
|
||||
{cwd:home,env:childEnvironment(home),raw:true,timeoutMs:15_000});
|
||||
const result = await runProcess(executable('git'), ['--no-optional-locks', '-C', repo, ...args], {
|
||||
cwd: home,
|
||||
env: childEnvironment(home),
|
||||
raw: true,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
if (result.code || result.timedOut || result.truncated) {
|
||||
// Git stderr and argv can contain repository paths, refs, and configured
|
||||
// content. Name only the fixed helper-owned operation and bounded process
|
||||
// outcome so native failures are actionable without exposing either.
|
||||
const knownOperations=new Set(['rev-parse','symbolic-ref','ls-files','ls-tree','log','merge-base']),operation=args.find(value=>knownOperations.has(value))??'metadata',
|
||||
phase=operation==='rev-parse'&&args.includes('--show-object-format')?'object-format':operation==='rev-parse'&&args.includes('--is-inside-work-tree')?'worktree-probe':operation,
|
||||
reason=/not a git repository|outside repository/i.test(result.stderr)?'repository unavailable':/dubious ownership/i.test(result.stderr)?'repository ownership rejected':/(?:bad|invalid|unable to read).*config|config (?:error|file)/i.test(result.stderr)?'configuration rejected':/unknown option|unknown switch|unrecognized option|usage:/i.test(result.stderr)?'unsupported invocation':/(?:cannot|could not|unable to) (?:chdir|change directory)|no such file or directory/i.test(result.stderr)?'path unavailable':'request rejected',
|
||||
outcome=result.timedOut?'timed out':result.truncated?'exceeded the output limit':`exited ${result.code}`;
|
||||
throw new CsoError('MISSING_INPUT',`Could not read bounded Git metadata: ${phase} ${outcome} (${reason}); source may not be a Git repository`);
|
||||
const knownOperations = new Set([
|
||||
'rev-parse',
|
||||
'symbolic-ref',
|
||||
'ls-files',
|
||||
'ls-tree',
|
||||
'log',
|
||||
'merge-base',
|
||||
]),
|
||||
operation = args.find((value) => knownOperations.has(value)) ?? 'metadata',
|
||||
phase =
|
||||
operation === 'rev-parse' && args.includes('--show-object-format')
|
||||
? 'object-format'
|
||||
: operation === 'rev-parse' && args.includes('--is-inside-work-tree')
|
||||
? 'worktree-probe'
|
||||
: operation,
|
||||
reason = /not a git repository|outside repository/i.test(result.stderr)
|
||||
? 'repository unavailable'
|
||||
: /dubious ownership/i.test(result.stderr)
|
||||
? 'repository ownership rejected'
|
||||
: /(?:bad|invalid|unable to read).*config|config (?:error|file)/i.test(result.stderr)
|
||||
? 'configuration rejected'
|
||||
: /unknown option|unknown switch|unrecognized option|usage:/i.test(result.stderr)
|
||||
? 'unsupported invocation'
|
||||
: /(?:cannot|could not|unable to) (?:chdir|change directory)|no such file or directory/i.test(
|
||||
result.stderr,
|
||||
)
|
||||
? 'path unavailable'
|
||||
: 'request rejected',
|
||||
outcome = result.timedOut
|
||||
? 'timed out'
|
||||
: result.truncated
|
||||
? 'exceeded the output limit'
|
||||
: `exited ${result.code}`;
|
||||
throw new CsoError(
|
||||
'MISSING_INPUT',
|
||||
`Could not read bounded Git metadata: ${phase} ${outcome} (${reason}); source may not be a Git repository`,
|
||||
);
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
+215
-59
@@ -13,10 +13,23 @@ interface RuntimeQualificationProvenance {
|
||||
provenanceDigest: string;
|
||||
verifiedProvenance: true;
|
||||
}
|
||||
export type RuntimeQualification = RuntimeQualificationProvenance & (
|
||||
| { kind: 'application'; containmentPassed: true; coldStartPassed: true; positiveNegativeAssertionsPassed: true; heldOutRepairPassed: true }
|
||||
| { kind: 'postgresql'; containmentPassed: true; coldStartPassed: true; multiDatabasePassed: true; readinessPassed: true }
|
||||
);
|
||||
export type RuntimeQualification = RuntimeQualificationProvenance &
|
||||
(
|
||||
| {
|
||||
kind: 'application';
|
||||
containmentPassed: true;
|
||||
coldStartPassed: true;
|
||||
positiveNegativeAssertionsPassed: true;
|
||||
heldOutRepairPassed: true;
|
||||
}
|
||||
| {
|
||||
kind: 'postgresql';
|
||||
containmentPassed: true;
|
||||
coldStartPassed: true;
|
||||
multiDatabasePassed: true;
|
||||
readinessPassed: true;
|
||||
}
|
||||
);
|
||||
export interface QualifiedRuntime {
|
||||
id: string;
|
||||
stack: CsoStack | 'postgresql';
|
||||
@@ -76,46 +89,96 @@ function versionsKey(versions: Record<string, string>): string {
|
||||
return JSON.stringify(Object.entries(versions).sort(([a], [b]) => a.localeCompare(b)));
|
||||
}
|
||||
|
||||
function validateRuntimeIdentity(value: { id: string; stack: string; platform: string; versions: Record<string, string> }): void {
|
||||
function validateRuntimeIdentity(value: {
|
||||
id: string;
|
||||
stack: string;
|
||||
platform: string;
|
||||
versions: Record<string, string>;
|
||||
}): void {
|
||||
if (typeof value.id !== 'string' || !ID.test(value.id)) throw new Error('INVALID_RUNTIME_ID');
|
||||
if (!STACKS.includes(value.stack as typeof STACKS[number]) || !PLATFORMS.includes(value.platform as RuntimePlatform)) throw new Error('UNSUPPORTED_RUNTIME_PLATFORM');
|
||||
if (!value.versions || typeof value.versions !== 'object' || Array.isArray(value.versions) || !Object.keys(value.versions).length ||
|
||||
Object.values(value.versions).some(version => typeof version !== 'string' || !/^[0-9][a-zA-Z0-9.+_-]*$/.test(version))) throw new Error('UNPINNED_RUNTIME_VERSION');
|
||||
if (Object.keys(value.versions).sort().join(',') !== [...REQUIRED[value.stack]].sort().join(',')) throw new Error('MISSING_RUNTIME_TOOL_VERSION');
|
||||
if (['node', 'bun', 'python', 'rails'].includes(value.stack) && value.versions['cso-preparation'] !== '1.0.0') throw new Error('INCOMPATIBLE_PREPARATION_HELPER');
|
||||
if (
|
||||
!STACKS.includes(value.stack as (typeof STACKS)[number]) ||
|
||||
!PLATFORMS.includes(value.platform as RuntimePlatform)
|
||||
)
|
||||
throw new Error('UNSUPPORTED_RUNTIME_PLATFORM');
|
||||
if (
|
||||
!value.versions ||
|
||||
typeof value.versions !== 'object' ||
|
||||
Array.isArray(value.versions) ||
|
||||
!Object.keys(value.versions).length ||
|
||||
Object.values(value.versions).some(
|
||||
(version) => typeof version !== 'string' || !/^[0-9][a-zA-Z0-9.+_-]*$/.test(version),
|
||||
)
|
||||
)
|
||||
throw new Error('UNPINNED_RUNTIME_VERSION');
|
||||
if (Object.keys(value.versions).sort().join(',') !== [...REQUIRED[value.stack]].sort().join(','))
|
||||
throw new Error('MISSING_RUNTIME_TOOL_VERSION');
|
||||
if (
|
||||
['node', 'bun', 'python', 'rails'].includes(value.stack) &&
|
||||
value.versions['cso-preparation'] !== '1.0.0'
|
||||
)
|
||||
throw new Error('INCOMPATIBLE_PREPARATION_HELPER');
|
||||
}
|
||||
|
||||
export function validateRuntimeCatalog(value: unknown): asserts value is RuntimeCatalog {
|
||||
const catalog = value as RuntimeCatalog;
|
||||
if (!catalog || catalog.schemaVersion !== 1 || catalog.helperAbi !== CSO_HELPER_ABI ||
|
||||
typeof catalog.revision !== 'string' || !BUILD_REVISION.test(catalog.revision) || !Array.isArray(catalog.runtimes)) throw new Error('INCOMPATIBLE_RUNTIME_CATALOG');
|
||||
if (catalog.previousRevision !== null && (typeof catalog.previousRevision !== 'string' || !BUILD_REVISION.test(catalog.previousRevision))) throw new Error('INVALID_RUNTIME_CATALOG');
|
||||
if (
|
||||
!catalog ||
|
||||
catalog.schemaVersion !== 1 ||
|
||||
catalog.helperAbi !== CSO_HELPER_ABI ||
|
||||
typeof catalog.revision !== 'string' ||
|
||||
!BUILD_REVISION.test(catalog.revision) ||
|
||||
!Array.isArray(catalog.runtimes)
|
||||
)
|
||||
throw new Error('INCOMPATIBLE_RUNTIME_CATALOG');
|
||||
if (
|
||||
catalog.previousRevision !== null &&
|
||||
(typeof catalog.previousRevision !== 'string' || !BUILD_REVISION.test(catalog.previousRevision))
|
||||
)
|
||||
throw new Error('INVALID_RUNTIME_CATALOG');
|
||||
|
||||
if (!Array.isArray(catalog.profiles) || catalog.profiles.length !== STACKS.length * PLATFORMS.length ||
|
||||
typeof catalog.buildRevision !== 'string' || !BUILD_REVISION.test(catalog.buildRevision)) throw new Error('INVALID_REVIEWED_RUNTIME_PROFILES');
|
||||
const profiles = new Map<string, ReviewedRuntimeProfile>(), profileIdentities = new Set<string>();
|
||||
if (
|
||||
!Array.isArray(catalog.profiles) ||
|
||||
catalog.profiles.length !== STACKS.length * PLATFORMS.length ||
|
||||
typeof catalog.buildRevision !== 'string' ||
|
||||
!BUILD_REVISION.test(catalog.buildRevision)
|
||||
)
|
||||
throw new Error('INVALID_REVIEWED_RUNTIME_PROFILES');
|
||||
const profiles = new Map<string, ReviewedRuntimeProfile>(),
|
||||
profileIdentities = new Set<string>();
|
||||
for (const profile of catalog.profiles) {
|
||||
validateRuntimeIdentity(profile);
|
||||
const identity = `${profile.stack}:${profile.platform}`;
|
||||
if (profiles.has(profile.id) || profileIdentities.has(identity) || profile.state !== 'build_reviewed' ||
|
||||
!Number.isFinite(Date.parse(profile.reviewedAt))) throw new Error('INVALID_REVIEWED_RUNTIME_PROFILE');
|
||||
profiles.set(profile.id, profile); profileIdentities.add(identity);
|
||||
}
|
||||
for (const stack of STACKS) for (const platform of PLATFORMS) {
|
||||
if (!profileIdentities.has(`${stack}:${platform}`)) throw new Error('INCOMPLETE_REVIEWED_RUNTIME_MATRIX');
|
||||
if (
|
||||
profiles.has(profile.id) ||
|
||||
profileIdentities.has(identity) ||
|
||||
profile.state !== 'build_reviewed' ||
|
||||
!Number.isFinite(Date.parse(profile.reviewedAt))
|
||||
)
|
||||
throw new Error('INVALID_REVIEWED_RUNTIME_PROFILE');
|
||||
profiles.set(profile.id, profile);
|
||||
profileIdentities.add(identity);
|
||||
}
|
||||
for (const stack of STACKS)
|
||||
for (const platform of PLATFORMS) {
|
||||
if (!profileIdentities.has(`${stack}:${platform}`))
|
||||
throw new Error('INCOMPLETE_REVIEWED_RUNTIME_MATRIX');
|
||||
}
|
||||
if (catalog.promotion !== undefined) {
|
||||
if (!/^[a-f0-9]{40}$/.test(catalog.promotion.sourceCommit) ||
|
||||
if (
|
||||
!/^[a-f0-9]{40}$/.test(catalog.promotion.sourceCommit) ||
|
||||
!QUALIFICATION_WORKFLOW.test(catalog.promotion.workflow) ||
|
||||
!DIGEST.test(catalog.promotion.evidenceDigest) ||
|
||||
!DIGEST.test(catalog.promotion.qualificationEvidenceDigest) ||
|
||||
Object.keys(catalog.promotion).sort().join(',') !==
|
||||
['evidenceDigest', 'qualificationEvidenceDigest', 'sourceCommit', 'workflow'].sort().join(',')) {
|
||||
['evidenceDigest', 'qualificationEvidenceDigest', 'sourceCommit', 'workflow'].sort().join(',')
|
||||
) {
|
||||
throw new Error('INVALID_RUNTIME_PROMOTION');
|
||||
}
|
||||
}
|
||||
|
||||
if (catalog.runtimes.length !== 0 && catalog.runtimes.length !== STACKS.length * PLATFORMS.length) throw new Error('INCOMPLETE_QUALIFIED_RUNTIME_MATRIX');
|
||||
if (catalog.runtimes.length !== 0 && catalog.runtimes.length !== STACKS.length * PLATFORMS.length)
|
||||
throw new Error('INCOMPLETE_QUALIFIED_RUNTIME_MATRIX');
|
||||
const ids = new Set<string>();
|
||||
const runtimeIdentities = new Set<string>();
|
||||
for (const runtime of catalog.runtimes) {
|
||||
@@ -124,35 +187,94 @@ export function validateRuntimeCatalog(value: unknown): asserts value is Runtime
|
||||
validateRuntimeIdentity(runtime);
|
||||
const identity = `${runtime.stack}:${runtime.platform}`;
|
||||
if (ids.has(runtime.id) || runtimeIdentities.has(identity)) throw new Error('INVALID_RUNTIME_ID');
|
||||
ids.add(runtime.id); runtimeIdentities.add(identity);
|
||||
ids.add(runtime.id);
|
||||
runtimeIdentities.add(identity);
|
||||
const arch = runtime.platform === 'linux/amd64' ? 'amd64' : 'arm64';
|
||||
const expectedImage = new RegExp(`^ghcr\\.io/garrytan/gstack/cso-staging/${runtime.stack}-${arch}@sha256:[a-f0-9]{64}$`);
|
||||
if (runtime.state !== 'qualified' || !IMAGE.test(runtime.image) || !expectedImage.test(runtime.image) || runtime.entrypoint !== '/opt/cso/entrypoint' ||
|
||||
runtime.helperAbi !== CSO_HELPER_ABI || runtime.policyVersion !== 'cso-isolation-v1') throw new Error('UNQUALIFIED_RUNTIME');
|
||||
const expectedImage = new RegExp(
|
||||
`^ghcr\\.io/garrytan/gstack/cso-staging/${runtime.stack}-${arch}@sha256:[a-f0-9]{64}$`,
|
||||
);
|
||||
if (
|
||||
runtime.state !== 'qualified' ||
|
||||
!IMAGE.test(runtime.image) ||
|
||||
!expectedImage.test(runtime.image) ||
|
||||
runtime.entrypoint !== '/opt/cso/entrypoint' ||
|
||||
runtime.helperAbi !== CSO_HELPER_ABI ||
|
||||
runtime.policyVersion !== 'cso-isolation-v1'
|
||||
)
|
||||
throw new Error('UNQUALIFIED_RUNTIME');
|
||||
const reviewed = profiles.get(runtime.id);
|
||||
if (!reviewed || reviewed.stack !== runtime.stack || reviewed.platform !== runtime.platform ||
|
||||
versionsKey(reviewed.versions) !== versionsKey(runtime.versions)) throw new Error('RUNTIME_BUILD_PROFILE_MISMATCH');
|
||||
if (!qualification || !/^[a-f0-9]{40}$/.test(qualification.sourceCommit) ||
|
||||
if (
|
||||
!reviewed ||
|
||||
reviewed.stack !== runtime.stack ||
|
||||
reviewed.platform !== runtime.platform ||
|
||||
versionsKey(reviewed.versions) !== versionsKey(runtime.versions)
|
||||
)
|
||||
throw new Error('RUNTIME_BUILD_PROFILE_MISMATCH');
|
||||
if (
|
||||
!qualification ||
|
||||
!/^[a-f0-9]{40}$/.test(qualification.sourceCommit) ||
|
||||
!QUALIFICATION_WORKFLOW.test(qualification.workflow) ||
|
||||
!DIGEST.test(qualification.sbomDigest) || !DIGEST.test(qualification.provenanceDigest) || qualification.verifiedProvenance !== true ||
|
||||
!Number.isFinite(Date.parse(runtime.qualifiedAt))) throw new Error('MISSING_RUNTIME_QUALIFICATION');
|
||||
!DIGEST.test(qualification.sbomDigest) ||
|
||||
!DIGEST.test(qualification.provenanceDigest) ||
|
||||
qualification.verifiedProvenance !== true ||
|
||||
!Number.isFinite(Date.parse(runtime.qualifiedAt))
|
||||
)
|
||||
throw new Error('MISSING_RUNTIME_QUALIFICATION');
|
||||
const keys = Object.keys(qualification).sort();
|
||||
const common = ['kind', 'sourceCommit', 'workflow', 'sbomDigest', 'provenanceDigest', 'verifiedProvenance'];
|
||||
const common = [
|
||||
'kind',
|
||||
'sourceCommit',
|
||||
'workflow',
|
||||
'sbomDigest',
|
||||
'provenanceDigest',
|
||||
'verifiedProvenance',
|
||||
];
|
||||
if (['node', 'bun', 'python', 'rails'].includes(runtime.stack)) {
|
||||
if (qualification.kind !== 'application' || qualification.containmentPassed !== true || qualification.coldStartPassed !== true ||
|
||||
qualification.positiveNegativeAssertionsPassed !== true || qualification.heldOutRepairPassed !== true ||
|
||||
keys.join(',') !== [...common, 'containmentPassed', 'coldStartPassed', 'positiveNegativeAssertionsPassed', 'heldOutRepairPassed'].sort().join(',')) throw new Error('MISSING_APPLICATION_QUALIFICATION');
|
||||
if (
|
||||
qualification.kind !== 'application' ||
|
||||
qualification.containmentPassed !== true ||
|
||||
qualification.coldStartPassed !== true ||
|
||||
qualification.positiveNegativeAssertionsPassed !== true ||
|
||||
qualification.heldOutRepairPassed !== true ||
|
||||
keys.join(',') !==
|
||||
[
|
||||
...common,
|
||||
'containmentPassed',
|
||||
'coldStartPassed',
|
||||
'positiveNegativeAssertionsPassed',
|
||||
'heldOutRepairPassed',
|
||||
]
|
||||
.sort()
|
||||
.join(',')
|
||||
)
|
||||
throw new Error('MISSING_APPLICATION_QUALIFICATION');
|
||||
} else {
|
||||
if (qualification.kind !== 'postgresql' || qualification.containmentPassed !== true || qualification.coldStartPassed !== true ||
|
||||
qualification.multiDatabasePassed !== true || qualification.readinessPassed !== true ||
|
||||
keys.join(',') !== [...common, 'containmentPassed', 'coldStartPassed', 'multiDatabasePassed', 'readinessPassed'].sort().join(',')) throw new Error('MISSING_POSTGRESQL_QUALIFICATION');
|
||||
if (
|
||||
qualification.kind !== 'postgresql' ||
|
||||
qualification.containmentPassed !== true ||
|
||||
qualification.coldStartPassed !== true ||
|
||||
qualification.multiDatabasePassed !== true ||
|
||||
qualification.readinessPassed !== true ||
|
||||
keys.join(',') !==
|
||||
[...common, 'containmentPassed', 'coldStartPassed', 'multiDatabasePassed', 'readinessPassed']
|
||||
.sort()
|
||||
.join(',')
|
||||
)
|
||||
throw new Error('MISSING_POSTGRESQL_QUALIFICATION');
|
||||
}
|
||||
}
|
||||
if (catalog.runtimes.length > 0) {
|
||||
for (const identity of profileIdentities) if (!runtimeIdentities.has(identity)) throw new Error('INCOMPLETE_QUALIFIED_RUNTIME_MATRIX');
|
||||
for (const identity of profileIdentities)
|
||||
if (!runtimeIdentities.has(identity)) throw new Error('INCOMPLETE_QUALIFIED_RUNTIME_MATRIX');
|
||||
if (!catalog.promotion) throw new Error('MISSING_RUNTIME_PROMOTION');
|
||||
if (catalog.runtimes.some(runtime => runtime.qualification.sourceCommit !== catalog.promotion!.sourceCommit ||
|
||||
runtime.qualification.workflow !== catalog.promotion!.workflow)) throw new Error('RUNTIME_PROMOTION_MISMATCH');
|
||||
if (
|
||||
catalog.runtimes.some(
|
||||
(runtime) =>
|
||||
runtime.qualification.sourceCommit !== catalog.promotion!.sourceCommit ||
|
||||
runtime.qualification.workflow !== catalog.promotion!.workflow,
|
||||
)
|
||||
)
|
||||
throw new Error('RUNTIME_PROMOTION_MISMATCH');
|
||||
if (catalog.promotion.evidenceDigest !== `sha256:${sha256(canonical(catalog.runtimes))}`) {
|
||||
throw new Error('RUNTIME_PROMOTION_EVIDENCE_MISMATCH');
|
||||
}
|
||||
@@ -163,38 +285,72 @@ export const RUNTIME_CATALOG = committedCatalog as RuntimeCatalog;
|
||||
validateRuntimeCatalog(RUNTIME_CATALOG);
|
||||
|
||||
export function assertRuntimeCompatible(plan: PreparationPlan, runtime: QualifiedRuntime): void {
|
||||
if (plan.schemaVersion !== 1 || plan.status !== 'ready' || runtime.stack !== plan.stack) throw new CsoError('INCOMPATIBLE_INPUT', `Prepared ${plan.stack} source cannot run in ${runtime.stack} runtime ${runtime.id}`);
|
||||
if (plan.schemaVersion !== 1 || plan.status !== 'ready' || runtime.stack !== plan.stack)
|
||||
throw new CsoError(
|
||||
'INCOMPATIBLE_INPUT',
|
||||
`Prepared ${plan.stack} source cannot run in ${runtime.stack} runtime ${runtime.id}`,
|
||||
);
|
||||
for (const [declared, rawRange] of Object.entries(plan.runtimeRequirements)) {
|
||||
if (!rawRange) continue;
|
||||
let tool = declared, range = rawRange;
|
||||
let tool = declared,
|
||||
range = rawRange;
|
||||
if (declared === 'packageManager') {
|
||||
const match = rawRange.match(/^([a-z][a-z0-9_-]*)@(.+)$/i);
|
||||
if (!match) throw new CsoError('PREREQUISITE', 'Package manager declaration must bind a named version range');
|
||||
tool = match[1]; range = match[2];
|
||||
if (!match)
|
||||
throw new CsoError('PREREQUISITE', 'Package manager declaration must bind a named version range');
|
||||
tool = match[1];
|
||||
range = match[2];
|
||||
}
|
||||
const version = runtime.versions[tool];
|
||||
if (!version) throw new CsoError('PREREQUISITE', `Qualified runtime ${runtime.id} does not declare a real ${tool} release`);
|
||||
if (!version)
|
||||
throw new CsoError(
|
||||
'PREREQUISITE',
|
||||
`Qualified runtime ${runtime.id} does not declare a real ${tool} release`,
|
||||
);
|
||||
let satisfies = false;
|
||||
try { satisfies = Bun.semver.satisfies(version.replace(/^v/, ''), range); } catch {}
|
||||
if (!satisfies) throw new CsoError('PREREQUISITE', `Qualified ${tool} ${version} does not satisfy source requirement ${range}`);
|
||||
try {
|
||||
satisfies = Bun.semver.satisfies(version.replace(/^v/, ''), range);
|
||||
} catch {}
|
||||
if (!satisfies)
|
||||
throw new CsoError(
|
||||
'PREREQUISITE',
|
||||
`Qualified ${tool} ${version} does not satisfy source requirement ${range}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function selectRuntime(profile: string, platform: RuntimePlatform, catalog: RuntimeCatalog = RUNTIME_CATALOG): QualifiedRuntime {
|
||||
export function selectRuntime(
|
||||
profile: string,
|
||||
platform: RuntimePlatform,
|
||||
catalog: RuntimeCatalog = RUNTIME_CATALOG,
|
||||
): QualifiedRuntime {
|
||||
validateRuntimeCatalog(catalog);
|
||||
const matches = catalog.runtimes.filter(runtime => runtime.platform === platform && (runtime.id === profile || runtime.stack === profile));
|
||||
const matches = catalog.runtimes.filter(
|
||||
(runtime) => runtime.platform === platform && (runtime.id === profile || runtime.stack === profile),
|
||||
);
|
||||
if (matches.length === 0) {
|
||||
const reviewed = catalog.profiles?.filter(item => item.platform === platform && (item.id === profile || item.stack === profile)) ?? [];
|
||||
const detail = reviewed.length === 1 ? ` Reviewed build profile ${reviewed[0].id} is awaiting a qualified image promotion.` : '';
|
||||
throw new Error(`MISSING_QUALIFIED_RUNTIME: ${profile} on ${platform}; build, qualify, and review a digest catalog before target execution.${detail}`);
|
||||
const reviewed =
|
||||
catalog.profiles?.filter(
|
||||
(item) => item.platform === platform && (item.id === profile || item.stack === profile),
|
||||
) ?? [];
|
||||
const detail =
|
||||
reviewed.length === 1
|
||||
? ` Reviewed build profile ${reviewed[0].id} is awaiting a qualified image promotion.`
|
||||
: '';
|
||||
throw new Error(
|
||||
`MISSING_QUALIFIED_RUNTIME: ${profile} on ${platform}; build, qualify, and review a digest catalog before target execution.${detail}`,
|
||||
);
|
||||
}
|
||||
if (matches.length !== 1) throw new Error(`AMBIGUOUS_RUNTIME: select an exact qualified runtime id for ${profile}.`);
|
||||
if (matches.length !== 1)
|
||||
throw new Error(`AMBIGUOUS_RUNTIME: select an exact qualified runtime id for ${profile}.`);
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** Rollback only pairs the previous catalog with a compatible helper; reports have their own schema. */
|
||||
export function rollbackCatalog(current: RuntimeCatalog, previous: RuntimeCatalog): RuntimeCatalog {
|
||||
validateRuntimeCatalog(current); validateRuntimeCatalog(previous);
|
||||
if (current.previousRevision !== previous.revision || current.helperAbi !== previous.helperAbi) throw new Error('INCOMPATIBLE_RUNTIME_ROLLBACK');
|
||||
validateRuntimeCatalog(current);
|
||||
validateRuntimeCatalog(previous);
|
||||
if (current.previousRevision !== previous.revision || current.helperAbi !== previous.helperAbi)
|
||||
throw new Error('INCOMPATIBLE_RUNTIME_ROLLBACK');
|
||||
return previous;
|
||||
}
|
||||
+162
-39
@@ -54,8 +54,15 @@ const IMAGE = /^(?:[a-z0-9.-]+(?::[0-9]+)?\/)?[a-z0-9][a-z0-9._/-]*@sha256:[a-f0
|
||||
const ID = /^[a-z0-9][a-z0-9._-]{0,100}$/;
|
||||
const QUALIFICATION_WORKFLOW = /^https:\/\/github\.com\/garrytan\/gstack\/actions\/runs\/[0-9]+$/;
|
||||
const PLATFORMS: RuntimePlatform[] = ['linux/amd64', 'linux/arm64'];
|
||||
const path = (s: unknown, prefix: string): s is string => typeof s === 'string' && s.startsWith(prefix) && !/[\x00-\x20\\,]/.test(s) && !s.split('/').some(x => x === '..' || x === '.') && !s.includes('//');
|
||||
function invalid(message: string): never { throw new CsoError('INCOMPATIBLE_INPUT', message); }
|
||||
const path = (s: unknown, prefix: string): s is string =>
|
||||
typeof s === 'string' &&
|
||||
s.startsWith(prefix) &&
|
||||
!/[\x00-\x20\\,]/.test(s) &&
|
||||
!s.split('/').some((x) => x === '..' || x === '.') &&
|
||||
!s.includes('//');
|
||||
function invalid(message: string): never {
|
||||
throw new CsoError('INCOMPATIBLE_INPUT', message);
|
||||
}
|
||||
function sameStrings(left: string[], right: string[]): boolean {
|
||||
return canonical([...left].sort()) === canonical([...right].sort());
|
||||
}
|
||||
@@ -68,63 +75,179 @@ export function scannerVersionHash(stdout: string, stderr = ''): string {
|
||||
* version to be one complete version token. A substring such as `1.2.3` in
|
||||
* `11.2.3`, `1.2.30`, or `1.2.3-dev` is not qualification evidence.
|
||||
*/
|
||||
export function assertScannerVersionOutput(scanner:ScannerId,version:string,stdout:string,stderr=''):void{
|
||||
if(!/^[0-9][A-Za-z0-9.+_-]{0,100}$/.test(version))invalid('Scanner version evidence has an invalid expected version');
|
||||
const output=`${stdout}\n${stderr}`;
|
||||
if(Buffer.byteLength(stdout)+Buffer.byteLength(stderr)>8192)invalid('Scanner version evidence exceeds the bounded output limit');
|
||||
const escaped=version.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
|
||||
const labels:Record<ScannerId,string>={gitleaks:'gitleaks',osv:'(?:osv|osv-scanner)',semgrep:'semgrep',zizmor:'zizmor',trivy:'trivy',schemathesis:'schemathesis'};
|
||||
const primary=output.split(/\r?\n/).map(line=>line.trim()).find(Boolean)??'';
|
||||
const exact=new RegExp(`^(?:v?${escaped}|${labels[scanner]},?\\s+(?:version\\s*:?\\s*)?v?${escaped}|version\\s*:\\s*v?${escaped})$`,'i');
|
||||
if(!exact.test(primary))invalid('Scanner primary version output does not match the exact catalog version');
|
||||
export function assertScannerVersionOutput(
|
||||
scanner: ScannerId,
|
||||
version: string,
|
||||
stdout: string,
|
||||
stderr = '',
|
||||
): void {
|
||||
if (!/^[0-9][A-Za-z0-9.+_-]{0,100}$/.test(version))
|
||||
invalid('Scanner version evidence has an invalid expected version');
|
||||
const output = `${stdout}\n${stderr}`;
|
||||
if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > 8192)
|
||||
invalid('Scanner version evidence exceeds the bounded output limit');
|
||||
const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const labels: Record<ScannerId, string> = {
|
||||
gitleaks: 'gitleaks',
|
||||
osv: '(?:osv|osv-scanner)',
|
||||
semgrep: 'semgrep',
|
||||
zizmor: 'zizmor',
|
||||
trivy: 'trivy',
|
||||
schemathesis: 'schemathesis',
|
||||
};
|
||||
const primary =
|
||||
output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? '';
|
||||
const exact = new RegExp(
|
||||
`^(?:v?${escaped}|${labels[scanner]},?\\s+(?:version\\s*:?\\s*)?v?${escaped}|version\\s*:\\s*v?${escaped})$`,
|
||||
'i',
|
||||
);
|
||||
if (!exact.test(primary))
|
||||
invalid('Scanner primary version output does not match the exact catalog version');
|
||||
}
|
||||
export function validateQualifiedScanner(s: QualifiedScanner): void {
|
||||
if (!SCANNER_IDS.includes(s.scanner) || !['linux/amd64', 'linux/arm64'].includes(s.platform)) invalid('Unsupported scanner or platform');
|
||||
const arch = s.platform === 'linux/amd64' ? 'amd64' : 'arm64';
|
||||
const expectedImage = new RegExp(`^ghcr\\.io/garrytan/gstack/cso-scanners/${s.scanner}-${arch}@sha256:[a-f0-9]{64}$`);
|
||||
if (s.state !== 'qualified' || !IMAGE.test(s.image) || !expectedImage.test(s.image) || s.entrypoint !== '/opt/cso/entrypoint' || s.helperAbi !== ABI || s.isolationPolicyHash !== ISOLATION_POLICY_HASH) invalid('Scanner profile is not qualified for this helper isolation policy');
|
||||
if (s.executable !== '/opt/cso/bin/scanner' || !/^[0-9][A-Za-z0-9.+_-]{0,100}$/.test(s.version) || !HASH.test(s.versionOutputSha256)) invalid('Scanner executable and version must be pinned');
|
||||
if (!Array.isArray(s.capabilities) || !s.capabilities.length || s.capabilities.length > 100 || s.capabilities.some(x => typeof x !== 'string' || !x || x.length > 100)) invalid('Scanner capabilities must be reviewed');
|
||||
const required = scannerPlans({ snapshotRoot: '/source', offline: true, selected: [s.scanner] })[0].requiredFeatures;
|
||||
if (!sameStrings(s.capabilities, required)) invalid('Scanner capabilities do not match the helper adapter contract');
|
||||
const rules = s.assets?.semgrepRules, db = s.assets?.advisoryDatabase;
|
||||
if (rules && (s.scanner !== 'semgrep' || !path(rules.path, '/policy/catalog/') || !HASH.test(rules.sha256))) invalid('Invalid immutable Semgrep rules');
|
||||
if (db && (!['osv', 'trivy'].includes(s.scanner) || !path(db.path, '/opt/cso/scanner-data/') || !HASH.test(db.contentSha256) || !Number.isFinite(Date.parse(db.updatedAt)) || !Array.isArray(db.ecosystems) || !db.ecosystems.length || db.ecosystems.some(x => typeof x !== 'string' || !x || x.length > 100))) invalid('Invalid immutable scanner database');
|
||||
if (s.scanner === 'semgrep' && !rules) invalid('Qualified Semgrep profiles require an immutable rules bundle');
|
||||
if (['osv', 'trivy'].includes(s.scanner) && !db) invalid(`Qualified ${s.scanner} profiles require an immutable offline database`);
|
||||
const q = s.qualification;
|
||||
if (!Number.isFinite(Date.parse(s.qualifiedAt)) || !q || !/^[a-f0-9]{40}$/.test(q.sourceCommit) || !QUALIFICATION_WORKFLOW.test(q.workflow) || !DIGEST.test(q.sbomDigest) || !DIGEST.test(q.provenanceDigest) || q.verifiedProvenance !== true || q.containmentPassed !== true || q.adapterContractPassed !== true || q.offlineAssetsPassed !== true) invalid('Missing trusted scanner qualification');
|
||||
if (!SCANNER_IDS.includes(s.scanner) || !['linux/amd64', 'linux/arm64'].includes(s.platform))
|
||||
invalid('Unsupported scanner or platform');
|
||||
const arch = s.platform === 'linux/amd64' ? 'amd64' : 'arm64';
|
||||
const expectedImage = new RegExp(
|
||||
`^ghcr\\.io/garrytan/gstack/cso-scanners/${s.scanner}-${arch}@sha256:[a-f0-9]{64}$`,
|
||||
);
|
||||
if (
|
||||
s.state !== 'qualified' ||
|
||||
!IMAGE.test(s.image) ||
|
||||
!expectedImage.test(s.image) ||
|
||||
s.entrypoint !== '/opt/cso/entrypoint' ||
|
||||
s.helperAbi !== ABI ||
|
||||
s.isolationPolicyHash !== ISOLATION_POLICY_HASH
|
||||
)
|
||||
invalid('Scanner profile is not qualified for this helper isolation policy');
|
||||
if (
|
||||
s.executable !== '/opt/cso/bin/scanner' ||
|
||||
!/^[0-9][A-Za-z0-9.+_-]{0,100}$/.test(s.version) ||
|
||||
!HASH.test(s.versionOutputSha256)
|
||||
)
|
||||
invalid('Scanner executable and version must be pinned');
|
||||
if (
|
||||
!Array.isArray(s.capabilities) ||
|
||||
!s.capabilities.length ||
|
||||
s.capabilities.length > 100 ||
|
||||
s.capabilities.some((x) => typeof x !== 'string' || !x || x.length > 100)
|
||||
)
|
||||
invalid('Scanner capabilities must be reviewed');
|
||||
const required = scannerPlans({ snapshotRoot: '/source', offline: true, selected: [s.scanner] })[0]
|
||||
.requiredFeatures;
|
||||
if (!sameStrings(s.capabilities, required))
|
||||
invalid('Scanner capabilities do not match the helper adapter contract');
|
||||
const rules = s.assets?.semgrepRules,
|
||||
db = s.assets?.advisoryDatabase;
|
||||
if (rules && (s.scanner !== 'semgrep' || !path(rules.path, '/policy/catalog/') || !HASH.test(rules.sha256)))
|
||||
invalid('Invalid immutable Semgrep rules');
|
||||
if (
|
||||
db &&
|
||||
(!['osv', 'trivy'].includes(s.scanner) ||
|
||||
!path(db.path, '/opt/cso/scanner-data/') ||
|
||||
!HASH.test(db.contentSha256) ||
|
||||
!Number.isFinite(Date.parse(db.updatedAt)) ||
|
||||
!Array.isArray(db.ecosystems) ||
|
||||
!db.ecosystems.length ||
|
||||
db.ecosystems.some((x) => typeof x !== 'string' || !x || x.length > 100))
|
||||
)
|
||||
invalid('Invalid immutable scanner database');
|
||||
if (s.scanner === 'semgrep' && !rules)
|
||||
invalid('Qualified Semgrep profiles require an immutable rules bundle');
|
||||
if (['osv', 'trivy'].includes(s.scanner) && !db)
|
||||
invalid(`Qualified ${s.scanner} profiles require an immutable offline database`);
|
||||
const q = s.qualification;
|
||||
if (
|
||||
!Number.isFinite(Date.parse(s.qualifiedAt)) ||
|
||||
!q ||
|
||||
!/^[a-f0-9]{40}$/.test(q.sourceCommit) ||
|
||||
!QUALIFICATION_WORKFLOW.test(q.workflow) ||
|
||||
!DIGEST.test(q.sbomDigest) ||
|
||||
!DIGEST.test(q.provenanceDigest) ||
|
||||
q.verifiedProvenance !== true ||
|
||||
q.containmentPassed !== true ||
|
||||
q.adapterContractPassed !== true ||
|
||||
q.offlineAssetsPassed !== true
|
||||
)
|
||||
invalid('Missing trusted scanner qualification');
|
||||
}
|
||||
export function validateScannerCatalog(value: unknown): asserts value is ScannerCatalog {
|
||||
const c = value as ScannerCatalog;
|
||||
if (!c || c.schemaVersion !== 1 || c.helperAbi !== ABI || typeof c.revision !== 'string' || !ID.test(c.revision) || !Array.isArray(c.scanners) || ![0, SCANNER_IDS.length * PLATFORMS.length].includes(c.scanners.length)) invalid('Incompatible scanner catalog');
|
||||
if (c.previousRevision !== undefined && c.previousRevision !== null && (typeof c.previousRevision !== 'string' || !ID.test(c.previousRevision) || c.previousRevision === c.revision)) invalid('Invalid previous scanner catalog revision');
|
||||
if (c.promotion !== undefined && (!/^[a-f0-9]{40}$/.test(c.promotion.sourceCommit) || !QUALIFICATION_WORKFLOW.test(c.promotion.workflow) || !DIGEST.test(c.promotion.evidenceDigest))) invalid('Invalid scanner catalog promotion');
|
||||
if (
|
||||
!c ||
|
||||
c.schemaVersion !== 1 ||
|
||||
c.helperAbi !== ABI ||
|
||||
typeof c.revision !== 'string' ||
|
||||
!ID.test(c.revision) ||
|
||||
!Array.isArray(c.scanners) ||
|
||||
![0, SCANNER_IDS.length * PLATFORMS.length].includes(c.scanners.length)
|
||||
)
|
||||
invalid('Incompatible scanner catalog');
|
||||
if (
|
||||
c.previousRevision !== undefined &&
|
||||
c.previousRevision !== null &&
|
||||
(typeof c.previousRevision !== 'string' ||
|
||||
!ID.test(c.previousRevision) ||
|
||||
c.previousRevision === c.revision)
|
||||
)
|
||||
invalid('Invalid previous scanner catalog revision');
|
||||
if (
|
||||
c.promotion !== undefined &&
|
||||
(!/^[a-f0-9]{40}$/.test(c.promotion.sourceCommit) ||
|
||||
!QUALIFICATION_WORKFLOW.test(c.promotion.workflow) ||
|
||||
!DIGEST.test(c.promotion.evidenceDigest))
|
||||
)
|
||||
invalid('Invalid scanner catalog promotion');
|
||||
if (c.scanners.length === 0) {
|
||||
if (c.promotion !== undefined) invalid('Empty scanner catalog cannot have a promotion');
|
||||
return;
|
||||
}
|
||||
if (!c.promotion) invalid('Qualified scanner catalog requires trusted promotion evidence');
|
||||
const ids = new Set<string>(), identities = new Set<string>();
|
||||
const ids = new Set<string>(),
|
||||
identities = new Set<string>();
|
||||
for (const s of c.scanners) {
|
||||
if (!s || typeof s.id !== 'string' || !ID.test(s.id) || ids.has(s.id)) invalid('Invalid or duplicate scanner profile');
|
||||
if (!s || typeof s.id !== 'string' || !ID.test(s.id) || ids.has(s.id))
|
||||
invalid('Invalid or duplicate scanner profile');
|
||||
const identity = `${s.scanner}:${s.platform}`;
|
||||
if (identities.has(identity)) invalid('Invalid or duplicate scanner profile');
|
||||
ids.add(s.id); identities.add(identity);
|
||||
ids.add(s.id);
|
||||
identities.add(identity);
|
||||
validateQualifiedScanner(s);
|
||||
if (s.qualification.sourceCommit !== c.promotion.sourceCommit || s.qualification.workflow !== c.promotion.workflow) invalid('Scanner qualification does not match catalog promotion');
|
||||
if (
|
||||
s.qualification.sourceCommit !== c.promotion.sourceCommit ||
|
||||
s.qualification.workflow !== c.promotion.workflow
|
||||
)
|
||||
invalid('Scanner qualification does not match catalog promotion');
|
||||
}
|
||||
for (const scanner of SCANNER_IDS) for (const platform of PLATFORMS) if (!identities.has(`${scanner}:${platform}`)) invalid('Incomplete qualified scanner matrix');
|
||||
if (c.promotion.evidenceDigest !== `sha256:${sha256(canonical(c.scanners))}`) invalid('Scanner catalog promotion does not bind the qualified matrix');
|
||||
for (const scanner of SCANNER_IDS)
|
||||
for (const platform of PLATFORMS)
|
||||
if (!identities.has(`${scanner}:${platform}`)) invalid('Incomplete qualified scanner matrix');
|
||||
if (c.promotion.evidenceDigest !== `sha256:${sha256(canonical(c.scanners))}`)
|
||||
invalid('Scanner catalog promotion does not bind the qualified matrix');
|
||||
}
|
||||
export const SCANNER_CATALOG = committedCatalog as unknown as ScannerCatalog;
|
||||
// A malformed source-controlled catalog must break the helper build/startup;
|
||||
// it can never degrade into an unreviewed executable fallback.
|
||||
validateScannerCatalog(SCANNER_CATALOG);
|
||||
export function selectScanner(scanner: ScannerId, platform: RuntimePlatform, profile?: string, catalog: ScannerCatalog = SCANNER_CATALOG): QualifiedScanner {
|
||||
export function selectScanner(
|
||||
scanner: ScannerId,
|
||||
platform: RuntimePlatform,
|
||||
profile?: string,
|
||||
catalog: ScannerCatalog = SCANNER_CATALOG,
|
||||
): QualifiedScanner {
|
||||
validateScannerCatalog(catalog);
|
||||
const matches = catalog.scanners.filter(s => s.scanner === scanner && s.platform === platform && (!profile || s.id === profile));
|
||||
if (!matches.length) throw new CsoError('PREREQUISITE', `No qualified ${scanner} image for ${platform}${profile ? ` (${profile})` : ''}; qualify and review an immutable scanner catalog before execution`);
|
||||
if (matches.length !== 1) throw new CsoError('PREREQUISITE', `Select an exact qualified ${scanner} profile for ${platform}`);
|
||||
const matches = catalog.scanners.filter(
|
||||
(s) => s.scanner === scanner && s.platform === platform && (!profile || s.id === profile),
|
||||
);
|
||||
if (!matches.length)
|
||||
throw new CsoError(
|
||||
'PREREQUISITE',
|
||||
`No qualified ${scanner} image for ${platform}${profile ? ` (${profile})` : ''}; qualify and review an immutable scanner catalog before execution`,
|
||||
);
|
||||
if (matches.length !== 1)
|
||||
throw new CsoError('PREREQUISITE', `Select an exact qualified ${scanner} profile for ${platform}`);
|
||||
return matches[0];
|
||||
}
|
||||
+628
-144
@@ -2,17 +2,63 @@
|
||||
import * as fs from 'node:fs';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
import { Command, CoverageRecord, CsoError, HttpAssertion, RunPolicy, SnapshotManifest, canonical, object, relativePath, sha256, snapshotPathHandleId, snapshotReference, string, strings, validateCommand, validateVerificationObservation, type ErrorCode } from './contracts';
|
||||
import {
|
||||
Command,
|
||||
CoverageRecord,
|
||||
CsoError,
|
||||
HttpAssertion,
|
||||
RunPolicy,
|
||||
SnapshotManifest,
|
||||
canonical,
|
||||
object,
|
||||
relativePath,
|
||||
sha256,
|
||||
snapshotPathHandleId,
|
||||
snapshotReference,
|
||||
string,
|
||||
strings,
|
||||
validateCommand,
|
||||
validateVerificationObservation,
|
||||
type ErrorCode,
|
||||
} from './contracts';
|
||||
import { DockerEndpoint, DockerGroup, dockerEndpoint } from './docker';
|
||||
import { inspectPreparation, type CsoStack } from './preparation';
|
||||
import { redact } from './process';
|
||||
import { QualifiedRuntime, RUNTIME_CATALOG, RuntimeCatalog, RuntimePlatform, assertRuntimeCompatible, selectRuntime } from './runtime-catalog';
|
||||
import { QualifiedScanner, SCANNER_CATALOG, ScannerCatalog, assertScannerVersionOutput, scannerVersionHash, selectScanner } from './scanner-catalog';
|
||||
import { ScannerExecution, ScannerGap, ScannerId, ScannerOutcome, ScannerPlan, parseScannerOutput, scannerPlans } from './scanners';
|
||||
import {
|
||||
QualifiedRuntime,
|
||||
RUNTIME_CATALOG,
|
||||
RuntimeCatalog,
|
||||
RuntimePlatform,
|
||||
assertRuntimeCompatible,
|
||||
selectRuntime,
|
||||
} from './runtime-catalog';
|
||||
import {
|
||||
QualifiedScanner,
|
||||
SCANNER_CATALOG,
|
||||
ScannerCatalog,
|
||||
assertScannerVersionOutput,
|
||||
scannerVersionHash,
|
||||
selectScanner,
|
||||
} from './scanner-catalog';
|
||||
import {
|
||||
ScannerExecution,
|
||||
ScannerGap,
|
||||
ScannerId,
|
||||
ScannerOutcome,
|
||||
ScannerPlan,
|
||||
parseScannerOutput,
|
||||
scannerPlans,
|
||||
} from './scanners';
|
||||
import { assertSnapshot } from './snapshot';
|
||||
import { hasPendingWatchdogCleanup, secureDirectory } from './state';
|
||||
import { PublicArchiveCache, publicArchiveCacheRoot } from './cache';
|
||||
import { admitPreparationRuntime, admitPreparationSidecar, PreparationExecutor, type PreparationSandboxRunner, type RailsDatabaseSelection } from './preparation-executor';
|
||||
import {
|
||||
admitPreparationRuntime,
|
||||
admitPreparationSidecar,
|
||||
PreparationExecutor,
|
||||
type PreparationSandboxRunner,
|
||||
type RailsDatabaseSelection,
|
||||
} from './preparation-executor';
|
||||
import type { PreparedDatabaseContract } from './preparation-executor';
|
||||
import { DockerPreparationSandboxRunner } from './preparation-docker';
|
||||
import { canonicalStartPlan, type CanonicalStartPlan } from './verification';
|
||||
@@ -79,7 +125,9 @@ export interface ScannerRunner {
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
/** The trusted HTTP control probe is always the bounded verifier process. */
|
||||
export function schemathesisControlRole(): 'verifier' { return 'verifier'; }
|
||||
export function schemathesisControlRole(): 'verifier' {
|
||||
return 'verifier';
|
||||
}
|
||||
export interface ScannerRunnerContext {
|
||||
input: ScannerRunInput;
|
||||
plan: ScannerPlan;
|
||||
@@ -107,58 +155,121 @@ export interface ScannerRunDependencies {
|
||||
}
|
||||
|
||||
function exact(v: Record<string, unknown>, allowed: string[], name: string): void {
|
||||
for (const key of Object.keys(v)) if (!allowed.includes(key)) throw new CsoError('INVALID_SCHEMA', `Unexpected ${name} field: ${key}`);
|
||||
for (const key of Object.keys(v))
|
||||
if (!allowed.includes(key)) throw new CsoError('INVALID_SCHEMA', `Unexpected ${name} field: ${key}`);
|
||||
}
|
||||
function boundedInt(v: unknown, min: number, max: number, name: string): number {
|
||||
if (!Number.isSafeInteger(v) || (v as number) < min || (v as number) > max) throw new CsoError('INVALID_SCHEMA', `${name} must be ${min}..${max}`);
|
||||
if (!Number.isSafeInteger(v) || (v as number) < min || (v as number) > max)
|
||||
throw new CsoError('INVALID_SCHEMA', `${name} must be ${min}..${max}`);
|
||||
return v as number;
|
||||
}
|
||||
function control(value: unknown): HttpAssertion {
|
||||
const v = object(value, 'API control'), expected = object(v.expected, 'API control expected');
|
||||
const v = object(value, 'API control'),
|
||||
expected = object(v.expected, 'API control expected');
|
||||
exact(v, ['name', 'path', 'method', 'headers', 'body', 'expected'], 'API control');
|
||||
exact(expected, ['status', 'includes', 'excludes'], 'API control expected');
|
||||
const path = string(v.path, 'API control path', 4096);
|
||||
if (!path.startsWith('/') || path.startsWith('//') || /[\r\n\\]/.test(path)) throw new CsoError('INVALID_SCHEMA', 'API control path must remain on numeric loopback');
|
||||
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(v.method)) throw new CsoError('INVALID_SCHEMA', 'Invalid API control method');
|
||||
if (!path.startsWith('/') || path.startsWith('//') || /[\r\n\\]/.test(path))
|
||||
throw new CsoError('INVALID_SCHEMA', 'API control path must remain on numeric loopback');
|
||||
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(v.method))
|
||||
throw new CsoError('INVALID_SCHEMA', 'Invalid API control method');
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(v.headers === undefined ? {} : object(v.headers, 'API control headers'))) {
|
||||
if (!/^[A-Za-z0-9-]{1,100}$/.test(key) || typeof value !== 'string' || value.length > 8192 || /[\r\n]/.test(value)) throw new CsoError('INVALID_SCHEMA', 'Invalid API control header');
|
||||
for (const [key, value] of Object.entries(
|
||||
v.headers === undefined ? {} : object(v.headers, 'API control headers'),
|
||||
)) {
|
||||
if (
|
||||
!/^[A-Za-z0-9-]{1,100}$/.test(key) ||
|
||||
typeof value !== 'string' ||
|
||||
value.length > 8192 ||
|
||||
/[\r\n]/.test(value)
|
||||
)
|
||||
throw new CsoError('INVALID_SCHEMA', 'Invalid API control header');
|
||||
headers[key] = value;
|
||||
}
|
||||
return { name: string(v.name, 'API control name', 200), path, method: v.method, headers,
|
||||
return {
|
||||
name: string(v.name, 'API control name', 200),
|
||||
path,
|
||||
method: v.method,
|
||||
headers,
|
||||
...(v.body === undefined ? {} : { body: string(v.body, 'API control body', 65536) }),
|
||||
expected: { status: boundedInt(expected.status, 100, 599, 'API control status'),
|
||||
...(expected.includes === undefined ? {} : { includes: string(expected.includes, 'API control includes', 8192) }),
|
||||
...(expected.excludes === undefined ? {} : { excludes: string(expected.excludes, 'API control excludes', 8192) }) } };
|
||||
expected: {
|
||||
status: boundedInt(expected.status, 100, 599, 'API control status'),
|
||||
...(expected.includes === undefined
|
||||
? {}
|
||||
: { includes: string(expected.includes, 'API control includes', 8192) }),
|
||||
...(expected.excludes === undefined
|
||||
? {}
|
||||
: { excludes: string(expected.excludes, 'API control excludes', 8192) }),
|
||||
},
|
||||
};
|
||||
}
|
||||
/** Accept a bounded OpenAPI document, with internal references and selected path operations only. */
|
||||
export function validateScannerRequest(value: unknown, id: ScannerId): ScannerRequest {
|
||||
const v = object(value, 'scanner request');
|
||||
exact(v, ['profile', 'api'], 'scanner request');
|
||||
const request: ScannerRequest = v.profile === undefined ? {} : { profile: string(v.profile, 'scanner profile', 100) };
|
||||
const request: ScannerRequest =
|
||||
v.profile === undefined ? {} : { profile: string(v.profile, 'scanner profile', 100) };
|
||||
if (v.api === undefined) return request;
|
||||
if (id !== 'schemathesis') throw new CsoError('INVALID_SCHEMA', 'Only Schemathesis accepts application execution inputs');
|
||||
if (id !== 'schemathesis')
|
||||
throw new CsoError('INVALID_SCHEMA', 'Only Schemathesis accepts application execution inputs');
|
||||
const api = object(v.api, 'API scan');
|
||||
exact(api, ['runtimeProfile', 'port', 'start', 'control', 'boundaryFiles', 'schema', 'operationIds', 'seed', 'maxExamples'], 'API scan');
|
||||
const schema = object(api.schema, 'OpenAPI schema'), operations = strings(api.operationIds, 'operation IDs');
|
||||
if (operations.length < 1 || operations.length > 20 || new Set(operations).size !== operations.length || operations.some(x => x.length > 200 || /[\x00-\x1f]/.test(x))) throw new CsoError('INVALID_SCHEMA', 'Declare 1..20 unique bounded operation IDs');
|
||||
if (typeof schema.openapi !== 'string' || !/^3\.[01]\.\d+$/.test(schema.openapi)) throw new CsoError('PREREQUISITE', 'Schemathesis requires a reviewed OpenAPI 3.0/3.1 JSON document');
|
||||
if (Buffer.byteLength(JSON.stringify(schema)) > 262144) throw new CsoError('INVALID_SCHEMA', 'OpenAPI schema exceeds 256 KiB');
|
||||
exact(
|
||||
api,
|
||||
[
|
||||
'runtimeProfile',
|
||||
'port',
|
||||
'start',
|
||||
'control',
|
||||
'boundaryFiles',
|
||||
'schema',
|
||||
'operationIds',
|
||||
'seed',
|
||||
'maxExamples',
|
||||
],
|
||||
'API scan',
|
||||
);
|
||||
const schema = object(api.schema, 'OpenAPI schema'),
|
||||
operations = strings(api.operationIds, 'operation IDs');
|
||||
if (
|
||||
operations.length < 1 ||
|
||||
operations.length > 20 ||
|
||||
new Set(operations).size !== operations.length ||
|
||||
operations.some((x) => x.length > 200 || /[\x00-\x1f]/.test(x))
|
||||
)
|
||||
throw new CsoError('INVALID_SCHEMA', 'Declare 1..20 unique bounded operation IDs');
|
||||
if (typeof schema.openapi !== 'string' || !/^3\.[01]\.\d+$/.test(schema.openapi))
|
||||
throw new CsoError('PREREQUISITE', 'Schemathesis requires a reviewed OpenAPI 3.0/3.1 JSON document');
|
||||
if (Buffer.byteLength(JSON.stringify(schema)) > 262144)
|
||||
throw new CsoError('INVALID_SCHEMA', 'OpenAPI schema exceeds 256 KiB');
|
||||
let nodes = 0;
|
||||
const inspect = (x: unknown, depth: number): void => {
|
||||
if (++nodes > 50_000 || depth > 32) throw new CsoError('INVALID_SCHEMA', 'OpenAPI schema exceeds structural bounds');
|
||||
if (++nodes > 50_000 || depth > 32)
|
||||
throw new CsoError('INVALID_SCHEMA', 'OpenAPI schema exceeds structural bounds');
|
||||
if (!x || typeof x !== 'object') return;
|
||||
for (const [key, value] of Object.entries(x)) {
|
||||
if (['__proto__', 'prototype', 'constructor', 'externalValue', 'callbacks', 'webhooks'].includes(key) || /hooks?/i.test(key)) throw new CsoError('PREREQUISITE', 'OpenAPI external examples, callbacks, webhooks, and hooks are not admitted');
|
||||
if (key === '$ref' && (typeof value !== 'string' || !value.startsWith('#/'))) throw new CsoError('PREREQUISITE', 'OpenAPI references must be internal JSON pointers');
|
||||
if (key === 'servers' && (!Array.isArray(value) || value.length)) throw new CsoError('PREREQUISITE', 'Remove server overrides from the reviewed API harness; its target is the isolated loopback application');
|
||||
if (
|
||||
['__proto__', 'prototype', 'constructor', 'externalValue', 'callbacks', 'webhooks'].includes(key) ||
|
||||
/hooks?/i.test(key)
|
||||
)
|
||||
throw new CsoError(
|
||||
'PREREQUISITE',
|
||||
'OpenAPI external examples, callbacks, webhooks, and hooks are not admitted',
|
||||
);
|
||||
if (key === '$ref' && (typeof value !== 'string' || !value.startsWith('#/')))
|
||||
throw new CsoError('PREREQUISITE', 'OpenAPI references must be internal JSON pointers');
|
||||
if (key === 'servers' && (!Array.isArray(value) || value.length))
|
||||
throw new CsoError(
|
||||
'PREREQUISITE',
|
||||
'Remove server overrides from the reviewed API harness; its target is the isolated loopback application',
|
||||
);
|
||||
inspect(value, depth + 1);
|
||||
}
|
||||
};
|
||||
inspect(schema, 0);
|
||||
const declared: string[] = [];
|
||||
for (const [path, item] of Object.entries(object(schema.paths, 'OpenAPI paths'))) {
|
||||
if (!path.startsWith('/') || path.startsWith('//') || /[\r\n\\?#]/.test(path)) throw new CsoError('INVALID_SCHEMA', 'OpenAPI paths must be relative to the loopback target');
|
||||
if (!path.startsWith('/') || path.startsWith('//') || /[\r\n\\?#]/.test(path))
|
||||
throw new CsoError('INVALID_SCHEMA', 'OpenAPI paths must be relative to the loopback target');
|
||||
const methods = object(item, 'OpenAPI path');
|
||||
for (const method of ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']) {
|
||||
if (methods[method] === undefined) continue;
|
||||
@@ -166,148 +277,413 @@ export function validateScannerRequest(value: unknown, id: ScannerId): ScannerRe
|
||||
if (typeof op.operationId === 'string') declared.push(op.operationId);
|
||||
}
|
||||
}
|
||||
if (operations.some(op => declared.filter(x => x === op).length !== 1)) throw new CsoError('INVALID_SCHEMA', 'Every selected operation must identify exactly one declared OpenAPI path operation');
|
||||
if (operations.some((op) => declared.filter((x) => x === op).length !== 1))
|
||||
throw new CsoError(
|
||||
'INVALID_SCHEMA',
|
||||
'Every selected operation must identify exactly one declared OpenAPI path operation',
|
||||
);
|
||||
const boundaries = strings(api.boundaryFiles, 'API boundary files').map(snapshotReference);
|
||||
if (!boundaries.length || new Set(boundaries).size !== boundaries.length) throw new CsoError('INVALID_SCHEMA', 'API scan needs unique security-boundary source paths');
|
||||
request.api = { runtimeProfile: string(api.runtimeProfile, 'API runtime profile', 100), port: boundedInt(api.port, 1024, 65535, 'API port'), start: validateCommand(api.start, 'API start'), control: control(api.control), boundaryFiles: boundaries, schema, operationIds: operations,
|
||||
if (!boundaries.length || new Set(boundaries).size !== boundaries.length)
|
||||
throw new CsoError('INVALID_SCHEMA', 'API scan needs unique security-boundary source paths');
|
||||
request.api = {
|
||||
runtimeProfile: string(api.runtimeProfile, 'API runtime profile', 100),
|
||||
port: boundedInt(api.port, 1024, 65535, 'API port'),
|
||||
start: validateCommand(api.start, 'API start'),
|
||||
control: control(api.control),
|
||||
boundaryFiles: boundaries,
|
||||
schema,
|
||||
operationIds: operations,
|
||||
...(api.seed === undefined ? {} : { seed: boundedInt(api.seed, 1, 2147483647, 'API seed') }),
|
||||
...(api.maxExamples === undefined ? {} : { maxExamples: boundedInt(api.maxExamples, 1, 100, 'API maxExamples') }) };
|
||||
...(api.maxExamples === undefined
|
||||
? {}
|
||||
: { maxExamples: boundedInt(api.maxExamples, 1, 100, 'API maxExamples') }),
|
||||
};
|
||||
const raw = JSON.stringify(request);
|
||||
if (redact(raw) !== raw) throw new CsoError('REDACTION_FAILED', 'Scanner harness contains secret-bearing material; use synthetic inputs');
|
||||
if (redact(raw) !== raw)
|
||||
throw new CsoError(
|
||||
'REDACTION_FAILED',
|
||||
'Scanner harness contains secret-bearing material; use synthetic inputs',
|
||||
);
|
||||
return request;
|
||||
}
|
||||
|
||||
/** Resolve only helper-issued path references before any application command reaches containment. */
|
||||
export function resolveScannerRequestPaths(manifest:SnapshotManifest,request:ScannerRequest):ScannerRequest{
|
||||
if(!request.api)return request;
|
||||
const resolve=(reference:string):string=>{const id=snapshotPathHandleId(reference);if(!id)return relativePath(reference);const entry=manifest.entries.find(item=>item.pathId===id);if(!entry)throw new CsoError('INVALID_SCHEMA',`API path handle is outside the retained snapshot: ${reference}`);return entry.path;};
|
||||
const argument=(value:string):string=>{if(snapshotPathHandleId(value))return resolve(value);if(value.startsWith('./')&&snapshotPathHandleId(value.slice(2)))return `./${resolve(value.slice(2))}`;return value;};
|
||||
return{...request,api:{...request.api,start:{...request.api.start,args:request.api.start.args.map(argument)},boundaryFiles:request.api.boundaryFiles.map(resolve)}};
|
||||
export function resolveScannerRequestPaths(
|
||||
manifest: SnapshotManifest,
|
||||
request: ScannerRequest,
|
||||
): ScannerRequest {
|
||||
if (!request.api) return request;
|
||||
const resolve = (reference: string): string => {
|
||||
const id = snapshotPathHandleId(reference);
|
||||
if (!id) return relativePath(reference);
|
||||
const entry = manifest.entries.find((item) => item.pathId === id);
|
||||
if (!entry)
|
||||
throw new CsoError('INVALID_SCHEMA', `API path handle is outside the retained snapshot: ${reference}`);
|
||||
return entry.path;
|
||||
};
|
||||
const argument = (value: string): string => {
|
||||
if (snapshotPathHandleId(value)) return resolve(value);
|
||||
if (value.startsWith('./') && snapshotPathHandleId(value.slice(2))) return `./${resolve(value.slice(2))}`;
|
||||
return value;
|
||||
};
|
||||
return {
|
||||
...request,
|
||||
api: {
|
||||
...request.api,
|
||||
start: { ...request.api.start, args: request.api.start.args.map(argument) },
|
||||
boundaryFiles: request.api.boundaryFiles.map(resolve),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function scannerCoverage(outcome: ScannerOutcome, scope: string): CoverageRecord {
|
||||
return { domain: `scanner:${outcome.tool}`, scope, status: outcome.status === 'complete' ? 'assessed' : outcome.status,
|
||||
method: outcome.tool === 'sarif' ? 'bounded untrusted SARIF import' : 'qualified offline Docker scanner; candidate evidence only',
|
||||
gaps: outcome.gaps.map(g => g.message), exclusions: outcome.exclusions,
|
||||
return {
|
||||
domain: `scanner:${outcome.tool}`,
|
||||
scope,
|
||||
status: outcome.status === 'complete' ? 'assessed' : outcome.status,
|
||||
method:
|
||||
outcome.tool === 'sarif'
|
||||
? 'bounded untrusted SARIF import'
|
||||
: 'qualified offline Docker scanner; candidate evidence only',
|
||||
gaps: outcome.gaps.map((g) => g.message),
|
||||
exclusions: outcome.exclusions,
|
||||
evidence: [`${outcome.candidates.length} scanner candidates; plan ${outcome.planSha256}`],
|
||||
tool: { name: outcome.tool, version: outcome.version ?? 'unavailable', freshness: outcome.databaseUpdatedAt ?? 'not reported', outcome: outcome.status } };
|
||||
tool: {
|
||||
name: outcome.tool,
|
||||
version: outcome.version ?? 'unavailable',
|
||||
freshness: outcome.databaseUpdatedAt ?? 'not reported',
|
||||
outcome: outcome.status,
|
||||
},
|
||||
};
|
||||
}
|
||||
function failure(plan: ScannerPlan, error: unknown, version?: string): ScannerOutcome {
|
||||
const e = error instanceof CsoError ? error : new CsoError('ISOLATION_FAILED', 'Scanner execution failed before bounded evidence was established');
|
||||
const e =
|
||||
error instanceof CsoError
|
||||
? error
|
||||
: new CsoError('ISOLATION_FAILED', 'Scanner execution failed before bounded evidence was established');
|
||||
const codes: Record<ErrorCode, ScannerGap['code']> = {
|
||||
INVALID_ARGUMENT: 'INVALID_OUTPUT', INVALID_SCHEMA: 'INVALID_OUTPUT', MISSING_INPUT: 'MISSING_INPUT', SNAPSHOT_RACE: 'SNAPSHOT_RACE',
|
||||
UNSAFE_PATH: 'UNSAFE_PATH', REDACTION_FAILED: 'REDACTION_FAILED', PERSISTENCE_FAILED: 'PERSISTENCE_FAILED', TOOL_UNAVAILABLE: 'UNAVAILABLE',
|
||||
TOOL_FAILED: 'TOOL_FAILED', ISOLATION_FAILED: 'ISOLATION_FAILED', INSUFFICIENT_CAPACITY: 'INSUFFICIENT_CAPACITY', DEADLINE: 'TIMEOUT',
|
||||
CANCELLED: 'CANCELLED', PREREQUISITE: 'PREREQUISITE', INCOMPATIBLE_INPUT: 'PREREQUISITE', ASSERTION_FAILED: 'TOOL_FAILED',
|
||||
INVALID_ARGUMENT: 'INVALID_OUTPUT',
|
||||
INVALID_SCHEMA: 'INVALID_OUTPUT',
|
||||
MISSING_INPUT: 'MISSING_INPUT',
|
||||
SNAPSHOT_RACE: 'SNAPSHOT_RACE',
|
||||
UNSAFE_PATH: 'UNSAFE_PATH',
|
||||
REDACTION_FAILED: 'REDACTION_FAILED',
|
||||
PERSISTENCE_FAILED: 'PERSISTENCE_FAILED',
|
||||
TOOL_UNAVAILABLE: 'UNAVAILABLE',
|
||||
TOOL_FAILED: 'TOOL_FAILED',
|
||||
ISOLATION_FAILED: 'ISOLATION_FAILED',
|
||||
INSUFFICIENT_CAPACITY: 'INSUFFICIENT_CAPACITY',
|
||||
DEADLINE: 'TIMEOUT',
|
||||
CANCELLED: 'CANCELLED',
|
||||
PREREQUISITE: 'PREREQUISITE',
|
||||
INCOMPATIBLE_INPUT: 'PREREQUISITE',
|
||||
ASSERTION_FAILED: 'TOOL_FAILED',
|
||||
};
|
||||
const code = codes[e.code];
|
||||
return { ...parseScannerOutput(plan, { stdout: '', exitCode: null, version }), status: 'not_assessed', candidates: [], gaps: [{ code, message: e.message }] };
|
||||
return {
|
||||
...parseScannerOutput(plan, { stdout: '', exitCode: null, version }),
|
||||
status: 'not_assessed',
|
||||
candidates: [],
|
||||
gaps: [{ code, message: e.message }],
|
||||
};
|
||||
}
|
||||
|
||||
/** Empty catalogs and missing assets produce coverage gaps without opening Docker. */
|
||||
export async function executeScanner(input: ScannerRunInput, dependencies: ScannerRunDependencies = {}): Promise<ScannerRunRecord> {
|
||||
const identityRequest = validateScannerRequest(input.request ?? {}, input.id), catalog = dependencies.catalog ?? SCANNER_CATALOG;
|
||||
export async function executeScanner(
|
||||
input: ScannerRunInput,
|
||||
dependencies: ScannerRunDependencies = {},
|
||||
): Promise<ScannerRunRecord> {
|
||||
const identityRequest = validateScannerRequest(input.request ?? {}, input.id),
|
||||
catalog = dependencies.catalog ?? SCANNER_CATALOG;
|
||||
const timeout = Math.min(300, Math.floor((input.executionDeadline - Date.now()) / 1000));
|
||||
let profile: QualifiedScanner | undefined, runtime: QualifiedRuntime | undefined, observedVersion: string | undefined, versionHash: string | null = null;
|
||||
let application: ScannerApplicationPreparation | undefined,request=identityRequest;
|
||||
let plan = scannerPlans({ snapshotRoot: '/source', offline: input.policy.offline, selected: [input.id], deadlineSeconds: Math.max(1, timeout) })[0];
|
||||
let profile: QualifiedScanner | undefined,
|
||||
runtime: QualifiedRuntime | undefined,
|
||||
observedVersion: string | undefined,
|
||||
versionHash: string | null = null;
|
||||
let application: ScannerApplicationPreparation | undefined,
|
||||
request = identityRequest;
|
||||
let plan = scannerPlans({
|
||||
snapshotRoot: '/source',
|
||||
offline: input.policy.offline,
|
||||
selected: [input.id],
|
||||
deadlineSeconds: Math.max(1, timeout),
|
||||
})[0];
|
||||
let outcome: ScannerOutcome, runner: ScannerRunner | undefined;
|
||||
try {
|
||||
if (timeout < 1) throw new CsoError('DEADLINE', 'No scanner time remains before the reporting reserve');
|
||||
assertSnapshot(input.runDir, input.manifest);
|
||||
request=resolveScannerRequestPaths(input.manifest,identityRequest);
|
||||
if (input.id === 'schemathesis' && input.policy.mode !== 'comprehensive') throw new CsoError('PREREQUISITE', 'Schemathesis requires comprehensive mode; daily audits do not execute applications');
|
||||
request = resolveScannerRequestPaths(input.manifest, identityRequest);
|
||||
if (input.id === 'schemathesis' && input.policy.mode !== 'comprehensive')
|
||||
throw new CsoError(
|
||||
'PREREQUISITE',
|
||||
'Schemathesis requires comprehensive mode; daily audits do not execute applications',
|
||||
);
|
||||
profile = selectScanner(input.id, input.platform, request.profile, catalog);
|
||||
const api = request.api;
|
||||
plan = scannerPlans({ snapshotRoot: '/source', offline: input.policy.offline, selected: [input.id], deadlineSeconds: timeout,
|
||||
tools: { [input.id]: { available: true, version: profile.version, capabilities: profile.capabilities } },
|
||||
semgrepRules: profile.assets?.semgrepRules?.path, advisoryCache: profile.assets?.advisoryDatabase?.path,
|
||||
...(api ? { schemaPath: '/policy/openapi.json', baseUrl: `http://127.0.0.1:${api.port}/`, operationIds: api.operationIds, seed: api.seed, maxExamples: api.maxExamples } : {}) })[0];
|
||||
plan = scannerPlans({
|
||||
snapshotRoot: '/source',
|
||||
offline: input.policy.offline,
|
||||
selected: [input.id],
|
||||
deadlineSeconds: timeout,
|
||||
tools: {
|
||||
[input.id]: { available: true, version: profile.version, capabilities: profile.capabilities },
|
||||
},
|
||||
semgrepRules: profile.assets?.semgrepRules?.path,
|
||||
advisoryCache: profile.assets?.advisoryDatabase?.path,
|
||||
...(api
|
||||
? {
|
||||
schemaPath: '/policy/openapi.json',
|
||||
baseUrl: `http://127.0.0.1:${api.port}/`,
|
||||
operationIds: api.operationIds,
|
||||
seed: api.seed,
|
||||
maxExamples: api.maxExamples,
|
||||
}
|
||||
: {}),
|
||||
})[0];
|
||||
if (plan.prerequisites.length) throw new CsoError('PREREQUISITE', plan.prerequisites.join('; '));
|
||||
if (input.id === 'schemathesis') {
|
||||
if (!api) throw new CsoError('PREREQUISITE', 'Schemathesis requires a reviewed API harness and legitimate control');
|
||||
if (!api)
|
||||
throw new CsoError(
|
||||
'PREREQUISITE',
|
||||
'Schemathesis requires a reviewed API harness and legitimate control',
|
||||
);
|
||||
for (const file of api.boundaryFiles) {
|
||||
const entry = input.manifest.entries.find(e => e.path === file);
|
||||
if (!entry || !entry.executionHash || entry.transformation) throw new CsoError('INCOMPATIBLE_INPUT', `API security boundary is missing or transformed: ${file}`);
|
||||
const entry = input.manifest.entries.find((e) => e.path === file);
|
||||
if (!entry || !entry.executionHash || entry.transformation)
|
||||
throw new CsoError(
|
||||
'INCOMPATIBLE_INPUT',
|
||||
`API security boundary is missing or transformed: ${file}`,
|
||||
);
|
||||
}
|
||||
try { runtime = selectRuntime(api.runtimeProfile, input.platform, dependencies.runtimes ?? RUNTIME_CATALOG); }
|
||||
catch { throw new CsoError('PREREQUISITE', `Qualified application runtime is unavailable: ${api.runtimeProfile}`); }
|
||||
if (!['node', 'bun', 'python', 'rails'].includes(runtime.stack)) throw new CsoError('INCOMPATIBLE_INPUT', 'Schemathesis requires a qualified application runtime');
|
||||
const stack = runtime.stack as CsoStack, sourceRoot = join(input.runDir, 'snapshot');
|
||||
try {
|
||||
runtime = selectRuntime(api.runtimeProfile, input.platform, dependencies.runtimes ?? RUNTIME_CATALOG);
|
||||
} catch {
|
||||
throw new CsoError(
|
||||
'PREREQUISITE',
|
||||
`Qualified application runtime is unavailable: ${api.runtimeProfile}`,
|
||||
);
|
||||
}
|
||||
if (!['node', 'bun', 'python', 'rails'].includes(runtime.stack))
|
||||
throw new CsoError('INCOMPATIBLE_INPUT', 'Schemathesis requires a qualified application runtime');
|
||||
const stack = runtime.stack as CsoStack,
|
||||
sourceRoot = join(input.runDir, 'snapshot');
|
||||
const preparation = inspectPreparation(sourceRoot, stack);
|
||||
assertRuntimeCompatible(preparation, runtime);
|
||||
const startPlan = canonicalStartPlan(sourceRoot, stack, api.port);
|
||||
if (canonical(api.start) !== canonical(startPlan.command)) throw new CsoError('INVALID_SCHEMA', `API start must use the helper-derived ${startPlan.kind} command`);
|
||||
for (const file of startPlan.entrypointFiles) if (!api.boundaryFiles.includes(file))
|
||||
throw new CsoError('INVALID_SCHEMA', `API boundary files must include canonical startup input: ${file}`);
|
||||
if (canonical(api.start) !== canonical(startPlan.command))
|
||||
throw new CsoError(
|
||||
'INVALID_SCHEMA',
|
||||
`API start must use the helper-derived ${startPlan.kind} command`,
|
||||
);
|
||||
for (const file of startPlan.entrypointFiles)
|
||||
if (!api.boundaryFiles.includes(file))
|
||||
throw new CsoError(
|
||||
'INVALID_SCHEMA',
|
||||
`API boundary files must include canonical startup input: ${file}`,
|
||||
);
|
||||
application = await (dependencies.applicationPreparer ?? prepareDockerScannerApplication)({
|
||||
input: { ...input, request }, runtime, stack, startPlan,
|
||||
deadline: Math.min(input.executionDeadline, Date.now() + timeout * 1000), catalog: dependencies.runtimes ?? RUNTIME_CATALOG,
|
||||
input: { ...input, request },
|
||||
runtime,
|
||||
stack,
|
||||
startPlan,
|
||||
deadline: Math.min(input.executionDeadline, Date.now() + timeout * 1000),
|
||||
catalog: dependencies.runtimes ?? RUNTIME_CATALOG,
|
||||
});
|
||||
const preparedStart = canonicalStartPlan(application.sourceRoot, stack, api.port);
|
||||
if (preparedStart.signature !== startPlan.signature || canonical(preparedStart.command) !== canonical(startPlan.command))
|
||||
throw new CsoError('ISOLATION_FAILED', 'Offline API preparation changed the canonical application startup inputs');
|
||||
if (
|
||||
preparedStart.signature !== startPlan.signature ||
|
||||
canonical(preparedStart.command) !== canonical(startPlan.command)
|
||||
)
|
||||
throw new CsoError(
|
||||
'ISOLATION_FAILED',
|
||||
'Offline API preparation changed the canonical application startup inputs',
|
||||
);
|
||||
}
|
||||
runner = await (dependencies.runnerFactory ?? createDockerScannerRunner)({ input: { ...input, request }, plan, profile, runtime, application, deadline: Math.min(input.executionDeadline, Date.now() + timeout * 1000) });
|
||||
runner = await (dependencies.runnerFactory ?? createDockerScannerRunner)({
|
||||
input: { ...input, request },
|
||||
plan,
|
||||
profile,
|
||||
runtime,
|
||||
application,
|
||||
deadline: Math.min(input.executionDeadline, Date.now() + timeout * 1000),
|
||||
});
|
||||
const version = await runner.version();
|
||||
if (version.exitCode !== 0 || version.timedOut || version.truncated || version.unavailable || Buffer.byteLength(version.stdout) + Buffer.byteLength(version.stderr ?? '') > 8192) throw new CsoError('TOOL_UNAVAILABLE', 'Scanner version probe did not complete within the qualified sandbox');
|
||||
assertScannerVersionOutput(profile.scanner,profile.version,version.stdout,version.stderr);
|
||||
if (
|
||||
version.exitCode !== 0 ||
|
||||
version.timedOut ||
|
||||
version.truncated ||
|
||||
version.unavailable ||
|
||||
Buffer.byteLength(version.stdout) + Buffer.byteLength(version.stderr ?? '') > 8192
|
||||
)
|
||||
throw new CsoError(
|
||||
'TOOL_UNAVAILABLE',
|
||||
'Scanner version probe did not complete within the qualified sandbox',
|
||||
);
|
||||
assertScannerVersionOutput(profile.scanner, profile.version, version.stdout, version.stderr);
|
||||
versionHash = scannerVersionHash(version.stdout, version.stderr);
|
||||
if (versionHash !== profile.versionOutputSha256) throw new CsoError('INCOMPATIBLE_INPUT', 'Scanner version output does not match its reviewed image profile');
|
||||
if (versionHash !== profile.versionOutputSha256)
|
||||
throw new CsoError(
|
||||
'INCOMPATIBLE_INPUT',
|
||||
'Scanner version output does not match its reviewed image profile',
|
||||
);
|
||||
observedVersion = profile.version;
|
||||
const execution = await runner.scan();
|
||||
assertSnapshot(input.runDir, input.manifest);
|
||||
outcome = parseScannerOutput(plan, { ...execution, version: profile.version, databaseUpdatedAt: profile.assets?.advisoryDatabase?.updatedAt });
|
||||
} catch (error) { outcome = failure(plan, error, observedVersion); }
|
||||
finally {
|
||||
outcome = parseScannerOutput(plan, {
|
||||
...execution,
|
||||
version: profile.version,
|
||||
databaseUpdatedAt: profile.assets?.advisoryDatabase?.updatedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
outcome = failure(plan, error, observedVersion);
|
||||
} finally {
|
||||
let cleanupError: unknown;
|
||||
if (runner) try { await runner.cleanup(); } catch (error) { cleanupError = error; }
|
||||
if (application) try { await application.cleanup(); } catch (error) { cleanupError ??= error; }
|
||||
if (runner)
|
||||
try {
|
||||
await runner.cleanup();
|
||||
} catch (error) {
|
||||
cleanupError = error;
|
||||
}
|
||||
if (application)
|
||||
try {
|
||||
await application.cleanup();
|
||||
} catch (error) {
|
||||
cleanupError ??= error;
|
||||
}
|
||||
if (cleanupError) outcome = failure(plan, cleanupError, observedVersion);
|
||||
}
|
||||
return { outcome: outcome!, coverage: scannerCoverage(outcome!, input.policy.scope), provenance: {
|
||||
scannerCatalog: catalog.revision, profile: profile?.id ?? null, image: profile?.image ?? null, platform: input.platform,
|
||||
isolationPolicyHash: profile?.isolationPolicyHash ?? null, sourceHash: input.manifest.executionHash, requestHash: sha256(canonical(identityRequest)), versionOutputSha256: versionHash,
|
||||
assets: profile?.assets ?? null, network: plan.network === 'loopback' ? 'isolated-loopback' : 'none', preparation: application?.proof ?? null } };
|
||||
return {
|
||||
outcome: outcome!,
|
||||
coverage: scannerCoverage(outcome!, input.policy.scope),
|
||||
provenance: {
|
||||
scannerCatalog: catalog.revision,
|
||||
profile: profile?.id ?? null,
|
||||
image: profile?.image ?? null,
|
||||
platform: input.platform,
|
||||
isolationPolicyHash: profile?.isolationPolicyHash ?? null,
|
||||
sourceHash: input.manifest.executionHash,
|
||||
requestHash: sha256(canonical(identityRequest)),
|
||||
versionOutputSha256: versionHash,
|
||||
assets: profile?.assets ?? null,
|
||||
network: plan.network === 'loopback' ? 'isolated-loopback' : 'none',
|
||||
preparation: application?.proof ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function prepareDockerScannerApplication(context: Parameters<ScannerApplicationPreparer>[0],dependencies:{endpoint?:DockerEndpoint;runnerFactory?:(options:ConstructorParameters<typeof DockerPreparationSandboxRunner>[0])=>PreparationSandboxRunner;cacheRoot?:string}={}): Promise<ScannerApplicationPreparation> {
|
||||
export async function prepareDockerScannerApplication(
|
||||
context: Parameters<ScannerApplicationPreparer>[0],
|
||||
dependencies: {
|
||||
endpoint?: DockerEndpoint;
|
||||
runnerFactory?: (
|
||||
options: ConstructorParameters<typeof DockerPreparationSandboxRunner>[0],
|
||||
) => PreparationSandboxRunner;
|
||||
cacheRoot?: string;
|
||||
} = {},
|
||||
): Promise<ScannerApplicationPreparation> {
|
||||
const { input, runtime, stack, deadline, catalog } = context;
|
||||
const root = secureDirectory(join(input.runDir, 'supervision', `scanner-preparation-${randomBytes(12).toString('hex')}`));
|
||||
let executor: PreparationExecutor | undefined, prepared: Awaited<ReturnType<PreparationExecutor['prepareOffline']>> | undefined;
|
||||
const root = secureDirectory(
|
||||
join(input.runDir, 'supervision', `scanner-preparation-${randomBytes(12).toString('hex')}`),
|
||||
);
|
||||
let executor: PreparationExecutor | undefined,
|
||||
prepared: Awaited<ReturnType<PreparationExecutor['prepareOffline']>> | undefined;
|
||||
try {
|
||||
const plan = inspectPreparation(join(input.runDir, 'snapshot'), stack);
|
||||
const admission = admitPreparationRuntime({ plan, platform: input.platform, profile: runtime.id, catalog });
|
||||
const endpoint = dependencies.endpoint??await dockerEndpoint(root),runnerOptions={ endpoint, watchdogPath: input.watchdogPath,
|
||||
runRoot: root, controlRoot: secureDirectory(join(root, 'execution')), admission },runner=dependencies.runnerFactory?dependencies.runnerFactory(runnerOptions):new DockerPreparationSandboxRunner(runnerOptions);
|
||||
executor = new PreparationExecutor({ cache: new PublicArchiveCache({ root: dependencies.cacheRoot??publicArchiveCacheRoot(), stagingRoot: secureDirectory(join(root, 'staging')) }),
|
||||
runner, materializationRoot: secureDirectory(join(root, 'materializations')) });
|
||||
const closure = await executor.acquire({ plan, admission, snapshot: join(input.runDir, 'snapshot'), deadline, offline: input.policy.offline });
|
||||
let database:RailsDatabaseSelection|undefined;
|
||||
if(stack==='rails'){
|
||||
if(!plan.database?.selected)throw new CsoError('PREREQUISITE','Rails API preparation could not select one locked database adapter');
|
||||
database=plan.database.selected==='postgresql'
|
||||
?{adapter:'postgresql',sidecar:admitPreparationSidecar({platform:input.platform,catalog})}:{adapter:'sqlite'};
|
||||
const admission = admitPreparationRuntime({
|
||||
plan,
|
||||
platform: input.platform,
|
||||
profile: runtime.id,
|
||||
catalog,
|
||||
});
|
||||
const endpoint = dependencies.endpoint ?? (await dockerEndpoint(root)),
|
||||
runnerOptions = {
|
||||
endpoint,
|
||||
watchdogPath: input.watchdogPath,
|
||||
runRoot: root,
|
||||
controlRoot: secureDirectory(join(root, 'execution')),
|
||||
admission,
|
||||
},
|
||||
runner = dependencies.runnerFactory
|
||||
? dependencies.runnerFactory(runnerOptions)
|
||||
: new DockerPreparationSandboxRunner(runnerOptions);
|
||||
executor = new PreparationExecutor({
|
||||
cache: new PublicArchiveCache({
|
||||
root: dependencies.cacheRoot ?? publicArchiveCacheRoot(),
|
||||
stagingRoot: secureDirectory(join(root, 'staging')),
|
||||
}),
|
||||
runner,
|
||||
materializationRoot: secureDirectory(join(root, 'materializations')),
|
||||
});
|
||||
const closure = await executor.acquire({
|
||||
plan,
|
||||
admission,
|
||||
snapshot: join(input.runDir, 'snapshot'),
|
||||
deadline,
|
||||
offline: input.policy.offline,
|
||||
});
|
||||
let database: RailsDatabaseSelection | undefined;
|
||||
if (stack === 'rails') {
|
||||
if (!plan.database?.selected)
|
||||
throw new CsoError(
|
||||
'PREREQUISITE',
|
||||
'Rails API preparation could not select one locked database adapter',
|
||||
);
|
||||
database =
|
||||
plan.database.selected === 'postgresql'
|
||||
? { adapter: 'postgresql', sidecar: admitPreparationSidecar({ platform: input.platform, catalog }) }
|
||||
: { adapter: 'sqlite' };
|
||||
}
|
||||
prepared = await executor.prepareOffline({ plan, admission, snapshot: join(input.runDir, 'snapshot'), closure, deadline, database });
|
||||
const proof = { dependencyClosureHash: prepared.dependencyClosureHash, preparedManifestHash: prepared.preparedManifestHash,
|
||||
sourceProjectionHash: prepared.sourceProjectionHash, receiptHash: prepared.receiptHash,
|
||||
executionEnvironmentHash: sha256(canonical(prepared.executionEnvironment)), databaseHash: prepared.databaseHash };
|
||||
prepared = await executor.prepareOffline({
|
||||
plan,
|
||||
admission,
|
||||
snapshot: join(input.runDir, 'snapshot'),
|
||||
closure,
|
||||
deadline,
|
||||
database,
|
||||
});
|
||||
const proof = {
|
||||
dependencyClosureHash: prepared.dependencyClosureHash,
|
||||
preparedManifestHash: prepared.preparedManifestHash,
|
||||
sourceProjectionHash: prepared.sourceProjectionHash,
|
||||
receiptHash: prepared.receiptHash,
|
||||
executionEnvironmentHash: sha256(canonical(prepared.executionEnvironment)),
|
||||
databaseHash: prepared.databaseHash,
|
||||
};
|
||||
let cleaned = false;
|
||||
return { sourceRoot: prepared.preparedRoot, environment: prepared.executionEnvironment, database: prepared.database, proof, cleanup: async () => {
|
||||
if (cleaned) return; cleaned = true;
|
||||
await executor!.dispose(prepared!);
|
||||
fs.rmSync(root, { recursive: true, force: false });
|
||||
} };
|
||||
return {
|
||||
sourceRoot: prepared.preparedRoot,
|
||||
environment: prepared.executionEnvironment,
|
||||
database: prepared.database,
|
||||
proof,
|
||||
cleanup: async () => {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
await executor!.dispose(prepared!);
|
||||
fs.rmSync(root, { recursive: true, force: false });
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
let cleanupError:unknown;
|
||||
if (prepared && executor) try { await executor.dispose(prepared); } catch (failed) { cleanupError=failed; }
|
||||
let cleanupError: unknown;
|
||||
if (prepared && executor)
|
||||
try {
|
||||
await executor.dispose(prepared);
|
||||
} catch (failed) {
|
||||
cleanupError = failed;
|
||||
}
|
||||
// A failed Docker/retained-copy cleanup deliberately hands ownership to a
|
||||
// detached watchdog. Its journals and label-sweep scratch files live below
|
||||
// this root, so only remove the tree after every watchdog acknowledged.
|
||||
let pending=true;try{pending=hasPendingWatchdogCleanup(input.runDir);}catch(failed){cleanupError??=failed;}
|
||||
if(!cleanupError&&!pending)try { fs.rmSync(root, { recursive: true, force: false }); } catch {}
|
||||
if(cleanupError)throw cleanupError;
|
||||
let pending = true;
|
||||
try {
|
||||
pending = hasPendingWatchdogCleanup(input.runDir);
|
||||
} catch (failed) {
|
||||
cleanupError ??= failed;
|
||||
}
|
||||
if (!cleanupError && !pending)
|
||||
try {
|
||||
fs.rmSync(root, { recursive: true, force: false });
|
||||
} catch {}
|
||||
if (cleanupError) throw cleanupError;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -320,8 +696,10 @@ export async function createDockerScannerRunner(context: ScannerRunnerContext):
|
||||
const policyDir = secureDirectory(join(controlDir, 'policy'));
|
||||
const files: Array<{ host: string; container: string }> = [];
|
||||
const writePolicy = (container: string, content: string): void => {
|
||||
if (redact(content) !== content) throw new CsoError('REDACTION_FAILED', 'Scanner policy contains secret-bearing material');
|
||||
const host = join(policyDir, String(files.length)); fs.writeFileSync(host, content, { mode: 0o600, flag: 'wx' });
|
||||
if (redact(content) !== content)
|
||||
throw new CsoError('REDACTION_FAILED', 'Scanner policy contains secret-bearing material');
|
||||
const host = join(policyDir, String(files.length));
|
||||
fs.writeFileSync(host, content, { mode: 0o600, flag: 'wx' });
|
||||
files.push({ host, container });
|
||||
};
|
||||
let group: DockerGroup | undefined;
|
||||
@@ -329,12 +707,27 @@ export async function createDockerScannerRunner(context: ScannerRunnerContext):
|
||||
for (const file of plan.trustedFiles) writePolicy(file.path, file.content);
|
||||
if (input.request?.api) writePolicy('/policy/openapi.json', JSON.stringify(input.request.api.schema));
|
||||
const endpoint: DockerEndpoint = await dockerEndpoint(controlDir);
|
||||
group = await DockerGroup.create(endpoint, attempt, controlDir, deadline, profile.image, input.watchdogPath);
|
||||
const createScanner=()=>group!.createContainer({ role: runtime ? 'verifier' : 'app', image: profile.image, source: join(input.runDir, 'snapshot'), command: ['/bin/sleep', '2147483647'], env: plan.env, readonlyFiles: files });
|
||||
group = await DockerGroup.create(
|
||||
endpoint,
|
||||
attempt,
|
||||
controlDir,
|
||||
deadline,
|
||||
profile.image,
|
||||
input.watchdogPath,
|
||||
);
|
||||
const createScanner = () =>
|
||||
group!.createContainer({
|
||||
role: runtime ? 'verifier' : 'app',
|
||||
image: profile.image,
|
||||
source: join(input.runDir, 'snapshot'),
|
||||
command: ['/bin/sleep', '2147483647'],
|
||||
env: plan.env,
|
||||
readonlyFiles: files,
|
||||
});
|
||||
let scanner = await createScanner();
|
||||
await group.start(scanner);
|
||||
const capture = async (command: string[]): Promise<ScannerExecution> => {
|
||||
if(!scanner)throw new CsoError('ISOLATION_FAILED','Scanner container is unavailable');
|
||||
if (!scanner) throw new CsoError('ISOLATION_FAILED', 'Scanner container is unavailable');
|
||||
const result = await group!.execCapture(scanner, command, { workdir: '/work', env: plan.env });
|
||||
return { stdout: result.stdout, stderr: result.stderr, exitCode: result.code };
|
||||
};
|
||||
@@ -343,41 +736,132 @@ export async function createDockerScannerRunner(context: ScannerRunnerContext):
|
||||
scan: async () => {
|
||||
const api = input.request?.api;
|
||||
if (api && runtime) {
|
||||
if (!application) throw new CsoError('ISOLATION_FAILED', 'Schemathesis application was not materialized through offline preparation');
|
||||
const env={ ...application.environment, PORT: String(api.port), HOST: '127.0.0.1', NODE_ENV: 'test', RAILS_ENV: 'test', RACK_ENV: 'test', PYTHONUNBUFFERED: '1', CI: '1', SECRET_KEY_BASE: 'cso-synthetic-test-key' };
|
||||
const rails=runtime.stack==='rails';
|
||||
if(rails){await group!.removeContainer(scanner);scanner='';}
|
||||
if(application.database?.adapter==='postgresql'){
|
||||
const databaseFile=join(policyDir,'postgresql.databases'),names=application.database.connections.map(name=>`cso_${name}`);
|
||||
if(!names.length||names.some(name=>!/^cso_[A-Za-z_][A-Za-z0-9_]{0,47}$/.test(name)))throw new CsoError('INCOMPATIBLE_INPUT','Prepared PostgreSQL connection names are invalid');
|
||||
fs.writeFileSync(databaseFile,names.join('\n')+'\n',{mode:0o444,flag:'wx'});
|
||||
const postgres=await group!.createContainer({role:'postgres',image:application.database.sidecar.image,command:['/opt/cso/run-postgresql','/policy/postgresql.databases'],postgresDatabasePolicy:databaseFile});await group!.start(postgres);
|
||||
let ready=false;for(let attempt=0;attempt<100&&!ready;attempt++){const checked=await group!.execCapture(postgres,['/opt/cso/postgresql-ready','/policy/postgresql.databases']);ready=checked.code===0;if(!ready)await new Promise(resolveWait=>setTimeout(resolveWait,50));}
|
||||
if(!ready)throw new CsoError('TOOL_FAILED','Disposable PostgreSQL did not become ready for Rails API scanning');
|
||||
if (!application)
|
||||
throw new CsoError(
|
||||
'ISOLATION_FAILED',
|
||||
'Schemathesis application was not materialized through offline preparation',
|
||||
);
|
||||
const env = {
|
||||
...application.environment,
|
||||
PORT: String(api.port),
|
||||
HOST: '127.0.0.1',
|
||||
NODE_ENV: 'test',
|
||||
RAILS_ENV: 'test',
|
||||
RACK_ENV: 'test',
|
||||
PYTHONUNBUFFERED: '1',
|
||||
CI: '1',
|
||||
SECRET_KEY_BASE: 'cso-synthetic-test-key',
|
||||
};
|
||||
const rails = runtime.stack === 'rails';
|
||||
if (rails) {
|
||||
await group!.removeContainer(scanner);
|
||||
scanner = '';
|
||||
}
|
||||
const app = await group!.createContainer({ role: 'app', image: runtime.image, source: application.sourceRoot, env,
|
||||
command:rails?['/opt/cso/run-app','/bin/sleep','2147483647']:['/opt/cso/run-app', api.start.executable, ...api.start.args] });
|
||||
if (application.database?.adapter === 'postgresql') {
|
||||
const databaseFile = join(policyDir, 'postgresql.databases'),
|
||||
names = application.database.connections.map((name) => `cso_${name}`);
|
||||
if (!names.length || names.some((name) => !/^cso_[A-Za-z_][A-Za-z0-9_]{0,47}$/.test(name)))
|
||||
throw new CsoError('INCOMPATIBLE_INPUT', 'Prepared PostgreSQL connection names are invalid');
|
||||
fs.writeFileSync(databaseFile, names.join('\n') + '\n', { mode: 0o444, flag: 'wx' });
|
||||
const postgres = await group!.createContainer({
|
||||
role: 'postgres',
|
||||
image: application.database.sidecar.image,
|
||||
command: ['/opt/cso/run-postgresql', '/policy/postgresql.databases'],
|
||||
postgresDatabasePolicy: databaseFile,
|
||||
});
|
||||
await group!.start(postgres);
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 100 && !ready; attempt++) {
|
||||
const checked = await group!.execCapture(postgres, [
|
||||
'/opt/cso/postgresql-ready',
|
||||
'/policy/postgresql.databases',
|
||||
]);
|
||||
ready = checked.code === 0;
|
||||
if (!ready) await new Promise((resolveWait) => setTimeout(resolveWait, 50));
|
||||
}
|
||||
if (!ready)
|
||||
throw new CsoError(
|
||||
'TOOL_FAILED',
|
||||
'Disposable PostgreSQL did not become ready for Rails API scanning',
|
||||
);
|
||||
}
|
||||
const app = await group!.createContainer({
|
||||
role: 'app',
|
||||
image: runtime.image,
|
||||
source: application.sourceRoot,
|
||||
env,
|
||||
command: rails
|
||||
? ['/opt/cso/run-app', '/bin/sleep', '2147483647']
|
||||
: ['/opt/cso/run-app', api.start.executable, ...api.start.args],
|
||||
});
|
||||
await group!.start(app);
|
||||
if(rails){const clean=['/usr/bin/env','-i',...Object.entries(env).sort(([a],[b])=>a.localeCompare(b)).map(([key,value])=>`${key}=${value}`),'/usr/local/bin/bundle','exec','rails','db:prepare'];const prepared=await group!.execCapture(app,clean,{workdir:'/work'});if(prepared.code!==0)throw new CsoError('TOOL_FAILED','Rails API database preparation failed');await group!.execDetached(app,[api.start.executable,...api.start.args]);}
|
||||
const security = { ...api.control, vulnerable: { status: api.control.expected.status === 599 ? 598 : 599 } };
|
||||
if (rails) {
|
||||
const clean = [
|
||||
'/usr/bin/env',
|
||||
'-i',
|
||||
...Object.entries(env)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => `${key}=${value}`),
|
||||
'/usr/local/bin/bundle',
|
||||
'exec',
|
||||
'rails',
|
||||
'db:prepare',
|
||||
];
|
||||
const prepared = await group!.execCapture(app, clean, { workdir: '/work' });
|
||||
if (prepared.code !== 0)
|
||||
throw new CsoError('TOOL_FAILED', 'Rails API database preparation failed');
|
||||
await group!.execDetached(app, [api.start.executable, ...api.start.args]);
|
||||
}
|
||||
const security = {
|
||||
...api.control,
|
||||
vulnerable: { status: api.control.expected.status === 599 ? 598 : 599 },
|
||||
};
|
||||
const controlFile = join(policyDir, 'control.json');
|
||||
fs.writeFileSync(controlFile, JSON.stringify({ phase: 'after', port: api.port, legitimate: [api.control], security }), { mode: 0o600, flag: 'wx' });
|
||||
const probe = await group!.createContainer({ role: schemathesisControlRole(), image: runtime.image, command: ['/opt/cso/verifier', '/policy/control.json'], readonlyFiles: [{ host: controlFile, container: '/policy/control.json' }] });
|
||||
const observed = await group!.startAttach(probe); await group!.removeContainer(probe);
|
||||
fs.writeFileSync(
|
||||
controlFile,
|
||||
JSON.stringify({ phase: 'after', port: api.port, legitimate: [api.control], security }),
|
||||
{ mode: 0o600, flag: 'wx' },
|
||||
);
|
||||
const probe = await group!.createContainer({
|
||||
role: schemathesisControlRole(),
|
||||
image: runtime.image,
|
||||
command: ['/opt/cso/verifier', '/policy/control.json'],
|
||||
readonlyFiles: [{ host: controlFile, container: '/policy/control.json' }],
|
||||
});
|
||||
const observed = await group!.startAttach(probe);
|
||||
await group!.removeContainer(probe);
|
||||
let valid = false;
|
||||
try { const v = validateVerificationObservation(JSON.parse(observed.output)); valid = observed.code === 0 && v.booted && v.legitimate && v.security === 'pass'; } catch {}
|
||||
if (!valid) throw new CsoError('PREREQUISITE', 'API application boot or legitimate control failed; no Schemathesis requests were sent');
|
||||
if(rails){scanner=await createScanner();await group!.start(scanner);}
|
||||
try {
|
||||
const v = validateVerificationObservation(JSON.parse(observed.output));
|
||||
valid = observed.code === 0 && v.booted && v.legitimate && v.security === 'pass';
|
||||
} catch {}
|
||||
if (!valid)
|
||||
throw new CsoError(
|
||||
'PREREQUISITE',
|
||||
'API application boot or legitimate control failed; no Schemathesis requests were sent',
|
||||
);
|
||||
if (rails) {
|
||||
scanner = await createScanner();
|
||||
await group!.start(scanner);
|
||||
}
|
||||
}
|
||||
const execution = await capture([profile.executable, ...plan.args]);
|
||||
if (plan.outputPath) {
|
||||
const report = await capture(['/bin/cat', plan.outputPath]);
|
||||
if (report.exitCode !== 0) throw new CsoError('PREREQUISITE', 'Scanner did not produce its required bounded report file');
|
||||
return { ...execution, stdout: report.stdout, stderr: [execution.stderr, report.stderr].filter(Boolean).join('\n') };
|
||||
if (report.exitCode !== 0)
|
||||
throw new CsoError('PREREQUISITE', 'Scanner did not produce its required bounded report file');
|
||||
return {
|
||||
...execution,
|
||||
stdout: report.stdout,
|
||||
stderr: [execution.stderr, report.stderr].filter(Boolean).join('\n'),
|
||||
};
|
||||
}
|
||||
return execution;
|
||||
},
|
||||
cleanup: async () => { await group!.cleanup(); fs.rmSync(policyDir, { recursive: true, force: true }); },
|
||||
cleanup: async () => {
|
||||
await group!.cleanup();
|
||||
fs.rmSync(policyDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (group) await group.cleanup();
|
||||
|
||||
+581
-126
@@ -8,8 +8,9 @@ import { posix } from 'node:path';
|
||||
import { redactFindingSpans } from '../redact-engine';
|
||||
|
||||
export const SCANNER_IDS = ['gitleaks', 'osv', 'semgrep', 'zizmor', 'trivy', 'schemathesis'] as const;
|
||||
export type ScannerId = typeof SCANNER_IDS[number];
|
||||
export type ScannerFormat = 'gitleaks-json' | 'osv-json' | 'semgrep-json' | 'sarif' | 'trivy-json' | 'schemathesis-json';
|
||||
export type ScannerId = (typeof SCANNER_IDS)[number];
|
||||
export type ScannerFormat =
|
||||
'gitleaks-json' | 'osv-json' | 'semgrep-json' | 'sarif' | 'trivy-json' | 'schemathesis-json';
|
||||
export const MAX_SCANNER_OUTPUT_BYTES = 1_048_576;
|
||||
const MAX_CANDIDATES = 5_000;
|
||||
|
||||
@@ -63,7 +64,13 @@ export interface ScannerCandidate {
|
||||
reportedSeverity: 'critical' | 'high' | 'medium' | 'low' | 'info' | 'unknown';
|
||||
location?: { path: string; line?: number; column?: number };
|
||||
advisoryIds: string[];
|
||||
dependency?: { name: string; version?: string; ecosystem?: string; reachability: 'unknown'; exposure: 'unknown' };
|
||||
dependency?: {
|
||||
name: string;
|
||||
version?: string;
|
||||
ecosystem?: string;
|
||||
reachability: 'unknown';
|
||||
exposure: 'unknown';
|
||||
};
|
||||
operation?: string;
|
||||
suppressed: boolean;
|
||||
evidence: 'scanner-candidate';
|
||||
@@ -71,10 +78,25 @@ export interface ScannerCandidate {
|
||||
}
|
||||
|
||||
export interface ScannerGap {
|
||||
code: 'UNAVAILABLE' | 'PREREQUISITE' | 'TIMEOUT' | 'OUTPUT_LIMIT' | 'INVALID_OUTPUT' | 'TOOL_FAILED' |
|
||||
'REDACTION_FAILED' | 'ISOLATION_FAILED' | 'PERSISTENCE_FAILED' | 'SNAPSHOT_RACE' | 'CANCELLED' |
|
||||
'INSUFFICIENT_CAPACITY' | 'UNSAFE_PATH' | 'MISSING_INPUT' | 'INCOMPATIBLE_INPUT' |
|
||||
'UNSAFE_LOCATION' | 'SKIPPED_INPUT' | 'UNKNOWN_FRESHNESS';
|
||||
code:
|
||||
| 'UNAVAILABLE'
|
||||
| 'PREREQUISITE'
|
||||
| 'TIMEOUT'
|
||||
| 'OUTPUT_LIMIT'
|
||||
| 'INVALID_OUTPUT'
|
||||
| 'TOOL_FAILED'
|
||||
| 'REDACTION_FAILED'
|
||||
| 'ISOLATION_FAILED'
|
||||
| 'PERSISTENCE_FAILED'
|
||||
| 'SNAPSHOT_RACE'
|
||||
| 'CANCELLED'
|
||||
| 'INSUFFICIENT_CAPACITY'
|
||||
| 'UNSAFE_PATH'
|
||||
| 'MISSING_INPUT'
|
||||
| 'INCOMPATIBLE_INPUT'
|
||||
| 'UNSAFE_LOCATION'
|
||||
| 'SKIPPED_INPUT'
|
||||
| 'UNKNOWN_FRESHNESS';
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -109,34 +131,64 @@ export interface ScannerExecution {
|
||||
|
||||
const SOURCES: Record<ScannerId, string[]> = {
|
||||
gitleaks: ['https://github.com/gitleaks/gitleaks/blob/master/README.md'],
|
||||
osv: ['https://google.github.io/osv-scanner/usage/scan-source/', 'https://google.github.io/osv-scanner/usage/offline-mode/'],
|
||||
osv: [
|
||||
'https://google.github.io/osv-scanner/usage/scan-source/',
|
||||
'https://google.github.io/osv-scanner/usage/offline-mode/',
|
||||
],
|
||||
semgrep: ['https://docs.semgrep.dev/cli-reference'],
|
||||
zizmor: ['https://docs.zizmor.sh/usage/', 'https://docs.zizmor.sh/quickstart/'],
|
||||
trivy: ['https://trivy.dev/docs/dev/docs/advanced/telemetry/', 'https://trivy.dev/docs/latest/guide/advanced/air-gap/'],
|
||||
schemathesis: ['https://schemathesis.readthedocs.io/en/stable/reference/cli/', 'https://github.com/schemathesis/schemathesis/blob/master/src/schemathesis/cli/json_report.py'],
|
||||
trivy: [
|
||||
'https://trivy.dev/docs/dev/docs/advanced/telemetry/',
|
||||
'https://trivy.dev/docs/latest/guide/advanced/air-gap/',
|
||||
],
|
||||
schemathesis: [
|
||||
'https://schemathesis.readthedocs.io/en/stable/reference/cli/',
|
||||
'https://github.com/schemathesis/schemathesis/blob/master/src/schemathesis/cli/json_report.py',
|
||||
],
|
||||
};
|
||||
|
||||
function absolutePath(value: string, name: string): string {
|
||||
if (value === '/' || !value.startsWith('/') || value.startsWith('//') || /[\x00-\x1f\\]/.test(value) || value.split('/').includes('..')) {
|
||||
if (
|
||||
value === '/' ||
|
||||
!value.startsWith('/') ||
|
||||
value.startsWith('//') ||
|
||||
/[\x00-\x1f\\]/.test(value) ||
|
||||
value.split('/').includes('..')
|
||||
) {
|
||||
throw new Error(`${name} must be an absolute sandbox path without traversal`);
|
||||
}
|
||||
return posix.normalize(value);
|
||||
}
|
||||
|
||||
function positiveInteger(value: number, max: number, name: string): number {
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new Error(`${name} must be between 1 and ${max}`);
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > max)
|
||||
throw new Error(`${name} must be between 1 and ${max}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Numeric loopback only: no DNS, URL credentials, redirected targets, or remote schemas. */
|
||||
export function validateScannerBaseUrl(raw: string): string {
|
||||
let url: URL;
|
||||
try { url = new URL(raw); } catch { throw new Error('Schemathesis requires a numeric loopback HTTP URL'); }
|
||||
if (!['http:', 'https:'].includes(url.protocol) || !['127.0.0.1', '[::1]'].includes(url.hostname) || url.username || url.password || url.hash || url.search) {
|
||||
throw new Error('Schemathesis requires a numeric loopback HTTP URL without credentials, query, or fragment');
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new Error('Schemathesis requires a numeric loopback HTTP URL');
|
||||
}
|
||||
if (
|
||||
!['http:', 'https:'].includes(url.protocol) ||
|
||||
!['127.0.0.1', '[::1]'].includes(url.hostname) ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.hash ||
|
||||
url.search
|
||||
) {
|
||||
throw new Error(
|
||||
'Schemathesis requires a numeric loopback HTTP URL without credentials, query, or fragment',
|
||||
);
|
||||
}
|
||||
// URL canonicalization accepts integer, hex, and shorthand IPv4. Reject these spellings.
|
||||
if (!/^https?:\/\/(127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/|$)/.test(raw)) throw new Error('Schemathesis requires canonical numeric loopback');
|
||||
if (!/^https?:\/\/(127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/|$)/.test(raw))
|
||||
throw new Error('Schemathesis requires canonical numeric loopback');
|
||||
return url.href;
|
||||
}
|
||||
|
||||
@@ -148,99 +200,288 @@ export function validateScannerBaseUrl(raw: string): string {
|
||||
export function scannerPlans(opts: ScannerOptions): ScannerPlan[] {
|
||||
const root = absolutePath(opts.snapshotRoot, 'snapshotRoot');
|
||||
const policy = absolutePath(opts.policyRoot ?? '/policy', 'policyRoot');
|
||||
if (policy === root || policy.startsWith(`${root}/`) || root.startsWith(`${policy}/`)) throw new Error('policyRoot must be separate from source');
|
||||
if (policy === root || policy.startsWith(`${root}/`) || root.startsWith(`${policy}/`))
|
||||
throw new Error('policyRoot must be separate from source');
|
||||
const cache = opts.advisoryCache ? absolutePath(opts.advisoryCache, 'advisoryCache') : undefined;
|
||||
if (cache && (cache === root || cache.startsWith(`${root}/`))) throw new Error('advisoryCache must be separate from source');
|
||||
if (cache && (cache === root || cache.startsWith(`${root}/`)))
|
||||
throw new Error('advisoryCache must be separate from source');
|
||||
const timeout = positiveInteger(opts.deadlineSeconds ?? 120, 300, 'deadlineSeconds');
|
||||
const selected = opts.selected ?? [...SCANNER_IDS];
|
||||
if (new Set(selected).size !== selected.length || selected.some(id => !SCANNER_IDS.includes(id))) throw new Error('Invalid or duplicate scanner selection');
|
||||
return selected.map(id => {
|
||||
if (new Set(selected).size !== selected.length || selected.some((id) => !SCANNER_IDS.includes(id)))
|
||||
throw new Error('Invalid or duplicate scanner selection');
|
||||
return selected.map((id) => {
|
||||
const plan: ScannerPlan = {
|
||||
id, executableName: id === 'osv' ? 'osv-scanner' : id, args: [], versionArgs: ['--version'], requiredFeatures: [],
|
||||
format: 'sarif', execution: 'sandbox', network: 'none', cwd: '/work', sourceRoot: root,
|
||||
env: { HOME: '/work/home', TMPDIR: '/tmp', LANG: 'C.UTF-8', NO_COLOR: '1' }, trustedFiles: [], prerequisites: [],
|
||||
timeoutSeconds: timeout, maxOutputBytes: MAX_SCANNER_OUTPUT_BYTES,
|
||||
coverage: { domain: id, scope: [root], exclusions: ['Snapshot transformations apply; inspect the snapshot manifest.'] },
|
||||
provenanceSources: SOURCES[id], documentationInspectedAt: '2026-09-09',
|
||||
id,
|
||||
executableName: id === 'osv' ? 'osv-scanner' : id,
|
||||
args: [],
|
||||
versionArgs: ['--version'],
|
||||
requiredFeatures: [],
|
||||
format: 'sarif',
|
||||
execution: 'sandbox',
|
||||
network: 'none',
|
||||
cwd: '/work',
|
||||
sourceRoot: root,
|
||||
env: { HOME: '/work/home', TMPDIR: '/tmp', LANG: 'C.UTF-8', NO_COLOR: '1' },
|
||||
trustedFiles: [],
|
||||
prerequisites: [],
|
||||
timeoutSeconds: timeout,
|
||||
maxOutputBytes: MAX_SCANNER_OUTPUT_BYTES,
|
||||
coverage: {
|
||||
domain: id,
|
||||
scope: [root],
|
||||
exclusions: ['Snapshot transformations apply; inspect the snapshot manifest.'],
|
||||
},
|
||||
provenanceSources: SOURCES[id],
|
||||
documentationInspectedAt: '2026-09-09',
|
||||
};
|
||||
if (opts.tools?.[id]?.available === false) plan.prerequisites.push(`Install a reviewed ${plan.executableName} executable in the scanner image.`);
|
||||
if (opts.tools?.[id]?.available === false)
|
||||
plan.prerequisites.push(`Install a reviewed ${plan.executableName} executable in the scanner image.`);
|
||||
switch (id) {
|
||||
case 'gitleaks': {
|
||||
const target = opts.gitHistory ? absolutePath(opts.gitHistory, 'gitHistory') : root;
|
||||
plan.format = 'gitleaks-json';
|
||||
plan.coverage.domain = 'secrets';
|
||||
plan.coverage.scope = [target];
|
||||
plan.trustedFiles.push({ path: `${policy}/gitleaks.toml`, content: '[extend]\nuseDefault = true\n' }, { path: `${policy}/gitleaksignore`, content: '' });
|
||||
plan.args = [opts.gitHistory ? 'git' : 'dir', '--redact=100', '--no-banner', '--no-color', '--ignore-gitleaks-allow', '--gitleaks-ignore-path', `${policy}/gitleaksignore`, '--config', `${policy}/gitleaks.toml`, '--report-format=json', '--report-path=-', '--exit-code=10', '--timeout', String(timeout), target];
|
||||
if (opts.gitHistory) plan.prerequisites.push('History input must be a sanitized Git object store with trusted config and no hooks, filters, alternates, or external helpers.');
|
||||
plan.trustedFiles.push(
|
||||
{ path: `${policy}/gitleaks.toml`, content: '[extend]\nuseDefault = true\n' },
|
||||
{ path: `${policy}/gitleaksignore`, content: '' },
|
||||
);
|
||||
plan.args = [
|
||||
opts.gitHistory ? 'git' : 'dir',
|
||||
'--redact=100',
|
||||
'--no-banner',
|
||||
'--no-color',
|
||||
'--ignore-gitleaks-allow',
|
||||
'--gitleaks-ignore-path',
|
||||
`${policy}/gitleaksignore`,
|
||||
'--config',
|
||||
`${policy}/gitleaks.toml`,
|
||||
'--report-format=json',
|
||||
'--report-path=-',
|
||||
'--exit-code=10',
|
||||
'--timeout',
|
||||
String(timeout),
|
||||
target,
|
||||
];
|
||||
if (opts.gitHistory)
|
||||
plan.prerequisites.push(
|
||||
'History input must be a sanitized Git object store with trusted config and no hooks, filters, alternates, or external helpers.',
|
||||
);
|
||||
else plan.coverage.exclusions.push('Historical revisions are not scanned by this directory pass.');
|
||||
plan.requiredFeatures = ['dir', '--redact', '--ignore-gitleaks-allow'];
|
||||
break;
|
||||
}
|
||||
case 'osv':
|
||||
plan.format = 'osv-json'; plan.coverage.domain = 'dependencies';
|
||||
plan.format = 'osv-json';
|
||||
plan.coverage.domain = 'dependencies';
|
||||
plan.trustedFiles.push({ path: `${policy}/osv-scanner.toml`, content: '' });
|
||||
plan.args = ['scan', 'source', '--format=json', '--offline', '--no-call-analysis=all', '--config', `${policy}/osv-scanner.toml`, '--recursive', root];
|
||||
plan.args = [
|
||||
'scan',
|
||||
'source',
|
||||
'--format=json',
|
||||
'--offline',
|
||||
'--no-call-analysis=all',
|
||||
'--config',
|
||||
`${policy}/osv-scanner.toml`,
|
||||
'--recursive',
|
||||
root,
|
||||
];
|
||||
plan.requiredFeatures = ['scan source', '--offline', '--no-call-analysis'];
|
||||
if (cache) plan.env.OSV_SCANNER_LOCAL_DB_CACHE_DIRECTORY = cache;
|
||||
else plan.prerequisites.push('Provide verified offline OSV databases for every assessed ecosystem.');
|
||||
plan.coverage.exclusions.push('Call analysis is disabled; dependency reachability remains unknown until independently investigated.');
|
||||
plan.coverage.exclusions.push(
|
||||
'Call analysis is disabled; dependency reachability remains unknown until independently investigated.',
|
||||
);
|
||||
break;
|
||||
case 'semgrep': {
|
||||
plan.format = 'semgrep-json'; plan.coverage.domain = 'code';
|
||||
const rules = opts.semgrepRules ? absolutePath(opts.semgrepRules, 'semgrepRules') : `${policy}/semgrep.yml`;
|
||||
if (!rules.startsWith(`${policy}/`)) throw new Error('Semgrep rules must be below the trusted policyRoot');
|
||||
if (!opts.semgrepRules) plan.prerequisites.push('Provide a reviewed, pinned local Semgrep ruleset; registry aliases and repo rules are not accepted.');
|
||||
plan.args = ['scan', '--json', '--config', rules, '--metrics=off', '--disable-version-check', '--disable-nosem', '--no-git-ignore', '--no-secrets-validation', '--oss-only', '--no-autofix', '--timeout=10', '--timeout-threshold=3', '--jobs=1', root];
|
||||
plan.env.SEMGREP_SEND_METRICS = 'off'; plan.env.SEMGREP_ENABLE_VERSION_CHECK = '0'; plan.env.SEMGREP_APP_TOKEN = '';
|
||||
plan.requiredFeatures = ['scan', '--metrics', '--disable-version-check', '--no-secrets-validation', '--oss-only'];
|
||||
plan.coverage.exclusions.push('Semgrep language support, built-in file selection, and .semgrepignore rules can exclude inputs; independently inspect these exclusions.');
|
||||
plan.format = 'semgrep-json';
|
||||
plan.coverage.domain = 'code';
|
||||
const rules = opts.semgrepRules
|
||||
? absolutePath(opts.semgrepRules, 'semgrepRules')
|
||||
: `${policy}/semgrep.yml`;
|
||||
if (!rules.startsWith(`${policy}/`))
|
||||
throw new Error('Semgrep rules must be below the trusted policyRoot');
|
||||
if (!opts.semgrepRules)
|
||||
plan.prerequisites.push(
|
||||
'Provide a reviewed, pinned local Semgrep ruleset; registry aliases and repo rules are not accepted.',
|
||||
);
|
||||
plan.args = [
|
||||
'scan',
|
||||
'--json',
|
||||
'--config',
|
||||
rules,
|
||||
'--metrics=off',
|
||||
'--disable-version-check',
|
||||
'--disable-nosem',
|
||||
'--no-git-ignore',
|
||||
'--no-secrets-validation',
|
||||
'--oss-only',
|
||||
'--no-autofix',
|
||||
'--timeout=10',
|
||||
'--timeout-threshold=3',
|
||||
'--jobs=1',
|
||||
root,
|
||||
];
|
||||
plan.env.SEMGREP_SEND_METRICS = 'off';
|
||||
plan.env.SEMGREP_ENABLE_VERSION_CHECK = '0';
|
||||
plan.env.SEMGREP_APP_TOKEN = '';
|
||||
plan.requiredFeatures = [
|
||||
'scan',
|
||||
'--metrics',
|
||||
'--disable-version-check',
|
||||
'--no-secrets-validation',
|
||||
'--oss-only',
|
||||
];
|
||||
plan.coverage.exclusions.push(
|
||||
'Semgrep language support, built-in file selection, and .semgrepignore rules can exclude inputs; independently inspect these exclusions.',
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'zizmor':
|
||||
plan.coverage.domain = 'github-actions';
|
||||
plan.args = ['--offline', '--no-config', '--no-ignores', '--no-exit-codes', '--no-progress', '--color=never', '--format=sarif', root];
|
||||
plan.env.ZIZMOR_OFFLINE = '1'; plan.requiredFeatures = ['--offline', '--no-config', '--no-ignores'];
|
||||
plan.coverage.exclusions.push('Online GitHub audits and remote reusable action inspection require separate assessment.');
|
||||
plan.args = [
|
||||
'--offline',
|
||||
'--no-config',
|
||||
'--no-ignores',
|
||||
'--no-exit-codes',
|
||||
'--no-progress',
|
||||
'--color=never',
|
||||
'--format=sarif',
|
||||
root,
|
||||
];
|
||||
plan.env.ZIZMOR_OFFLINE = '1';
|
||||
plan.requiredFeatures = ['--offline', '--no-config', '--no-ignores'];
|
||||
plan.coverage.exclusions.push(
|
||||
'Online GitHub audits and remote reusable action inspection require separate assessment.',
|
||||
);
|
||||
break;
|
||||
case 'trivy':
|
||||
plan.format = 'trivy-json'; plan.coverage.domain = 'dependencies-and-infrastructure';
|
||||
plan.trustedFiles.push({ path: `${policy}/trivy.yaml`, content: '{}\n' }, { path: `${policy}/trivyignore`, content: '' });
|
||||
plan.args = ['fs', '--format=json', '--config', `${policy}/trivy.yaml`, '--ignorefile', `${policy}/trivyignore`, '--scanners=vuln,misconfig,secret', '--cache-backend=memory', '--disable-telemetry', '--offline-scan', '--skip-db-update', '--skip-java-db-update', '--skip-check-update', '--skip-version-check', '--skip-vex-repo-update', '--timeout', `${timeout}s`, ...(cache ? ['--cache-dir', cache] : []), root];
|
||||
plan.format = 'trivy-json';
|
||||
plan.coverage.domain = 'dependencies-and-infrastructure';
|
||||
plan.trustedFiles.push(
|
||||
{ path: `${policy}/trivy.yaml`, content: '{}\n' },
|
||||
{ path: `${policy}/trivyignore`, content: '' },
|
||||
);
|
||||
plan.args = [
|
||||
'fs',
|
||||
'--format=json',
|
||||
'--config',
|
||||
`${policy}/trivy.yaml`,
|
||||
'--ignorefile',
|
||||
`${policy}/trivyignore`,
|
||||
'--scanners=vuln,misconfig,secret',
|
||||
'--cache-backend=memory',
|
||||
'--disable-telemetry',
|
||||
'--offline-scan',
|
||||
'--skip-db-update',
|
||||
'--skip-java-db-update',
|
||||
'--skip-check-update',
|
||||
'--skip-version-check',
|
||||
'--skip-vex-repo-update',
|
||||
'--timeout',
|
||||
`${timeout}s`,
|
||||
...(cache ? ['--cache-dir', cache] : []),
|
||||
root,
|
||||
];
|
||||
plan.env.TRIVY_DISABLE_TELEMETRY = 'true';
|
||||
plan.requiredFeatures = ['--cache-backend', '--disable-telemetry', '--offline-scan', '--skip-db-update', '--skip-java-db-update', '--skip-check-update', '--skip-version-check', '--skip-vex-repo-update'];
|
||||
if (!cache) plan.prerequisites.push('Provide verified offline Trivy vulnerability, Java, and misconfiguration databases as needed.');
|
||||
plan.requiredFeatures = [
|
||||
'--cache-backend',
|
||||
'--disable-telemetry',
|
||||
'--offline-scan',
|
||||
'--skip-db-update',
|
||||
'--skip-java-db-update',
|
||||
'--skip-check-update',
|
||||
'--skip-version-check',
|
||||
'--skip-vex-repo-update',
|
||||
];
|
||||
if (!cache)
|
||||
plan.prerequisites.push(
|
||||
'Provide verified offline Trivy vulnerability, Java, and misconfiguration databases as needed.',
|
||||
);
|
||||
break;
|
||||
case 'schemathesis': {
|
||||
plan.format = 'schemathesis-json'; plan.network = 'loopback'; plan.coverage.domain = 'api-runtime';
|
||||
plan.format = 'schemathesis-json';
|
||||
plan.network = 'loopback';
|
||||
plan.coverage.domain = 'api-runtime';
|
||||
plan.outputPath = '/work/schemathesis.json';
|
||||
// The upstream image enables a Python hook module and coverage plugin by
|
||||
// default. Qualified CSO scans use only the reviewed schema/config.
|
||||
plan.env.SCHEMATHESIS_HOOKS = ''; plan.env.SCHEMATHESIS_COVERAGE = 'false';
|
||||
plan.env.SCHEMATHESIS_HOOKS = '';
|
||||
plan.env.SCHEMATHESIS_COVERAGE = 'false';
|
||||
plan.trustedFiles.push({ path: `${policy}/schemathesis.toml`, content: '' });
|
||||
const schema = opts.schemaPath ? absolutePath(opts.schemaPath, 'schemaPath') : `${policy}/openapi.json`;
|
||||
if (!schema.startsWith(`${policy}/`)) throw new Error('Schemathesis schema must be below trusted policyRoot');
|
||||
if (!opts.schemaPath) plan.prerequisites.push('Provide a reviewed local schema with resolved local references, no remote references, and no hook imports.');
|
||||
const schema = opts.schemaPath
|
||||
? absolutePath(opts.schemaPath, 'schemaPath')
|
||||
: `${policy}/openapi.json`;
|
||||
if (!schema.startsWith(`${policy}/`))
|
||||
throw new Error('Schemathesis schema must be below trusted policyRoot');
|
||||
if (!opts.schemaPath)
|
||||
plan.prerequisites.push(
|
||||
'Provide a reviewed local schema with resolved local references, no remote references, and no hook imports.',
|
||||
);
|
||||
const base = opts.baseUrl ? validateScannerBaseUrl(opts.baseUrl) : 'http://127.0.0.1:3000/';
|
||||
if (!opts.baseUrl) plan.prerequisites.push('Start the application and a legitimate control in the admitted loopback namespace.');
|
||||
if (!opts.baseUrl)
|
||||
plan.prerequisites.push(
|
||||
'Start the application and a legitimate control in the admitted loopback namespace.',
|
||||
);
|
||||
const seed = positiveInteger(opts.seed ?? 1, 2_147_483_647, 'seed');
|
||||
const examples = positiveInteger(opts.maxExamples ?? 20, 100, 'maxExamples');
|
||||
const operations = opts.operationIds ?? [];
|
||||
if (operations.length === 0 || operations.length > 20) plan.prerequisites.push('Declare between 1 and 20 reviewed operation IDs to bound the API assessment.');
|
||||
if (operations.some(op => !op || op.length > 200 || /[\x00-\x1f]/.test(op))) throw new Error('Invalid Schemathesis operation ID');
|
||||
plan.args = ['--config-file', `${policy}/schemathesis.toml`, '--no-color', 'run', schema, '--url', base, '--workers=1', '--phases=fuzzing', '--max-examples', String(examples), '--max-failures=10', '--max-time', String(timeout), '--seed', String(seed), '--request-timeout=5', '--request-retries=0', '--max-redirects=0', '--rate-limit=10/s', '--output-sanitize=true', '--generation-database=none', '--report-json-path', plan.outputPath, ...operations.flatMap(op => ['--include-operation-id', op])];
|
||||
plan.requiredFeatures = ['--report-json-path', '--max-time', '--seed', '--max-redirects', '--include-operation-id'];
|
||||
plan.coverage.scope = operations.map(op => `operation:${op}`);
|
||||
plan.coverage.exclusions.push('Only declared operations and generated examples are exercised; API failures are candidates, not security proofs.');
|
||||
if (operations.length === 0 || operations.length > 20)
|
||||
plan.prerequisites.push(
|
||||
'Declare between 1 and 20 reviewed operation IDs to bound the API assessment.',
|
||||
);
|
||||
if (operations.some((op) => !op || op.length > 200 || /[\x00-\x1f]/.test(op)))
|
||||
throw new Error('Invalid Schemathesis operation ID');
|
||||
plan.args = [
|
||||
'--config-file',
|
||||
`${policy}/schemathesis.toml`,
|
||||
'--no-color',
|
||||
'run',
|
||||
schema,
|
||||
'--url',
|
||||
base,
|
||||
'--workers=1',
|
||||
'--phases=fuzzing',
|
||||
'--max-examples',
|
||||
String(examples),
|
||||
'--max-failures=10',
|
||||
'--max-time',
|
||||
String(timeout),
|
||||
'--seed',
|
||||
String(seed),
|
||||
'--request-timeout=5',
|
||||
'--request-retries=0',
|
||||
'--max-redirects=0',
|
||||
'--rate-limit=10/s',
|
||||
'--output-sanitize=true',
|
||||
'--generation-database=none',
|
||||
'--report-json-path',
|
||||
plan.outputPath,
|
||||
...operations.flatMap((op) => ['--include-operation-id', op]),
|
||||
];
|
||||
plan.requiredFeatures = [
|
||||
'--report-json-path',
|
||||
'--max-time',
|
||||
'--seed',
|
||||
'--max-redirects',
|
||||
'--include-operation-id',
|
||||
];
|
||||
plan.coverage.scope = operations.map((op) => `operation:${op}`);
|
||||
plan.coverage.exclusions.push(
|
||||
'Only declared operations and generated examples are exercised; API failures are candidates, not security proofs.',
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const capabilities = opts.tools?.[id]?.capabilities;
|
||||
if (capabilities) for (const required of plan.requiredFeatures) {
|
||||
if (!capabilities.includes(required)) plan.prerequisites.push(`${plan.executableName} lacks required capability ${required}.`);
|
||||
}
|
||||
if (capabilities)
|
||||
for (const required of plan.requiredFeatures) {
|
||||
if (!capabilities.includes(required))
|
||||
plan.prerequisites.push(`${plan.executableName} lacks required capability ${required}.`);
|
||||
}
|
||||
const version = opts.tools?.[id]?.version;
|
||||
if (id === 'osv' && version && !/\b(?:v)?2\./.test(version)) plan.prerequisites.push('OSV-Scanner major version 2 is required.');
|
||||
if (id === 'osv' && version && !/\b(?:v)?2\./.test(version))
|
||||
plan.prerequisites.push('OSV-Scanner major version 2 is required.');
|
||||
return plan;
|
||||
});
|
||||
}
|
||||
@@ -258,7 +499,9 @@ function str(value: unknown): string {
|
||||
if (typeof value !== 'string' || value.length > 16_384) throw new Error('Expected bounded string');
|
||||
return value;
|
||||
}
|
||||
function optionalString(value: unknown): string | undefined { return value === undefined || value === null ? undefined : str(value); }
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return value === undefined || value === null ? undefined : str(value);
|
||||
}
|
||||
function integer(value: unknown): number | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 1) throw new Error('Invalid source coordinate');
|
||||
@@ -266,20 +509,32 @@ function integer(value: unknown): number | undefined {
|
||||
}
|
||||
function severity(value: unknown): ScannerCandidate['reportedSeverity'] {
|
||||
const normalized = typeof value === 'string' ? value.toLowerCase() : '';
|
||||
if (['critical', 'high', 'medium', 'low', 'info'].includes(normalized)) return normalized as ScannerCandidate['reportedSeverity'];
|
||||
return ({ error: 'high', warning: 'medium', note: 'info', informational: 'info', unknown: 'unknown' } as const)[normalized] ?? 'unknown';
|
||||
if (['critical', 'high', 'medium', 'low', 'info'].includes(normalized))
|
||||
return normalized as ScannerCandidate['reportedSeverity'];
|
||||
return (
|
||||
({ error: 'high', warning: 'medium', note: 'info', informational: 'info', unknown: 'unknown' } as const)[
|
||||
normalized
|
||||
] ?? 'unknown'
|
||||
);
|
||||
}
|
||||
|
||||
/** No path is opened by this module. Normalization refuses URI/traversal escapes. */
|
||||
export function scannerLocation(raw: string, sourceRoot: string): string {
|
||||
let decoded: string;
|
||||
try { decoded = decodeURIComponent(raw); } catch { throw new Error('Unsafe location'); }
|
||||
if (/[\x00-\x1f\x7f]/.test(decoded) || /%[\da-f]{2}/i.test(decoded) || decoded.includes('\\')) throw new Error('Unsafe location');
|
||||
try {
|
||||
decoded = decodeURIComponent(raw);
|
||||
} catch {
|
||||
throw new Error('Unsafe location');
|
||||
}
|
||||
if (/[\x00-\x1f\x7f]/.test(decoded) || /%[\da-f]{2}/i.test(decoded) || decoded.includes('\\'))
|
||||
throw new Error('Unsafe location');
|
||||
if (decoded.startsWith('file:')) {
|
||||
const url = new URL(decoded);
|
||||
if (url.hostname || url.username || url.password || url.search || url.hash) throw new Error('Unsafe file URI');
|
||||
if (url.hostname || url.username || url.password || url.search || url.hash)
|
||||
throw new Error('Unsafe file URI');
|
||||
decoded = decodeURIComponent(url.pathname);
|
||||
} else if (/^[a-z][a-z\d+.-]*:/i.test(decoded) || decoded.startsWith('//')) throw new Error('Unsafe location');
|
||||
} else if (/^[a-z][a-z\d+.-]*:/i.test(decoded) || decoded.startsWith('//'))
|
||||
throw new Error('Unsafe location');
|
||||
if (decoded.split('/').includes('..')) throw new Error('Unsafe location');
|
||||
const root = absolutePath(sourceRoot, 'sourceRoot');
|
||||
const absolute = decoded.startsWith('/') ? posix.normalize(decoded) : posix.join(root, decoded);
|
||||
@@ -314,32 +569,66 @@ function decodedDocument(raw: string): unknown {
|
||||
return document;
|
||||
}
|
||||
|
||||
function candidate(tool: ScannerCandidate['tool'], fields: Omit<ScannerCandidate, 'id' | 'tool' | 'evidence' | 'trust' | 'suppressed'> & { suppressed?: boolean }): ScannerCandidate {
|
||||
const identity = [tool, fields.ruleId, fields.location?.path ?? fields.operation ?? '', fields.location?.line ?? '', ...fields.advisoryIds.slice().sort()];
|
||||
function candidate(
|
||||
tool: ScannerCandidate['tool'],
|
||||
fields: Omit<ScannerCandidate, 'id' | 'tool' | 'evidence' | 'trust' | 'suppressed'> & {
|
||||
suppressed?: boolean;
|
||||
},
|
||||
): ScannerCandidate {
|
||||
const identity = [
|
||||
tool,
|
||||
fields.ruleId,
|
||||
fields.location?.path ?? fields.operation ?? '',
|
||||
fields.location?.line ?? '',
|
||||
...fields.advisoryIds.slice().sort(),
|
||||
];
|
||||
const id = createHash('sha256').update(JSON.stringify(identity)).digest('hex');
|
||||
return { ...fields, id, tool, suppressed: fields.suppressed ?? false, evidence: 'scanner-candidate', trust: 'untrusted' };
|
||||
return {
|
||||
...fields,
|
||||
id,
|
||||
tool,
|
||||
suppressed: fields.suppressed ?? false,
|
||||
evidence: 'scanner-candidate',
|
||||
trust: 'untrusted',
|
||||
};
|
||||
}
|
||||
|
||||
function location(path: unknown, line: unknown, column: unknown, root: string): ScannerCandidate['location'] {
|
||||
return { path: scannerLocation(str(path), root), line: integer(line), column: integer(column) };
|
||||
}
|
||||
|
||||
function parseSarif(document: unknown, tool: ScannerCandidate['tool'], root: string, add: (value: ScannerCandidate) => void, gap: (code: ScannerGap['code'], message: string) => void): void {
|
||||
function parseSarif(
|
||||
document: unknown,
|
||||
tool: ScannerCandidate['tool'],
|
||||
root: string,
|
||||
add: (value: ScannerCandidate) => void,
|
||||
gap: (code: ScannerGap['code'], message: string) => void,
|
||||
): void {
|
||||
const sarif = obj(document);
|
||||
if (sarif.version !== '2.1.0') throw new Error('SARIF 2.1.0 required');
|
||||
const runs = arr(sarif.runs);
|
||||
if (!runs.length) { gap('SKIPPED_INPUT', 'SARIF contains no assessment runs.'); return; }
|
||||
if (!runs.length) {
|
||||
gap('SKIPPED_INPUT', 'SARIF contains no assessment runs.');
|
||||
return;
|
||||
}
|
||||
for (const input of runs) {
|
||||
const run = obj(input); const driver = obj(obj(run.tool).driver);
|
||||
const run = obj(input);
|
||||
const driver = obj(obj(run.tool).driver);
|
||||
str(driver.name);
|
||||
if (run.externalPropertyFileReferences !== undefined) {
|
||||
const refs = obj(run.externalPropertyFileReferences);
|
||||
if (refs.results !== undefined && arr(refs.results).length) gap('SKIPPED_INPUT', 'External SARIF result files were not fetched or assessed.');
|
||||
if (refs.results !== undefined && arr(refs.results).length)
|
||||
gap('SKIPPED_INPUT', 'External SARIF result files were not fetched or assessed.');
|
||||
}
|
||||
for (const invocation of run.invocations === undefined ? [] : arr(run.invocations)) {
|
||||
const inv = obj(invocation);
|
||||
if (inv.executionSuccessful === false) gap('TOOL_FAILED', 'SARIF records an unsuccessful tool invocation.');
|
||||
if (Array.isArray(inv.toolExecutionNotifications) && inv.toolExecutionNotifications.some(n => obj(n).level === 'error')) gap('TOOL_FAILED', 'SARIF records tool execution errors.');
|
||||
if (inv.executionSuccessful === false)
|
||||
gap('TOOL_FAILED', 'SARIF records an unsuccessful tool invocation.');
|
||||
if (
|
||||
Array.isArray(inv.toolExecutionNotifications) &&
|
||||
inv.toolExecutionNotifications.some((n) => obj(n).level === 'error')
|
||||
)
|
||||
gap('TOOL_FAILED', 'SARIF records tool execution errors.');
|
||||
}
|
||||
const rules = driver.rules === undefined ? [] : arr(driver.rules);
|
||||
const results = arr(run.results);
|
||||
@@ -349,7 +638,10 @@ function parseSarif(document: unknown, tool: ScannerCandidate['tool'], root: str
|
||||
// SARIF also represents passing checks and informational inventory.
|
||||
if (['pass', 'notApplicable', 'informational'].includes(String(result.kind))) continue;
|
||||
const ruleIndex = result.ruleIndex;
|
||||
const rule = Number.isSafeInteger(ruleIndex) && (ruleIndex as number) >= 0 && rules[ruleIndex as number] ? obj(rules[ruleIndex as number]) : undefined;
|
||||
const rule =
|
||||
Number.isSafeInteger(ruleIndex) && (ruleIndex as number) >= 0 && rules[ruleIndex as number]
|
||||
? obj(rules[ruleIndex as number])
|
||||
: undefined;
|
||||
const ruleId = str(result.ruleId ?? rule?.id);
|
||||
const message = obj(result.message);
|
||||
let loc: ScannerCandidate['location'];
|
||||
@@ -358,7 +650,8 @@ function parseSarif(document: unknown, tool: ScannerCandidate['tool'], root: str
|
||||
let artifact = obj(physical.artifactLocation);
|
||||
if (artifact.uri === undefined && Number.isSafeInteger(artifact.index)) {
|
||||
const index = artifact.index as number;
|
||||
if (index < 0 || !Array.isArray(run.artifacts) || !run.artifacts[index]) throw new Error('Invalid artifact index');
|
||||
if (index < 0 || !Array.isArray(run.artifacts) || !run.artifacts[index])
|
||||
throw new Error('Invalid artifact index');
|
||||
artifact = obj(obj(run.artifacts[index]).location);
|
||||
}
|
||||
let uri = str(artifact.uri);
|
||||
@@ -373,21 +666,58 @@ function parseSarif(document: unknown, tool: ScannerCandidate['tool'], root: str
|
||||
loc = location(uri, region.startLine, region.startColumn, root);
|
||||
}
|
||||
const properties = result.properties === undefined ? {} : obj(result.properties);
|
||||
const aliases = properties.tags === undefined ? [] : arr(properties.tags).filter(v => typeof v === 'string' && /^(CVE-|GHSA-|OSV-)/.test(v));
|
||||
add(candidate(tool, { ruleId, message: str(message.text ?? message.markdown ?? message.id), location: loc, reportedSeverity: severity(result.level ?? (rule?.defaultConfiguration as Obj | undefined)?.level), advisoryIds: aliases as string[], suppressed: Array.isArray(result.suppressions) && result.suppressions.length > 0 }));
|
||||
} catch (error) { gap(error instanceof Error && /[Ll]ocation|URI|source root/.test(error.message) ? 'UNSAFE_LOCATION' : 'INVALID_OUTPUT', 'A SARIF result could not be safely normalized.'); }
|
||||
const aliases =
|
||||
properties.tags === undefined
|
||||
? []
|
||||
: arr(properties.tags).filter((v) => typeof v === 'string' && /^(CVE-|GHSA-|OSV-)/.test(v));
|
||||
add(
|
||||
candidate(tool, {
|
||||
ruleId,
|
||||
message: str(message.text ?? message.markdown ?? message.id),
|
||||
location: loc,
|
||||
reportedSeverity: severity(
|
||||
result.level ?? (rule?.defaultConfiguration as Obj | undefined)?.level,
|
||||
),
|
||||
advisoryIds: aliases as string[],
|
||||
suppressed: Array.isArray(result.suppressions) && result.suppressions.length > 0,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
gap(
|
||||
error instanceof Error && /[Ll]ocation|URI|source root/.test(error.message)
|
||||
? 'UNSAFE_LOCATION'
|
||||
: 'INVALID_OUTPUT',
|
||||
'A SARIF result could not be safely normalized.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseResults(plan: ScannerPlan, document: unknown, add: (value: ScannerCandidate) => void, gap: (code: ScannerGap['code'], message: string) => void): void {
|
||||
function parseResults(
|
||||
plan: ScannerPlan,
|
||||
document: unknown,
|
||||
add: (value: ScannerCandidate) => void,
|
||||
gap: (code: ScannerGap['code'], message: string) => void,
|
||||
): void {
|
||||
const root = plan.sourceRoot;
|
||||
if (plan.format === 'sarif') { parseSarif(document, plan.id, root, add, gap); return; }
|
||||
if (plan.format === 'sarif') {
|
||||
parseSarif(document, plan.id, root, add, gap);
|
||||
return;
|
||||
}
|
||||
if (plan.format === 'gitleaks-json') {
|
||||
for (const value of arr(document)) {
|
||||
const row = obj(value);
|
||||
// Never retain Match, Secret, Line, commit message, author, or scanner fingerprint.
|
||||
add(candidate(plan.id, { ruleId: str(row.RuleID), message: str(row.Description), reportedSeverity: 'unknown', location: location(row.File, row.StartLine, row.StartColumn, root), advisoryIds: [] }));
|
||||
add(
|
||||
candidate(plan.id, {
|
||||
ruleId: str(row.RuleID),
|
||||
message: str(row.Description),
|
||||
reportedSeverity: 'unknown',
|
||||
location: location(row.File, row.StartLine, row.StartColumn, root),
|
||||
advisoryIds: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -395,38 +725,93 @@ function parseResults(plan: ScannerPlan, document: unknown, add: (value: Scanner
|
||||
switch (plan.format) {
|
||||
case 'semgrep-json':
|
||||
for (const value of arr(doc.results)) {
|
||||
const row = obj(value), extra = obj(row.extra), start = obj(row.start);
|
||||
add(candidate(plan.id, { ruleId: str(row.check_id), message: str(extra.message), reportedSeverity: severity(extra.severity), location: location(row.path, start.line, start.col, root), advisoryIds: [], suppressed: extra.is_ignored === true }));
|
||||
const row = obj(value),
|
||||
extra = obj(row.extra),
|
||||
start = obj(row.start);
|
||||
add(
|
||||
candidate(plan.id, {
|
||||
ruleId: str(row.check_id),
|
||||
message: str(extra.message),
|
||||
reportedSeverity: severity(extra.severity),
|
||||
location: location(row.path, start.line, start.col, root),
|
||||
advisoryIds: [],
|
||||
suppressed: extra.is_ignored === true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (arr(doc.errors).length) gap('TOOL_FAILED', 'Semgrep reported parser, rule, or execution errors; inspect affected coverage.');
|
||||
if (arr(doc.errors).length)
|
||||
gap('TOOL_FAILED', 'Semgrep reported parser, rule, or execution errors; inspect affected coverage.');
|
||||
if (!arr(obj(doc.paths).scanned).length) gap('SKIPPED_INPUT', 'Semgrep did not scan any source files.');
|
||||
if (Array.isArray(obj(doc.paths).skipped) && (obj(doc.paths).skipped as unknown[]).length) gap('SKIPPED_INPUT', 'Semgrep skipped source files.');
|
||||
if (Array.isArray(obj(doc.paths).skipped) && (obj(doc.paths).skipped as unknown[]).length)
|
||||
gap('SKIPPED_INPUT', 'Semgrep skipped source files.');
|
||||
return;
|
||||
case 'osv-json':
|
||||
for (const value of arr(doc.results)) {
|
||||
const result = obj(value), source = obj(result.source);
|
||||
const result = obj(value),
|
||||
source = obj(result.source);
|
||||
for (const entry of arr(result.packages)) {
|
||||
const pkg = obj(entry), detail = obj(pkg.package);
|
||||
const pkg = obj(entry),
|
||||
detail = obj(pkg.package);
|
||||
for (const input of arr(pkg.vulnerabilities)) {
|
||||
const vuln = obj(input), id = str(vuln.id);
|
||||
const vuln = obj(input),
|
||||
id = str(vuln.id);
|
||||
const aliases = vuln.aliases === undefined ? [] : arr(vuln.aliases).map(str);
|
||||
add(candidate(plan.id, { ruleId: id, message: optionalString(vuln.summary) ?? id, reportedSeverity: 'unknown', location: location(source.path, undefined, undefined, root), advisoryIds: [...new Set([id, ...aliases])], dependency: { name: str(detail.name), version: optionalString(detail.version), ecosystem: optionalString(detail.ecosystem), reachability: 'unknown', exposure: 'unknown' } }));
|
||||
add(
|
||||
candidate(plan.id, {
|
||||
ruleId: id,
|
||||
message: optionalString(vuln.summary) ?? id,
|
||||
reportedSeverity: 'unknown',
|
||||
location: location(source.path, undefined, undefined, root),
|
||||
advisoryIds: [...new Set([id, ...aliases])],
|
||||
dependency: {
|
||||
name: str(detail.name),
|
||||
version: optionalString(detail.version),
|
||||
ecosystem: optionalString(detail.ecosystem),
|
||||
reachability: 'unknown',
|
||||
exposure: 'unknown',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
case 'trivy-json':
|
||||
if (doc.SchemaVersion !== 2) throw new Error('Trivy schema version 2 required');
|
||||
if (doc.Results === undefined && (typeof doc.ArtifactName !== 'string' || doc.ArtifactType !== 'filesystem')) throw new Error('Missing Trivy assessment metadata');
|
||||
if (
|
||||
doc.Results === undefined &&
|
||||
(typeof doc.ArtifactName !== 'string' || doc.ArtifactType !== 'filesystem')
|
||||
)
|
||||
throw new Error('Missing Trivy assessment metadata');
|
||||
for (const value of arr(doc.Results ?? [])) {
|
||||
const result = obj(value);
|
||||
for (const key of ['Vulnerabilities', 'Misconfigurations', 'Secrets'] as const) {
|
||||
for (const input of result[key] === undefined ? [] : arr(result[key])) {
|
||||
const row = obj(input), id = str(row.VulnerabilityID ?? row.ID ?? row.RuleID);
|
||||
const row = obj(input),
|
||||
id = str(row.VulnerabilityID ?? row.ID ?? row.RuleID);
|
||||
const cause = row.CauseMetadata === undefined ? {} : obj(row.CauseMetadata);
|
||||
// Some filesystem package scanners add " (type)" after their target.
|
||||
const target = str(result.Target).replace(/ \([a-zA-Z0-9_. -]+\)$/, '');
|
||||
add(candidate(plan.id, { ruleId: id, message: optionalString(row.Title) ?? optionalString(row.Description) ?? id, reportedSeverity: severity(row.Severity), location: location(target, cause.StartLine ?? row.StartLine, undefined, root), advisoryIds: row.VulnerabilityID ? [id] : [], ...(key === 'Vulnerabilities' ? { dependency: { name: str(row.PkgName), version: optionalString(row.InstalledVersion), ecosystem: optionalString(result.Type), reachability: 'unknown' as const, exposure: 'unknown' as const } } : {}) }));
|
||||
add(
|
||||
candidate(plan.id, {
|
||||
ruleId: id,
|
||||
message: optionalString(row.Title) ?? optionalString(row.Description) ?? id,
|
||||
reportedSeverity: severity(row.Severity),
|
||||
location: location(target, cause.StartLine ?? row.StartLine, undefined, root),
|
||||
advisoryIds: row.VulnerabilityID ? [id] : [],
|
||||
...(key === 'Vulnerabilities'
|
||||
? {
|
||||
dependency: {
|
||||
name: str(row.PkgName),
|
||||
version: optionalString(row.InstalledVersion),
|
||||
ecosystem: optionalString(result.Type),
|
||||
reachability: 'unknown' as const,
|
||||
exposure: 'unknown' as const,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -434,13 +819,31 @@ function parseResults(plan: ScannerPlan, document: unknown, add: (value: Scanner
|
||||
case 'schemathesis-json': {
|
||||
str(doc.schemathesis_version);
|
||||
const operations = doc.operations === null ? null : obj(doc.operations);
|
||||
if (doc.complete !== true || doc.stop_reason !== 'completed') gap('SKIPPED_INPUT', 'Schemathesis did not finish its declared operation assessment.');
|
||||
if (!operations || typeof operations.tested !== 'number' || operations.tested === 0) gap('SKIPPED_INPUT', 'Schemathesis exercised no operations.');
|
||||
if (operations && (Number(operations.errored) > 0 || Number(operations.skipped) > 0 || Number(operations.tested) < Number(operations.selected))) gap('SKIPPED_INPUT', 'Schemathesis skipped or failed to exercise selected operations.');
|
||||
if (arr(doc.errors).length) gap('TOOL_FAILED', 'Schemathesis reported setup or test-generation errors.');
|
||||
if (doc.complete !== true || doc.stop_reason !== 'completed')
|
||||
gap('SKIPPED_INPUT', 'Schemathesis did not finish its declared operation assessment.');
|
||||
if (!operations || typeof operations.tested !== 'number' || operations.tested === 0)
|
||||
gap('SKIPPED_INPUT', 'Schemathesis exercised no operations.');
|
||||
if (
|
||||
operations &&
|
||||
(Number(operations.errored) > 0 ||
|
||||
Number(operations.skipped) > 0 ||
|
||||
Number(operations.tested) < Number(operations.selected))
|
||||
)
|
||||
gap('SKIPPED_INPUT', 'Schemathesis skipped or failed to exercise selected operations.');
|
||||
if (arr(doc.errors).length)
|
||||
gap('TOOL_FAILED', 'Schemathesis reported setup or test-generation errors.');
|
||||
for (const value of arr(doc.failures)) {
|
||||
const row = obj(value);
|
||||
for (const op of arr(row.operations)) add(candidate(plan.id, { ruleId: str(row.type), message: str(row.title), reportedSeverity: severity(row.severity), advisoryIds: [], operation: str(op) }));
|
||||
for (const op of arr(row.operations))
|
||||
add(
|
||||
candidate(plan.id, {
|
||||
ruleId: str(row.type),
|
||||
message: str(row.title),
|
||||
reportedSeverity: severity(row.severity),
|
||||
advisoryIds: [],
|
||||
operation: str(op),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -450,16 +853,38 @@ function parseResults(plan: ScannerPlan, document: unknown, add: (value: Scanner
|
||||
/** Failed or malformed tools never become an empty-clean assessment. */
|
||||
export function parseScannerOutput(plan: ScannerPlan, execution: ScannerExecution): ScannerOutcome {
|
||||
const outcome: ScannerOutcome = {
|
||||
tool: plan.id, version: null, status: 'not_assessed', candidates: [], gaps: [], scope: plan.coverage.scope.slice(), exclusions: plan.coverage.exclusions.slice(),
|
||||
databaseUpdatedAt: null, exitCode: execution.exitCode, evidence: 'scanner-candidate', provenanceSources: plan.provenanceSources.slice(),
|
||||
planSha256: createHash('sha256').update(JSON.stringify(plan)).digest('hex'), documentationInspectedAt: plan.documentationInspectedAt,
|
||||
tool: plan.id,
|
||||
version: null,
|
||||
status: 'not_assessed',
|
||||
candidates: [],
|
||||
gaps: [],
|
||||
scope: plan.coverage.scope.slice(),
|
||||
exclusions: plan.coverage.exclusions.slice(),
|
||||
databaseUpdatedAt: null,
|
||||
exitCode: execution.exitCode,
|
||||
evidence: 'scanner-candidate',
|
||||
provenanceSources: plan.provenanceSources.slice(),
|
||||
planSha256: createHash('sha256').update(JSON.stringify(plan)).digest('hex'),
|
||||
documentationInspectedAt: plan.documentationInspectedAt,
|
||||
};
|
||||
const gap = (code: ScannerGap['code'], message: string) => { if (!outcome.gaps.some(g => g.code === code && g.message === message)) outcome.gaps.push({ code, message }); };
|
||||
if (execution.unavailable) { gap('UNAVAILABLE', `${plan.id} was unavailable; this scanner assessment did not run.`); return outcome; }
|
||||
if (plan.prerequisites.length) { for (const value of plan.prerequisites) gap('PREREQUISITE', value); return outcome; }
|
||||
const gap = (code: ScannerGap['code'], message: string) => {
|
||||
if (!outcome.gaps.some((g) => g.code === code && g.message === message))
|
||||
outcome.gaps.push({ code, message });
|
||||
};
|
||||
if (execution.unavailable) {
|
||||
gap('UNAVAILABLE', `${plan.id} was unavailable; this scanner assessment did not run.`);
|
||||
return outcome;
|
||||
}
|
||||
if (plan.prerequisites.length) {
|
||||
for (const value of plan.prerequisites) gap('PREREQUISITE', value);
|
||||
return outcome;
|
||||
}
|
||||
if (execution.timedOut) gap('TIMEOUT', 'Scanner exceeded its execution deadline.');
|
||||
const outputBytes = Buffer.byteLength(execution.stdout) + Buffer.byteLength(execution.stderr ?? '');
|
||||
if (execution.truncated || outputBytes > Math.min(plan.maxOutputBytes, MAX_SCANNER_OUTPUT_BYTES)) { gap('OUTPUT_LIMIT', 'Scanner output exceeded the capture limit; payload withheld.'); return outcome; }
|
||||
if (execution.truncated || outputBytes > Math.min(plan.maxOutputBytes, MAX_SCANNER_OUTPUT_BYTES)) {
|
||||
gap('OUTPUT_LIMIT', 'Scanner output exceeded the capture limit; payload withheld.');
|
||||
return outcome;
|
||||
}
|
||||
try {
|
||||
if (execution.version) {
|
||||
const safe = redactFindingSpans(execution.version);
|
||||
@@ -467,31 +892,61 @@ export function parseScannerOutput(plan: ScannerPlan, execution: ScannerExecutio
|
||||
outcome.version = safe.slice(0, 200).replace(/[\x00-\x1f\x7f]/g, '');
|
||||
}
|
||||
if (redactFindingSpans(execution.stderr ?? '') === null) throw new RedactionFailure();
|
||||
if (/\b(?:error|fatal|panic|failed to|unable to|no offline version)\b/i.test(execution.stderr ?? '')) gap('TOOL_FAILED', 'Scanner diagnostic output reported a failure; the JSON result does not establish complete coverage.');
|
||||
if (/\b(?:error|fatal|panic|failed to|unable to|no offline version)\b/i.test(execution.stderr ?? ''))
|
||||
gap(
|
||||
'TOOL_FAILED',
|
||||
'Scanner diagnostic output reported a failure; the JSON result does not establish complete coverage.',
|
||||
);
|
||||
const doc = decodedDocument(execution.stdout);
|
||||
const seen = new Set<string>();
|
||||
parseResults(plan, doc, item => {
|
||||
if (outcome.candidates.length >= MAX_CANDIDATES) throw new Error('Candidate limit exceeded');
|
||||
if (!seen.has(item.id)) { seen.add(item.id); outcome.candidates.push(item); }
|
||||
}, gap);
|
||||
parseResults(
|
||||
plan,
|
||||
doc,
|
||||
(item) => {
|
||||
if (outcome.candidates.length >= MAX_CANDIDATES) throw new Error('Candidate limit exceeded');
|
||||
if (!seen.has(item.id)) {
|
||||
seen.add(item.id);
|
||||
outcome.candidates.push(item);
|
||||
}
|
||||
},
|
||||
gap,
|
||||
);
|
||||
outcome.status = 'complete';
|
||||
} catch (error) {
|
||||
if (error instanceof RedactionFailure) { outcome.candidates = []; gap('REDACTION_FAILED', 'Scanner payload could not be safely redacted and was withheld.'); }
|
||||
else gap('INVALID_OUTPUT', 'Scanner report is malformed, unsupported, or exceeds structural limits.');
|
||||
if (error instanceof RedactionFailure) {
|
||||
outcome.candidates = [];
|
||||
gap('REDACTION_FAILED', 'Scanner payload could not be safely redacted and was withheld.');
|
||||
} else gap('INVALID_OUTPUT', 'Scanner report is malformed, unsupported, or exceeds structural limits.');
|
||||
}
|
||||
const successCodes = plan.id === 'gitleaks' ? [0, 10] : ['osv', 'schemathesis'].includes(plan.id) ? [0, 1] : [0];
|
||||
if (execution.exitCode === null || !successCodes.includes(execution.exitCode)) gap('TOOL_FAILED', 'Scanner did not exit with a recognized assessment status.');
|
||||
if ((plan.id === 'gitleaks' && execution.exitCode === 10 || plan.id === 'osv' && execution.exitCode === 1) && !outcome.candidates.length) gap('INVALID_OUTPUT', 'Scanner finding exit status disagrees with its empty report.');
|
||||
const successCodes =
|
||||
plan.id === 'gitleaks' ? [0, 10] : ['osv', 'schemathesis'].includes(plan.id) ? [0, 1] : [0];
|
||||
if (execution.exitCode === null || !successCodes.includes(execution.exitCode))
|
||||
gap('TOOL_FAILED', 'Scanner did not exit with a recognized assessment status.');
|
||||
if (
|
||||
((plan.id === 'gitleaks' && execution.exitCode === 10) ||
|
||||
(plan.id === 'osv' && execution.exitCode === 1)) &&
|
||||
!outcome.candidates.length
|
||||
)
|
||||
gap('INVALID_OUTPUT', 'Scanner finding exit status disagrees with its empty report.');
|
||||
if (['osv', 'trivy'].includes(plan.id)) {
|
||||
if (execution.databaseUpdatedAt && /^\d{4}-\d\d-\d\dT/.test(execution.databaseUpdatedAt) && Number.isFinite(Date.parse(execution.databaseUpdatedAt))) outcome.databaseUpdatedAt = execution.databaseUpdatedAt;
|
||||
if (
|
||||
execution.databaseUpdatedAt &&
|
||||
/^\d{4}-\d\d-\d\dT/.test(execution.databaseUpdatedAt) &&
|
||||
Number.isFinite(Date.parse(execution.databaseUpdatedAt))
|
||||
)
|
||||
outcome.databaseUpdatedAt = execution.databaseUpdatedAt;
|
||||
else gap('UNKNOWN_FRESHNESS', 'The advisory database freshness is unknown.');
|
||||
}
|
||||
if (outcome.gaps.length) outcome.status = outcome.status === 'complete' || outcome.candidates.length ? 'partial' : 'not_assessed';
|
||||
if (outcome.gaps.length)
|
||||
outcome.status = outcome.status === 'complete' || outcome.candidates.length ? 'partial' : 'not_assessed';
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/** Import CodeQL or other SARIF as read-only candidates; never trust its verdict. */
|
||||
export function importSarif(raw: string, opts: { sourceRoot: string; version?: string; scope?: string[] }): ScannerOutcome {
|
||||
export function importSarif(
|
||||
raw: string,
|
||||
opts: { sourceRoot: string; version?: string; scope?: string[] },
|
||||
): ScannerOutcome {
|
||||
const root = absolutePath(opts.sourceRoot, 'sourceRoot');
|
||||
const plan = scannerPlans({ snapshotRoot: root, offline: true, selected: ['zizmor'] })[0];
|
||||
plan.coverage.scope = opts.scope ?? [root];
|
||||
@@ -499,6 +954,6 @@ export function importSarif(raw: string, opts: { sourceRoot: string; version?: s
|
||||
plan.coverage.exclusions = ['Imported scanner scope and suppressions require independent validation.'];
|
||||
const outcome = parseScannerOutput(plan, { stdout: raw, exitCode: 0, version: opts.version });
|
||||
outcome.tool = 'sarif';
|
||||
outcome.candidates = outcome.candidates.map(item => candidate('sarif', item));
|
||||
outcome.candidates = outcome.candidates.map((item) => candidate('sarif', item));
|
||||
return outcome;
|
||||
}
|
||||
+902
-192
File diff suppressed because it is too large.
Load diff
+2338
-655
File diff suppressed because it is too large.
Load diff
+2117
-301
File diff suppressed because it is too large.
Load diff
+153
-24
@@ -3,30 +3,159 @@ import * as fs from 'node:fs';
|
||||
import { connect } from 'node:net';
|
||||
import { HttpAssertion, VerificationObservation, object } from './contracts';
|
||||
|
||||
interface Config { phase:'before'|'after'; port:number; legitimate:HttpAssertion[]; security:HttpAssertion }
|
||||
function matches(status:number,body:string,oracle:HttpAssertion['expected']):boolean{return status===oracle.status&&(oracle.includes===undefined||body.includes(oracle.includes))&&(oracle.excludes===undefined||!body.includes(oracle.excludes));}
|
||||
export async function boundedResponseBody(response:Response,limit=65536):Promise<string>{
|
||||
if(!Number.isSafeInteger(limit)||limit<1)throw new Error('invalid response limit');
|
||||
const declared=response.headers.get('content-length');
|
||||
if(declared!==null&&(/^\d+$/.test(declared)?Number(declared)>limit:true)){await response.body?.cancel();throw new Error('response too large');}
|
||||
if(!response.body)return'';
|
||||
const reader=response.body.getReader(),chunks:Uint8Array[]=[];let total=0;
|
||||
try{
|
||||
for(;;){const next=await reader.read();if(next.done)break;if(!next.value)continue;total+=next.value.byteLength;if(total>limit){await reader.cancel();throw new Error('response too large');}chunks.push(next.value);}
|
||||
}finally{reader.releaseLock();}
|
||||
const bytes=new Uint8Array(total);let offset=0;for(const chunk of chunks){bytes.set(chunk,offset);offset+=chunk.byteLength;}return new TextDecoder().decode(bytes);
|
||||
interface Config {
|
||||
phase: 'before' | 'after';
|
||||
port: number;
|
||||
legitimate: HttpAssertion[];
|
||||
security: HttpAssertion;
|
||||
}
|
||||
async function request(a:HttpAssertion,port:number):Promise<{status:number;body:string}>{
|
||||
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),5000);
|
||||
try{const response=await fetch(`http://127.0.0.1:${port}${a.path}`,{method:a.method,headers:a.headers,body:['GET'].includes(a.method)?undefined:a.body,redirect:'manual',signal:controller.signal});return{status:response.status,body:await boundedResponseBody(response)};}finally{clearTimeout(timer);}
|
||||
function matches(status: number, body: string, oracle: HttpAssertion['expected']): boolean {
|
||||
return (
|
||||
status === oracle.status &&
|
||||
(oracle.includes === undefined || body.includes(oracle.includes)) &&
|
||||
(oracle.excludes === undefined || !body.includes(oracle.excludes))
|
||||
);
|
||||
}
|
||||
async function ready(port:number):Promise<boolean>{return await new Promise(resolve=>{const socket=connect({host:'127.0.0.1',port}),done=(value:boolean)=>{socket.removeAllListeners();socket.destroy();resolve(value);},timer=setTimeout(()=>done(false),500);socket.once('connect',()=>{clearTimeout(timer);done(true);});socket.once('error',()=>{clearTimeout(timer);done(false);});});}
|
||||
async function main(){
|
||||
const file=process.argv[2];if(!file||!file.startsWith('/policy/'))throw new Error('trusted policy path required');const raw=fs.readFileSync(file,'utf8');if(Buffer.byteLength(raw)>1024*1024)throw new Error('policy too large');const v=object(JSON.parse(raw),'verifier policy') as any;
|
||||
if(!['before','after'].includes(v.phase)||!Number.isInteger(v.port)||v.port<1024||v.port>65535||!Array.isArray(v.legitimate)||!v.security)throw new Error('invalid verifier policy');const config=v as Config;
|
||||
let booted=false;for(let attempt=0;attempt<60;attempt++){if(await ready(config.port)){booted=true;break;}await Bun.sleep(250);}
|
||||
let legitimate=false,security:VerificationObservation['security']='inconclusive',summary='application did not answer a legitimate control';
|
||||
if(booted){try{legitimate=(await Promise.all(config.legitimate.map(async a=>{const r=await request(a,config.port);return matches(r.status,r.body,a.expected);}))).every(Boolean);const r=await request(config.security,config.port),fixed=matches(r.status,r.body,config.security.expected),vulnerable=matches(r.status,r.body,config.security.vulnerable!);security=config.phase==='before'?(vulnerable&&!fixed?'intended_failure':fixed&&!vulnerable?'pass':'inconclusive'):(fixed&&!vulnerable?'pass':'inconclusive');summary=`boot=true legitimate=${legitimate} security=${security}`;}catch{summary='bounded verifier request failed';}}
|
||||
process.stdout.write(JSON.stringify({booted,legitimate,security,existingTests:false,output:summary,inputHash:''})+'\n');
|
||||
export async function boundedResponseBody(response: Response, limit = 65536): Promise<string> {
|
||||
if (!Number.isSafeInteger(limit) || limit < 1) throw new Error('invalid response limit');
|
||||
const declared = response.headers.get('content-length');
|
||||
if (declared !== null && (/^\d+$/.test(declared) ? Number(declared) > limit : true)) {
|
||||
await response.body?.cancel();
|
||||
throw new Error('response too large');
|
||||
}
|
||||
if (!response.body) return '';
|
||||
const reader = response.body.getReader(),
|
||||
chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const next = await reader.read();
|
||||
if (next.done) break;
|
||||
if (!next.value) continue;
|
||||
total += next.value.byteLength;
|
||||
if (total > limit) {
|
||||
await reader.cancel();
|
||||
throw new Error('response too large');
|
||||
}
|
||||
chunks.push(next.value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
if(import.meta.main)main().catch(()=>{process.stdout.write(JSON.stringify({booted:false,legitimate:false,security:'inconclusive',existingTests:false,output:'verifier setup failed',inputHash:''})+'\n');process.exitCode=1;});
|
||||
async function request(a: HttpAssertion, port: number): Promise<{ status: number; body: string }> {
|
||||
const controller = new AbortController(),
|
||||
timer = setTimeout(() => controller.abort(), 5000);
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}${a.path}`, {
|
||||
method: a.method,
|
||||
headers: a.headers,
|
||||
body: ['GET'].includes(a.method) ? undefined : a.body,
|
||||
redirect: 'manual',
|
||||
signal: controller.signal,
|
||||
});
|
||||
return { status: response.status, body: await boundedResponseBody(response) };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
async function ready(port: number): Promise<boolean> {
|
||||
return await new Promise((resolve) => {
|
||||
const socket = connect({ host: '127.0.0.1', port }),
|
||||
done = (value: boolean) => {
|
||||
socket.removeAllListeners();
|
||||
socket.destroy();
|
||||
resolve(value);
|
||||
},
|
||||
timer = setTimeout(() => done(false), 500);
|
||||
socket.once('connect', () => {
|
||||
clearTimeout(timer);
|
||||
done(true);
|
||||
});
|
||||
socket.once('error', () => {
|
||||
clearTimeout(timer);
|
||||
done(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
async function main() {
|
||||
const file = process.argv[2];
|
||||
if (!file || !file.startsWith('/policy/')) throw new Error('trusted policy path required');
|
||||
const raw = fs.readFileSync(file, 'utf8');
|
||||
if (Buffer.byteLength(raw) > 1024 * 1024) throw new Error('policy too large');
|
||||
const v = object(JSON.parse(raw), 'verifier policy') as any;
|
||||
if (
|
||||
!['before', 'after'].includes(v.phase) ||
|
||||
!Number.isInteger(v.port) ||
|
||||
v.port < 1024 ||
|
||||
v.port > 65535 ||
|
||||
!Array.isArray(v.legitimate) ||
|
||||
!v.security
|
||||
)
|
||||
throw new Error('invalid verifier policy');
|
||||
const config = v as Config;
|
||||
let booted = false;
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
if (await ready(config.port)) {
|
||||
booted = true;
|
||||
break;
|
||||
}
|
||||
await Bun.sleep(250);
|
||||
}
|
||||
let legitimate = false,
|
||||
security: VerificationObservation['security'] = 'inconclusive',
|
||||
summary = 'application did not answer a legitimate control';
|
||||
if (booted) {
|
||||
try {
|
||||
legitimate = (
|
||||
await Promise.all(
|
||||
config.legitimate.map(async (a) => {
|
||||
const r = await request(a, config.port);
|
||||
return matches(r.status, r.body, a.expected);
|
||||
}),
|
||||
)
|
||||
).every(Boolean);
|
||||
const r = await request(config.security, config.port),
|
||||
fixed = matches(r.status, r.body, config.security.expected),
|
||||
vulnerable = matches(r.status, r.body, config.security.vulnerable!);
|
||||
security =
|
||||
config.phase === 'before'
|
||||
? vulnerable && !fixed
|
||||
? 'intended_failure'
|
||||
: fixed && !vulnerable
|
||||
? 'pass'
|
||||
: 'inconclusive'
|
||||
: fixed && !vulnerable
|
||||
? 'pass'
|
||||
: 'inconclusive';
|
||||
summary = `boot=true legitimate=${legitimate} security=${security}`;
|
||||
} catch {
|
||||
summary = 'bounded verifier request failed';
|
||||
}
|
||||
}
|
||||
process.stdout.write(
|
||||
JSON.stringify({ booted, legitimate, security, existingTests: false, output: summary, inputHash: '' }) +
|
||||
'\n',
|
||||
);
|
||||
}
|
||||
if (import.meta.main)
|
||||
main().catch(() => {
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
booted: false,
|
||||
legitimate: false,
|
||||
security: 'inconclusive',
|
||||
existingTests: false,
|
||||
output: 'verifier setup failed',
|
||||
inputHash: '',
|
||||
}) + '\n',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
+680
-103
@@ -1,136 +1,713 @@
|
||||
import { generateKeyPairSync, createPrivateKey, createPublicKey, randomBytes, sign, verify } from 'node:crypto';
|
||||
import {
|
||||
generateKeyPairSync,
|
||||
createPrivateKey,
|
||||
createPublicKey,
|
||||
randomBytes,
|
||||
sign,
|
||||
verify,
|
||||
} from 'node:crypto';
|
||||
import { lstatSync, realpathSync } from 'node:fs';
|
||||
import { basename, dirname } from 'node:path';
|
||||
import {
|
||||
AssertionWitnessBinding, AssertionWitnessReceipt, Command, CsoError, MAX_OUTPUT,
|
||||
VerificationObservation, canonical, object, oneOf, sha256, string, validateCommand,
|
||||
AssertionWitnessBinding,
|
||||
AssertionWitnessReceipt,
|
||||
Command,
|
||||
CsoError,
|
||||
MAX_OUTPUT,
|
||||
VerificationObservation,
|
||||
canonical,
|
||||
object,
|
||||
oneOf,
|
||||
sha256,
|
||||
string,
|
||||
validateCommand,
|
||||
validateVerificationObservation,
|
||||
} from './contracts';
|
||||
import { runProcess } from './process';
|
||||
|
||||
export interface WitnessTestExecution { command:Command; code:number; output:string; minimumPassingTests:number }
|
||||
export interface WitnessedVerificationResult { observation:VerificationObservation; witness:AssertionWitnessReceipt }
|
||||
export interface WitnessTestExecution {
|
||||
command: Command;
|
||||
code: number;
|
||||
output: string;
|
||||
minimumPassingTests: number;
|
||||
}
|
||||
export interface WitnessedVerificationResult {
|
||||
observation: VerificationObservation;
|
||||
witness: AssertionWitnessReceipt;
|
||||
}
|
||||
export interface AssertionWitnessHandle {
|
||||
readonly binding:AssertionWitnessBinding;
|
||||
attest(observation:VerificationObservation,executions:WitnessTestExecution[]):Promise<AssertionWitnessReceipt>;
|
||||
validate(receipt:unknown,observation:VerificationObservation,now?:number):AssertionWitnessReceipt;
|
||||
readonly binding: AssertionWitnessBinding;
|
||||
attest(
|
||||
observation: VerificationObservation,
|
||||
executions: WitnessTestExecution[],
|
||||
): Promise<AssertionWitnessReceipt>;
|
||||
validate(receipt: unknown, observation: VerificationObservation, now?: number): AssertionWitnessReceipt;
|
||||
}
|
||||
|
||||
const HASH=/^[a-f0-9]{64}$/;
|
||||
const PUBLIC_KEY=/^[a-f0-9]{88}$/;
|
||||
const SIGNATURE=/^[a-f0-9]{128}$/;
|
||||
const PROTOCOL='gstack-cso-assertion-witness-v1' as const;
|
||||
const MAX_RECEIPT_AGE=300_000;
|
||||
const exact=(value:Record<string,any>,allowed:readonly string[],name:string)=>{for(const key of Object.keys(value))if(!allowed.includes(key))throw new CsoError('INVALID_SCHEMA',`Unexpected ${name} field: ${key}`);};
|
||||
const hash=(value:unknown,name:string):string=>{if(typeof value!=='string'||!HASH.test(value))throw new CsoError('INVALID_SCHEMA',`${name} must be a sha256 hash`);return value;};
|
||||
const timestamp=(value:unknown,name:string):string=>{const result=string(value,name,64),ms=Date.parse(result);if(!Number.isFinite(ms)||new Date(ms).toISOString()!==result)throw new CsoError('INVALID_SCHEMA',`${name} must be a canonical UTC timestamp`);return result;};
|
||||
const HASH = /^[a-f0-9]{64}$/;
|
||||
const PUBLIC_KEY = /^[a-f0-9]{88}$/;
|
||||
const SIGNATURE = /^[a-f0-9]{128}$/;
|
||||
const PROTOCOL = 'gstack-cso-assertion-witness-v1' as const;
|
||||
const MAX_RECEIPT_AGE = 300_000;
|
||||
const exact = (value: Record<string, any>, allowed: readonly string[], name: string) => {
|
||||
for (const key of Object.keys(value))
|
||||
if (!allowed.includes(key)) throw new CsoError('INVALID_SCHEMA', `Unexpected ${name} field: ${key}`);
|
||||
};
|
||||
const hash = (value: unknown, name: string): string => {
|
||||
if (typeof value !== 'string' || !HASH.test(value))
|
||||
throw new CsoError('INVALID_SCHEMA', `${name} must be a sha256 hash`);
|
||||
return value;
|
||||
};
|
||||
const timestamp = (value: unknown, name: string): string => {
|
||||
const result = string(value, name, 64),
|
||||
ms = Date.parse(result);
|
||||
if (!Number.isFinite(ms) || new Date(ms).toISOString() !== result)
|
||||
throw new CsoError('INVALID_SCHEMA', `${name} must be a canonical UTC timestamp`);
|
||||
return result;
|
||||
};
|
||||
|
||||
export function validateAssertionWitnessBinding(value:unknown):AssertionWitnessBinding{
|
||||
const v=object(value,'assertion witness binding'),runtime=object(v.runtime,'assertion witness runtime'),runner=object(v.runner,'assertion witness runner');
|
||||
exact(v,['schemaVersion','protocol','nonce','phase','issuedAt','expiresAt','runId','findingId','policyHash','auditPolicyHash','runtime','runner','sourceHash','dependencyHash','configurationHash','requestHash','patchHash','harnessHash','assertionHash','fixturesHash'],'assertion witness binding');
|
||||
exact(runtime,['image','verifierImage','platform','profile'],'assertion witness runtime');exact(runner,['testToolchain','startPlanHash','testPlanHash','commandsHash','minimumPassingTestsHash'],'assertion witness runner');
|
||||
if(v.schemaVersion!==1||v.protocol!==PROTOCOL)throw new CsoError('INVALID_SCHEMA','Unsupported assertion witness protocol');
|
||||
const issuedAt=timestamp(v.issuedAt,'assertion witness issuedAt'),expiresAt=timestamp(v.expiresAt,'assertion witness expiresAt'),duration=Date.parse(expiresAt)-Date.parse(issuedAt);
|
||||
if(duration<=0||duration>MAX_RECEIPT_AGE)throw new CsoError('INVALID_SCHEMA','Assertion witness lifetime exceeds the bounded attempt policy');
|
||||
if(typeof v.nonce!=='string'||!HASH.test(v.nonce))throw new CsoError('INVALID_SCHEMA','Assertion witness nonce must be 32 random bytes');
|
||||
const findingId=string(v.findingId,'assertion witness findingId',64);if(!/^[a-f0-9]{32}$/.test(findingId))throw new CsoError('INVALID_SCHEMA','Assertion witness findingId is invalid');
|
||||
return{schemaVersion:1,protocol:PROTOCOL,nonce:v.nonce,phase:oneOf(v.phase,['before','after'],'assertion witness phase'),issuedAt,expiresAt,runId:string(v.runId,'assertion witness runId',200),findingId,policyHash:hash(v.policyHash,'assertion witness policyHash'),auditPolicyHash:hash(v.auditPolicyHash,'assertion witness auditPolicyHash'),runtime:{image:string(runtime.image,'assertion witness runtime image',500),verifierImage:string(runtime.verifierImage,'assertion witness verifier image',500),platform:string(runtime.platform,'assertion witness runtime platform',100),profile:string(runtime.profile,'assertion witness runtime profile',100)},runner:{testToolchain:oneOf(runner.testToolchain,['runtime','project'],'assertion witness test toolchain'),startPlanHash:hash(runner.startPlanHash,'assertion witness start plan'),testPlanHash:hash(runner.testPlanHash,'assertion witness test plan'),commandsHash:hash(runner.commandsHash,'assertion witness commands'),minimumPassingTestsHash:hash(runner.minimumPassingTestsHash,'assertion witness execution floors')},sourceHash:hash(v.sourceHash,'assertion witness sourceHash'),dependencyHash:hash(v.dependencyHash,'assertion witness dependencyHash'),configurationHash:hash(v.configurationHash,'assertion witness configurationHash'),requestHash:hash(v.requestHash,'assertion witness requestHash'),patchHash:hash(v.patchHash,'assertion witness patchHash'),harnessHash:hash(v.harnessHash,'assertion witness harnessHash'),assertionHash:hash(v.assertionHash,'assertion witness assertionHash'),fixturesHash:hash(v.fixturesHash,'assertion witness fixturesHash')};
|
||||
export function validateAssertionWitnessBinding(value: unknown): AssertionWitnessBinding {
|
||||
const v = object(value, 'assertion witness binding'),
|
||||
runtime = object(v.runtime, 'assertion witness runtime'),
|
||||
runner = object(v.runner, 'assertion witness runner');
|
||||
exact(
|
||||
v,
|
||||
[
|
||||
'schemaVersion',
|
||||
'protocol',
|
||||
'nonce',
|
||||
'phase',
|
||||
'issuedAt',
|
||||
'expiresAt',
|
||||
'runId',
|
||||
'findingId',
|
||||
'policyHash',
|
||||
'auditPolicyHash',
|
||||
'runtime',
|
||||
'runner',
|
||||
'sourceHash',
|
||||
'dependencyHash',
|
||||
'configurationHash',
|
||||
'requestHash',
|
||||
'patchHash',
|
||||
'harnessHash',
|
||||
'assertionHash',
|
||||
'fixturesHash',
|
||||
],
|
||||
'assertion witness binding',
|
||||
);
|
||||
exact(runtime, ['image', 'verifierImage', 'platform', 'profile'], 'assertion witness runtime');
|
||||
exact(
|
||||
runner,
|
||||
['testToolchain', 'startPlanHash', 'testPlanHash', 'commandsHash', 'minimumPassingTestsHash'],
|
||||
'assertion witness runner',
|
||||
);
|
||||
if (v.schemaVersion !== 1 || v.protocol !== PROTOCOL)
|
||||
throw new CsoError('INVALID_SCHEMA', 'Unsupported assertion witness protocol');
|
||||
const issuedAt = timestamp(v.issuedAt, 'assertion witness issuedAt'),
|
||||
expiresAt = timestamp(v.expiresAt, 'assertion witness expiresAt'),
|
||||
duration = Date.parse(expiresAt) - Date.parse(issuedAt);
|
||||
if (duration <= 0 || duration > MAX_RECEIPT_AGE)
|
||||
throw new CsoError('INVALID_SCHEMA', 'Assertion witness lifetime exceeds the bounded attempt policy');
|
||||
if (typeof v.nonce !== 'string' || !HASH.test(v.nonce))
|
||||
throw new CsoError('INVALID_SCHEMA', 'Assertion witness nonce must be 32 random bytes');
|
||||
const findingId = string(v.findingId, 'assertion witness findingId', 64);
|
||||
if (!/^[a-f0-9]{32}$/.test(findingId))
|
||||
throw new CsoError('INVALID_SCHEMA', 'Assertion witness findingId is invalid');
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
protocol: PROTOCOL,
|
||||
nonce: v.nonce,
|
||||
phase: oneOf(v.phase, ['before', 'after'], 'assertion witness phase'),
|
||||
issuedAt,
|
||||
expiresAt,
|
||||
runId: string(v.runId, 'assertion witness runId', 200),
|
||||
findingId,
|
||||
policyHash: hash(v.policyHash, 'assertion witness policyHash'),
|
||||
auditPolicyHash: hash(v.auditPolicyHash, 'assertion witness auditPolicyHash'),
|
||||
runtime: {
|
||||
image: string(runtime.image, 'assertion witness runtime image', 500),
|
||||
verifierImage: string(runtime.verifierImage, 'assertion witness verifier image', 500),
|
||||
platform: string(runtime.platform, 'assertion witness runtime platform', 100),
|
||||
profile: string(runtime.profile, 'assertion witness runtime profile', 100),
|
||||
},
|
||||
runner: {
|
||||
testToolchain: oneOf(runner.testToolchain, ['runtime', 'project'], 'assertion witness test toolchain'),
|
||||
startPlanHash: hash(runner.startPlanHash, 'assertion witness start plan'),
|
||||
testPlanHash: hash(runner.testPlanHash, 'assertion witness test plan'),
|
||||
commandsHash: hash(runner.commandsHash, 'assertion witness commands'),
|
||||
minimumPassingTestsHash: hash(runner.minimumPassingTestsHash, 'assertion witness execution floors'),
|
||||
},
|
||||
sourceHash: hash(v.sourceHash, 'assertion witness sourceHash'),
|
||||
dependencyHash: hash(v.dependencyHash, 'assertion witness dependencyHash'),
|
||||
configurationHash: hash(v.configurationHash, 'assertion witness configurationHash'),
|
||||
requestHash: hash(v.requestHash, 'assertion witness requestHash'),
|
||||
patchHash: hash(v.patchHash, 'assertion witness patchHash'),
|
||||
harnessHash: hash(v.harnessHash, 'assertion witness harnessHash'),
|
||||
assertionHash: hash(v.assertionHash, 'assertion witness assertionHash'),
|
||||
fixturesHash: hash(v.fixturesHash, 'assertion witness fixturesHash'),
|
||||
};
|
||||
}
|
||||
|
||||
function receiptUnsigned(receipt:AssertionWitnessReceipt):Omit<AssertionWitnessReceipt,'signature'>{const {signature:_,...unsigned}=receipt;return unsigned;}
|
||||
function observationForReceipt(observation:VerificationObservation,binding:AssertionWitnessBinding,diagnosticTestsPassed:boolean):VerificationObservation{
|
||||
const checked=validateVerificationObservation(observation);
|
||||
return{...checked,existingTests:diagnosticTestsPassed,inputHash:binding.harnessHash};
|
||||
function receiptUnsigned(receipt: AssertionWitnessReceipt): Omit<AssertionWitnessReceipt, 'signature'> {
|
||||
const { signature: _, ...unsigned } = receipt;
|
||||
return unsigned;
|
||||
}
|
||||
export function witnessObservationHash(observation:VerificationObservation):string{return sha256(canonical(validateVerificationObservation(observation)));}
|
||||
|
||||
export function validateStoredAssertionWitnessReceipt(value:unknown):AssertionWitnessReceipt{
|
||||
const v=object(value,'assertion witness receipt'),binding=validateAssertionWitnessBinding(v.binding);
|
||||
exact(v,['schemaVersion','binding','keyId','publicKey','observationHash','externalAssertionsPassed','diagnosticTestsPassed','executions','signature'],'assertion witness receipt');
|
||||
if(v.schemaVersion!==1||typeof v.keyId!=='string'||!HASH.test(v.keyId)||typeof v.publicKey!=='string'||!PUBLIC_KEY.test(v.publicKey)||typeof v.signature!=='string'||!SIGNATURE.test(v.signature))throw new CsoError('INVALID_SCHEMA','Assertion witness cryptographic metadata is invalid');
|
||||
if(sha256(Buffer.from(v.publicKey,'hex'))!==v.keyId)throw new CsoError('INCOMPATIBLE_INPUT','Assertion witness key identity does not match its public key');
|
||||
if(typeof v.observationHash!=='string'||!HASH.test(v.observationHash)||typeof v.externalAssertionsPassed!=='boolean'||typeof v.diagnosticTestsPassed!=='boolean'||!Array.isArray(v.executions)||!v.executions.length||v.executions.length>100)throw new CsoError('INVALID_SCHEMA','Assertion witness outcomes are malformed');
|
||||
const executions=v.executions.map((raw:any,index:number)=>{const item=object(raw,`assertion witness execution ${index}`);exact(item,['commandHash','exitCode','outputHash','minimumPassingTests','executedTests','passingTests','reportedPassed'],`assertion witness execution ${index}`);if(!Number.isSafeInteger(item.exitCode)||item.exitCode<-1||item.exitCode>255||!Number.isSafeInteger(item.minimumPassingTests)||item.minimumPassingTests<1||!Number.isSafeInteger(item.executedTests)||item.executedTests<0||!Number.isSafeInteger(item.passingTests)||item.passingTests<0||item.passingTests>item.executedTests||typeof item.reportedPassed!=='boolean'||(item.reportedPassed&&item.passingTests<item.minimumPassingTests))throw new CsoError('INVALID_SCHEMA','Assertion witness execution outcome is malformed');return{commandHash:hash(item.commandHash,'assertion witness commandHash'),exitCode:item.exitCode,outputHash:hash(item.outputHash,'assertion witness outputHash'),minimumPassingTests:item.minimumPassingTests,executedTests:item.executedTests,passingTests:item.passingTests,reportedPassed:item.reportedPassed};});
|
||||
if(v.diagnosticTestsPassed!==executions.every(item=>item.reportedPassed))throw new CsoError('INCOMPATIBLE_INPUT','Assertion witness diagnostic summary does not match its executions');
|
||||
const receipt:AssertionWitnessReceipt={schemaVersion:1,binding,keyId:v.keyId,publicKey:v.publicKey,observationHash:v.observationHash,externalAssertionsPassed:v.externalAssertionsPassed,diagnosticTestsPassed:v.diagnosticTestsPassed,executions,signature:v.signature};
|
||||
let valid=false;try{valid=verify(null,Buffer.from(canonical(receiptUnsigned(receipt))),createPublicKey({key:Buffer.from(receipt.publicKey,'hex'),format:'der',type:'spki'}),Buffer.from(receipt.signature,'hex'));}catch{}
|
||||
if(!valid)throw new CsoError('INCOMPATIBLE_INPUT','Assertion witness signature is invalid');return receipt;
|
||||
function observationForReceipt(
|
||||
observation: VerificationObservation,
|
||||
binding: AssertionWitnessBinding,
|
||||
diagnosticTestsPassed: boolean,
|
||||
): VerificationObservation {
|
||||
const checked = validateVerificationObservation(observation);
|
||||
return { ...checked, existingTests: diagnosticTestsPassed, inputHash: binding.harnessHash };
|
||||
}
|
||||
export function witnessObservationHash(observation: VerificationObservation): string {
|
||||
return sha256(canonical(validateVerificationObservation(observation)));
|
||||
}
|
||||
|
||||
export function validateAssertionWitnessReceipt(value:unknown,expected:AssertionWitnessBinding,expectedPublicKey:string,observation:VerificationObservation,now=Date.now()):AssertionWitnessReceipt{
|
||||
const receipt=validateStoredAssertionWitnessReceipt(value),binding=validateAssertionWitnessBinding(expected);
|
||||
if(canonical(receipt.binding)!==canonical(binding)||receipt.publicKey!==expectedPublicKey)throw new CsoError('INCOMPATIBLE_INPUT','Assertion witness receipt does not bind this verification challenge');
|
||||
if(now<Date.parse(binding.issuedAt)||now>Date.parse(binding.expiresAt))throw new CsoError('INCOMPATIBLE_INPUT','Assertion witness receipt is stale');
|
||||
const normalized=observationForReceipt(observation,binding,receipt.diagnosticTestsPassed),external=normalized.booted&&normalized.legitimate&&normalized.security!=='inconclusive';
|
||||
if(receipt.observationHash!==witnessObservationHash(normalized)||receipt.externalAssertionsPassed!==external)throw new CsoError('INCOMPATIBLE_INPUT','Assertion witness receipt does not bind the external verifier observation');
|
||||
export function validateStoredAssertionWitnessReceipt(value: unknown): AssertionWitnessReceipt {
|
||||
const v = object(value, 'assertion witness receipt'),
|
||||
binding = validateAssertionWitnessBinding(v.binding);
|
||||
exact(
|
||||
v,
|
||||
[
|
||||
'schemaVersion',
|
||||
'binding',
|
||||
'keyId',
|
||||
'publicKey',
|
||||
'observationHash',
|
||||
'externalAssertionsPassed',
|
||||
'diagnosticTestsPassed',
|
||||
'executions',
|
||||
'signature',
|
||||
],
|
||||
'assertion witness receipt',
|
||||
);
|
||||
if (
|
||||
v.schemaVersion !== 1 ||
|
||||
typeof v.keyId !== 'string' ||
|
||||
!HASH.test(v.keyId) ||
|
||||
typeof v.publicKey !== 'string' ||
|
||||
!PUBLIC_KEY.test(v.publicKey) ||
|
||||
typeof v.signature !== 'string' ||
|
||||
!SIGNATURE.test(v.signature)
|
||||
)
|
||||
throw new CsoError('INVALID_SCHEMA', 'Assertion witness cryptographic metadata is invalid');
|
||||
if (sha256(Buffer.from(v.publicKey, 'hex')) !== v.keyId)
|
||||
throw new CsoError('INCOMPATIBLE_INPUT', 'Assertion witness key identity does not match its public key');
|
||||
if (
|
||||
typeof v.observationHash !== 'string' ||
|
||||
!HASH.test(v.observationHash) ||
|
||||
typeof v.externalAssertionsPassed !== 'boolean' ||
|
||||
typeof v.diagnosticTestsPassed !== 'boolean' ||
|
||||
!Array.isArray(v.executions) ||
|
||||
!v.executions.length ||
|
||||
v.executions.length > 100
|
||||
)
|
||||
throw new CsoError('INVALID_SCHEMA', 'Assertion witness outcomes are malformed');
|
||||
const executions = v.executions.map((raw: any, index: number) => {
|
||||
const item = object(raw, `assertion witness execution ${index}`);
|
||||
exact(
|
||||
item,
|
||||
[
|
||||
'commandHash',
|
||||
'exitCode',
|
||||
'outputHash',
|
||||
'minimumPassingTests',
|
||||
'executedTests',
|
||||
'passingTests',
|
||||
'reportedPassed',
|
||||
],
|
||||
`assertion witness execution ${index}`,
|
||||
);
|
||||
if (
|
||||
!Number.isSafeInteger(item.exitCode) ||
|
||||
item.exitCode < -1 ||
|
||||
item.exitCode > 255 ||
|
||||
!Number.isSafeInteger(item.minimumPassingTests) ||
|
||||
item.minimumPassingTests < 1 ||
|
||||
!Number.isSafeInteger(item.executedTests) ||
|
||||
item.executedTests < 0 ||
|
||||
!Number.isSafeInteger(item.passingTests) ||
|
||||
item.passingTests < 0 ||
|
||||
item.passingTests > item.executedTests ||
|
||||
typeof item.reportedPassed !== 'boolean' ||
|
||||
(item.reportedPassed && item.passingTests < item.minimumPassingTests)
|
||||
)
|
||||
throw new CsoError('INVALID_SCHEMA', 'Assertion witness execution outcome is malformed');
|
||||
return {
|
||||
commandHash: hash(item.commandHash, 'assertion witness commandHash'),
|
||||
exitCode: item.exitCode,
|
||||
outputHash: hash(item.outputHash, 'assertion witness outputHash'),
|
||||
minimumPassingTests: item.minimumPassingTests,
|
||||
executedTests: item.executedTests,
|
||||
passingTests: item.passingTests,
|
||||
reportedPassed: item.reportedPassed,
|
||||
};
|
||||
});
|
||||
if (v.diagnosticTestsPassed !== executions.every((item) => item.reportedPassed))
|
||||
throw new CsoError(
|
||||
'INCOMPATIBLE_INPUT',
|
||||
'Assertion witness diagnostic summary does not match its executions',
|
||||
);
|
||||
const receipt: AssertionWitnessReceipt = {
|
||||
schemaVersion: 1,
|
||||
binding,
|
||||
keyId: v.keyId,
|
||||
publicKey: v.publicKey,
|
||||
observationHash: v.observationHash,
|
||||
externalAssertionsPassed: v.externalAssertionsPassed,
|
||||
diagnosticTestsPassed: v.diagnosticTestsPassed,
|
||||
executions,
|
||||
signature: v.signature,
|
||||
};
|
||||
let valid = false;
|
||||
try {
|
||||
valid = verify(
|
||||
null,
|
||||
Buffer.from(canonical(receiptUnsigned(receipt))),
|
||||
createPublicKey({ key: Buffer.from(receipt.publicKey, 'hex'), format: 'der', type: 'spki' }),
|
||||
Buffer.from(receipt.signature, 'hex'),
|
||||
);
|
||||
} catch {}
|
||||
if (!valid) throw new CsoError('INCOMPATIBLE_INPUT', 'Assertion witness signature is invalid');
|
||||
return receipt;
|
||||
}
|
||||
|
||||
export function assertionWitnessSemanticValue(receipt:AssertionWitnessReceipt):unknown{
|
||||
const checked=validateStoredAssertionWitnessReceipt(receipt),{nonce:_,issuedAt:__,expiresAt:___,...stable}=checked.binding;
|
||||
return{binding:stable,observationHash:checked.observationHash,externalAssertionsPassed:checked.externalAssertionsPassed,diagnosticTestsPassed:checked.diagnosticTestsPassed,executions:checked.executions};
|
||||
}
|
||||
export function assertionWitnessPairHash(pair:{before:AssertionWitnessReceipt;after:AssertionWitnessReceipt}):string{
|
||||
return sha256(canonical({before:assertionWitnessSemanticValue(pair.before),after:assertionWitnessSemanticValue(pair.after)}));
|
||||
export function validateAssertionWitnessReceipt(
|
||||
value: unknown,
|
||||
expected: AssertionWitnessBinding,
|
||||
expectedPublicKey: string,
|
||||
observation: VerificationObservation,
|
||||
now = Date.now(),
|
||||
): AssertionWitnessReceipt {
|
||||
const receipt = validateStoredAssertionWitnessReceipt(value),
|
||||
binding = validateAssertionWitnessBinding(expected);
|
||||
if (canonical(receipt.binding) !== canonical(binding) || receipt.publicKey !== expectedPublicKey)
|
||||
throw new CsoError(
|
||||
'INCOMPATIBLE_INPUT',
|
||||
'Assertion witness receipt does not bind this verification challenge',
|
||||
);
|
||||
if (now < Date.parse(binding.issuedAt) || now > Date.parse(binding.expiresAt))
|
||||
throw new CsoError('INCOMPATIBLE_INPUT', 'Assertion witness receipt is stale');
|
||||
const normalized = observationForReceipt(observation, binding, receipt.diagnosticTestsPassed),
|
||||
external = normalized.booted && normalized.legitimate && normalized.security !== 'inconclusive';
|
||||
if (
|
||||
receipt.observationHash !== witnessObservationHash(normalized) ||
|
||||
receipt.externalAssertionsPassed !== external
|
||||
)
|
||||
throw new CsoError(
|
||||
'INCOMPATIBLE_INPUT',
|
||||
'Assertion witness receipt does not bind the external verifier observation',
|
||||
);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
function witnessReplayValue(receipt:AssertionWitnessReceipt):unknown{
|
||||
const checked=validateStoredAssertionWitnessReceipt(receipt),{nonce:_,issuedAt:__,expiresAt:___,...binding}=checked.binding;
|
||||
return{binding,externalAssertionsPassed:checked.externalAssertionsPassed,diagnosticTestsPassed:checked.diagnosticTestsPassed,
|
||||
executions:checked.executions.map(({outputHash:_,...execution})=>execution)};
|
||||
export function assertionWitnessSemanticValue(receipt: AssertionWitnessReceipt): unknown {
|
||||
const checked = validateStoredAssertionWitnessReceipt(receipt),
|
||||
{ nonce: _, issuedAt: __, expiresAt: ___, ...stable } = checked.binding;
|
||||
return {
|
||||
binding: stable,
|
||||
observationHash: checked.observationHash,
|
||||
externalAssertionsPassed: checked.externalAssertionsPassed,
|
||||
diagnosticTestsPassed: checked.diagnosticTestsPassed,
|
||||
executions: checked.executions,
|
||||
};
|
||||
}
|
||||
export function assertionWitnessReplayHash(pair:{before:AssertionWitnessReceipt;after:AssertionWitnessReceipt}):string{
|
||||
return sha256(canonical({before:witnessReplayValue(pair.before),after:witnessReplayValue(pair.after)}));
|
||||
export function assertionWitnessPairHash(pair: {
|
||||
before: AssertionWitnessReceipt;
|
||||
after: AssertionWitnessReceipt;
|
||||
}): string {
|
||||
return sha256(
|
||||
canonical({
|
||||
before: assertionWitnessSemanticValue(pair.before),
|
||||
after: assertionWitnessSemanticValue(pair.after),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
interface TestExecutionSummary {executedTests:number;passingTests:number;reportedPassed:boolean}
|
||||
function testExecutionSummary(command:Command,code:number,output:string,minimumPassingTests=1):TestExecutionSummary{
|
||||
const failed={executedTests:0,passingTests:0,reportedPassed:false};
|
||||
if(!Number.isInteger(minimumPassingTests)||minimumPassingTests<1||code!==0||!output||output.includes('[sensitive process output redacted]'))return failed;
|
||||
const clean=output.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g,''),args=command.args,name=basename(command.executable),json=()=>{const end=clean.lastIndexOf('}');if(end<0)return undefined;for(let start=clean.lastIndexOf('{',end);start>=0;start=clean.lastIndexOf('{',start-1)){try{const value=JSON.parse(clean.slice(start,end+1));if(value&&typeof value==='object')return value;}catch{}}};
|
||||
let executedTests=0,passingTests=0,valid=false;
|
||||
if(name==='node'&&args.includes('--test')&&args.includes('--test-reporter=tap')){const paths=args.filter(arg=>arg.startsWith('./')).map(arg=>arg.slice(2)),registered=[...clean.matchAll(/^# Subtest:\s+(.+?)\s*$/gm)].map(match=>match[1]),isPathWrapper=(label:string)=>paths.some(path=>label===path||label.endsWith(`/${path}`));executedTests=Number(clean.match(/^# tests\s+(\d+)\s*$/m)?.[1]);passingTests=Number(clean.match(/^# pass\s+(\d+)\s*$/m)?.[1]);valid=registered.some(label=>!isPathWrapper(label))&&!registered.some(isPathWrapper)&&executedTests>=passingTests&&/^# fail\s+0\s*$/m.test(clean)&&/^# cancelled\s+0\s*$/m.test(clean);}
|
||||
else if(name==='bun'&&args.includes('test')){passingTests=Number(clean.match(/^\s*(\d+)\s+pass(?:es)?\s*$/mi)?.[1]);executedTests=Number(clean.match(/\bRan\s+(\d+)\s+tests?\b/i)?.[1]);valid=executedTests>=passingTests&&/^\s*0\s+fail(?:ures?)?\s*$/mi.test(clean);}
|
||||
else if(name==='jest'&&args.includes('--json')){const value=json();passingTests=Number(value?.numPassedTests);executedTests=Number(value?.numTotalTests);valid=value?.success===true&&value?.numFailedTests===0&&value?.numRuntimeErrorTestSuites===0&&executedTests>=passingTests;}
|
||||
else if(name==='vitest'&&args.includes('--reporter=verbose')){const match=clean.match(/^\s*Tests\s+.*?(\d+)\s+passed.*?\((\d+)\)\s*$/mi);passingTests=Number(match?.[1]);executedTests=Number(match?.[2]);valid=executedTests>=passingTests&&!/\b\d+\s+failed\b/i.test(match?.[0]??'');}
|
||||
else if(name==='mocha'&&args.includes('json')){const stats=json()?.stats;passingTests=Number(stats?.passes);executedTests=Number(stats?.tests);valid=stats?.failures===0&&Number.isSafeInteger(stats?.pending)&&executedTests===passingTests+stats.pending;}
|
||||
else if(name==='ava'&&args.includes('--tap')){executedTests=Number(clean.match(/^# tests\s+(\d+)\s*$/m)?.[1]);passingTests=Number(clean.match(/^# pass\s+(\d+)\s*$/m)?.[1]);valid=executedTests>=passingTests&&/^# fail\s+0\s*$/m.test(clean);}
|
||||
else if(name==='python'&&args.some(arg=>arg.includes('import pytest;')&&arg.includes('pytest.main'))){passingTests=Number(clean.match(/(?:^|\s)(\d+)\s+passed\b/i)?.[1]);const skipped=Number(clean.match(/(?:^|\s)(\d+)\s+skipped\b/i)?.[1]??0);executedTests=passingTests+skipped;valid=true;}
|
||||
else if(name==='python'&&args.some(arg=>arg.includes('import os,sys,unittest;')&&arg.includes('unittest.main'))){executedTests=Number(clean.match(/\bRan\s+(\d+)\s+tests?\b/i)?.[1]);const skipped=Number(clean.match(/\bskipped=(\d+)\b/i)?.[1]??0);passingTests=executedTests-skipped;valid=Number.isSafeInteger(skipped);}
|
||||
else if(name==='bundle'&&args[0]==='exec'&&args[1]==='rspec'&&args.includes('json')){const summary=json()?.summary,pending=Number(summary?.pending_count??0);executedTests=Number(summary?.example_count);passingTests=executedTests-pending;valid=Number.isSafeInteger(pending)&&summary?.failure_count===0&&(summary?.errors_outside_of_examples_count??0)===0;}
|
||||
else if(name==='bundle'&&args[0]==='exec'&&args[1]==='rails'&&args[2]==='test'&&args.includes('--no-color')){const match=clean.match(/\b(\d+)\s+runs?\s*,\s*(\d+)\s+assertions?\s*,\s*0\s+failures?\s*,\s*0\s+errors?\s*,\s*(\d+)\s+skips?\b/i),skipped=Number(match?.[3]);executedTests=Number(match?.[1]);passingTests=executedTests-skipped;valid=Number.isSafeInteger(skipped);}
|
||||
const countsValid=Number.isSafeInteger(executedTests)&&executedTests>=0&&Number.isSafeInteger(passingTests)&&passingTests>=0&&executedTests>=passingTests;
|
||||
return countsValid?{executedTests,passingTests,reportedPassed:valid&&passingTests>=minimumPassingTests}:failed;
|
||||
function witnessReplayValue(receipt: AssertionWitnessReceipt): unknown {
|
||||
const checked = validateStoredAssertionWitnessReceipt(receipt),
|
||||
{ nonce: _, issuedAt: __, expiresAt: ___, ...binding } = checked.binding;
|
||||
return {
|
||||
binding,
|
||||
externalAssertionsPassed: checked.externalAssertionsPassed,
|
||||
diagnosticTestsPassed: checked.diagnosticTestsPassed,
|
||||
executions: checked.executions.map(({ outputHash: _, ...execution }) => execution),
|
||||
};
|
||||
}
|
||||
export function testExecutionPassed(command:Command,code:number,output:string,minimumPassingTests=1):boolean{
|
||||
return testExecutionSummary(command,code,output,minimumPassingTests).reportedPassed;
|
||||
export function assertionWitnessReplayHash(pair: {
|
||||
before: AssertionWitnessReceipt;
|
||||
after: AssertionWitnessReceipt;
|
||||
}): string {
|
||||
return sha256(
|
||||
canonical({ before: witnessReplayValue(pair.before), after: witnessReplayValue(pair.after) }),
|
||||
);
|
||||
}
|
||||
|
||||
interface ChildRequest {privateKey:string;publicKey:string;binding:AssertionWitnessBinding;observation:VerificationObservation;executions:WitnessTestExecution[]}
|
||||
async function readChildInput():Promise<string>{const chunks:Buffer[]=[];let bytes=0;for await(const value of process.stdin){const chunk=Buffer.from(value);bytes+=chunk.length;if(bytes>2*MAX_OUTPUT)throw new CsoError('INVALID_SCHEMA','Assertion witness request exceeds the bounded input limit');chunks.push(chunk);}return Buffer.concat(chunks).toString('utf8');}
|
||||
function createReceipt(input:unknown):AssertionWitnessReceipt{
|
||||
const v=object(input,'assertion witness child request');exact(v,['privateKey','publicKey','binding','observation','executions'],'assertion witness child request');const binding=validateAssertionWitnessBinding(v.binding);
|
||||
if(Date.now()<Date.parse(binding.issuedAt)||Date.now()>Date.parse(binding.expiresAt))throw new CsoError('INCOMPATIBLE_INPUT','Assertion witness challenge is stale');
|
||||
if(typeof v.privateKey!=='string'||v.privateKey.length>4096||typeof v.publicKey!=='string'||!PUBLIC_KEY.test(v.publicKey))throw new CsoError('INVALID_SCHEMA','Assertion witness signing input is invalid');
|
||||
let privateKey;try{privateKey=createPrivateKey(v.privateKey);const derived=createPublicKey(privateKey).export({format:'der',type:'spki'}).toString('hex');if(derived!==v.publicKey)throw new Error();}catch{throw new CsoError('INCOMPATIBLE_INPUT','Assertion witness signing authority does not match the challenge');}
|
||||
const rawObservation=validateVerificationObservation(v.observation);if(!Array.isArray(v.executions)||!v.executions.length||v.executions.length>100)throw new CsoError('INVALID_SCHEMA','Assertion witness needs one or more canonical test executions');
|
||||
let outputBytes=0;const rawExecutions:WitnessTestExecution[]=v.executions.map((raw:any,index:number)=>{const item=object(raw,`witness execution ${index}`);exact(item,['command','code','output','minimumPassingTests'],`witness execution ${index}`);const command=validateCommand(item.command,`witness execution ${index}.command`);if(!Number.isSafeInteger(item.code)||item.code<-1||item.code>255||typeof item.output!=='string'||item.output.includes('\0')||!Number.isSafeInteger(item.minimumPassingTests)||item.minimumPassingTests<1)throw new CsoError('INVALID_SCHEMA','Assertion witness test execution is malformed');outputBytes+=Buffer.byteLength(item.output);if(outputBytes>MAX_OUTPUT)throw new CsoError('INVALID_SCHEMA','Assertion witness test output exceeds the group capture limit');return{command,code:item.code,output:item.output,minimumPassingTests:item.minimumPassingTests};});
|
||||
if(sha256(canonical(rawExecutions.map(item=>item.command)))!==binding.runner.commandsHash||sha256(canonical(rawExecutions.map(item=>item.minimumPassingTests)))!==binding.runner.minimumPassingTestsHash)throw new CsoError('INCOMPATIBLE_INPUT','Assertion witness executions do not match the helper-derived runner');
|
||||
const executions=rawExecutions.map(item=>{const summary=testExecutionSummary(item.command,item.code,item.output,item.minimumPassingTests);return{commandHash:sha256(canonical(item.command)),exitCode:item.code,outputHash:sha256(item.output),minimumPassingTests:item.minimumPassingTests,...summary};}),diagnosticTestsPassed=executions.every(item=>item.reportedPassed),observation=observationForReceipt(rawObservation,binding,diagnosticTestsPassed),externalAssertionsPassed=observation.booted&&observation.legitimate&&observation.security!=='inconclusive';
|
||||
const unsigned:Omit<AssertionWitnessReceipt,'signature'>={schemaVersion:1,binding,keyId:sha256(Buffer.from(v.publicKey,'hex')),publicKey:v.publicKey,observationHash:witnessObservationHash(observation),externalAssertionsPassed,diagnosticTestsPassed,executions};
|
||||
return{...unsigned,signature:sign(null,Buffer.from(canonical(unsigned)),privateKey).toString('hex')};
|
||||
interface TestExecutionSummary {
|
||||
executedTests: number;
|
||||
passingTests: number;
|
||||
reportedPassed: boolean;
|
||||
}
|
||||
function testExecutionSummary(
|
||||
command: Command,
|
||||
code: number,
|
||||
output: string,
|
||||
minimumPassingTests = 1,
|
||||
): TestExecutionSummary {
|
||||
const failed = { executedTests: 0, passingTests: 0, reportedPassed: false };
|
||||
if (
|
||||
!Number.isInteger(minimumPassingTests) ||
|
||||
minimumPassingTests < 1 ||
|
||||
code !== 0 ||
|
||||
!output ||
|
||||
output.includes('[sensitive process output redacted]')
|
||||
)
|
||||
return failed;
|
||||
const clean = output.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, ''),
|
||||
args = command.args,
|
||||
name = basename(command.executable),
|
||||
json = () => {
|
||||
const end = clean.lastIndexOf('}');
|
||||
if (end < 0) return undefined;
|
||||
for (let start = clean.lastIndexOf('{', end); start >= 0; start = clean.lastIndexOf('{', start - 1)) {
|
||||
try {
|
||||
const value = JSON.parse(clean.slice(start, end + 1));
|
||||
if (value && typeof value === 'object') return value;
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
let executedTests = 0,
|
||||
passingTests = 0,
|
||||
valid = false;
|
||||
if (name === 'node' && args.includes('--test') && args.includes('--test-reporter=tap')) {
|
||||
const paths = args.filter((arg) => arg.startsWith('./')).map((arg) => arg.slice(2)),
|
||||
registered = [...clean.matchAll(/^# Subtest:\s+(.+?)\s*$/gm)].map((match) => match[1]),
|
||||
isPathWrapper = (label: string) => paths.some((path) => label === path || label.endsWith(`/${path}`));
|
||||
executedTests = Number(clean.match(/^# tests\s+(\d+)\s*$/m)?.[1]);
|
||||
passingTests = Number(clean.match(/^# pass\s+(\d+)\s*$/m)?.[1]);
|
||||
valid =
|
||||
registered.some((label) => !isPathWrapper(label)) &&
|
||||
!registered.some(isPathWrapper) &&
|
||||
executedTests >= passingTests &&
|
||||
/^# fail\s+0\s*$/m.test(clean) &&
|
||||
/^# cancelled\s+0\s*$/m.test(clean);
|
||||
} else if (name === 'bun' && args.includes('test')) {
|
||||
passingTests = Number(clean.match(/^\s*(\d+)\s+pass(?:es)?\s*$/im)?.[1]);
|
||||
executedTests = Number(clean.match(/\bRan\s+(\d+)\s+tests?\b/i)?.[1]);
|
||||
valid = executedTests >= passingTests && /^\s*0\s+fail(?:ures?)?\s*$/im.test(clean);
|
||||
} else if (name === 'jest' && args.includes('--json')) {
|
||||
const value = json();
|
||||
passingTests = Number(value?.numPassedTests);
|
||||
executedTests = Number(value?.numTotalTests);
|
||||
valid =
|
||||
value?.success === true &&
|
||||
value?.numFailedTests === 0 &&
|
||||
value?.numRuntimeErrorTestSuites === 0 &&
|
||||
executedTests >= passingTests;
|
||||
} else if (name === 'vitest' && args.includes('--reporter=verbose')) {
|
||||
const match = clean.match(/^\s*Tests\s+.*?(\d+)\s+passed.*?\((\d+)\)\s*$/im);
|
||||
passingTests = Number(match?.[1]);
|
||||
executedTests = Number(match?.[2]);
|
||||
valid = executedTests >= passingTests && !/\b\d+\s+failed\b/i.test(match?.[0] ?? '');
|
||||
} else if (name === 'mocha' && args.includes('json')) {
|
||||
const stats = json()?.stats;
|
||||
passingTests = Number(stats?.passes);
|
||||
executedTests = Number(stats?.tests);
|
||||
valid =
|
||||
stats?.failures === 0 &&
|
||||
Number.isSafeInteger(stats?.pending) &&
|
||||
executedTests === passingTests + stats.pending;
|
||||
} else if (name === 'ava' && args.includes('--tap')) {
|
||||
executedTests = Number(clean.match(/^# tests\s+(\d+)\s*$/m)?.[1]);
|
||||
passingTests = Number(clean.match(/^# pass\s+(\d+)\s*$/m)?.[1]);
|
||||
valid = executedTests >= passingTests && /^# fail\s+0\s*$/m.test(clean);
|
||||
} else if (
|
||||
name === 'python' &&
|
||||
args.some((arg) => arg.includes('import pytest;') && arg.includes('pytest.main'))
|
||||
) {
|
||||
passingTests = Number(clean.match(/(?:^|\s)(\d+)\s+passed\b/i)?.[1]);
|
||||
const skipped = Number(clean.match(/(?:^|\s)(\d+)\s+skipped\b/i)?.[1] ?? 0);
|
||||
executedTests = passingTests + skipped;
|
||||
valid = true;
|
||||
} else if (
|
||||
name === 'python' &&
|
||||
args.some((arg) => arg.includes('import os,sys,unittest;') && arg.includes('unittest.main'))
|
||||
) {
|
||||
executedTests = Number(clean.match(/\bRan\s+(\d+)\s+tests?\b/i)?.[1]);
|
||||
const skipped = Number(clean.match(/\bskipped=(\d+)\b/i)?.[1] ?? 0);
|
||||
passingTests = executedTests - skipped;
|
||||
valid = Number.isSafeInteger(skipped);
|
||||
} else if (name === 'bundle' && args[0] === 'exec' && args[1] === 'rspec' && args.includes('json')) {
|
||||
const summary = json()?.summary,
|
||||
pending = Number(summary?.pending_count ?? 0);
|
||||
executedTests = Number(summary?.example_count);
|
||||
passingTests = executedTests - pending;
|
||||
valid =
|
||||
Number.isSafeInteger(pending) &&
|
||||
summary?.failure_count === 0 &&
|
||||
(summary?.errors_outside_of_examples_count ?? 0) === 0;
|
||||
} else if (
|
||||
name === 'bundle' &&
|
||||
args[0] === 'exec' &&
|
||||
args[1] === 'rails' &&
|
||||
args[2] === 'test' &&
|
||||
args.includes('--no-color')
|
||||
) {
|
||||
const match = clean.match(
|
||||
/\b(\d+)\s+runs?\s*,\s*(\d+)\s+assertions?\s*,\s*0\s+failures?\s*,\s*0\s+errors?\s*,\s*(\d+)\s+skips?\b/i,
|
||||
),
|
||||
skipped = Number(match?.[3]);
|
||||
executedTests = Number(match?.[1]);
|
||||
passingTests = executedTests - skipped;
|
||||
valid = Number.isSafeInteger(skipped);
|
||||
}
|
||||
const countsValid =
|
||||
Number.isSafeInteger(executedTests) &&
|
||||
executedTests >= 0 &&
|
||||
Number.isSafeInteger(passingTests) &&
|
||||
passingTests >= 0 &&
|
||||
executedTests >= passingTests;
|
||||
return countsValid
|
||||
? { executedTests, passingTests, reportedPassed: valid && passingTests >= minimumPassingTests }
|
||||
: failed;
|
||||
}
|
||||
export function testExecutionPassed(
|
||||
command: Command,
|
||||
code: number,
|
||||
output: string,
|
||||
minimumPassingTests = 1,
|
||||
): boolean {
|
||||
return testExecutionSummary(command, code, output, minimumPassingTests).reportedPassed;
|
||||
}
|
||||
|
||||
export async function runAssertionWitnessChild():Promise<void>{const receipt=createReceipt(JSON.parse(await readChildInput()));process.stdout.write(JSON.stringify(receipt)+'\n');}
|
||||
interface ChildRequest {
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
binding: AssertionWitnessBinding;
|
||||
observation: VerificationObservation;
|
||||
executions: WitnessTestExecution[];
|
||||
}
|
||||
async function readChildInput(): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
for await (const value of process.stdin) {
|
||||
const chunk = Buffer.from(value);
|
||||
bytes += chunk.length;
|
||||
if (bytes > 2 * MAX_OUTPUT)
|
||||
throw new CsoError('INVALID_SCHEMA', 'Assertion witness request exceeds the bounded input limit');
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
function createReceipt(input: unknown): AssertionWitnessReceipt {
|
||||
const v = object(input, 'assertion witness child request');
|
||||
exact(
|
||||
v,
|
||||
['privateKey', 'publicKey', 'binding', 'observation', 'executions'],
|
||||
'assertion witness child request',
|
||||
);
|
||||
const binding = validateAssertionWitnessBinding(v.binding);
|
||||
if (Date.now() < Date.parse(binding.issuedAt) || Date.now() > Date.parse(binding.expiresAt))
|
||||
throw new CsoError('INCOMPATIBLE_INPUT', 'Assertion witness challenge is stale');
|
||||
if (
|
||||
typeof v.privateKey !== 'string' ||
|
||||
v.privateKey.length > 4096 ||
|
||||
typeof v.publicKey !== 'string' ||
|
||||
!PUBLIC_KEY.test(v.publicKey)
|
||||
)
|
||||
throw new CsoError('INVALID_SCHEMA', 'Assertion witness signing input is invalid');
|
||||
let privateKey;
|
||||
try {
|
||||
privateKey = createPrivateKey(v.privateKey);
|
||||
const derived = createPublicKey(privateKey).export({ format: 'der', type: 'spki' }).toString('hex');
|
||||
if (derived !== v.publicKey) throw new Error();
|
||||
} catch {
|
||||
throw new CsoError(
|
||||
'INCOMPATIBLE_INPUT',
|
||||
'Assertion witness signing authority does not match the challenge',
|
||||
);
|
||||
}
|
||||
const rawObservation = validateVerificationObservation(v.observation);
|
||||
if (!Array.isArray(v.executions) || !v.executions.length || v.executions.length > 100)
|
||||
throw new CsoError('INVALID_SCHEMA', 'Assertion witness needs one or more canonical test executions');
|
||||
let outputBytes = 0;
|
||||
const rawExecutions: WitnessTestExecution[] = v.executions.map((raw: any, index: number) => {
|
||||
const item = object(raw, `witness execution ${index}`);
|
||||
exact(item, ['command', 'code', 'output', 'minimumPassingTests'], `witness execution ${index}`);
|
||||
const command = validateCommand(item.command, `witness execution ${index}.command`);
|
||||
if (
|
||||
!Number.isSafeInteger(item.code) ||
|
||||
item.code < -1 ||
|
||||
item.code > 255 ||
|
||||
typeof item.output !== 'string' ||
|
||||
item.output.includes('\0') ||
|
||||
!Number.isSafeInteger(item.minimumPassingTests) ||
|
||||
item.minimumPassingTests < 1
|
||||
)
|
||||
throw new CsoError('INVALID_SCHEMA', 'Assertion witness test execution is malformed');
|
||||
outputBytes += Buffer.byteLength(item.output);
|
||||
if (outputBytes > MAX_OUTPUT)
|
||||
throw new CsoError('INVALID_SCHEMA', 'Assertion witness test output exceeds the group capture limit');
|
||||
return { command, code: item.code, output: item.output, minimumPassingTests: item.minimumPassingTests };
|
||||
});
|
||||
if (
|
||||
sha256(canonical(rawExecutions.map((item) => item.command))) !== binding.runner.commandsHash ||
|
||||
sha256(canonical(rawExecutions.map((item) => item.minimumPassingTests))) !==
|
||||
binding.runner.minimumPassingTestsHash
|
||||
)
|
||||
throw new CsoError(
|
||||
'INCOMPATIBLE_INPUT',
|
||||
'Assertion witness executions do not match the helper-derived runner',
|
||||
);
|
||||
const executions = rawExecutions.map((item) => {
|
||||
const summary = testExecutionSummary(item.command, item.code, item.output, item.minimumPassingTests);
|
||||
return {
|
||||
commandHash: sha256(canonical(item.command)),
|
||||
exitCode: item.code,
|
||||
outputHash: sha256(item.output),
|
||||
minimumPassingTests: item.minimumPassingTests,
|
||||
...summary,
|
||||
};
|
||||
}),
|
||||
diagnosticTestsPassed = executions.every((item) => item.reportedPassed),
|
||||
observation = observationForReceipt(rawObservation, binding, diagnosticTestsPassed),
|
||||
externalAssertionsPassed =
|
||||
observation.booted && observation.legitimate && observation.security !== 'inconclusive';
|
||||
const unsigned: Omit<AssertionWitnessReceipt, 'signature'> = {
|
||||
schemaVersion: 1,
|
||||
binding,
|
||||
keyId: sha256(Buffer.from(v.publicKey, 'hex')),
|
||||
publicKey: v.publicKey,
|
||||
observationHash: witnessObservationHash(observation),
|
||||
externalAssertionsPassed,
|
||||
diagnosticTestsPassed,
|
||||
executions,
|
||||
};
|
||||
return { ...unsigned, signature: sign(null, Buffer.from(canonical(unsigned)), privateKey).toString('hex') };
|
||||
}
|
||||
|
||||
export class AssertionWitnessSession{
|
||||
private privateKey:string;readonly publicKey:string;readonly keyId:string;private nonces=new Set<string>();
|
||||
constructor(private workDirectory:string,private deadline:number){const stat=lstatSync(workDirectory),real=realpathSync(workDirectory),resolved=lstatSync(real);if(!stat.isDirectory()||stat.isSymbolicLink()||!resolved.isDirectory()||resolved.isSymbolicLink()||stat.dev!==resolved.dev||stat.ino!==resolved.ino||(process.getuid&&resolved.uid!==process.getuid())||(resolved.mode&0o022)!==0)throw new CsoError('UNSAFE_PATH','Assertion witness working directory must be private and owned');this.workDirectory=real;const pair=generateKeyPairSync('ed25519');this.privateKey=pair.privateKey.export({format:'pem',type:'pkcs8'}).toString();this.publicKey=pair.publicKey.export({format:'der',type:'spki'}).toString('hex');this.keyId=sha256(Buffer.from(this.publicKey,'hex'));}
|
||||
handle(stable:Omit<AssertionWitnessBinding,'schemaVersion'|'protocol'|'nonce'|'issuedAt'|'expiresAt'>):AssertionWitnessHandle{
|
||||
const now=Date.now(),expires=Math.min(this.deadline,now+MAX_RECEIPT_AGE);if(expires<=now)throw new CsoError('DEADLINE','No time remains for an authenticated assertion witness');let nonce='';do{nonce=randomBytes(32).toString('hex');}while(this.nonces.has(nonce));this.nonces.add(nonce);
|
||||
const binding=validateAssertionWitnessBinding({schemaVersion:1,protocol:PROTOCOL,nonce,issuedAt:new Date(now).toISOString(),expiresAt:new Date(expires).toISOString(),...stable});let consumed=false;
|
||||
return{binding,attest:async(observation,executions)=>{if(consumed)throw new CsoError('INCOMPATIBLE_INPUT','Assertion witness challenge was already consumed');consumed=true;const input=JSON.stringify({privateKey:this.privateKey,publicKey:this.publicKey,binding,observation,executions} satisfies ChildRequest);if(Buffer.byteLength(input)>2*MAX_OUTPUT)throw new CsoError('REDACTION_FAILED','Assertion witness input exceeds the bounded helper channel');const bun=/^bun(?:\.exe)?$/i.test(basename(process.execPath)),file=bun?process.execPath:join(dirname(process.execPath),process.platform==='win32'?'gstack-cso-launcher.exe':'gstack-cso-launcher'),args=bun?[import.meta.path,'--child']:['__cso-assertion-witness'],env=process.platform==='win32'?{PATH:dirname(process.execPath),SYSTEMROOT:process.env.SYSTEMROOT??'C:\\Windows',WINDIR:process.env.WINDIR??'C:\\Windows'}:{PATH:'/usr/bin:/bin',LANG:'C.UTF-8',LC_ALL:'C.UTF-8',TZ:'UTC'},result=await runProcess(file,args,{cwd:this.workDirectory,env,timeoutMs:Math.max(1,expires-Date.now()),maxBytes:128*1024,input,raw:true});if(result.timedOut)throw new CsoError('DEADLINE','Assertion witness exceeded the verification deadline');if(result.truncated||result.code!==0)throw new CsoError('TOOL_FAILED','Authenticated assertion witness did not return a bounded receipt');let receipt:unknown;try{receipt=JSON.parse(result.stdout);}catch{throw new CsoError('TOOL_FAILED','Authenticated assertion witness returned invalid output');}return validateAssertionWitnessReceipt(receipt,binding,this.publicKey,observation);},validate:(receipt,observation,current=Date.now())=>validateAssertionWitnessReceipt(receipt,binding,this.publicKey,observation,current)};
|
||||
export async function runAssertionWitnessChild(): Promise<void> {
|
||||
const receipt = createReceipt(JSON.parse(await readChildInput()));
|
||||
process.stdout.write(JSON.stringify(receipt) + '\n');
|
||||
}
|
||||
|
||||
export class AssertionWitnessSession {
|
||||
private privateKey: string;
|
||||
readonly publicKey: string;
|
||||
readonly keyId: string;
|
||||
private nonces = new Set<string>();
|
||||
constructor(
|
||||
private workDirectory: string,
|
||||
private deadline: number,
|
||||
) {
|
||||
const stat = lstatSync(workDirectory),
|
||||
real = realpathSync(workDirectory),
|
||||
resolved = lstatSync(real);
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
!resolved.isDirectory() ||
|
||||
resolved.isSymbolicLink() ||
|
||||
stat.dev !== resolved.dev ||
|
||||
stat.ino !== resolved.ino ||
|
||||
(process.getuid && resolved.uid !== process.getuid()) ||
|
||||
(resolved.mode & 0o022) !== 0
|
||||
)
|
||||
throw new CsoError('UNSAFE_PATH', 'Assertion witness working directory must be private and owned');
|
||||
this.workDirectory = real;
|
||||
const pair = generateKeyPairSync('ed25519');
|
||||
this.privateKey = pair.privateKey.export({ format: 'pem', type: 'pkcs8' }).toString();
|
||||
this.publicKey = pair.publicKey.export({ format: 'der', type: 'spki' }).toString('hex');
|
||||
this.keyId = sha256(Buffer.from(this.publicKey, 'hex'));
|
||||
}
|
||||
handle(
|
||||
stable: Omit<AssertionWitnessBinding, 'schemaVersion' | 'protocol' | 'nonce' | 'issuedAt' | 'expiresAt'>,
|
||||
): AssertionWitnessHandle {
|
||||
const now = Date.now(),
|
||||
expires = Math.min(this.deadline, now + MAX_RECEIPT_AGE);
|
||||
if (expires <= now)
|
||||
throw new CsoError('DEADLINE', 'No time remains for an authenticated assertion witness');
|
||||
let nonce = '';
|
||||
do {
|
||||
nonce = randomBytes(32).toString('hex');
|
||||
} while (this.nonces.has(nonce));
|
||||
this.nonces.add(nonce);
|
||||
const binding = validateAssertionWitnessBinding({
|
||||
schemaVersion: 1,
|
||||
protocol: PROTOCOL,
|
||||
nonce,
|
||||
issuedAt: new Date(now).toISOString(),
|
||||
expiresAt: new Date(expires).toISOString(),
|
||||
...stable,
|
||||
});
|
||||
let consumed = false;
|
||||
return {
|
||||
binding,
|
||||
attest: async (observation, executions) => {
|
||||
if (consumed)
|
||||
throw new CsoError('INCOMPATIBLE_INPUT', 'Assertion witness challenge was already consumed');
|
||||
consumed = true;
|
||||
const input = JSON.stringify({
|
||||
privateKey: this.privateKey,
|
||||
publicKey: this.publicKey,
|
||||
binding,
|
||||
observation,
|
||||
executions,
|
||||
} satisfies ChildRequest);
|
||||
if (Buffer.byteLength(input) > 2 * MAX_OUTPUT)
|
||||
throw new CsoError(
|
||||
'REDACTION_FAILED',
|
||||
'Assertion witness input exceeds the bounded helper channel',
|
||||
);
|
||||
const bun = /^bun(?:\.exe)?$/i.test(basename(process.execPath)),
|
||||
file = bun
|
||||
? process.execPath
|
||||
: join(
|
||||
dirname(process.execPath),
|
||||
process.platform === 'win32' ? 'gstack-cso-launcher.exe' : 'gstack-cso-launcher',
|
||||
),
|
||||
args = bun ? [import.meta.path, '--child'] : ['__cso-assertion-witness'],
|
||||
env =
|
||||
process.platform === 'win32'
|
||||
? {
|
||||
PATH: dirname(process.execPath),
|
||||
SYSTEMROOT: process.env.SYSTEMROOT ?? 'C:\\Windows',
|
||||
WINDIR: process.env.WINDIR ?? 'C:\\Windows',
|
||||
}
|
||||
: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', TZ: 'UTC' },
|
||||
result = await runProcess(file, args, {
|
||||
cwd: this.workDirectory,
|
||||
env,
|
||||
timeoutMs: Math.max(1, expires - Date.now()),
|
||||
maxBytes: 128 * 1024,
|
||||
input,
|
||||
raw: true,
|
||||
});
|
||||
if (result.timedOut)
|
||||
throw new CsoError('DEADLINE', 'Assertion witness exceeded the verification deadline');
|
||||
if (result.truncated || result.code !== 0)
|
||||
throw new CsoError(
|
||||
'TOOL_FAILED',
|
||||
'Authenticated assertion witness did not return a bounded receipt',
|
||||
);
|
||||
let receipt: unknown;
|
||||
try {
|
||||
receipt = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
throw new CsoError('TOOL_FAILED', 'Authenticated assertion witness returned invalid output');
|
||||
}
|
||||
return validateAssertionWitnessReceipt(receipt, binding, this.publicKey, observation);
|
||||
},
|
||||
validate: (receipt, observation, current = Date.now()) =>
|
||||
validateAssertionWitnessReceipt(receipt, binding, this.publicKey, observation, current),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if(import.meta.main&&process.argv.at(-1)==='--child')runAssertionWitnessChild().catch(()=>{process.stderr.write('assertion witness failed\n');process.exitCode=1;});
|
||||
if (import.meta.main && process.argv.at(-1) === '--child')
|
||||
runAssertionWitnessChild().catch(() => {
|
||||
process.stderr.write('assertion witness failed\n');
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in new issue
Block a user