Files
safetynet/apps/network-broker/audit-gate.cjs
T

74 lines
7.8 KiB
JavaScript

'use strict';
const {randomUUID}=require('node:crypto');
const {isIP}=require('node:net');
const {z}=require('zod');
const {FileFactsSchema}=require('../../packages/core/transfer-facts.cjs');
const {RequestFactsSchema}=require('../../packages/core/request-transfer-facts.cjs');
const {parseAuthority}=require('./tls-authority.cjs');
const {GateError}=require('./http-request-gate.cjs');
const {BrokerAuditEventSchema,BrokerFactsSchema,BROKER_REASONS}=require('../../packages/core/broker-audit.cjs');
const reasons=BROKER_REASONS;
const builtinRuleIds=Object.freeze(['private-key','aws-access-key','api-secret','content-blocklist','size-limit','uninspectable','normalization-limit','file-transfer-size-limit','file-transfer-type-blocked','file-transfer-type-not-allowed','file-transfer-unknown','file-transfer-extension-mismatch','file-transfer-mime-mismatch','inspection-incomplete']);
const FactsSchema=RequestFactsSchema.extend({host:z.string().max(253).optional(),reason:z.enum(reasons).optional(),matchedRuleId:z.string().min(1).max(100).optional(),broker:BrokerFactsSchema.optional()}).strict();
// Existing /api/device/events-compatible subset. Network request digests are
// intentionally absent: bodies can contain passwords or credential envelopes.
const fail=code=>{throw new GateError(code);};
function freeze(event){if(event.request)Object.freeze(event.request);if(event.file)Object.freeze(event.file);if(event.broker)Object.freeze(event.broker);return Object.freeze(event);}
/** persist is a trusted durable store adapter, not a telemetry exporter. Its
* receipt is issued ONLY after persistence succeeds; an asynchronous queue
* enqueue or successful OTLP request is not a durability acknowledgment.
* No retry occurs when persistence outcome is uncertain.
*/
function createBrokerAudit({persist,current,policyVersion,mandatory=true,correlation,allowedRuleIds=[]}={}){
if(typeof current!=='function'||!Number.isSafeInteger(policyVersion)||policyVersion<1||typeof mandatory!=='boolean'||persist!==undefined&&typeof persist!=='function'||correlation!==undefined&&typeof correlation!=='function')fail('audit-configuration-invalid');
if(!Array.isArray(allowedRuleIds)||allowedRuleIds.length>20000||allowedRuleIds.some(id=>typeof id!=='string'||!id||id.length>100||[...id].some(character=>character.charCodeAt(0)<32||character.charCodeAt(0)===127)))fail('audit-configuration-invalid');
// Copy the trusted signed-policy IDs; callers cannot expand authorization
// later by mutating their original list. These are IDs, never rule bodies.
const knownRules=new Set([...builtinRuleIds,...allowedRuleIds]);
const isCurrent=()=>{try{return current()===true;}catch{return false;}};
return Object.freeze({
async recordDownload(decision,input){
const schema=FileFactsSchema.extend({host:z.string().max(253).optional(),matchedRuleId:z.string().min(1).max(100).optional(),broker:BrokerFactsSchema}).strict();
let facts;try{if(!input||Object.values(Object.getOwnPropertyDescriptors(input)).some(value=>!('value'in value)))fail('audit-facts-invalid');facts=schema.parse(input);}catch{fail('audit-facts-invalid');}
if(!['allow','block'].includes(decision)||facts.matchedRuleId&&(decision==='allow'||!knownRules.has(facts.matchedRuleId)))fail('audit-facts-invalid');
if(facts.host!==undefined){try{if(parseAuthority(isIP(facts.host)===6?'['+facts.host+']':facts.host).hostname!==facts.host)fail('audit-facts-invalid');}catch{fail('audit-facts-invalid');}}
if(decision==='allow'&&!isCurrent())fail('authority-expired');if(!persist)fail('audit-unavailable');
let event;try{event=freeze(BrokerAuditEventSchema.parse({eventId:randomUUID(),kind:decision==='allow'?'download.scan':'download.block',ruleId:facts.matchedRuleId||facts.broker.reason,policyVersion,...(facts.host?{host:facts.host}:{}),file:{bytes:facts.bytes,detectedType:facts.detectedType,sha256:facts.sha256},broker:facts.broker}));}catch{fail('audit-facts-invalid');}
let receipt;try{receipt=await persist(event);if(!receipt||Object.values(Object.getOwnPropertyDescriptors(receipt)).some(value=>!('value'in value))||receipt.persisted!==true||receipt.eventId!==event.eventId)fail('audit-persistence-failed');}catch{fail('audit-persistence-failed');}
if(decision==='allow'&&!isCurrent()){const error=new GateError('authority-expired');error.committedEventId=event.eventId;throw error;}return Object.freeze({persisted:true,eventId:event.eventId});
},
async record(decision,input){
if(!['allow','block','fault'].includes(decision))fail('audit-facts-invalid');
let parsed;
try{
if(!input||typeof input!=='object'||![Object.prototype,null].includes(Object.getPrototypeOf(input))||Object.values(Object.getOwnPropertyDescriptors(input)).some(descriptor=>!('value'in descriptor)))fail('audit-facts-invalid');
parsed=FactsSchema.safeParse(input);
}catch{fail('audit-facts-invalid');}
if(!parsed.success)fail('audit-facts-invalid');
const facts=parsed.data;
if(facts.matchedRuleId&&(decision==='allow'||!knownRules.has(facts.matchedRuleId)))fail('audit-facts-invalid');
const permittedReasons=facts.broker?{allow:['broker-approved'],block:['broker-scan-timeout','broker-policy-block','broker-authority-expired','broker-connect-denied','broker-auth-denied','broker-framing-block','broker-size-block','broker-request-aborted','broker-request-timeout'],fault:['broker-scan-timeout','broker-inspection-fault','broker-audit-failed','broker-upstream-failed','broker-response-invalid','broker-request-aborted','broker-request-timeout','broker-authority-expired']}:{allow:['broker-approved'],block:['broker-policy-block','broker-authority-expired'],fault:['broker-inspection-fault','broker-audit-failed']};
if(facts.reason&&!permittedReasons[decision].includes(facts.reason))fail('audit-facts-invalid');
if(facts.host!==undefined){try{if(parseAuthority(isIP(facts.host)===6?'['+facts.host+']':facts.host).hostname!==facts.host)fail('audit-facts-invalid');}catch{fail('audit-facts-invalid');}}
if(decision==='allow'&&!isCurrent())fail('authority-expired');
if(!persist){if(mandatory)fail('audit-unavailable');return Object.freeze({persisted:false});}
const trace={};
try{
const ids=correlation?.();
if(ids&&/^[a-f0-9]{32}$/.test(ids.traceId)&&!/^(0){32}$/.test(ids.traceId)&&/^[a-f0-9]{16}$/.test(ids.spanId)&&!/^(0){16}$/.test(ids.spanId))Object.assign(trace,{traceId:ids.traceId,spanId:ids.spanId});
}catch{}
// Server schema has one ruleId and no separate reason field. Preserve a
// verified matched detector ID there; otherwise use the fixed reason.
// Matched IDs are not supplied to diagnostic traces by this helper.
let event;try{event=freeze(BrokerAuditEventSchema.parse({eventId:randomUUID(),kind:decision==='allow'?'upload.authorized':'upload.block',ruleId:facts.matchedRuleId||facts.reason||(decision==='allow'?'broker-approved':decision==='block'?'broker-policy-block':'broker-inspection-fault'),policyVersion,...(facts.host?{host:facts.host}:{}),request:{bytes:facts.bytes,detectedType:facts.detectedType},...(facts.broker?{broker:Object.freeze({...facts.broker})}:{}),...trace}));}catch{fail('audit-facts-invalid');}
let receipt;try{receipt=await persist(event);}catch{fail('audit-persistence-failed');}
try{
if(!receipt||Object.values(Object.getOwnPropertyDescriptors(receipt)).some(descriptor=>!('value'in descriptor))||receipt.persisted!==true||receipt.eventId!==event.eventId)fail('audit-persistence-failed');
}catch{fail('audit-persistence-failed');}
if(decision==='allow'&&!isCurrent()){const error=new GateError('authority-expired');error.committedEventId=event.eventId;throw error;}
return Object.freeze({persisted:true,eventId:event.eventId});
}
});
}
module.exports={createBrokerAudit,BrokerAuditEventSchema};