Implement managed browser security, individual agents and observability

This commit is contained in:
Karl committed 2026-10-13 16:58:15 +02:00
1 parent ff482ebe23
commit ea448de2d4
172 files changed
+5404 -347

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
'use strict';
const {z}=require('zod');
const application=['application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply'];
const AGENT_SCOPES=Object.freeze({sessions:'sessions:list',navigate:'browser:navigate',back:'browser:navigate',forward:'browser:navigate',reload:'browser:navigate',read:'browser:read',inspect:'browser:read',scroll:'browser:interact',click:'browser:interact',type:'browser:interact',select:'browser:interact',check:'browser:interact',ai:'ai:request',...Object.fromEntries(application.map(action=>[action,action])),'agent-request':'agent:request'});
const AgentAuditSchema=z.object({action:z.enum(Object.keys(AGENT_SCOPES)),scope:z.enum([...new Set(Object.values(AGENT_SCOPES))]),outcome:z.enum(['authorized','completed','denied','failed']),sessionId:z.string().uuid().optional(),principalId:z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$/).optional(),credentialGeneration:z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).optional()}).strict().superRefine((value,ctx)=>{
if((value.principalId!==undefined)!==(value.credentialGeneration!==undefined))ctx.addIssue({code:'custom',message:'Agent principal attribution requires both identity and generation'});
if(AGENT_SCOPES[value.action]!==value.scope)ctx.addIssue({code:'custom',message:'Agent action scope mismatch'});
if(value.action==='agent-request'&&!['denied','failed'].includes(value.outcome))ctx.addIssue({code:'custom',message:'Unvalidated request cannot be authorized'});
});
// Only fixed control-plane facts cross the audit boundary; action arguments never do.
function agentAuditFacts(value){return AgentAuditSchema.parse({...value,scope:value?.scope??AGENT_SCOPES[value?.action]});}
function agentFactsForEvent(kind,value){if(!['agent.action','agent.denied'].includes(kind))return null;const result=AgentAuditSchema.safeParse(value);return result.success&&((kind==='agent.action')===['authorized','completed'].includes(result.data.outcome))?result.data:null;}
module.exports={AGENT_SCOPES,AgentAuditSchema,agentAuditFacts,agentFactsForEvent};
+8
View File
@@ -0,0 +1,8 @@
import {z} from 'zod';
export function validCompanyLogo(value){
if(typeof value!=='string'||!/^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/.test(value))return false;
const raw=value.slice(22),bytes=Buffer.from(raw,'base64');
return bytes.length>=33&&bytes.length<=128*1024&&bytes.toString('base64')===raw&&bytes.subarray(0,8).equals(Buffer.from('89504e470d0a1a0a','hex'))&&bytes.readUInt32BE(8)===13&&bytes.subarray(12,16).toString()==='IHDR'&&bytes.readUInt32BE(16)>0&&bytes.readUInt32BE(16)<=1024&&bytes.readUInt32BE(20)>0&&bytes.readUInt32BE(20)<=1024;
}
export const BrandSchema=z.object({name:z.string().trim().min(1).max(40),color:z.string().regex(/^#[0-9a-f]{6}$/i),logoDataUrl:z.string().max(180000).refine(validCompanyLogo,'Company logo must be a bounded PNG, at most 1024 × 1024').optional()}).strict();
export const StartPageSchema=z.object({title:z.string().max(100).default('Welcome'),subtitle:z.string().max(240).default(''),newsEnabled:z.boolean().default(false),feedUrl:z.string().max(2048).refine(v=>{try{const u=new URL(v);return u.protocol==='https:'&&!u.username&&!u.password&&!u.hash;}catch{return false;}},'Company feed requires HTTPS without credentials or fragment').optional()}).strict().prefault({}).superRefine((p,c)=>{if(p.newsEnabled&&!p.feedUrl)c.addIssue({code:'custom',path:['feedUrl'],message:'Enabled company news requires a feed URL'});});
+4 -4
View File
@@ -1,16 +1,16 @@
'use strict';
const {createHash}=require('node:crypto');
const path=require('node:path');
const TYPES=['pe','elf','macho','pdf','zip','png','jpeg','gif','webp','gzip','text','unknown'];
const EXT={exe:'pe',dll:'pe',sys:'pe',elf:'elf',pdf:'pdf',zip:'zip',docx:'zip',xlsx:'zip',pptx:'zip',png:'png',jpg:'jpeg',jpeg:'jpeg',gif:'gif',webp:'webp',gz:'gzip',gzip:'gzip',txt:'text',csv:'text',json:'text',html:'text',js:'text',css:'text',xml:'text',md:'text'};
const MIME={'application/pdf':'pdf','application/zip':'zip','application/x-zip-compressed':'zip','application/vnd.openxmlformats-officedocument.wordprocessingml.document':'zip','application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':'zip','application/vnd.openxmlformats-officedocument.presentationml.presentation':'zip','image/png':'png','image/jpeg':'jpeg','image/gif':'gif','image/webp':'webp','application/gzip':'gzip','application/x-gzip':'gzip','application/json':'text','application/xml':'text','application/javascript':'text','application/x-msdownload':'pe','application/vnd.microsoft.portable-executable':'pe'};
const TYPES=['pe','elf','macho','pdf','zip','ole','png','jpeg','gif','webp','gzip','text','unknown'];
const EXT={exe:'pe',dll:'pe',sys:'pe',elf:'elf',pdf:'pdf',zip:'zip',docx:'zip',xlsx:'zip',pptx:'zip',docm:'zip',xlsm:'zip',xlsb:'zip',pptm:'zip',dotm:'zip',xlam:'zip',ppam:'zip',doc:'ole',xls:'ole',ppt:'ole',png:'png',jpg:'jpeg',jpeg:'jpeg',gif:'gif',webp:'webp',gz:'gzip',gzip:'gzip',txt:'text',csv:'text',json:'text',html:'text',js:'text',css:'text',xml:'text',md:'text'};
const MIME={'application/msword':'ole','application/vnd.ms-excel':'ole','application/vnd.ms-powerpoint':'ole','application/vnd.ms-word.document.macroenabled.12':'zip','application/vnd.ms-excel.sheet.macroenabled.12':'zip','application/vnd.ms-powerpoint.presentation.macroenabled.12':'zip','application/pdf':'pdf','application/zip':'zip','application/x-zip-compressed':'zip','application/vnd.openxmlformats-officedocument.wordprocessingml.document':'zip','application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':'zip','application/vnd.openxmlformats-officedocument.presentationml.presentation':'zip','image/png':'png','image/jpeg':'jpeg','image/gif':'gif','image/webp':'webp','application/gzip':'gzip','application/x-gzip':'gzip','application/json':'text','application/xml':'text','application/javascript':'text','application/x-msdownload':'pe','application/vnd.microsoft.portable-executable':'pe'};
function safeFilename(value){const name=path.win32.basename(path.posix.basename(String(value||'download.bin'))).replace(/[<>:"/\\|?*\x00-\x1f\x7f]/g,'_').replace(/[. ]+$/g,'').slice(0,180);return !name||/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name)?'download.bin':name;}
function detectType(value){
const b=Buffer.from(value);const starts=(hex)=>b.length>=hex.length/2&&b.subarray(0,hex.length/2).equals(Buffer.from(hex,'hex'));
// MZ is blocked as PE-family even if the header is corrupt or deliberately truncated.
if(starts('4d5a'))return 'pe';if(starts('7f454c46'))return 'elf';
if(['feedface','cefaedfe','feedfacf','cffaedfe','cafebabe','bebafeca','cafebabf','bfbafeca'].some(starts))return 'macho';
if(starts('255044462d'))return 'pdf';if(['504b0304','504b0506','504b0708'].some(starts))return 'zip';
if(starts('d0cf11e0a1b11ae1'))return 'ole';if(starts('255044462d'))return 'pdf';if(['504b0304','504b0506','504b0708'].some(starts))return 'zip';
if(starts('89504e470d0a1a0a'))return 'png';if(starts('ffd8ff'))return 'jpeg';
if(b.subarray(0,6).toString('ascii')==='GIF87a'||b.subarray(0,6).toString('ascii')==='GIF89a')return 'gif';
if(b.length>=12&&b.subarray(0,4).toString('ascii')==='RIFF'&&b.subarray(8,12).toString('ascii')==='WEBP')return 'webp';if(starts('1f8b'))return 'gzip';
+1 -1
View File
@@ -2,4 +2,4 @@ import {z} from 'zod';
import transfer from './file-transfer.cjs';
export const {TYPES,safeFilename,detectType,inspectFileTransfer}=transfer;
const DirectionSchema=z.object({maxBytes:z.number().int().min(1).max(20*1024*1024).default(20*1024*1024),allowTypes:z.array(z.enum(TYPES)).max(TYPES.length).default([]),blockedTypes:z.array(z.enum(TYPES)).max(TYPES.length).default([]),blockUnknown:z.boolean().default(false),blockExtensionMismatch:z.boolean().default(false),blockMimeMismatch:z.boolean().default(false),allowWithoutMalwareScanner:z.boolean().default(false)}).strict();
export const FileTransfersSchema=z.object({upload:DirectionSchema.prefault({}),download:DirectionSchema.prefault({})}).strict();
export const FileTransfersSchema=z.object({upload:DirectionSchema.prefault({}),download:DirectionSchema.extend({requireDocumentDlp:z.boolean().default(true)}).prefault({})}).strict();
+12
View File
@@ -0,0 +1,12 @@
'use strict';
function validateStages(envelope,now=Date.now()){try{
const own=(object,key)=>{const d=Object.getOwnPropertyDescriptor(object,key);return d&&'value'in d?d.value:undefined;};
if(!envelope||typeof envelope!=='object')return null;const base=own(envelope,'baseUnixMs'),records=own(envelope,'records');if(!Number.isSafeInteger(base)||base<now-60000||base>now+2000||!Array.isArray(records)||records.length>64)return null;
const allowed=new Set(['inspect','parse','ocr','yara','semantic','macros','ocr.load','semantic.load']),seen=new Map(),validated=[];
const identifier=value=>Number.isSafeInteger(value)&&value>=0||typeof value==='string'&&/^[A-Za-z0-9_-]{1,64}$/.test(value);
for(let i=0;i<records.length;i++){const record=own(records,String(i));if(!record||typeof record!=='object')return null;const id=own(record,'id'),parentId=own(record,'parentId'),stage=own(record,'stage'),start=own(record,'startOffsetMs'),duration=own(record,'durationMs'),status=own(record,'status');if(!identifier(id)||seen.has(id)||parentId!==null&&!seen.has(parentId)||!allowed.has(stage)||!Number.isFinite(start)||!Number.isFinite(duration)||start<0||duration<0||start+duration>30000||base+start+duration>now+2000||!['ok','error'].includes(status))return null;
const item={id,parentId,stage,start,duration,status};seen.set(id,item);validated.push(item);
}
return {baseUnixMs:base,records:validated.map(item=>({id:item.id,parentId:item.parentId,stage:item.stage,startOffsetMs:item.start,durationMs:item.duration,status:item.status}))};
}catch{return null;}}
module.exports={validateStages};
+20
View File
@@ -0,0 +1,20 @@
import {z} from 'zod';
import {createHash} from 'node:crypto';
const id=z.string().min(1).max(80).regex(/^[a-zA-Z0-9_-]+$/);
const label=z.string().trim().max(80).refine(v=>!/[\u0000-\u001f\u007f]/.test(v));
export const LibraryPolicySchema=z.object({aiSuggestionsEnabled:z.boolean().default(false),aiAutoApplyEnabled:z.boolean().default(false)}).strict().prefault({});
export const LibraryTabsSchema=z.array(z.object({id,title:z.string().max(120).refine(v=>!/[\u0000-\u001f\u007f]/.test(v)),url:z.string().max(2048).url().refine(v=>['https:','http:'].includes(new URL(v).protocol))}).strict()).max(100).refine(v=>new Set(v.map(t=>t.id)).size===v.length);
export const LibraryPlanSchema=z.object({bookmarks:z.array(z.object({id,folder:label}).strict()).max(100),tabs:z.array(z.object({id,group:label.refine(v=>v.length<=40)}).strict()).max(100)}).strict().superRefine((v,c)=>{for(const key of ['bookmarks','tabs'])if(new Set(v[key].map(i=>i.id)).size!==v[key].length)c.addIssue({code:'custom',message:'Duplicate organization IDs'});});
export function librarySnapshotHash(tabs){return createHash('sha256').update(JSON.stringify(LibraryTabsSchema.parse(tabs))).digest('hex');}
export function validateLibraryPlan(value,{bookmarks,tabs}){
const plan=LibraryPlanSchema.parse(value);
for(const [key,items] of [['bookmarks',bookmarks],['tabs',tabs]]){
const ids=new Set(items.map(i=>i.id));
if(plan[key].length!==ids.size||plan[key].some(i=>!ids.has(i.id)))throw Error('Organization must contain each existing item exactly once');
}
return plan;
}
// Query strings, fragments, credentials and path components are never needed
// to group a library. Titles remain untrusted data and are inspected by DLP.
export function libraryMetadata(items){return items.map(({id,title,url})=>({id,title,host:new URL(url).hostname}));}
+16 -10
View File
@@ -1,8 +1,10 @@
'use strict';
// Application-layer policy encryption over authenticated HTTPS. No TLS replacement.
// Fixed, domain-separated application-layer channels over authenticated HTTPS.
// The server owns one-use challenge consumption; this cryptographic module is stateless.
const c=require('node:crypto');
const VERSION='safetynet-policy-mlkem1024-mldsa87-v1';
const MAX=3*1024*1024;
function createChannel(VERSION, payloadProfile=false){
const MAX=payloadProfile?65536+1024:3*1024*1024;
const validatePayload=value=>{record(value);const encoded=bytes(value);try{if(encoded.length>65536)fail();return value;}finally{encoded.fill(0);}};
const fail=()=>{throw Error('Protected management channel refused');};
const record=value=>{if(!value||typeof value!=='object'||Array.isArray(value))fail();return value;};
function exact(value,keys){record(value);if(Object.keys(value).sort().join(',')!==keys.slice().sort().join(','))fail();}
@@ -26,10 +28,14 @@ function verifyChallenge(value,deviceId,edPublic,pqPublic,now=Date.now()){
}
function derive(shared,bundle,direction){const salt=c.createHash('sha384').update(bytes(bundle)).digest();return Buffer.from(c.hkdfSync('sha384',shared,salt,Buffer.from(VERSION+':'+direction),32));}
const aad=(bundle,direction)=>bytes({version:VERSION,id:bundle.id,deviceId:bundle.deviceId,direction});
function seal(key,value,bundle,direction){const input=bytes(value);if(input.length>MAX)fail();const iv=c.randomBytes(12),cipher=c.createCipheriv('aes-256-gcm',key,iv);cipher.setAAD(aad(bundle,direction));try{return {iv:encode(iv),ciphertext:encode(Buffer.concat([cipher.update(input),cipher.final()])),tag:encode(cipher.getAuthTag())};}finally{input.fill(0);}}
function open(key,value,bundle,direction){exact(value,['iv','ciphertext','tag']);const iv=decode(value.iv,12),tag=decode(value.tag,16);if(iv.length!==12||tag.length!==16)fail();const cipher=c.createDecipheriv('aes-256-gcm',key,iv);cipher.setAAD(aad(bundle,direction));cipher.setAuthTag(tag);let plaintext;try{plaintext=Buffer.concat([cipher.update(decode(value.ciphertext)),cipher.final()]);return JSON.parse(plaintext.toString('utf8'));}finally{plaintext?.fill(0);}}
function prepare(bundle){const {sharedKey,ciphertext}=c.encapsulate(publicKey(bundle.kemKey,'ml-kem-1024'));const requestKey=derive(sharedKey,bundle,'request'),responseKey=derive(sharedKey,bundle,'response');sharedKey.fill(0);const requestId=encode(c.randomBytes(32));try{return {request:{version:VERSION,id:bundle.id,deviceId:bundle.deviceId,kemCiphertext:encode(ciphertext),encrypted:seal(requestKey,{purpose:'policy',requestId},bundle,'request')},responseKey,requestId};}finally{requestKey.fill(0);}}
function receive(request,bundle,privateKey){exact(request,['version','id','deviceId','kemCiphertext','encrypted']);if(request.version!==VERSION||request.id!==bundle.id||request.deviceId!==bundle.deviceId||bundle.expiresAt<=Date.now())fail();const ciphertext=decode(request.kemCiphertext,1568);if(ciphertext.length!==1568)fail();const shared=c.decapsulate(privateKey,ciphertext),requestKey=derive(shared,bundle,'request'),responseKey=derive(shared,bundle,'response');shared.fill(0);try{const body=open(requestKey,request.encrypted,bundle,'request');exact(body,['purpose','requestId']);if(body.purpose!=='policy'||decode(body.requestId,32).length!==32)fail();return {responseKey,requestId:body.requestId};}catch(error){responseKey.fill(0);throw error;}finally{requestKey.fill(0);}}
function response(policy,bundle,context){return {version:VERSION,id:bundle.id,encrypted:seal(context.responseKey,{requestId:context.requestId,policy},bundle,'response')};}
function finish(value,bundle,context){exact(value,['version','id','encrypted']);if(value.version!==VERSION||value.id!==bundle.id)fail();const body=open(context.responseKey,value.encrypted,bundle,'response');exact(body,['requestId','policy']);if(body.requestId!==context.requestId)fail();return body.policy;}
module.exports={VERSION,identity,verifyIdentity,challenge,verifyChallenge,prepare,receive,response,finish};
function seal(key,value,bundle,direction){const input=bytes(value);try{if(input.length>MAX)fail();const iv=c.randomBytes(12),cipher=c.createCipheriv('aes-256-gcm',key,iv);cipher.setAAD(aad(bundle,direction));return {iv:encode(iv),ciphertext:encode(Buffer.concat([cipher.update(input),cipher.final()])),tag:encode(cipher.getAuthTag())};}finally{input.fill(0);}}
function open(key,value,bundle,direction){exact(value,['iv','ciphertext','tag']);const iv=decode(value.iv,12),tag=decode(value.tag,16);if(iv.length!==12||tag.length!==16)fail();const cipher=c.createDecipheriv('aes-256-gcm',key,iv);cipher.setAAD(aad(bundle,direction));cipher.setAuthTag(tag);let plaintext,partial,final;try{partial=cipher.update(decode(value.ciphertext));final=cipher.final();plaintext=Buffer.concat([partial,final]);return JSON.parse(plaintext.toString('utf8'));}finally{partial?.fill(0);final?.fill(0);plaintext?.fill(0);}}
function prepare(bundle,payload){if(payloadProfile){if(bundle.version!==VERSION)fail();validatePayload(payload);}const {sharedKey,ciphertext}=c.encapsulate(publicKey(bundle.kemKey,'ml-kem-1024'));const requestKey=derive(sharedKey,bundle,'request'),responseKey=derive(sharedKey,bundle,'response');sharedKey.fill(0);const requestId=encode(c.randomBytes(32));try{return {request:{version:VERSION,id:bundle.id,deviceId:bundle.deviceId,kemCiphertext:encode(ciphertext),encrypted:seal(requestKey,payloadProfile?{purpose:'evidence',requestId,payload}:{purpose:'policy',requestId},bundle,'request')},responseKey,requestId};}catch(error){responseKey.fill(0);throw error;}finally{requestKey.fill(0);}}
function receive(request,bundle,privateKey){exact(request,['version','id','deviceId','kemCiphertext','encrypted']);if(request.version!==VERSION||request.id!==bundle.id||request.deviceId!==bundle.deviceId||bundle.expiresAt<=Date.now())fail();const ciphertext=decode(request.kemCiphertext,1568);if(ciphertext.length!==1568)fail();const shared=c.decapsulate(privateKey,ciphertext),requestKey=derive(shared,bundle,'request'),responseKey=derive(shared,bundle,'response');shared.fill(0);try{const body=open(requestKey,request.encrypted,bundle,'request');exact(body,payloadProfile?['purpose','requestId','payload']:['purpose','requestId']);if(body.purpose!==(payloadProfile?'evidence':'policy')||decode(body.requestId,32).length!==32)fail();if(payloadProfile)validatePayload(body.payload);return {responseKey,requestId:body.requestId,...(payloadProfile?{payload:body.payload}:{})};}catch(error){responseKey.fill(0);throw error;}finally{requestKey.fill(0);}}
function response(policy,bundle,context){return {version:VERSION,id:bundle.id,encrypted:seal(context.responseKey,payloadProfile?{requestId:context.requestId,result:validatePayload(policy)}:{requestId:context.requestId,policy},bundle,'response')};}
function finish(value,bundle,context){exact(value,['version','id','encrypted']);if(value.version!==VERSION||value.id!==bundle.id)fail();const body=open(context.responseKey,value.encrypted,bundle,'response');exact(body,payloadProfile?['requestId','result']:['requestId','policy']);if(body.requestId!==context.requestId)fail();return payloadProfile?validatePayload(body.result):body.policy;}
return Object.freeze({VERSION,identity,verifyIdentity,challenge,verifyChallenge,prepare,receive,response,finish});
}
const policyChannel=createChannel('safetynet-policy-mlkem1024-mldsa87-v1');
const evidenceChannel=createChannel('safetynet-evidence-mlkem1024-mldsa87-v1',true);
module.exports={...policyChannel,evidenceChannel};
+6 -1
View File
@@ -9,6 +9,8 @@ import {GroupAssignmentSchema} from './policy-groups.mjs';
import {DiagnosticsSchema} from './diagnostics.mjs';
import {FileTransfersSchema} from './file-transfer.mjs';
import {UserAccessSchema} from './user-profiles.mjs';
import {LibraryPolicySchema} from './library.mjs';
import {BrandSchema,StartPageSchema} from './branding.mjs';
const host = z.string().max(253).regex(/^(\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i);
export const PolicySchema = z.object({
@@ -16,9 +18,12 @@ export const PolicySchema = z.object({
assignment:GroupAssignmentSchema.optional(),
userAccess:UserAccessSchema,
diagnostics:DiagnosticsSchema.prefault({}),
evidence:z.object({enabled:z.boolean().default(false),captureDownloads:z.boolean().default(false)}).strict().prefault({}),
fileTransfers:FileTransfersSchema.prefault({}),
library:LibraryPolicySchema,
expiresAt: z.string().datetime(),
brand: z.object({ name: z.string().min(1).max(40), color: z.string().regex(/^#[0-9a-f]{6}$/i) }),
brand: BrandSchema,
startPage:StartPageSchema,
browser: z.object({
homeUrl: z.string().url(), allowedHosts: z.array(host).max(200), blockedHosts: z.array(host).max(200),
allowDownloads: z.boolean(), allowDevTools: z.boolean(), allowWebSockets: z.boolean(),
+21
View File
@@ -0,0 +1,21 @@
'use strict';
const {z}=require('zod');
const {FileFactsSchema}=require('./transfer-facts.cjs');
// Network bodies are not independently identified files: they may contain passwords,
// OAuth tokens, multipart form secrets or binary credential envelopes. No digest field.
const RequestFactsSchema=z.object({bytes:FileFactsSchema.shape.bytes,detectedType:FileFactsSchema.shape.detectedType}).strict();
function requestTransferFacts(value){return value?RequestFactsSchema.parse(value):undefined;}
function requestFactsForTransfer(transfer){return RequestFactsSchema.parse({bytes:transfer.size,detectedType:transfer.detectedType});}
function migrateQueuedRequestFacts(queue){
if(!Array.isArray(queue))throw Error('Invalid security event queue');
return queue.map(event=>{
if(!event||!['upload.authorized','upload.block'].includes(event.kind))return event;
const {file,request,...rest}=event;
// Preserve delivery identity and decision, but discard old generic-body digest.
// Invalid legacy facts do not become a new loosely typed logging channel.
const candidate=request||file;
const parsed=RequestFactsSchema.safeParse(candidate?{bytes:candidate.bytes,detectedType:candidate.detectedType}:undefined);
return {...rest,...(parsed.success?{request:parsed.data}:{})};
});
}
module.exports={RequestFactsSchema,requestTransferFacts,requestFactsForTransfer,migrateQueuedRequestFacts};
+4 -3
View File
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { isIP } from 'node:net';
export const AgentScopes=['sessions','navigate','read','click','type','screenshot','ai','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group'];
export const AgentScopes=['sessions','navigate','back','forward','reload','read','inspect','scroll','click','type','select','check','screenshot','ai','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply'];
const host=z.string().max(253).regex(/^(\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i);
export const VisualOriginSchema=z.string().max(2048).refine(value=>{try{const u=new URL(value);return u.protocol==='https:'&&!u.username&&!u.password&&!u.search&&!u.hash&&u.origin===value&&!u.hostname.includes('*')&&!u.hostname.endsWith('.')&&!isIP(u.hostname.replace(/^\[|\]$/g,''))&&/^[a-z0-9.-]+$/.test(u.hostname);}catch{return false;}},'Expected a canonical exact HTTPS domain origin');
export const VisualViewportSchema=z.object({width:z.number().int().min(16).max(8192),height:z.number().int().min(16).max(8192)}).strict();
@@ -10,12 +10,13 @@ export const SigmaRuleSchema=z.object({id:z.string().min(1).max(80),title:z.stri
export const SecuritySchema=z.object({
clipboard:z.object({enabled:z.boolean().default(true),allowCopy:z.boolean().default(true),allowCut:z.boolean().default(true),allowPaste:z.boolean().default(true),blockSuspiciousCommands:z.boolean().default(true),failClosed:z.literal(true).default(true),maxTextBytes:z.number().int().min(1024).max(1048576).default(1048576)}).strict().prefault({}),
dns:z.object({enabled:z.boolean().default(true),blockedHosts:z.array(host).max(10000).default([]),blockedIPs:z.array(z.string().refine(v=>isIP(v)>0,'Expected exact IPv4 or IPv6 address')).max(10000).default([]),failClosed:z.literal(true).default(true)}).prefault({}),
yara:z.object({enabled:z.boolean().default(false),rules:z.array(z.object({id:z.string().min(1).max(80),name:z.string().min(1).max(100),source:z.string().min(1).max(65536)}).strict()).max(100).default([]),maxScanBytes:z.number().int().min(1024).max(20*1024*1024).default(20*1024*1024)}).prefault({}),
yara:z.object({enabled:z.boolean().default(false),scanOfficeMacros:z.boolean().default(true),rules:z.array(z.object({id:z.string().min(1).max(80),name:z.string().min(1).max(100),source:z.string().min(1).max(65536)}).strict()).max(100).default([]),maxScanBytes:z.number().int().min(1024).max(20*1024*1024).default(20*1024*1024)}).prefault({}),
content:z.object({blockedPhrases:z.array(z.string().min(1).max(4096)).max(500).default([])}).prefault({}),
sigma:z.object({rules:z.array(SigmaRuleSchema).max(100).default([])}).prefault({}),
visualPhishing:z.object({enabled:z.boolean().default(false),failClosed:z.literal(true).default(true),brands:z.array(VisualBrandSchema).max(50).default([])}).strict().prefault({})
}).prefault({});
export const AgentsSchema=z.object({enabled:z.boolean().default(false),scopes:z.array(z.enum(AgentScopes)).max(AgentScopes.length).default(['sessions']),allowedHosts:z.array(host).max(200).default([]),allowSensitiveRead:z.boolean().default(false)}).prefault({});
export const AgentGrantSchema=z.object({id:z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/),name:z.string().trim().min(1).max(100),enabled:z.boolean(),expiresAt:z.iso.datetime(),scopes:z.array(z.enum(AgentScopes)).max(AgentScopes.length).refine(v=>new Set(v).size===v.length,'Duplicate agent scopes'),allowedHosts:z.array(host).max(200),generation:z.number().int().min(1).max(Number.MAX_SAFE_INTEGER)}).strict();
export const AgentsSchema=z.object({enabled:z.boolean().default(false),identityMode:z.enum(['legacy','individual']).default('legacy'),grants:z.array(AgentGrantSchema).max(32).refine(v=>new Set(v.map(g=>g.id)).size===v.length,'Duplicate agent grant identifiers').default([]),scopes:z.array(z.enum(AgentScopes)).max(AgentScopes.length).default(['sessions']),allowedHosts:z.array(host).max(200).default([]),allowSensitiveRead:z.boolean().default(false)}).prefault({});
export function dnsAddressBlocked(address,policy){
const normal=v=>{
let result;try{result=new URL(`http://[${v}]/`).hostname.toLowerCase().replace(/^\[|\]$/g,'');}catch{return v.toLowerCase();}
+11 -6
View File
@@ -1,7 +1,10 @@
import agentAudit from './agent-audit.cjs';
import transferFacts from './transfer-facts.cjs';
import requestTransferFacts from './request-transfer-facts.cjs';
const requestFacts=(metadata,kind)=>{if(!kind?.startsWith('upload.'))return null;const old=metadata.file;const parsed=requestTransferFacts.RequestFactsSchema.safeParse(metadata.request||(old?{bytes:old.bytes,detectedType:old.detectedType}:undefined));return parsed.success?parsed.data:null;};
const facts=metadata=>{const parsed=transferFacts.FileFactsSchema.safeParse(metadata.file);return parsed.success?parsed.data:null;};
export function toECS(event,installationId='safetynet') {
const m=event.metadata,block=event.kind.includes('block')||event.kind.endsWith('.denied')||m.decision==='block',failure=event.kind.endsWith('.failure'),alert=event.kind==='sigma.alert';
const m=event.metadata,agent=agentAudit.agentFactsForEvent(event.kind,event.metadata.agent),block=event.kind.includes('block')||event.kind.endsWith('.denied')||m.decision==='block',failure=event.kind.endsWith('.failure')||agent?.outcome==='failed',alert=event.kind==='sigma.alert';
const config=event.kind.startsWith('policy.')||event.kind.startsWith('enrollment.')||event.kind==='device.revoked';
const auth=event.kind.startsWith('sso.'),clipboard=event.kind.startsWith('clipboard.');
return {
@@ -13,10 +16,10 @@ export function toECS(event,installationId='safetynet') {
...(event.device_id?{host:{id:event.device_id}}:{}),
...(m.actor?{user:{id:m.actor}}:{}),...(m.host?{destination:{domain:m.host}}:{}),
...(m.ruleId?{rule:{id:m.ruleId}}:{}),
...(facts(m)?{file:{size:facts(m).bytes,type:facts(m).detectedType,hash:{sha256:facts(m).sha256}}}:{}),
...(event.kind.startsWith('download.')&&facts(m)?{file:{size:facts(m).bytes,type:facts(m).detectedType,hash:{sha256:facts(m).sha256}}}:{}),
...(/^[a-f0-9]{32}$/.test(m.traceId||'')?{trace:{id:m.traceId}}:{}),
...(/^[a-f0-9]{16}$/.test(m.spanId||'')?{span:{id:m.spanId}}:{}),
safetynet:{policy_version:m.policyVersion??m.version??null,decision:m.decision??(block?'block':null),
safetynet:{...(agent?{agent:{action:agent.action,scope:agent.scope,outcome:agent.outcome,...(agent.sessionId?{session_id:agent.sessionId}:{}),...(agent.principalId?{principal_id:agent.principalId,credential_generation:agent.credentialGeneration}:{})}}:{}),...(requestFacts(m,event.kind)?{request:{bytes:requestFacts(m,event.kind).bytes,detected_type:requestFacts(m,event.kind).detectedType}}:{}),...(event.audit?{audit:event.audit}:{}),policy_version:m.policyVersion??m.version??null,decision:m.decision??(block?'block':null),
content_type:m.contentType??null,model:m.model??null,coverage:m.coverage??null,classification:m.classification==='local-only'?'local-only':null,
...(alert?{source_event_id:`${installationId}:${m.sourceEventId}`,severity:m.level}:{})}
};
@@ -25,12 +28,14 @@ const header=v=>String(v).replace(/\\/g,'\\\\').replace(/\|/g,'\\|').replace(/[\
const ext=v=>String(v).replace(/\\/g,'\\\\').replace(/=/g,'\\=').replace(/\r/g,'\\r').replace(/\n/g,'\\n');
const severity=level=>({low:3,medium:5,high:7,critical:10}[level]??3);
export function toCEF(event,installationId='safetynet') {
const m=event.metadata,blocked=event.kind.includes('block')||event.kind.endsWith('.denied')||m.decision==='block',alert=event.kind==='sigma.alert';
const fields={externalId:`${installationId}:${event.id}`,rt:Date.parse(event.time),act:event.kind,
const m=event.metadata,agent=agentAudit.agentFactsForEvent(event.kind,event.metadata.agent),blocked=event.kind.includes('block')||event.kind.endsWith('.denied')||m.decision==='block',alert=event.kind==='sigma.alert';
// suid identifies the verified agent capability principal, not an employee account.
// suser, when present, remains the independently supplied human actor.
const fields={...(agent?{cs2Label:'AgentAction',cs2:agent.action,cs3Label:'AgentScope',cs3:agent.scope,cs6Label:'AgentOutcome',cs6:agent.outcome,...(agent.sessionId?{requestContext:agent.sessionId}:{}),...(agent.principalId?{suid:agent.principalId,flexNumber1Label:'AgentCredentialGeneration',flexNumber1:agent.credentialGeneration}:{})}:{}),...(requestFacts(m,event.kind)?{cn3Label:'RequestBytes',cn3:requestFacts(m,event.kind).bytes,cs6Label:'DetectedRequestType',cs6:requestFacts(m,event.kind).detectedType}:{}),...(event.audit?{flexString1Label:'AuditRecordHash',flexString1:event.audit.hash,flexString2Label:'AuditSequence',flexString2:event.audit.sequence}:{}),externalId:`${installationId}:${event.id}`,rt:Date.parse(event.time),act:event.kind,
...(event.device_id?{deviceExternalId:event.device_id}:{}),...(m.actor?{suser:m.actor}:{}),
...(m.ruleId?{cs1Label:'RuleId',cs1:m.ruleId}:{}),...(m.host?{dhost:m.host}:{}),
...(m.policyVersion||m.version?{cn1Label:'PolicyVersion',cn1:m.policyVersion??m.version}:{}),
...(facts(m)?{cn2Label:'FileBytes',cn2:facts(m).bytes,cs6Label:'DetectedFileType',cs6:facts(m).detectedType,fileHash:facts(m).sha256}:{}),
...(event.kind.startsWith('download.')&&facts(m)?{cn2Label:'FileBytes',cn2:facts(m).bytes,cs6Label:'DetectedFileType',cs6:facts(m).detectedType,fileHash:facts(m).sha256}:{}),
...(/^[a-f0-9]{32}$/.test(m.traceId||'')?{cs4Label:'TraceId',cs4:m.traceId}:{}),
...(/^[a-f0-9]{16}$/.test(m.spanId||'')?{cs5Label:'SpanId',cs5:m.spanId}:{}),
...(alert?{cs2Label:'SourceEventId',cs2:`${installationId}:${m.sourceEventId}`,cs3Label:'AlertLevel',cs3:m.level}:{})};
+1 -1
View File
@@ -1,5 +1,5 @@
'use strict';
const {z}=require('zod');
const FileFactsSchema=z.object({bytes:z.number().int().min(0).max(1073741824),detectedType:z.enum(['pe','elf','macho','pdf','zip','png','jpeg','gif','webp','gzip','text','unknown']),sha256:z.string().regex(/^[a-f0-9]{64}$/)}).strict();
const FileFactsSchema=z.object({bytes:z.number().int().min(0).max(1073741824),detectedType:z.enum(['pe','elf','macho','pdf','zip','ole','png','jpeg','gif','webp','gzip','text','unknown']),sha256:z.string().regex(/^[a-f0-9]{64}$/)}).strict();
function transferFacts(value){return value?FileFactsSchema.parse(value):undefined;}
module.exports={FileFactsSchema,transferFacts};