Files
safetynet/packages/core/policy.mjs
T

139 lines
9.8 KiB
JavaScript
Raw Normal View History

2026-10-13 10:33:31 +02:00
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';
2026-10-13 10:33:31 +02:00
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,
2026-10-13 10:33:31 +02:00
fileTransfers:FileTransfersSchema.prefault({}),
library:LibraryPolicySchema,
2026-10-13 10:33:31 +02:00
expiresAt: z.string().datetime(),
brand: BrandSchema,
startPage:StartPageSchema,
2026-10-13 10:33:31 +02:00
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;
}