import { z } from 'zod'; import { createHash, createPrivateKey, createPublicKey, sign, verify } from 'node:crypto'; import { SecuritySchema, AgentsSchema } from './security.mjs'; import { SemanticPolicySchema } from './semantic.mjs'; import { ManagedSettingsSchema } from './managed-settings.mjs'; import { EnterpriseSchema } from './enterprise.mjs'; import { BookmarksSchema } from './bookmarks.mjs'; 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'; import {EvidencePolicySchema} from './evidence.mjs'; const host = z.string().max(253).regex(/^(\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i); export const PolicySchema = z.object({ version: z.number().int().positive(), assignment:GroupAssignmentSchema.optional(), userAccess:UserAccessSchema, diagnostics:DiagnosticsSchema.prefault({}), evidence:EvidencePolicySchema, fileTransfers:FileTransfersSchema.prefault({}), library:LibraryPolicySchema, expiresAt: z.string().datetime(), 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(), settings: ManagedSettingsSchema, bookmarks: BookmarksSchema }), proxy: z.discriminatedUnion('mode', [ z.object({ mode: z.literal('system') }), z.object({ mode: z.literal('direct') }), z.object({ mode: z.literal('fixed_servers'), rules: z.string().min(1).max(2048), bypass: z.string().max(1024).default('') }), z.object({ mode: z.literal('pac_script'), pacUrl: z.string().url().refine(v => v.startsWith('https://')) }) ]), dlp: z.object({ maxBytes: z.number().int().min(1024).max(10 * 1024 * 1024), blockUninspectable: z.literal(true), semantic: SemanticPolicySchema, rules: z.array(z.object({ id: z.string().min(1).max(80), label: z.string().min(1).max(100), kind: z.enum(['literal', 'sha256','fragment-sha256']), value: z.string().min(1).max(4096), action: z.literal('block') }).superRefine((r,ctx) => { if(r.kind !== 'literal' && !/^[a-f0-9]{64}$/i.test(r.value)) ctx.addIssue({code:'custom',message:'Invalid SHA-256 fingerprint'}); })).max(10000) }), security: SecuritySchema, inspection:z.object({documentAnalysisEnabled:z.boolean().default(true)}).prefault({}), passwordManager:z.object({enabled:z.boolean().default(true),allowSaving:z.boolean().default(true),allowAutofill:z.boolean().default(true),idleLockSeconds:z.number().int().min(60).max(900).default(300)}).strict().prefault({}), agents: AgentsSchema, enterprise: EnterpriseSchema, ai: z.object({ enabled: z.boolean(), model: z.string().max(100),provider:z.enum(['openai-compatible','ollama']).default('openai-compatible'),trustMode:z.enum(['external','local']).default('external'),endpointId:z.string().max(80).default('default'),allowConfidentialLocal:z.boolean().default(false) }) }).strict().superRefine((p, ctx) => { if (!['https:', 'http:'].includes(new URL(p.browser.homeUrl).protocol)) ctx.addIssue({code:'custom',message:'Home must be HTTP(S)'}); if(new Set(p.dlp.rules.map(r=>r.id)).size !== p.dlp.rules.length) ctx.addIssue({code:'custom',message:'Rule IDs must be unique'}); if(p.dlp.semantic.enabled&&(!p.dlp.semantic.revision||!p.dlp.semantic.categories.length))ctx.addIssue({code:'custom',message:'Semantic enforcement requires a pinned model revision and references'}); if(new Set(p.dlp.semantic.categories.map(r=>r.id)).size!==p.dlp.semantic.categories.length)ctx.addIssue({code:'custom',message:'Semantic category IDs must be unique'}); for(const [name,rules] of [['YARA',p.security.yara.rules],['Sigma',p.security.sigma.rules]])if(new Set(rules.map(r=>r.id)).size!==rules.length)ctx.addIssue({code:'custom',message:`${name} rule IDs must be unique`}); if(new Set(p.security.visualPhishing.brands.map(b=>b.id)).size!==p.security.visualPhishing.brands.length)ctx.addIssue({code:'custom',message:'Visual brand IDs must be unique'}); if(p.security.visualPhishing.enabled&&!p.security.visualPhishing.brands.length)ctx.addIssue({code:'custom',message:'Visual protection requires explicit reference brands'}); if(Buffer.byteLength(JSON.stringify(p))>1572864)ctx.addIssue({code:'custom',message:'Signed policy exceeds the 1.5 MiB feature budget'}); }); export function defaultPolicy() { return PolicySchema.parse({ version: 1, expiresAt: new Date(Date.now()+7*86400000).toISOString(), brand: {name:'SafetyNet',color:'#137c66'}, browser:{homeUrl:'https://example.com',allowedHosts:[],blockedHosts:[],allowDownloads:false,allowDevTools:false,allowWebSockets:false}, proxy:{mode:'system'}, dlp:{maxBytes:1048576,blockUninspectable:true,rules:[]}, ai:{enabled:false,model:''} }); } export function hostMatches(hostname, pattern) { const name = hostname.toLowerCase().replace(/\.$/, ''); const p = pattern.toLowerCase(); return p.startsWith('*.') ? name.endsWith(p.slice(1)) && name !== p.slice(2) : name === p; } export function destinationAllowed(url, policy) { try { const u = new URL(url); if(!['http:','https:','ws:','wss:'].includes(u.protocol)) return false; if(u.username || u.password) return false; if(policy.browser.blockedHosts.some(p=>hostMatches(u.hostname,p))) return false; if(policy.security?.dns?.enabled && policy.security.dns.blockedHosts.some(p=>hostMatches(u.hostname,p))) return false; return !policy.browser.allowedHosts.length || policy.browser.allowedHosts.some(p=>hostMatches(u.hostname,p)); } catch { return false; } } const builtins = [ ['private-key', /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/], ['aws-access-key', /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/], ['api-secret', /\b(?:sk-[a-zA-Z0-9_-]{20,}|gh[pousr]_[a-zA-Z0-9]{20,})\b/] ]; export function fragmentHashes(text){ const normalized=text.normalize('NFKC').toLowerCase().replace(/ß/g,'ss').replace(/ς/g,'σ'),words=normalized.match(/[\p{L}\p{N}_]+/gu)||[],hashes=new Set(); const hash=s=>createHash('sha256').update(s).digest('hex');for(let i=0;i+8<=words.length;i++)hashes.add(hash(words.slice(i,i+8).join(' '))); for(const part of normalized.split(/\n\s*\n/)){const p=part.trim().replace(/\s+/gu,' ');if([...p].length>=32)hashes.add(hash(p));}return hashes; } export function inspect(bytes, policy) { const data = Buffer.from(bytes); if(data.length > policy.dlp.maxBytes) return {decision:'block',ruleId:'size-limit'}; const hash = createHash('sha256').update(data).digest('hex'); for(const r of policy.dlp.rules) if(r.kind==='sha256' && r.value.toLowerCase()===hash) return {decision:'block',ruleId:r.id}; let text; try { text = new TextDecoder('utf-8',{fatal:true}).decode(data); } catch { return {decision:'block',ruleId:'uninspectable'}; } if(text.includes('\0')) return {decision:'block',ruleId:'uninspectable'}; const variants = [text]; try { variants.push(decodeURIComponent(text.replace(/\+/g,' '))); } catch {} try { variants.push(JSON.stringify(JSON.parse(text))); } catch {} for(const value of variants) { let fragments;for(const r of policy.dlp.rules)if(r.kind==='fragment-sha256'){fragments??=fragmentHashes(value);if(fragments.has(r.value.toLowerCase()))return {decision:'block',ruleId:r.id};} for(const phrase of policy.security?.content?.blockedPhrases||[])if(value.normalize('NFKC').toLowerCase().includes(phrase.normalize('NFKC').toLowerCase()))return {decision:'block',ruleId:'content-blocklist'}; for(const [id,pattern] of builtins) if(pattern.test(value)) return {decision:'block',ruleId:id}; for(const r of policy.dlp.rules) if(r.kind==='literal' && value.normalize('NFKC').toLowerCase().includes(r.value.normalize('NFKC').toLowerCase())) return {decision:'block',ruleId:r.id}; } return {decision:'allow',ruleId:null}; } export function allowsConfidentialLocal(verdict,policy){ if(verdict.decision!=='block'||!policy.ai.enabled||policy.ai.trustMode!=='local'||!policy.ai.allowConfidentialLocal)return false; const id=verdict.ruleId; if(!id||['private-key','aws-access-key','api-secret','content-blocklist','size-limit','uninspectable'].includes(id)||/yara|malware|parser|coverage|failure|failed|timeout|unsupported|encrypted|inspection|incomplete/i.test(id)||verdict.malware||policy.security?.yara?.rules.some(r=>r.id===id))return false; return policy.dlp.rules.some(r=>r.id===id&&['literal','sha256','fragment-sha256'].includes(r.kind))||policy.dlp.semantic.categories.some(r=>r.id===id); } export function inspectForAI(bytes,policy){ const verdict=inspect(bytes,policy);if(!allowsConfidentialLocal(verdict,policy))return verdict; // Re-scan without corporate detectors so a preceding phrase cannot hide a credential or content block. const baseline=inspect(bytes,{...policy,dlp:{...policy.dlp,rules:[]}});if(baseline.decision!=='allow')return baseline; return {...verdict,decision:'allow',classification:'local-only'}; } export function signPolicy(policy, privatePem) { const payload = Buffer.from(JSON.stringify(PolicySchema.parse(policy))).toString('base64url'); return {payload,signature:sign(null,Buffer.from(payload),createPrivateKey(privatePem)).toString('base64url')}; } export function verifyPolicy(envelope, publicPem, minimumVersion=0) { if(!verify(null,Buffer.from(envelope.payload),createPublicKey(publicPem),Buffer.from(envelope.signature,'base64url'))) throw Error('Policy signature rejected'); const policy = PolicySchema.parse(JSON.parse(Buffer.from(envelope.payload,'base64url').toString())); if(policy.version < minimumVersion) throw Error('Policy rollback rejected'); if(Date.parse(policy.expiresAt)<=Date.now()) throw Error('Policy expired'); return policy; }