134 lines
9.6 KiB
JavaScript
134 lines
9.6 KiB
JavaScript
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';
|
||
|
||
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({}),
|
||
fileTransfers:FileTransfersSchema.prefault({}),
|
||
expiresAt: z.string().datetime(),
|
||
brand: z.object({ name: z.string().min(1).max(40), color: z.string().regex(/^#[0-9a-f]{6}$/i) }),
|
||
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;
|
||
}
|
||
|