Files

120 lines
9.5 KiB
JavaScript

'use strict';
const {createHash}=require('node:crypto');
const {createConnectProxy}=require('./connect-inspection-proxy.cjs');
const {GateError}=require('./http-request-gate.cjs');
const {inspectFileTransfer,detectType}=require('../../packages/core/file-transfer.cjs');
const {createBrokerAudit}=require('./audit-gate.cjs');
const {createBrokerLifecycle}=require('./lifecycle-audit.cjs');
const {icapRequired}=require('../browser/enterprise-inspection.cjs');
const {inspectTextRepresentations}=require('../browser/inspect-text-representations.cjs');
const {snapshotHeaders,inspectHeaders}=require('../browser/header-inspection.cjs');
const {parseMultipart}=require('./multipart.cjs');
const {extractEncodedDocuments}=require('../../packages/core/encoded-documents.cjs');
const hash=bytes=>createHash('sha256').update(bytes).digest('hex');
/** Signed policy foundation for an isolated inspection broker. Trusted
* callbacks supply scoped certificates, real local/enterprise scanners and
* upstream transport. No classifier substitutes, enrollment or main wiring.
* A policy update invalidates this snapshot: integration must replace the
* listener with a newly authorized snapshot after protected publication.
*/
async function createManagedProxy({policyAuthority,snapshot=policyAuthority?.snapshot(),resolveCertificate,localInspector,enterpriseInspector,forward,persistAudit,auditCorrelation,deadlineMs=5000,maxClients=8,authMode='basic'}) {
if(!snapshot||!policyAuthority||typeof policyAuthority.current!=='function'||!policyAuthority.current(snapshot.token)||policyAuthority.snapshot()!==snapshot||typeof resolveCertificate!=='function'||typeof forward!=='function')throw new GateError('managed-authority-invalid');
if(typeof persistAudit!=='function')throw new GateError('managed-audit-unavailable');
const core=await import('../../packages/core/policy.mjs');
const p=snapshot.policy,current=token=>token===snapshot.token&&policyAuthority.current(token);
const check=signal=>{if(signal?.aborted)throw new GateError('aborted');if(!current(snapshot.token))throw new GateError('authority-expired');};
check();
const registeredIds=[...p.dlp.rules.map(rule=>rule.id),...p.dlp.semantic.categories.map(category=>category.id),...p.security.yara.rules.map(rule=>rule.id)];
const knownIds=new Set([...registeredIds,'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 audit=createBrokerAudit({persist:persistAudit,current:()=>current(snapshot.token),policyVersion:p.version,allowedRuleIds:registeredIds,correlation:auditCorrelation});
const lifecycle=createBrokerLifecycle(audit);
const destinationAllowed=url=>core.destinationAllowed(url,p)&&core.inspect(Buffer.from(url),p).decision==='allow';
const blocked=ruleId=>({decision:'block',ruleId});
const scanner=async(fn,bytes,signal,enterprise=false)=>{
const copy=Buffer.from(bytes),digest=hash(copy);
try {
check(signal);const result=await fn(copy,p,{signal});check(signal);
if(hash(copy)!==digest)return blocked('inspection-incomplete');
if(result?.decision==='block')return blocked(knownIds.has(result.ruleId)?result.ruleId:'inspection-incomplete');
if(result?.decision!=='allow'||result.coverage?.complete!==true||enterprise&&(result.sha256!==digest||result.bytes!==copy.length))return blocked('inspection-incomplete');
return result;
} finally {copy.fill(0);}
};
const textScan=(bytes,signal)=>inspectTextRepresentations({bytes,policy:p,isCurrent:()=>current(snapshot.token),
inspectLocal:typeof localInspector==='function'?value=>scanner(localInspector,value,signal):undefined,
inspectEnterprise:icapRequired(p,'upload')&&typeof enterpriseInspector==='function'?value=>scanner(enterpriseInspector,value,signal,true):undefined});
const textValue=async(value,signal,budget)=>{const bytes=Buffer.from(value);try{return await inspectContent(bytes,signal,undefined,budget);}finally{bytes.fill(0);}};
const inspectContent=async(bytes,signal,metadata,budget,depth=0)=>{
check(signal);const transfer=inspectFileTransfer(bytes,p,'upload',metadata);
if(transfer.decision!=='allow')return blocked(transfer.ruleId);
const verdict=core.inspect(bytes,p);
if(verdict.decision!=='allow'&&verdict.ruleId!=='uninspectable')return verdict;
if(transfer.detectedType==='text'){
if(/^image\/svg\+xml(?:;|$)/i.test(metadata?.mime||'')||/<svg(?:\s|>)/i.test(bytes.toString('utf8')))return blocked('inspection-incomplete');
if(!budget||budget.bytes<1||budget.nodes<1||budget.parts<1)return blocked('inspection-incomplete');
const decoded=extractEncodedDocuments(bytes,{jsonRequired:/^(?:application\/(?:json|[a-z0-9.+-]+\+json))(?:;|$)/i.test(metadata?.mime||''),maxDecodedBytes:budget.bytes,maxNodes:budget.nodes,maxParts:budget.parts,maxDepth:32});
try{
budget.bytes-=decoded.decodedBytes;budget.nodes-=decoded.visitedNodes;budget.parts-=decoded.parts.length;
if(decoded.complete!==true||budget.bytes<0||budget.nodes<0||budget.parts<0||depth>=4&&decoded.parts.length)return blocked('inspection-incomplete');
for(const part of decoded.parts){const result=await inspectContent(part.bytes,signal,{mime:part.mime},budget,depth+1);if(result.decision!=='allow')return result;}
check(signal);return textScan(bytes,signal);
}finally{decoded.dispose();}
}
if(typeof localInspector!=='function')return blocked('inspector-unavailable');
const local=await scanner(localInspector,bytes,signal);if(local.decision!=='allow')return local;
if(icapRequired(p,'upload')){const enterprise=await scanner(enterpriseInspector,bytes,signal,true);if(enterprise.decision!=='allow')return enterprise;}
check(signal);return {decision:'allow'};
};
return createConnectProxy({authority:snapshot.token,current,authMode,lifecycle,deadlineMs,maxClients,maxBytes:p.dlp.maxBytes,maxResponseBytes:Math.min(16777216,p.fileTransfers.download.maxBytes),
resolveCertificate:async(binding,options)=>{
check();if(!destinationAllowed(`https://${binding.authority}/`))throw new GateError('destination-denied');
const result=await resolveCertificate(binding,options);check();return result;
},
inspect:async({request,body,signal,auditContext})=>{
const classify=async()=>{
const budget={bytes:Math.min(p.dlp.maxBytes,16777216),nodes:20000,parts:64};
check();if(!destinationAllowed(request.url))return blocked('destination-denied');
if(icapRequired(p,'upload')&&typeof enterpriseInspector!=='function')return blocked('enterprise-unavailable');
const urlVerdict=await textValue(request.url,signal,budget);if(urlVerdict.decision!=='allow')return urlVerdict;
const headers=snapshotHeaders(request.headers,p);
const headerVerdict=await inspectHeaders(headers,{policy:p,inspect:core.inspect,isCurrent:()=>current(snapshot.token),
inspectLocal:typeof localInspector==='function'?bytes=>scanner(localInspector,bytes,signal):undefined,
inspectEnterprise:icapRequired(p,'upload')?bytes=>scanner(enterpriseInspector,bytes,signal,true):undefined});
if(headerVerdict.decision!=='allow')return headerVerdict;
for(const value of headers.values){const verdict=await textValue(value,signal,budget);if(verdict.decision!=='allow')return verdict;}
if(/^multipart\//i.test(request.headers['content-type']||'')){
const raw=core.inspect(body,p);if(raw.decision!=='allow'&&raw.ruleId!=='uninspectable')return raw;
const parsed=parseMultipart(body,request.headers['content-type'],{maxBytes:p.dlp.maxBytes});
try {
if(parsed.coverage?.complete!==true)return blocked('multipart-incomplete');
for(const part of parsed.parts){
for(const value of [part.name,part.filename,part.contentType].filter(value=>value!==undefined)){
const verdict=core.inspect(Buffer.from(value),p);if(verdict.decision!=='allow')return verdict;
const representation=await textValue(value,signal,budget);if(representation.decision!=='allow')return representation;
}
const verdict=await inspectContent(part.body,signal,{filename:part.filename,mime:part.contentType},budget);if(verdict.decision!=='allow')return verdict;
}
check(signal);return {decision:'allow'};
} finally {parsed.dispose();}
}
return inspectContent(body,signal,{mime:request.headers['content-type']},budget);
};
const facts={bytes:body.length,detectedType:detectType(body),host:new URL(request.url).hostname.replace(/^\[|\]$/g,'')};
let verdict;
try {verdict=await classify();}
catch {
if(signal.aborted)throw new GateError('aborted');
await auditContext.decision(current(snapshot.token)?'fault':'block',{...facts,reason:current(snapshot.token)?'broker-inspection-fault':'broker-authority-expired'});
throw new GateError('inspection-failed');
}
// This awaits a durable receipt while the original inspection job still
// owns global admission. Outer cancellation cannot free pending audit.
await auditContext.decision(verdict.decision==='allow'?'allow':'block',{...facts,...(verdict.decision!=='allow'&&knownIds.has(verdict.ruleId)?{matchedRuleId:verdict.ruleId}:{})});
check(signal);return verdict;
},
forward:value=>{check();return forward(value);}
});
}
module.exports={createManagedProxy};