test(fixtures): capture redacted SimAdmin responses
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { upstream58e2204Operations } from '../../operation-registry/src/upstream-58e2204.ts';
|
||||
import { collectOne, selectReadonlyOperations, validateReadonlyOperation, DENY_REASONS } from '../scripts/collector.ts';
|
||||
import { redact } from '../src/redactor.ts';
|
||||
import { payloadShape, validateShapeNode, validateSensitivePayload, validateStatusContract } from '../src/verifier.ts';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..');
|
||||
const fixtureRoot = path.join(root, 'src/simadmin');
|
||||
const allowedEnvelope = ['schemaVersion','upstreamBaseline','capturedAt','sourceInstanceAlias','operationId','method','pathTemplate','statusCategory','httpStatus','contentType','latencyBucket','redacted','payload'];
|
||||
// Preserve business-schema keys such as `url` and channel `headers`; sensitive values
|
||||
// are typed-redacted. The exact top-level envelope already excludes transport metadata.
|
||||
const forbiddenKeys = /^(raw(response)?|instance(id|name))$/i;
|
||||
const DENIED_PATHS = new Set(Object.keys(DENY_REASONS));
|
||||
const leakPatterns = [
|
||||
/https?:\/\//i, /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i,
|
||||
/\b(?:\d{1,3}\.){3}\d{1,3}\b/, /(?:[0-9a-f]{2}:){5}[0-9a-f]{2}/i,
|
||||
/\+?\d(?:[ ()-]*\d){9,14}/, /\b\d{14,22}\b/,
|
||||
/\b(?:bearer\s+)?[A-Za-z0-9_-]{32,}\b/i,
|
||||
];
|
||||
|
||||
async function jsonFiles(dir:string):Promise<string[]> { const out:string[]=[]; for(const e of await readdir(dir,{withFileTypes:true})){const p=path.join(dir,e.name); if(e.isDirectory()) out.push(...await jsonFiles(p)); else if(e.name.endsWith('.json')) out.push(p);} return out; }
|
||||
function walkKeys(v:any, cb:(k:string)=>void){ if(Array.isArray(v)) return v.forEach(x=>walkKeys(x,cb)); if(v&&typeof v==='object') for(const [k,x] of Object.entries(v)){cb(k);walkKeys(x,cb);} }
|
||||
function stringLeaves(v:any,out:string[]=[]){if(Array.isArray(v))v.forEach(x=>stringLeaves(x,out));else if(v&&typeof v==='object')Object.values(v).forEach(x=>stringLeaves(x,out));else if(typeof v==='string'&&v.length>=3)out.push(v);return out;}
|
||||
|
||||
test('selector is registry-derived passive GET R0 only with explicit denials', () => {
|
||||
const { selected, denied } = selectReadonlyOperations(upstream58e2204Operations);
|
||||
assert.ok(selected.length > 20);
|
||||
assert.ok(denied.some((x:any)=>x.pathTemplate==='/api/network/operators/scan' && x.denyReason));
|
||||
for(const op of selected){ assert.equal(op.method,'GET'); assert.equal(op.riskLevel,'R0'); assert.doesNotMatch(op.pathTemplate,/[{}]/); assert.notEqual(op.pathTemplate,'/api/network/operators/scan'); }
|
||||
});
|
||||
|
||||
test('collector source has no write-method or authentication escape hatch', async () => {
|
||||
const source = await readFile(path.join(root,'scripts/collect-readonly-fixtures.ts'),'utf8');
|
||||
assert.doesNotMatch(source, /ensureAuthenticated|login\s*\(|--method|--path|--origin/i);
|
||||
assert.doesNotMatch(source, /['"](?:POST|PUT|PATCH|DELETE)['"]/);
|
||||
});
|
||||
|
||||
test('transport can only receive credential-free GET, manual redirect and timeout', async () => {
|
||||
let init:any; const fake=async (_url:any, i:any)=>{init=i; return new Response(JSON.stringify({phone:'+15551234567',message:'private',ip:'10.1.2.3'}),{status:200,headers:{'content-type':'application/json','set-cookie':'bad=1'}})};
|
||||
const op=upstream58e2204Operations.find(x=>x.operationId==='getHealth')!;
|
||||
const f=await collectOne({origin:'http://192.168.1.2',alias:'instance-1'},op,fake as any);
|
||||
assert.deepEqual({method:init.method,body:init.body,redirect:init.redirect,credentials:init.credentials},{method:'GET',body:undefined,redirect:'manual',credentials:'omit'});
|
||||
assert.deepEqual(init.headers, { accept: 'application/json' }); assert.ok(init.signal);
|
||||
assert.equal(f.payload.phone,'[REDACTED]'); assert.equal(f.payload.message,'private'); assert.equal(f.payload.ip,'[REDACTED]');
|
||||
});
|
||||
|
||||
test('redactor covers sensitive keys and value patterns deterministically',()=>{
|
||||
const x=redact({password:'p',TOKEN:'t',content:'body',phone:'x',other:['+155****4567','+155****4567','a@b.example','https://private.example/x','aa:bb:cc:dd:ee:ff','2001:db8::1']});
|
||||
assert.equal(x.password,'[REDACTED]'); assert.equal(x.TOKEN,'[REDACTED]'); assert.equal(x.content,'[REDACTED]'); assert.equal(x.phone,'[REDACTED]');
|
||||
for(const p of leakPatterns) assert.doesNotMatch(JSON.stringify(x),p);
|
||||
});
|
||||
|
||||
test('Registry operation identity also rejects in-place structural mutation',()=>{
|
||||
const op:any=upstream58e2204Operations.find(x=>x.operationId==='getHealth')!;
|
||||
const original=structuredClone(op);
|
||||
const mutations=[
|
||||
(x:any)=>x.sensitiveFields.push({direction:'response',path:'$.response.data.secret'}),
|
||||
(x:any)=>{delete x.pathTemplate;},
|
||||
(x:any)=>{x.method='POST';},
|
||||
(x:any)=>{x.sensitiveFields='changed';},
|
||||
];
|
||||
for(const mutate of mutations){
|
||||
try{mutate(op);assert.throws(()=>validateReadonlyOperation(op),/Registry integrity/);}
|
||||
finally{for(const key of Object.keys(op))delete op[key];Object.assign(op,structuredClone(original));}
|
||||
}
|
||||
validateReadonlyOperation(op);
|
||||
});
|
||||
|
||||
test('collector categorizes status and JSON parse outcomes consistently',async()=>{
|
||||
const op=upstream58e2204Operations.find(x=>x.operationId==='getHealth')!;
|
||||
const run=(status:number,type:string,body:string|null)=>collectOne({origin:'http://192.168.1.2',alias:'instance-1'},op,async()=>new Response(body,{status,headers:{'content-type':type}}));
|
||||
for(const [status,type,body,category] of [[200,'application/problem+json','{}','success'],[200,'application/json','bad','non-json'],[204,'text/plain',null,'non-json'],[401,'application/json','{}','auth-required'],[405,'application/json','{}','unsupported'],[500,'application/json','{}','http-error']] as const){
|
||||
const f=await run(status,type,body);assert.equal(f.statusCategory,category);validateStatusContract(f);
|
||||
}
|
||||
const redirect=await run(302,'application/json','{}');assert.equal(redirect.statusCategory,'http-error');validateStatusContract(redirect);
|
||||
assert.throws(()=>validateStatusContract({statusCategory:'success',httpStatus:401,contentType:'application/json',payload:{},latencyBucket:'<250ms'}));
|
||||
});
|
||||
|
||||
test('real fixtures satisfy envelope, registry, leak scan, and manifest integrity', async()=>{
|
||||
const manifest=JSON.parse(await readFile(path.join(root,'src/manifest.json'),'utf8'));assert.deepEqual(Object.keys(manifest).sort(),['domainCoverage','files','realFixtureCount','schemaVersion','shapeBaseline','syntheticFixtureCount','upstreamBaseline']); const files=(await jsonFiles(fixtureRoot)).filter(x=>!x.includes('/synthetic-errors/'));
|
||||
const srcEntries=await readdir(path.join(root,'src'));assert.ok(!srcEntries.includes('synthetic-errors'));
|
||||
const config=JSON.parse(await readFile(path.resolve(root,'../../config.json'),'utf8'));
|
||||
const privateValues=stringLeaves(config);
|
||||
const expected=selectReadonlyOperations(upstream58e2204Operations).selected;
|
||||
assert.equal(files.length,expected.length*2); assert.equal(manifest.realFixtureCount,files.length);assert.equal(manifest.syntheticFixtureCount,0);
|
||||
assert.equal(manifest.schemaVersion,1);assert.equal(manifest.upstreamBaseline,'58e2204');
|
||||
const shapePath=path.join(root,'src/response-shapes-58e2204.json');const shapeText=await readFile(shapePath,'utf8');const shapeBaseline=JSON.parse(shapeText);
|
||||
assert.deepEqual(Object.keys(shapeBaseline).sort(),['schemaVersion','shapes','upstreamBaseline']);assert.equal(shapeBaseline.schemaVersion,1);assert.equal(shapeBaseline.upstreamBaseline,'58e2204');assert.equal(Object.keys(shapeBaseline.shapes).length,78);
|
||||
assert.deepEqual(Object.keys(manifest.shapeBaseline).sort(),['path','sha256','size']);assert.equal(manifest.shapeBaseline.path,'src/response-shapes-58e2204.json');assert.equal(manifest.shapeBaseline.size,(await stat(shapePath)).size);assert.equal(manifest.shapeBaseline.sha256,createHash('sha256').update(shapeText).digest('hex'));assert.ok(!manifest.files.some((x:any)=>x.path===manifest.shapeBaseline.path));
|
||||
for(const node of Object.values(shapeBaseline.shapes))validateShapeNode(node);
|
||||
const registry=new Map(upstream58e2204Operations.map(x=>[x.operationId,x])); const domains=new Map<string,number>();const pairs=new Set<string>();
|
||||
const disk=files.map(file=>path.relative(root,file)).sort();const listed=manifest.files.map((x:any)=>x.path).sort();assert.deepEqual(listed,disk);assert.equal(new Set(listed).size,listed.length);
|
||||
for(const file of files){const text=await readFile(file,'utf8'); const f=JSON.parse(text); assert.deepEqual(Object.keys(f).sort(),[...allowedEnvelope].sort()); assert.equal(f.schemaVersion,1);assert.equal(f.upstreamBaseline,'58e2204');assert.match(f.capturedAt,/^20(?:2[4-9]|[3-9]\d)-\d{2}-\d{2}$/);assert.ok(!Number.isNaN(Date.parse(`${f.capturedAt}T00:00:00Z`)));assert.ok(['instance-1','instance-2'].includes(f.sourceInstanceAlias));assert.equal(f.redacted,true); assert.equal(f.method,'GET'); assert.ok(!('synthetic' in f));assert.ok(['success','non-json','auth-required','unsupported','http-error','timeout','network-error'].includes(f.statusCategory));if(['timeout','network-error'].includes(f.statusCategory))assert.equal(f.httpStatus,null);else assert.ok(Number.isInteger(f.httpStatus)); const op:any=registry.get(f.operationId);assert.ok(op);validateStatusContract(f);validateSensitivePayload(f.payload,op);const shapeKey=`${f.sourceInstanceAlias}::${f.operationId}`;assert.deepEqual(payloadShape(f.payload),shapeBaseline.shapes[shapeKey]); assert.equal(op?.riskLevel,'R0');assert.equal(op?.method,'GET'); assert.equal(op?.pathTemplate,f.pathTemplate);assert.ok(!DENIED_PATHS.has(f.pathTemplate));domains.set(op.upstreamDomain,(domains.get(op.upstreamDomain)||0)+1);const pair=`${f.sourceInstanceAlias}:${f.operationId}`;assert.ok(!pairs.has(pair));pairs.add(pair); walkKeys(f,k=>assert.doesNotMatch(k,forbiddenKeys)); for(const p of leakPatterns) assert.doesNotMatch(text,p); for(const value of privateValues) assert.ok(!text.includes(value),'fixture contains configured value'); const rel=path.relative(root,file); const m=manifest.files.find((x:any)=>x.path===rel); assert.ok(m); assert.equal(m.size,(await stat(file)).size);assert.match(m.sha256,/^[a-f0-9]{64}$/); assert.equal(m.sha256,createHash('sha256').update(text).digest('hex')); }
|
||||
assert.deepEqual(Object.fromEntries([...domains].sort()),manifest.domainCoverage);
|
||||
assert.deepEqual(Object.keys(shapeBaseline.shapes).sort(),[...pairs].map(x=>x.replace(':','::')).sort());
|
||||
assert.deepEqual([...pairs].sort(),expected.flatMap((op:any)=>['instance-1','instance-2'].map(alias=>`${alias}:${op.operationId}`)).sort());
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { upstream58e2204Operations } from '../../operation-registry/src/upstream-58e2204.ts';
|
||||
import { collectOne, selectReadonlyOperations, validateReadonlyOperation, validateInstanceOrigin } from '../scripts/collector.ts';
|
||||
import { loadInstances } from '../scripts/collect-readonly-fixtures.ts';
|
||||
import { redact, redactOperationPayload, parseResponseSensitivePath } from '../src/redactor.ts';
|
||||
|
||||
const health=upstream58e2204Operations.find((x:any)=>x.operationId==='getHealth')!;
|
||||
const fakeResponse=async()=>new Response('{"ok":true}',{headers:{'content-type':'application/json'}});
|
||||
|
||||
test('operation validator rejects path and registry identity bypasses', async()=>{
|
||||
const attacks=['https://example.invalid/api/health','//example.invalid/api/health','/api/health?scan=1','/api/health#x','/api/%73can','/api/%252e%252e/health','/api/a/../health','/api\\health','/api//health'];
|
||||
for(const pathTemplate of attacks) assert.throws(()=>validateReadonlyOperation({...health,pathTemplate}));
|
||||
assert.throws(()=>validateReadonlyOperation({...health}));
|
||||
assert.throws(()=>validateReadonlyOperation({operationId:health.operationId,method:'GET',riskLevel:'R0',pathTemplate:health.pathTemplate}));
|
||||
await assert.rejects(()=>collectOne({origin:'http://192.168.1.2',alias:'instance-1'},{...health},fakeResponse));
|
||||
});
|
||||
|
||||
test('all path bypass classes fail before transport',async()=>{
|
||||
let calls=0;const transport=async()=>{calls++;return new Response('{}',{headers:{'content-type':'application/json'}})};
|
||||
const attacks=['https://evil.invalid/api/health','//evil.invalid/api/health','/api/health?q=1','/api/health#x','/api/%68ealth','/api/%252e%252e/x','/api/a/../x','/api\\health','/api//health'];
|
||||
for(const pathTemplate of attacks) await assert.rejects(()=>collectOne({origin:'http://192.168.1.2',alias:'instance-1'},{...health,pathTemplate},transport));
|
||||
assert.equal(calls,0);
|
||||
assert.throws(()=>selectReadonlyOperations([{...health}]));
|
||||
});
|
||||
|
||||
test('collectOne validates RFC1918 origin before transport',()=>{
|
||||
for(const origin of ['http://127.0.0.1','http://169.254.1.1','http://100.100.100.200','http://8.8.8.8','http://router.local','http://u:p@192.168.1.2','http://192.168.1.2/x','http://192.168.1.2?q=1','ftp://192.168.1.2']) assert.throws(()=>validateInstanceOrigin(origin));
|
||||
assert.equal(validateInstanceOrigin('https://172.16.2.3:8443'),'https://172.16.2.3:8443');
|
||||
});
|
||||
|
||||
test('passive selection explicitly denies connectivity and active scan',()=>{
|
||||
const {selected,denied}=selectReadonlyOperations(upstream58e2204Operations);
|
||||
assert.ok(denied.some((x:any)=>x.pathTemplate==='/api/connectivity'));
|
||||
assert.ok(denied.some((x:any)=>x.pathTemplate==='/api/network/operators/scan'));
|
||||
assert.ok(!selected.some((x:any)=>x.pathTemplate==='/api/connectivity'));
|
||||
});
|
||||
|
||||
test('config uses formal validation plus collector LAN/auth/origin constraints',()=>{
|
||||
const good={instances:[{id:'a',url:'http://192.168.1.2',auth:{mode:'none'}},{id:'b',url:'https://10.0.0.2',auth:{mode:'none'}}]};
|
||||
assert.deepEqual(loadInstances(good).map((x:any)=>x.alias),['instance-1','instance-2']);
|
||||
const bad=['http://127.0.0.1','http://169.254.1.2','http://100.100.100.200','http://8.8.8.8','http://router.local','http://u:***@192.168.1.2','http://192.168.1.2/path','http://192.168.1.2?q=1','http://192.168.1.2#x','http://[::1]'];
|
||||
for(const url of bad) assert.throws(()=>loadInstances({instances:[{id:'a',url},{id:'b',url:'http://10.0.0.2'}]}));
|
||||
assert.throws(()=>loadInstances({instances:[{id:'a',url:'http://192.168.1.2',auth:{password:'x'}},{id:'b',url:'http://10.0.0.2'}]}));
|
||||
assert.throws(()=>loadInstances({instances:[{id:'a',url:'http://192.168.1.2'}]}));
|
||||
assert.throws(()=>loadInstances({instances:[{id:'a',url:'http://192.168.1.2'},{id:'b',url:'http://10.0.0.2'},{id:'c',url:'http://10.0.0.3'}]}));
|
||||
});
|
||||
|
||||
test('directional response JSONPaths redact every declared path and preserve shape/types',()=>{
|
||||
const ops=upstream58e2204Operations.filter((op:any)=>op.sensitiveFields.some((f:any)=>f.direction==='response'));
|
||||
assert.ok(ops.length>0);
|
||||
for(const op of ops) for(const field of op.sensitiveFields.filter((f:any)=>f.direction==='response')){
|
||||
const parts=parseResponseSensitivePath(field.path); assert.ok(parts.length>0,`${op.operationId} unsupported response path`);
|
||||
let leaf:any={s:'private',n:42,b:true,z:null,a:['x',{q:9}]};
|
||||
let payload:any=leaf;
|
||||
for(let i=parts.length-1;i>=0;i--){const p=parts[i]; payload=p==='*'?[payload]:{[p]:payload};}
|
||||
const out:any=redactOperationPayload(payload,op);
|
||||
let hit=out; for(const p of parts) hit=p==='*'?hit[0]:hit[p];
|
||||
assert.deepEqual(hit,{s:'[REDACTED]',n:0,b:false,z:null,a:['[REDACTED]',{q:0}]});
|
||||
}
|
||||
});
|
||||
|
||||
test('deep sanitizer handles reviewed key/value bypasses without schema destruction',()=>{
|
||||
const input:any={credential:'x',api_key:'x',access_key:'x',device_key:'x',private_key:'x',pin:12,puk:34,username:'x',user:'x',account:'x',serial:'x',revision:'x',path:'/private/a',template:'user=x token=abc123 path=/private/a',config:{access_token:'tiny'},phone_number_is_manual:true,sms_center_is_manual:false,message:'API is healthy',nested:'{"username":"x","enabled":true}',host:'private.internal:8080',short_number:'12345678',split_id:'12-34-56-78-90-12-34',ipv6:'::1%lo0',latitude:1.234567,lon:2.345678,cell_id:99,lac:2,tac:3,pci:4,enb:5,gnb:6,uptime:777,traffic:888,timestamp:'2026-07-16T12:34:56Z',unknown:'AKIAIOSFODNN7EXAMPLE'};
|
||||
const x:any=redact(input);
|
||||
for(const k of ['credential','api_key','access_key','device_key','private_key','username','user','account','serial','revision','path','template','host','short_number','split_id','ipv6','timestamp','unknown']) assert.equal(x[k],'[REDACTED]');
|
||||
for(const k of ['pin','puk','latitude','lon','cell_id','lac','tac','pci','enb','gnb','uptime','traffic']) assert.equal(x[k],0);
|
||||
assert.equal(typeof x.nested,'string'); assert.deepEqual(JSON.parse(x.nested),{username:'[REDACTED]',enabled:true});
|
||||
assert.deepEqual(x.config,{access_token:'[REDACTED]'}); assert.equal(x.phone_number_is_manual,true); assert.equal(x.sms_center_is_manual,false); assert.equal(x.message,'API is healthy');
|
||||
});
|
||||
|
||||
test('schema shapes and primitive types survive conservative redaction',()=>{
|
||||
const x:any=redact({phone_numbers:['12345678','87654321'],ip_addresses:[{v4:'192.168.1.9',active:true}],urls:['private.example/x'],webhook:{url:'//private.example/h',enabled:true},config:{s:'secret',n:42,b:true,z:null,a:['x',{n:9}]}});
|
||||
assert.ok(Array.isArray(x.phone_numbers));assert.equal(x.phone_numbers.length,2);
|
||||
assert.ok(Array.isArray(x.ip_addresses));assert.equal(typeof x.ip_addresses[0],'object');
|
||||
assert.ok(Array.isArray(x.urls));assert.equal(typeof x.webhook,'object');
|
||||
assert.deepEqual(x.config,{s:'[REDACTED]',n:0,b:false,z:null,a:['[REDACTED]',{n:0}]});
|
||||
});
|
||||
|
||||
test('invalid declared JSON and non-JSON bodies are non-json and discarded',async()=>{
|
||||
const invalid=await collectOne({origin:'http://192.168.1.2',alias:'instance-1'},health,async()=>new Response('{bad',{status:200,headers:{'content-type':'application/json'}}));
|
||||
assert.equal(invalid.statusCategory,'non-json');assert.deepEqual(invalid.payload,{error:'invalid-json'});
|
||||
const text=await collectOne({origin:'http://192.168.1.2',alias:'instance-1'},health,async()=>new Response('private body',{status:200,headers:{'content-type':'text/plain'}}));
|
||||
assert.equal(text.statusCategory,'non-json');assert.deepEqual(text.payload,{text:'[REDACTED]'});
|
||||
});
|
||||
|
||||
test('collector errors never echo rejected origin values',async()=>{
|
||||
const secret='http://user:password@evil.invalid/private?token=x';
|
||||
try{await collectOne({origin:secret,alias:'instance-1'},health,fakeResponse);assert.fail('expected rejection');}catch(error:any){assert.ok(!String(error.message).includes(secret));assert.ok(!String(error.message).includes('password'));}
|
||||
});
|
||||
Reference in New Issue
Block a user