Harden Windows DLP enforcement and Electron runtime packaging

This commit is contained in:
Karl committed 2026-10-13 23:19:22 +02:00
1 parent e7d778aeac
commit 8a253eda89
120 files changed
+3569 -135

No files matched your search

+70 -17
View File
@@ -1,7 +1,10 @@
const { app, BrowserWindow, WebContentsView, ipcMain, session, safeStorage, shell, dialog, powerMonitor, clipboard, Menu } = require('electron');
const {assertSecureStartup}=require('./startup-security.cjs');
const {assertPolicyAssignment}=require('./policy-assignment.cjs');
assertSecureStartup(app.commandLine);
try{assertSecureStartup(app.commandLine);}catch{
console.error('Managed browser startup denied');
process.exit(1);
}
// Force Chromium sandboxing globally before any renderer is created. The subframe
// preload option below never grants a Node engine: native proof tests verify this.
app.enableSandbox();
@@ -20,7 +23,7 @@ function diagnosticSpan(name,attributes,operation){
let diagnosticsConfiguration='';
function configureDiagnostics(config){const value=config||{enabled:false},context=diagnosticContext(),key=JSON.stringify([value,context]);if(key===diagnosticsConfiguration)return;try{diagnostics.configure(value,context);diagnosticsConfiguration=key;}catch{diagnosticsConfiguration='';try{diagnostics.configure({enabled:false});}catch{}}}
function diagnosticCorrelation(){try{const ids=diagnostics.correlation?.();return /^[a-f0-9]{32}$/.test(ids?.traceId||'')&&/[1-9a-f]/.test(ids.traceId)&&/^[a-f0-9]{16}$/.test(ids?.spanId||'')&&/[1-9a-f]/.test(ids.spanId)?{traceId:ids.traceId,spanId:ids.spanId}:{};}catch{return {};}}
let win,view,browserSession,managementSession,core,device,policy,ready=false,syncing=false,aiPanel=false,vaultPanel=false,vault,vaultTimer,fileUploadGate,uploadLeaseFatal=false;
let win,view,browserSession,managementSession,localRelay,core,device,policy,ready=false,syncing=false,aiPanel=false,vaultPanel=false,vault,vaultTimer,fileUploadGate,uploadLeaseFatal=false;
let chromeHeight=104,leftSidebar=false,startPage=false,overviewPanel=false,toolsOpen=false,pageTitle='',pageColor='#edf1f6';
const {createTabRegistry}=require('./tab-registry.cjs');
let tabs,tabsRevision=0;
@@ -39,7 +42,7 @@ const {startAgentControl}=require('./agent-control.cjs');
const {createAgentAdapters}=require('./agent-adapters.cjs');
const {createVault}=require('./vault.cjs');
const {createDeveloperToolsGuard,developerAssetRequestAllowed}=require('./developer-tools.cjs');
const {settingsState,sectionVisible,sectionAllowed,featureAllowed,requireFeature,requireSection,validateAppearance}=require('./managed-settings.cjs');
const {settingsState,sectionAllowed,featureAllowed,requireFeature,requireSection,validateAppearance}=require('./managed-settings.cjs');
const {bookmarkState,validateMutation:validateBookmarkMutation,libraryVisible}=require('./bookmarks.cjs');
let bookmarkBusy=false,personalBookmarks={revision:0,items:[]},bookmarkSnapshotJSON='',libraryOrganizer;
function effectiveBookmarkPolicy(){return policy?{...policy,browser:{...policy.browser,bookmarks:{...policy.browser.bookmarks,personal:personalBookmarks}}}:null;}
@@ -180,7 +183,7 @@ async function pageTheme(tab=activeTab()){
}catch{}
}
function lockVault(){const unlocked=vault&&!vault.status().locked;credentialBroker?.revoke();vault?.lock();if(unlocked)audit('vault.locked');emit();}
function quarantine(reason){ready=false;fileUploadGate?.cancelAll();if(policy&&Date.parse(policy.expiresAt)<=Date.now()){configureDiagnostics({enabled:false});const expiredService=agentService;agentService=null;agentServiceMode=undefined;expiredService?.revoke();void expiredService?.close().catch(()=>{});}for(const [controller,route] of managementRequests)if(route==='/api/device/ai')controller.abort();enforceEvidencePolicy();message=reason;vaultPanel=false;vault?.lock();tabs?.quarantine();emit();}
function quarantine(reason){ready=false;localRelay?.invalidate();fileUploadGate?.cancelAll();if(policy&&Date.parse(policy.expiresAt)<=Date.now()){configureDiagnostics({enabled:false});const expiredService=agentService;agentService=null;agentServiceMode=undefined;expiredService?.revoke();void expiredService?.close().catch(()=>{});}for(const [controller,route] of managementRequests)if(route==='/api/device/ai')controller.abort();enforceEvidencePolicy();message=reason;vaultPanel=false;vault?.lock();tabs?.quarantine();emit();}
function vaultSettings(){return {enabled:ready&&!!policy?.passwordManager?.enabled,allowSaving:!!policy?.passwordManager?.allowSaving,allowAutofill:!!policy?.passwordManager?.allowAutofill,allowControlledSubmission:!!policy?.passwordManager?.controlledSubmission?.enabled,idleLockSeconds:policy?.passwordManager?.idleLockSeconds||300};}
function vaultStatus(){return {...(vault?.status()||{exists:fs.existsSync(path.join(app.getPath('userData'),'password-vault.json')),locked:true}),...vaultSettings()};}
function requireVault(){
@@ -240,8 +243,9 @@ async function applyPolicyImpl(envelope){
configureDiagnostics({enabled:false});quarantine('Applying company policy…');
const proxy=next.proxy;
const settings={mode:proxy.mode,...(proxy.mode==='fixed_servers'?{proxyRules:proxy.rules,proxyBypassRules:proxy.bypass}:{}),...(proxy.mode==='pac_script'?{pacScript:proxy.pacUrl}:{})};
await browserSession.closeAllConnections();await browserSession.setProxy(settings);
localRelay.invalidate();await browserSession.closeAllConnections();
await managementSession.closeAllConnections();await managementSession.setProxy(settings);
await browserSession.setProxy({mode:'fixed_servers',proxyRules:localRelay.proxyRules,proxyBypassRules:localRelay.proxyBypassRules});
app.configureHostResolver(resolverOptions(next.enterprise?.dns));
await browserSession.clearHostResolverCache();recentRequests.clear();
await managementSession.clearHostResolverCache();
@@ -375,6 +379,7 @@ function requireLibraryAI(){
if(!ready||!policy?.ai.enabled||!policy.library?.aiSuggestionsEnabled||Date.parse(policy.expiresAt)<=Date.now())throw Error('AI library organization is disabled by company policy');
if(!policy.browser.bookmarks.allowUserEdit)throw Error('Bookmark organization is controlled by your administrator');
}
// eslint-disable-next-line no-control-regex -- Strip control characters from security-sensitive metadata.
function nativeLibraryRows(){return tabs.all().filter(tab=>!tab.startPage&&/^https?:\/\//.test(tab.view.webContents.getURL())).map(tab=>({id:tab.id,title:String(tab.title).replace(/[\u0000-\u001f\u007f]/g,' ').slice(0,120),url:tab.view.webContents.getURL()}));}
function nativeLibraryEpoch(){return JSON.stringify(tabs.all().map(tab=>[tab.id,tab.navigationId,tab.title,tab.view.webContents.getURL(),tab.group]));}
function assertAgentLibraryDestinations(snapshot){if(agentActorStorage.getStore()?.principalId&&[...(snapshot.tabs||[]),...(snapshot.bookmarks||[])].some(row=>!actorDestinationAllowed(row.url)))throw Error('Agent destination denied');}
@@ -462,9 +467,46 @@ async function dnsGateImpl(url,currentPolicy){
const addresses=resolved.endpoints?.map(v=>v.address)||[];
if(!addresses.length||addresses.some(ip=>securityCore.dnsAddressBlocked(ip,currentPolicy)))throw Error('dns-ip-blocklist');
}
function inspectOutgoingText(bytes,currentPolicy,isCurrent){
return require('./inspect-text-representations.cjs').inspectTextRepresentations({bytes,policy:currentPolicy,isCurrent,
inspectLocal:localInspector?(data,p)=>localInspector.inspect(data,p):undefined,
inspectEnterprise:(data,p)=>scanEnterprise(data,p,'upload'),
traceOperation:(operation,facts,fn)=>diagnosticSpan(operation==='text.local'?'browser.inspect.local':'browser.inspect.enterprise',{operation,bytes:facts.bytes,count:facts.count},fn)});
}
function relayAuthorityCurrent(authority){return ready&&policy===authority.policy&&device?.id===authority.deviceId&&Date.parse(policy.expiresAt)>Date.now();}
async function initializeLocalRelay(){
localRelay=await require('./local-dlp-relay.cjs').startLocalDlpRelay({
authorize:url=>{const authority={policy,deviceId:device?.id};if(!relayAuthorityCurrent(authority)||!core.destinationAllowed(url,policy)||core.inspect(Buffer.from(url),policy).decision!=='allow')throw Error('relay-target-refused');return authority;},
isCurrent:relayAuthorityCurrent,
resolveUpstream:url=>managementSession.resolveProxy(url),
resolveAddress:async(host,authority)=>{const resolved=await managementSession.resolveHost(host,{cacheUsage:'allowed'}),addresses=resolved.endpoints?.map(endpoint=>endpoint.address)||[];if(!relayAuthorityCurrent(authority)||!addresses.length||addresses.some(address=>securityCore.dnsAddressBlocked(address,authority.policy)))throw Error('relay-address-refused');return addresses[0];},
credentialsForProxy:async selected=>require('./proxy-credentials.cjs').deploymentProxyCredentials(selected)
});
// Every managed URL resolves to a non-DIRECT native proxy, including loopback.
// Enterprise DIRECT/PAC/system choices apply only behind this private relay.
await browserSession.setProxy({mode:'fixed_servers',proxyRules:localRelay.proxyRules,proxyBypassRules:localRelay.proxyBypassRules});
}
function responsePolicyHeaders(input){
if(!input||typeof input!=='object'||Array.isArray(input))throw Error('Response policy headers unavailable');
const headers={...input},keys=Object.keys(headers).filter(key=>key.toLowerCase()==='connection-allowlist');
const values=keys.flatMap(key=>Array.isArray(headers[key])?headers[key]:[headers[key]]);
if(values.some(value=>typeof value!=='string'))throw Error('Response policy headers unavailable');
let existing=values.join(',');if(existing.length>65536)throw Error('Response policy headers unavailable');
for(const key of keys)delete headers[key];
if(existing){let quoted=false,escaped=false,depth=0,split=existing.length;
for(let index=0;index<existing.length;index++){const ch=existing[index];if(escaped){escaped=false;continue;}if(quoted&&ch==='\\'){escaped=true;continue;}if(ch==='"'){quoted=!quoted;continue;}if(quoted)continue;if(ch==='(')depth++;if(ch===')'&&--depth<0)throw Error('Response policy headers unavailable');if(ch===','&&depth===0){split=index;break;}}
if(quoted||depth!==0)throw Error('Response policy headers unavailable');
existing=existing.slice(0,split).trim()+'; webrtc=block'+existing.slice(split);
}else existing='("http://*:*/*" "https://*:*/*"); redirects=allow; webrtc=block';
headers['Connection-Allowlist']=[existing];return headers;
}
function hooks(){
browserSession.setPermissionRequestHandler((_wc,_permission,callback)=>callback(false));
browserSession.setPermissionCheckHandler(()=>false);
// Native Chromium policy blocks uninspected WebRTC; HTTP(S) retains normal DLP.
browserSession.webRequest.onHeadersReceived({urls:['http://*/*','https://*/*']},(details,callback)=>{
try{callback({cancel:false,responseHeaders:responsePolicyHeaders(details.responseHeaders)});}catch{callback({cancel:true});void audit('dlp.block','response-policy-uninspectable');}
});
const leaseRoot=app.isPackaged?process.resourcesPath:path.join(__dirname,'../..');
fileUploadGate=require('./file-upload-gate.cjs').createFileUploadGate({
maxBytes:context=>context.policy.dlp.maxBytes,
@@ -527,12 +569,18 @@ function hooks(){
if(!core.destinationAllowed(details.url,policy)||(source?.security.agentAuthority&&!actorDestinationAllowed(details.url,source.security.agentAuthority))){audit('navigation.block',null,new URL(details.url).hostname);finish(true);return;}
if(credentialBroker&&source){try{credentialBroker.guardIssued(details,credentialContext(source));credentialBroker.validateRedirect(details,credentialContext(source));}catch{await audit('vault.submission-denied');message='Login submission blocked by company policy. Contact your administrator.';emit();finish(true);return;}}
recentRequests.set(`${details.webContentsId}:${details.url}`,{method:details.method,time:Date.now()});if(recentRequests.size>2000)for(const [url,value]of recentRequests)if(value.time<Date.now()-120000||recentRequests.size>2000)recentRequests.delete(url);
try{await dnsGate(details.url,initialPolicy);}catch(error){audit('dns.block',error.message,new URL(details.url).hostname);finish(true);return;}
if(called||!ready||policy!==initialPolicy||!sourceCurrent()){if(!called&&ready&&policy===initialPolicy&&!sourceCurrent())await audit('navigation.block','request-source-context-changed',new URL(details.url).hostname);finish(true);return;}
if(details.resourceType==='webSocket'&&!policy.browser.allowWebSockets){audit('dlp.block','websocket-disabled',new URL(details.url).hostname);finish(true);return;}
const address=new URL(details.url),query=address.pathname+address.search;
const queryVerdict=core.inspect(Buffer.from(query),policy);
if(queryVerdict.decision==='block'){audit('dlp.block',queryVerdict.ruleId,new URL(details.url).hostname);finish(true);return;}
if(details.resourceType==='webSocket'){audit('dlp.block','websocket-disabled',new URL(details.url).hostname);finish(true);return;}
const address=new URL(details.url),urlBytes=Buffer.from(address.href);
try{
const urlVerdict=core.inspect(urlBytes,initialPolicy);
if(urlVerdict.decision!=='allow'){await audit('dlp.block',urlVerdict.ruleId,address.hostname);finish(true);return;}
const inspected=await inspectOutgoingText(urlBytes,initialPolicy,()=>!called&&ready&&policy===initialPolicy&&Date.parse(initialPolicy.expiresAt)>Date.now()&&sourceCurrent());
if(inspected.decision!=='allow'){await audit('dlp.block',inspected.ruleId||'inspection-incomplete',address.hostname);finish(true);return;}
}finally{urlBytes.fill(0);}
if(called||!ready||policy!==initialPolicy||!sourceCurrent()){finish(true);return;}
try{await dnsGate(details.url,initialPolicy);}catch(error){audit('dns.block',error.message,address.hostname);finish(true);return;}
if(called||!ready||policy!==initialPolicy||!sourceCurrent()){finish(true);return;}
const uploads=details.uploadData||[];
if(policy.security.visualPhishing.enabled&&(uploads.length||(address.search&&details.resourceType==='xhr')||details.resourceType==='webSocket')){
const checked=source?await checkVisual(source):{decision:'unknown'};if(checked.decision!=='allow'||!ready||policy!==initialPolicy||!sourceCurrent()){finish(true);return;}
@@ -565,7 +613,7 @@ function hooks(){
const needsLocal=verdict.ruleId==='uninspectable'||policy.dlp.semantic.enabled||policy.security.yara.enabled;
if(needsLocal&&!localInspector){claim?.residual.fill(0);audit('dlp.block','inspector-unavailable',new URL(details.url).hostname);finish(true);return;}
const snapshot=claim?Buffer.from(claim.residual):Buffer.concat(uploads.map(p=>Buffer.from(p.bytes))),currentPolicy=policy;claim?.residual.fill(0);
try{let result=needsLocal?await diagnosticSpan('browser.inspect.local',{bytes:snapshot.length},()=>localInspector.inspect(snapshot,currentPolicy)):{decision:'allow'};if(result.decision==='allow')result=await diagnosticSpan('browser.inspect.enterprise',{bytes:snapshot.length},()=>scanEnterprise(snapshot,currentPolicy,'upload'));
try{let result;if(claim||transfer.detectedType==='text'){result=await inspectOutgoingText(snapshot,currentPolicy,()=>!called&&ready&&policy===currentPolicy&&Date.parse(currentPolicy.expiresAt)>Date.now()&&sourceCurrent());}else{result=needsLocal?await diagnosticSpan('browser.inspect.local',{bytes:snapshot.length},()=>localInspector.inspect(snapshot,currentPolicy)):{decision:'allow'};if(result.decision==='allow')result=await diagnosticSpan('browser.inspect.enterprise',{bytes:snapshot.length},()=>scanEnterprise(snapshot,currentPolicy,'upload'));}
const cancel=called||!ready||policy.version!==currentPolicy.version||!sourceCurrent()||result.decision!=='allow';
if(cancel){audit('dlp.block',result.ruleId||'policy-changed',new URL(details.url).hostname);finish(true);}else await releaseUpload();
}catch{audit('dlp.block','inspection-incomplete',new URL(details.url).hostname);finish(true);}finally{snapshot.fill(0);}return;
@@ -748,6 +796,7 @@ app.whenReady().then(async()=>{
core=await import('../../packages/core/policy.mjs');
clipboardCore=await import('../../packages/core/clipboard.mjs');
ipcMain.on('managed:clipboard-sandbox-failure',event=>{if(tabs?.fromContents(event.sender.id)||event.sender===win?.webContents){clipboardFatal=true;quarantine('Managed renderer security failed. Restart this browser.');}});
ipcMain.on('managed:transport-guard-failure',event=>{if(tabs?.fromContents(event.sender.id)){clipboardFatal=true;quarantine('Managed renderer security failed. Restart this browser.');void diagnosticSpan('browser.security',{decision:'block',reason:'transport-guard-unavailable'},()=>audit('navigation.block','transport-guard-unavailable')).catch(()=>{});}});
powerMonitor.on('lock-screen',lockVault);powerMonitor.on('suspend',lockVault);
securityCore=await import('../../packages/core/security.mjs');
localInspector=startInspector(app.isPackaged?process.resourcesPath:path.join(__dirname,'../..'),app.isPackaged,{onStages:stages=>diagnostics.importStages(stages)});
@@ -755,6 +804,8 @@ app.whenReady().then(async()=>{
// Management has its own cookie jar and no website request gate. It uses the
// same company proxy and Chromium DNS configuration as managed website traffic.
managementSession=session.fromPartition('persist:safetynet-management',{cache:false});
await initializeLocalRelay();
browserSession.registerPreloadScript({type:'frame',id:'managed-transport-deny',filePath:path.join(__dirname,'transport-deny.cjs')});
browserSession.registerPreloadScript({type:'frame',id:'managed-clipboard-deny',filePath:path.join(__dirname,'clipboard-deny.cjs')});
session.defaultSession.registerPreloadScript({type:'frame',id:'shell-clipboard-deny',filePath:path.join(__dirname,'clipboard-deny.cjs')});
Menu.setApplicationMenu(null);
@@ -767,7 +818,7 @@ app.whenReady().then(async()=>{
stage:async args=>{const tab=tabs.fromContents(args.webContents.id);if(tab&&args.operation==='paste'&&policy?.security.visualPhishing.enabled&&(await checkVisual(tab)).decision!=='allow')throw Error('Login protection holds paste');return executeClipboard('stage',args);},
validate:args=>executeClipboard('validate',args),commit:args=>executeClipboard('commit',args),
discard:args=>executeClipboard('discard',{...args,url:'',operation:'copy',limit:0}),
readClipboard:async limit=>{const items=await clipboard.read();if(items.length!==1)throw Error('Clipboard format unsupported');const item=items[0];if(!item.types.includes('text/plain')||item.types.some(type=>type!=='text/plain'&&!/^electron application\/osclipboard;format="(?:CF_TEXT|CF_UNICODETEXT|CF_LOCALE)"$/.test(type)))throw Error('Rich clipboard denied');const blob=await item.getType('text/plain');if(blob.size>limit)throw Error('Clipboard size limit');return blob.text();},
readClipboard:async limit=>{const items=await clipboard.read();if(items.length!==1)throw Error('Clipboard format unsupported');const item=items[0];if(!item.types.includes('text/plain')||item.types.some(type=>type!=='text/plain'&&type!=='text/html'&&!/^electron application\/osclipboard;format="(?:CF_TEXT|CF_UNICODETEXT|CF_LOCALE|HTML Format)"$/.test(type)))throw Error('Rich clipboard denied');const blob=await item.getType('text/plain');if(blob.size>limit)throw Error('Clipboard size limit');return blob.text();},
writeClipboard:text=>clipboard.writeText(text),
inspect:async(bytes,currentPolicy,operation)=>{let verdict=clipboardCore.inspectClipboard(bytes,currentPolicy,operation);if(verdict.decision!=='allow')return verdict;if(currentPolicy.dlp.semantic.enabled||currentPolicy.security.yara.enabled){if(!localInspector)return {decision:'block',ruleId:'clipboard-inspector-unavailable'};verdict=await diagnosticSpan('browser.inspect.local',{bytes:bytes.length},()=>localInspector.inspect(bytes,currentPolicy));}if(verdict.decision!=='allow')return verdict;const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanEnterprise(bytes,currentPolicy,'clipboard'));return external.decision==='allow'?verdict:external;},
onCompleted:async metadata=>{
@@ -921,13 +972,15 @@ app.whenReady().then(async()=>{
let wasLocked=true;vaultTimer=setInterval(()=>{const locked=vault?.status().locked??true;if(locked!==wasLocked){if(locked)audit('vault.locked');wasLocked=locked;emit();}},1000);
}).catch(error=>{console.error(error.stack);app.quit();});
app.on('window-all-closed',()=>app.quit());
app.on('before-quit',()=>{fileUploadGate?.dispose();tabs?.close();});
app.on('before-quit',()=>{void localRelay?.close().catch(()=>{});fileUploadGate?.dispose();tabs?.close();});
app.on('will-quit',()=>{Promise.resolve(diagnostics.shutdown()).catch(()=>{});vault?.lock();clearInterval(vaultTimer);clearInterval(networkTimer);localInspector?.stop();agentService?.close();});
app.on('certificate-error',(event,_wc,_url,_error,_certificate,callback)=>{event.preventDefault();callback(false);});
app.on('login',(event,_wc,_details,authInfo,callback)=>{
app.on('login',(event,wc,_details,authInfo,callback)=>{
event.preventDefault();
if(localRelay&&authInfo.isProxy&&authInfo.host==='127.0.0.1'&&authInfo.port===Number(localRelay.proxyRules.split(':').at(-1))){
if(ready&&policy&&Date.parse(policy.expiresAt)>Date.now()&&(!wc||wc.session===browserSession)){const credentials=localRelay.credentials();callback(credentials.username,credentials.password);}else callback();return;
}
// Proxy credentials are supplied by deployment environment, never a page or policy bundle.
if(authInfo.isProxy&&process.env.SAFETYNET_PROXY_USER&&process.env.SAFETYNET_PROXY_PASSWORD)callback(process.env.SAFETYNET_PROXY_USER,process.env.SAFETYNET_PROXY_PASSWORD);
else callback();
const credentials=authInfo.isProxy?require('./proxy-credentials.cjs').deploymentProxyCredentials(authInfo):null;
if(credentials)callback(credentials.username,credentials.password);else callback();
});