85 lines
5.2 KiB
JavaScript
85 lines
5.2 KiB
JavaScript
import test from 'node:test';
|
|||
|
|
import assert from 'node:assert/strict';
|
||
|
|
import {mkdtemp,mkdir,writeFile,rm,symlink,open} from 'node:fs/promises';
|
||
|
|
import {writeFileSync,utimesSync} from 'node:fs';
|
||
|
|
import os from 'node:os';
|
||
|
|
import path from 'node:path';
|
||
|
|
import {buildBrokerPackageManifest,verifyBrokerPackage} from '../scripts/broker-package-contract.mjs';
|
||
|
|
|
||
|
|
const release={version:'0.1.0',protocol:1,entrypoint:'broker.exe'};
|
||
|
|
async function fixture(t){
|
||
|
|
const root=await mkdtemp(path.join(os.tmpdir(),'safetynet-broker-contract-'));
|
||
|
|
t.after(()=>rm(root,{recursive:true,force:true}));
|
||
|
|
await mkdir(path.join(root,'runtime'));
|
||
|
|
// Synthetic bytes deliberately are not a broker executable or launch proof.
|
||
|
|
await writeFile(path.join(root,'broker.exe'),'synthetic packaged executable');
|
||
|
|
await writeFile(path.join(root,'runtime','scanner.dll'),'synthetic scanner');
|
||
|
|
const manifest=await buildBrokerPackageManifest(root,release);
|
||
|
|
return {root,manifest};
|
||
|
|
}
|
||
|
|
test('complete bundled broker inventory hashes and verifies without external runtime',async t=>{
|
||
|
|
const {root,manifest}=await fixture(t);
|
||
|
|
assert.equal(manifest.files.length,2);
|
||
|
|
assert.equal((await verifyBrokerPackage(root,manifest,release)).files,2);
|
||
|
|
});
|
||
|
|
for(const operation of ['missing','tampered','extra'])test(`rejects ${operation} broker payload`,async t=>{
|
||
|
|
const {root,manifest}=await fixture(t);
|
||
|
|
if(operation==='missing')await rm(path.join(root,'broker.exe'));
|
||
|
|
if(operation==='tampered')await writeFile(path.join(root,'runtime','scanner.dll'),'replacement');
|
||
|
|
if(operation==='extra')await writeFile(path.join(root,'runtime','injected.dll'),'injection');
|
||
|
|
await assert.rejects(verifyBrokerPackage(root,manifest,release),{code:operation==='tampered'?'broker_package_integrity_mismatch':'broker_package_inventory_mismatch'});
|
||
|
|
});
|
||
|
|
test('runtime protocol and version must match integrity-protected expectations',async t=>{
|
||
|
|
const {root,manifest}=await fixture(t);
|
||
|
|
for(const expected of [{version:'0.2.0',protocol:1},{version:'0.1.0',protocol:2}])
|
||
|
|
await assert.rejects(verifyBrokerPackage(root,manifest,expected),{code:'broker_package_incompatible'});
|
||
|
|
});
|
||
|
|
test('rejects ambiguous paths and manifests before package traversal',async t=>{
|
||
|
|
const {root,manifest}=await fixture(t);
|
||
|
|
for(const unsafe of ['../broker.exe','C:/broker.exe','runtime\\scanner.dll','CON.exe','broker.exe:stream','./broker.exe']){
|
||
|
|
const changed=structuredClone(manifest);changed.files[0].path=unsafe;
|
||
|
|
await assert.rejects(verifyBrokerPackage(root,changed,release),{code:'broker_manifest_invalid'});
|
||
|
|
}
|
||
|
|
const duplicate=structuredClone(manifest);duplicate.files.push({...duplicate.files[0],path:duplicate.files[0].path.toUpperCase()});
|
||
|
|
await assert.rejects(verifyBrokerPackage(root,duplicate,release),{code:'broker_manifest_invalid'});
|
||
|
|
});
|
||
|
|
test('rejects absent entrypoint and malformed hashes',async t=>{
|
||
|
|
const {root,manifest}=await fixture(t);
|
||
|
|
await assert.rejects(verifyBrokerPackage(root,{...manifest,entrypoint:'missing.exe'},release),{code:'broker_manifest_invalid'});
|
||
|
|
const changed=structuredClone(manifest);changed.files[0].sha256='invalid';
|
||
|
|
await assert.rejects(verifyBrokerPackage(root,changed,release),{code:'broker_manifest_invalid'});
|
||
|
|
});
|
||
|
|
test('directory junctions cannot redirect broker payload outside installation',async t=>{
|
||
|
|
const {root,manifest}=await fixture(t);
|
||
|
|
const target=path.join(root,'runtime'),alias=path.join(root,'linked');
|
||
|
|
await symlink(target,alias,process.platform==='win32'?'junction':'dir');
|
||
|
|
await assert.rejects(verifyBrokerPackage(root,manifest,release),{code:'broker_path_unsafe'});
|
||
|
|
});
|
||
|
|
test('unavailable package errors contain no filesystem or raw exception content',async()=>{
|
||
|
|
await assert.rejects(verifyBrokerPackage(path.join(os.tmpdir(),'absent-sensitive-company-path'),{...release,format:'safetynet-broker-package-v1',files:[{path:'broker.exe',bytes:0,sha256:'0'.repeat(64)}]},release),error=>error.code==='broker_package_unavailable'&&error.message==='broker_package_unavailable');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('same-size mutation during actual read rejects with bounded fixed invariant facts',async t=>{
|
||
|
|
const {root,manifest}=await fixture(t),target=path.join(root,'broker.exe');
|
||
|
|
const probe=await open(target,'r'),prototype=Object.getPrototypeOf(probe);
|
||
|
|
const original=prototype.createReadStream;await probe.close();
|
||
|
|
let mutated=false;
|
||
|
|
t.mock.method(prototype,'createReadStream',function(options){
|
||
|
|
const stream=original.call(this,options);
|
||
|
|
stream.once('end',()=>{
|
||
|
|
if(mutated)return;mutated=true;
|
||
|
|
writeFileSync(target,'X'.repeat(manifest.files.find(file=>file.path==='broker.exe').bytes));
|
||
|
|
const future=new Date(Date.now()+5000);utimesSync(target,future,future);
|
||
|
|
});
|
||
|
|
return stream;
|
||
|
|
});
|
||
|
|
await assert.rejects(verifyBrokerPackage(root,manifest,release),error=>{
|
||
|
|
assert.equal(error.code,'broker_package_changed');assert.equal(error.message,error.code);
|
||
|
|
assert.ok(error.failureFacts.changed.includes('modification-time'));
|
||
|
|
assert.ok(error.failureFacts.changed.length<=5);
|
||
|
|
assert.ok(error.failureFacts.changed.every(value=>['size-before-read','size-after-read','modification-time','change-time','file-identity'].includes(value)));
|
||
|
|
assert.ok(Object.isFrozen(error.failureFacts)&&Object.isFrozen(error.failureFacts.changed));
|
||
|
|
assert.ok(!JSON.stringify(error.failureFacts).includes(root));return true;
|
||
|
|
});
|
||
|
|
assert.equal(mutated,true);
|
||
|
|
});
|