Files
safetynet/packages/core/policy.mjs
T
2026-10-13 10:34:48 +02:00

134 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}