feat(contracts): share notification, organization and device capability models
Move the Hub channel table, organization tags and device capability definitions into the workspace packages so the control plane and the console validate the same contract, and refresh the frozen upstream evidence for the new paths.
This commit is contained in:
@@ -6,35 +6,123 @@ import { upstream58e2204Operations } from '../../operation-registry/src/upstream
|
||||
import { normalizeConfig } from '../../../server/config/schema.js';
|
||||
import { collectOne, selectReadonlyOperations, validateInstanceOrigin } from './collector.ts';
|
||||
|
||||
const here=path.dirname(fileURLToPath(import.meta.url));const pkg=path.resolve(here,'..');const repo=path.resolve(pkg,'../..');
|
||||
export function loadInstances(config:any){
|
||||
if(!config||!Array.isArray(config.instances)||config.instances.length!==2)throw new Error('config must contain exactly two instances');
|
||||
for(const raw of config.instances){
|
||||
if(!raw||typeof raw!=='object'||typeof raw.url!=='string')throw new Error('invalid collector instance configuration');
|
||||
validateInstanceOrigin(raw.url);
|
||||
if(raw.password||raw.auth?.password||!['none',undefined].includes(raw.auth?.mode))throw new Error('collector instances must be password-free');
|
||||
}
|
||||
const normalized=normalizeConfig(config,{HOST:'127.0.0.1',PORT:'8788'});
|
||||
if(normalized.instances.length!==2)throw new Error('config must contain exactly two instances');
|
||||
return normalized.instances.map((raw:any,i:number)=>{
|
||||
if(raw.auth.mode!=='none'||raw.auth.password)throw new Error(`instance-${i+1} must be password-free`);
|
||||
return {origin:validateInstanceOrigin(raw.url),alias:`instance-${i+1}`};
|
||||
});
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const pkg = path.resolve(here, '..');
|
||||
const repo = path.resolve(pkg, '../..');
|
||||
export function loadInstances(config: any) {
|
||||
if (!config || !Array.isArray(config.instances) || config.instances.length !== 2)
|
||||
throw new Error('config must contain exactly two instances');
|
||||
for (const raw of config.instances) {
|
||||
if (!raw || typeof raw !== 'object' || typeof raw.url !== 'string')
|
||||
throw new Error('invalid collector instance configuration');
|
||||
validateInstanceOrigin(raw.url);
|
||||
if (raw.password || raw.auth?.password || !['none', undefined].includes(raw.auth?.mode))
|
||||
throw new Error('collector instances must be password-free');
|
||||
}
|
||||
const normalized = normalizeConfig(config, { HOST: '127.0.0.1', PORT: '8788' });
|
||||
if (normalized.instances.length !== 2)
|
||||
throw new Error('config must contain exactly two instances');
|
||||
return normalized.instances.map((raw: any, i: number) => {
|
||||
if (raw.auth.mode !== 'none' || raw.auth.password)
|
||||
throw new Error(`instance-${i + 1} must be password-free`);
|
||||
return { origin: validateInstanceOrigin(raw.url), alias: `instance-${i + 1}` };
|
||||
});
|
||||
}
|
||||
async function mapLimit<T,R>(xs:T[],limit:number,fn:(x:T)=>Promise<R>){const out:R[]=[];let n=0;async function worker(){while(n<xs.length){const i=n++;out[i]=await fn(xs[i]);}}await Promise.all(Array.from({length:Math.min(limit,xs.length)},worker));return out;}
|
||||
async function allJson(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 allJson(p));else if(e.name.endsWith('.json'))out.push(p);}return out;}
|
||||
function safeName(s:string){return s.replace(/[^a-zA-Z0-9_-]/g,'-');}
|
||||
export async function main(argv=process.argv.slice(2)){
|
||||
if(argv.length!==1||!['--dry-run','--capture'].includes(argv[0]))throw new Error('usage: collect-readonly-fixtures.ts --dry-run|--capture');
|
||||
const config=JSON.parse(await readFile(path.join(repo,'config.json'),'utf8'));const instances=loadInstances(config);const {selected,denied}=selectReadonlyOperations(upstream58e2204Operations);
|
||||
if(argv[0]==='--dry-run'){for(const op of selected)console.log(`registry ${op.operationId}`);console.log(`selected=${selected.length} denied=${denied.length}`);return;}
|
||||
const shapePath=path.join(pkg,'src/response-shapes-58e2204.json');
|
||||
// Separately reviewed: capture attests this baseline but never creates it.
|
||||
const shapeBytes=await readFile(shapePath);
|
||||
const root=path.join(pkg,'src/simadmin');await rm(root,{recursive:true,force:true});await rm(path.join(pkg,'src/manifest.json'),{force:true});await mkdir(root,{recursive:true});
|
||||
const jobs=instances.flatMap(instance=>selected.map(op=>({instance,op})));const fixtures=await mapLimit(jobs,2,async({instance,op})=>{const f=await collectOne(instance,op);console.log(`${instance.alias} ${op.operationId} ${f.statusCategory}`);return {f,domain:op.upstreamDomain};});
|
||||
for(const {f,domain} of fixtures){const dir=path.join(root,safeName(domain));await mkdir(dir,{recursive:true});await writeFile(path.join(dir,`${f.sourceInstanceAlias}--${f.operationId}.json`),JSON.stringify(f,null,2)+'\n',{flag:'wx'});}
|
||||
const files=[];const coverage:Record<string,number>={};for(const full of await allJson(root)){const text=await readFile(full);const rel=path.relative(pkg,full);const domain=path.basename(path.dirname(full));coverage[domain]=(coverage[domain]||0)+1;files.push({path:rel,size:(await stat(full)).size,sha256:createHash('sha256').update(text).digest('hex')});}
|
||||
files.sort((a,b)=>a.path.localeCompare(b.path));const shapeBaseline={path:'src/response-shapes-58e2204.json',size:shapeBytes.length,sha256:createHash('sha256').update(shapeBytes).digest('hex')};await writeFile(path.join(pkg,'src/manifest.json'),JSON.stringify({schemaVersion:1,upstreamBaseline:'58e2204',realFixtureCount:files.length,syntheticFixtureCount:0,domainCoverage:Object.fromEntries(Object.entries(coverage).sort()),shapeBaseline,files},null,2)+'\n');
|
||||
async function mapLimit<T, R>(xs: T[], limit: number, fn: (x: T) => Promise<R>) {
|
||||
const out: R[] = [];
|
||||
let n = 0;
|
||||
async function worker() {
|
||||
while (n < xs.length) {
|
||||
const i = n++;
|
||||
out[i] = await fn(xs[i]);
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(limit, xs.length) }, worker));
|
||||
return out;
|
||||
}
|
||||
if(process.argv[1]===fileURLToPath(import.meta.url))main().catch(()=>{console.error('collector failed');process.exitCode=1;});
|
||||
async function allJson(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 allJson(p)));
|
||||
else if (e.name.endsWith('.json')) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function safeName(s: string) {
|
||||
return s.replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||
}
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
if (argv.length !== 1 || !['--dry-run', '--capture'].includes(argv[0]))
|
||||
throw new Error('usage: collect-readonly-fixtures.ts --dry-run|--capture');
|
||||
const config = JSON.parse(await readFile(path.join(repo, 'config.json'), 'utf8'));
|
||||
const instances = loadInstances(config);
|
||||
const { selected, denied } = selectReadonlyOperations(upstream58e2204Operations);
|
||||
if (argv[0] === '--dry-run') {
|
||||
for (const op of selected) console.log(`registry ${op.operationId}`);
|
||||
console.log(`selected=${selected.length} denied=${denied.length}`);
|
||||
return;
|
||||
}
|
||||
const shapePath = path.join(pkg, 'src/response-shapes-58e2204.json');
|
||||
// Separately reviewed: capture attests this baseline but never creates it.
|
||||
const shapeBytes = await readFile(shapePath);
|
||||
const root = path.join(pkg, 'src/simadmin');
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(path.join(pkg, 'src/manifest.json'), { force: true });
|
||||
await mkdir(root, { recursive: true });
|
||||
const jobs = instances.flatMap((instance) => selected.map((op) => ({ instance, op })));
|
||||
const fixtures = await mapLimit(jobs, 2, async ({ instance, op }) => {
|
||||
const f = await collectOne(instance, op);
|
||||
console.log(`${instance.alias} ${op.operationId} ${f.statusCategory}`);
|
||||
return { f, domain: op.upstreamDomain };
|
||||
});
|
||||
for (const { f, domain } of fixtures) {
|
||||
const dir = path.join(root, safeName(domain));
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(dir, `${f.sourceInstanceAlias}--${f.operationId}.json`),
|
||||
JSON.stringify(f, null, 2) + '\n',
|
||||
{ flag: 'wx' },
|
||||
);
|
||||
}
|
||||
const files = [];
|
||||
const coverage: Record<string, number> = {};
|
||||
for (const full of await allJson(root)) {
|
||||
const text = await readFile(full);
|
||||
const rel = path.relative(pkg, full);
|
||||
const domain = path.basename(path.dirname(full));
|
||||
coverage[domain] = (coverage[domain] || 0) + 1;
|
||||
files.push({
|
||||
path: rel,
|
||||
size: (await stat(full)).size,
|
||||
sha256: createHash('sha256').update(text).digest('hex'),
|
||||
});
|
||||
}
|
||||
files.sort((a, b) => a.path.localeCompare(b.path));
|
||||
const shapeBaseline = {
|
||||
path: 'src/response-shapes-58e2204.json',
|
||||
size: shapeBytes.length,
|
||||
sha256: createHash('sha256').update(shapeBytes).digest('hex'),
|
||||
};
|
||||
await writeFile(
|
||||
path.join(pkg, 'src/manifest.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
upstreamBaseline: '58e2204',
|
||||
realFixtureCount: files.length,
|
||||
syntheticFixtureCount: 0,
|
||||
domainCoverage: Object.fromEntries(Object.entries(coverage).sort()),
|
||||
shapeBaseline,
|
||||
files,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
);
|
||||
}
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url))
|
||||
main().catch(() => {
|
||||
console.error('collector failed');
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
@@ -1,64 +1,190 @@
|
||||
import { redactOperationPayload } from '../src/redactor.ts';
|
||||
import { upstream58e2204Operations } from '../../operation-registry/src/upstream-58e2204.ts';
|
||||
|
||||
export const UPSTREAM_BASELINE='58e2204';
|
||||
export const DENY_REASONS:Record<string,string>={
|
||||
'/api/network/operators/scan':'active radio/network scan',
|
||||
'/api/connectivity':'handler performs active connectivity ping',
|
||||
'/api/sms/list':'message list can expose body; intentionally not collected',
|
||||
'/api/sms/conversation':'requires correspondent query and exposes message bodies',
|
||||
'/api/call/history':'query limit is not encoded in Registry path contract',
|
||||
'/api/notifications/logs':'query limit is not encoded in Registry path contract',
|
||||
'/api/notifications/queue':'query limit is not encoded in Registry path contract',
|
||||
'/api/automation/logs':'query limit is not encoded in Registry path contract',
|
||||
'/api/device-network/ddns/logs':'no safe limit contract',
|
||||
'/api/esim/euicc':'query-bearing endpoint omitted',
|
||||
'/api/esim/profiles':'query-bearing endpoint omitted',
|
||||
export const UPSTREAM_BASELINE = '58e2204';
|
||||
export const DENY_REASONS: Record<string, string> = {
|
||||
'/api/network/operators/scan': 'active radio/network scan',
|
||||
'/api/connectivity': 'handler performs active connectivity ping',
|
||||
'/api/sms/list': 'message list can expose body; intentionally not collected',
|
||||
'/api/sms/conversation': 'requires correspondent query and exposes message bodies',
|
||||
'/api/call/history': 'query limit is not encoded in Registry path contract',
|
||||
'/api/notifications/logs': 'query limit is not encoded in Registry path contract',
|
||||
'/api/notifications/queue': 'query limit is not encoded in Registry path contract',
|
||||
'/api/automation/logs': 'query limit is not encoded in Registry path contract',
|
||||
'/api/device-network/ddns/logs': 'no safe limit contract',
|
||||
'/api/esim/euicc': 'query-bearing endpoint omitted',
|
||||
'/api/esim/profiles': 'query-bearing endpoint omitted',
|
||||
};
|
||||
const registryById=new Map(upstream58e2204Operations.map((op:any)=>[op.operationId,op]));
|
||||
const registryById = new Map(upstream58e2204Operations.map((op: any) => [op.operationId, op]));
|
||||
// Capture the complete imported JSON contract before any caller can mutate it.
|
||||
// Exact identity alone does not protect mutable Registry objects.
|
||||
const registrySignatures=new Map(upstream58e2204Operations.map((op:any)=>[op.operationId,JSON.stringify(op)]));
|
||||
function isPrivateLan(host:string){const p=host.split('.').map(Number);return p.length===4&&p.every(n=>Number.isInteger(n)&&n>=0&&n<=255)&&(p[0]===10||(p[0]===172&&p[1]>=16&&p[1]<=31)||(p[0]===192&&p[1]===168));}
|
||||
export function validateInstanceOrigin(origin:string){
|
||||
let u:URL;try{u=new URL(origin);}catch{throw new Error('invalid collector instance origin');}
|
||||
if(!['http:','https:'].includes(u.protocol)||!isPrivateLan(u.hostname)||u.pathname!=='/'||u.search||u.hash||u.username||u.password)throw new Error('invalid collector instance origin');
|
||||
return u.origin;
|
||||
const registrySignatures = new Map(
|
||||
upstream58e2204Operations.map((op: any) => [op.operationId, JSON.stringify(op)]),
|
||||
);
|
||||
function isPrivateLan(host: string) {
|
||||
const p = host.split('.').map(Number);
|
||||
return (
|
||||
p.length === 4 &&
|
||||
p.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) &&
|
||||
(p[0] === 10 || (p[0] === 172 && p[1] >= 16 && p[1] <= 31) || (p[0] === 192 && p[1] === 168))
|
||||
);
|
||||
}
|
||||
function validatePath(path:string){
|
||||
if(typeof path!=='string'||!path.startsWith('/api/')||path.startsWith('//')||path.includes('\\')||/[?#%]/.test(path)||path.includes('//')) throw new Error('unsafe operation path');
|
||||
let decoded=path; for(let i=0;i<2;i++){const next=decodeURIComponent(decoded);if(next!==decoded)throw new Error('encoded operation path');decoded=next;}
|
||||
if(path.split('/').some(x=>x==='.'||x==='..'))throw new Error('dot segment operation path');
|
||||
return path;
|
||||
export function validateInstanceOrigin(origin: string) {
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(origin);
|
||||
} catch {
|
||||
throw new Error('invalid collector instance origin');
|
||||
}
|
||||
if (
|
||||
!['http:', 'https:'].includes(u.protocol) ||
|
||||
!isPrivateLan(u.hostname) ||
|
||||
u.pathname !== '/' ||
|
||||
u.search ||
|
||||
u.hash ||
|
||||
u.username ||
|
||||
u.password
|
||||
)
|
||||
throw new Error('invalid collector instance origin');
|
||||
return u.origin;
|
||||
}
|
||||
export function validateReadonlyOperation(op:any){
|
||||
const exact=registryById.get(op?.operationId);
|
||||
if(!exact||exact!==op||registrySignatures.get(op?.operationId)!==JSON.stringify(op))throw new Error('operation violates Registry integrity');
|
||||
validatePath(op.pathTemplate);
|
||||
if(op.method!=='GET'||op.riskLevel!=='R0'||/[{}]/.test(op.pathTemplate))throw new Error('operation is not readonly R0');
|
||||
if(DENY_REASONS[op.pathTemplate])throw new Error('operation is explicitly denied');
|
||||
return op;
|
||||
function validatePath(path: string) {
|
||||
if (
|
||||
typeof path !== 'string' ||
|
||||
!path.startsWith('/api/') ||
|
||||
path.startsWith('//') ||
|
||||
path.includes('\\') ||
|
||||
/[?#%]/.test(path) ||
|
||||
path.includes('//')
|
||||
)
|
||||
throw new Error('unsafe operation path');
|
||||
let decoded = path;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const next = decodeURIComponent(decoded);
|
||||
if (next !== decoded) throw new Error('encoded operation path');
|
||||
decoded = next;
|
||||
}
|
||||
if (path.split('/').some((x) => x === '.' || x === '..'))
|
||||
throw new Error('dot segment operation path');
|
||||
return path;
|
||||
}
|
||||
export function selectReadonlyOperations(registry:any[]){
|
||||
const candidates=registry.filter(o=>o.method==='GET'&&o.riskLevel==='R0'&&!/[{}]/.test(o.pathTemplate));
|
||||
for(const op of candidates) validatePath(op.pathTemplate);
|
||||
return {selected:candidates.filter(o=>!DENY_REASONS[o.pathTemplate]).map(validateReadonlyOperation),denied:candidates.filter(o=>DENY_REASONS[o.pathTemplate]).map(o=>({...o,denyReason:DENY_REASONS[o.pathTemplate]}))};
|
||||
export function validateReadonlyOperation(op: any) {
|
||||
const exact = registryById.get(op?.operationId);
|
||||
if (!exact || exact !== op || registrySignatures.get(op?.operationId) !== JSON.stringify(op))
|
||||
throw new Error('operation violates Registry integrity');
|
||||
validatePath(op.pathTemplate);
|
||||
if (op.method !== 'GET' || op.riskLevel !== 'R0' || /[{}]/.test(op.pathTemplate))
|
||||
throw new Error('operation is not readonly R0');
|
||||
if (DENY_REASONS[op.pathTemplate]) throw new Error('operation is explicitly denied');
|
||||
return op;
|
||||
}
|
||||
function latency(ms:number,timedOut=false){if(timedOut)return 'timeout';if(ms<250)return '<250ms';if(ms<1000)return '250ms-1s';if(ms<3000)return '1-3s';return '>3s';}
|
||||
function statusCategory(status:number,isJson:boolean){if(status===401||status===403)return 'auth-required';if(status===404||status===405||status===501)return 'unsupported';if(status>=200&&status<300)return isJson?'success':'non-json';return 'http-error';}
|
||||
export async function collectOne(instance:{origin:string,alias:string},op:any,transport:any=fetch){
|
||||
validateReadonlyOperation(op);
|
||||
if(!['instance-1','instance-2'].includes(instance?.alias))throw new Error('invalid instance alias');
|
||||
const origin=validateInstanceOrigin(instance?.origin);
|
||||
const requestUrl=new URL(op.pathTemplate,origin);
|
||||
if(requestUrl.origin!==origin||requestUrl.pathname!==op.pathTemplate||requestUrl.search||requestUrl.hash)throw new Error('unsafe collector request URL');
|
||||
const started=Date.now();const controller=new AbortController();const timer=setTimeout(()=>controller.abort(),6000);
|
||||
const base={schemaVersion:1,upstreamBaseline:UPSTREAM_BASELINE,capturedAt:new Date().toISOString().slice(0,10),sourceInstanceAlias:instance.alias,operationId:op.operationId,method:'GET',pathTemplate:op.pathTemplate,redacted:true};
|
||||
try{
|
||||
export function selectReadonlyOperations(registry: any[]) {
|
||||
const candidates = registry.filter(
|
||||
(o) => o.method === 'GET' && o.riskLevel === 'R0' && !/[{}]/.test(o.pathTemplate),
|
||||
);
|
||||
for (const op of candidates) validatePath(op.pathTemplate);
|
||||
return {
|
||||
selected: candidates
|
||||
.filter((o) => !DENY_REASONS[o.pathTemplate])
|
||||
.map(validateReadonlyOperation),
|
||||
denied: candidates
|
||||
.filter((o) => DENY_REASONS[o.pathTemplate])
|
||||
.map((o) => ({ ...o, denyReason: DENY_REASONS[o.pathTemplate] })),
|
||||
};
|
||||
}
|
||||
function latency(ms: number, timedOut = false) {
|
||||
if (timedOut) return 'timeout';
|
||||
if (ms < 250) return '<250ms';
|
||||
if (ms < 1000) return '250ms-1s';
|
||||
if (ms < 3000) return '1-3s';
|
||||
return '>3s';
|
||||
}
|
||||
function statusCategory(status: number, isJson: boolean) {
|
||||
if (status === 401 || status === 403) return 'auth-required';
|
||||
if (status === 404 || status === 405 || status === 501) return 'unsupported';
|
||||
if (status >= 200 && status < 300) return isJson ? 'success' : 'non-json';
|
||||
return 'http-error';
|
||||
}
|
||||
export async function collectOne(
|
||||
instance: { origin: string; alias: string },
|
||||
op: any,
|
||||
transport: any = fetch,
|
||||
) {
|
||||
validateReadonlyOperation(op);
|
||||
const response=await transport(requestUrl,{method:'GET',headers:{accept:'application/json'},body:undefined,credentials:'omit',redirect:'manual',signal:controller.signal});
|
||||
const contentType=(response.headers.get('content-type')||'').split(';')[0].trim().toLowerCase()||null;const claimsJson=contentType==='application/json'||contentType?.endsWith('+json');
|
||||
let parsedJson=false;let payload:any;if(claimsJson){try{payload=await response.json();parsedJson=true;}catch{payload={error:'invalid-json'};}}else{await response.text();payload={text:'[REDACTED]'};}
|
||||
return {...base,statusCategory:statusCategory(response.status,parsedJson),httpStatus:response.status,contentType,latencyBucket:latency(Date.now()-started),payload:redactOperationPayload(payload,op)};
|
||||
}catch(error:any){if(String(error?.message||'').includes('Registry')||String(error?.message||'').includes('operation'))throw error;const timeout=error?.name==='AbortError';return {...base,statusCategory:timeout?'timeout':'network-error',httpStatus:null,contentType:null,latencyBucket:latency(Date.now()-started,timeout),payload:{error:timeout?'timeout':'network-error'}};}finally{clearTimeout(timer);}
|
||||
if (!['instance-1', 'instance-2'].includes(instance?.alias))
|
||||
throw new Error('invalid instance alias');
|
||||
const origin = validateInstanceOrigin(instance?.origin);
|
||||
const requestUrl = new URL(op.pathTemplate, origin);
|
||||
if (
|
||||
requestUrl.origin !== origin ||
|
||||
requestUrl.pathname !== op.pathTemplate ||
|
||||
requestUrl.search ||
|
||||
requestUrl.hash
|
||||
)
|
||||
throw new Error('unsafe collector request URL');
|
||||
const started = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 6000);
|
||||
const base = {
|
||||
schemaVersion: 1,
|
||||
upstreamBaseline: UPSTREAM_BASELINE,
|
||||
capturedAt: new Date().toISOString().slice(0, 10),
|
||||
sourceInstanceAlias: instance.alias,
|
||||
operationId: op.operationId,
|
||||
method: 'GET',
|
||||
pathTemplate: op.pathTemplate,
|
||||
redacted: true,
|
||||
};
|
||||
try {
|
||||
validateReadonlyOperation(op);
|
||||
const response = await transport(requestUrl, {
|
||||
method: 'GET',
|
||||
headers: { accept: 'application/json' },
|
||||
body: undefined,
|
||||
credentials: 'omit',
|
||||
redirect: 'manual',
|
||||
signal: controller.signal,
|
||||
});
|
||||
const contentType =
|
||||
(response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase() || null;
|
||||
const claimsJson = contentType === 'application/json' || contentType?.endsWith('+json');
|
||||
let parsedJson = false;
|
||||
let payload: any;
|
||||
if (claimsJson) {
|
||||
try {
|
||||
payload = await response.json();
|
||||
parsedJson = true;
|
||||
} catch {
|
||||
payload = { error: 'invalid-json' };
|
||||
}
|
||||
} else {
|
||||
await response.text();
|
||||
payload = { text: '[REDACTED]' };
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
statusCategory: statusCategory(response.status, parsedJson),
|
||||
httpStatus: response.status,
|
||||
contentType,
|
||||
latencyBucket: latency(Date.now() - started),
|
||||
payload: redactOperationPayload(payload, op),
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (
|
||||
String(error?.message || '').includes('Registry') ||
|
||||
String(error?.message || '').includes('operation')
|
||||
)
|
||||
throw error;
|
||||
const timeout = error?.name === 'AbortError';
|
||||
return {
|
||||
...base,
|
||||
statusCategory: timeout ? 'timeout' : 'network-error',
|
||||
httpStatus: null,
|
||||
contentType: null,
|
||||
latencyBucket: latency(Date.now() - started, timeout),
|
||||
payload: { error: timeout ? 'timeout' : 'network-error' },
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,82 +1,113 @@
|
||||
const SECRET_KEY = /(?:password|passwd|credential|api[_-]?key|access[_-]?(?:key|token|secret|id)|device[_-]?key|private[_-]?key|pin|puk|username|(?:^|[_-])user(?:$|[_-])|account|serial|revision|(?:^|[_-])path(?:$|[_-])|template|token|secret|cookie|authorization|content|body|confirmation[_-]?code)/i;
|
||||
const SECRET_KEY =
|
||||
/(?:password|passwd|credential|api[_-]?key|access[_-]?(?:key|token|secret|id)|device[_-]?key|private[_-]?key|pin|puk|username|(?:^|[_-])user(?:$|[_-])|account|serial|revision|(?:^|[_-])path(?:$|[_-])|template|token|secret|cookie|authorization|content|body|confirmation[_-]?code)/i;
|
||||
const STRUCTURED_KEY = /^(?:config)$/i;
|
||||
const LOCATION_KEY = /^(?:lat(?:itude)?|lon(?:gitude)?|cell_?id|cid|lac|tac|pci|e?nb|gnb)$/i;
|
||||
const FINGERPRINT_KEY = /^(?:uptime|traffic|temperature|.*(?:rx|tx)[_-]?(?:bytes|packets)|.*percent|(?:created|updated|captured)?_?at|timestamp|time)$/i;
|
||||
const FINGERPRINT_KEY =
|
||||
/^(?:uptime|traffic|temperature|.*(?:rx|tx)[_-]?(?:bytes|packets)|.*percent|(?:created|updated|captured)?_?at|timestamp|time)$/i;
|
||||
const SAFE_BOOLEAN_KEY = /(?:phone_number|sms_center)_is_manual$/i;
|
||||
const PHONE_KEY = /(?:phone|msisdn|recipient|number|smsc|sms_center)/i;
|
||||
const IDENTIFIER_KEY = /(?:iccid|imsi|imei|eid|matching_id)/i;
|
||||
const ADDRESS_KEY = /(?:ssid|bssid|mac|(?:^|_)(?:ip|address)(?:_|$)|hostname|host|url|webhook)/i;
|
||||
|
||||
export function typedRedact(value:any):any {
|
||||
if(Array.isArray(value)) return value.map(typedRedact);
|
||||
if(value && typeof value==='object') return Object.fromEntries(Object.entries(value).map(([k,v])=>[k,typedRedact(v)]));
|
||||
if(typeof value==='string') return '[REDACTED]';
|
||||
if(typeof value==='number') return 0;
|
||||
if(typeof value==='boolean') return false;
|
||||
return value;
|
||||
export function typedRedact(value: any): any {
|
||||
if (Array.isArray(value)) return value.map(typedRedact);
|
||||
if (value && typeof value === 'object')
|
||||
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, typedRedact(v)]));
|
||||
if (typeof value === 'string') return '[REDACTED]';
|
||||
if (typeof value === 'number') return 0;
|
||||
if (typeof value === 'boolean') return false;
|
||||
return value;
|
||||
}
|
||||
|
||||
function looksHighEntropy(s:string){
|
||||
if(s.length<16 || /\s/.test(s)) return false;
|
||||
const classes=[/[a-z]/,/[A-Z]/,/\d/,/[^A-Za-z0-9]/].filter(r=>r.test(s)).length;
|
||||
return classes>=3 || (classes>=2 && new Set(s).size>=12);
|
||||
function looksHighEntropy(s: string) {
|
||||
if (s.length < 16 || /\s/.test(s)) return false;
|
||||
const classes = [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((r) => r.test(s)).length;
|
||||
return classes >= 3 || (classes >= 2 && new Set(s).size >= 12);
|
||||
}
|
||||
function unsafeString(s:string){
|
||||
return /https?:\/\//i.test(s) || /(?:^|\s|["'=])(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?/i.test(s) ||
|
||||
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i.test(s) || /(?:[0-9a-f]{2}:){5}[0-9a-f]{2}/i.test(s) ||
|
||||
/(?:^|[^\w])(?:[0-9a-f]{0,4}:){2,}[0-9a-f:%]+(?:$|[^\w])/i.test(s) || /\b(?:\d{1,3}\.){3}\d{1,3}\b/.test(s) ||
|
||||
/(?:^|\D)\+?\d(?:[ ()-]*\d){7,}(?:$|\D)/.test(s) || /(?:\d[ -]){6,}\d/.test(s) ||
|
||||
/(?:^|\s)(?:\/Users\/|\/home\/|\/var\/|\/etc\/|[A-Za-z]:\\)\S+/i.test(s) ||
|
||||
/\b(?:token|user(?:name)?|account|path|url|host)\s*[=:]\s*\S+/i.test(s) || /\b(?:AKIA|ASIA)[A-Z0-9]{12,}\b/.test(s) || looksHighEntropy(s);
|
||||
function unsafeString(s: string) {
|
||||
return (
|
||||
/https?:\/\//i.test(s) ||
|
||||
/(?:^|\s|["'=])(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?/i.test(s) ||
|
||||
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i.test(s) ||
|
||||
/(?:[0-9a-f]{2}:){5}[0-9a-f]{2}/i.test(s) ||
|
||||
/(?:^|[^\w])(?:[0-9a-f]{0,4}:){2,}[0-9a-f:%]+(?:$|[^\w])/i.test(s) ||
|
||||
/\b(?:\d{1,3}\.){3}\d{1,3}\b/.test(s) ||
|
||||
/(?:^|\D)\+?\d(?:[ ()-]*\d){7,}(?:$|\D)/.test(s) ||
|
||||
/(?:\d[ -]){6,}\d/.test(s) ||
|
||||
/(?:^|\s)(?:\/Users\/|\/home\/|\/var\/|\/etc\/|[A-Za-z]:\\)\S+/i.test(s) ||
|
||||
/\b(?:token|user(?:name)?|account|path|url|host)\s*[=:]\s*\S+/i.test(s) ||
|
||||
/\b(?:AKIA|ASIA)[A-Z0-9]{12,}\b/.test(s) ||
|
||||
looksHighEntropy(s)
|
||||
);
|
||||
}
|
||||
|
||||
class Context {
|
||||
value(value:any,key='',force=false):any {
|
||||
if(SAFE_BOOLEAN_KEY.test(key) && typeof value==='boolean') return value;
|
||||
if(SECRET_KEY.test(key) || LOCATION_KEY.test(key) || FINGERPRINT_KEY.test(key)) return typedRedact(value);
|
||||
if(STRUCTURED_KEY.test(key)) force=true;
|
||||
if(PHONE_KEY.test(key)||IDENTIFIER_KEY.test(key)||ADDRESS_KEY.test(key)) return typedRedact(value);
|
||||
if(Array.isArray(value)) return value.map(v=>this.value(v,'',force));
|
||||
if(value && typeof value==='object') return Object.fromEntries(Object.entries(value).map(([k,v])=>[k,this.value(v,k,force)]));
|
||||
if(force) return typedRedact(value);
|
||||
if(typeof value==='string'){
|
||||
const t=value.trim();
|
||||
if((t.startsWith('{')&&t.endsWith('}'))||(t.startsWith('[')&&t.endsWith(']'))){try{return JSON.stringify(this.value(JSON.parse(value)));}catch{}}
|
||||
return unsafeString(value)?'[REDACTED]':value;
|
||||
value(value: any, key = '', force = false): any {
|
||||
if (SAFE_BOOLEAN_KEY.test(key) && typeof value === 'boolean') return value;
|
||||
if (SECRET_KEY.test(key) || LOCATION_KEY.test(key) || FINGERPRINT_KEY.test(key))
|
||||
return typedRedact(value);
|
||||
if (STRUCTURED_KEY.test(key)) force = true;
|
||||
if (PHONE_KEY.test(key) || IDENTIFIER_KEY.test(key) || ADDRESS_KEY.test(key))
|
||||
return typedRedact(value);
|
||||
if (Array.isArray(value)) return value.map((v) => this.value(v, '', force));
|
||||
if (value && typeof value === 'object')
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([k, v]) => [k, this.value(v, k, force)]),
|
||||
);
|
||||
if (force) return typedRedact(value);
|
||||
if (typeof value === 'string') {
|
||||
const t = value.trim();
|
||||
if ((t.startsWith('{') && t.endsWith('}')) || (t.startsWith('[') && t.endsWith(']'))) {
|
||||
try {
|
||||
return JSON.stringify(this.value(JSON.parse(value)));
|
||||
} catch {}
|
||||
}
|
||||
return unsafeString(value) ? '[REDACTED]' : value;
|
||||
}
|
||||
if (typeof value === 'number' && Number.isInteger(value) && Math.abs(value) >= 100_000_000)
|
||||
return 0;
|
||||
return value;
|
||||
}
|
||||
if(typeof value==='number' && Number.isInteger(value) && Math.abs(value)>=100_000_000) return 0;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseResponseSensitivePath(path:string):string[] {
|
||||
if(typeof path!=='string'||!path.startsWith('$.response')) throw new Error('unsupported response sensitive path');
|
||||
const rest=path.slice('$.response'.length);
|
||||
if(!rest) return [];
|
||||
if(!/^(?:\.[A-Za-z0-9_-]+|\[\*\])+$/.test(rest)) throw new Error('unsupported response sensitive path');
|
||||
return [...rest.matchAll(/\.([A-Za-z0-9_-]+)|\[\*\]/g)].map(m=>m[1]||'*');
|
||||
export function parseResponseSensitivePath(path: string): string[] {
|
||||
if (typeof path !== 'string' || !path.startsWith('$.response'))
|
||||
throw new Error('unsupported response sensitive path');
|
||||
const rest = path.slice('$.response'.length);
|
||||
if (!rest) return [];
|
||||
if (!/^(?:\.[A-Za-z0-9_-]+|\[\*\])+$/.test(rest))
|
||||
throw new Error('unsupported response sensitive path');
|
||||
return [...rest.matchAll(/\.([A-Za-z0-9_-]+)|\[\*\]/g)].map((m) => m[1] || '*');
|
||||
}
|
||||
export function applySensitivePath(node:any,parts:string[],index=0):number {
|
||||
if(index===parts.length) return 0;
|
||||
const part=parts[index];
|
||||
if(part==='*'){
|
||||
if(!Array.isArray(node)) return 0;
|
||||
if(index===parts.length-1){for(let i=0;i<node.length;i++)node[i]=typedRedact(node[i]);return node.length;}
|
||||
return node.reduce((n,item)=>n+applySensitivePath(item,parts,index+1),0);
|
||||
}
|
||||
if(!node||typeof node!=='object'||!(part in node)) return 0;
|
||||
if(index===parts.length-1){node[part]=typedRedact(node[part]);return 1;}
|
||||
return applySensitivePath(node[part],parts,index+1);
|
||||
export function applySensitivePath(node: any, parts: string[], index = 0): number {
|
||||
if (index === parts.length) return 0;
|
||||
const part = parts[index];
|
||||
if (part === '*') {
|
||||
if (!Array.isArray(node)) return 0;
|
||||
if (index === parts.length - 1) {
|
||||
for (let i = 0; i < node.length; i++) node[i] = typedRedact(node[i]);
|
||||
return node.length;
|
||||
}
|
||||
return node.reduce((n, item) => n + applySensitivePath(item, parts, index + 1), 0);
|
||||
}
|
||||
if (!node || typeof node !== 'object' || !(part in node)) return 0;
|
||||
if (index === parts.length - 1) {
|
||||
node[part] = typedRedact(node[part]);
|
||||
return 1;
|
||||
}
|
||||
return applySensitivePath(node[part], parts, index + 1);
|
||||
}
|
||||
export function redactOperationPayload<T>(value:T,operation:any):T {
|
||||
const clone=structuredClone(value);
|
||||
for(const field of operation?.sensitiveFields||[]){
|
||||
if(field?.direction!=='response') continue;
|
||||
const parts=parseResponseSensitivePath(field.path);
|
||||
// The decoded HTTP body corresponds to $.response; parts are body-relative
|
||||
// (normally beginning with `data`), so do not add another response wrapper.
|
||||
applySensitivePath(clone,parts);
|
||||
}
|
||||
return new Context().value(clone) as T;
|
||||
export function redactOperationPayload<T>(value: T, operation: any): T {
|
||||
const clone = structuredClone(value);
|
||||
for (const field of operation?.sensitiveFields || []) {
|
||||
if (field?.direction !== 'response') continue;
|
||||
const parts = parseResponseSensitivePath(field.path);
|
||||
// The decoded HTTP body corresponds to $.response; parts are body-relative
|
||||
// (normally beginning with `data`), so do not add another response wrapper.
|
||||
applySensitivePath(clone, parts);
|
||||
}
|
||||
return new Context().value(clone) as T;
|
||||
}
|
||||
export function redact<T>(value: T): T {
|
||||
return new Context().value(value) as T;
|
||||
}
|
||||
export function redact<T>(value:T):T { return new Context().value(value) as T; }
|
||||
|
||||
@@ -1,73 +1,144 @@
|
||||
import { parseResponseSensitivePath } from './redactor.ts';
|
||||
|
||||
export type Shape={type:string,keys?:Record<string,Shape>,items?:Shape[]};
|
||||
export type Shape = { type: string; keys?: Record<string, Shape>; items?: Shape[] };
|
||||
|
||||
export function payloadShape(value:any):Shape {
|
||||
if(value===null)return {type:'null'};
|
||||
if(Array.isArray(value))return {type:'array',items:value.map(payloadShape)};
|
||||
if(typeof value==='object')return {type:'object',keys:Object.fromEntries(Object.keys(value).sort().map(k=>[k,payloadShape(value[k])]))};
|
||||
return {type:typeof value};
|
||||
export function payloadShape(value: any): Shape {
|
||||
if (value === null) return { type: 'null' };
|
||||
if (Array.isArray(value)) return { type: 'array', items: value.map(payloadShape) };
|
||||
if (typeof value === 'object')
|
||||
return {
|
||||
type: 'object',
|
||||
keys: Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.sort()
|
||||
.map((k) => [k, payloadShape(value[k])]),
|
||||
),
|
||||
};
|
||||
return { type: typeof value };
|
||||
}
|
||||
|
||||
export function validateShapeNode(node:any):void {
|
||||
if(!node||typeof node!=='object'||Array.isArray(node))throw new Error('invalid shape node');
|
||||
const allowed=node.type==='object'?['keys','type']:node.type==='array'?['items','type']:['type'];
|
||||
if(!['object','array','string','number','boolean','null'].includes(node.type)||JSON.stringify(Object.keys(node).sort())!==JSON.stringify(allowed))throw new Error('invalid shape node');
|
||||
if(node.type==='object'){
|
||||
if(!node.keys||typeof node.keys!=='object'||Array.isArray(node.keys))throw new Error('invalid shape keys');
|
||||
for(const child of Object.values(node.keys))validateShapeNode(child);
|
||||
} else if(node.type==='array'){
|
||||
if(!Array.isArray(node.items))throw new Error('invalid shape items');
|
||||
for(const child of node.items)validateShapeNode(child);
|
||||
}
|
||||
export function validateShapeNode(node: any): void {
|
||||
if (!node || typeof node !== 'object' || Array.isArray(node))
|
||||
throw new Error('invalid shape node');
|
||||
const allowed =
|
||||
node.type === 'object'
|
||||
? ['keys', 'type']
|
||||
: node.type === 'array'
|
||||
? ['items', 'type']
|
||||
: ['type'];
|
||||
if (
|
||||
!['object', 'array', 'string', 'number', 'boolean', 'null'].includes(node.type) ||
|
||||
JSON.stringify(Object.keys(node).sort()) !== JSON.stringify(allowed)
|
||||
)
|
||||
throw new Error('invalid shape node');
|
||||
if (node.type === 'object') {
|
||||
if (!node.keys || typeof node.keys !== 'object' || Array.isArray(node.keys))
|
||||
throw new Error('invalid shape keys');
|
||||
for (const child of Object.values(node.keys)) validateShapeNode(child);
|
||||
} else if (node.type === 'array') {
|
||||
if (!Array.isArray(node.items)) throw new Error('invalid shape items');
|
||||
for (const child of node.items) validateShapeNode(child);
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalPrimitive(value:any){
|
||||
return value===null||value==='[REDACTED]'||(typeof value==='number'&&value===0)||(typeof value==='boolean'&&value===false);
|
||||
function canonicalPrimitive(value: any) {
|
||||
return (
|
||||
value === null ||
|
||||
value === '[REDACTED]' ||
|
||||
(typeof value === 'number' && value === 0) ||
|
||||
(typeof value === 'boolean' && value === false)
|
||||
);
|
||||
}
|
||||
function assertCanonicalTree(value:any):void {
|
||||
if(Array.isArray(value)){for(const item of value)assertCanonicalTree(item);return;}
|
||||
if(value&&typeof value==='object'){for(const item of Object.values(value))assertCanonicalTree(item);return;}
|
||||
if(!canonicalPrimitive(value))throw new Error('non-canonical sensitive leaf');
|
||||
function assertCanonicalTree(value: any): void {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) assertCanonicalTree(item);
|
||||
return;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
for (const item of Object.values(value)) assertCanonicalTree(item);
|
||||
return;
|
||||
}
|
||||
if (!canonicalPrimitive(value)) throw new Error('non-canonical sensitive leaf');
|
||||
}
|
||||
function matchedNodes(node:any,parts:string[],i=0):any[]{
|
||||
if(i===parts.length)return [node];
|
||||
if(parts[i]==='*')return Array.isArray(node)?node.flatMap(x=>matchedNodes(x,parts,i+1)):[];
|
||||
return node&&typeof node==='object'&&parts[i] in node?matchedNodes(node[parts[i]],parts,i+1):[];
|
||||
function matchedNodes(node: any, parts: string[], i = 0): any[] {
|
||||
if (i === parts.length) return [node];
|
||||
if (parts[i] === '*')
|
||||
return Array.isArray(node) ? node.flatMap((x) => matchedNodes(x, parts, i + 1)) : [];
|
||||
return node && typeof node === 'object' && parts[i] in node
|
||||
? matchedNodes(node[parts[i]], parts, i + 1)
|
||||
: [];
|
||||
}
|
||||
// Deliberately excludes ordinary `message`; only credential-like generic keys belong here.
|
||||
const GENERIC_SENSITIVE_KEY=/(?:password|passwd|credential|api[_-]?key|access[_-]?(?:key|token|secret|id)|device[_-]?key|private[_-]?key|(?:^|[_-])pin(?:$|[_-])|puk|token|secret|cookie|authorization|confirmation[_-]?code)/i;
|
||||
export function validateSensitivePayload(payload:any,operation:any):void {
|
||||
for(const field of operation?.sensitiveFields||[]){
|
||||
if(field?.direction!=='response')continue;
|
||||
for(const node of matchedNodes(payload,parseResponseSensitivePath(field.path)))assertCanonicalTree(node);
|
||||
}
|
||||
function walk(node:any):void{
|
||||
if(Array.isArray(node)){for(const x of node)walk(x);return;}
|
||||
if(node&&typeof node==='object')for(const [key,value] of Object.entries(node)){if(GENERIC_SENSITIVE_KEY.test(key))assertCanonicalTree(value);else walk(value);}
|
||||
}
|
||||
walk(payload);
|
||||
const GENERIC_SENSITIVE_KEY =
|
||||
/(?:password|passwd|credential|api[_-]?key|access[_-]?(?:key|token|secret|id)|device[_-]?key|private[_-]?key|(?:^|[_-])pin(?:$|[_-])|puk|token|secret|cookie|authorization|confirmation[_-]?code)/i;
|
||||
export function validateSensitivePayload(payload: any, operation: any): void {
|
||||
for (const field of operation?.sensitiveFields || []) {
|
||||
if (field?.direction !== 'response') continue;
|
||||
for (const node of matchedNodes(payload, parseResponseSensitivePath(field.path)))
|
||||
assertCanonicalTree(node);
|
||||
}
|
||||
function walk(node: any): void {
|
||||
if (Array.isArray(node)) {
|
||||
for (const x of node) walk(x);
|
||||
return;
|
||||
}
|
||||
if (node && typeof node === 'object')
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (GENERIC_SENSITIVE_KEY.test(key)) assertCanonicalTree(value);
|
||||
else walk(value);
|
||||
}
|
||||
}
|
||||
walk(payload);
|
||||
}
|
||||
|
||||
const isJson=(s:any)=>typeof s==='string'&&(s==='application/json'||s.endsWith('+json'));
|
||||
const exactPayload=(actual:any,expected:any)=>JSON.stringify(actual)===JSON.stringify(expected);
|
||||
export function validateStatusContract(f:any):void {
|
||||
const status=f.httpStatus,category=f.statusCategory;
|
||||
if(category==='success'){
|
||||
if(!(Number.isInteger(status)&&status>=200&&status<300&&isJson(f.contentType)))throw new Error('inconsistent success status');
|
||||
}else if(category==='auth-required'){
|
||||
if(![401,403].includes(status))throw new Error('inconsistent auth status');
|
||||
}else if(category==='unsupported'){
|
||||
if(![404,405,501].includes(status))throw new Error('inconsistent unsupported status');
|
||||
}else if(category==='non-json'){
|
||||
const valid=Number.isInteger(status)&&status>=200&&status<300&&((!isJson(f.contentType)&&exactPayload(f.payload,{text:'[REDACTED]'}))||(isJson(f.contentType)&&exactPayload(f.payload,{error:'invalid-json'})));
|
||||
if(!valid)throw new Error('inconsistent non-json status');
|
||||
}else if(category==='http-error'){
|
||||
// Redirects are observed with redirect:'manual' and are non-success HTTP outcomes.
|
||||
if(!(Number.isInteger(status)&&status>=300&&status<600&&![401,403,404,405,501].includes(status)))throw new Error('inconsistent http error status');
|
||||
}else if(category==='timeout'){
|
||||
if(status!==null||f.contentType!==null||f.latencyBucket!=='timeout'||!exactPayload(f.payload,{error:'timeout'}))throw new Error('inconsistent timeout status');
|
||||
}else if(category==='network-error'){
|
||||
if(status!==null||f.contentType!==null||f.latencyBucket==='timeout'||!exactPayload(f.payload,{error:'network-error'}))throw new Error('inconsistent network status');
|
||||
}else throw new Error('unknown status category');
|
||||
}
|
||||
const isJson = (s: any) =>
|
||||
typeof s === 'string' && (s === 'application/json' || s.endsWith('+json'));
|
||||
const exactPayload = (actual: any, expected: any) =>
|
||||
JSON.stringify(actual) === JSON.stringify(expected);
|
||||
export function validateStatusContract(f: any): void {
|
||||
const status = f.httpStatus,
|
||||
category = f.statusCategory;
|
||||
if (category === 'success') {
|
||||
if (!(Number.isInteger(status) && status >= 200 && status < 300 && isJson(f.contentType)))
|
||||
throw new Error('inconsistent success status');
|
||||
} else if (category === 'auth-required') {
|
||||
if (![401, 403].includes(status)) throw new Error('inconsistent auth status');
|
||||
} else if (category === 'unsupported') {
|
||||
if (![404, 405, 501].includes(status)) throw new Error('inconsistent unsupported status');
|
||||
} else if (category === 'non-json') {
|
||||
const valid =
|
||||
Number.isInteger(status) &&
|
||||
status >= 200 &&
|
||||
status < 300 &&
|
||||
((!isJson(f.contentType) && exactPayload(f.payload, { text: '[REDACTED]' })) ||
|
||||
(isJson(f.contentType) && exactPayload(f.payload, { error: 'invalid-json' })));
|
||||
if (!valid) throw new Error('inconsistent non-json status');
|
||||
} else if (category === 'http-error') {
|
||||
// Redirects are observed with redirect:'manual' and are non-success HTTP outcomes.
|
||||
if (
|
||||
!(
|
||||
Number.isInteger(status) &&
|
||||
status >= 300 &&
|
||||
status < 600 &&
|
||||
![401, 403, 404, 405, 501].includes(status)
|
||||
)
|
||||
)
|
||||
throw new Error('inconsistent http error status');
|
||||
} else if (category === 'timeout') {
|
||||
if (
|
||||
status !== null ||
|
||||
f.contentType !== null ||
|
||||
f.latencyBucket !== 'timeout' ||
|
||||
!exactPayload(f.payload, { error: 'timeout' })
|
||||
)
|
||||
throw new Error('inconsistent timeout status');
|
||||
} else if (category === 'network-error') {
|
||||
if (
|
||||
status !== null ||
|
||||
f.contentType !== null ||
|
||||
f.latencyBucket === 'timeout' ||
|
||||
!exactPayload(f.payload, { error: 'network-error' })
|
||||
)
|
||||
throw new Error('inconsistent network status');
|
||||
} else throw new Error('unknown status category');
|
||||
}
|
||||
|
||||
@@ -4,98 +4,316 @@ 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 {
|
||||
collectOne,
|
||||
selectReadonlyOperations,
|
||||
validateReadonlyOperation,
|
||||
DENY_REASONS,
|
||||
} from '../scripts/collector.ts';
|
||||
import { redact } from '../src/redactor.ts';
|
||||
import { payloadShape, validateShapeNode, validateSensitivePayload, validateStatusContract } from '../src/verifier.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'];
|
||||
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/,
|
||||
/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;}
|
||||
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'); }
|
||||
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');
|
||||
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]');
|
||||
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('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('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('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());
|
||||
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(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,90 +1,311 @@
|
||||
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 {
|
||||
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'}});
|
||||
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('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('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('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('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('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('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('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('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('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'));}
|
||||
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