Harden Windows DLP enforcement and Electron runtime packaging
This commit is contained in:
1 parent
e7d778aeac
commit
8a253eda89
120 files changed
+3569
-135
No files matched your search
@@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
const {z}=require('zod');
|
||||
const application=['vault:prepare-submit','sessions:save','sessions:list','sessions:restore','vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply'];
|
||||
const AGENT_SCOPES=Object.freeze({screenshot:'browser:screenshot',sessions:'sessions:list',navigate:'browser:navigate',back:'browser:navigate',forward:'browser:navigate',reload:'browser:navigate',read:'browser:read',inspect:'browser:read',scroll:'browser:interact',click:'browser:interact',type:'browser:interact',select:'browser:interact',check:'browser:interact',ai:'ai:request',...Object.fromEntries(application.map(action=>[action,action])),'agent-request':'agent:request'});
|
||||
const AGENT_SCOPES=Object.freeze({'network:background':'network:background',screenshot:'browser:screenshot',sessions:'sessions:list',navigate:'browser:navigate',back:'browser:navigate',forward:'browser:navigate',reload:'browser:navigate',read:'browser:read',inspect:'browser:read',scroll:'browser:interact',click:'browser:interact',type:'browser:interact',select:'browser:interact',check:'browser:interact',ai:'ai:request',...Object.fromEntries(application.map(action=>[action,action])),'agent-request':'agent:request'});
|
||||
const AgentAuditSchema=z.object({action:z.enum(Object.keys(AGENT_SCOPES)),scope:z.enum([...new Set(Object.values(AGENT_SCOPES))]),outcome:z.enum(['authorized','completed','denied','failed']),sessionId:z.string().uuid().optional(),principalId:z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$/).optional(),credentialGeneration:z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).optional()}).strict().superRefine((value,ctx)=>{
|
||||
if((value.principalId!==undefined)!==(value.credentialGeneration!==undefined))ctx.addIssue({code:'custom',message:'Agent principal attribution requires both identity and generation'});
|
||||
if(AGENT_SCOPES[value.action]!==value.scope)ctx.addIssue({code:'custom',message:'Agent action scope mismatch'});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
|
||||
export const BookmarkSchema=z.object({id:z.string().min(1).max(80).regex(/^[a-zA-Z0-9_-]+$/),title:z.string().trim().min(1).max(120).refine(v=>!/[\u0000-\u001f\u007f]/.test(v)),url:z.string().max(2048).url().refine(v=>{try{const u=new URL(v);return ['https:','http:'].includes(u.protocol)&&!u.username&&!u.password&&!u.search&&!u.hash;}catch{return false;}},'Bookmarks require HTTP(S) without credentials, query or fragment').transform(v=>new URL(v).href).refine(v=>v.length<=2048,'Canonical bookmark URL exceeds limit'),folder:z.string().trim().max(80).refine(v=>!/[\u0000-\u001f\u007f]/.test(v)).default('')}).strict();
|
||||
export const BookmarkItemsSchema=z.array(BookmarkSchema).max(100).refine(items=>new Set(items.map(i=>i.id)).size===items.length,'Bookmark IDs must be unique').refine(items=>Buffer.byteLength(JSON.stringify(items))<=250000,'Bookmark collection exceeds byte budget');
|
||||
export const PersonalBookmarksSchema=z.object({revision:z.number().int().nonnegative().safe().default(0),items:BookmarkItemsSchema.default([])}).strict().prefault({});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {z} from 'zod';
|
||||
|
||||
export function credentialActionUrlAllowed(value){
|
||||
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
|
||||
try {const u=new URL(value);return u.protocol==='https:'&&!u.username&&!u.password&&!u.search&&!u.hash&&!value.includes('?')&&!value.includes('#')&&u.href===value&&!/[\u0000-\u0020\u007f]/.test(value);}catch{return false;}
|
||||
}
|
||||
const field=z.string().min(1).max(128).regex(/^[\x21-\x7e]+$/,'Credential field names require bounded printable ASCII');
|
||||
|
||||
@@ -4,6 +4,7 @@ const path=require('node:path');
|
||||
const TYPES=['pe','elf','macho','pdf','zip','ole','png','jpeg','gif','webp','gzip','text','unknown'];
|
||||
const EXT={exe:'pe',dll:'pe',sys:'pe',elf:'elf',pdf:'pdf',zip:'zip',docx:'zip',xlsx:'zip',pptx:'zip',docm:'zip',xlsm:'zip',xlsb:'zip',pptm:'zip',dotm:'zip',xlam:'zip',ppam:'zip',doc:'ole',xls:'ole',ppt:'ole',png:'png',jpg:'jpeg',jpeg:'jpeg',gif:'gif',webp:'webp',gz:'gzip',gzip:'gzip',txt:'text',csv:'text',json:'text',html:'text',js:'text',css:'text',xml:'text',md:'text'};
|
||||
const MIME={'application/msword':'ole','application/vnd.ms-excel':'ole','application/vnd.ms-powerpoint':'ole','application/vnd.ms-word.document.macroenabled.12':'zip','application/vnd.ms-excel.sheet.macroenabled.12':'zip','application/vnd.ms-powerpoint.presentation.macroenabled.12':'zip','application/pdf':'pdf','application/zip':'zip','application/x-zip-compressed':'zip','application/vnd.openxmlformats-officedocument.wordprocessingml.document':'zip','application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':'zip','application/vnd.openxmlformats-officedocument.presentationml.presentation':'zip','image/png':'png','image/jpeg':'jpeg','image/gif':'gif','image/webp':'webp','application/gzip':'gzip','application/x-gzip':'gzip','application/json':'text','application/xml':'text','application/javascript':'text','application/x-msdownload':'pe','application/vnd.microsoft.portable-executable':'pe'};
|
||||
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
|
||||
function safeFilename(value){const name=path.win32.basename(path.posix.basename(String(value||'download.bin'))).replace(/[<>:"/\\|?*\x00-\x1f\x7f]/g,'_').replace(/[. ]+$/g,'').slice(0,180);return !name||/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name)?'download.bin':name;}
|
||||
function detectType(value){
|
||||
const b=Buffer.from(value);const starts=(hex)=>b.length>=hex.length/2&&b.subarray(0,hex.length/2).equals(Buffer.from(hex,'hex'));
|
||||
@@ -15,6 +16,7 @@ function detectType(value){
|
||||
if(b.subarray(0,6).toString('ascii')==='GIF87a'||b.subarray(0,6).toString('ascii')==='GIF89a')return 'gif';
|
||||
if(b.length>=12&&b.subarray(0,4).toString('ascii')==='RIFF'&&b.subarray(8,12).toString('ascii')==='WEBP')return 'webp';if(starts('1f8b'))return 'gzip';
|
||||
// Decode the entire bounded payload. Prefix-only heuristics miss binary tails.
|
||||
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
|
||||
try{const text=new TextDecoder('utf-8',{fatal:true}).decode(b);if(!/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(text))return 'text';}catch{}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import {z} from 'zod';
|
||||
import {createHash} from 'node:crypto';
|
||||
|
||||
const id=z.string().min(1).max(80).regex(/^[a-zA-Z0-9_-]+$/);
|
||||
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
|
||||
const label=z.string().trim().max(80).refine(v=>!/[\u0000-\u001f\u007f]/.test(v));
|
||||
export const LibraryPolicySchema=z.object({aiSuggestionsEnabled:z.boolean().default(false),aiAutoApplyEnabled:z.boolean().default(false)}).strict().prefault({});
|
||||
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
|
||||
export const LibraryTabsSchema=z.array(z.object({id,title:z.string().max(120).refine(v=>!/[\u0000-\u001f\u007f]/.test(v)),url:z.string().max(2048).url().refine(v=>['https:','http:'].includes(new URL(v).protocol))}).strict()).max(100).refine(v=>new Set(v.map(t=>t.id)).size===v.length);
|
||||
export const LibraryPlanSchema=z.object({bookmarks:z.array(z.object({id,folder:label}).strict()).max(100),tabs:z.array(z.object({id,group:label.refine(v=>v.length<=40)}).strict()).max(100)}).strict().superRefine((v,c)=>{for(const key of ['bookmarks','tabs'])if(new Set(v[key].map(i=>i.id)).size!==v[key].length)c.addIssue({code:'custom',message:'Duplicate organization IDs'});});
|
||||
export function librarySnapshotHash(tabs){return createHash('sha256').update(JSON.stringify(LibraryTabsSchema.parse(tabs))).digest('hex');}
|
||||
|
||||
@@ -13,6 +13,7 @@ export const GroupRestrictionsSchema=z.object({
|
||||
lockedSections:unique(z.enum(SETTINGS_SECTIONS),SETTINGS_SECTIONS.length).default([]),
|
||||
managedBookmarks:BookmarkItemsSchema.default([])
|
||||
}).strict();
|
||||
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
|
||||
export const PolicyGroupSchema=z.object({id,name:z.string().min(1).max(100).refine(v=>!/[\u0000-\u001f\u007f]/.test(v)),
|
||||
memberDeviceIds:unique(id,10000),restrictions:GroupRestrictionsSchema,
|
||||
rollout:z.object({stage:z.enum(['pilot','expanded','all']),deviceIds:unique(id,10000).default([])}).strict()
|
||||
|
||||
@@ -13,6 +13,7 @@ import {UserAccessSchema} from './user-profiles.mjs';
|
||||
import {LibraryPolicySchema} from './library.mjs';
|
||||
import {BrandSchema,StartPageSchema} from './branding.mjs';
|
||||
import {EvidencePolicySchema} from './evidence.mjs';
|
||||
import {inspectionTextVariants} from './text-variants.mjs';
|
||||
|
||||
const host = z.string().max(253).regex(/^(\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i);
|
||||
export const PolicySchema = z.object({
|
||||
@@ -29,6 +30,7 @@ export const PolicySchema = z.object({
|
||||
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(),
|
||||
allowAgentBackgroundWorkers: z.boolean().default(false),
|
||||
settings: ManagedSettingsSchema, bookmarks: BookmarksSchema
|
||||
}),
|
||||
proxy: z.discriminatedUnion('mode', [
|
||||
@@ -103,9 +105,9 @@ export function inspect(bytes, policy) {
|
||||
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 {}
|
||||
let variants;
|
||||
try{variants=inspectionTextVariants(text,policy.dlp.maxBytes);}
|
||||
catch{return {decision:'block',ruleId:'normalization-limit'};}
|
||||
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'};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { isIP } from 'node:net';
|
||||
export const AgentScopes=['vault:prepare-submit','sessions:save','sessions:list','sessions:restore','vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','sessions','navigate','back','forward','reload','read','inspect','scroll','click','type','select','check','screenshot','ai','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply'];
|
||||
export const AgentScopes=['network:background','vault:prepare-submit','sessions:save','sessions:list','sessions:restore','vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','sessions','navigate','back','forward','reload','read','inspect','scroll','click','type','select','check','screenshot','ai','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply'];
|
||||
const host=z.string().max(253).regex(/^(\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i);
|
||||
export const VisualOriginSchema=z.string().max(2048).refine(value=>{try{const u=new URL(value);return u.protocol==='https:'&&!u.username&&!u.password&&!u.search&&!u.hash&&u.origin===value&&!u.hostname.includes('*')&&!u.hostname.endsWith('.')&&!isIP(u.hostname.replace(/^\[|\]$/g,''))&&/^[a-z0-9.-]+$/.test(u.hostname);}catch{return false;}},'Expected a canonical exact HTTPS domain origin');
|
||||
export const VisualViewportSchema=z.object({width:z.number().int().min(16).max(8192),height:z.number().int().min(16).max(8192)}).strict();
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Bounded decoding for common wire encodings. This is not arbitrary deobfuscation.
|
||||
export function inspectionTextVariants(text,maxBytes){
|
||||
const values=[],seen=new Set();let size=0,candidates=0;
|
||||
const budget=Math.min(maxBytes*4,16*1024*1024);
|
||||
function add(value,depth){
|
||||
if(typeof value!=='string'||seen.has(value))return;
|
||||
if(depth>3)throw Error('Inspection normalization limit');
|
||||
const bytes=Buffer.byteLength(value);
|
||||
if(values.length>=64||bytes>maxBytes||size+bytes>budget)throw Error('Inspection normalization limit');
|
||||
if(value.includes('\0'))return;
|
||||
seen.add(value);size+=bytes;values.push({value,depth});
|
||||
}
|
||||
add(text,0);
|
||||
for(let i=0;i<values.length;i++){
|
||||
const {value,depth}=values[i];
|
||||
try{add(decodeURIComponent(value.replace(/\+/g,' ')),depth+1);}catch(error){if(error.message==='Inspection normalization limit')throw error;}
|
||||
try{const parsed=JSON.parse(value);const queue=[parsed];let nodes=0;
|
||||
while(queue.length){if(++nodes>4096)throw Error('Inspection normalization limit');const node=queue.pop();
|
||||
if(typeof node==='string')add(node,depth+1);
|
||||
else if(node&&typeof node==='object'){
|
||||
const children=Object.values(node);
|
||||
if(nodes+queue.length+children.length>4096)throw Error('Inspection normalization limit');
|
||||
for(const child of children)queue.push(child);
|
||||
}
|
||||
}
|
||||
}catch(error){if(error.message==='Inspection normalization limit')throw error;}
|
||||
for(const match of value.matchAll(/[A-Za-z0-9+/_-]{12,}={0,2}/g)){
|
||||
const encoded=match[0].replace(/-/g,'+').replace(/_/g,'/'),raw=Buffer.from(encoded,'base64');
|
||||
if(raw.toString('base64').replace(/=+$/,'')!==encoded.replace(/=+$/,''))continue;
|
||||
try{const decoded=new TextDecoder('utf-8',{fatal:true}).decode(raw);
|
||||
if(decoded.includes('\0'))continue;
|
||||
if(++candidates>256)throw Error('Inspection normalization limit');
|
||||
add(decoded,depth+1);
|
||||
}catch(error){if(error.message==='Inspection normalization limit')throw error;}
|
||||
}
|
||||
}
|
||||
return values.map(row=>row.value);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ const unique=(schema,max)=>z.array(schema).max(max).refine(a=>new Set(a).size===
|
||||
const host=z.string().max(253).regex(/^(\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i);
|
||||
export const UserAccessSchema=z.object({delegatableFeatures:z.object({devTools:z.boolean().default(false)}).strict().prefault({})}).strict().prefault({});
|
||||
export const DomainCategorySchema=z.object({id,name:z.string().min(1).max(100),hosts:unique(host,500)}).strict();
|
||||
export const UserProfileSchema=z.object({id,name:z.string().min(1).max(100),subjects:unique(z.string().min(1).max(512),10000).default([]),deviceIds:unique(id,10000).default([]),allowedHosts:unique(host,500).default([]),blockedHosts:unique(host,500).default([]),blockedCategoryIds:unique(id,100).default([]),features:z.object({devTools:z.enum(['inherit','allow','deny']).default('inherit'),downloads:z.enum(['inherit','deny']).default('inherit'),ai:z.enum(['inherit','deny']).default('inherit'),agents:z.enum(['inherit','deny']).default('inherit')}).strict().prefault({}),requireDocumentScan:z.boolean().default(false)}).strict();
|
||||
export const UserProfileSchema=z.object({id,name:z.string().min(1).max(100),subjects:unique(z.string().min(1).max(512),10000).default([]),deviceIds:unique(id,10000).default([]),allowedHosts:unique(host,500).default([]),blockedHosts:unique(host,500).default([]),blockedCategoryIds:unique(id,100).default([]),features:z.object({devTools:z.enum(['inherit','allow','deny']).default('inherit'),downloads:z.enum(['inherit','deny']).default('inherit'),ai:z.enum(['inherit','deny']).default('inherit'),agents:z.enum(['inherit','deny']).default('inherit'),agentBackgroundWorkers:z.enum(['inherit','deny']).default('inherit')}).strict().prefault({}),requireDocumentScan:z.boolean().default(false)}).strict();
|
||||
export const UserProfileConfigurationSchema=z.object({profiles:z.array(UserProfileSchema).max(100).default([]),categories:z.array(DomainCategorySchema).max(100).default([])}).strict();
|
||||
export function profileApplies(profile,device){return profile.deviceIds.includes(device.id)||!!device.verified_subject&&profile.subjects.includes(device.verified_subject);}
|
||||
const covers=(a,b)=>a===b||a.startsWith('*.')&&b.endsWith(a.slice(1));
|
||||
@@ -18,6 +18,7 @@ export function applyUserProfiles(policy,configuration,device){
|
||||
if(p.features.downloads==='deny')result.browser.allowDownloads=false;
|
||||
if(p.features.ai==='deny')result.ai.enabled=false;
|
||||
if(p.features.agents==='deny')result.agents.enabled=false;
|
||||
if(p.features.agentBackgroundWorkers==='deny')result.browser.allowAgentBackgroundWorkers=false;
|
||||
if(p.requireDocumentScan)result.inspection.documentAnalysisEnabled=true;
|
||||
}
|
||||
if(profiles.some(p=>p.features.devTools==='deny'))result.browser.allowDevTools=false;
|
||||
|
||||
Reference in new issue
Block a user