first commit
This commit is contained in:
commit
ff482ebe23
251 files changed
+19126
No files matched your search
@@ -0,0 +1,133 @@
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user