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' ;
2026-10-13 16:58:15 +02:00
import { LibraryPolicySchema } from './library.mjs' ;
import { BrandSchema , StartPageSchema } from './branding.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 ({}),
2026-10-13 16:58:15 +02:00
evidence : z . object ({ enabled : z . boolean (). default ( false ), captureDownloads : z . boolean (). default ( false )}). strict (). prefault ({}),
2026-10-13 10:33:31 +02:00
fileTransfers : FileTransfersSchema . prefault ({}),
2026-10-13 16:58:15 +02:00
library : LibraryPolicySchema ,
2026-10-13 10:33:31 +02:00
expiresAt : z . string (). datetime (),
2026-10-13 16:58:15 +02:00
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 ;
}