first commit
No files matched your search
@@ -0,0 +1,112 @@
|
||||
'use strict';
|
||||
function domAction(mode, selector, text, expectedUrl) {
|
||||
if (location.href !== expectedUrl) throw Error('Document changed before action');
|
||||
const visible = e => {
|
||||
if (!e || e.closest('[hidden],[aria-hidden="true"],script,style,template,noscript')) return false;
|
||||
const style = getComputedStyle(e);
|
||||
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' && e.getClientRects().length > 0;
|
||||
};
|
||||
const sensitive = e => {
|
||||
const forbidden = /password|passwd|secret|token|credential|api.?key|auth|one.?time|otp|credit.?card|cc-number|cc-csc/i;
|
||||
for (let node = e; node && node !== document.body; node = node.parentElement) {
|
||||
if (['name','id','autocomplete','aria-label','data-sensitive','action'].some(a => forbidden.test(node.getAttribute(a) || ''))) return true;
|
||||
}
|
||||
return /^(password|file|hidden)$/i.test(e.type || '');
|
||||
};
|
||||
if (mode === 'read') {
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
||||
const parts = []; let length = 0, visited = 0, node;
|
||||
while ((node = walker.nextNode()) && visited++ < 20000 && length < 10000) {
|
||||
const element = node.parentElement;
|
||||
if (!element || element.closest('input,textarea,select,option,[contenteditable],script,style,template,noscript') || !visible(element) || sensitive(element)) continue;
|
||||
const value = (node.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
if (value) { const piece = value.slice(0, 10000 - length); parts.push(piece); length += piece.length + 1; }
|
||||
}
|
||||
return { text: parts.join('\n').slice(0,10000) };
|
||||
}
|
||||
const e = document.querySelector(selector);
|
||||
if (!visible(e) || sensitive(e) || e.disabled || e.getAttribute('aria-disabled') === 'true') throw Error('Target is unavailable or sensitive');
|
||||
if (mode === 'click') {
|
||||
if (!/^(A|BUTTON|INPUT)$/.test(e.tagName) || (e.tagName === 'INPUT' && !/^(button|submit|checkbox|radio)$/i.test(e.type))) throw Error('Target cannot be clicked');
|
||||
e.click(); return {completed:true};
|
||||
}
|
||||
if (mode === 'type') {
|
||||
if (e.readOnly || !(e.tagName === 'TEXTAREA' || (e.tagName === 'INPUT' && /^(text|search|email|url|tel)$/i.test(e.type)))) throw Error('Target cannot receive text');
|
||||
const prototype = e.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||||
Object.getOwnPropertyDescriptor(prototype,'value').set.call(e,text);
|
||||
e.dispatchEvent(new Event('input',{bubbles:true})); e.dispatchEvent(new Event('change',{bubbles:true}));
|
||||
return {completed:true};
|
||||
}
|
||||
throw Error('Unsupported DOM action');
|
||||
}
|
||||
const secret = text => /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----|\b(?:AKIA|ASIA)[A-Z0-9]{16}\b|\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,})\b|\b(?:password|passwd|api[_ -]?key|access[_ -]?token|client[_ -]?secret)\s*[:=]\s*["']?[^\s"']{4,}/i.test(text);
|
||||
const script = (mode,args,url) => `(${domAction.toString()})(${JSON.stringify(mode)},${JSON.stringify(args.selector || '')},${JSON.stringify(args.text || '')},${JSON.stringify(url)})`;
|
||||
const hostMatches = (hostname, pattern) => {
|
||||
const name=hostname.toLowerCase().replace(/\.$/,''),p=String(pattern).toLowerCase();
|
||||
return p.startsWith('*.') ? name.endsWith(p.slice(1)) && name!==p.slice(2) : name===p;
|
||||
};
|
||||
function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,ai,inspect,audit,appControls={}} = {}) {
|
||||
if ([getContext,getSessions,contextCurrent,navigate,ai,inspect,audit].some(fn=>typeof fn!=='function')) throw TypeError('All adapter callbacks are required');
|
||||
const snapshot = (args,signal,action) => {
|
||||
const c = getContext(args.sessionId);
|
||||
if (signal?.aborted || !c?.ready || !c.policy?.agents?.enabled || !c.webContents || c.webContents.isDestroyed?.() || (c.policy.expiresAt && Date.parse(c.policy.expiresAt)<=Date.now())) throw Error('Managed session unavailable');
|
||||
if (!c.sessionId || !c.token || (args.sessionId !== undefined && args.sessionId !== c.sessionId)) throw Error('Unknown managed session');
|
||||
if (['click','type'].includes(action) && (c.foreground !== true || c.interactive===false)) throw Error('Managed session unavailable');
|
||||
const url = c.webContents.getURL();
|
||||
if (typeof c.destinationAllowed!=='function' || (action!=='navigate' && (!/^https?:\/\//.test(url) || c.destinationAllowed(url,c.policy)!==true))) throw Error('Session destination blocked');
|
||||
const agents=c.policy.agents;
|
||||
if (!Array.isArray(agents.scopes) || !agents.scopes.includes(action)) throw Error('Agent scope denied');
|
||||
if (!Array.isArray(agents.allowedHosts) || (action!=='navigate' && agents.allowedHosts.length && !agents.allowedHosts.some(p=>hostMatches(new URL(url).hostname,p)))) throw Error('Agent destination denied');
|
||||
return {...c,url,action,version:c.policyVersion ?? c.policy.version};
|
||||
};
|
||||
const check = (initial,args,signal,expectedUrl=initial.url) => {
|
||||
const current = snapshot({...args,sessionId:initial.sessionId},signal,initial.action);
|
||||
if (!contextCurrent(initial.token) || current.version !== initial.version || current.webContents !== initial.webContents || current.url !== expectedUrl) throw Error('Managed session changed during action');
|
||||
return current;
|
||||
};
|
||||
const classify = async (text,initial,args,signal,allowSensitive=false) => {
|
||||
if (typeof text!=='string' || text.length>10000 || secret(text)) throw Error('Sensitive content cannot be returned');
|
||||
const verdict = await inspect(text,initial);check(initial,args,signal);
|
||||
if (!verdict || !['allow','block'].includes(verdict.decision)) throw Error('Inspection unavailable');
|
||||
const reserved=/^(?:private-key|aws-access-key|api-secret|uninspectable|size-limit|inspection-incomplete|yara-.*)$/;
|
||||
const knownCorporateRule=verdict.ruleId==='content-blocklist' || initial.policy.dlp?.rules?.some(rule=>rule.id===verdict.ruleId && ['literal','sha256','fragment-sha256'].includes(rule.kind));
|
||||
const override=allowSensitive && knownCorporateRule && !reserved.test(verdict.ruleId || '') && !verdict.malware && verdict.engine!=='yara' && verdict.coverage?.complete!==false;
|
||||
if (verdict.decision==='block' && !override) { await audit({action:'agent.read',outcome:'blocked'});throw Error('Company policy blocks content access'); }
|
||||
return text;
|
||||
};
|
||||
const dom = mode => async (args,{signal}={}) => {
|
||||
const initial = snapshot(args,signal,mode);
|
||||
if (mode!=='read' && (typeof args.selector!=='string' || args.selector.length>512 || !/^[a-zA-Z0-9_#.*\[\]="' >+~-]+$/.test(args.selector))) throw Error('Invalid selector');
|
||||
if (mode==='type' && (typeof args.text!=='string' || args.text.length>16384)) throw Error('Invalid text');
|
||||
const result = await initial.webContents.executeJavaScript(script(mode,args,initial.url),false);
|
||||
check(initial,args,signal);
|
||||
if (mode==='read') return {text:await classify(result?.text,initial,args,signal,initial.policy.agents.allowSensitiveRead===true)};
|
||||
return {completed:result?.completed===true};
|
||||
};
|
||||
const appActions=['application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group'];
|
||||
const appSnapshot=action=>{const c=getContext();if(!c?.ready||!c.policy?.agents?.enabled||!c.policy.agents.scopes?.includes(action)||(c.policy.expiresAt&&Date.parse(c.policy.expiresAt)<=Date.now()))throw Error('Agent scope denied');return c;};
|
||||
const appAdapters=Object.fromEntries(appActions.map(action=>[action,async(args={}, {signal}={})=>{
|
||||
const initial=appSnapshot(action),version=initial.policyVersion??initial.policy.version;
|
||||
const current=()=>{const c=appSnapshot(action);if(signal?.aborted||c.policy!==initial.policy||(c.policyVersion??c.policy.version)!==version)throw Error('Managed session changed during action');};
|
||||
if(signal?.aborted||typeof appControls[action]!=='function')throw Error('Managed session unavailable');
|
||||
if(action==='tabs:create'&&args.url){const url=new URL(args.url);if(!['http:','https:'].includes(url.protocol)||url.username||url.password||initial.destinationAllowed(url.href,initial.policy)!==true||(initial.policy.agents.allowedHosts.length&&!initial.policy.agents.allowedHosts.some(p=>hostMatches(url.hostname,p))))throw Error('Agent destination denied');}
|
||||
current();const result=await appControls[action](args,{signal,policyVersion:version});current();
|
||||
if(action==='bookmarks:list'){
|
||||
const entries=[...(result?.managed||[]),...(result?.personal?.items||[])];
|
||||
for(const item of entries){const text=[item.title,item.url,item.folder].filter(v=>typeof v==='string').join('\n');if(text.length>10000||secret(text))throw Error('Sensitive content cannot be returned');const verdict=await inspect(text,{...initial,action});current();if(verdict?.decision!=='allow')throw Error('Company policy blocks content access');}
|
||||
}
|
||||
return result;
|
||||
}]));
|
||||
return {
|
||||
...appAdapters,
|
||||
sessions:async(args={}, {signal}={})=>{
|
||||
const c=getContext();
|
||||
if(signal?.aborted || !c?.ready || !c.policy?.agents?.enabled || !c.policy.agents.scopes?.includes('sessions') || (c.policy.expiresAt && Date.parse(c.policy.expiresAt)<=Date.now())) throw Error('Managed session unavailable');
|
||||
return getSessions().map(item=>({id:item.id,title:String(item.title || 'Tab').slice(0,160),foreground:item.foreground===true}));
|
||||
},
|
||||
navigate:async(args,{signal}={})=>{const initial=snapshot(args,signal,'navigate');const url=new URL(args.url);if(!['http:','https:'].includes(url.protocol)||url.username||url.password||initial.destinationAllowed(url.href,initial.policy)!==true || (initial.policy.agents.allowedHosts.length && !initial.policy.agents.allowedHosts.some(p=>hostMatches(url.hostname,p))))throw Error('Destination blocked');const refreshed=await navigate(url.href,initial,{signal});if(!refreshed || refreshed.sessionId!==initial.sessionId || refreshed.webContents!==initial.webContents || (refreshed.policyVersion ?? refreshed.policy?.version)!==initial.version)throw Error('Managed session changed during action');check({...refreshed,action:initial.action,version:initial.version,url:url.href},args,signal,url.href);return {completed:true};},
|
||||
read:dom('read'),click:dom('click'),type:dom('type'),
|
||||
ai:async(args,{signal}={})=>{const initial=snapshot(args,signal,'ai');if(typeof args.prompt!=='string'||args.prompt.length>16384)throw Error('Invalid prompt');const result=await ai(args.prompt,{signal});check(initial,args,signal);const text=typeof result==='string'?result:result?.text ?? result?.answer;return {text:await classify(text,initial,args,signal,false)};}
|
||||
};
|
||||
}
|
||||
module.exports={createAgentAdapters};
|
||||
@@ -0,0 +1,23 @@
|
||||
const {app}=require('electron');
|
||||
const fs=require('node:fs'),path=require('node:path');
|
||||
function configureClient(connectionFile){
|
||||
if(!connectionFile||!path.isAbsolute(connectionFile))throw Error('Absolute encrypted pairing file required');
|
||||
app.setPath('userData',path.join(app.getPath('appData'),'safetynet-agent-helper'));
|
||||
}
|
||||
async function runClient(connectionFile,requestFile){
|
||||
let stage='credential-store';try{
|
||||
if(!requestFile)throw Error('Request file required');
|
||||
stage='pairing';const descriptor=JSON.parse(fs.readFileSync(connectionFile,'utf8'));
|
||||
if(descriptor.format!=='windows-dpapi-v1')throw Error('Pairing format rejected');
|
||||
const connection=JSON.parse(await require('./dpapi.cjs').capabilityCrypto('unprotect',descriptor.protectedConnection));
|
||||
const origin=new URL(connection.address);
|
||||
if(origin.protocol!=='http:'||origin.hostname!=='127.0.0.1'||origin.username||origin.password||origin.pathname!=='/'||origin.search||origin.hash)throw Error('Agent endpoint rejected');
|
||||
stage='request-file';const info=fs.statSync(requestFile);if(!info.isFile()||info.size>32768)throw Error('Request too large');
|
||||
const body=fs.readFileSync(requestFile);if(body.length>32768)throw Error('Request too large');JSON.parse(body.toString());
|
||||
stage='transport';const response=await require('./loopback-http.cjs').postLoopback({port:Number(origin.port),route:'/v1/actions',token:connection.token,body:JSON.parse(body.toString()),signal:AbortSignal.timeout(15000)});
|
||||
stage='policy-response';
|
||||
const result=response.body;if(response.status!==200)throw Error('Agent policy denied action');
|
||||
process.stdout.write(JSON.stringify(result)+'\n');app.exit(0);
|
||||
}catch(error){error.clientStage=stage;throw error;}
|
||||
}
|
||||
module.exports={configureClient,runClient};
|
||||
@@ -0,0 +1,131 @@
|
||||
'use strict';
|
||||
const http = require('node:http');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
|
||||
const APP_ACTIONS=Object.freeze(['application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group']);
|
||||
const SCOPES = Object.freeze({ sessions: 'sessions:list', navigate: 'browser:navigate', read: 'browser:read', click: 'browser:interact', type: 'browser:interact', ai: 'ai:request',...Object.fromEntries(APP_ACTIONS.map(action=>[action,action])) });
|
||||
const fail = (status, message) => Object.assign(new Error(message), { status });
|
||||
const bounded = (value, max, name) => {
|
||||
if (typeof value !== 'string' || !value.length || value.length > max || /\u0000/.test(value)) throw fail(400, `Invalid ${name}`);
|
||||
return value;
|
||||
};
|
||||
function validate(body) {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(k => !['action', 'args'].includes(k))) throw fail(400, 'Invalid action envelope');
|
||||
const action = body.action;
|
||||
if (!Object.hasOwn(SCOPES, action)) throw fail(400, 'Unsupported action');
|
||||
const args = body.args ?? {};
|
||||
if (!args || typeof args !== 'object' || Array.isArray(args)) throw fail(400, 'Invalid arguments');
|
||||
const keys = { sessions: [], navigate: ['sessionId', 'url'], read: ['sessionId'], click: ['sessionId', 'selector'], type: ['sessionId', 'selector', 'text'], ai: ['sessionId', 'prompt'],'application:state':[],'settings:get':[],'settings:update':['appearance'],'bookmarks:list':[],'bookmarks:mutate':['action','bookmark','id','expectedRevision','policyVersion'],'tabs:create':['group','url'],'tabs:activate':['sessionId'],'tabs:close':['sessionId'],'tabs:group':['sessionId','group'] }[action];
|
||||
if (Object.keys(args).some(k => !keys.includes(k))) throw fail(400, 'Unsupported argument');
|
||||
const clean = {};
|
||||
if (action !== 'sessions' && args.sessionId !== undefined) clean.sessionId = bounded(args.sessionId, 128, 'sessionId');
|
||||
if (action === 'navigate') {
|
||||
let url; try { url = new URL(bounded(args.url, 4096, 'url')); } catch { throw fail(400, 'Invalid URL'); }
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw fail(400, 'Unsupported URL');
|
||||
clean.url = url.href;
|
||||
}
|
||||
if (['click', 'type'].includes(action)) {
|
||||
clean.selector = bounded(args.selector, 512, 'selector');
|
||||
// A small CSS subset prevents pseudo-elements, escapes and control characters.
|
||||
if (!/^[a-zA-Z0-9_#.*\[\]="' >+~-]+$/.test(clean.selector) || /password|autocomplete/i.test(clean.selector)) throw fail(400, 'Unsupported selector');
|
||||
}
|
||||
if (action === 'type') clean.text = bounded(args.text, 16384, 'text');
|
||||
if (action === 'ai') clean.prompt = bounded(args.prompt, 16384, 'prompt');
|
||||
if(action.startsWith('tabs:')&&action!=='tabs:create')clean.sessionId=bounded(args.sessionId,128,'sessionId');
|
||||
if(action==='tabs:create'&&args.url!==undefined){let url;try{url=new URL(bounded(args.url,4096,'url'));}catch{throw fail(400,'Invalid URL');}if(!['http:','https:'].includes(url.protocol)||url.username||url.password)throw fail(400,'Unsupported URL');clean.url=url.href;}
|
||||
if(['tabs:create','tabs:group'].includes(action)&&args.group!==undefined){if(typeof args.group!=='string'||args.group.length>40||/[\u0000-\u001f\u007f]/.test(args.group))throw fail(400,'Invalid group');clean.group=args.group;}
|
||||
if(action==='tabs:group'&&args.group===undefined)throw fail(400,'Invalid group');
|
||||
if(action==='settings:update'){try{clean.appearance=require('./managed-settings.cjs').validateAppearance(args.appearance);}catch{throw fail(400,'Invalid appearance settings');}}
|
||||
if(action==='bookmarks:mutate'){
|
||||
if(!['add','update','delete'].includes(args.action)||!Number.isSafeInteger(args.expectedRevision)||args.expectedRevision<0||!Number.isSafeInteger(args.policyVersion)||args.policyVersion<1)throw fail(400,'Invalid bookmark operation');
|
||||
clean.action=args.action;clean.expectedRevision=args.expectedRevision;clean.policyVersion=args.policyVersion;
|
||||
if(args.action==='delete')clean.id=bounded(args.id,80,'bookmark id');else{const item=args.bookmark;if(!item||typeof item!=='object'||Array.isArray(item)||Object.keys(item).some(key=>!['id','title','url','folder'].includes(key)))throw fail(400,'Invalid bookmark');const folder=item.folder===undefined?'':item.folder;if(typeof folder!=='string'||folder.length>80||/[\u0000-\u001f\u007f]/.test(folder))throw fail(400,'Invalid bookmark folder');clean.bookmark={id:bounded(item.id,80,'bookmark id'),title:bounded(item.title,120,'bookmark title'),url:bounded(item.url,2048,'bookmark URL'),folder};}
|
||||
}
|
||||
return { action, args: clean, scope: SCOPES[action], sessionId: clean.sessionId };
|
||||
}
|
||||
// Never return adapter objects wholesale: session handles and sanitized text only.
|
||||
function project(action, result) {
|
||||
if(action==='application:state')return {ready:result?.ready===true,policyVersion:Number.isSafeInteger(result?.policyVersion)?result.policyVersion:0,activeTabId:typeof result?.activeTabId==='string'?result.activeTabId.slice(0,128):null,tabCount:Number.isSafeInteger(result?.tabCount)?result.tabCount:0};
|
||||
if(action==='settings:get'||action==='settings:update'){const appearance=result?.appearance||{};return {visible:result?.visible===true,hiddenSections:Array.isArray(result?.hiddenSections)?result.hiddenSections.filter(s=>['appearance','library','ai','passwords','documentScan','agents'].includes(s)):[],lockedSections:Array.isArray(result?.lockedSections)?result.lockedSections.filter(s=>['appearance','library','ai','passwords','documentScan','agents'].includes(s)):[],appearance:{mode:['system','light','dark'].includes(appearance.mode)?appearance.mode:'system',accent:['blue','teal','violet'].includes(appearance.accent)?appearance.accent:'blue',tabMode:['compact','separate'].includes(appearance.tabMode)?appearance.tabMode:'separate'}};}
|
||||
if(action==='bookmarks:list'){const items=values=>{if(!Array.isArray(values)||values.length>1000)throw fail(502,'Invalid bookmarks');return values.map(item=>({id:bounded(item.id,80,'bookmark id'),title:bounded(item.title,120,'bookmark title'),url:bounded(item.url,2048,'bookmark URL'),folder:typeof item.folder==='string'?item.folder.slice(0,80):''}));};return {managed:items(result?.managed||[]),personal:{revision:Number.isSafeInteger(result?.personal?.revision)?result.personal.revision:0,items:items(result?.personal?.items||[])},allowUserAdd:result?.allowUserAdd===true,allowUserEdit:result?.allowUserEdit===true,allowUserDelete:result?.allowUserDelete===true,policyVersion:Number.isSafeInteger(result?.policyVersion)?result.policyVersion:0};}
|
||||
if(action.startsWith('tabs:')||action==='bookmarks:mutate')return {completed:result?.completed===true,...(typeof result?.sessionId==='string'?{sessionId:bounded(result.sessionId,128,'session id')}:{})};
|
||||
if (action === 'sessions') {
|
||||
if (!Array.isArray(result) || result.length > 100) throw fail(502, 'Invalid session response');
|
||||
return result.map(s => ({ id: bounded(s.id, 128, 'session id'), ...(typeof s.title === 'string' ? { title: s.title.slice(0, 256) } : {}), ...(typeof s.foreground === 'boolean' ? {foreground:s.foreground} : {}) }));
|
||||
}
|
||||
if (action === 'read' || action === 'ai') {
|
||||
if (typeof result?.text !== 'string' || result.text.length > 65536) throw fail(502, 'Invalid text response');
|
||||
return { text: result.text };
|
||||
}
|
||||
return { completed: result?.completed !== false };
|
||||
}
|
||||
|
||||
async function startAgentControl({ authorize, actions, audit, credentialFile, persistCredential, onError, timeoutMs = 10000, maxRequestsPerMinute = 60 } = {}) {
|
||||
if (typeof authorize !== 'function' || typeof audit !== 'function' || !actions) throw new TypeError('authorize, actions and audit are required');
|
||||
if (!Number.isInteger(timeoutMs) || timeoutMs < 10 || timeoutMs > 60000 || !Number.isInteger(maxRequestsPerMinute) || maxRequestsPerMinute < 1 || maxRequestsPerMinute > 1000) throw new TypeError('Invalid limits');
|
||||
const token = crypto.randomBytes(32).toString('base64url');
|
||||
const expected = Buffer.from(`Bearer ${token}`);
|
||||
let enabled = true, windowStart = Date.now(), requests = 0;
|
||||
const running = new Set();
|
||||
const server = http.createServer(async (req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
const reply = (status, data) => { if (!res.destroyed && !res.writableEnded) { res.writeHead(status); res.end(JSON.stringify(data)); } };
|
||||
const controller = new AbortController();
|
||||
let action, outcome = 'denied', timer;
|
||||
try {
|
||||
const supplied = Buffer.from(req.headers.authorization || '');
|
||||
if (!enabled || supplied.length !== expected.length || !crypto.timingSafeEqual(supplied, expected)) throw fail(401, 'Unauthorized');
|
||||
if (req.headers.origin || req.headers['sec-fetch-site'] || !/^127\.0\.0\.1:\d+$/.test(req.headers.host || '')) throw fail(403, 'Browser-origin requests forbidden');
|
||||
if (req.method !== 'POST' || req.url !== '/v1/actions') throw fail(404, 'Not found');
|
||||
if (!/^application\/json(?:\s*;|$)/i.test(req.headers['content-type'] || '')) throw fail(415, 'JSON required');
|
||||
if (Date.now() - windowStart >= 60000) { windowStart = Date.now(); requests = 0; }
|
||||
if (++requests > maxRequestsPerMinute) throw fail(429, 'Rate limit exceeded');
|
||||
let size = 0, chunks = [];
|
||||
for await (const chunk of req) { size += chunk.length; if (size > 32768) throw fail(413, 'Request too large'); chunks.push(chunk); }
|
||||
let body; try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw fail(400, 'Invalid JSON'); }
|
||||
const request = validate(body); action = request.action;
|
||||
if (typeof actions[action] !== 'function') throw fail(403, 'Action unavailable');
|
||||
running.add(controller);
|
||||
const operation = (async () => {
|
||||
const grant = await authorize({ ...request, signal: controller.signal });
|
||||
if (!enabled || controller.signal.aborted || grant?.allowed !== true || !['agent', 'admin'].includes(grant.role) || !Array.isArray(grant.scopes) || !grant.scopes.includes(request.scope)) throw fail(403, 'Policy denied action');
|
||||
await audit({ action, scope: request.scope, sessionId: request.sessionId, outcome: 'authorized' });
|
||||
if (!enabled || controller.signal.aborted) throw fail(403, 'Agent access revoked');
|
||||
return project(action, await actions[action](request.args, { signal: controller.signal }));
|
||||
})();
|
||||
const result = await Promise.race([operation, new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(fail(504, 'Action timed out')); }, timeoutMs); })]);
|
||||
if (!enabled || controller.signal.aborted) throw fail(403, 'Agent access revoked');
|
||||
outcome = 'completed';
|
||||
await audit({ action, outcome });
|
||||
reply(200, { result });
|
||||
} catch (error) {
|
||||
// Diagnostics expose only known static reasons, never arbitrary adapter errors.
|
||||
if (typeof onError === 'function') {
|
||||
const reasons=['Document changed before action','Managed session unavailable','Unknown managed session','Session destination blocked','Agent scope denied','Agent destination denied','Managed session changed during action','Sensitive content cannot be returned','Inspection unavailable','Company policy blocks content access','Target is unavailable or sensitive','Target cannot receive text','Invalid text response','Policy denied action','Action timed out'];
|
||||
const reason=reasons.find(value=>String(error.message).includes(value)) || 'unclassified-action-failure';
|
||||
try { await onError({action:action || 'agent-request',status:error.status || 500,reason}); } catch { /* Diagnostics cannot change enforcement. */ }
|
||||
}
|
||||
try { await audit({ action: action || 'agent-request', outcome, status: error.status || 500 }); } catch { /* Deny even if audit unavailable. */ }
|
||||
reply(error.status || 500, { error: error.status ? error.message : 'Agent action failed' });
|
||||
} finally { clearTimeout(timer); running.delete(controller); }
|
||||
});
|
||||
server.requestTimeout = 15000; server.headersTimeout = 10000; server.maxHeadersCount = 32;
|
||||
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
||||
const address = `http://127.0.0.1:${server.address().port}`;
|
||||
const revoke = () => { enabled = false; for (const c of running) c.abort(); };
|
||||
const close = async () => { revoke(); server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); };
|
||||
try {
|
||||
const credential = { address, token };
|
||||
if (persistCredential) await persistCredential(credential);
|
||||
if (credentialFile) {
|
||||
if (!path.isAbsolute(credentialFile)) throw new TypeError('Credential path must be absolute');
|
||||
await fs.writeFile(credentialFile, JSON.stringify(credential), { mode: 0o600, flag: 'wx' });
|
||||
}
|
||||
} catch (error) { await close(); throw error; }
|
||||
return { address, token, close, revoke };
|
||||
}
|
||||
module.exports = { startAgentControl, SCOPES };
|
||||
@@ -0,0 +1,25 @@
|
||||
const {requireSection}=require('./managed-settings.cjs');
|
||||
function libraryVisible(settings){return !!settings?.available&&!settings.hiddenSections.includes('library');}
|
||||
function bookmarkState({policy,ready,settings}){
|
||||
const rules=policy?.browser?.bookmarks;
|
||||
if(!ready||!libraryVisible(settings)||!rules)return {managed:[],personal:{revision:0,items:[]},allowUserAdd:false,allowUserEdit:false,allowUserDelete:false};
|
||||
const result=structuredClone(rules);if(!settings.visible||settings.lockedSections.includes('library'))result.allowUserAdd=result.allowUserEdit=result.allowUserDelete=false;return result;
|
||||
}
|
||||
function validateMutation(input,{policy,ready,settings,destinationAllowed,now=Date.now()}){
|
||||
if(!ready||!policy||Date.parse(policy.expiresAt)<=now)throw Error('Managed browser access is paused');
|
||||
requireSection(settings,'library');
|
||||
const rules=policy.browser.bookmarks;
|
||||
if(!input||!['add','update','delete'].includes(input.action)||Object.keys(input).some(key=>!['action','bookmark','id','expectedRevision','policyVersion'].includes(key)))throw Error('Invalid bookmark operation');
|
||||
if(input.expectedRevision!==rules.personal.revision||input.policyVersion!==policy.version)throw Error('Bookmarks changed. Refresh before editing.');
|
||||
if(!rules[{add:'allowUserAdd',update:'allowUserEdit',delete:'allowUserDelete'}[input.action]])throw Error('Bookmark changes are controlled by your administrator');
|
||||
const id=input.action==='delete'?input.id:input.bookmark?.id;
|
||||
if(typeof id!=='string'||!/^[A-Za-z0-9_-]{1,80}$/.test(id)||rules.managed.some(item=>item.id===id))throw Error('Company bookmarks cannot be changed');
|
||||
if(input.action==='add'&&rules.personal.items.some(item=>item.id===id)||input.action!=='add'&&!rules.personal.items.some(item=>item.id===id))throw Error('Bookmark identity is unavailable');
|
||||
if(input.action==='delete')return {action:input.action,id,expectedRevision:input.expectedRevision,policyVersion:input.policyVersion};
|
||||
const item=input.bookmark;
|
||||
if(Object.keys(item).some(key=>!['id','title','url','folder'].includes(key))||typeof item.title!=='string'||!item.title.trim()||item.title.length>120||/[\u0000-\u001f\u007f]/.test(item.title)||typeof item.folder!=='string'||item.folder.length>80||/[\u0000-\u001f\u007f]/.test(item.folder)||typeof item.url!=='string'||item.url.length>2048)throw Error('Invalid bookmark details');
|
||||
let url;try{url=new URL(item.url);}catch{throw Error('Invalid bookmark URL');}
|
||||
if(!['https:','http:'].includes(url.protocol)||url.username||url.password||url.search||url.hash||!destinationAllowed(url.href,policy))throw Error('Bookmark destination is not approved');
|
||||
return {action:input.action,bookmark:{id,title:item.title.trim(),url:url.href,folder:item.folder.trim()},expectedRevision:input.expectedRevision,policyVersion:input.policyVersion};
|
||||
}
|
||||
module.exports={bookmarkState,validateMutation,libraryVisible};
|
||||
@@ -0,0 +1,27 @@
|
||||
// Session frame preload: no page bridge, Node engine, filesystem API or clipboard payloads.
|
||||
if(process.sandboxed!==true||process.contextIsolated!==true){require('electron').ipcRenderer.send('managed:clipboard-sandbox-failure');window.stop();throw Error('Managed renderer isolation is required');}
|
||||
// Freeze the legacy command primitive in the page realm. Document.open() removes
|
||||
// DOM listeners, but cannot replace this non-configurable prototype property.
|
||||
try {
|
||||
require('electron').contextBridge.executeInMainWorld({func:()=>{
|
||||
const prototype=Document.prototype,existing=Object.getOwnPropertyDescriptor(prototype,'execCommand');
|
||||
if(existing?.value?._managedClipboardGuard===true&&!existing.configurable)return;
|
||||
const original=prototype.execCommand,apply=Reflect.apply,test=RegExp.prototype.test.bind(/^(copy|cut|paste)/i);
|
||||
function managedClipboardCommand(command,...args){
|
||||
if(typeof command!=='string'||test(command))return false;
|
||||
return apply(original,this,[command,...args]);
|
||||
}
|
||||
Object.defineProperty(managedClipboardCommand,'_managedClipboardGuard',{value:true});
|
||||
Object.defineProperty(prototype,'execCommand',{value:managedClipboardCommand,writable:false,configurable:false});
|
||||
// Replacement clears security listeners; these unsupported legacy APIs fail closed.
|
||||
const SecurityException=DOMException;
|
||||
const denyReplacement=()=>{throw new SecurityException('Managed document replacement is disabled','SecurityError');};
|
||||
for(const name of ['open','write','writeln'])Object.defineProperty(prototype,name,{value:denyReplacement,writable:false,configurable:false});
|
||||
}});
|
||||
} catch {
|
||||
require('electron').ipcRenderer.send('managed:clipboard-sandbox-failure');
|
||||
window.stop();
|
||||
throw Error('Managed clipboard primitive could not be protected');
|
||||
}
|
||||
for(const name of ['copy','cut','paste','dragstart','dragover','drop'])window.addEventListener(name,event=>{event.preventDefault();event.stopImmediatePropagation();},true);
|
||||
window.addEventListener('beforeinput',event=>{if(/^(insertFromPaste|insertFromDrop|deleteByCut)/.test(event.inputType||'')){event.preventDefault();event.stopImmediatePropagation();}},true);
|
||||
@@ -0,0 +1,44 @@
|
||||
'use strict';
|
||||
function clipboardDOM(mode,url,token,operation,payload,limit){
|
||||
if(mode==='discard'){if(globalThis.__safetynetClipboardSnapshot?.token===token)delete globalThis.__safetynetClipboardSnapshot;return {completed:true};}
|
||||
if(location.href!==url||window.top!==window)throw Error('Clipboard frame changed');
|
||||
const field=document.activeElement;
|
||||
const editable=e=>e&&(e.tagName==='TEXTAREA'||e.tagName==='INPUT'&&/^(text|search|email|url|tel)$/i.test(e.type))&&!e.disabled&&!e.readOnly;
|
||||
const sensitive=e=>{for(let p=e;p&&p!==document.body;p=p.parentElement)if(/password|passwd|secret|token|credential|api.?key|auth|one.?time|otp|master/i.test(['name','id','autocomplete','aria-label'].map(a=>p.getAttribute(a)||'').join(' ')))return true;return /^(password|file|hidden)$/i.test(e?.type||'');};
|
||||
const visible=e=>{if(!e||e.closest('[hidden],[aria-hidden="true"],[inert]'))return false;let p=e,count=0;while(p&&count++<64){const s=getComputedStyle(p);if(s.display==='none'||s.visibility==='hidden'||s.opacity==='0')return false;p=p.parentElement;}return !p&&e.getClientRects().length>0;};
|
||||
const rangeSafe=range=>{const ancestor=range.commonAncestorContainer.nodeType===1?range.commonAncestorContainer:range.commonAncestorContainer.parentElement;if(!ancestor?.isConnected||!visible(ancestor)||sensitive(ancestor)||ancestor.closest('input,textarea,[contenteditable],script,style,template,noscript')||ancestor.querySelector?.('input,textarea,[contenteditable]'))return false;const walker=document.createTreeWalker(ancestor,NodeFilter.SHOW_ELEMENT);let e,visited=0;while((e=walker.nextNode())){if(++visited>2048)return false;if(range.intersectsNode(e)&&(!visible(e)||sensitive(e)||/^(SCRIPT|STYLE|TEMPLATE|NOSCRIPT)$/.test(e.tagName)))return false;}return true;};
|
||||
if(mode==='stage'){
|
||||
const snapshot={token,operation,field};let text='';
|
||||
if(field&&(field.tagName==='INPUT'||field.tagName==='TEXTAREA'||field.isContentEditable)){
|
||||
if(!editable(field)||sensitive(field)||!visible(field))throw Error('Clipboard target denied');
|
||||
snapshot.kind='field';snapshot.value=field.value;if(snapshot.value.length>limit)throw Error('Clipboard target size limit');snapshot.start=field.selectionStart;snapshot.end=field.selectionEnd;
|
||||
if(!Number.isInteger(snapshot.start)||!Number.isInteger(snapshot.end))throw Error('Clipboard selection unsupported');
|
||||
text=operation==='paste'?'':snapshot.value.slice(snapshot.start,snapshot.end);
|
||||
}else{
|
||||
if(operation!=='copy')throw Error('Clipboard edit target unsupported');
|
||||
const selection=getSelection();if(!selection||selection.rangeCount!==1||selection.isCollapsed)throw Error('Clipboard selection unavailable');
|
||||
const range=selection.getRangeAt(0),ancestor=range.commonAncestorContainer.nodeType===1?range.commonAncestorContainer:range.commonAncestorContainer.parentElement;
|
||||
if(!rangeSafe(range))throw Error('Clipboard rich selection unsupported');
|
||||
snapshot.kind='range';snapshot.range=range.cloneRange();text=range.toString();
|
||||
}
|
||||
if(typeof text!=='string'||text.length>limit||text.includes('\0'))throw Error('Clipboard size limit');
|
||||
snapshot.text=text;globalThis.__safetynetClipboardSnapshot=snapshot;return {text};
|
||||
}
|
||||
const snapshot=globalThis.__safetynetClipboardSnapshot;if(mode!=='validate')delete globalThis.__safetynetClipboardSnapshot;
|
||||
if(!snapshot||snapshot.token!==token||snapshot.operation!==operation)throw Error('Clipboard snapshot unavailable');
|
||||
if(snapshot.kind==='field'){
|
||||
const e=snapshot.field;if(e!==document.activeElement||!e.isConnected||e.value!==snapshot.value||e.selectionStart!==snapshot.start||e.selectionEnd!==snapshot.end||!editable(e)||sensitive(e)||!visible(e))throw Error('Clipboard target changed');
|
||||
if(mode!=='validate'&&operation!=='copy'){
|
||||
const value=operation==='paste'?payload:'';if(typeof value!=='string'||value.length>limit)throw Error('Clipboard payload denied');
|
||||
e.setRangeText(value,snapshot.start,snapshot.end,'end');e.dispatchEvent(new Event('input',{bubbles:true}));
|
||||
}
|
||||
}else{
|
||||
const selection=getSelection(),range=selection?.rangeCount===1?selection.getRangeAt(0):null,original=snapshot.range;
|
||||
if(!range||range.startContainer!==original.startContainer||range.endContainer!==original.endContainer||range.startOffset!==original.startOffset||range.endOffset!==original.endOffset||range.toString()!==snapshot.text)throw Error('Clipboard selection changed');
|
||||
const ancestor=range.commonAncestorContainer.nodeType===1?range.commonAncestorContainer:range.commonAncestorContainer.parentElement;
|
||||
if(!rangeSafe(range))throw Error('Clipboard selection changed');
|
||||
}
|
||||
return {completed:true};
|
||||
}
|
||||
const clipboardScript=(mode,{url,token,operation,text='',limit})=>`(${clipboardDOM.toString()})(${JSON.stringify(mode)},${JSON.stringify(url)},${JSON.stringify(token)},${JSON.stringify(operation)},${JSON.stringify(text)},${JSON.stringify(limit)})`;
|
||||
module.exports={clipboardScript};
|
||||
@@ -0,0 +1,43 @@
|
||||
'use strict';
|
||||
const {randomUUID}=require('node:crypto');
|
||||
function createClipboardGuard({getContext,stage,validate,commit,discard,readClipboard,writeClipboard,inspect,audit,timeoutMs=10000}){
|
||||
let pending=null;
|
||||
const current=c=>{const n=getContext(c.webContents);return n.ready&&n.tabId===c.tabId&&n.activation===c.activation&&n.policy===c.policy&&n.url===c.url&&n.navigationId===c.navigationId&&n.webContents===c.webContents&&n.webContents?.focusedFrame===n.webContents?.mainFrame&&Date.parse(n.policy?.expiresAt)>Date.now();};
|
||||
async function run(operation,webContents){
|
||||
const c=getContext(webContents),token=randomUUID();let text='',timer,expired=false;
|
||||
const denied=async ruleId=>{await audit('clipboard.block',ruleId);return{completed:false,ruleId};};
|
||||
const work=async()=>{try{
|
||||
if(!['copy','cut','paste'].includes(operation)||!current(c))return denied('clipboard-context-denied');
|
||||
const limit=Math.min(c.policy.security?.clipboard?.maxTextBytes||0,c.policy.dlp.maxBytes);
|
||||
if(!limit)return denied('clipboard-policy-unavailable');
|
||||
const snapshot=await stage({operation,token,url:c.url,limit,webContents:c.webContents});
|
||||
if(!current(c)||expired)return denied('clipboard-context-changed');
|
||||
if(operation==='paste')text=await readClipboard(limit);else text=snapshot.text;
|
||||
if(!current(c)||expired)return denied('clipboard-context-changed');
|
||||
if(typeof text!=='string'||Buffer.byteLength(text)>limit||text.includes('\0'))return denied('clipboard-size-limit');
|
||||
const verdict=await inspect(Buffer.from(text),c.policy,operation);
|
||||
if(!current(c)||expired)return denied('clipboard-context-changed');
|
||||
if(verdict?.decision!=='allow')return denied(verdict?.ruleId||'clipboard-inspection-failed');
|
||||
// Persist authorization before side effects; never audit raw clipboard text.
|
||||
await audit(`clipboard.${operation}`);
|
||||
if(!current(c)||expired)return denied('clipboard-context-changed');
|
||||
const args={operation,token,url:c.url,text,limit,webContents:c.webContents};
|
||||
if(operation==='cut'){
|
||||
const validated=await validate(args);
|
||||
if(validated?.completed!==true||!current(c)||expired)return denied('clipboard-target-changed');
|
||||
await writeClipboard(text);
|
||||
if(!current(c)||expired)return denied('clipboard-context-changed');
|
||||
}
|
||||
const result=await commit(args);
|
||||
if(result?.completed!==true||!current(c)||expired)return denied('clipboard-target-changed');
|
||||
if(operation==='copy'){await writeClipboard(text);if(!current(c)||expired)return denied('clipboard-context-changed');}
|
||||
return {completed:true};
|
||||
}finally{try{await discard?.({token,webContents:c.webContents});}catch{}}};
|
||||
try{return await Promise.race([work(),new Promise(resolve=>{timer=setTimeout(()=>{expired=true;Promise.resolve(discard?.({token,webContents:c.webContents})).catch(()=>{});denied('clipboard-timeout').then(resolve,()=>resolve({completed:false,ruleId:'clipboard-timeout'}));},timeoutMs);})]);}
|
||||
catch{return denied('clipboard-inspection-failed');}
|
||||
finally{clearTimeout(timer);text='';}
|
||||
}
|
||||
function perform(operation,webContents){if(pending)return Promise.resolve({completed:false,ruleId:'clipboard-busy'});const task=run(operation,webContents);pending=task;task.finally(()=>{if(pending===task)pending=null;}).catch(()=>{});return task;}
|
||||
return {perform,isPending:()=>!!pending};
|
||||
}
|
||||
module.exports={createClipboardGuard};
|
||||
@@ -0,0 +1,36 @@
|
||||
'use strict';
|
||||
function developerShortcut(input){return input?.type==='keyDown'&&(input.key==='F12'||((input.control||input.meta)&&input.shift&&!input.alt&&['i','j'].includes(String(input.key).toLowerCase())));}
|
||||
function developerAssetRequestAllowed(details,{ready,policy,tabs}={}){
|
||||
if(!ready||policy?.browser?.allowDevTools!==true||Date.parse(policy.expiresAt)<=Date.now()||!Number.isSafeInteger(details?.webContentsId))return false;
|
||||
let url;try{url=new URL(details.url);}catch{return false;}
|
||||
if(url.protocol!=='devtools:'||url.hostname!=='devtools'||!url.pathname.startsWith('/bundled/')||url.username||url.password)return false;
|
||||
return tabs?.some(tab=>{if(tab.view.webContents.isDestroyed())return false;const frontend=tab.security?.developerFrontend||tab.view.webContents.devToolsWebContents;return frontend&&!frontend.isDestroyed()&&frontend.id===details.webContentsId;})===true;
|
||||
}
|
||||
function createDeveloperToolsGuard({contents,getContext,isCurrent,audit,createFrontend}={}){
|
||||
if(!contents||[getContext,isCurrent,audit].some(fn=>typeof fn!=='function'))throw TypeError('Native developer-tools callbacks required');
|
||||
let opening=false,openedState=false,frontend;
|
||||
const disposeFrontend=()=>{const previous=frontend;frontend=null;if(previous&&!previous.isDestroyed())previous.destroy();};
|
||||
const close=()=>{openedState=false;if(!contents.isDestroyed())contents.closeDevTools();disposeFrontend();};
|
||||
const allowed=context=>!!context?.ready&&context.foreground===true&&context.policy?.browser?.allowDevTools===true&&Date.parse(context.policy.expiresAt)>Date.now()&&/^https?:\/\//.test(context.url||'')&&!contents.isDestroyed();
|
||||
async function toggle(){
|
||||
if(contents.isDestroyed())return;
|
||||
if(openedState||contents.isDevToolsOpened()){close();await audit('devtools.closed');return;}
|
||||
const context=getContext();
|
||||
if(!allowed(context)||opening){await audit('devtools.denied');return;}
|
||||
opening=true;
|
||||
try{
|
||||
// Publication and activation changes across audit persistence invalidate this grant.
|
||||
await audit('devtools.open-requested');
|
||||
if(!allowed(getContext())||!isCurrent(context)){await audit('devtools.denied');return;}
|
||||
if(createFrontend){frontend=createFrontend();contents.setDevToolsWebContents(frontend.webContents);frontend.once('closed',()=>{if(!contents.isDestroyed()&&(openedState||contents.isDevToolsOpened()))contents.closeDevTools();});}
|
||||
contents.openDevTools({mode:'detach',activate:true,title:'S Browser · Privileged developer tools'});
|
||||
}finally{opening=false;}
|
||||
}
|
||||
const opened=()=>{openedState=true;const context=getContext();if(!allowed(context))close();else {frontend?.show();Promise.resolve(audit('devtools.opened')).catch(close);}};
|
||||
const closed=()=>{openedState=false;disposeFrontend();};
|
||||
contents.on('devtools-opened',opened);
|
||||
contents.on('devtools-closed',closed);
|
||||
contents.once('destroyed',disposeFrontend);
|
||||
return {handleInput:(event,input)=>{if(!developerShortcut(input))return false;event.preventDefault();toggle().catch(close);return true;},toggle,close,isOpened:()=>openedState,dispose:()=>{contents.removeListener('devtools-opened',opened);contents.removeListener('devtools-closed',closed);close();}};
|
||||
}
|
||||
module.exports={createDeveloperToolsGuard,developerShortcut,developerAssetRequestAllowed};
|
||||
@@ -0,0 +1,56 @@
|
||||
'use strict';
|
||||
const {AsyncLocalStorage}=require('node:async_hooks');
|
||||
const {performance}=require('node:perf_hooks');
|
||||
const {randomInt}=require('node:crypto');
|
||||
const http=require('node:http'),https=require('node:https');
|
||||
const {TracerProvider,BatchSpanProcessor,TraceIdRatioBasedSampler,ParentBasedSampler}=require('@opentelemetry/sdk-trace');
|
||||
const {LoggerProvider,BatchLogRecordProcessor}=require('@opentelemetry/sdk-logs');
|
||||
const {resourceFromAttributes}=require('@opentelemetry/resources');
|
||||
const {OTLPTraceExporter}=require('@opentelemetry/exporter-trace-otlp-http');
|
||||
const {OTLPLogExporter}=require('@opentelemetry/exporter-logs-otlp-http');
|
||||
const {trace,ROOT_CONTEXT,SpanStatusCode}=require('@opentelemetry/api');
|
||||
const names=new Set(['browser.network','browser.ipc','browser.agent','browser.management','browser.navigation','browser.dns','browser.request','browser.inspect.local','browser.inspect.enterprise','browser.download','browser.clipboard','browser.visual','browser.password.fill','browser.ai','browser.policy','browser.startup','browser.parser','browser.semantic','browser.yara','browser.icap','browser.tab','browser.security']);
|
||||
const operations=new Set(['application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','state','authorize-setting','settings-update','window-control','chrome-layout','share-url','bookmarks-mutate','enroll','sso','navigate','back','forward','reload','ai','inspect-file','agent-connection','ai-panel','vault-panel','vault-status','vault-create','vault-unlock','vault-lock','vault-list','vault-save','vault-delete','vault-fill','vault-generate','tab-create','tab-activate','tab-close','tab-group','sessions','read','click','type','screenshot','copy','cut','paste','scan','download','sync','create','activate','close','group','policy','request','dns','parse','embed','yara','icap']);
|
||||
const components=new Set(['browser','ipc','agent','management','navigation','network','dns','dlp','inspector','parser','semantic','yara','icap','download','clipboard','visual','vault','ai','policy','tabs']);
|
||||
const decisions=new Set(['allow','block','deny','unknown']),statuses=new Set(['ok','error','cancelled','timeout']);
|
||||
const requestTypes=new Set(['mainFrame','subFrame','stylesheet','script','image','font','object','xhr','ping','cspReport','media','webSocket','other']);
|
||||
const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
|
||||
function attributes(input){const out={};if(!input||typeof input!=='object')return out;for(const [key,descriptor]of Object.entries(Object.getOwnPropertyDescriptors(input))){if(!('value'in descriptor))continue;const value=descriptor.value;
|
||||
if(['sessionId','deviceId'].includes(key)&&typeof value==='string'&&uuid.test(value))out[key==='sessionId'?'safetynet.session.id':'safetynet.device.id']=value;
|
||||
else if(['policyVersion','bytes','count'].includes(key)&&Number.isSafeInteger(value)&&value>=0)out[key==='policyVersion'?'safetynet.policy.version':`safetynet.${key}`]=value;
|
||||
else if(key==='durationMs'&&Number.isFinite(value)&&value>=0&&value<=86400000)out['safetynet.duration.ms']=value;
|
||||
else if(key==='operation'&&operations.has(value))out['safetynet.operation']=value;
|
||||
else if(key==='component'&&components.has(value))out['safetynet.component']=value;
|
||||
else if(key==='decision'&&decisions.has(value))out['dlp.decision']=value;
|
||||
else if(key==='status'&&statuses.has(value))out['safetynet.status']=value;
|
||||
else if(key==='requestType'&&requestTypes.has(value))out['safetynet.request.type']=value;
|
||||
}return out;}
|
||||
function endpointAllowed(value){try{const u=new URL(value);if(u.username||u.password||u.search||u.hash)return false;const host=u.hostname.replace(/^\[|\]$/g,'');return u.protocol==='https:'||(u.protocol==='http:'&&(host==='::1'||/^127\.(?:\d{1,3}\.){2}\d{1,3}$/.test(host)&&host.split('.').every(p=>+p<=255)));}catch{return false;}}
|
||||
function valid(config){return config&&config.enabled===true&&['operational','debug'].includes(config.mode)&&endpointAllowed(config.endpoint)&&['sampleRate','debugSampleRate'].every(k=>Number.isFinite(config[k])&&config[k]>=0&&config[k]<=1)&&Number.isSafeInteger(config.maxQueueSize)&&config.maxQueueSize>=16&&config.maxQueueSize<=4096&&Number.isSafeInteger(config.flushIntervalMs)&&config.flushIntervalMs>=250&&config.flushIntervalMs<=10000&&Number.isSafeInteger(config.maxRecordAgeMs)&&config.maxRecordAgeMs>=1000&&config.maxRecordAgeMs<=300000&&Number.isSafeInteger(config.exportTimeoutMs)&&config.exportTimeoutMs>=250&&config.exportTimeoutMs<=10000;}
|
||||
const defaults={enabled:false,mode:'operational',sampleRate:0.1,debugSampleRate:1,maxQueueSize:512,flushIntervalMs:2000,maxRecordAgeMs:30000,exportTimeoutMs:2000};
|
||||
function createDiagnostics(){const storage=new AsyncLocalStorage();let generation=null,configurationKey='',closed=false;const retiring=new Set();let activeSpans=0;
|
||||
function retire(current){if(!current)return;current.active=false;for(const agent of current.agents)agent.destroy();const pending=Promise.allSettled([current.tracerProvider?.shutdown(),current.loggerProvider?.shutdown()]);retiring.add(pending);pending.finally(()=>retiring.delete(pending));}
|
||||
function gatedExporter(exporter,current){return {export(records,callback){if(!current.active){callback({code:0});return;}const now=Date.now(),fresh=records.filter(record=>{const timestamp=record.endTime||record.hrTime||record.hrTimeObserved;return timestamp&&now-(timestamp[0]*1000+timestamp[1]/1e6)<=current.config.maxRecordAgeMs;});if(!fresh.length){callback({code:0});return;}try{exporter.export(fresh,result=>{if(result.code!==0)current.failedExports++;callback(result);});}catch{current.failedExports++;callback({code:1});}},shutdown:()=>exporter.shutdown(),forceFlush:()=>exporter.forceFlush?.()||Promise.resolve()};}
|
||||
function configure(input={},context={}){if(closed)return false;const config={...defaults,...input};const key=JSON.stringify([config,attributes(context)]);if(key===configurationKey)return !!generation?.active;configurationKey=key;const previous=generation;generation=null;retire(previous);if(!valid(config)||retiring.size>=4)return false;
|
||||
let current;try{current={active:true,config,context:attributes(context),agents:new Set(),failedExports:0};
|
||||
const agentFactory=async protocol=>{const Base=protocol==='https:'?https.Agent:http.Agent;class PrivateAgent extends Base{addRequest(request,options){if(!current.active){request.destroy(Error('Diagnostics disabled'));return;}for(const key of request.getHeaderNames())if(!['host','content-type','content-length','user-agent'].includes(key.toLowerCase()))request.removeHeader(key);super.addRequest(request,options);}}
|
||||
const agent=new PrivateAgent({keepAlive:false,maxSockets:1,rejectUnauthorized:true});current.agents.add(agent);return agent;};
|
||||
const base=config.endpoint.replace(/\/$/,'');const exporterConfig=path=>({url:base+path,headers:{},timeoutMillis:config.exportTimeoutMs,concurrencyLimit:1,compression:'none',httpAgentOptions:agentFactory});
|
||||
const resource=resourceFromAttributes({'service.name':'safetynet-browser','service.version':'0.1.0','safetynet.diagnostics.mode':config.mode});
|
||||
const limits={maxQueueSize:config.maxQueueSize,maxExportBatchSize:Math.min(config.maxQueueSize,128),scheduledDelayMillis:config.flushIntervalMs,exportTimeoutMillis:config.exportTimeoutMs};
|
||||
current.tracerProvider=new TracerProvider({resource,sampler:new ParentBasedSampler({root:new TraceIdRatioBasedSampler(config.mode==='debug'?config.debugSampleRate:config.sampleRate)}),spanLimits:{attributeCountLimit:20,attributeValueLengthLimit:64,eventCountLimit:0,linkCountLimit:0},spanProcessors:[new BatchSpanProcessor({...limits,exporter:gatedExporter(new OTLPTraceExporter(exporterConfig('/v1/traces')),current)})]});
|
||||
current.loggerProvider=new LoggerProvider({resource,logRecordLimits:{attributeCountLimit:20,attributeValueLengthLimit:64},processors:[new BatchLogRecordProcessor({...limits,exporter:gatedExporter(new OTLPLogExporter(exporterConfig('/v1/logs')),current)})]});current.tracer=current.tracerProvider.getTracer('safetynet.browser','0.1.0');current.logger=current.loggerProvider.getLogger('safetynet.browser','0.1.0');generation=current;return true;
|
||||
}catch{generation=null;retire(current);return false;}
|
||||
}
|
||||
function event(name,input={}){const current=generation;if(!current?.active||!names.has(name))return;try{const parent=storage.getStore(),rate=current.config.mode==='debug'?current.config.debugSampleRate:current.config.sampleRate;if(parent?.generation===current){if(!parent.span.isRecording())return;}else if(randomInt(1000000)>=Math.floor(rate*1000000))return;
|
||||
current.logger.emit({severityNumber:9,severityText:'INFO',body:name,attributes:{...current.context,...attributes(input)},...(parent?.generation===current?{context:trace.setSpan(ROOT_CONTEXT,parent.span)}:{})});
|
||||
}catch{}
|
||||
}
|
||||
async function span(name,input,fn){if(typeof fn!=='function')throw TypeError('Diagnostics operation requires a function');const current=generation;if(!current?.active||!names.has(name)||activeSpans>=128)return fn();let nativeSpan;try{const parent=storage.getStore(),context=parent?.generation===current?trace.setSpan(ROOT_CONTEXT,parent.span):ROOT_CONTEXT;nativeSpan=current.tracer.startSpan(name,{attributes:{...current.context,...attributes(input)}},context);}catch{return fn();}
|
||||
activeSpans++;const started=performance.now();let status='ok';try{const result=await storage.run({generation:current,span:nativeSpan},fn);try{nativeSpan.setStatus({code:SpanStatusCode.OK});}catch{}return result;}catch(error){status='error';try{nativeSpan.setStatus({code:SpanStatusCode.ERROR});nativeSpan.setAttribute('error.type',error instanceof TypeError?'TypeError':error instanceof RangeError?'RangeError':'Error');}catch{}throw error;}finally{activeSpans--;try{const sampled=nativeSpan.isRecording();nativeSpan.setAttribute('safetynet.status',status);nativeSpan.setAttribute('safetynet.duration.ms',Math.max(0,performance.now()-started));nativeSpan.end();if(current.active&&sampled)current.logger.emit({severityNumber:9,severityText:'INFO',body:name,attributes:{...current.context,...attributes(input),'safetynet.status':status,'safetynet.duration.ms':Math.max(0,performance.now()-started)},context:trace.setSpan(ROOT_CONTEXT,nativeSpan)});}catch{}}
|
||||
}
|
||||
async function flush(){const current=generation;if(!current?.active)return;await Promise.allSettled([current.tracerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs}),current.loggerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs})]);}
|
||||
async function shutdown(){if(closed)return;await flush();closed=true;const current=generation;generation=null;retire(current);await Promise.allSettled([...retiring]);}
|
||||
return {configure,span,event,flush,shutdown,correlation:()=>{const parent=storage.getStore();if(!parent||parent.generation!==generation||!generation?.active||!parent.span.isRecording())return {};const ids=parent.span.spanContext();return {traceId:ids.traceId,spanId:ids.spanId};},status:()=>({enabled:!!generation?.active,mode:generation?.config.mode||'operational',failedExports:generation?.failedExports||0,activeSpans})};
|
||||
}
|
||||
module.exports={createDiagnostics,attributes,endpointAllowed};
|
||||
@@ -0,0 +1,45 @@
|
||||
'use strict';
|
||||
const {safeFilename:filename,inspectFileTransfer}=require('../../packages/core/file-transfer.cjs');
|
||||
const {createHash}=require('node:crypto');
|
||||
function createDownloadGate({getContext,isCurrent,fetchResponse,scan,choosePath,writeFile,audit,status}){
|
||||
let busy=false;
|
||||
return async function download({url,name,method,webContentsId}){
|
||||
const context=getContext(webContentsId);let bytes,facts;
|
||||
const auditFacts=()=>facts?{bytes:facts.size,detectedType:facts.detectedType,sha256:facts.sha256}:{};
|
||||
const deny=async reason=>{await audit('download.block',reason,new URL(url).hostname,auditFacts());status?.(`Download blocked: ${reason}`);return {released:false,reason};};
|
||||
if(busy)return deny('download-busy');
|
||||
if(!context||context.webContentsId!==webContentsId)return deny('unmanaged-download');
|
||||
if(!context.ready||!context.policy?.browser.allowDownloads)return deny('downloads-disabled');
|
||||
const yara=context.policy.security?.yara,icap=context.policy.enterprise?.icap;
|
||||
const hasYara=!!yara?.enabled&&!!yara.rules.length,hasIcap=!!icap?.enabled&&icap.scanDownloads!==false;
|
||||
if(yara?.enabled&&!hasYara)return deny('yara-required');
|
||||
if(!hasYara&&!hasIcap&&!context.policy.fileTransfers?.download?.allowWithoutMalwareScanner)return deny('malware-scanner-required');
|
||||
if(method!=='GET'||!/^https?:\/\//.test(url))return deny('unsupported-download');
|
||||
if(!isCurrent(context))return deny('policy-changed');
|
||||
busy=true;const controller=new AbortController(),timeout=setTimeout(()=>controller.abort(),45000);
|
||||
try{
|
||||
const response=await fetchResponse(url,{signal:controller.signal,cache:'no-store',credentials:'include',redirect:'error'});
|
||||
if(!response.ok||!response.body)return deny('download-fetch-failed');
|
||||
const limit=Math.min(hasYara?yara.maxScanBytes:20*1024*1024,hasIcap?icap.maxBytes:20*1024*1024,context.policy.fileTransfers?.download?.maxBytes??20*1024*1024);
|
||||
const length=response.headers.get('content-length');if(length&&Number(length)>limit)return deny('download-size-limit');
|
||||
const reader=response.body.getReader(),chunks=[];let size=0;
|
||||
try{for(;;){const {value,done}=await reader.read();if(done)break;size+=value.length;if(size>limit){await reader.cancel();return deny('download-size-limit');}chunks.push(Buffer.from(value));}}
|
||||
finally{reader.releaseLock();}
|
||||
bytes=Buffer.concat(chunks);const digest=createHash('sha256').update(bytes).digest('hex');
|
||||
if(!isCurrent(context)||controller.signal.aborted)return deny('policy-changed');
|
||||
facts=inspectFileTransfer(bytes,context.policy,'download',{filename:name,mime:response.headers.get('content-type')});if(facts.decision!=='allow')return deny(facts.ruleId);
|
||||
const result=await scan(bytes,context.policy);
|
||||
if(result.decision!=='allow'||result.coverage?.complete!==true||result.sha256!==digest)return deny(result.ruleId||'yara-scan-incomplete');
|
||||
if(!isCurrent(context)||controller.signal.aborted)return deny('policy-changed');
|
||||
const scanLabel=hasYara&&hasIcap?'yara-icap-allow':hasYara?'yara-allow':hasIcap?'icap-allow':'explicit-no-antivirus';
|
||||
await audit('download.scan',scanLabel,new URL(url).hostname,auditFacts());
|
||||
const selected=await choosePath(filename(name));if(!selected)return {released:false,reason:'user-cancelled'};
|
||||
if(!isCurrent(context)||controller.signal.aborted)return deny('policy-changed');
|
||||
// The only file-writing operation occurs after verified scanning of these exact bytes.
|
||||
await writeFile(selected,bytes,()=>isCurrent(context)&&!controller.signal.aborted);
|
||||
await audit('download.released',scanLabel,new URL(url).hostname,auditFacts());status?.('Download scanned and saved.');return {released:true,sha256:digest,facts};
|
||||
}catch{return deny('download-inspection-failed');}
|
||||
finally{clearTimeout(timeout);bytes?.fill(0);busy=false;}
|
||||
};
|
||||
}
|
||||
module.exports={createDownloadGate,filename};
|
||||
@@ -0,0 +1,20 @@
|
||||
const {app}=require('electron');
|
||||
const {spawn}=require('node:child_process');
|
||||
const path=require('node:path');
|
||||
function capabilityCrypto(operation,input){
|
||||
const root=path.join(__dirname,'../..');
|
||||
const python=app.isPackaged?path.join(process.resourcesPath,'python/python.exe'):path.join(root,'.runtime/windows/python/python.exe');
|
||||
const script=app.isPackaged?path.join(process.resourcesPath,'inspector/dpapi.py'):path.join(root,'apps/inspector/dpapi.py');
|
||||
if(!['protect','unprotect'].includes(operation)||Buffer.byteLength(input)>32768)return Promise.reject(Error('Capability rejected'));
|
||||
return new Promise((resolve,reject)=>{
|
||||
const environment={...process.env};for(const key of ['ADMIN_TOKEN','OIDC_CLIENT_SECRET','AI_API_KEY','INSPECTOR_TOKEN','SIEM_HTTP_TOKEN','SAFETYNET_PROXY_PASSWORD'])delete environment[key];
|
||||
const child=spawn(python,[script,operation],{windowsHide:true,stdio:['pipe','pipe','pipe'],env:environment});let output='',settled=false;
|
||||
const timer=setTimeout(()=>{child.kill();fail();},5000);
|
||||
function fail(){if(!settled){settled=true;clearTimeout(timer);reject(Error('Windows capability protection failed'));}}
|
||||
child.on('error',fail);child.stdin.on('error',fail);child.stderr.on('data',()=>{});
|
||||
child.stdout.on('data',data=>{output+=data;if(output.length>65536){child.kill();fail();}});
|
||||
child.on('close',code=>{if(code!==0||!output)return fail();if(!settled){settled=true;clearTimeout(timer);resolve(output);}});
|
||||
child.stdin.end(input);
|
||||
});
|
||||
}
|
||||
module.exports={capabilityCrypto};
|
||||
@@ -0,0 +1,47 @@
|
||||
'use strict';
|
||||
const fs=require('node:fs');
|
||||
const {createHash}=require('node:crypto');
|
||||
const {detectType,inspectFileTransfer}=require('../../packages/core/file-transfer.cjs');
|
||||
const CHANNELS={upload:'scanUploads',download:'scanDownloads',clipboard:'scanClipboard',ai:'scanAI'};
|
||||
function icapRequired(policy,channel){if(!Object.hasOwn(CHANNELS,channel))throw Error('Unsupported enterprise inspection channel');const settings=policy.enterprise?.icap;return !!settings?.enabled&&settings[CHANNELS[channel]]!==false;}
|
||||
function deploymentTLS(){
|
||||
const options={};for(const [key,name] of [['ca','SAFETYNET_ICAP_CA_FILE'],['cert','SAFETYNET_ICAP_CERT_FILE'],['key','SAFETYNET_ICAP_KEY_FILE']]){if(process.env[name]){const stat=fs.statSync(process.env[name]);if(!stat.isFile()||stat.size>131072)throw Error('Enterprise scanner certificate configuration unavailable');options[key]=fs.readFileSync(process.env[name]);}}
|
||||
if(!!options.cert!==!!options.key)throw Error('Enterprise scanner client identity incomplete');return options;
|
||||
}
|
||||
async function scanEnterprise(bytes,policy,channel,scanner){
|
||||
const digest=createHash('sha256').update(bytes).digest('hex');
|
||||
const base={engine:'icap',sha256:digest,bytes:bytes.length,coverage:{complete:false},decision:'block',ruleId:'icap-unavailable'};
|
||||
if(!icapRequired(policy,channel))return {...base,engine:'not-required',decision:'allow',ruleId:null,coverage:{complete:true}};
|
||||
const snapshot=Buffer.from(bytes);
|
||||
try{
|
||||
const result=await (scanner||require('./icap-client.cjs').scanIcap)(snapshot,policy.enterprise.icap,deploymentTLS());
|
||||
if(result.decision==='allow'&&result.coverage?.complete===true&&result.sha256===digest&&result.bytes===snapshot.length&&createHash('sha256').update(snapshot).digest('hex')===digest)return {...base,...result};
|
||||
return {...base,ruleId:typeof result.ruleId==='string'&&/^icap-[a-z0-9-]{1,80}$/.test(result.ruleId)?result.ruleId:'icap-inspection-incomplete'};
|
||||
}catch{return base;}
|
||||
finally{snapshot.fill(0);}
|
||||
}
|
||||
async function scanDownloadEnterprise(bytes,policy,localInspector,scanner){
|
||||
const digest=createHash('sha256').update(bytes).digest('hex');
|
||||
const blocked=ruleId=>({decision:'block',ruleId,engine:'enterprise',sha256:digest,bytes:bytes.length,coverage:{complete:false}});
|
||||
const fileVerdict=inspectFileTransfer(bytes,policy,'download');if(fileVerdict.decision!=='allow')return blocked(fileVerdict.ruleId);
|
||||
const noAv=!policy.security?.yara.enabled&&!icapRequired(policy,'download');
|
||||
if(noAv&&!policy.fileTransfers?.download?.allowWithoutMalwareScanner)return blocked('malware-scanner-required');
|
||||
// Antivirus clean does not classify company secrets. Supported documents always
|
||||
// require the contained DLP parser; explicit no-antivirus mode still requires DLP.
|
||||
if(noAv||policy.dlp?.semantic?.enabled||['pdf','zip','png','jpeg','gif','webp','text'].includes(detectType(bytes))){
|
||||
if(!localInspector?.inspect)return blocked('download-dlp-unavailable');
|
||||
let dlp;try{dlp=await localInspector.inspect(bytes,policy);}catch{return blocked('download-dlp-unavailable');}
|
||||
if(dlp.decision!=='allow')return blocked(typeof dlp.ruleId==='string'?dlp.ruleId:'download-dlp-blocked');
|
||||
if(dlp.coverage?.complete!==true)return blocked('download-dlp-incomplete');
|
||||
}
|
||||
if(noAv)return {decision:'allow',ruleId:null,engine:'explicit-no-antivirus',sha256:digest,bytes:bytes.length,coverage:{complete:true}};
|
||||
if(policy.security?.yara.enabled){
|
||||
if(!localInspector)return blocked('yara-unavailable');
|
||||
let local;try{local=await localInspector.scanDownload(bytes,policy);}catch{return blocked('yara-unavailable');}
|
||||
if(local.decision!=='allow')return local;
|
||||
if(local.coverage?.complete!==true||local.sha256!==digest||local.bytes!==bytes.length)return blocked('yara-inspection-incomplete');
|
||||
if(!icapRequired(policy,'download'))return local;
|
||||
}
|
||||
return scanEnterprise(bytes,policy,'download',scanner);
|
||||
}
|
||||
module.exports={icapRequired,scanEnterprise,scanDownloadEnterprise,deploymentTLS};
|
||||
@@ -0,0 +1,97 @@
|
||||
'use strict';
|
||||
// RFC 3507, full-message ICAP only. Adapted objects are never released as originals.
|
||||
// TLS follows the endpoint trust chain and hostname; deployment credentials stay local.
|
||||
const net = require('node:net');
|
||||
const tls = require('node:tls');
|
||||
const crypto = require('node:crypto');
|
||||
const HEADER_LIMIT = 32768, ABSOLUTE_LIMIT = 32 * 1024 * 1024;
|
||||
function configuration(config) {
|
||||
const url = new URL(config.url);
|
||||
if (!['icap:', 'icaps:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || /[\r\n]/.test(config.url)) throw Error('configuration');
|
||||
const host = url.hostname.replace(/^\[|\]$/g, '');
|
||||
const loopback = host === 'localhost' || host === '::1' || (net.isIP(host) === 4 && host.split('.')[0] === '127');
|
||||
if (url.protocol === 'icap:' && !loopback && config.allowInsecureTransport !== true) throw Error('configuration');
|
||||
if (!['RESPMOD', 'REQMOD'].includes(config.method || 'RESPMOD')) throw Error('configuration');
|
||||
const maxBytes = config.maxBytes ?? ABSOLUTE_LIMIT, timeoutMs = config.timeoutMs ?? 10000;
|
||||
if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > ABSOLUTE_LIMIT || !Number.isInteger(timeoutMs) || timeoutMs < 50 || timeoutMs > 120000) throw Error('configuration');
|
||||
const port = Number(url.port || (url.protocol === 'icaps:' ? 11344 : 1344));
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw Error('configuration');
|
||||
return {url, host, connectHost:host === 'localhost' ? '127.0.0.1' : host, port, method: config.method || 'RESPMOD', maxBytes, timeoutMs, allow204: config.allow204 === true};
|
||||
}
|
||||
function headers(bytes) {
|
||||
const lines = bytes.toString('latin1').split('\r\n');
|
||||
const match = /^ICAP\/1\.0 ([0-9]{3})(?: [\x20-\x7e]*)?$/.exec(lines.shift());
|
||||
if (!match) throw Error('protocol');
|
||||
const values = Object.create(null);
|
||||
for (const line of lines) {
|
||||
const header = /^([!#$%&'*+.^_`|~0-9A-Za-z-]+):[ \t]*([\x20-\x7e\t]*)$/.exec(line);
|
||||
if (!header) throw Error('protocol');
|
||||
const name = header[1].toLowerCase();
|
||||
if (Object.hasOwn(values, name)) throw Error('protocol');
|
||||
values[name] = header[2].trim();
|
||||
}
|
||||
return {status: Number(match[1]), values};
|
||||
}
|
||||
function exchange(c, request, deadline, credentials = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let socket, buffered = Buffer.alloc(0), finished = false, sent = false;
|
||||
const finish = (error, response) => { if (finished) return; finished = true; clearTimeout(timer); socket?.destroy(); error ? reject(Error('ICAP unavailable')) : resolve(response); };
|
||||
const timer = setTimeout(() => finish(true), Math.max(1, deadline - Date.now()));
|
||||
const connect = () => socket.write(request, error => { if (error) finish(true); else sent = true; });
|
||||
try {
|
||||
socket = c.url.protocol === 'icaps:' ? tls.connect({host:c.connectHost, port:c.port, servername:net.isIP(c.host) ? undefined : c.host, rejectUnauthorized:true, minVersion:'TLSv1.2', ca:credentials.ca, cert:credentials.cert, key:credentials.key}, connect) : net.connect({host:c.connectHost,port:c.port}, connect);
|
||||
socket.on('error', () => finish(true));
|
||||
socket.on('end', () => finish(true));
|
||||
socket.on('data', chunk => {
|
||||
try {
|
||||
if (buffered.length + chunk.length > HEADER_LIMIT) throw Error('protocol');
|
||||
buffered = Buffer.concat([buffered,chunk]);
|
||||
const boundary = buffered.indexOf('\r\n\r\n');
|
||||
if (boundary < 0) return;
|
||||
const response = headers(buffered.subarray(0,boundary));
|
||||
// No preview is sent: a premature verdict cannot attest complete inspection.
|
||||
if (!sent || response.status === 100) throw Error('protocol');
|
||||
const bodyBytes = buffered.length - boundary - 4;
|
||||
// Adaptations are blocked immediately; do not consume arbitrary vendor bodies.
|
||||
if (response.status === 204 && bodyBytes !== 0) throw Error('protocol');
|
||||
finish(false,response);
|
||||
} catch { finish(true); }
|
||||
});
|
||||
} catch { finish(true); }
|
||||
});
|
||||
}
|
||||
function request(c, method, encapsulation, body = Buffer.alloc(0)) {
|
||||
const allow = method !== 'OPTIONS' && c.allow204 ? 'Allow: 204\r\n' : '';
|
||||
return Buffer.concat([Buffer.from(`${method} ${c.url.href} ICAP/1.0\r\nHost: ${c.url.host}\r\nUser-Agent: S-Browser/1\r\nConnection: close\r\n${allow}Encapsulated: ${encapsulation}\r\n\r\n`,'ascii'),body]);
|
||||
}
|
||||
async function negotiate(c, deadline, options) {
|
||||
const response = await exchange(c,request(c,'OPTIONS','null-body=0'),deadline,options);
|
||||
if (response.status !== 200 || response.values.encapsulated !== 'null-body=0' || !response.values.istag || !response.values.methods?.split(/\s*,\s*/).includes(c.method)) throw Error('unsupported');
|
||||
return response.values.allow?.split(/\s*,\s*/).includes('204') === true;
|
||||
}
|
||||
async function probeIcap(config, options = {}) {
|
||||
try { const c = configuration(config); const allow204 = await negotiate(c,Date.now()+c.timeoutMs,options); return {ready:!c.allow204 || allow204,engine:'icap',method:c.method,tls:c.url.protocol === 'icaps:',allow204}; }
|
||||
catch { return {ready:false,engine:'icap'}; }
|
||||
}
|
||||
async function scanIcap(input, config, options = {}) {
|
||||
// Snapshot before any await: the approval is bound to exactly these bytes.
|
||||
const bytes = Buffer.from(input);
|
||||
const result = (decision,ruleId,complete) => ({decision,ruleId,engine:'icap',sha256:crypto.createHash('sha256').update(bytes).digest('hex'),bytes:bytes.length,coverage:{complete}});
|
||||
try {
|
||||
const c = configuration(config);
|
||||
if (bytes.length > c.maxBytes) return result('block','icap-oversize',false);
|
||||
const deadline = Date.now()+c.timeoutMs;
|
||||
const negotiated = await negotiate(c,deadline,options);
|
||||
if (c.allow204 && !negotiated) return result('block','icap-capability',false);
|
||||
// Synthetic metadata avoids sending usernames, private URLs or filenames.
|
||||
const http = Buffer.from(c.method === 'RESPMOD' ? `HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: ${bytes.length}\r\n\r\n` : `POST /scan/object HTTP/1.1\r\nHost: inspection.invalid\r\nContent-Type: application/octet-stream\r\nContent-Length: ${bytes.length}\r\n\r\n`,'ascii');
|
||||
const chunk = bytes.length ? Buffer.concat([Buffer.from(bytes.length.toString(16)+'\r\n'),bytes,Buffer.from('\r\n0\r\n\r\n')]) : Buffer.from('0\r\n\r\n');
|
||||
const prefix = c.method === 'RESPMOD' ? 'res' : 'req';
|
||||
const response = await exchange(c,request(c,c.method,`${prefix}-hdr=0, ${prefix}-body=${http.length}`,Buffer.concat([http,chunk])),deadline,options);
|
||||
const flagged = Object.keys(response.values).some(name => ['x-infection-found','x-virus-id','x-violations-found','x-block-reason'].includes(name));
|
||||
if (flagged) return result('block','icap-threat',false);
|
||||
if (response.status === 204 && c.allow204 && negotiated && response.values.istag && (!response.values.encapsulated || response.values.encapsulated === 'null-body=0')) return result('allow','icap-clean',true);
|
||||
return result('block',response.status === 200 ? 'icap-adaptation' : 'icap-verdict',false);
|
||||
} catch { return result('block','icap-unavailable',false); }
|
||||
}
|
||||
module.exports = {scanIcap,probeIcap};
|
||||
@@ -0,0 +1,43 @@
|
||||
ISC License
|
||||
|
||||
Copyright (c) 2026 Lucide Icons and Contributors
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
The following Lucide icons are derived from the Feather project:
|
||||
|
||||
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
|
||||
|
||||
The MIT License (MIT) (for the icons listed above)
|
||||
|
||||
Copyright (c) 2013-present Cole Bemis
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-arrow-up"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m5 12 7-7 7 7" />
|
||||
<path d="M12 19V5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 340 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-book-open"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 5v16" />
|
||||
<path d="M20.001 19A2 2 0 0022 17V5a2 2 0 00-1.999-2L16 3.002A5 5 0 0012 5a5 5 0 00-4-2H4a2 2 0 00-2 2v12a2 2 0 001.999 2H8a5 5 0 014 2 5 5 0 014-2z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 467 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-bookmark-plus"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 7v6" />
|
||||
<path d="M15 10H9" />
|
||||
<path d="M17 3a2 2 0 0 1 2 2v15a1 1 0 0 1-1.496.868l-4.512-2.578a2 2 0 0 0-1.984 0l-4.512 2.578A1 1 0 0 1 5 20V5a2 2 0 0 1 2-2z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 473 B |
@@ -0,0 +1,15 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-bookmark"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M17 3a2 2 0 0 1 2 2v15a1 1 0 0 1-1.496.868l-4.512-2.578a2 2 0 0 0-1.984 0l-4.512 2.578A1 1 0 0 1 5 20V5a2 2 0 0 1 2-2z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 421 B |
@@ -0,0 +1,20 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-bot"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 8V4H8" />
|
||||
<rect width="16" height="12" x="4" y="8" rx="2" />
|
||||
<path d="M2 14h2" />
|
||||
<path d="M20 14h2" />
|
||||
<path d="M15 13v2" />
|
||||
<path d="M9 13v2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 454 B |
@@ -0,0 +1,15 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-chevron-left"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 321 B |
@@ -0,0 +1,15 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-chevron-right"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 321 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-ellipsis"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="19" cy="12" r="1" />
|
||||
<circle cx="5" cy="12" r="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1,15 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-folder"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 419 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-globe"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
<path d="M2 12h20" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 407 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-grid-2x2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 3v18" />
|
||||
<path d="M3 12h18" />
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 388 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-key-round"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z" />
|
||||
<circle cx="16.5" cy="7.5" r=".5" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 533 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-lock-keyhole"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="16" r="1" />
|
||||
<rect x="3" y="10" width="18" height="12" rx="2" />
|
||||
<path d="M7 10V7a5 5 0 0 1 10 0v3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 420 B |
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-maximize-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M15 3h6v6" />
|
||||
<path d="m21 3-7 7" />
|
||||
<path d="m3 21 7-7" />
|
||||
<path d="M9 21H3v-6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 390 B |
@@ -0,0 +1,15 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-minus"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 308 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-panel-left"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M9 3v18" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 365 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-panel-right"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M15 3v18" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 367 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-plus"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
<path d="M12 5v14" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 331 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-rotate-cw"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 379 B |
@@ -0,0 +1,21 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-scan-text"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
|
||||
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
|
||||
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
|
||||
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
|
||||
<path d="M7 8h8" />
|
||||
<path d="M7 12h10" />
|
||||
<path d="M7 16h6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 515 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-search"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m21 21-4.34-4.34" />
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 352 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-share"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 2v13" />
|
||||
<path d="m16 6-4-4-4 4" />
|
||||
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 394 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-shield-check"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 498 B |
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-sparkles"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z" />
|
||||
<path d="M20 2v4" />
|
||||
<path d="M22 4h-4" />
|
||||
<circle cx="4" cy="20" r="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 646 B |
@@ -0,0 +1,15 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-squares-unite"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4 16a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v3a1 1 0 0 0 1 1h3a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2v-3a1 1 0 0 0-1-1z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 444 B |
@@ -0,0 +1,15 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-star"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 663 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-x"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 332 B |
@@ -0,0 +1,481 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'">
|
||||
<title>
|
||||
S Browser
|
||||
</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body data-theme="auto" data-tab-mode="separate">
|
||||
|
||||
<!-- Trusted browser chrome: native layout uses this measured height. -->
|
||||
<header id="chrome">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-leading">
|
||||
<div class="traffic-lights" aria-label="Window controls">
|
||||
<button id="window-close" class="traffic close" aria-label="Close window" title="Close">
|
||||
<img src="icons/x.svg" alt="">
|
||||
</button>
|
||||
<button id="window-minimize" class="traffic minimize" aria-label="Minimize window" title="Minimize">
|
||||
<img src="icons/minus.svg" alt="">
|
||||
</button>
|
||||
<button id="window-maximize" class="traffic maximize" aria-label="Maximize or restore window" title="Maximize">
|
||||
<img src="icons/maximize-2.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
<button id="sidebar-toggle" class="icon-button" title="Show sidebar" aria-label="Toggle sidebar" aria-expanded="false">
|
||||
<img src="icons/panel-left.svg" alt="">
|
||||
</button>
|
||||
<button type="button" id="back" class="icon-button" aria-label="Back" title="Back">
|
||||
<img src="icons/chevron-left.svg" alt="">
|
||||
</button>
|
||||
<button type="button" id="forward" class="icon-button" aria-label="Forward" title="Forward">
|
||||
<img src="icons/chevron-right.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
<form id="address-form">
|
||||
<img class="address-lock" src="icons/globe.svg" alt="">
|
||||
<input id="address" aria-label="Website address" aria-describedby="managed" placeholder="Enter website name or address" autocomplete="off" spellcheck="false">
|
||||
<button type="button" id="reload" class="icon-button" aria-label="Reload" title="Reload">
|
||||
<img src="icons/rotate-cw.svg" alt="">
|
||||
</button>
|
||||
<button type="submit" class="visually-hidden">
|
||||
Go
|
||||
</button>
|
||||
</form>
|
||||
<div class="toolbar-trailing">
|
||||
<button id="share-url" class="icon-button" title="Share page link" aria-label="Share page link">
|
||||
<img src="icons/share.svg" alt="">
|
||||
</button>
|
||||
<button id="new-tab" class="icon-button" title="New tab" aria-label="New tab">
|
||||
<img src="icons/plus.svg" alt="">
|
||||
</button>
|
||||
<button id="tab-overview" class="icon-button" title="Tab overview" aria-label="Tab overview" aria-expanded="false">
|
||||
<img src="icons/squares-unite.svg" alt="">
|
||||
</button>
|
||||
<button id="quick-ai" class="icon-button" hidden disabled title="AI workspace" aria-label="AI workspace"><img src="icons/sparkles.svg" alt=""></button>
|
||||
<button id="quick-passwords" class="icon-button" hidden disabled title="Passwords" aria-label="Passwords"><img src="icons/key-round.svg" alt=""></button>
|
||||
<button id="quick-inspect" class="icon-button" hidden disabled title="Inspect document locally" aria-label="Inspect document locally"><img src="icons/scan-text.svg" alt=""></button>
|
||||
<button id="quick-agents" class="icon-button" hidden disabled title="Local agents" aria-label="Local agents"><img src="icons/bot.svg" alt=""></button>
|
||||
<button id="tools-toggle" hidden disabled class="icon-button" title="Browser tools and appearance" aria-label="Browser tools and appearance" aria-expanded="false" aria-controls="tools-menu">
|
||||
<img src="icons/ellipsis.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-row">
|
||||
<div id="tab-strip" aria-label="Saved browsing tabs">
|
||||
<button class="browser-tab active">
|
||||
Start Page
|
||||
</button>
|
||||
</div>
|
||||
<div class="security-state">
|
||||
<img src="icons/shield-check.svg" alt="">
|
||||
<span id="managed">
|
||||
Enrollment required
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div id="tools-menu" class="glass-popover" hidden>
|
||||
<p class="menu-caption">
|
||||
WORKSPACE TOOLS
|
||||
</p>
|
||||
<button id="ai-toggle" class="menu-item" aria-expanded="false">
|
||||
<img src="icons/sparkles.svg" alt="">
|
||||
AI workspace
|
||||
</button>
|
||||
<button id="sidebar" class="menu-item" aria-expanded="false">
|
||||
<img src="icons/panel-right.svg" alt="">
|
||||
Toggle AI sidebar
|
||||
</button>
|
||||
<button id="passwords" class="menu-item" aria-expanded="false">
|
||||
<img src="icons/key-round.svg" alt="">
|
||||
Passwords
|
||||
</button>
|
||||
<button id="inspect" class="menu-item" title="Inspect document locally">
|
||||
<img src="icons/scan-text.svg" alt="">
|
||||
Inspect document / media
|
||||
</button>
|
||||
<button id="agents" class="menu-item">
|
||||
<img src="icons/bot.svg" alt="">
|
||||
Local agents
|
||||
</button>
|
||||
<button id="bookmark" class="menu-item">
|
||||
<img src="icons/bookmark-plus.svg" alt="">
|
||||
Bookmark this page
|
||||
</button>
|
||||
<button id="reading-add" class="menu-item">
|
||||
<img src="icons/book-open.svg" alt="">
|
||||
Save to Reading List
|
||||
</button>
|
||||
<section id="appearance-section" hidden>
|
||||
<div class="menu-divider">
|
||||
</div>
|
||||
<p class="menu-caption">APPEARANCE</p>
|
||||
<div id="settings">
|
||||
<label for="theme-mode">
|
||||
Theme
|
||||
</label>
|
||||
<select id="theme-mode">
|
||||
<option value="auto">
|
||||
System
|
||||
</option>
|
||||
<option value="light">
|
||||
Light
|
||||
</option>
|
||||
<option value="dark">
|
||||
Dark
|
||||
</option>
|
||||
</select>
|
||||
<label for="accent-mode">Accent</label>
|
||||
<select id="accent-mode">
|
||||
<option value="blue">Blue</option>
|
||||
<option value="teal">Teal</option>
|
||||
<option value="violet">Violet</option>
|
||||
</select>
|
||||
<label for="tab-mode">
|
||||
Tab layout
|
||||
</label>
|
||||
<select id="tab-mode">
|
||||
<option value="separate">
|
||||
Separate
|
||||
</option>
|
||||
<option value="compact">
|
||||
Compact
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<p id="settings-managed" class="appearance-status" hidden>Appearance is set by your administrator.</p>
|
||||
<p id="theme-status" class="appearance-status" aria-live="polite">Follows your system appearance.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Local sidebar metadata; remote website content is a separate native view. -->
|
||||
<aside id="library" hidden>
|
||||
<div class="library-brand">
|
||||
<div class="brand-monogram">
|
||||
S
|
||||
</div>
|
||||
<div>
|
||||
<strong id="brand">
|
||||
S Browser
|
||||
</strong>
|
||||
<span>
|
||||
Managed workspace
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav aria-label="Browser library">
|
||||
<details class="library-section" open>
|
||||
<summary class="library-item">
|
||||
<img src="icons/star.svg" alt="">
|
||||
Favorites
|
||||
</summary>
|
||||
<div id="library-favorites" class="library-links">
|
||||
</div>
|
||||
</details>
|
||||
<details class="library-section">
|
||||
<summary class="library-item">
|
||||
<img src="icons/bookmark.svg" alt="">
|
||||
Bookmarks
|
||||
</summary>
|
||||
<div id="library-bookmarks" class="library-links">
|
||||
</div>
|
||||
</details>
|
||||
<details class="library-section">
|
||||
<summary class="library-item">
|
||||
<img src="icons/book-open.svg" alt="">
|
||||
Reading List
|
||||
</summary>
|
||||
<div id="library-reading" class="library-links">
|
||||
</div>
|
||||
</details>
|
||||
<div class="library-heading">
|
||||
<span>
|
||||
TAB GROUPS
|
||||
</span>
|
||||
</div>
|
||||
<div class="group-controls">
|
||||
<input id="group-name" placeholder="New group name" aria-label="New tab group name" maxlength="80" autocomplete="off">
|
||||
<button id="group-add" class="icon-button" aria-label="Add tab group">
|
||||
<img src="icons/plus.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
<div id="library-groups">
|
||||
</div>
|
||||
<div class="library-heading">
|
||||
<span>
|
||||
OPEN TABS
|
||||
</span>
|
||||
</div>
|
||||
<div id="library-tabs">
|
||||
</div>
|
||||
</nav>
|
||||
<div class="library-footer">
|
||||
<img src="icons/shield-check.svg" alt="">
|
||||
<span>
|
||||
Managed browser workspace
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Trusted start page, shown only while the native website view is hidden. -->
|
||||
<main id="start-page" hidden>
|
||||
<div class="start-content">
|
||||
<p class="start-eyebrow">
|
||||
S BROWSER
|
||||
</p>
|
||||
<h1>
|
||||
Your space to explore.
|
||||
</h1>
|
||||
<section class="start-section">
|
||||
<div class="section-title">
|
||||
<h2>
|
||||
Favorites
|
||||
</h2>
|
||||
<span>
|
||||
Keep what matters close.
|
||||
</span>
|
||||
</div>
|
||||
<div id="favorites-grid" class="favorites-grid">
|
||||
</div>
|
||||
<p id="favorites-empty" hidden>
|
||||
Bookmark a page to add it to your favorites.
|
||||
</p>
|
||||
</section>
|
||||
<section class="start-section">
|
||||
<div class="section-title">
|
||||
<h2>
|
||||
Recently visited
|
||||
</h2>
|
||||
</div>
|
||||
<div id="recent-list" class="recent-list">
|
||||
</div>
|
||||
</section>
|
||||
<div class="privacy-card">
|
||||
<div class="privacy-icon">
|
||||
<img src="icons/shield-check.svg" alt="">
|
||||
</div>
|
||||
<div>
|
||||
<strong>
|
||||
A protected workspace.
|
||||
</strong>
|
||||
<p>
|
||||
Your organization's rules follow you as you browse.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="session-note">
|
||||
Each tab keeps its own managed browsing context.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Navigation-record overview; inactive records are not running isolated pages. -->
|
||||
<section id="overview" hidden aria-label="Tab overview">
|
||||
<div class="overview-heading">
|
||||
<h1>
|
||||
Your tabs
|
||||
</h1>
|
||||
<button id="overview-close" class="icon-button" aria-label="Close tab overview">
|
||||
<img src="icons/x.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
<p class="overview-note">
|
||||
Pick up where you left off.
|
||||
</p>
|
||||
<div id="overview-grid">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Enterprise enrollment and paused-access state. -->
|
||||
<main id="welcome">
|
||||
<div class="card">
|
||||
<div class="welcome-mark">
|
||||
<img src="icons/shield-check.svg" alt="">
|
||||
</div>
|
||||
<p class="eyebrow">
|
||||
YOUR MANAGED WORKSPACE
|
||||
</p>
|
||||
<h1>
|
||||
A safer place to work.
|
||||
</h1>
|
||||
<p id="message">
|
||||
Connect to your organization for secure browsing and approved AI.
|
||||
</p>
|
||||
<div id="enrollment">
|
||||
<label>
|
||||
Management server
|
||||
<input id="server" value="http://127.0.0.1:4310" type="url">
|
||||
</label>
|
||||
<label>
|
||||
Enrollment code
|
||||
<input id="code" type="password" autocomplete="off" placeholder="Provided by your administrator">
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button id="enroll">
|
||||
Connect browser
|
||||
</button>
|
||||
<button id="sso" class="secondary">
|
||||
Enterprise SSO
|
||||
</button>
|
||||
</div>
|
||||
<small>
|
||||
Your organization's management server supplies the security policy for this browser.
|
||||
</small>
|
||||
</div>
|
||||
<p id="error" role="status" aria-live="polite">
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Approved AI sidebar; retains existing security and inspection controls. -->
|
||||
<aside id="ai" hidden>
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<p class="eyebrow">
|
||||
APPROVED BY YOUR ORGANIZATION
|
||||
</p>
|
||||
<h2>
|
||||
AI workspace
|
||||
</h2>
|
||||
</div>
|
||||
<button id="ai-close" class="icon-button" aria-label="Close AI panel">
|
||||
<img src="icons/x.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
<p class="panel-description">
|
||||
Prompts are inspected before reaching your approved model. Page content is shared only when you choose.
|
||||
</p>
|
||||
<div id="conversation" aria-live="polite">
|
||||
<div class="empty-chat">
|
||||
<img src="icons/sparkles.svg" alt="">
|
||||
<h3>
|
||||
Make room for better work.
|
||||
</h3>
|
||||
<p>
|
||||
Ask a question, draft an idea, or inspect a document locally.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form id="ai-form">
|
||||
<label class="visually-hidden" for="prompt">
|
||||
Prompt
|
||||
</label>
|
||||
<textarea id="prompt" placeholder="Ask your company AI…" maxlength="100000">
|
||||
</textarea>
|
||||
<div class="composer-footer">
|
||||
<span>
|
||||
Policy checked
|
||||
</span>
|
||||
<button id="send" type="submit">
|
||||
Send
|
||||
<img src="icons/arrow-up.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
<!-- Local password-vault UI: passwords remain masked and never rendered in lists. -->
|
||||
<aside id="vault" hidden>
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<p class="eyebrow">
|
||||
PRIVATE TO THIS DEVICE
|
||||
</p>
|
||||
<h2>
|
||||
Passwords
|
||||
</h2>
|
||||
</div>
|
||||
<button id="vault-close" class="icon-button" aria-label="Close passwords">
|
||||
<img src="icons/x.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
<p id="vault-description" class="panel-description">
|
||||
Unlock your encrypted vault to use a saved login.
|
||||
</p>
|
||||
<p id="vault-error" role="status" aria-live="polite">
|
||||
</p>
|
||||
<form id="vault-access">
|
||||
<label id="master-label" for="vault-master">
|
||||
Master password
|
||||
</label>
|
||||
<input id="vault-master" type="password" autocomplete="current-password" minlength="16" maxlength="1024" required>
|
||||
<label id="vault-confirm-label" for="vault-confirm" hidden>
|
||||
Confirm master password
|
||||
</label>
|
||||
<input id="vault-confirm" type="password" autocomplete="new-password" minlength="16" maxlength="1024" hidden>
|
||||
<button id="vault-access-submit" type="submit">
|
||||
Unlock vault
|
||||
</button>
|
||||
<small id="vault-access-note">
|
||||
Use at least 16 characters. Your master password is never saved.
|
||||
</small>
|
||||
</form>
|
||||
<div id="vault-open" hidden>
|
||||
<div class="vault-actions">
|
||||
<span id="vault-state">
|
||||
Unlocked
|
||||
</span>
|
||||
<button id="vault-lock" class="secondary">
|
||||
Lock
|
||||
</button>
|
||||
</div>
|
||||
<div id="vault-list">
|
||||
</div>
|
||||
<details id="vault-add">
|
||||
<summary>
|
||||
Add a login
|
||||
</summary>
|
||||
<form id="vault-save">
|
||||
<label>
|
||||
Website origin
|
||||
<input id="vault-origin" type="url" placeholder="https://company.example" required autocomplete="off" maxlength="4096">
|
||||
</label>
|
||||
<label>
|
||||
Label
|
||||
<input id="vault-label" placeholder="Work account" maxlength="100" autocomplete="off">
|
||||
</label>
|
||||
<label>
|
||||
Username
|
||||
<input id="vault-username" autocomplete="off" maxlength="512">
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input id="vault-password" type="password" autocomplete="new-password" maxlength="1024" required>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button id="vault-generate" type="button" class="secondary">
|
||||
Generate
|
||||
</button>
|
||||
<button id="vault-save-submit" type="submit">
|
||||
Save login
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
<small>
|
||||
Logins fill only when you click Fill. Automatic page-load filling is disabled.
|
||||
</small>
|
||||
</div>
|
||||
</aside>
|
||||
<dialog id="bookmark-editor" aria-labelledby="bookmark-editor-title">
|
||||
<form id="bookmark-form">
|
||||
<h2 id="bookmark-editor-title">Add bookmark</h2>
|
||||
<p class="bookmark-notice">Bookmarks are managed by your company. Administrators may access them when enabled by policy.</p>
|
||||
<label>Title<input id="bookmark-title" maxlength="120" required autocomplete="off"></label>
|
||||
<label>Website URL<input id="bookmark-url" type="url" maxlength="2048" required autocomplete="off"></label>
|
||||
<label>Folder<input id="bookmark-folder" maxlength="80" autocomplete="off"></label>
|
||||
<p id="bookmark-editor-error" role="alert"></p>
|
||||
<div class="actions"><button id="bookmark-cancel" type="button" class="secondary">Cancel</button><button id="bookmark-save" type="submit">Save bookmark</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<script src="renderer.js">
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
const {spawn}=require('node:child_process');
|
||||
const fs=require('node:fs'),path=require('node:path'),crypto=require('node:crypto');
|
||||
function privateInspectorPaths(root,packaged=false,environment=process.env){
|
||||
const python=path.resolve(root,packaged?'python/python.exe':'.runtime/windows/python/python.exe');
|
||||
const script=path.resolve(root,packaged?'inspector/server.py':'apps/inspector/server.py');
|
||||
try{
|
||||
for(const [file,override]of [[python,environment.SAFETYNET_PYTHON],[script,environment.SAFETYNET_INSPECTOR_SCRIPT]]){
|
||||
if(override&&path.resolve(override).toLowerCase()!==file.toLowerCase())return null;
|
||||
if(!fs.statSync(file).isFile())return null;
|
||||
// Do not resolve away a junction before checking ownership boundaries.
|
||||
for(let item=file;;item=path.dirname(item)){
|
||||
if(fs.lstatSync(item).isSymbolicLink())return null;
|
||||
if(path.dirname(item)===item)break;
|
||||
}
|
||||
}
|
||||
return {python,script};
|
||||
}catch{return null;}
|
||||
}
|
||||
function startInspector(root,packaged=false){
|
||||
const files=privateInspectorPaths(root,packaged);if(!files)return null;
|
||||
const {python,script}=files;
|
||||
const credential=crypto.randomBytes(32).toString('base64url');
|
||||
const semanticRuntime=path.join(root,packaged?'semantic':'.runtime/windows/semantic');
|
||||
// Native model loading remains wholly local; the Python adapter verifies pinned artifacts.
|
||||
// Explicit deployment paths take precedence, and missing artifacts fail semantic inspection closed.
|
||||
const environment={...process.env,INSPECTOR_TOKEN:credential,INSPECTOR_PORT:'0',INSPECTOR_PARENT_PID:String(process.pid)};
|
||||
if(!environment.EMBEDDING_MODEL_DIR)environment.EMBEDDING_MODEL_DIR=path.join(semanticRuntime,'models');
|
||||
if(!environment.EMBEDDING_WORKER_PATH)environment.EMBEDDING_WORKER_PATH=path.join(semanticRuntime,'searchi-gemma-worker.exe');
|
||||
for(const key of ['ADMIN_TOKEN','OIDC_CLIENT_SECRET','AI_API_KEY','SIEM_HTTP_TOKEN','SIEM_KEY_FILE','SAFETYNET_PROXY_PASSWORD'])delete environment[key];
|
||||
const child=spawn(python,[script],{windowsHide:true,stdio:['ignore','pipe','pipe'],env:environment});
|
||||
let port,output='';const startup=new Promise((resolve,reject)=>{
|
||||
const timer=setTimeout(()=>{child.kill();reject(Error('Local inspector startup timed out'));},30000);
|
||||
child.on('error',()=>{clearTimeout(timer);reject(Error('Local inspector failed to start'));});
|
||||
child.once('exit',()=>{clearTimeout(timer);reject(Error('Local inspector stopped'));});
|
||||
child.stdout.on('data',chunk=>{output+=chunk.toString();if(output.length>4096){child.kill();return;}
|
||||
if(output.includes('\n')){try{const value=JSON.parse(output.split('\n')[0]);if(!Number.isInteger(value.port)||value.port<1||value.port>65535)throw Error();if(process.platform==='win32'&&(value.containment?.active!==true||value.containment?.kind!=='windows-job-v1'||value.containment?.killsDescendants!==true||value.containment?.parserIsolation?.active!==true||value.containment?.parserIsolation?.kind!=='windows-appcontainer-v1'||value.containment?.parserIsolation?.networkCapabilities!==0||value.containment?.parserIsolation?.privatePipes!==true||value.containment?.parserIsolation?.readOnlyRuntime!==true))throw Error();port=value.port;clearTimeout(timer);resolve();}catch{child.kill();reject(Error('Local inspector startup rejected'));}}});
|
||||
child.stderr.on('data',()=>{});
|
||||
});startup.catch(()=>{});
|
||||
const waitReady=async()=>{await startup;if(child.exitCode!==null||child.signalCode!==null)throw Error('Local content inspector stopped; restart the browser');};
|
||||
const invoke=async(route,bytes,policy)=>{
|
||||
await waitReady();
|
||||
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),30000);
|
||||
try{
|
||||
const response=await require('./loopback-http.cjs').postLoopback({port,route,token:credential,body:{data:Buffer.from(bytes).toString('base64'),policy},signal:controller.signal});
|
||||
if(response.status!==200)throw Error('Local content inspection failed');const result=response.body;
|
||||
if(!['allow','block'].includes(result.decision)||typeof result.coverage?.complete!=='boolean')throw Error('Inspector response rejected');
|
||||
if(result.decision==='allow'&&!result.coverage.complete)throw Error('Incomplete content cannot be released');return result;
|
||||
}catch(error){if(error.name==='AbortError'){child.kill();throw Error('Content inspection exceeded its time budget');}throw error;}
|
||||
finally{clearTimeout(timer);}
|
||||
};
|
||||
return {stop:()=>child.kill(),waitReady,inspect:(bytes,policy)=>invoke('/inspect',bytes,policy),scanDownload:(bytes,policy)=>invoke('/scan-download',bytes,policy)};
|
||||
}
|
||||
module.exports={startInspector,privateInspectorPaths};
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
const http=require('node:http');
|
||||
function postLoopback({port,route,token,body,signal,maxResponseBytes=2*1024*1024}){
|
||||
if(!Number.isInteger(Number(port))||Number(port)<1||Number(port)>65535||!/^\/[a-z0-9/-]+$/i.test(route)||typeof token!=='string')return Promise.reject(Error('Local endpoint rejected'));
|
||||
const payload=Buffer.from(JSON.stringify(body));
|
||||
return new Promise((resolve,reject)=>{
|
||||
// Direct TCP avoids environment/global proxy routing of private content.
|
||||
const request=http.request({hostname:'127.0.0.1',port:Number(port),path:route,method:'POST',agent:false,signal,
|
||||
headers:{authorization:`Bearer ${token}`,'content-type':'application/json','content-length':payload.length}},response=>{
|
||||
if(response.statusCode>=300&&response.statusCode<400){response.resume();reject(Error('Local redirects rejected'));return;}
|
||||
let size=0;const chunks=[];response.on('error',reject);
|
||||
response.on('data',chunk=>{size+=chunk.length;if(size>maxResponseBytes){response.destroy();request.destroy();reject(Error('Local response budget exceeded'));}else chunks.push(chunk);});
|
||||
response.on('end',()=>{try{resolve({status:response.statusCode,body:JSON.parse(Buffer.concat(chunks).toString('utf8'))});}catch{reject(Error('Invalid local JSON response'));}});
|
||||
});
|
||||
request.on('error',reject);request.end(payload);
|
||||
});
|
||||
}
|
||||
module.exports={postLoopback};
|
||||
@@ -0,0 +1,631 @@
|
||||
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);
|
||||
// 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();
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
const {createDiagnostics}=require('./diagnostics.cjs');
|
||||
const diagnostics=createDiagnostics();
|
||||
function diagnosticContext(extra={}){return {policyVersion:policy?.version||0,...(device?.id?{deviceId:device.id}:{}),...extra};}
|
||||
function diagnosticSpan(name,attributes,operation){
|
||||
// Instrumentation never replaces, skips or retries a security operation.
|
||||
let started=false;const run=()=>{started=true;return Promise.resolve(operation()).then(result=>{if(['allow','block','deny','unknown'].includes(result?.decision)){try{diagnostics.event(name,diagnosticContext({...attributes,decision:result.decision}));}catch{}}return result;});};
|
||||
try{return diagnostics.span(name,diagnosticContext(attributes),run);}catch(error){if(started)throw error;return run();}
|
||||
}
|
||||
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;
|
||||
let chromeHeight=104,leftSidebar=false,startPage=false,overviewPanel=false,toolsOpen=false,pageTitle='',pageColor='#edf1f6';
|
||||
const {createTabRegistry}=require('./tab-registry.cjs');
|
||||
let tabs,tabsRevision=0;
|
||||
function activeTab(){return tabs?.active();}
|
||||
function tabContext(id,{foreground=true}={}){const tab=id?tabs.get(id):activeTab();if(!tab)throw Error('No native browser tab');return tabs.context(tab.id,{foreground});}
|
||||
function currentTabContext(token){return ready&&token?.policyVersion===policy?.version&&tabs.current(token)&&Date.parse(policy?.expiresAt)>Date.now();}
|
||||
function selectTab(id){const target=tabs.get(id);if(!target.guarded)throw Error('Native tab security is still initializing');const changed=target!==activeTab();const tab=tabs.activate(id);if(changed){win.webContents.focus();vault?.lock();vaultPanel=false;}view=tab.view;startPage=tab.startPage;pageTitle=tab.title;pageColor=tab.color;layout();return tab;}
|
||||
function blockedTab(){return tabs?.all().find(tab=>tab.security.visualBlocked||tab.security.contentBlocked);}
|
||||
function stickyBlock(){const tab=blockedTab();return tab&&(tab.security.visualBlocked||tab.security.contentBlocked);}
|
||||
const {startInspector}=require('./inspection.cjs');
|
||||
const {icapRequired,scanEnterprise,scanDownloadEnterprise}=require('./enterprise-inspection.cjs');
|
||||
const {createDownloadGate}=require('./download-gate.cjs');
|
||||
const {resolverOptions,createNetworkPosture}=require('./network-posture.cjs');
|
||||
const networkPosture=createNetworkPosture();let networkTimer,networkChecking=false;
|
||||
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 {bookmarkState,validateMutation:validateBookmarkMutation,libraryVisible}=require('./bookmarks.cjs');
|
||||
let bookmarkBusy=false,personalBookmarks={revision:0,items:[]},bookmarkSnapshotJSON='';
|
||||
function effectiveBookmarkPolicy(){return policy?{...policy,browser:{...policy.browser,bookmarks:{...policy.browser.bookmarks,personal:personalBookmarks}}}:null;}
|
||||
let appearancePreferences={};
|
||||
function managedSettings(){return settingsState({policy,ready,preferences:appearancePreferences});}
|
||||
function requireManagedSection(section){requireSection(managedSettings(),section);}
|
||||
function requireFeatureSection(section){requireFeature(managedSettings(),section);}
|
||||
function loadAppearance(){try{appearancePreferences=validateAppearance(JSON.parse(fs.readFileSync(path.join(app.getPath('userData'),'appearance.json'),'utf8')));}catch{appearancePreferences={};}}
|
||||
function persistAppearance(){fs.writeFileSync(path.join(app.getPath('userData'),'appearance.json'),JSON.stringify(appearancePreferences),{mode:0o600});}
|
||||
const {fillScript}=require('./password-fill.cjs');
|
||||
const {createVisualPhishing,describeScript}=require('./visual-phishing.cjs');
|
||||
const {fingerprintPNG}=require('../../packages/core/visual-image.cjs');
|
||||
const {hammingDistance}=require('../../packages/core/visual-hash.cjs');
|
||||
const {capturePNG}=require('./visual-capture.cjs');
|
||||
const {createClipboardGuard}=require('./clipboard-guard.cjs');
|
||||
const {clipboardScript}=require('./clipboard-dom.cjs');
|
||||
const {attachPageClipboardGuard,disposePageClipboardGuard}=require('./page-clipboard-guard.cjs');
|
||||
let clipboardGuard,clipboardCore,clipboardFatal=false;
|
||||
function installClipboardControls(contents){
|
||||
contents.on('before-input-event',(event,input)=>{
|
||||
if(input.type!=='keyDown')return;
|
||||
const key=input.key.toLowerCase(),modifier=(input.control||input.meta)&&!input.alt;
|
||||
const operation=modifier&&key==='c'||input.control&&key==='insert'?'copy':modifier&&key==='x'||input.shift&&key==='delete'?'cut':modifier&&key==='v'||input.shift&&key==='insert'?'paste':null;
|
||||
if(operation){event.preventDefault();diagnosticSpan('browser.clipboard',{operation},()=>clipboardGuard?.perform(operation,contents)).catch(()=>{});}
|
||||
});
|
||||
contents.on('context-menu',(_event,params)=>{
|
||||
const actions=params.isEditable?['copy','cut','paste']:params.selectionText?['copy']:[];
|
||||
if(!actions.length)return;
|
||||
Menu.buildFromTemplate(actions.map(operation=>({label:operation[0].toUpperCase()+operation.slice(1),click:()=>diagnosticSpan('browser.clipboard',{operation},()=>clipboardGuard?.perform(operation,contents)).catch(()=>{})}))).popup({window:win});
|
||||
});
|
||||
}
|
||||
let managementHealthy=false;
|
||||
function visualDeadline(operation){let timeout;return Promise.race([operation,new Promise((_,reject)=>{timeout=setTimeout(()=>reject(Error('Visual check timed out')),5000);})]).finally(()=>clearTimeout(timeout));}
|
||||
function checkVisual(...args){return diagnosticSpan('browser.visual',args[0]?.id?{sessionId:args[0].id}:{},()=>checkVisualImpl(...args));}
|
||||
async function checkVisualImpl(tab=activeTab()){
|
||||
if(!tab||tab.view.webContents.isDestroyed())return {decision:'unknown'};
|
||||
if(!policy?.security.visualPhishing.enabled)return {decision:'allow'};
|
||||
const security=tab.security;
|
||||
if(!security.visualPending){const operation=security.visualScanner.check({force:true});security.visualPending=operation;operation.finally(()=>{if(security.visualPending===operation)security.visualPending=null;}).catch(()=>{});}
|
||||
const generation=tab.navigationId,version=policy.version,url=tab.view.webContents.getURL(),result=await security.visualPending;
|
||||
if(ready&&generation===tab.navigationId&&version===policy.version&&!tab.view.webContents.isDestroyed()&&url===tab.view.webContents.getURL()&&result.decision==='allow')security.visualApproval={generation,version,url};
|
||||
if(!tab.view.webContents.isDestroyed()&&generation===tab.navigationId&&version===policy.version&&url===tab.view.webContents.getURL()){if(ready){if(result.decision==='unknown')message='Login protection could not approve this view. Credential actions are held.';emit();}return result;}
|
||||
return {decision:'unknown',reason:'stale-context'};
|
||||
}
|
||||
async function configureVisualPage(enabled,tab=activeTab()){
|
||||
if(!tab)return;tabs.navigation(tab.id);const contents=tab.view.webContents;
|
||||
if(contents.isDestroyed()||!/^https?:\/\//.test(contents.getURL()))return;
|
||||
const token=require('node:crypto').randomUUID();
|
||||
await new Promise((resolve,reject)=>{
|
||||
const handler=(event,value)=>{if(event.sender===contents&&event.senderFrame===contents.mainFrame&&value===token){clearTimeout(timeout);ipcMain.removeListener('managed:visual-configured',handler);resolve();}};
|
||||
const timeout=setTimeout(()=>{ipcMain.removeListener('managed:visual-configured',handler);enabled?reject(Error('Visual protection could not initialize')):resolve();},2000);
|
||||
ipcMain.on('managed:visual-configured',handler);contents.send('managed:visual-config',{enabled,token});
|
||||
});
|
||||
}
|
||||
let message='Enroll this browser to receive company policy.',syncTimer,localInspector,auditQueue=[],flushing=false,agentService,agentStarting=false,securityCore;
|
||||
const recentRequests=new Map();
|
||||
const agentFile=()=>path.join(app.getPath('userData'),'agent-connection.json');
|
||||
app.commandLine.appendSwitch('disable-http-cache');
|
||||
const uiUrl=pathToFileURL(path.join(__dirname,'index.html')).href;
|
||||
const configPath=()=>path.join(app.getPath('userData'),'enrollment.json');
|
||||
const cliRequest=app.commandLine.getSwitchValue('agent-action'),cliConnection=app.commandLine.getSwitchValue('agent-connection');
|
||||
if(cliRequest){try{require('./agent-client.cjs').configureClient(cliConnection);}catch{app.exit(1);}}
|
||||
const ownsBrowserInstance=!!cliRequest||app.requestSingleInstanceLock();
|
||||
if(!ownsBrowserInstance)app.quit();
|
||||
else if(!cliRequest)app.on('second-instance',()=>{if(win&&!win.isDestroyed()){if(win.isMinimized())win.restore();win.focus();}});
|
||||
function requireEncryption(){
|
||||
if(!safeStorage.isEncryptionAvailable() || (process.platform==='linux' && safeStorage.getSelectedStorageBackend()==='basic_text'))throw Error('An OS credential store is required. Configure Keychain, Windows DPAPI, or Linux Secret Service.');
|
||||
}
|
||||
function saveDevice(value){requireEncryption();fs.writeFileSync(configPath(),JSON.stringify({encryptedEnrollment:safeStorage.encryptString(JSON.stringify(value)).toString('base64')}),{mode:0o600});}
|
||||
function loadDevice(){
|
||||
if(!fs.existsSync(configPath()))return;
|
||||
requireEncryption();const d=JSON.parse(fs.readFileSync(configPath(),'utf8'));return JSON.parse(safeStorage.decryptString(Buffer.from(d.encryptedEnrollment,'base64')));
|
||||
}
|
||||
function managementUrl(value){
|
||||
const u=new URL(value);
|
||||
if(u.username||u.password||u.search||u.hash||u.pathname!=='/')throw Error('Enter a management server origin without path or credentials');
|
||||
if(u.protocol!=='https:' && !(u.protocol==='http:'&&['localhost','127.0.0.1','[::1]'].includes(u.hostname)))throw Error('Management requires HTTPS; local development may use loopback HTTP.');
|
||||
if(process.env.SAFETYNET_MANAGEMENT_URL&&new URL(process.env.SAFETYNET_MANAGEMENT_URL).origin!==u.origin)throw Error('Enrollment must use the administrator-deployed management server');
|
||||
return u.origin;
|
||||
}
|
||||
const managementRequests=new Map();
|
||||
function request(...args){return diagnosticSpan('browser.management',{operation:args[1]==='/api/device/ai'?'ai':args[1]==='/api/device/enroll'?'enroll':'sync'},()=>requestImpl(...args));}
|
||||
async function requestImpl(server,route,body,credential){
|
||||
if(route==='/api/device/policy'){
|
||||
if(!device||device.server!==server||device.token!==credential)throw Error('Protected management identity unavailable');
|
||||
return require('./management-channel.cjs').protectedPolicyRequest({device,request,save:saveDevice});
|
||||
}
|
||||
const headers={};if(body)headers['content-type']='application/json';if(credential)headers.authorization=`Bearer ${credential}`;
|
||||
if(route==='/api/device/enroll'||route==='/api/device/sso/poll')headers['x-safetynet-pq-bootstrap']='1';
|
||||
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),route.endsWith('/ai')?65000:10000);managementRequests.set(controller,route);
|
||||
try{
|
||||
const fetcher=managementSession?managementSession.fetch.bind(managementSession):fetch;
|
||||
const r=await fetcher(`${server}${route}`,{method:body?'POST':'GET',headers,body:body?JSON.stringify(body):undefined,credentials:'omit',redirect:'error',signal:controller.signal});
|
||||
const chunks=[];let size=0;for await(const part of r.body){size+=part.length;if(size>5*1024*1024)throw Error('Management response exceeds its limit');chunks.push(part);}
|
||||
const data=JSON.parse(Buffer.concat(chunks).toString('utf8'));if(!r.ok && r.status!==202){const e=Error('Management request refused');e.status=r.status;throw e;}return data;
|
||||
}finally{clearTimeout(timer);managementRequests.delete(controller);}
|
||||
}
|
||||
function state(){return {bookmarks:bookmarkState({policy:effectiveBookmarkPolicy(),ready,settings:managedSettings()}),settings:managedSettings(),enrolled:!!device,deviceId:device?.id||'',ready,clipboardPending:clipboardGuard?.isPending()||false,...tabs?.summary(),tabsRevision:++tabsRevision,canNavigate:ready||!!(stickyBlock()&&managementHealthy),loginCheckPending:ready&&!!policy?.security.visualPhishing.enabled&&(!activeTab()?.security.visualApproval||activeTab().security.visualApproval.generation!==activeTab().navigationId),message,brand:policy?.brand||{name:'SafetyNet',color:'#137c66'},policyVersion:policy?.version||0,server:device?.server||'',url:view?.webContents.getURL()||'',aiEnabled:ready&&!!policy?.ai.enabled,documentScanEnabled:ready&&policy?.inspection?.documentAnalysisEnabled!==false,passwordManagerEnabled:ready&&!!policy?.passwordManager?.enabled,vaultLocked:vault?.status().locked??true,vaultExists:vault?.status().exists??fs.existsSync(path.join(app.getPath('userData'),'password-vault.json')),agentsEnabled:ready&&!!policy?.agents.enabled,agentConnected:!!agentService};}
|
||||
function emit(){if(win&&!win.isDestroyed()){const settings=managedSettings();if(!settings.visible)toolsOpen=false;if(!sectionAllowed(settings,'appearance'))chromeHeight=settings.appearance.tabMode==='compact'?64:104;if(!featureAllowed(settings,'ai'))aiPanel=false;if(!featureAllowed(settings,'passwords')){vaultPanel=false;vault?.lock();}if(!libraryVisible(settings))leftSidebar=false;layout();win.webContents.send('managed:state',{...state(),pageTitle,pageColor,windowMaximized:win.isMaximized()});win.setTitle(`S Browser · ${state().brand.name}`);}}
|
||||
function layout(){const tab=activeTab();if(!win||!tab)return;view=tab.view;startPage=tab.startPage;const [width,height]=win.getContentSize(),left=leftSidebar?240:0;view.setBounds({x:left,y:chromeHeight,width:Math.max(0,width-left-((aiPanel||vaultPanel)?360:0)),height:Math.max(0,height-chromeHeight)});view.setVisible(ready&&tab.guarded&&!startPage&&!overviewPanel&&!toolsOpen);}
|
||||
async function pageTheme(tab=activeTab()){
|
||||
if(!tab||!ready)return;const contents=tab.view.webContents,url=contents.getURL(),context=tabContext(tab.id,{foreground:false});if(!/^https?:\/\//.test(url))return;
|
||||
try{const result=await contents.executeJavaScriptInIsolatedWorld(999,[{code:`(()=>{if(location.href!==${JSON.stringify(url)})return null;return {color:(document.querySelector('meta[name="theme-color"]')?.content||'').slice(0,32),background:getComputedStyle(document.body||document.documentElement).backgroundColor};})()`}],false);
|
||||
if(!currentTabContext(context)||!result)return;const hex=/^#[a-f0-9]{6}$/i.test(result.color)?result.color:null,rgb=/^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/.exec(result.background||'');tab.color=hex||(rgb&&rgb.slice(1).every(v=>+v<=255)?'#'+rgb.slice(1).map(v=>(+v).toString(16).padStart(2,'0')).join(''):'#edf1f6');tab.title=contents.getTitle().slice(0,200);if(tab===activeTab()){pageColor=tab.color;pageTitle=tab.title;}emit();
|
||||
}catch{}
|
||||
}
|
||||
function lockVault(){const unlocked=vault&&!vault.status().locked;vault?.lock();if(unlocked)audit('vault.locked');emit();}
|
||||
function quarantine(reason){ready=false;if(policy&&Date.parse(policy.expiresAt)<=Date.now())configureDiagnostics({enabled:false});for(const [controller,route] of managementRequests)if(route==='/api/device/ai')controller.abort();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,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(){
|
||||
requireFeatureSection('passwords');
|
||||
const settings=vaultSettings();if(!settings.enabled||Date.parse(policy.expiresAt)<=Date.now())throw Error('Password manager is disabled or browser access is paused');
|
||||
requireEncryption();
|
||||
if(!vault||vault.status().idleSeconds!==settings.idleLockSeconds){vault?.lock();vault=createVault({filePath:path.join(app.getPath('userData'),'password-vault.json'),idleSeconds:settings.idleLockSeconds,protect:bytes=>safeStorage.encryptString(bytes.toString('base64')),unprotect:bytes=>Buffer.from(safeStorage.decryptString(bytes),'base64')});}
|
||||
return vault;
|
||||
}
|
||||
function persistAudit(){requireEncryption();fs.writeFileSync(path.join(app.getPath('userData'),'audit-queue.bin'),safeStorage.encryptString(JSON.stringify(auditQueue)),{mode:0o600});}
|
||||
async function flushAudit(){
|
||||
if(!device||flushing||!auditQueue.length)return;flushing=true;
|
||||
try{for(let n=0;n<100&&auditQueue.length;n++){await request(device.server,'/api/device/events',auditQueue[0],device.token);auditQueue.shift();persistAudit();}}
|
||||
catch{}finally{flushing=false;}
|
||||
}
|
||||
async function audit(kind,ruleId,host,fileFacts){
|
||||
if(!device||!policy)return;
|
||||
// Operational correlation contains no rule text, hostname or forensic payload.
|
||||
try{diagnostics.event('browser.security',diagnosticContext({decision:kind.endsWith('.block')?'block':kind.endsWith('.denied')?'deny':'unknown'}));}catch{}
|
||||
if(auditQueue.length>=10000){quarantine('Security event queue is full. Contact your administrator.');return;}
|
||||
try{const file=fileFacts?require('../../packages/core/transfer-facts.cjs').transferFacts(fileFacts):undefined;auditQueue.push({eventId:require('node:crypto').randomUUID(),kind,...(ruleId?{ruleId}:{}),...(host?{host}:{}),...(file?{file}:{}),...diagnosticCorrelation(),policyVersion:policy.version});persistAudit();flushAudit();}
|
||||
catch{quarantine('Security events cannot be persisted. Contact your administrator.');}
|
||||
}
|
||||
function applyPolicy(envelope){return diagnosticSpan('browser.policy',{},()=>applyPolicyImpl(envelope));}
|
||||
async function applyPolicyImpl(envelope){
|
||||
const next=assertPolicyAssignment(core.verifyPolicy(envelope,device.publicKey,device.minimumVersion||0),device.id);
|
||||
const nextBookmarks=(await import('../../packages/core/bookmarks.mjs')).verifyBookmarkSnapshot(envelope.bookmarks,device.publicKey,{deviceId:device.id,policyVersion:next.version,minimumRevision:device.bookmarkRevision||0});
|
||||
const nextBookmarkJSON=JSON.stringify(nextBookmarks.items);
|
||||
if(nextBookmarks.revision===(device.bookmarkRevision||0)&&device.bookmarkDigest&&require('node:crypto').createHash('sha256').update(nextBookmarkJSON).digest('hex')!==device.bookmarkDigest||nextBookmarks.revision===personalBookmarks.revision&&bookmarkSnapshotJSON&&nextBookmarkJSON!==bookmarkSnapshotJSON)throw Error('Bookmarks changed without a new revision');
|
||||
const acceptBookmarks=()=>{personalBookmarks={revision:nextBookmarks.revision,items:nextBookmarks.items};bookmarkSnapshotJSON=nextBookmarkJSON;device.bookmarkRevision=nextBookmarks.revision;device.bookmarkDigest=require('node:crypto').createHash('sha256').update(nextBookmarkJSON).digest('hex');saveDevice(device);};
|
||||
if(process.env.SAFETYNET_POLICY_KEY_FILE&&fs.readFileSync(process.env.SAFETYNET_POLICY_KEY_FILE,'utf8').trim()!==device.publicKey.trim())throw Error('Policy signing key does not match deployment pin');
|
||||
if(policy&&next.version===policy.version){
|
||||
// Same versions must describe identical policy; reject equivocation.
|
||||
if(JSON.stringify(next)!==JSON.stringify(policy))throw Error('Policy changed without a new version');acceptBookmarks();if(Date.parse(policy.expiresAt)>Date.now())configureDiagnostics(policy.diagnostics);emit();return;
|
||||
}
|
||||
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);
|
||||
await managementSession.closeAllConnections();await managementSession.setProxy(settings);
|
||||
app.configureHostResolver(resolverOptions(next.enterprise?.dns));
|
||||
await browserSession.clearHostResolverCache();recentRequests.clear();
|
||||
await managementSession.clearHostResolverCache();
|
||||
policy=next;device.minimumVersion=next.version;acceptBookmarks();configureDiagnostics(next.diagnostics);
|
||||
for(const tab of tabs.all()){await configureVisualPage(next.security.visualPhishing.enabled,tab);if(!next.browser.allowDevTools){tab.security.developerToolsGuard?.close();tab.view.webContents.closeDevTools();}}
|
||||
if((!next.agents.enabled||next.browser.settings.hiddenSections.includes('agents')||next.browser.settings.lockedSections.includes('agents'))&&agentService){agentService.revoke();await agentService.close();agentService=null;try{fs.unlinkSync(agentFile());}catch{}}
|
||||
await audit('policy.applied');
|
||||
}
|
||||
async function sync(){
|
||||
if(!device||syncing)return;syncing=true;
|
||||
try{
|
||||
const envelope=await request(device.server,'/api/device/policy',null,device.token);await applyPolicy(envelope);
|
||||
await request(device.server,'/api/device/heartbeat',{policyVersion:policy.version},device.token);
|
||||
await flushAudit();if(auditQueue.length>=10000)throw Error('Security event queue is full');
|
||||
// Mandatory local inspection must be attested before unlocking the browser;
|
||||
// first user gestures must not consume their scan budget waiting for ACL setup.
|
||||
if(policy.security.yara.enabled||policy.dlp.semantic.enabled){
|
||||
if(!localInspector)throw Error('Required private content inspector is unavailable');
|
||||
await diagnosticSpan('browser.parser',{operation:'scan'},()=>localInspector.waitReady());
|
||||
}
|
||||
if(Date.parse(policy.expiresAt)<=Date.now())throw Error('Company policy expired');
|
||||
if(clipboardFatal)throw Error('Managed renderer security failed; restart the browser');
|
||||
const posture=await diagnosticSpan('browser.network',{operation:'scan'},()=>networkPosture.check(policy.enterprise?.vpn));
|
||||
if(!posture.allowed)throw Error('Required corporate network is unavailable. Connect your company VPN.');
|
||||
managementHealthy=true;
|
||||
const blocked=stickyBlock();
|
||||
if(blocked&&blocked.policyVersion===policy.version&&(blockedTab()?.security.visualBlocked?policy.security.visualPhishing.enabled:policy.security.content.blockedPhrases.length)){message=blocked.message;ready=false;emit();layout();return;}
|
||||
for(const tab of tabs.all()){tab.security.visualBlocked=null;tab.security.contentBlocked=null;}
|
||||
const recovering=!ready;ready=true;tabs.resume(policy.version);message=`Managed · policy v${policy.version}`;emit();layout();
|
||||
await ensureAgentAPI();
|
||||
if(recovering){for(const tab of tabs.all()){const current=tab.view.webContents.getURL();if(/^https?:\/\//.test(current)&&core.destinationAllowed(current,policy))tab.view.webContents.reload();else if(current!=='about:blank'||tabs.all().length===1)await navigate({url:policy.browser.homeUrl,tabId:tab.id});}emit();}
|
||||
}catch(e){
|
||||
// A deliberate page quarantine aborts in-flight management requests too.
|
||||
// Preserve that stronger, sticky verdict instead of replacing it with an
|
||||
// expected cancellation error or treating it as a management outage.
|
||||
const blocked=stickyBlock();
|
||||
if(e.name==='AbortError'&&!ready&&blocked&&message===blocked.message){emit();return;}
|
||||
managementHealthy=false;quarantine(e.status===401?'Device revoked or credentials rejected. Contact your administrator.':`Browsing paused: ${e.message}`);if(e.status===401){configureDiagnostics({enabled:false});agentService?.revoke();await agentService?.close();agentService=null;try{fs.unlinkSync(agentFile());}catch{}await browserSession.closeAllConnections();await browserSession.clearStorageData();}
|
||||
}
|
||||
finally{syncing=false;}
|
||||
}
|
||||
async function completeEnrollment(server,result){
|
||||
if(device)throw Error('This installation is already enrolled');
|
||||
const candidate={server,id:result.id,token:result.token,publicKey:result.publicKey,minimumVersion:0};
|
||||
candidate.channelSignatureKey=require('../../packages/core/policy-channel.cjs').verifyIdentity(result.managementChannel,candidate.publicKey);
|
||||
const channelPin=require('./management-channel.cjs').deploymentPin();if(channelPin&&candidate.channelSignatureKey.trim()!==channelPin)throw Error('Protected management signing pin mismatch');
|
||||
// Optional deployment-time key pin strengthens first enrollment beyond TLS trust.
|
||||
if(process.env.SAFETYNET_POLICY_KEY_FILE && fs.readFileSync(process.env.SAFETYNET_POLICY_KEY_FILE,'utf8').trim()!==candidate.publicKey.trim())throw Error('Management signing key does not match deployment pin');
|
||||
const initialPolicy=await require('./management-channel.cjs').protectedPolicyRequest({device:candidate,request,save:()=>{}});
|
||||
assertPolicyAssignment(core.verifyPolicy(initialPolicy,candidate.publicKey),candidate.id);
|
||||
saveDevice(candidate);device=candidate;await sync();return state();
|
||||
}
|
||||
function navigate(...args){return diagnosticSpan('browser.navigation',{},()=>navigateImpl(...args));}
|
||||
async function navigateImpl(input,captured,options={}){
|
||||
const address=typeof input==='string'?input:input?.url;
|
||||
if(typeof address!=='string'||address.length>4096)throw Error('Invalid address');
|
||||
const tab=tabs.get(captured?.sessionId||input?.tabId||activeTab().id),contents=tab.view.webContents;
|
||||
if(captured&&(!currentTabContext(captured.token)||captured.webContents!==contents))throw Error('Agent context changed');
|
||||
const url=address.includes('://')?address:`https://${address}`;
|
||||
if(!policy||!['https:','http:'].includes(new URL(url).protocol)||!core.destinationAllowed(url,policy))throw Error('Destination is blocked by company policy');
|
||||
const blocked=stickyBlock(),documentURL=value=>{const parsed=new URL(value);parsed.hash='';return parsed.href;};
|
||||
if(blocked&&managementHealthy&&Date.parse(policy.expiresAt)>Date.now()&&blockedTab()===tab&&documentURL(url)!==documentURL(blocked.checkedURL)){
|
||||
for(const record of tabs.all()){record.security.visualBlocked=null;record.security.contentBlocked=null;}ready=true;tabs.resume(policy.version);message='Checking destination…';
|
||||
}
|
||||
if(!ready)throw Error('Browser is not managed and ready');
|
||||
if(options.signal?.aborted)throw Error('Agent action cancelled');
|
||||
const initial=policy;tab.startPage=false;if(tab===activeTab())startPage=false;layout();
|
||||
const abort=()=>contents.stop();options.signal?.addEventListener('abort',abort,{once:true});
|
||||
try{await contents.loadURL(url);}finally{options.signal?.removeEventListener('abort',abort);}
|
||||
if(options.signal?.aborted||!ready||policy!==initial||contents.isDestroyed()||tabs.fromContents(contents.id)!==tab)throw Error('Navigation context changed');
|
||||
const refreshed=captured?agentContext(tab.id):null;
|
||||
if(captured?.token.foreground&&(!refreshed.foreground||captured.token.activation!==refreshed.token.activation))throw Error('Agent foreground changed during navigation');
|
||||
emit();return refreshed||state();
|
||||
}
|
||||
function agentContext(id){const tab=id?tabs.get(id):activeTab(),foreground=tab===activeTab();return {ready:ready&&featureAllowed(managedSettings(),'agents'),interactive:foreground&&!tab.startPage&&!overviewPanel&&!toolsOpen,policy,policyVersion:policy?.version,sessionId:tab.id,foreground,token:tabContext(tab.id,{foreground}),webContents:tab.view.webContents,destinationAllowed:core.destinationAllowed};}
|
||||
function managedAI(...args){return diagnosticSpan('browser.ai',{},()=>managedAIImpl(...args));}
|
||||
async function managedAIImpl(prompt){
|
||||
requireFeatureSection('ai');
|
||||
if(!ready||!policy?.ai.enabled)throw Error('AI access is disabled by company policy');
|
||||
if(typeof prompt!=='string'||!prompt.trim()||prompt.length>100000)throw Error('Enter a prompt of up to 100,000 characters');
|
||||
const initial=policy,verdict=core.inspectForAI(Buffer.from(prompt),initial);
|
||||
if(verdict.decision==='block'){await audit('dlp.block',verdict.ruleId);throw Error(`Blocked by company policy: ${verdict.ruleId}`);}
|
||||
if(initial.dlp.semantic.enabled||initial.security.yara.enabled){if(!localInspector)throw Error('Local inspector unavailable');const result=await diagnosticSpan('browser.inspect.local',{bytes:Buffer.byteLength(prompt)},()=>localInspector.inspect(Buffer.from(prompt),initial));if(result.decision!=='allow'&&!core.allowsConfidentialLocal(result,initial))throw Error('Local content policy blocked this prompt');}
|
||||
const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:Buffer.byteLength(prompt)},()=>scanEnterprise(Buffer.from(prompt),initial,'ai'));if(external.decision!=='allow'){await audit('dlp.block',external.ruleId);throw Error('Company external scanner blocked this prompt');}
|
||||
if(!ready||policy.version!==initial.version)throw Error('Policy changed during inspection');
|
||||
const result=await request(device.server,'/api/device/ai',{prompt},device.token);
|
||||
if(!ready||policy.version!==initial.version)throw Error('Policy changed during AI request');return result;
|
||||
}
|
||||
async function mutateManagedBookmarks(input){
|
||||
if(bookmarkBusy||syncing)throw Error('Management update in progress. Try again.');
|
||||
const initial=policy,identity=device;
|
||||
const payload=validateBookmarkMutation(input,{policy:effectiveBookmarkPolicy(),ready,settings:managedSettings(),destinationAllowed:core.destinationAllowed});
|
||||
bookmarkBusy=true;
|
||||
try{
|
||||
const result=await request(identity.server,'/api/device/bookmarks',payload,identity.token);
|
||||
if(policy!==initial||device!==identity)throw Error('Managed policy changed during bookmark update');
|
||||
const deadline=Date.now()+12000;while(syncing){if(Date.now()>=deadline)throw Error('Bookmark update awaits management synchronization');await new Promise(resolve=>setTimeout(resolve,50));}
|
||||
await sync();
|
||||
if(!ready||device!==identity||personalBookmarks.revision!==result.revision||policy.version!==result.version)throw Error('Bookmark update requires a fresh signed policy');
|
||||
return state();
|
||||
}finally{bookmarkBusy=false;}
|
||||
}
|
||||
async function ensureAgentAPI(){
|
||||
if(!ready||!policy.agents.enabled||!featureAllowed(managedSettings(),'agents')||agentService||agentStarting)return;agentStarting=true;
|
||||
try{
|
||||
const getContext=agentContext;
|
||||
const actions=createAgentAdapters({getContext,appControls:agentAppControls(),getSessions:()=>tabs.all().map(tab=>({id:tab.id,title:tab.title,foreground:tab===activeTab()})),contextCurrent:currentTabContext,navigate,ai:managedAI,
|
||||
inspect:async(text,context)=>{
|
||||
const aiContext=context.action==='ai';
|
||||
let verdict=aiContext?core.inspectForAI(Buffer.from(text),context.policy):core.inspect(Buffer.from(text),context.policy);if(verdict.decision==='block')return verdict;
|
||||
if(context.policy.dlp.semantic.enabled||context.policy.security.yara.enabled){
|
||||
if(!localInspector)return {decision:'block'};const result=await diagnosticSpan('browser.inspect.local',{bytes:Buffer.byteLength(text)},()=>localInspector.inspect(Buffer.from(text),context.policy));
|
||||
verdict=aiContext&&core.allowsConfidentialLocal(result,context.policy)?{...result,decision:'allow',classification:'local-only'}:result;
|
||||
}if(verdict.decision!=='allow')return verdict;const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:Buffer.byteLength(text)},()=>scanEnterprise(Buffer.from(text),context.policy,aiContext?'ai':'upload'));return external.decision==='allow'?verdict:external;
|
||||
},audit:event=>audit('agent.denied',`${event.action}:${event.outcome}`)});
|
||||
for(const action of ['click','type']){const original=actions[action];actions[action]=async(...args)=>{const tab=tabs.get(args[0]?.sessionId||activeTab().id),context=tabContext(tab.id);const checked=await checkVisual(tab);if(!currentTabContext(context)||checked.decision!=='allow')throw Error('Visual login protection has not approved this page');return original(...args);};}
|
||||
for(const action of Object.keys(actions)){const original=actions[action];actions[action]=(...args)=>diagnosticSpan('browser.agent',{operation:action},()=>original(...args));}
|
||||
agentService=await startAgentControl({actions,
|
||||
onError:error=>{if(!app.isPackaged&&process.env.SAFETYNET_SMOKE_ARTIFACTS)fs.appendFileSync(path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'smoke-entry.txt'),`Agent action diagnostic: ${error.reason}\n`);},
|
||||
authorize:({action,args,scope})=>{
|
||||
if(!ready||!policy?.agents.enabled||Date.parse(policy.expiresAt)<=Date.now()||!policy.agents.scopes.includes(action))return {allowed:false};
|
||||
if(action==='sessions'||Object.hasOwn(agentAppControls(),action))return {allowed:true,role:'agent',scopes:[scope]};
|
||||
let tab;try{tab=args.sessionId?tabs.get(args.sessionId):activeTab();}catch{return {allowed:false};}
|
||||
const url=action==='navigate'?args.url:tab.view.webContents.getURL();
|
||||
if(!core.destinationAllowed(url,policy))return {allowed:false};
|
||||
if(policy.agents.allowedHosts.length&&!policy.agents.allowedHosts.some(pattern=>core.hostMatches(new URL(url).hostname,pattern)))return {allowed:false};
|
||||
return {allowed:true,role:'agent',scopes:[scope]};
|
||||
},audit:event=>audit(event.outcome==='denied'?'agent.denied':'agent.action',`${event.action}:${event.outcome}`),
|
||||
persistCredential:async connection=>{const protectedConnection=await require('./dpapi.cjs').capabilityCrypto('protect',JSON.stringify(connection));fs.writeFileSync(agentFile(),JSON.stringify({format:'windows-dpapi-v1',protectedConnection}),{mode:0o600});}
|
||||
});emit();
|
||||
}finally{agentStarting=false;}
|
||||
}
|
||||
function dnsGate(...args){return diagnosticSpan('browser.dns',{},()=>dnsGateImpl(...args));}
|
||||
async function dnsGateImpl(url,currentPolicy){
|
||||
const settings=currentPolicy.security.dns;
|
||||
if(!settings.enabled||!settings.blockedIPs.length)return;
|
||||
const host=new URL(url).hostname;
|
||||
if(securityCore.dnsAddressBlocked(host.replace(/^\[|\]$/g,''),currentPolicy))throw Error('dns-ip-blocklist');
|
||||
let timer;const resolved=await Promise.race([browserSession.resolveHost(host,{cacheUsage:'allowed'}),new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('dns-resolution-timeout')),5000);})]).finally(()=>clearTimeout(timer));
|
||||
const addresses=resolved.endpoints?.map(v=>v.address)||[];
|
||||
if(!addresses.length||addresses.some(ip=>securityCore.dnsAddressBlocked(ip,currentPolicy)))throw Error('dns-ip-blocklist');
|
||||
}
|
||||
function hooks(){
|
||||
browserSession.setPermissionRequestHandler((_wc,_permission,callback)=>callback(false));
|
||||
browserSession.setPermissionCheckHandler(()=>false);
|
||||
const download=createDownloadGate({getContext:id=>{const tab=tabs.fromContents(id);return tab?{ready,policy,webContentsId:id,token:tabContext(tab.id,{foreground:false})}:null;},isCurrent:context=>!!context&¤tTabContext(context.token)&&policy===context.policy,
|
||||
fetchResponse:(url,options)=>diagnosticSpan('browser.request',{operation:'download'},()=>browserSession.fetch(url,options)),scan:(bytes,p)=>diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanDownloadEnterprise(bytes,p,localInspector)),
|
||||
choosePath:async name=>{const chosen=await dialog.showSaveDialog(win,{title:'Save scanned download (choose a new file)',defaultPath:path.join(app.getPath('downloads'),name)});return chosen.canceled?null:chosen.filePath;},
|
||||
writeFile:async(file,bytes,current)=>{
|
||||
if(!current()||!path.isAbsolute(file)||file.includes('\\\\?\\')||file.slice(2).includes(':'))throw Error('Save path rejected');
|
||||
const handle=await fs.promises.open(file,'wx',0o600);let release=false;
|
||||
try{if(!current())throw Error('Policy changed');await handle.writeFile(bytes);await handle.sync();if(!current())throw Error('Policy changed');release=true;}
|
||||
finally{await handle.close();if(!release)await fs.promises.unlink(file).catch(()=>{});}
|
||||
},audit,status:text=>{message=text;emit();}});
|
||||
browserSession.on('will-download',(event,item,contents)=>{
|
||||
const url=item.getURL(),name=item.getFilename(),tracked=recentRequests.get(`${contents?.id}:${url}`);event.preventDefault();
|
||||
if(!/^https?:\/\//.test(url)){audit('download.block','unsupported-download');return;}
|
||||
diagnosticSpan('browser.download',{},()=>download({url,name,method:tracked?.method,webContentsId:contents?.id})).catch(()=>audit('download.block','download-inspection-failed'));
|
||||
});
|
||||
browserSession.webRequest.onBeforeRequest({urls:['<all_urls>']},(details,callback)=>{
|
||||
// A delegated native frontend may load its bundled Chromium UI only. Its HTTP
|
||||
// requests receive no exemption, and neither pages nor trusted chrome qualify.
|
||||
if(developerAssetRequestAllowed(details,{ready,policy,tabs:tabs.all()})){callback({cancel:false});return;}
|
||||
let called=false;const finish=cancel=>{if(!called){called=true;callback({cancel});}};
|
||||
const initialPolicy=policy,source=details.webContentsId>0?tabs.fromContents(details.webContentsId):null,sourceContext=source?tabContext(source.id,{foreground:false}):null;
|
||||
const sourceCurrent=()=>!sourceContext||currentTabContext(sourceContext);const timer=setTimeout(()=>finish(true),40000);
|
||||
// Async gates complete before Chromium may transmit the request.
|
||||
diagnosticSpan('browser.request',{requestType:details.resourceType,...(source?{sessionId:source.id}:{})},async()=>{try{
|
||||
if(!ready||!policy||Date.parse(policy.expiresAt)<=Date.now()||(details.webContentsId>0&&(!source||!source.guarded))){finish(true);return;}
|
||||
if(!core.destinationAllowed(details.url,policy)){audit('navigation.block',null,new URL(details.url).hostname);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(!ready||policy!==initialPolicy||!sourceCurrent()){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;}
|
||||
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;}
|
||||
}
|
||||
// Electron file/data-pipe interception cannot bind scanning to the bytes later sent.
|
||||
// Block these paths rather than scanning a mutable file and allowing a race.
|
||||
if(uploads.some(part=>!part.bytes || part.file || part.blobUUID)){audit('dlp.block','uninspectable-upload',new URL(details.url).hostname);finish(true);return;}
|
||||
if(!['GET','HEAD','OPTIONS'].includes(details.method)&&!uploads.length){audit('dlp.block','missing-request-body',new URL(details.url).hostname);finish(true);return;}
|
||||
if(uploads.length){
|
||||
if(uploads.reduce((n,p)=>n+p.bytes.length,0)>policy.dlp.maxBytes){audit('dlp.block','size-limit',new URL(details.url).hostname);finish(true);return;}
|
||||
const requestBytes=Buffer.concat(uploads.map(p=>Buffer.from(p.bytes)));
|
||||
const transfer=require('../../packages/core/file-transfer.cjs').inspectFileTransfer(requestBytes,policy,'upload');
|
||||
const fileFacts={bytes:requestBytes.length,detectedType:transfer.detectedType,sha256:transfer.sha256};
|
||||
const releaseUpload=async()=>{await audit('upload.authorized',null,new URL(details.url).hostname,fileFacts);finish(!ready||policy!==initialPolicy||!sourceCurrent());};
|
||||
if(transfer.decision!=='allow'){await audit('upload.block',transfer.ruleId,new URL(details.url).hostname,fileFacts);requestBytes.fill(0);finish(true);return;}
|
||||
const verdict=core.inspect(requestBytes,policy);requestBytes.fill(0);
|
||||
if(verdict.decision==='block'&&verdict.ruleId!=='uninspectable'){audit('dlp.block',verdict.ruleId,new URL(details.url).hostname);finish(true);return;}
|
||||
if(verdict.ruleId==='uninspectable'||policy.dlp.semantic.enabled||policy.security.yara.enabled||icapRequired(policy,'upload')){
|
||||
const needsLocal=verdict.ruleId==='uninspectable'||policy.dlp.semantic.enabled||policy.security.yara.enabled;
|
||||
if(needsLocal&&!localInspector){audit('dlp.block','inspector-unavailable',new URL(details.url).hostname);finish(true);return;}
|
||||
const snapshot=Buffer.concat(uploads.map(p=>Buffer.from(p.bytes))),currentPolicy=policy;
|
||||
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'));
|
||||
const cancel=!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;
|
||||
}else{await releaseUpload();return;}
|
||||
}
|
||||
finish(false);
|
||||
}catch{finish(true);}finally{clearTimeout(timer);}}).catch(()=>finish(true));
|
||||
});
|
||||
// Session request and permission gates are installed exactly once.
|
||||
}
|
||||
function installSiteHooks(tab){
|
||||
const contents=tab.view.webContents,security=tab.security;
|
||||
const developerTools=createDeveloperToolsGuard({contents,getContext:()=>({ready,foreground:tab===activeTab()&&!tab.startPage&&!overviewPanel&&!toolsOpen,policy,url:contents.getURL(),token:tabContext(tab.id)}),isCurrent:context=>currentTabContext(context.token),audit:kind=>audit(kind),createFrontend:()=>{const frontend=new BrowserWindow({show:false,width:1000,height:750,title:'S Browser · Privileged developer tools',webPreferences:{session:browserSession,nodeIntegration:false,contextIsolation:true,sandbox:true,devTools:false}});security.developerFrontend=frontend.webContents;frontend.webContents.setWindowOpenHandler(()=>({action:'deny'}));return frontend;}});
|
||||
security.developerToolsGuard=developerTools;
|
||||
contents.setWindowOpenHandler(({url})=>{createNativeTab({url}).catch(e=>{message=e.message;emit();});return {action:'deny'};});
|
||||
contents.on('will-navigate',(event,url)=>{if(!ready||!core.destinationAllowed(url,policy))event.preventDefault();});
|
||||
contents.on('will-attach-webview',event=>event.preventDefault());
|
||||
contents.on('before-input-event',(event,input)=>{if(developerTools.handleInput(event,input))return;const approval=security.visualApproval;if(policy?.security.visualPhishing.enabled&&(!approval||approval.generation!==tab.navigationId||approval.version!==policy.version||approval.url!==contents.getURL())){event.preventDefault();checkVisual(tab).catch(()=>{});}});
|
||||
contents.on('did-start-navigation',(_event,_url,_inPlace,isMainFrame)=>{if(isMainFrame){tabs.navigation(tab.id);if(_url!=='about:blank')tab.startPage=false;}});
|
||||
contents.on('did-navigate',()=>emit());contents.on('did-navigate-in-page',()=>emit());
|
||||
contents.on('did-finish-load',()=>pageTheme(tab));contents.on('page-title-updated',()=>{tab.title=contents.getTitle().slice(0,200);if(tab===activeTab())pageTitle=tab.title;emit();});
|
||||
installClipboardControls(contents);
|
||||
security.visualScanner=createVisualPhishing({getContext:()=>({ready,policy,webContents:contents,url:contents.getURL(),navigationId:tab.navigationId}),
|
||||
describe:url=>visualDeadline(contents.executeJavaScriptInIsolatedWorld(999,[{code:describeScript(url)}],false)),
|
||||
capture:async(scope,description)=>{let rect;if(scope==='login-form'){const r=description.formRect;if(!r)throw Error('Login region unavailable');rect={x:Math.floor(r.x),y:Math.floor(r.y),width:Math.ceil(r.width),height:Math.ceil(r.height)};}const bytes=await visualDeadline(capturePNG(contents,rect));if(bytes.length<24)throw Error('Capture dimensions unavailable');return {bytes,width:bytes.readUInt32BE(16),height:bytes.readUInt32BE(20)};},
|
||||
fingerprint:capture=>{try{return fingerprintPNG(capture.bytes);}finally{capture.bytes.fill(0);}},compare:hammingDistance,
|
||||
audit:event=>audit(event.kind,event.ruleId,event.host),block:result=>{security.visualBlocked={...result,message:result.reason==='credential-brand-impersonation'?'Blocked: this login resembles a protected brand on an unapproved origin.':'Blocked: this login could not be inspected safely.'};security.visualApproval=null;quarantine(security.visualBlocked.message);}
|
||||
});
|
||||
}
|
||||
function createNativeTab(input={}){return diagnosticSpan('browser.tab',{operation:'create'},()=>createNativeTabImpl(input));}
|
||||
async function createNativeTabImpl(input={}){
|
||||
if(!input||typeof input!=='object'||Object.keys(input).some(key=>!['group','url'].includes(key)))throw Error('Invalid native tab request');
|
||||
if(input.group)requireManagedSection('library');
|
||||
const tab=tabs.create({group:input.group||''});installSiteHooks(tab);
|
||||
try{
|
||||
await tab.view.webContents.loadURL('about:blank');
|
||||
await diagnosticSpan('browser.clipboard',{operation:'create',sessionId:tab.id},()=>attachPageClipboardGuard(tab.view.webContents,()=>{clipboardFatal=true;quarantine('Managed clipboard protection failed; restart the browser');audit('clipboard.block','clipboard-primitive-unavailable');app.quit();}));
|
||||
tab.guarded=true;selectTab(tab.id);
|
||||
if(input.url)await navigate({url:input.url,tabId:tab.id});emit();return state();
|
||||
}catch(error){if(tabs.all().includes(tab)){tabs.remove(tab.id);if(tabs.active()?.guarded)selectTab(tabs.active().id);}throw error;}
|
||||
}
|
||||
|
||||
function updateManagedAppearance(input){requireManagedSection('appearance');appearancePreferences={...appearancePreferences,...validateAppearance(input)};persistAppearance();emit();return managedSettings();}
|
||||
async function closeNativeTab(id){tabs.remove(id);if(!tabs.all().length)await createNativeTab();else selectTab(tabs.active().id);emit();return state();}
|
||||
function groupNativeTab(input){requireManagedSection('library');if(!input||typeof input.group!=='string'||input.group.length>40)throw Error('Invalid native tab group');tabs.get(input.id).group=input.group;emit();return state();}
|
||||
function agentAppControls(){
|
||||
const checked=(operation)=>async(args,{signal,policyVersion}={})=>{
|
||||
if(signal?.aborted||!ready||policy?.version!==policyVersion||!featureAllowed(managedSettings(),'agents'))throw Error('Managed application changed');
|
||||
return operation(args);
|
||||
};
|
||||
return {
|
||||
'application:state':checked(()=>({ready,policyVersion:policy.version,activeTabId:activeTab()?.id,tabCount:tabs.all().length})),
|
||||
'settings:get':checked(()=>managedSettings()),
|
||||
'settings:update':checked(args=>updateManagedAppearance(args.appearance)),
|
||||
'bookmarks:list':checked(()=>({...bookmarkState({policy:effectiveBookmarkPolicy(),ready,settings:managedSettings()}),policyVersion:policy.version})),
|
||||
'bookmarks:mutate':checked(async args=>{await mutateManagedBookmarks(args);return {completed:true};}),
|
||||
'tabs:create':checked(async args=>{const result=await createNativeTab(args);return {completed:true,sessionId:result.activeTabId};}),
|
||||
'tabs:activate':checked(args=>{selectTab(args.sessionId);emit();return {completed:true,sessionId:args.sessionId};}),
|
||||
'tabs:close':checked(async args=>{await closeNativeTab(args.sessionId);return {completed:true};}),
|
||||
'tabs:group':checked(args=>{groupNativeTab({id:args.sessionId,group:args.group});return {completed:true,sessionId:args.sessionId};})
|
||||
};
|
||||
}
|
||||
function ipc(channel,fn){ipcMain.handle(channel,async(event,...args)=>{
|
||||
if(event.sender!==win.webContents || event.senderFrame!==win.webContents.mainFrame || event.senderFrame?.url!==uiUrl)throw Error('Untrusted IPC sender');
|
||||
try{return {ok:true,data:await diagnosticSpan('browser.ipc',{operation:channel.slice('managed:'.length)},()=>fn(...args))};}catch(e){return {ok:false,error:e.message};}
|
||||
});}
|
||||
app.whenReady().then(async()=>{
|
||||
if(!ownsBrowserInstance)return;
|
||||
if(cliRequest){try{await require('./agent-client.cjs').runClient(cliConnection,cliRequest);}catch{console.error('Local agent action failed. Check policy and encrypted pairing file.');app.exit(1);}return;}
|
||||
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.');}});
|
||||
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);
|
||||
browserSession=session.fromPartition('persist:safetynet-managed',{cache:false});
|
||||
// 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});
|
||||
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);
|
||||
win=new BrowserWindow({width:1320,height:900,minWidth:800,minHeight:600,frame:false,roundedCorners:true,hasShadow:true,backgroundMaterial:'acrylic',backgroundColor:'#eef1f5',autoHideMenuBar:true,webPreferences:{preload:path.join(__dirname,'preload.cjs'),nodeIntegration:false,contextIsolation:true,sandbox:true,devTools:false}});
|
||||
win.removeMenu();
|
||||
tabs=createTabRegistry({createView:()=>new WebContentsView({webPreferences:{session:browserSession,preload:path.join(__dirname,'site-preload.cjs'),nodeIntegration:false,nodeIntegrationInSubFrames:true,contextIsolation:true,sandbox:true,webSecurity:true,allowRunningInsecureContent:false}}),attach:v=>win.contentView.addChildView(v),detach:v=>win.contentView.removeChildView(v),dispose:tab=>disposePageClipboardGuard(tab.view.webContents)});
|
||||
const executeClipboard=(mode,args)=>args.webContents.executeJavaScriptInIsolatedWorld(999,[{code:clipboardScript(mode,args)}],false);
|
||||
clipboardGuard=createClipboardGuard({
|
||||
getContext:contents=>{const target=contents||view?.webContents,tab=target&&tabs.fromContents(target.id),site=!!tab,url=target?.getURL()||'',token=site?tabContext(tab.id):null;return {ready:ready&&target?.isFocused()&&(site?tab===activeTab()&&!tab.startPage&&!overviewPanel&&!toolsOpen&&core.destinationAllowed(url,policy):target===win.webContents&&url===uiUrl),policy,webContents:target,url,navigationId:site?tab.navigationId:0,tabId:tab?.id,activation:token?.activation};},
|
||||
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();},
|
||||
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;},
|
||||
audit:async(kind,ruleId)=>{await audit(kind,ruleId);if(kind==='clipboard.block'&&ready){message=`Clipboard blocked by company policy (${ruleId}).`;emit();}}
|
||||
});
|
||||
installClipboardControls(win.webContents);hooks();
|
||||
ipcMain.on('managed:visual-policy',event=>{event.returnValue={enabled:!!tabs.fromContents(event.sender.id)&&!!policy?.security.visualPhishing.enabled};});
|
||||
ipcMain.handle('managed:visual-check',async(event,input)=>{
|
||||
const tab=tabs.fromContents(event.sender.id);if(!tab||event.senderFrame!==event.sender.mainFrame||!input||typeof input.token!=='string'||input.token.length>80||!Number.isSafeInteger(input.revision)||input.revision<0||input.url!==event.sender.getURL())return {decision:'unknown'};
|
||||
const security=tab.security;if(input.token!==security.visualToken||input.revision!==security.visualRevision){tabs.navigation(tab.id);security.visualToken=input.token;security.visualRevision=input.revision;}
|
||||
try{return await checkVisual(tab);}catch{return {decision:'unknown'};}
|
||||
});
|
||||
await createNativeTab();
|
||||
win.webContents.setWindowOpenHandler(()=>({action:'deny'}));win.webContents.on('will-navigate',event=>event.preventDefault());
|
||||
ipc('managed:state',()=>({...state(),pageTitle,pageColor,windowMaximized:win.isMaximized()}));
|
||||
ipc('managed:window-control',action=>{if(action==='minimize')win.minimize();else if(action==='maximize'){win.isMaximized()?win.unmaximize():win.maximize();}else if(action==='close')win.close();else throw Error('Unknown window action');});
|
||||
ipc('managed:authorize-setting',section=>{requireManagedSection(section);return {authorized:true,policyVersion:policy.version};});
|
||||
ipc('managed:bookmarks-mutate',mutateManagedBookmarks);
|
||||
ipc('managed:settings-update',updateManagedAppearance);
|
||||
ipc('managed:chrome-layout',async input=>{
|
||||
if(!input||![64,104].includes(input.height)||['sidebarOpen','startPageOpen','overviewOpen'].some(key=>typeof input[key]!=='boolean'))throw Error('Unsupported browser layout');
|
||||
if(managedSettings().available&&!sectionAllowed(managedSettings(),'appearance')&&input.height!==(managedSettings().appearance.tabMode==='compact'?64:104))throw Error('Tab layout is controlled by your administrator');
|
||||
if(input.toolsOpen&&!managedSettings().visible)throw Error('Settings access is disabled by your administrator');
|
||||
if(input.sidebarOpen&&!libraryVisible(managedSettings()))throw Error('Library access is controlled by your administrator');
|
||||
if(input.toolsOpen!==undefined&&typeof input.toolsOpen!=='boolean')throw Error('Unsupported tools layout');chromeHeight=input.height;leftSidebar=input.sidebarOpen;startPage=activeTab()?.startPage??true;overviewPanel=input.overviewOpen;toolsOpen=!!input.toolsOpen;layout();
|
||||
if(policy?.security.visualPhishing.enabled)await configureVisualPage(true,activeTab());return {height:chromeHeight};
|
||||
});
|
||||
ipc('managed:share-url',async()=>{
|
||||
if(!ready)throw Error('Browser access paused');const initial=policy,context=tabContext(),contents=context.webContents,pageURL=contents.getURL(),url=new URL(pageURL);
|
||||
if(!['https:','http:'].includes(url.protocol)||url.username||url.password)throw Error('Cannot share this page');url.search='';url.hash='';const text=url.toString(),bytes=Buffer.from(text);
|
||||
let verdict=clipboardCore.inspectClipboard(bytes,initial,'copy');
|
||||
if(verdict.decision==='allow'&&(initial.dlp.semantic.enabled||initial.security.yara.enabled))verdict=localInspector?await diagnosticSpan('browser.inspect.local',{bytes:bytes.length},()=>localInspector.inspect(bytes,initial)):{decision:'block',ruleId:'clipboard-inspector-unavailable'};
|
||||
if(verdict.decision==='allow')verdict=await diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanEnterprise(bytes,initial,'clipboard'));
|
||||
if(verdict.decision!=='allow'){await audit('clipboard.block',verdict.ruleId);throw Error('Company policy blocks sharing this link');}
|
||||
await audit('clipboard.copy','authorized-link');
|
||||
if(!ready||policy!==initial||Date.parse(initial.expiresAt)<=Date.now()||!currentTabContext(context))throw Error('Browser state changed during link inspection');
|
||||
await clipboard.writeText(text);return {copied:true};
|
||||
});
|
||||
ipc('managed:enroll',async input=>{
|
||||
if(device)throw Error('Already enrolled');requireEncryption();
|
||||
if(!input||typeof input.server!=='string'||typeof input.code!=='string'||input.code.length>200)throw Error('Invalid enrollment');
|
||||
const server=managementUrl(input.server),result=await request(server,'/api/device/enroll',{code:input.code,name:os.hostname(),platform:process.platform});
|
||||
return completeEnrollment(server,result);
|
||||
});
|
||||
ipc('managed:sso',async input=>{
|
||||
if(device)throw Error('Already enrolled');requireEncryption();const server=managementUrl(input.server);
|
||||
const ticket=await request(server,'/api/device/sso/start',{});
|
||||
const loginUrl=new URL(ticket.url);if(loginUrl.origin!==server || loginUrl.pathname!=='/auth/login')throw Error('Invalid login URL');
|
||||
await shell.openExternal(loginUrl.toString());
|
||||
message='Complete enterprise sign-in in your system browser…';emit();
|
||||
for(let attempt=0;attempt<100;attempt++){
|
||||
await new Promise(resolve=>setTimeout(resolve,3000));
|
||||
const result=await request(server,'/api/device/sso/poll',{id:ticket.id,secret:ticket.secret,name:os.hostname(),platform:process.platform});
|
||||
if(!result.pending)return completeEnrollment(server,result);
|
||||
}
|
||||
throw Error('Enterprise sign-in expired');
|
||||
});
|
||||
ipc('managed:tab-create',createNativeTab);
|
||||
ipc('managed:tab-activate',id=>{selectTab(id);emit();return state();});
|
||||
ipc('managed:tab-close',closeNativeTab);
|
||||
ipc('managed:tab-group',groupNativeTab);
|
||||
ipc('managed:navigate',navigate);
|
||||
ipc('managed:back',()=>{if(ready&&view.webContents.navigationHistory.canGoBack())view.webContents.navigationHistory.goBack();});
|
||||
ipc('managed:forward',()=>{if(ready&&view.webContents.navigationHistory.canGoForward())view.webContents.navigationHistory.goForward();});
|
||||
ipc('managed:reload',()=>{if(ready)view.webContents.reload();});
|
||||
ipc('managed:ai-panel',open=>{if(open)requireFeatureSection('ai');aiPanel=!!open;if(aiPanel){vaultPanel=false;lockVault();}layout();});
|
||||
ipc('managed:vault-panel',open=>{if(open)requireVault();vaultPanel=!!open;if(vaultPanel)aiPanel=false;else lockVault();layout();});
|
||||
ipc('managed:vault-status',()=>{requireFeatureSection('passwords');return vaultStatus();});
|
||||
ipc('managed:vault-create',async master=>{requireVault().create(master);await audit('vault.created');if(!ready){lockVault();throw Error('Browser access paused');}emit();return vaultStatus();});
|
||||
ipc('managed:vault-unlock',async master=>{requireVault().unlock(master);await audit('vault.unlocked');if(!ready){lockVault();throw Error('Browser access paused');}emit();return vaultStatus();});
|
||||
ipc('managed:vault-lock',()=>{lockVault();return vaultStatus();});
|
||||
ipc('managed:vault-list',()=>requireVault().list());
|
||||
ipc('managed:vault-save',async entry=>{if(!vaultSettings().allowSaving)throw Error('Saving passwords is disabled by company policy');const result=requireVault().upsert(entry);await audit('vault.saved');emit();return result;});
|
||||
ipc('managed:vault-delete',async id=>{if(!vaultSettings().allowSaving)throw Error('Password changes are disabled by company policy');const result=requireVault().delete(id);await audit('vault.deleted');return result;});
|
||||
ipc('managed:vault-generate',()=>{const current=requireVault();if(!vaultSettings().allowSaving||current.status().locked)throw Error('Unlock the vault before generating a password');return current.generatePassword();});
|
||||
ipc('managed:vault-fill',async id=>{
|
||||
const context=tabContext(),tab=tabs.get(context.tabId),contents=context.webContents;
|
||||
if(startPage||overviewPanel||toolsOpen)throw Error('Show the saved login website before filling credentials');
|
||||
if((await checkVisual(tab)).decision!=='allow'||!currentTabContext(context))throw Error('Visual login protection has not approved this page');
|
||||
if(startPage||overviewPanel||toolsOpen)throw Error('The login website is no longer visible');
|
||||
const current=requireVault(),initial=policy,url=context.url;
|
||||
if(!initial.passwordManager.allowAutofill||!core.destinationAllowed(url,initial)||new URL(url).protocol!=='https:')throw Error('Password filling requires an approved HTTPS destination');
|
||||
let credential;
|
||||
try{
|
||||
const origin=new URL(url).origin;credential={...current.credential(id,origin),origin};
|
||||
await audit('vault.fill-requested');
|
||||
if(!currentTabContext(context)||startPage||overviewPanel||toolsOpen||policy!==initial||Date.parse(initial.expiresAt)<=Date.now()||current.status().locked)throw Error('Browser state changed');
|
||||
contents.focus();if(!currentTabContext(context))throw Error('Browser state changed');
|
||||
const result=await diagnosticSpan('browser.password.fill',{sessionId:context.tabId},()=>contents.executeJavaScriptInIsolatedWorld(999,[{code:fillScript(url,credential,Date.parse(initial.expiresAt))}],false));
|
||||
if(!currentTabContext(context)||startPage||overviewPanel||toolsOpen||policy!==initial||current.status().locked||result?.completed!==true)throw Error('Browser state changed');
|
||||
await audit('vault.filled');return {completed:true};
|
||||
}catch{throw Error('Cannot fill this login. Unlock the vault and use its exact HTTPS origin with a single visible same-origin POST login form.');}
|
||||
finally{if(credential){credential.username='';credential.password='';}}
|
||||
});
|
||||
ipc('managed:ai',managedAI);
|
||||
ipc('managed:agent-connection',()=>{
|
||||
requireFeatureSection('agents');
|
||||
if(!ready||!policy.agents.enabled||!agentService)throw Error('Agent access is disabled by company policy');return {address:agentService.address,credentialPath:agentFile()};
|
||||
});
|
||||
ipcMain.on('managed:page-content',async(event,input)=>{
|
||||
const tab=tabs.fromContents(event.sender.id);if(!tab||event.senderFrame!==event.sender.mainFrame||!ready||typeof input?.text!=='string'||input.text.length>250000)return;
|
||||
const initial=policy,context=tabContext(tab.id,{foreground:false});if(!initial.security.content.blockedPhrases.length)return;
|
||||
const result=core.inspect(Buffer.from(input.text),initial);if(result.decision==='block'&&result.ruleId?.startsWith('content-')&¤tTabContext(context)&&policy===initial){await audit('content.block',result.ruleId);if(!currentTabContext(context))return;tab.security.contentBlocked={checkedURL:context.url,policyVersion:initial.version,message:'This page contains content blocked by company policy.'};quarantine(tab.security.contentBlocked.message);}
|
||||
});
|
||||
ipc('managed:inspect-file',async()=>{
|
||||
requireFeatureSection('documentScan');
|
||||
if(!ready||!policy)throw Error('Device must be managed before inspecting files');
|
||||
if(policy.inspection?.documentAnalysisEnabled===false)throw Error('Document analysis is disabled for this browser by your administrator');
|
||||
if(!localInspector)throw Error('Local content inspector is not installed');
|
||||
const currentPolicy=policy;
|
||||
const selection=await dialog.showOpenDialog(win,{title:icapRequired(policy,'upload')?'Inspect content with company security services':'Inspect company content locally',properties:['openFile']});if(selection.canceled)return null;
|
||||
requireFeatureSection('documentScan');
|
||||
if(!ready||policy!==currentPolicy)throw Error('Policy changed during file selection; inspect again');
|
||||
const file=selection.filePaths[0],handle=await fs.promises.open(file,'r');let bytes;
|
||||
try{const info=await handle.stat();if(!info.isFile()||info.size>policy.dlp.maxBytes)throw Error('File exceeds company inspection limit');
|
||||
// Read a bounded immutable snapshot, never release the subsequently mutable original.
|
||||
const chunks=[];let size=0;for await(const chunk of handle.createReadStream()){size+=chunk.length;if(size>policy.dlp.maxBytes)throw Error('File exceeds company inspection limit');chunks.push(chunk);}bytes=Buffer.concat(chunks);
|
||||
}finally{await handle.close();}
|
||||
if(!ready||policy!==currentPolicy){bytes.fill(0);throw Error('Policy changed during file selection; inspect again');}
|
||||
let result;
|
||||
try{result=await diagnosticSpan('browser.inspect.local',{bytes:bytes.length},()=>localInspector.inspect(bytes,currentPolicy));if(result.decision==='allow'&&icapRequired(currentPolicy,'upload')){const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanEnterprise(bytes,currentPolicy,'upload'));if(external.decision!=='allow')result=external;}}finally{bytes.fill(0);}
|
||||
if(!ready||currentPolicy.version!==policy.version)throw Error('Policy changed during inspection; inspect again');
|
||||
if(result.decision==='block')await audit('dlp.block',result.ruleId);
|
||||
return {name:path.basename(file),...result};
|
||||
});
|
||||
win.on('close',()=>tabs.close());
|
||||
win.on('resize',layout);win.on('maximize',emit);win.on('unmaximize',emit);win.on('blur',lockVault);win.on('minimize',lockVault);win.on('closed',()=>{vault?.lock();clearInterval(vaultTimer);win=null;clearInterval(syncTimer);localInspector?.stop();agentService?.close();});
|
||||
loadAppearance();
|
||||
await win.loadFile(path.join(__dirname,'index.html'));layout();
|
||||
try{device=loadDevice();const queuePath=path.join(app.getPath('userData'),'audit-queue.bin');if(device&&fs.existsSync(queuePath))auditQueue=JSON.parse(safeStorage.decryptString(fs.readFileSync(queuePath)));if(device){await sync();await audit('browser.started');}}catch(e){quarantine(`Enrollment could not load: ${e.message}`);}
|
||||
const refreshMs=!app.isPackaged&&process.env.SAFETYNET_SMOKE_USERDATA===app.getPath('userData')?1000:15000;
|
||||
syncTimer=setInterval(()=>{if(policy&&Date.parse(policy.expiresAt)<=Date.now())quarantine('Company policy expired');sync();},refreshMs);
|
||||
networkTimer=setInterval(async()=>{
|
||||
if(!policy?.enterprise?.vpn.required||networkChecking)return;networkChecking=true;
|
||||
const checkedPolicy=policy;
|
||||
try{const posture=await diagnosticSpan('browser.network',{operation:'scan'},()=>networkPosture.check(checkedPolicy.enterprise.vpn));if(policy===checkedPolicy&&!posture.allowed){managementHealthy=false;quarantine('Required corporate network is unavailable. Connect your company VPN.');await Promise.all([browserSession.closeAllConnections(),managementSession.closeAllConnections()]);}}
|
||||
finally{networkChecking=false;}
|
||||
},5000);
|
||||
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',()=>{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)=>{
|
||||
event.preventDefault();
|
||||
// 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();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
const SECTIONS=Object.freeze(['appearance','library','ai','passwords','documentScan','agents']);
|
||||
const DEFAULT_APPEARANCE=Object.freeze({mode:'system',accent:'blue',tabMode:'separate'});
|
||||
// This object is computed in the privileged process, never from renderer storage.
|
||||
function settingsState({policy,ready,preferences={},now=Date.now()}){
|
||||
const available=!!ready&&!!policy&&Date.parse(policy.expiresAt)>now;
|
||||
const rules=policy?.browser?.settings||{};
|
||||
const visible=available&&rules.visible!==false;
|
||||
const hiddenSections=Array.isArray(rules.hiddenSections)?rules.hiddenSections:[];
|
||||
const lockedSections=Array.isArray(rules.lockedSections)?rules.lockedSections:[];
|
||||
const forced=!visible||hiddenSections.includes('appearance')||lockedSections.includes('appearance');
|
||||
return {available,visible,hiddenSections:[...hiddenSections],lockedSections:[...lockedSections],appearance:{...DEFAULT_APPEARANCE,...rules.appearance,...(forced?{}:preferences)}};
|
||||
}
|
||||
function sectionVisible(state,section){return SECTIONS.includes(section)&&state.available&&state.visible&&!state.hiddenSections.includes(section);}
|
||||
function sectionAllowed(state,section){return sectionVisible(state,section)&&!state.lockedSections.includes(section);}
|
||||
function featureAllowed(state,section){return SECTIONS.includes(section)&&state.available&&!state.hiddenSections.includes(section)&&!state.lockedSections.includes(section);}
|
||||
function requireFeature(state,section){if(!featureAllowed(state,section))throw Error('This feature is controlled by your administrator');}
|
||||
function requireSection(state,section){if(!sectionAllowed(state,section))throw Error('This setting is controlled by your administrator');}
|
||||
function validateAppearance(input){
|
||||
if(!input||typeof input!=='object'||Array.isArray(input)||Object.keys(input).some(key=>!Object.hasOwn(DEFAULT_APPEARANCE,key))||!Object.keys(input).length)throw Error('Invalid appearance settings');
|
||||
const values={mode:['system','light','dark'],accent:['blue','teal','violet'],tabMode:['separate','compact']};
|
||||
for(const [key,value] of Object.entries(input))if(!values[key].includes(value))throw Error('Invalid appearance settings');
|
||||
return {...input};
|
||||
}
|
||||
module.exports={settingsState,sectionVisible,sectionAllowed,featureAllowed,requireFeature,requireSection,validateAppearance};
|
||||
@@ -0,0 +1,19 @@
|
||||
'use strict';
|
||||
const channel=require('../../packages/core/policy-channel.cjs');
|
||||
const fs=require('node:fs');
|
||||
function deploymentPin(){if(process.env.SAFETYNET_REQUIRE_CHANNEL_PIN==='true'&&!process.env.SAFETYNET_CHANNEL_SIGNATURE_KEY_FILE)throw Error('Deployment requires a protected management signing pin');return process.env.SAFETYNET_CHANNEL_SIGNATURE_KEY_FILE?fs.readFileSync(process.env.SAFETYNET_CHANNEL_SIGNATURE_KEY_FILE,'utf8').trim():undefined;}
|
||||
async function protectedPolicyRequest({device,request,save}){
|
||||
const pin=deploymentPin();if(pin&&device.channelSignatureKey&&device.channelSignatureKey.trim()!==pin)throw Error('Protected management signing pin mismatch');
|
||||
if(!device.channelSignatureKey){
|
||||
// One-time legacy migration is authenticated by the enrollment signing pin and HTTPS.
|
||||
const identity=await request(device.server,'/api/device/channel/identity',null,device.token);
|
||||
channel.verifyIdentity(identity,device.publicKey);if(pin&&identity.signatureKey.trim()!==pin)throw Error('Protected management signing pin mismatch');
|
||||
device.channelSignatureKey=identity.signatureKey;save(device);
|
||||
}
|
||||
const challenge=await request(device.server,'/api/device/channel/challenge',{},device.token);
|
||||
channel.verifyChallenge(challenge,device.id,device.publicKey,device.channelSignatureKey);
|
||||
const context=channel.prepare(challenge);
|
||||
try{const response=await request(device.server,'/api/device/channel/policy',context.request,device.token);return channel.finish(response,challenge,context);}
|
||||
finally{context.responseKey.fill(0);}
|
||||
}
|
||||
module.exports={protectedPolicyRequest,deploymentPin};
|
||||
@@ -0,0 +1,34 @@
|
||||
'use strict';
|
||||
const {execFile}=require('node:child_process');
|
||||
const path=require('node:path');
|
||||
const net=require('node:net');
|
||||
// No administrator input is interpolated into PowerShell. Adapter matching happens
|
||||
// after bounded JSON parsing; the deployment cannot turn names into shell code.
|
||||
const postureScript="$ErrorActionPreference='Stop'; $dnsRecords=@(Get-DnsClientServerAddress -ErrorAction Stop); $adapters=@(Get-NetAdapter -IncludeHidden | Where-Object {$_.Status -eq 'Up'} | ForEach-Object { $adapter=$_; $dns=@($dnsRecords | Where-Object {$_.InterfaceIndex -eq $adapter.ifIndex} | ForEach-Object {$_.ServerAddresses}); [pscustomobject]@{name=$adapter.Name; dns=$dns} }); ConvertTo-Json -InputObject $adapters -Depth 4 -Compress";
|
||||
function resolverOptions(dns={mode:'system',templates:[]}){
|
||||
if(dns.mode==='system')return {enableBuiltInResolver:false,secureDnsMode:'off',secureDnsServers:[]};
|
||||
if(dns.mode!=='secure'||!Array.isArray(dns.templates)||!dns.templates.length||dns.templates.length>8)throw Error('Managed DNS configuration is invalid');
|
||||
for(const template of dns.templates){const u=new URL(template.replace('{?dns}',''));if(u.protocol!=='https:'||u.username||u.password||u.hash)throw Error('Managed DNS requires HTTPS templates');}
|
||||
return {enableBuiltInResolver:true,secureDnsMode:'secure',secureDnsServers:[...dns.templates]};
|
||||
}
|
||||
function evaluatePosture(vpn,adapters){
|
||||
if(!vpn?.required)return {allowed:true,reason:'not-required'};
|
||||
if(!Array.isArray(vpn.interfaceNames)||!vpn.interfaceNames.length||!Array.isArray(vpn.requiredDnsServers)||vpn.requiredDnsServers.some(ip=>!net.isIP(ip)))return {allowed:false,reason:'configuration-invalid'};
|
||||
if(!Array.isArray(adapters)||adapters.length>256)return {allowed:false,reason:'probe-invalid'};
|
||||
const matches=adapters.filter(a=>typeof a?.name==='string'&&Array.isArray(a.dns)&&vpn.interfaceNames.some(name=>name.toLowerCase()===a.name.toLowerCase()));
|
||||
return matches.some(a=>vpn.requiredDnsServers.every(ip=>a.dns.some(found=>typeof found==='string'&&found.toLowerCase()===ip.toLowerCase())))?{allowed:true,reason:'approved-interface-present'}:{allowed:false,reason:'required-interface-or-dns-missing'};
|
||||
}
|
||||
function probeWindows({platform=process.platform,run=execFile}={}){
|
||||
if(platform!=='win32')return Promise.reject(Error('Network posture is available only on Windows'));
|
||||
const executable=path.join(process.env.SystemRoot||'C:\\Windows','System32','WindowsPowerShell','v1.0','powershell.exe');
|
||||
return new Promise((resolve,reject)=>run(executable,['-NoLogo','-NoProfile','-NonInteractive','-Command',postureScript],{windowsHide:true,timeout:10000,maxBuffer:65536,encoding:'utf8'},(error,stdout)=>{
|
||||
if(error)return reject(Error('Network posture probe unavailable'));
|
||||
try{const value=JSON.parse(stdout.replace(/^\uFEFF/,''));if(!Array.isArray(value))throw Error();resolve(value);}catch{reject(Error('Network posture response invalid'));}
|
||||
}));
|
||||
}
|
||||
function createNetworkPosture({probe=probeWindows}={}){
|
||||
let pending;
|
||||
return {async check(vpn){if(!vpn?.required)return {allowed:true,reason:'not-required'};if(!pending)pending=Promise.resolve().then(probe).finally(()=>{pending=null;});try{return evaluatePosture(vpn,await pending);}catch{return {allowed:false,reason:'probe-unavailable'};}}};
|
||||
}
|
||||
module.exports={resolverOptions,evaluatePosture,probeWindows,createNetworkPosture};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
'use strict';
|
||||
// This capability stays in the main process. Neither pages nor agents receive CDP access.
|
||||
const owners=new WeakMap();
|
||||
function protectClipboardPrimitives(){
|
||||
const prototype=Document.prototype,existing=Object.getOwnPropertyDescriptor(prototype,'execCommand');
|
||||
if(existing?.value?._managedClipboardGuard===true&&!existing.configurable)return;
|
||||
const original=prototype.execCommand,apply=Reflect.apply,test=RegExp.prototype.test.bind(/^(copy|cut|paste)/i);
|
||||
function managedClipboardCommand(command,...args){
|
||||
if(typeof command!=='string'||test(command))return false;
|
||||
return apply(original,this,[command,...args]);
|
||||
}
|
||||
Object.defineProperty(managedClipboardCommand,'_managedClipboardGuard',{value:true});
|
||||
Object.defineProperty(prototype,'execCommand',{value:managedClipboardCommand,writable:false,configurable:false});
|
||||
const SecurityException=DOMException;
|
||||
const denyReplacement=()=>{throw new SecurityException('Managed document replacement is disabled','SecurityError');};
|
||||
for(const name of ['open','write','writeln'])Object.defineProperty(prototype,name,{value:denyReplacement,writable:false,configurable:false});
|
||||
const deny=event=>{event.preventDefault();event.stopImmediatePropagation();};
|
||||
for(const name of ['copy','cut','paste','dragstart','dragover','drop'])window.addEventListener(name,deny,true);
|
||||
const transferred=RegExp.prototype.test.bind(/^(insertFromPaste|insertFromDrop|deleteByCut)/);
|
||||
window.addEventListener('beforeinput',event=>{if(transferred(event.inputType||''))deny(event);},true);
|
||||
}
|
||||
const source=`(${protectClipboardPrimitives.toString()})();`;
|
||||
function deadline(operation){let timer;return Promise.race([operation,new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('Page security initialization timed out')),5000);})]).finally(()=>clearTimeout(timer));}
|
||||
async function attachPageClipboardGuard(contents,onFailure){
|
||||
const api=contents.debugger;
|
||||
if(!api||api.isAttached()||owners.has(contents))throw Error('Page security initialization unavailable');
|
||||
const owner={api,healthy:false};
|
||||
const detached=()=>{owners.delete(contents);owner.healthy=false;if(!contents.isDestroyed())onFailure();};
|
||||
owner.detached=detached;
|
||||
api.on('detach',detached);
|
||||
try{
|
||||
api.attach('1.3');
|
||||
await deadline(api.sendCommand('Page.enable',{}));
|
||||
const registration=await deadline(api.sendCommand('Page.addScriptToEvaluateOnNewDocument',{source}));
|
||||
if(typeof registration?.identifier!=='string'||!registration.identifier||!api.isAttached())throw Error('Page security initialization failed');
|
||||
owner.healthy=true;owners.set(contents,owner);
|
||||
}catch{
|
||||
api.removeListener('detach',detached);
|
||||
if(api.isAttached())api.detach();
|
||||
throw Error('Page security initialization failed');
|
||||
}
|
||||
}
|
||||
function guardedScreenshot(contents,parameters){
|
||||
const owner=owners.get(contents);
|
||||
if(!owner)return null;
|
||||
if(!owner.healthy||owner.api!==contents.debugger||!owner.api.isAttached())throw Error('Page security connection unavailable');
|
||||
// Only the fixed screenshot command is shared. There is no general command dispatcher.
|
||||
return owner.api.sendCommand('Page.captureScreenshot',parameters);
|
||||
}
|
||||
function disposePageClipboardGuard(contents){
|
||||
const owner=owners.get(contents);if(!owner)return;
|
||||
owners.delete(contents);owner.healthy=false;owner.api.removeListener('detach',owner.detached);
|
||||
if(owner.api.isAttached())owner.api.detach();
|
||||
}
|
||||
module.exports={attachPageClipboardGuard,guardedScreenshot,disposePageClipboardGuard};
|
||||
@@ -0,0 +1,44 @@
|
||||
'use strict';
|
||||
// Runs only in the isolated main-frame world. No arbitrary selectors or scripts from callers.
|
||||
function fillDocument(expectedUrl,origin,username,password,expiresAt){
|
||||
if(Date.now()>=expiresAt||window.top!==window||!window.isSecureContext||location.href!==expectedUrl||location.origin!==origin)throw Error('Login destination changed');
|
||||
const visible=element=>{
|
||||
const style=getComputedStyle(element);
|
||||
return !element.disabled&&!element.readOnly&&!element.closest('[hidden],[inert],[aria-hidden="true"]')&&style.display!=='none'&&style.visibility!=='hidden'&&Number(style.opacity)!==0&&element.getClientRects().length>0;
|
||||
};
|
||||
const passwords=[...document.querySelectorAll('input[type="password"]')].filter(element=>visible(element)&&element.autocomplete!=='new-password');
|
||||
if(passwords.length!==1)throw Error('A single visible login password field is required');
|
||||
const target=passwords[0],form=target.form;
|
||||
if(!form||form.method.toLowerCase()!=='post'||!['','_self'].includes(form.target.toLowerCase()))throw Error('A same-window POST login form is required');
|
||||
const action=new URL(form.action||location.href,location.href);
|
||||
if(action.protocol!=='https:'||action.origin!==origin||action.username||action.password)throw Error('Login form sends to a different destination');
|
||||
for(const control of [...(form.elements||form.querySelectorAll('button,input'))]){
|
||||
if(control.disabled||typeof control.getAttribute!=='function')continue;
|
||||
const actionOverride=control.getAttribute('formaction'),methodOverride=control.getAttribute('formmethod'),targetOverride=control.getAttribute('formtarget');
|
||||
if(actionOverride!==null){const destination=new URL(actionOverride||location.href,location.href);if(destination.protocol!=='https:'||destination.origin!==origin||destination.username||destination.password)throw Error('Login submit destination differs');}
|
||||
if(methodOverride!==null&&methodOverride.toLowerCase()!=='post')throw Error('Login submit method differs');
|
||||
if(targetOverride!==null&&!['','_self'].includes(targetOverride.toLowerCase()))throw Error('Login submit window differs');
|
||||
}
|
||||
const inputs=[...form.querySelectorAll('input')].filter(element=>visible(element)&&['text','email'].includes(element.type));
|
||||
const named=inputs.filter(element=>element.autocomplete==='username'||/^(?:username|user|email|login)$/i.test(element.name||element.id));
|
||||
const candidates=named.length?named:inputs;
|
||||
if(username&&candidates.length!==1)throw Error('A single login username field is required');
|
||||
const user=username?candidates[0]:null;
|
||||
if(user&&user.form!==form)throw Error('Login fields belong to different forms');
|
||||
const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set;
|
||||
// Recheck inside the target renderer, after queued native dispatch. A hidden
|
||||
// or blurred document must not receive credentials after a tab switch.
|
||||
if(document.visibilityState!=='visible'||!document.hasFocus())throw Error('Login tab is no longer foreground');
|
||||
// Set both values before dispatching events: page handlers cannot move focus between writes.
|
||||
if(user)setter.call(user,username);setter.call(target,password);
|
||||
if(user)user.dispatchEvent(new Event('input',{bubbles:true}));
|
||||
target.dispatchEvent(new Event('input',{bubbles:true}));
|
||||
return {completed:true};
|
||||
}
|
||||
function fillScript(expectedUrl,credential,expiresAt=Date.now()+10000){
|
||||
const url=new URL(expectedUrl);
|
||||
if(url.protocol!=='https:'||url.username||url.password||url.origin!==credential.origin)throw Error('Saved login requires its exact HTTPS origin');
|
||||
if(!Number.isFinite(expiresAt)||expiresAt<=Date.now())throw Error('Password policy expired');
|
||||
return `(${fillDocument.toString()})(${JSON.stringify(url.href)},${JSON.stringify(credential.origin)},${JSON.stringify(credential.username)},${JSON.stringify(credential.password)},${JSON.stringify(expiresAt)})`;
|
||||
}
|
||||
module.exports={fillDocument,fillScript};
|
||||
@@ -0,0 +1,10 @@
|
||||
'use strict';
|
||||
// Check the signed effective policy before any enrollment state or browser
|
||||
// settings are committed. A valid fleet signature alone is not device binding.
|
||||
function assertPolicyAssignment(policy, deviceId) {
|
||||
if (policy.assignment && policy.assignment.deviceId !== deviceId) {
|
||||
throw Error('Policy device assignment rejected');
|
||||
}
|
||||
return policy;
|
||||
}
|
||||
module.exports = {assertPolicyAssignment};
|
||||
@@ -0,0 +1,33 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
contextBridge.exposeInMainWorld('safetynet', {
|
||||
mutateBookmarks:input=>ipcRenderer.invoke('managed:bookmarks-mutate',input),
|
||||
state:()=>ipcRenderer.invoke('managed:state'),
|
||||
authorizeSetting:section=>ipcRenderer.invoke('managed:authorize-setting',section),
|
||||
updateSettings:input=>ipcRenderer.invoke('managed:settings-update',input),
|
||||
windowControls:action=>ipcRenderer.invoke('managed:window-control',action),
|
||||
setChromeLayout:input=>ipcRenderer.invoke('managed:chrome-layout',input),
|
||||
shareURL:()=>ipcRenderer.invoke('managed:share-url'),
|
||||
enroll:input=>ipcRenderer.invoke('managed:enroll',input),
|
||||
sso:input=>ipcRenderer.invoke('managed:sso',input),
|
||||
navigate:url=>ipcRenderer.invoke('managed:navigate',url),
|
||||
createTab:input=>ipcRenderer.invoke('managed:tab-create',input),
|
||||
activateTab:id=>ipcRenderer.invoke('managed:tab-activate',id),
|
||||
closeTab:id=>ipcRenderer.invoke('managed:tab-close',id),
|
||||
groupTab:input=>ipcRenderer.invoke('managed:tab-group',input),
|
||||
back:()=>ipcRenderer.invoke('managed:back'),forward:()=>ipcRenderer.invoke('managed:forward'),reload:()=>ipcRenderer.invoke('managed:reload'),
|
||||
ai:prompt=>ipcRenderer.invoke('managed:ai',prompt),
|
||||
inspectFile:()=>ipcRenderer.invoke('managed:inspect-file'),
|
||||
agentConnection:()=>ipcRenderer.invoke('managed:agent-connection'),
|
||||
toggleAI:open=>ipcRenderer.invoke('managed:ai-panel',open),
|
||||
toggleVault:open=>ipcRenderer.invoke('managed:vault-panel',open),
|
||||
vaultStatus:()=>ipcRenderer.invoke('managed:vault-status'),
|
||||
vaultCreate:master=>ipcRenderer.invoke('managed:vault-create',master),
|
||||
vaultUnlock:master=>ipcRenderer.invoke('managed:vault-unlock',master),
|
||||
vaultLock:()=>ipcRenderer.invoke('managed:vault-lock'),
|
||||
vaultList:()=>ipcRenderer.invoke('managed:vault-list'),
|
||||
vaultSave:entry=>ipcRenderer.invoke('managed:vault-save',entry),
|
||||
vaultDelete:id=>ipcRenderer.invoke('managed:vault-delete',id),
|
||||
vaultFill:id=>ipcRenderer.invoke('managed:vault-fill',id),
|
||||
vaultGenerate:()=>ipcRenderer.invoke('managed:vault-generate'),
|
||||
onState:callback=>{const listener=(_event,data)=>callback(data);ipcRenderer.on('managed:state',listener);return()=>ipcRenderer.removeListener('managed:state',listener);}
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
const $=id=>document.getElementById(id);let panel=false,vaultPanel=false,lastState,vaultState,vaultBusy=false,vaultRevision=0;
|
||||
let sidebarOpen=false,startPageOpen=false,overviewOpen=false,tabs=[],activeTab=null,groupFilter='',navigationRevision=0,pendingNavigation=null,lastLayoutKey='',lastTabsKey='',lastLibraryKey='',nativeTabsRevision=-1;
|
||||
const preference=(key,fallback)=>{try{return JSON.parse(localStorage.getItem(`sbrowser.${key}`))??fallback;}catch{return fallback;}};
|
||||
const remember=(key,value)=>{try{localStorage.setItem(`sbrowser.${key}`,JSON.stringify(value));}catch{}};
|
||||
const safeURL=value=>{try{const u=new URL(value);if(!['https:','http:'].includes(u.protocol)||u.username||u.password)return null;u.search='';u.hash='';return u.href;}catch{return null;}};
|
||||
const metadata=list=>Array.isArray(list)?list.slice(0,100).filter(v=>v&&safeURL(v.url)&&typeof v.title==='string').map(v=>({url:safeURL(v.url),title:v.title.slice(0,160)})):[];
|
||||
let bookmarks=[],readingList=metadata(preference('reading-list',[])),groups=preference('groups',[]);groups=Array.isArray(groups)?groups.filter(v=>typeof v==='string'&&v.length<=40).slice(0,20):[];
|
||||
function currentTab(){return tabs.find(t=>t.id===activeTab)||{id:null,url:'',title:'New Tab',group:'',startPage:true};}
|
||||
async function updateChrome(){
|
||||
document.body.classList.toggle('sidebar-open',sidebarOpen);if($('library'))$('library').hidden=!sidebarOpen;if($('start-page'))$('start-page').hidden=!startPageOpen||overviewOpen||!lastState?.ready;if($('overview'))$('overview').hidden=!overviewOpen||!lastState?.ready;
|
||||
for(const id of ['bookmark','reading-add','share-url'])if($(id))$(id).disabled=!lastState?.ready||startPageOpen||overviewOpen||!currentTab().url||(id==='bookmark'&&(!sectionEditable('library')||!lastState?.bookmarks?.allowUserAdd))||(id==='reading-add'&&!sectionEditable('library'));
|
||||
const height=Math.ceil($('chrome')?.getBoundingClientRect().bottom||104),layout={height,sidebarOpen,startPageOpen:startPageOpen&&!!lastState?.ready,overviewOpen:overviewOpen&&!!lastState?.ready,toolsOpen:!!($('tools-menu')&&!$('tools-menu').hidden)||!!$('bookmark-editor')?.open},key=JSON.stringify(layout);document.documentElement.style.setProperty('--chrome-height',`${height}px`);if(window.safetynet.setChromeLayout&&key!==lastLayoutKey){lastLayoutKey=key;try{await call('setChromeLayout',layout);}catch(e){lastLayoutKey='';throw e;}}
|
||||
}
|
||||
async function navigateTab(value){
|
||||
const token=++navigationRevision,tab=currentTab();if(!tab.id)throw Error('No managed tab is available');pendingNavigation={token,tabId:tab.id};overviewOpen=false;
|
||||
try{const result=await call('navigate',{url:value,tabId:tab.id});if(token===navigationRevision)show(result);}
|
||||
finally{if(pendingNavigation?.token===token)pendingNavigation=null;}
|
||||
}
|
||||
// Native WebContents own identity, history and lifetime. Switching never reloads a URL.
|
||||
async function selectTab(id){if(!tabs.some(t=>t.id===id))return;navigationRevision++;overviewOpen=false;show(await call('activateTab',id));$('address').value=currentTab().startPage?'':currentTab().url;await updateChrome();}
|
||||
async function newTab(){navigationRevision++;overviewOpen=false;show(await call('createTab',{group:groupFilter}));$('address').value=currentTab().startPage?'':currentTab().url;await updateChrome();$('address').focus();}
|
||||
async function closeTab(id){if(!tabs.some(t=>t.id===id))return;navigationRevision++;const previous=activeTab;show(await call('closeTab',id));if(previous!==activeTab)$('address').value=currentTab().startPage?'':currentTab().url;await updateChrome();}
|
||||
function button(text,fn,cls=''){const b=document.createElement('button');b.type='button';b.className=cls;b.textContent=text;b.onclick=task(fn);return b;}
|
||||
function renderTabs(){
|
||||
const strip=$('tab-strip');if(!strip)return;const key=JSON.stringify({tabs,activeTab,groupFilter,ready:!!lastState?.ready});if(key===lastTabsKey)return;lastTabsKey=key;strip.replaceChildren();for(const tab of tabs.filter(t=>!groupFilter||t.group===groupFilter)){
|
||||
const el=document.createElement('div');el.className=`browser-tab${tab.id===activeTab?' active':''}`;el.setAttribute('role','tab');el.setAttribute('aria-selected',String(tab.id===activeTab));el.title=tab.url||'New Tab';
|
||||
const select=button(tab.title||'New Tab',()=>selectTab(tab.id),'tab-label'),close=button('×',()=>closeTab(tab.id),'tab-close');close.setAttribute('aria-label',`Close ${tab.title}`);select.disabled=!lastState?.ready;close.disabled=!lastState?.ready;el.dataset.tabId=tab.id;el.append(select,close);strip.append(el);}
|
||||
if($('overview-grid')){$('overview-grid').replaceChildren();for(const tab of tabs){const card=button('',()=>selectTab(tab.id),'overview-card');const title=document.createElement('strong'),url=document.createElement('span');title.textContent=tab.title;url.textContent=safeURL(tab.url)||'Favorites and a fresh start';card.append(title,url);$('overview-grid').append(card);}}
|
||||
}
|
||||
function renderLinks(id,items){if(!$(id))return;$(id).replaceChildren();for(const item of items){const b=button('',()=>navigateTab(item.url),'favorite-card');const initial=document.createElement('span'),label=document.createElement('strong');initial.className='favorite-icon';initial.textContent=new URL(item.url).hostname.charAt(0).toUpperCase();label.textContent=item.title||new URL(item.url).hostname;if(item.managed){const badge=document.createElement('small');badge.textContent='Company';b.append(badge);}b.title=item.url;b.append(initial,label);$(id).append(b);}}
|
||||
let bookmarkEditingId=null;
|
||||
async function openBookmarkEditor(item){
|
||||
requireSection('library',true);const rules=lastState?.bookmarks;
|
||||
if(item?.managed||!(item?rules?.allowUserEdit:rules?.allowUserAdd))throw Error('This bookmark is managed by your administrator');
|
||||
bookmarkEditingId=item?.id||null;$('bookmark-editor-title').textContent=item?'Edit bookmark':'Add bookmark';
|
||||
$('bookmark-title').value=(item?.title||lastState?.pageTitle||'New bookmark').slice(0,120);$('bookmark-url').value=item?.url||safeURL(lastState?.url)||'';$('bookmark-folder').value=item?.folder||'';
|
||||
$('bookmark-editor-error').textContent='';$('bookmark-editor').showModal();await updateChrome();$('bookmark-title').focus();
|
||||
}
|
||||
async function mutateBookmark(action,bookmark,id){
|
||||
const snapshot=lastState;const result=await call('mutateBookmarks',{action,...(bookmark?{bookmark}:{}),...(id?{id}:{}),expectedRevision:snapshot.bookmarks.personal.revision,policyVersion:snapshot.policyVersion});show({...snapshot,...result});
|
||||
}
|
||||
function renderBookmarkLibrary(){
|
||||
const target=$('library-bookmarks');target.replaceChildren();
|
||||
for(const [managed,label] of [[true,'Company bookmarks (managed)'],[false,'Your bookmarks']]){
|
||||
const heading=document.createElement('p');heading.className='bookmark-section-label';heading.textContent=label;target.append(heading);
|
||||
let folder=null;for(const item of bookmarks.filter(item=>!!item.managed===managed).sort((a,b)=>(a.folder||'').localeCompare(b.folder||''))){
|
||||
if(item.folder&&item.folder!==folder){const group=document.createElement('small');group.textContent=item.folder;target.append(group);}folder=item.folder;
|
||||
const row=document.createElement('div');row.className='bookmark-row';const link=button(item.title,()=>navigateTab(item.url),'bookmark-link');link.title=item.url;row.append(link);
|
||||
if(!managed&§ionEditable('library')){if(lastState?.bookmarks?.allowUserEdit){const edit=button('Edit',()=>openBookmarkEditor(item),'bookmark-action');edit.setAttribute('aria-label',`Edit ${item.title}`);row.append(edit);}if(lastState?.bookmarks?.allowUserDelete){const remove=button('Remove',()=>mutateBookmark('delete',null,item.id),'bookmark-action');remove.setAttribute('aria-label',`Remove ${item.title}`);row.append(remove);}}
|
||||
target.append(row);
|
||||
}
|
||||
}
|
||||
const legacy=metadata(preference('bookmarks',[]));
|
||||
if(legacy.length&§ionVisible('library')){const heading=document.createElement('p');heading.className='bookmark-section-label';heading.textContent='Previous local bookmarks';target.append(heading);const note=document.createElement('small');note.textContent='These existing links remain local. Add them to your bookmarks to sync them when company policy permits.';target.append(note);for(const item of legacy){const link=button(item.title,()=>navigateTab(item.url),'bookmark-link');link.title=item.url;target.append(link);}}
|
||||
}
|
||||
$('bookmark-cancel').onclick=()=>$('bookmark-editor').close();
|
||||
$('bookmark-editor').addEventListener('close',()=>updateChrome().catch(()=>{}));
|
||||
$('bookmark-form').onsubmit=async event=>{event.preventDefault();const submit=$('bookmark-save');submit.disabled=true;try{
|
||||
requireSection('library',true);await mutateBookmark(bookmarkEditingId?'update':'add',{id:bookmarkEditingId||crypto.randomUUID(),title:$('bookmark-title').value,url:$('bookmark-url').value,folder:$('bookmark-folder').value});$('bookmark-editor').close();
|
||||
}catch(e){$('bookmark-editor-error').textContent=e.message;}finally{submit.disabled=false;}};
|
||||
function renderLibrary(){
|
||||
const key=JSON.stringify({bookmarks,bookmarkPermissions:lastState?.bookmarks,settings:lastState?.settings,readingList,groups,tabs,activeTab});if(key===lastLibraryKey)return;lastLibraryKey=key;
|
||||
const favorites=sectionVisible('library')?bookmarks.slice(0,12):[];renderLinks('favorites-grid',favorites);renderLinks('library-favorites',favorites);renderBookmarkLibrary();renderLinks('library-reading',readingList);
|
||||
if($('library-groups')){$('library-groups').replaceChildren();$('library-groups').append(button('All tabs',()=>{groupFilter='';renderTabs();},'group-item'));for(const group of groups)$('library-groups').append(button(group,()=>{groupFilter=group;renderTabs();},'group-item'));}
|
||||
if($('favorites-empty'))$('favorites-empty').hidden=!!favorites.length;
|
||||
if($('library-tabs')){$('library-tabs').replaceChildren();for(const tab of tabs)$('library-tabs').append(button(tab.title,()=>selectTab(tab.id),`library-tab${tab.id===activeTab?' active':''}`));}
|
||||
renderLinks('recent-list',tabs.filter(t=>t.url).slice(-5).map(t=>({url:safeURL(t.url),title:t.title})).filter(t=>t.url));
|
||||
}
|
||||
const systemAppearance=window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const systemIsDark=()=>window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const accentPresets={blue:{base:'#0969da',light:'#0969da',dark:'#75baff'},teal:{base:'#007d76',light:'#00736d',dark:'#6cdbca'},violet:{base:'#7042c1',light:'#7042c1',dark:'#c5a4ff'}};
|
||||
function applyAppearance(){
|
||||
// Appearance comes exclusively from the privileged process, including unlocked saved choices.
|
||||
const appearance=lastState?.settings?.appearance||{mode:'system',accent:'blue',tabMode:'separate'},theme=appearance.mode==='system'?'auto':appearance.mode,mode=appearance.tabMode;
|
||||
const effective=theme==='auto'?(systemIsDark()?'dark':'light'):theme,savedAccent=appearance.accent,accent=typeof savedAccent==='string'&&Object.hasOwn(accentPresets,savedAccent)?savedAccent:'blue';
|
||||
document.documentElement.dataset.theme=theme;document.documentElement.dataset.effectiveTheme=effective;document.documentElement.dataset.accent=accent;document.documentElement.dataset.tabMode=mode==='compact'?'compact':'separate';
|
||||
document.body.dataset.theme=effective;document.body.dataset.tabMode=document.documentElement.dataset.tabMode;document.documentElement.style.colorScheme=effective;
|
||||
document.documentElement.style.setProperty('--accent',accentPresets[accent].base);document.documentElement.style.setProperty('--accent-text',accentPresets[accent][effective]);
|
||||
if($('theme-mode'))$('theme-mode').value=theme;if($('tab-mode'))$('tab-mode').value=document.documentElement.dataset.tabMode;if($('accent-mode'))$('accent-mode').value=accent;
|
||||
if($('theme-status'))$('theme-status').textContent=theme==='auto'?`System · ${effective==='dark'?'Dark':'Light'}. Changes follow your device.`:`${effective==='dark'?'Dark':'Light'} theme · browser workspace only.`;
|
||||
updateChrome().catch(()=>{});
|
||||
}
|
||||
systemAppearance.addEventListener('change',()=>{if((!lastState?.settings||lastState.settings.appearance.mode==='system'))applyAppearance();});
|
||||
function refreshSystemAppearance(){if((!lastState?.settings||lastState.settings.appearance.mode==='system')&&document.body.dataset.theme!==(systemIsDark()?'dark':'light'))applyAppearance();}
|
||||
window.addEventListener('focus',refreshSystemAppearance);
|
||||
document.addEventListener('visibilitychange',()=>{if(!document.hidden)refreshSystemAppearance();});
|
||||
// Some Windows compositor/theme transitions update the query without a change event.
|
||||
setInterval(refreshSystemAppearance,1000);
|
||||
|
||||
|
||||
// Hidden controls are removed from keyboard navigation as well as visual presentation.
|
||||
// Native IPC repeats every check: DOM visibility is never the security boundary.
|
||||
function sectionVisible(name){const settings=lastState?.settings;return !!settings?.available&&(settings.visible||['library','ai','passwords','documentScan','agents'].includes(name))&&!settings.hiddenSections.includes(name);}
|
||||
function sectionEditable(name){return sectionVisible(name)&&(name!=='library'||lastState.settings.visible)&&!lastState.settings.lockedSections.includes(name);}
|
||||
function requireSection(name,edit=false){if(!(edit?sectionEditable(name):sectionVisible(name)))throw Error('This action is managed by your administrator.');}
|
||||
function applyManagedSettings(){
|
||||
const settings=lastState?.settings,visible=!!settings?.available&&settings.visible;
|
||||
$('tools-toggle').hidden=!visible;$('tools-toggle').disabled=!visible;
|
||||
if(!visible){$('tools-menu').hidden=true;$('tools-toggle').setAttribute('aria-expanded','false');}
|
||||
const sections={appearance:['appearance-section'],library:['bookmark','reading-add','sidebar-toggle'],ai:['ai-toggle','sidebar'],passwords:['passwords'],documentScan:['inspect'],agents:['agents']};
|
||||
for(const [name,ids] of Object.entries(sections))for(const id of ids){const el=$(id);if(!el)continue;el.hidden=!sectionVisible(name);if(!sectionVisible(name)||(['library','ai','passwords','documentScan','agents'].includes(name)&&!sectionEditable(name)))el.disabled=true;}
|
||||
for(const id of ['theme-mode','accent-mode','tab-mode'])$(id).disabled=!sectionEditable('appearance');
|
||||
for(const id of ['bookmark','reading-add','group-add','group-name'])if($(id))$(id).disabled=!sectionEditable('library')||!lastState?.ready||(id==='bookmark'&&!lastState?.bookmarks?.allowUserAdd);
|
||||
$('send').disabled=!lastState?.aiEnabled||!sectionEditable('ai');$('prompt').disabled=!sectionEditable('ai');
|
||||
for(const [quick,original,name,enabled] of [['quick-ai','ai-toggle','ai',lastState?.aiEnabled],['quick-passwords','passwords','passwords',lastState?.ready&&lastState?.passwordManagerEnabled!==false],['quick-inspect','inspect','documentScan',lastState?.documentScanEnabled],['quick-agents','agents','agents',lastState?.agentsEnabled]]){const el=$(quick);el.hidden=visible||!sectionVisible(name)||!enabled;el.disabled=!sectionEditable(name)||!enabled;el.setAttribute('aria-expanded',$(original).getAttribute('aria-expanded')||'false');}
|
||||
$('settings-managed').hidden=!sectionVisible('appearance')||sectionEditable('appearance');
|
||||
if(!sectionVisible('library')){sidebarOpen=false;$('sidebar-toggle').setAttribute('aria-expanded','false');}
|
||||
if(!sectionEditable('ai')&&panel)closeAI().catch(()=>{});
|
||||
if(!sectionEditable('passwords')&&vaultPanel)closeVault().catch(()=>{});
|
||||
applyAppearance();
|
||||
}
|
||||
|
||||
async function call(method,...args){const r=await window.safetynet[method](...args);if(!r.ok)throw Error(r.error);return r.data;}
|
||||
function clearVaultFields(){for(const id of ['vault-master','vault-confirm','vault-password','vault-origin','vault-label','vault-username'])$(id).value='';}
|
||||
async function closeVault(){vaultRevision++;vaultPanel=false;$('vault').hidden=true;$('passwords').setAttribute('aria-expanded','false');clearVaultFields();$('vault-list').replaceChildren();$('vault-add').open=false;await call('vaultLock');await call('toggleVault',false);}
|
||||
async function closeAI(){panel=false;$('ai').hidden=true;for(const id of ['sidebar','ai-toggle'])$(id).setAttribute('aria-expanded','false');await call('toggleAI',false);}
|
||||
async function openAI(){requireSection('ai',true);if(vaultPanel)await closeVault();panel=true;$('ai').hidden=false;for(const id of ['sidebar','ai-toggle'])$(id).setAttribute('aria-expanded','true');await call('toggleAI',true);}
|
||||
function show(s){
|
||||
// A delayed invoke response must not replace a newer tab switch or navigation event.
|
||||
if(Number.isSafeInteger(s.tabsRevision)&&s.tabsRevision<nativeTabsRevision)return;
|
||||
if(Array.isArray(s.tabs)){nativeTabsRevision=s.tabsRevision??nativeTabsRevision;tabs=s.tabs;activeTab=s.activeTabId;startPageOpen=!!currentTab().startPage;if(groupFilter&¤tTab().group!==groupFilter)groupFilter=currentTab().group;}
|
||||
document.body.classList.toggle('window-maximized',!!s.windowMaximized);
|
||||
lastState=s;bookmarks=[...(s.bookmarks?.managed||[]).map(item=>({...item,managed:true})),...(s.bookmarks?.personal?.items||[])];if(!s.ready||!sectionEditable('library')||($('bookmark-editor').open&&!(bookmarkEditingId?s.bookmarks?.allowUserEdit:s.bookmarks?.allowUserAdd)))$('bookmark-editor').close();const brand=s.brand.name==='SafetyNet'?'S Browser':s.brand.name;$('brand').textContent=brand;document.title=brand;$('managed').textContent=s.ready?(s.loginCheckPending?'Login check required':`Managed · policy ${s.policyVersion}`):'Access paused';$('managed').parentElement.classList.toggle('paused',!s.ready);
|
||||
$('welcome').hidden=s.ready;$('enrollment').hidden=s.enrolled;$('message').textContent=s.message;
|
||||
if(document.activeElement!==$('address'))$('address').value=startPageOpen?'':s.url||'';
|
||||
$('managed').title=s.message;$('address-form').querySelector('.address-lock').src=s.url?.startsWith('https:')?'icons/lock-keyhole.svg':'icons/globe.svg';
|
||||
$('ai-toggle').disabled=!s.aiEnabled;$('sidebar').disabled=!s.ready;$('send').disabled=!s.aiEnabled;
|
||||
$('agents').disabled=!s.agentsEnabled;$('inspect').disabled=!s.documentScanEnabled;$('passwords').disabled=!s.ready||s.passwordManagerEnabled===false;
|
||||
$('inspect').title=s.documentScanEnabled?'Inspect a document locally':'Document analysis is disabled by company policy';
|
||||
$('back').disabled=!s.ready||!currentTab().canGoBack;$('forward').disabled=!s.ready||!currentTab().canGoForward;$('reload').disabled=!s.ready||currentTab().startPage;$('address').disabled=!(s.canNavigate??s.ready);
|
||||
if(vaultPanel&&s.vaultLocked===true&&vaultState?.locked!==true){vaultRevision++;clearVaultFields();$('vault-list').replaceChildren();$('vault-open').hidden=true;vaultState={...vaultState,locked:true};refreshVault().catch(()=>{});}
|
||||
if(vaultPanel&&s.passwordManagerEnabled===false)closeVault().catch(()=>{});
|
||||
if(!s.ready){if(panel)closeAI().catch(()=>{});if(vaultPanel)closeVault().catch(()=>{});clearVaultFields();}
|
||||
document.documentElement.style.setProperty('--page-tint',/^#[a-f0-9]{6}$/i.test(s.pageColor||'')?s.pageColor:'#b8c7dd');
|
||||
if($('start-greeting'))$('start-greeting').textContent='A fresh place to begin';
|
||||
if($('sidebar-toggle'))$('sidebar-toggle').disabled=!s.ready;
|
||||
if($('new-tab'))$('new-tab').disabled=!s.ready;if($('tab-overview'))$('tab-overview').disabled=!s.ready;
|
||||
applyManagedSettings();updateChrome().catch(()=>{});renderTabs();renderLibrary();
|
||||
}
|
||||
function task(fn){return async event=>{event?.preventDefault();$('error').textContent='';try{await fn();}catch(e){$('error').textContent=e.message;$('managed').textContent=e.message;}};}
|
||||
function vaultTask(fn){return async event=>{event?.preventDefault();if(vaultBusy)return;vaultBusy=true;$('vault-error').textContent='';try{await fn();}catch{$('vault-error').textContent='The vault action could not be completed. Check your password and company policy.';}finally{vaultBusy=false;$('vault-master').value='';$('vault-confirm').value='';}};}
|
||||
function input(){return {server:$('server').value,code:$('code').value};}
|
||||
$('enroll').onclick=task(async()=>{$('enroll').disabled=true;try{show(await call('enroll',input()));$('code').value='';}finally{$('enroll').disabled=false;}});
|
||||
$('sso').onclick=task(async()=>{$('sso').disabled=true;try{show(await call('sso',input()));}finally{$('sso').disabled=false;}});
|
||||
$('address-form').onsubmit=task(async()=>navigateTab($('address').value));
|
||||
for(const method of ['back','forward','reload'])$(method).onclick=task(()=>call(method));
|
||||
$('ai-toggle').onclick=$('sidebar').onclick=task(async()=>{if(panel)await closeAI();else await openAI();});
|
||||
$('ai-close').onclick=task(closeAI);
|
||||
$('inspect').onclick=task(async()=>{
|
||||
requireSection('documentScan',true);$('inspect').disabled=true;try{const result=await call('inspectFile');if(!result)return;await openAI();entry(`Inspection: ${result.name}\nDecision: ${result.decision}\nDetector: ${result.ruleId||'No match'}\nType: ${result.contentType}\nComplete coverage: ${result.coverage.complete}\n${result.model?`Model: ${result.model}`:''}\nAnalyzed locally. This result does not authorize a website upload.`);}finally{show(await call('state'));}
|
||||
});
|
||||
function entry(text){const empty=$('conversation').querySelector('.empty-chat');empty?.remove();const e=document.createElement('div');e.className='entry';e.textContent=text;$('conversation').append(e);e.scrollIntoView();}
|
||||
$('agents').onclick=task(async()=>{requireSection('agents',true);const connection=await call('agentConnection');await openAI();entry(`Local agent API\nEndpoint: ${connection.address}\nEncrypted pairing file: ${connection.credentialPath}\nUse the trusted local helper. Every action follows company policy.`);});
|
||||
$('ai-form').onsubmit=task(async()=>{const prompt=$('prompt').value;if(!prompt.trim())return;entry(`You: ${prompt}`);$('send').disabled=true;try{const result=await call('ai',prompt);entry(result.answer);$('prompt').value='';}catch(e){entry(e.message);}finally{$('send').disabled=!lastState?.aiEnabled;}});
|
||||
async function refreshVault(){
|
||||
const revision=++vaultRevision;const status=await call('vaultStatus');if(!vaultPanel||revision!==vaultRevision)return;const previous=vaultState;vaultState=status;
|
||||
$('vault-access').hidden=!status.enabled||!status.locked&&status.exists;$('vault-open').hidden=!status.enabled||status.locked||!status.exists;
|
||||
$('vault-access-submit').textContent=status.exists?'Unlock vault':'Create vault';$('vault-master').autocomplete=status.exists?'current-password':'new-password';
|
||||
$('vault-confirm').hidden=status.exists;$('vault-confirm-label').hidden=status.exists;$('vault-confirm').required=!status.exists&&status.enabled;
|
||||
$('master-label').textContent=status.exists?'Master password':'Choose a master password';
|
||||
$('vault-description').textContent=!status.enabled?'Password storage is disabled by your administrator.':status.locked||!status.exists?'Your encrypted logins stay on this device. Unlock with your master password.':`Unlocked · locks after ${status.idleLockSeconds} seconds of inactivity.`;
|
||||
$('vault-add').hidden=!status.allowSaving;$('vault-save-submit').disabled=!status.allowSaving;$('vault-generate').disabled=!status.allowSaving;
|
||||
if(!status.enabled||status.locked||!status.exists){if(!status.enabled||previous?.locked===false)clearVaultFields();$('vault-list').replaceChildren();return;}
|
||||
const entries=await call('vaultList');if(!vaultPanel||revision!==vaultRevision)return;$('vault-list').replaceChildren();
|
||||
if(!entries.length){const empty=document.createElement('div');empty.className='empty-vault';empty.textContent='No saved logins yet.';$('vault-list').append(empty);}
|
||||
for(const item of entries){const card=document.createElement('div');card.className='login-card';const label=document.createElement('strong');label.textContent=item.label||item.origin;const origin=document.createElement('p');origin.textContent=item.origin;const username=document.createElement('p');username.textContent=item.username||'No username';const buttons=document.createElement('div');buttons.className='login-buttons';const fill=document.createElement('button');fill.textContent='Fill login';fill.disabled=!status.allowAutofill;fill.onclick=vaultTask(async()=>{await call('vaultFill',item.id);$('vault-error').textContent='Login filled in the current page.';});const remove=document.createElement('button');remove.className='secondary';remove.textContent='Delete';remove.disabled=!status.allowSaving;remove.onclick=vaultTask(async()=>{await call('vaultDelete',item.id);await refreshVault();});buttons.append(fill,remove);card.append(label,origin,username,buttons);$('vault-list').append(card);}
|
||||
}
|
||||
$('passwords').onclick=vaultTask(async()=>{requireSection('passwords',true);if(vaultPanel){await closeVault();return;}await closeAI();vaultPanel=true;$('vault').hidden=false;$('passwords').setAttribute('aria-expanded','true');await call('toggleVault',true);await refreshVault();});
|
||||
$('vault-close').onclick=vaultTask(closeVault);
|
||||
$('vault-access').onsubmit=vaultTask(async()=>{const exists=vaultState?.exists,master=$('vault-master').value;if(!exists&&master!==$('vault-confirm').value){$('vault-error').textContent='The master passwords do not match.';return;}await call(exists?'vaultUnlock':'vaultCreate',master);$('vault-master').value='';$('vault-confirm').value='';await refreshVault();});
|
||||
$('vault-lock').onclick=vaultTask(async()=>{vaultRevision++;clearVaultFields();$('vault-list').replaceChildren();await call('vaultLock');await refreshVault();});
|
||||
$('vault-save').onsubmit=vaultTask(async()=>{await call('vaultSave',{origin:$('vault-origin').value,label:$('vault-label').value,username:$('vault-username').value,password:$('vault-password').value});clearVaultFields();$('vault-add').open=false;await refreshVault();});
|
||||
$('vault-generate').onclick=vaultTask(async()=>{const generated=await call('vaultGenerate');$('vault-password').value=typeof generated==='string'?generated:generated.password;});
|
||||
$('vault-add').ontoggle=()=>{if($('vault-add').open&&lastState?.url&&!$('vault-origin').value){try{$('vault-origin').value=new URL(lastState.url).origin;}catch{}}else if(!$('vault-add').open)$('vault-password').value='';};
|
||||
window.addEventListener('blur',()=>{vaultRevision++;clearVaultFields();$('vault-list').replaceChildren();if(vaultPanel)call('vaultLock').then(refreshVault).catch(()=>{});});
|
||||
window.addEventListener('pagehide',()=>{clearVaultFields();if(vaultPanel)call('vaultLock').catch(()=>{});});
|
||||
setInterval(()=>{if(vaultPanel&&!vaultBusy)refreshVault().catch(()=>{clearVaultFields();$('vault-list').replaceChildren();});},5000);
|
||||
function wire(id,handler){if($(id))$(id).onclick=task(handler);}
|
||||
wire('sidebar-toggle',async()=>{requireSection('library');sidebarOpen=!sidebarOpen;$('sidebar-toggle').setAttribute('aria-expanded',String(sidebarOpen));await updateChrome();});
|
||||
wire('new-tab',newTab);wire('tab-overview',async()=>{overviewOpen=!overviewOpen;startPageOpen=!!currentTab().startPage;$('tab-overview').setAttribute('aria-expanded',String(overviewOpen));renderTabs();await updateChrome();});
|
||||
wire('overview-close',async()=>{overviewOpen=false;startPageOpen=!!currentTab().startPage;await updateChrome();});
|
||||
for(const action of ['minimize','maximize','close'])wire(`window-${action}`,()=>call('windowControls',action));
|
||||
wire('share-url',()=>call('shareURL'));
|
||||
wire('bookmark',async()=>{requireSection('library',true);if(!lastState?.bookmarks?.allowUserAdd)throw Error('Adding bookmarks is disabled by your administrator');await openBookmarkEditor();});
|
||||
wire('reading-add',async()=>{requireSection('library',true);await call('authorizeSetting','library');requireSection('library',true);if(!lastState?.ready)throw Error('Open an approved page first');const url=safeURL(lastState.url);if(!url)throw Error('This page cannot be saved');if(!readingList.some(b=>b.url===url)){readingList.push({url,title:String(lastState.pageTitle||new URL(url).hostname).slice(0,160)});readingList=readingList.slice(-100);remember('reading-list',readingList);renderLibrary();}});
|
||||
wire('group-add',async()=>{requireSection('library',true);await call('authorizeSetting','library');requireSection('library',true);const field=$('group-name'),label=field?.value.trim();if(!label)throw Error('Enter a tab group name');if(label.length>40)throw Error('Tab group names are limited to 40 characters');if(!groups.includes(label)){if(groups.length>=20)throw Error('At most 20 tab groups are supported');groups.push(label);remember('groups',groups);}show(await call('groupTab',{id:currentTab().id,group:label}));groupFilter=label;field.value='';renderLibrary();renderTabs();});
|
||||
for(const [quick,original] of [['quick-ai','ai-toggle'],['quick-passwords','passwords'],['quick-inspect','inspect'],['quick-agents','agents']])wire(quick,()=>$(original).click());
|
||||
wire('tools-toggle',async()=>{if(!lastState?.settings?.available||!lastState.settings.visible)throw Error('Settings are managed by your administrator.');const menu=$('tools-menu');if(menu){menu.hidden=!menu.hidden;$('tools-toggle').setAttribute('aria-expanded',String(!menu.hidden));await updateChrome();}});
|
||||
for(const id of ['theme-mode','accent-mode','tab-mode'])$(id).onchange=task(async()=>{
|
||||
requireSection('appearance',true);
|
||||
try{const settings=await call('updateSettings',{mode:$('theme-mode').value==='auto'?'system':$('theme-mode').value,accent:$('accent-mode').value,tabMode:$('tab-mode').value});lastState={...lastState,settings};}
|
||||
finally{applyManagedSettings();}
|
||||
});
|
||||
window.addEventListener('resize',()=>updateChrome().catch(()=>{}));
|
||||
document.addEventListener('click',event=>{const menu=$('tools-menu');if(!menu||menu.hidden)return;if(event.target.closest('.menu-item')||(!event.target.closest('#tools-menu')&&!event.target.closest('#tools-toggle'))){menu.hidden=true;$('tools-toggle')?.setAttribute('aria-expanded','false');updateChrome().catch(()=>{});}});
|
||||
window.addEventListener('keydown',event=>{if(event.ctrlKey&&event.key.toLowerCase()==='t'&&lastState?.ready){event.preventDefault();newTab().catch(()=>{});}if(event.key==='Escape'&&overviewOpen){overviewOpen=false;startPageOpen=!!currentTab().startPage;updateChrome().catch(()=>{});}});
|
||||
applyAppearance();renderTabs();renderLibrary();
|
||||
window.safetynet.onState(show);call('state').then(show).catch(e=>{$('error').textContent=e.message;});
|
||||
@@ -0,0 +1,50 @@
|
||||
const {ipcRenderer}=require('electron');
|
||||
if(process.sandboxed!==true||process.contextIsolated!==true){ipcRenderer.send('managed:clipboard-sandbox-failure');window.stop();throw Error('Managed renderer sandbox and isolation are required');}
|
||||
// Native shortcuts and guarded menus are the only supported clipboard transfer paths.
|
||||
// Prevent page handlers/legacy execCommand from replacing approved plaintext with hidden data.
|
||||
// Freeze the legacy command primitive in the page realm. Document.open() removes
|
||||
// DOM listeners, but cannot replace this non-configurable prototype property.
|
||||
try {
|
||||
require('electron').contextBridge.executeInMainWorld({func:()=>{
|
||||
const prototype=Document.prototype,existing=Object.getOwnPropertyDescriptor(prototype,'execCommand');
|
||||
if(existing?.value?._managedClipboardGuard===true&&!existing.configurable)return;
|
||||
const original=prototype.execCommand,apply=Reflect.apply,test=RegExp.prototype.test.bind(/^(copy|cut|paste)/i);
|
||||
function managedClipboardCommand(command,...args){
|
||||
if(typeof command!=='string'||test(command))return false;
|
||||
return apply(original,this,[command,...args]);
|
||||
}
|
||||
Object.defineProperty(managedClipboardCommand,'_managedClipboardGuard',{value:true});
|
||||
Object.defineProperty(prototype,'execCommand',{value:managedClipboardCommand,writable:false,configurable:false});
|
||||
// Replacement clears security listeners; these unsupported legacy APIs fail closed.
|
||||
const SecurityException=DOMException;
|
||||
const denyReplacement=()=>{throw new SecurityException('Managed document replacement is disabled','SecurityError');};
|
||||
for(const name of ['open','write','writeln'])Object.defineProperty(prototype,name,{value:denyReplacement,writable:false,configurable:false});
|
||||
}});
|
||||
} catch {
|
||||
require('electron').ipcRenderer.send('managed:clipboard-sandbox-failure');
|
||||
window.stop();
|
||||
throw Error('Managed clipboard primitive could not be protected');
|
||||
}
|
||||
for(const name of ['copy','cut','paste','dragstart','dragover','drop'])window.addEventListener(name,event=>{event.preventDefault();event.stopImmediatePropagation();},true);
|
||||
window.addEventListener('beforeinput',event=>{if(/^(insertFromPaste|insertFromDrop|deleteByCut)/.test(event.inputType||'')){event.preventDefault();event.stopImmediatePropagation();}},true);
|
||||
let visualEnabled=ipcRenderer.sendSync('managed:visual-policy').enabled;
|
||||
const documentToken=typeof crypto.randomUUID==='function'?crypto.randomUUID():Array.from(crypto.getRandomValues(new Uint8Array(16)),value=>value.toString(16).padStart(2,'0')).join('');let visualRevision=0,visualApproved=!visualEnabled,visualScheduled=false;
|
||||
ipcRenderer.on('managed:visual-config',(_event,input)=>{visualEnabled=input.enabled===true;visualApproved=!visualEnabled;visualRevision++;visualChanged();ipcRenderer.send('managed:visual-configured',input.token);});
|
||||
function visualChanged(){
|
||||
if(!visualEnabled)return;visualRevision++;visualApproved=false;
|
||||
if(visualScheduled)return;visualScheduled=true;
|
||||
setTimeout(async()=>{visualScheduled=false;const revision=visualRevision,url=location.href;
|
||||
try{const result=await ipcRenderer.invoke('managed:visual-check',{token:documentToken,revision,url});if(revision===visualRevision&&url===location.href)visualApproved=result.decision==='allow';}catch{visualApproved=false;}
|
||||
if(!visualApproved&&revision===visualRevision)setTimeout(visualChanged,1000);
|
||||
},150);
|
||||
}
|
||||
for(const name of ['keydown','beforeinput','paste','submit','pointerdown'])window.addEventListener(name,event=>{
|
||||
if(!visualEnabled||visualApproved)return;
|
||||
const element=event.target;
|
||||
if(name==='submit'||element?.closest?.('input,textarea,[contenteditable="true"],button[type="submit"]')){event.preventDefault();event.stopImmediatePropagation();visualChanged();}
|
||||
},true);
|
||||
// This isolated-world preload exposes no bridge or automation capability to sites.
|
||||
let scheduled=false,last='';
|
||||
function inspect(){scheduled=false;const text=(document.body?.innerText||'').slice(0,250000);if(text===last)return;last=text;ipcRenderer.send('managed:page-content',{text});}
|
||||
function schedule(){if(!scheduled){scheduled=true;setTimeout(inspect,350);}}
|
||||
window.addEventListener('DOMContentLoaded',()=>{schedule();visualChanged();new MutationObserver(()=>{schedule();visualChanged();}).observe(document,{childList:true,subtree:true,characterData:true,attributes:true});});
|
||||
@@ -0,0 +1,22 @@
|
||||
'use strict';
|
||||
// Check only switch names. Arguments, paths, credentials and agent payloads never enter errors.
|
||||
const BLOCKED_SWITCHES=Object.freeze([
|
||||
'no-sandbox','disable-gpu-sandbox','disable-web-security','allow-running-insecure-content',
|
||||
'remote-debugging-port','remote-debugging-pipe','remote-debugging-socket-name',
|
||||
'load-extension','disable-extensions-except','disable-site-isolation-trials','disable-site-isolation-for-policy',
|
||||
'single-process','in-process-gpu','renderer-cmd-prefix','utility-cmd-prefix','gpu-launcher',
|
||||
'inspect','inspect-brk','inspect-wait','inspect-port','debug','debug-brk','debug-port'
|
||||
]);
|
||||
const failure=()=>{throw new Error('Unsafe browser startup configuration');};
|
||||
function assertSecureStartup(commandLine,nodeExecArgv=process.execArgv){
|
||||
try{
|
||||
if(!commandLine||typeof commandLine.hasSwitch!=='function'||!Array.isArray(nodeExecArgv))return failure();
|
||||
for(const name of BLOCKED_SWITCHES){const present=commandLine.hasSwitch(name);if(typeof present!=='boolean'||present)return failure();}
|
||||
// Electron/Node can consume inspector flags before they reach Chromium's CommandLine.
|
||||
for(const argument of nodeExecArgv){if(typeof argument!=='string'||/^--(?:inspect(?:-brk|-wait|-port)?|debug(?:-brk|-port)?)(?:=|$)/.test(argument))return failure();}
|
||||
// NODE_OPTIONS can activate Node's inspector without appearing in process.execArgv.
|
||||
if(require('node:inspector').url())return failure();
|
||||
return true;
|
||||
}catch{return failure();}
|
||||
}
|
||||
module.exports={assertSecureStartup,BLOCKED_SWITCHES};
|
||||
@@ -0,0 +1,54 @@
|
||||
'use strict';
|
||||
const {randomUUID}=require('node:crypto');
|
||||
|
||||
// Native ownership only: renderer tab labels never own or replace a WebContents.
|
||||
function createTabRegistry({createView,attach=()=>{},detach=()=>{},dispose=()=>{},maxTabs=32}={}) {
|
||||
if(typeof createView!=='function'||![attach,detach,dispose].every(fn=>typeof fn==='function')||!Number.isSafeInteger(maxTabs)||maxTabs<1||maxTabs>128)throw TypeError('Invalid native tab registry');
|
||||
const tabs=new Map(),byContents=new Map();let activeId=null,activation=0,policyVersion=0,paused=true,closed=false;
|
||||
const requireTab=id=>{const tab=tabs.get(id);if(!tab||tab.view.webContents.isDestroyed())throw Error('Unknown native tab');return tab;};
|
||||
const invalidate=tab=>{tab.navigationId++;tab.security.visualApproval=null;tab.security.visualToken='';tab.security.visualRevision=-1;tab.security.visualScanner?.reset();};
|
||||
function create({group=''}={}) {
|
||||
if(closed||tabs.size>=maxTabs)throw Error('Native tab capacity reached');
|
||||
if(typeof group!=='string'||group.length>100)throw TypeError('Invalid tab group');
|
||||
const view=createView(),contents=view?.webContents;
|
||||
if(!contents||!Number.isSafeInteger(contents.id)||contents.isDestroyed()||byContents.has(contents.id))throw Error('Invalid native tab view');
|
||||
const tab={id:randomUUID(),view,group,navigationId:0,title:'New Tab',color:'#edf1f6',startPage:true,security:{visualApproval:null,visualToken:'',visualRevision:-1,visualPending:null,visualBlocked:null,contentBlocked:null,visualScanner:null}};
|
||||
view.setVisible(false);tabs.set(tab.id,tab);byContents.set(contents.id,tab);
|
||||
if(!activeId)activate(tab.id);return tab;
|
||||
}
|
||||
function activate(id) {
|
||||
const next=requireTab(id);if(id===activeId)return next;
|
||||
const previous=tabs.get(activeId);if(previous){previous.view.setVisible(false);detach(previous.view);}
|
||||
activeId=id;activation++;attach(next.view);next.view.setVisible(false);return next;
|
||||
}
|
||||
function remove(id) {
|
||||
const tab=requireTab(id),wasActive=id===activeId;invalidate(tab);tab.view.setVisible(false);
|
||||
tabs.delete(id);byContents.delete(tab.view.webContents.id);
|
||||
if(wasActive){detach(tab.view);activeId=null;activation++;}
|
||||
try{dispose(tab);}finally{tab.view.webContents.close({waitForBeforeUnload:false});}
|
||||
if(wasActive&&tabs.size)activate(tabs.keys().next().value);
|
||||
}
|
||||
function context(id=activeId,{foreground=true}={}) {
|
||||
const tab=requireTab(id);return Object.freeze({tabId:tab.id,webContents:tab.view.webContents,navigationId:tab.navigationId,url:tab.view.webContents.getURL(),policyVersion,activation:foreground?activation:null,foreground,paused});
|
||||
}
|
||||
function current(token,{expectedURL=token?.url}={}) {
|
||||
const tab=token&&tabs.get(token.tabId);
|
||||
return !!tab&&!paused&&!token.paused&&!tab.view.webContents.isDestroyed()&&tab.view.webContents===token.webContents&&tab.navigationId===token.navigationId&&policyVersion===token.policyVersion&&tab.view.webContents.getURL()===expectedURL&&(!token.foreground||(activeId===tab.id&&activation===token.activation));
|
||||
}
|
||||
function quarantine() {
|
||||
paused=true;activation++;
|
||||
for(const tab of tabs.values()){invalidate(tab);tab.view.webContents.stop();tab.view.setVisible(false);tab.security.developerToolsGuard?.close();tab.view.webContents.closeDevTools?.();}
|
||||
}
|
||||
function resume(version) {
|
||||
if(!Number.isSafeInteger(version)||version<1||version<policyVersion)throw Error('Invalid tab policy generation');
|
||||
if(version!==policyVersion)for(const tab of tabs.values())invalidate(tab);
|
||||
policyVersion=version;paused=false;
|
||||
}
|
||||
return {create,activate,remove,context,current,quarantine,resume,
|
||||
get:requireTab,active:()=>activeId?requireTab(activeId):null,
|
||||
fromContents:id=>byContents.get(id)||null,all:()=>[...tabs.values()],
|
||||
navigation:id=>invalidate(requireTab(id)),
|
||||
close:()=>{closed=true;quarantine();for(const id of [...tabs.keys()])remove(id);},
|
||||
summary:()=>({activeTabId:activeId,tabs:[...tabs.values()].map(tab=>({id:tab.id,group:tab.group,title:tab.title,url:tab.view.webContents.getURL(),startPage:tab.startPage,color:tab.color,canGoBack:tab.view.webContents.navigationHistory?.canGoBack()||false,canGoForward:tab.view.webContents.navigationHistory?.canGoForward()||false}))})};
|
||||
}
|
||||
module.exports={createTabRegistry};
|
||||
@@ -0,0 +1,161 @@
|
||||
'use strict';
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const MAX_FILE = 8 * 1024 * 1024;
|
||||
const SCRYPT = Object.freeze({ N: 131072, r: 8, p: 1, maxmem: 192 * 1024 * 1024 });
|
||||
const FORMAT = 'safetynet-local-vault-v1';
|
||||
const fail = () => { throw new Error('Vault operation refused'); };
|
||||
const wipe = value => { if (Buffer.isBuffer(value)) value.fill(0); };
|
||||
const encode = value => value.toString('base64');
|
||||
function decode(value, maximum = MAX_FILE) {
|
||||
if (typeof value !== 'string' || value.length > Math.ceil(maximum / 3) * 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) fail();
|
||||
const result = Buffer.from(value, 'base64');
|
||||
if (result.length > maximum || encode(result) !== value) fail();
|
||||
return result;
|
||||
}
|
||||
function seal(key, bytes, aad) {
|
||||
const nonce = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
|
||||
cipher.setAAD(Buffer.from(aad));
|
||||
return { nonce: encode(nonce), ciphertext: encode(Buffer.concat([cipher.update(bytes), cipher.final()])), tag: encode(cipher.getAuthTag()) };
|
||||
}
|
||||
function open(key, record, aad) {
|
||||
if (!record || typeof record !== 'object') fail();
|
||||
const nonce = decode(record.nonce, 12), tag = decode(record.tag, 16);
|
||||
if (nonce.length !== 12 || tag.length !== 16) fail();
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
|
||||
decipher.setAAD(Buffer.from(aad)); decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(decode(record.ciphertext)), decipher.final()]);
|
||||
}
|
||||
function origin(value) {
|
||||
if (typeof value !== 'string' || value.length > 2048) fail();
|
||||
let url; try { url = new URL(value); } catch { fail(); }
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.origin !== value) fail();
|
||||
return url.origin;
|
||||
}
|
||||
function text(value, maximum) {
|
||||
if (typeof value !== 'string' || value.length > maximum || value.includes('\0')) fail();
|
||||
return value;
|
||||
}
|
||||
function master(value) { return text(value, 1024).length >= 16 ? value : fail(); }
|
||||
function wrappingKey(shared, vaultId) {
|
||||
return Buffer.from(crypto.hkdfSync('sha256', shared, Buffer.from(vaultId), Buffer.from(FORMAT + ':kem-wrap'), 32));
|
||||
}
|
||||
function generatePassword(length = 32) {
|
||||
if (!Number.isInteger(length) || length < 24 || length > 128) fail();
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+';
|
||||
return Array.from({ length }, () => alphabet[crypto.randomInt(alphabet.length)]).join('');
|
||||
}
|
||||
function createVault({ filePath, protect, unprotect, idleSeconds = 300 }) {
|
||||
if (typeof filePath !== 'string' || !path.isAbsolute(filePath) || typeof protect !== 'function' || typeof unprotect !== 'function' || !Number.isInteger(idleSeconds) || idleSeconds < 60 || idleSeconds > 900) fail();
|
||||
let key = null, entries = [], envelope = null, timer = null;
|
||||
function lock() {
|
||||
clearTimeout(timer); timer = null; wipe(key); key = null;
|
||||
for (const entry of entries) { entry.password = ''; entry.username = ''; entry.label = ''; entry.origin = ''; }
|
||||
entries = []; envelope = null;
|
||||
}
|
||||
function touch() { clearTimeout(timer); timer = setTimeout(lock, idleSeconds * 1000); timer.unref(); }
|
||||
function requireUnlocked(refreshIdle = true) { if (!key) fail(); if (refreshIdle) touch(); }
|
||||
function checkedFile() {
|
||||
const stat = fs.lstatSync(filePath);
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.size < 1 || stat.size > MAX_FILE) fail();
|
||||
const fd = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
||||
const bytes = Buffer.alloc(MAX_FILE + 1);
|
||||
try {
|
||||
if (fs.fstatSync(fd).size > MAX_FILE) fail();
|
||||
let length = 0, count;
|
||||
while (length < bytes.length && (count = fs.readSync(fd, bytes, length, bytes.length - length, null))) length += count;
|
||||
if (length > MAX_FILE) fail();
|
||||
return JSON.parse(bytes.subarray(0, length).toString('utf8'));
|
||||
} finally { wipe(bytes); fs.closeSync(fd); }
|
||||
}
|
||||
function header(record) {
|
||||
return JSON.stringify({ format: record.format, vaultId: record.vaultId, salt: record.salt, privateKey: record.privateKey, kemCiphertext: record.kemCiphertext });
|
||||
}
|
||||
function persist() {
|
||||
const bytes = Buffer.from(JSON.stringify({ entries }));
|
||||
let temporary;
|
||||
try {
|
||||
const aad = header(envelope);
|
||||
const output = Buffer.from(JSON.stringify({ ...envelope, payload: seal(key, bytes, aad + ':' + JSON.stringify(envelope.wrappedKey)) }));
|
||||
if (output.length > MAX_FILE) fail();
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
if (fs.existsSync(filePath) && !fs.lstatSync(filePath).isFile()) fail();
|
||||
temporary = filePath + '.' + crypto.randomBytes(16).toString('hex') + '.tmp';
|
||||
const fd = fs.openSync(temporary, 'wx', 0o600);
|
||||
try { fs.writeFileSync(fd, output); fs.fsyncSync(fd); } finally { fs.closeSync(fd); wipe(output); }
|
||||
fs.renameSync(temporary, filePath); temporary = null;
|
||||
} finally { wipe(bytes); if (temporary) { try { fs.unlinkSync(temporary); } catch {} } }
|
||||
}
|
||||
function create(password) {
|
||||
lock(); let derived, privateDer, protectedBytes, shared, wrap;
|
||||
try {
|
||||
master(password); if (fs.existsSync(filePath)) fail();
|
||||
if (typeof crypto.encapsulate !== 'function' || typeof crypto.decapsulate !== 'function') fail();
|
||||
const salt = crypto.randomBytes(32), vaultId = crypto.randomUUID();
|
||||
derived = crypto.scryptSync(password, salt, 32, SCRYPT);
|
||||
const pair = crypto.generateKeyPairSync('ml-kem-1024');
|
||||
privateDer = pair.privateKey.export({ format: 'der', type: 'pkcs8' });
|
||||
protectedBytes = Buffer.from(JSON.stringify(seal(derived, privateDer, FORMAT + ':private:' + vaultId)));
|
||||
const platformProtected = protect(protectedBytes);
|
||||
if (!Buffer.isBuffer(platformProtected) || !platformProtected.length || platformProtected.length > 65536) fail();
|
||||
const encapsulated = crypto.encapsulate(pair.publicKey); shared = encapsulated.sharedKey;
|
||||
wrap = wrappingKey(shared, vaultId); key = crypto.randomBytes(32);
|
||||
envelope = { format: FORMAT, vaultId, salt: encode(salt), privateKey: encode(platformProtected), kemCiphertext: encode(encapsulated.ciphertext) };
|
||||
envelope.wrappedKey = seal(wrap, key, header(envelope));
|
||||
persist(); touch(); return status();
|
||||
} catch { lock(); fail(); } finally { [derived, privateDer, protectedBytes, shared, wrap].forEach(wipe); }
|
||||
}
|
||||
function unlock(password) {
|
||||
lock(); let derived, protectedBytes, privateDer, shared, wrap, plaintext;
|
||||
try {
|
||||
master(password); const record = checkedFile();
|
||||
if (record.format !== FORMAT || typeof record.vaultId !== 'string' || !/^[a-f0-9-]{36}$/.test(record.vaultId)) fail();
|
||||
const salt = decode(record.salt, 32), ciphertext = decode(record.kemCiphertext, 1568);
|
||||
if (salt.length !== 32 || ciphertext.length !== 1568) fail();
|
||||
protectedBytes = unprotect(decode(record.privateKey, 65536));
|
||||
if (!Buffer.isBuffer(protectedBytes) || protectedBytes.length > 65536) fail();
|
||||
derived = crypto.scryptSync(password, salt, 32, SCRYPT);
|
||||
privateDer = open(derived, JSON.parse(protectedBytes.toString('utf8')), FORMAT + ':private:' + record.vaultId);
|
||||
const privateKey = crypto.createPrivateKey({ key: privateDer, format: 'der', type: 'pkcs8' });
|
||||
if (privateKey.asymmetricKeyType !== 'ml-kem-1024') fail();
|
||||
shared = crypto.decapsulate(privateKey, ciphertext); wrap = wrappingKey(shared, record.vaultId);
|
||||
key = open(wrap, record.wrappedKey, header(record)); if (key.length !== 32) fail();
|
||||
plaintext = open(key, record.payload, header(record) + ':' + JSON.stringify(record.wrappedKey));
|
||||
const decoded = JSON.parse(plaintext.toString('utf8'));
|
||||
if (!Array.isArray(decoded.entries) || decoded.entries.length > 1000) fail();
|
||||
const ids = new Set();
|
||||
for (const entry of decoded.entries) {
|
||||
if (typeof entry.id !== 'string' || !/^[a-f0-9-]{36}$/.test(entry.id) || ids.has(entry.id)) fail(); ids.add(entry.id);
|
||||
origin(entry.origin); text(entry.username, 512); text(entry.password, 4096); text(entry.label, 256);
|
||||
}
|
||||
entries = decoded.entries; envelope = { ...record }; delete envelope.payload; touch(); return status();
|
||||
} catch { lock(); fail(); } finally { [derived, protectedBytes, privateDer, shared, wrap, plaintext].forEach(wipe); }
|
||||
}
|
||||
function status() { return { exists: fs.existsSync(filePath), locked: !key, idleSeconds, algorithm: 'AES-256-GCM / ML-KEM-1024 / scrypt / Windows DPAPI' }; }
|
||||
function list() { requireUnlocked(false); return entries.map(({ id, origin, username, label }) => ({ id, origin, username, label })); }
|
||||
function mutate(action) {
|
||||
requireUnlocked(); const previous = entries.map(entry => ({ ...entry }));
|
||||
try { const result = action(); persist(); return result; } catch { entries = previous; fail(); }
|
||||
}
|
||||
function upsert(entry) {
|
||||
return mutate(() => {
|
||||
if (!entry || typeof entry !== 'object') fail();
|
||||
const current = entry.id ? entries.find(item => item.id === entry.id) : null;
|
||||
if (entry.id && !current) fail(); if (!current && entries.length >= 1000) fail();
|
||||
const updated = { id: current?.id || crypto.randomUUID(), origin: origin(entry.origin), username: text(entry.username, 512), password: text(entry.password, 4096), label: text(entry.label ?? '', 256) };
|
||||
if (!updated.password) fail();
|
||||
if (current) entries[entries.indexOf(current)] = updated; else entries.push(updated);
|
||||
return { id: updated.id };
|
||||
});
|
||||
}
|
||||
function remove(id) { return mutate(() => { const index = entries.findIndex(entry => entry.id === id); if (index < 0) fail(); entries.splice(index, 1); return { deleted: true }; }); }
|
||||
function credential(id, targetOrigin) {
|
||||
requireUnlocked(); const expected = origin(targetOrigin); const entry = entries.find(item => item.id === id);
|
||||
if (!entry || entry.origin !== expected) fail();
|
||||
return { username: entry.username, password: entry.password };
|
||||
}
|
||||
return { create, unlock, lock, status, list, upsert, delete: remove, credential, generatePassword };
|
||||
}
|
||||
module.exports = { createVault, generatePassword };
|
||||
@@ -0,0 +1,20 @@
|
||||
'use strict';
|
||||
const {guardedScreenshot}=require('./page-clipboard-guard.cjs');
|
||||
function bounded(operation,ms){let timer;return Promise.race([operation,new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('Visual capture timed out')),ms);})]).finally(()=>clearTimeout(timer));}
|
||||
async function capturePNG(contents,rect){
|
||||
if(rect&&(!['x','y','width','height'].every(key=>Number.isInteger(rect[key]))||rect.x<0||rect.y<0||rect.width<16||rect.height<16||rect.width*rect.height>8*1024*1024))throw Error('Visual capture region invalid');
|
||||
// Use Chromium's current render surface: Windows native capture can return a stale frame.
|
||||
// Share the verified internal page-security connection when present; never detach it.
|
||||
// No debugger endpoint or general command interface is exposed to sites or agents.
|
||||
const parameters={format:'png',fromSurface:true,captureBeyondViewport:false,...(rect?{clip:{...rect,scale:1}}:{})};
|
||||
const owned=guardedScreenshot(contents,parameters);
|
||||
const debuggerAPI=contents.debugger;if(!debuggerAPI||!owned&&debuggerAPI.isAttached())throw Error('Visual capture unavailable');
|
||||
let attached=false;
|
||||
try{
|
||||
if(!owned){debuggerAPI.attach('1.3');attached=true;}
|
||||
const result=await bounded(owned||debuggerAPI.sendCommand('Page.captureScreenshot',parameters),3000);
|
||||
if(typeof result?.data!=='string'||result.data.length>14*1024*1024||!/^[A-Za-z0-9+/]+={0,2}$/.test(result.data))throw Error('Visual capture bytes invalid');
|
||||
return Buffer.from(result.data,'base64');
|
||||
}finally{if(attached)debuggerAPI.detach();}
|
||||
}
|
||||
module.exports={capturePNG};
|
||||
@@ -0,0 +1,80 @@
|
||||
'use strict';
|
||||
// `unknown` is never an approval: callers must hold credential actions until a fresh verdict.
|
||||
function describeDocument(expectedURL){
|
||||
if(location.href!==expectedURL)throw Error('Document changed');
|
||||
const visible=e=>{if(!e||e.closest('[hidden],[aria-hidden="true"],script,style,template,noscript'))return false;let p=e,count=0;while(p&&count++<32){const s=getComputedStyle(p);if(s.display==='none'||s.visibility==='hidden'||s.opacity==='0')return false;p=p.parentElement;}return e.getClientRects().length>0;};
|
||||
const candidates=[];
|
||||
for(const e of document.querySelectorAll('input')){
|
||||
if(candidates.length>=32)break;
|
||||
if(!visible(e)||e.disabled||e.type==='hidden')continue;
|
||||
if(/^(password|email)$/i.test(e.type)||/username|email|login|current-password|new-password/i.test([e.name,e.id,e.getAttribute('autocomplete')].join(' ')))candidates.push(e);
|
||||
}
|
||||
let formRect;
|
||||
if(candidates.length){const container=candidates[0].closest('form')||candidates[0].closest('main,[role="main"]')||candidates[0];const r=container.getBoundingClientRect();const x=Math.max(0,r.x),y=Math.max(0,r.y),width=Math.min(innerWidth,r.right)-x,height=Math.min(innerHeight,r.bottom)-y;if(width>=16&&height>=16)formRect={x,y,width,height};}
|
||||
const walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT);let node,text='',visited=0;
|
||||
while((node=walker.nextNode())&&visited++<20000&&text.length<8000){const e=node.parentElement;if(!e||e.closest('input,textarea,select,[contenteditable],script,style,template,noscript')||!visible(e))continue;const piece=(node.textContent||'').replace(/\s+/g,' ').trim();if(piece)text+=(text?'\n':'')+piece.slice(0,8000-text.length);}
|
||||
return {hasCredentialInputs:candidates.length>0,visibleText:text.slice(0,8000),...(formRect?{formRect}:{})};
|
||||
}
|
||||
const describeScript=expectedURL=>`(${describeDocument.toString()})(${JSON.stringify(expectedURL)})`;
|
||||
const normalize=text=>String(text).normalize('NFKC').toLowerCase().replace(/ß/g,'ss').replace(/ς/g,'σ');
|
||||
function createVisualPhishing({getContext,capture,describe,fingerprint,compare,audit,block}={}){
|
||||
if([getContext,capture,describe,fingerprint,compare,audit,block].some(fn=>typeof fn!=='function'))throw TypeError('Trusted visual phishing callbacks required');
|
||||
let pending=null,generation=0;
|
||||
const snapshot=()=>{const c=getContext();return {...c,url:c?.url||c?.webContents?.getURL(),generation};};
|
||||
const current=initial=>{const c=snapshot();return c.ready&&Number.isFinite(Date.parse(c.policy?.expiresAt))&&Date.parse(c.policy.expiresAt)>Date.now()&&c.policy===initial.policy&&c.webContents===initial.webContents&&c.url===initial.url&&c.navigationId===initial.navigationId&&c.generation===initial.generation;};
|
||||
const result=(initial,decision,reason,ruleId)=>({decision,reason,...(ruleId?{ruleId}:{}),checkedURL:initial.url,policyVersion:initial.policy?.version});
|
||||
async function run(){
|
||||
const initial=snapshot(),settings=initial.policy?.security?.visualPhishing;
|
||||
if(!initial.ready)return result(initial,'unknown','browser-not-ready');
|
||||
if(!Number.isFinite(Date.parse(initial.policy?.expiresAt))||Date.parse(initial.policy.expiresAt)<=Date.now())return result(initial,'unknown','policy-expired');
|
||||
if(!settings?.enabled)return result(initial,'allow','visual-policy-disabled');
|
||||
let pageURL;try{pageURL=new URL(initial.url);if(!['https:','http:'].includes(pageURL.protocol))throw Error();}catch{return result(initial,'unknown','unsupported-url');}
|
||||
const blocked=async(reason,ruleId)=>{
|
||||
if(!current(initial))return result(initial,'unknown','stale-context');
|
||||
await audit({kind:'phishing.block',ruleId:ruleId||'visual-inspection-failed',host:pageURL.hostname});
|
||||
if(!current(initial))return result(initial,'unknown','stale-context');
|
||||
const decision=result(initial,'block',reason,ruleId||'visual-inspection-failed');await block(decision,initial);
|
||||
return decision;
|
||||
};
|
||||
let description;
|
||||
try{description=await describe(initial.url,initial);if(!current(initial))return result(initial,'unknown','stale-context');if(typeof description?.hasCredentialInputs!=='boolean'||typeof description.visibleText!=='string'||description.visibleText.length>8000)throw Error();}
|
||||
catch{if(!current(initial))return result(initial,'unknown','stale-context');return settings.failClosed?blocked('visual-description-unavailable'):result(initial,'unknown','description-unavailable');}
|
||||
if(!description.hasCredentialInputs)return result(initial,'allow','no-credential-inputs');
|
||||
const text=normalize(description.visibleText),captures=new Map(),features=new Map();let comparable=0,unknown=false;
|
||||
try{
|
||||
for(const brand of settings.brands){
|
||||
// Exemption applies only to this brand; other protected brands remain checked.
|
||||
if(pageURL.protocol==='https:'&&brand.allowedOrigins.includes(pageURL.origin))continue;
|
||||
if(!brand.requiredText.every(phrase=>text.includes(normalize(phrase))))continue;
|
||||
for(const reference of brand.references){
|
||||
if(reference.scope==='login-form'&&!description.formRect){unknown=true;continue;}
|
||||
const key=reference.scope;let image;
|
||||
if(captures.has(key))image=captures.get(key);else{image=await capture(reference.scope,description,reference);if(!current(initial))return result(initial,'unknown','stale-context');captures.set(key,image);}
|
||||
if(reference.viewport){
|
||||
if(!Number.isFinite(image.width)||!Number.isFinite(image.height)||image.width<=0||image.height<=0)throw Error('Capture dimensions unavailable');
|
||||
const expected=reference.viewport;
|
||||
if(reference.scope==='viewport'?(image.width!==expected.width||image.height!==expected.height):Math.abs((image.width/image.height)/(expected.width/expected.height)-1)>.1){unknown=true;continue;}
|
||||
}
|
||||
const featureKey=`${reference.scope}:${reference.algorithm}`;let feature;
|
||||
if(features.has(featureKey))feature=features.get(featureKey);
|
||||
else{feature=await fingerprint(image,reference.algorithm);if(!current(initial))return result(initial,'unknown','stale-context');features.set(featureKey,feature);}
|
||||
const hash=typeof feature==='string'?feature:feature?.hash;
|
||||
if(feature?.usable===false||!/^[a-f0-9]{16}$/.test(hash||'')){unknown=true;continue;}
|
||||
const distance=await compare(hash,reference.hash);if(!current(initial))return result(initial,'unknown','stale-context');
|
||||
if(!Number.isInteger(distance)||distance<0||distance>64)throw Error('Comparison unavailable');
|
||||
comparable++;
|
||||
if(distance<=brand.maxDistance)return blocked('credential-brand-impersonation',brand.id);
|
||||
}
|
||||
}
|
||||
}catch{if(!current(initial))return result(initial,'unknown','stale-context');return settings.failClosed?blocked('credential-visual-inspection-failed'):result(initial,'unknown','visual-inspection-failed');}
|
||||
if(unknown)return result(initial,'unknown','reference-not-comparable');
|
||||
return result(initial,'allow',comparable?'no-protected-brand-match':'no-applicable-brand-reference');
|
||||
}
|
||||
function check({force=false}={}){
|
||||
if(pending&&!force)return pending;
|
||||
const prior=pending;const operation=(prior?prior.catch(()=>{}):Promise.resolve()).then(run);
|
||||
pending=operation;operation.finally(()=>{if(pending===operation)pending=null;}).catch(()=>{});return operation;
|
||||
}
|
||||
return {check,reset:()=>{generation++;}};
|
||||
}
|
||||
module.exports={createVisualPhishing,describeScript};
|
||||
@@ -0,0 +1,31 @@
|
||||
# Local inspector capabilities
|
||||
|
||||
All inspection is local. No scan downloads models or calls cloud inference.
|
||||
|
||||
- YARA: genuine pinned yara-python 4.5.4 in private Windows CPython 3.13 runtime. Centrally supplied sources have bounded size and count, includes disabled, imported modules disallowed, compile warnings treated as errors, and five-second match timeout. Rules compile from in-memory strings and scan bytes. Errors, unavailable engine, missing rules and oversize payloads block downloads. Compilation currently runs inside the inspector process; a native compiler hang is bounded by the browser's inspector request deadline but does not restart the inspector automatically.
|
||||
- Text: UTF-8 extraction and deterministic secret/literal/fingerprint classification.
|
||||
- PDF/OOXML: bounded extraction of text and embedded images; full rendered visual coverage is incomplete and outbound DLP fails closed.
|
||||
- Images: bundled RapidOCR 3.8.1 with CPU ONNXRuntime 1.24.4 and packaged PP-OCRv4 detection/recognition plus v2 classification weights. Explicit verified local model paths bypass all model downloads. Real Windows test renders a secret into an image and blocks it with network model downloading forbidden. Models primarily support Chinese/English; other languages and recognition accuracy require validation. OCR completion indicates all frames processed, not guaranteed recognition accuracy. Pillow parsing has dimension/frame limits. Optional `SAFETYNET_OCR_ENGINE=tesseract` uses administrator-installed trusted `TESSERACT_PATH`, optionally `TESSDATA_PREFIX` for local traineddata. Tesseract weights are not bundled. Missing OCR blocks DLP.
|
||||
- Audio/video: recognition of container types only. Transcription and full video inspection are unconfigured; DLP blocks rather than claiming safe coverage.
|
||||
- Semantic adapter: real Google EmbeddingGemma 2, Q8_0 GGUF export revision `bfcd298762cc34d0357ece5ebdd31791a3a374d8`, 768 dimensions. A verified native worker runs explicitly on CPU, reads bounded local NDJSON, and returns actual normalized vectors. Both GGUF weights and native runtime modules must pass size/SHA-256 checks before loading. Configure `EMBEDDING_MODEL_DIR` and `EMBEDDING_WORKER_PATH` to staged, protected local resources. No cloud inference, model download or Python ML framework is used. Text uses the same pinned `document-v1` profile for training and inspection; native token windows must cover exact UTF-8 bytes through the final byte, otherwise scanning fails closed. At most 64 vectors are allowed; incomplete or oversized training is refused. Native image embeddings process all bounded frames resized to at most672px with48px alignment. Native audio supports only mono16kHz PCM16 WAV up to30seconds; other media remains uninspectable. Actual model fixtures must run separately from protocol unit tests to establish operational readiness.
|
||||
- Reference registration: SHA-256 plus normalized eight-word shingle and paragraph hashes, coverage and registrationReady. Every shingle offset is inspected; paragraph normalization uses NFKC, lowercase, sharp-s to ss, final sigma to sigma and collapsed whitespace, shared with JavaScript policy enforcement. Up to 1000 hashes return per registration. Published `fragment-sha256` rules use this same canonicalization and detect excerpts after reformatting. No extracted plaintext returned or logged. Hashes remain sensitive reference metadata and require access control. The whole-file fingerprint is useful even for incomplete extraction, but registrationReady requires complete coverage.
|
||||
|
||||
`POST /scan-download` accepts `{data:base64,policy}` and returns malware-only `{decision,ruleId,engine,sha256,bytes,matchedRules,coverage}`. A YARA allow is not a DLP allow. `/inspect` applies enabled YARA first and then DLP. `/reference-document` accepts `{data:base64}` and returns registration features.
|
||||
|
||||
Dependencies and packaged model distributions are pinned with official PyPI wheel SHA-256 hashes and installed with `--require-hashes`. OCR model license review and a complete third-party software notice are still required before distribution. The Windows broker uses a Job Object limiting its process tree to four processes and 4 GiB, denying breakaway and killing descendants when the owner exits. Document parsing, YARA, OCR and inference now execute in a separate AppContainer per request. The worker verifies its actual AppContainer token and zero capabilities before importing parsers. Documents cross bounded anonymous pipes; source/runtime/model files receive package-SID read/execute access, never writable grants. Network and private-file probes, native child inheritance, deadlines, 16 MiB pipe integrity and orphan termination are exercised in `test_windows_appcontainer.py` and `artifacts/windows-appcontainer-proof.json`.
|
||||
|
||||
The AppContainer can use its Windows-provided private package storage and system resources that Windows exposes to application packages. This is not a promise that no filesystem writes anywhere are possible. Runtime roots and their ancestors reject reparse points; external Python/parser/model overrides are rejected rather than granting access to other projects. An explicit installer/deployment-owner command provisions specific-SID ACLs; normal startup reads native DACLs without changing them. Missing or writable grants fail closed. Packaging provisions the completed copied resources. Protected Program Files operation under a separate standard user remains untested. No capability or loopback exemption is added. Broker startup allows 30 seconds for cold ACL verification; each parser request retains a 29-second worker deadline and 30-second browser request budget. Broker/worker/verifier bytecode writes are disabled so cold startup does not add unapproved files. Independent exploit/bypass assessment remains required.
|
||||
|
||||
The isolated worker uses the bundled RapidOCR path. External Tesseract executables and their deployment environment are not forwarded into this boundary; a future separately validated sandbox configuration is required to support them.
|
||||
|
||||
Training retains only returned fingerprints, fragment hashes and coverage metadata. The inspector does not retain source documents or extracted plaintext, and its HTTP handlers clear transient request references after processing. Python immutable byte/string allocations and native parser/OCR buffers cannot guarantee forensic memory erasure; OS paging and crash dumps need endpoint controls. Text and bundled RapidOCR image training use memory only. Optional Tesseract writes temporary decoded image frames and removes them on normal completion; secure deletion is not guaranteed, so use the default RapidOCR mode for memory-only training.
|
||||
|
||||
Registration reports `extractionComplete` separately from `featuresComplete`, total/retained fragment counts and `fragmentHashesTruncated`. More than 1000 fragment hashes marks coverage incomplete and registrationReady false; a reduced document or separately approved whole-file fingerprint can then be used. A truncated feature set is never presented as complete protection.
|
||||
|
||||
Sources: https://yara.readthedocs.io/en/stable/yarapython.html https://pypi.org/project/yara-python/4.5.4/ https://github.com/RapidAI/RapidOCR https://pypi.org/project/rapidocr/3.8.1/
|
||||
|
||||
Google model sources: https://ai.google.dev/gemma/docs/embeddinggemma/model_card_2 https://ai.google.dev/gemma/docs/embeddinggemma/multimodal-embeddinggemma-with-sentence-transformers and https://huggingface.co/ggml-org/embeddinggemma-2-GGUF . Multimodal vectors support classification signals; unsupported parsing or incomplete extraction never becomes a DLP allow solely because a vector exists. Model similarity is probabilistic and requires customer-specific thresholds and false-negative testing. Native worker errors/timeouts are sanitized and kill the worker; startup readiness must attest CPU and no Vulkan module. Source strings and native memory are transient but cannot be securely erased by this Python adapter.
|
||||
|
||||
Actual Windows CPU validation on 2026-10-12: four real-weight fixtures passed in 25.774 seconds, covering native image embeddings, native mono 16 kHz audio embeddings, multi-window document tail coverage, and a semantic-only policy that blocks a protected paraphrase while allowing unrelated public text. All emitted vectors have 768 finite normalized values. A separate three-document check measured protected-paraphrase cosine 0.861730 versus unrelated-text 0.606880; these are synthetic fixture measurements, not a general accuracy claim. The worker handshake pins the exact hardened build `b11461-no-logits-v1`; policy/runtime manifest field `b11461` identifies its upstream runtime release. Other suffixes are refused, CPU must be attested, and loaded Vulkan is refused.
|
||||
|
||||
Repeat actual fixtures with staged resources using `SAFETYNET_TEST_NATIVE_SEMANTIC=1`, `EMBEDDING_MODEL_DIR`, and `EMBEDDING_WORKER_PATH`, then run the bundled Python `-m unittest discover -s apps/inspector -p test_semantic_real.py -v`. Routine protocol tests do not substitute for these opt-in real-model checks.
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Current-user Windows capability protection; no browser profile crypto state."""
|
||||
import base64
|
||||
import ctypes
|
||||
import sys
|
||||
|
||||
class Blob(ctypes.Structure):
|
||||
_fields_=[('size',ctypes.c_uint32),('data',ctypes.POINTER(ctypes.c_ubyte))]
|
||||
|
||||
def convert(data,protect):
|
||||
crypt=ctypes.WinDLL('crypt32',use_last_error=True)
|
||||
kernel=ctypes.WinDLL('kernel32',use_last_error=True)
|
||||
allocation=(ctypes.c_ubyte*len(data)).from_buffer_copy(data)
|
||||
source=Blob(len(data),allocation);result=Blob()
|
||||
function=crypt.CryptProtectData if protect else crypt.CryptUnprotectData
|
||||
function.argtypes=[ctypes.POINTER(Blob),ctypes.c_void_p,ctypes.c_void_p,ctypes.c_void_p,ctypes.c_void_p,ctypes.c_uint32,ctypes.POINTER(Blob)]
|
||||
function.restype=ctypes.c_int
|
||||
# UI_FORBIDDEN; omit LOCAL_MACHINE so only the signed-in Windows user can decrypt.
|
||||
if not function(ctypes.byref(source),None,None,None,None,1,ctypes.byref(result)):
|
||||
raise ValueError('DPAPI rejected capability')
|
||||
kernel.LocalFree.argtypes=[ctypes.c_void_p];kernel.LocalFree.restype=ctypes.c_void_p
|
||||
try:return ctypes.string_at(result.data,result.size)
|
||||
finally:kernel.LocalFree(result.data)
|
||||
|
||||
if __name__=='__main__':
|
||||
try:
|
||||
if sys.platform!='win32' or len(sys.argv)!=2 or sys.argv[1] not in ['protect','unprotect']: raise ValueError()
|
||||
data=sys.stdin.buffer.read(32769)
|
||||
if not data or len(data)>32768:raise ValueError()
|
||||
if sys.argv[1]=='protect':output=base64.b64encode(convert(data,True))
|
||||
else:output=convert(base64.b64decode(data,validate=True),False)
|
||||
sys.stdout.buffer.write(output)
|
||||
except Exception:
|
||||
sys.stderr.write('Windows capability protection failed.\n');sys.exit(1)
|
||||
@@ -0,0 +1,340 @@
|
||||
"""Private content inspection. No external inference, raw-content logs or remote URLs."""
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unicodedata
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
os.environ['HF_HUB_OFFLINE']='1'
|
||||
os.environ['TRANSFORMERS_OFFLINE']='1'
|
||||
os.environ['HF_HUB_DISABLE_TELEMETRY']='1'
|
||||
|
||||
MAX_BYTES = 20 * 1024 * 1024
|
||||
MAX_TEXT = 1_000_000
|
||||
_MODEL = None
|
||||
_OCR = None
|
||||
|
||||
|
||||
def local_ocr():
|
||||
global _OCR
|
||||
if _OCR is None:
|
||||
import onnxruntime
|
||||
onnxruntime.disable_telemetry_events()
|
||||
import rapidocr
|
||||
from rapidocr import RapidOCR
|
||||
directory=Path(rapidocr.__file__).parent/"models"
|
||||
names={"Det":"ch_PP-OCRv4_det_infer.onnx","Rec":"ch_PP-OCRv4_rec_infer.onnx","Cls":"ch_ppocr_mobile_v2.0_cls_infer.onnx"}
|
||||
checksums={"Det":"d2a7720d45a54257208b1e13e36a8479894cb74155a5efe29462512d42f49da9",
|
||||
"Rec":"48fc40f24f6d2a207a2b1091d3437eb3cc3eb6b676dc3ef9c37384005483683b",
|
||||
"Cls":"e47acedf663230f8863ff1ab0e64dd2d82b838fceb5957146dab185a89d6215c"}
|
||||
params={"Global.log_level":"critical","EngineConfig.onnxruntime.intra_op_num_threads":2,
|
||||
"EngineConfig.onnxruntime.inter_op_num_threads":1}
|
||||
for task,name in names.items():
|
||||
model=directory/name
|
||||
if not model.is_file(): raise ValueError("Bundled offline OCR model unavailable")
|
||||
if hashlib.sha256(model.read_bytes()).hexdigest()!=checksums[task]: raise ValueError("Offline OCR model checksum mismatch")
|
||||
params[task+".model_path"]=str(model)
|
||||
# Explicit paths bypass RapidOCR's download path entirely.
|
||||
_OCR=RapidOCR(params=params)
|
||||
return _OCR
|
||||
|
||||
|
||||
def scan_download(data, policy):
|
||||
"""Malware gate only: never implies document/DLP coverage."""
|
||||
result={"decision":"block","ruleId":"yara-unavailable","engine":"yara",
|
||||
"sha256":hashlib.sha256(data).hexdigest(),"bytes":len(data),
|
||||
"matchedRules":[],"coverage":{"complete":False}}
|
||||
try:
|
||||
config=policy.get("security",{}).get("yara",{})
|
||||
if config.get("enabled") is not True:
|
||||
result["ruleId"]="yara-required";return result
|
||||
maximum=config.get("maxScanBytes",MAX_BYTES)
|
||||
if not isinstance(maximum,int) or maximum<=0 or len(data)>min(maximum,MAX_BYTES):
|
||||
result["ruleId"]="yara-size-limit";return result
|
||||
entries=config.get("rules",[])
|
||||
if not isinstance(entries,list) or not 1<=len(entries)<=128: raise ValueError()
|
||||
sources={};ids={};total=0
|
||||
for index,entry in enumerate(entries):
|
||||
source=entry["source"];identifier=entry["id"]
|
||||
if not isinstance(source,str) or not isinstance(identifier,str) or not identifier: raise ValueError()
|
||||
total+=len(source.encode("utf-8"))
|
||||
if total>262144: raise ValueError()
|
||||
# No imported modules: console can leak scanned content; others expand attack surface.
|
||||
if re.search(r'\bimport\s+"',source): raise ValueError()
|
||||
namespace="policy_"+str(index);sources[namespace]=source;ids[namespace]=identifier
|
||||
import yara
|
||||
rules=yara.compile(sources=sources,includes=False,error_on_warning=True)
|
||||
matches=rules.match(data=data,timeout=5)
|
||||
result["coverage"]["complete"]=True
|
||||
result["matchedRules"]=[{"id":ids[item.namespace],"rule":item.rule} for item in matches]
|
||||
if matches: result["ruleId"]=ids[matches[0].namespace]
|
||||
else: result["decision"]="allow";result["ruleId"]=None
|
||||
except ImportError: pass
|
||||
except Exception: result["ruleId"]="yara-inspection-failed"
|
||||
return result
|
||||
|
||||
|
||||
def reference_document(data):
|
||||
"""Return privacy-preserving registration features, without extracted plaintext."""
|
||||
result={"sha256":hashlib.sha256(data).hexdigest(),"bytes":len(data),
|
||||
"contentType":content_kind(data),"fragmentHashes":[],"coverage":{"complete":False}}
|
||||
text="";media=[];hashes=set()
|
||||
try:
|
||||
if len(data)>MAX_BYTES: raise ValueError()
|
||||
text,complete,media=extract(data,result["contentType"])
|
||||
if len(text)>MAX_TEXT: raise ValueError()
|
||||
hashes=fragment_hashes(text)
|
||||
result["fragmentHashes"]=sorted(hashes)[:1000]
|
||||
truncated=len(hashes)>1000
|
||||
result["coverage"]={"complete":complete and not truncated,"extractionComplete":complete,
|
||||
"featuresComplete":not truncated,"fragmentHashesTruncated":truncated,
|
||||
"fragmentHashesTotal":len(hashes),"fragmentHashesRetained":len(result["fragmentHashes"]),
|
||||
"textCharacters":len(text),"mediaItems":len(media)}
|
||||
result["registrationReady"]=bool(complete and not truncated and (hashes or text.strip()))
|
||||
except Exception: result["registrationReady"]=False
|
||||
finally:
|
||||
# Release transient content references; immutable Python strings cannot be securely wiped.
|
||||
text="";media.clear();hashes.clear();data=None
|
||||
return result
|
||||
|
||||
|
||||
def fragment_hashes(text):
|
||||
normalized=unicodedata.normalize("NFKC",text).lower().replace("ß","ss").replace("ς","σ")
|
||||
words=re.findall(r"\w+",normalized)
|
||||
# Every shingle matches excerpts even when their starting offset changes.
|
||||
hashes={hashlib.sha256(" ".join(words[index:index+8]).encode()).hexdigest()
|
||||
for index in range(max(0,len(words)-7))}
|
||||
for paragraph in re.split(r"\n\s*\n",normalized):
|
||||
paragraph=" ".join(paragraph.split())
|
||||
if len(paragraph)>=32: hashes.add(hashlib.sha256(paragraph.encode()).hexdigest())
|
||||
return hashes
|
||||
|
||||
|
||||
def cosine(left, right):
|
||||
if len(left) != len(right) or not left:
|
||||
raise ValueError("Embedding dimensions do not match")
|
||||
if not all(math.isfinite(float(v)) for v in [*left, *right]):
|
||||
raise ValueError("Invalid embedding")
|
||||
denominator = math.sqrt(sum(v*v for v in left)) * math.sqrt(sum(v*v for v in right))
|
||||
if not denominator:
|
||||
raise ValueError("Empty embedding")
|
||||
return sum(a*b for a, b in zip(left, right)) / denominator
|
||||
|
||||
|
||||
def content_kind(data):
|
||||
if data.startswith(b"%PDF-"): return "pdf"
|
||||
if data.startswith(b"PK\x03\x04"): return "office"
|
||||
if data.startswith(b"\x89PNG\r\n\x1a\n") or data.startswith(b"\xff\xd8\xff") or data.startswith((b"GIF87a", b"GIF89a")): return "image"
|
||||
if data.startswith(b"RIFF"):
|
||||
return {b"WAVE": "audio", b"WEBP": "image", b"AVI ": "video"}.get(data[8:12], "unknown")
|
||||
if data.startswith((b"ID3", b"fLaC", b"OggS")): return "audio"
|
||||
if len(data)>12 and data[4:8] == b"ftyp": return "video"
|
||||
try:
|
||||
text=data.decode("utf-8", errors="strict")
|
||||
if "\x00" in text: return "unknown"
|
||||
return "text"
|
||||
except UnicodeError: return "unknown"
|
||||
|
||||
|
||||
def ocr_image(data):
|
||||
from PIL import Image, ImageSequence
|
||||
image=Image.open(io.BytesIO(data))
|
||||
frames=getattr(image,"n_frames",1)
|
||||
if image.width*image.height>20_000_000 or frames>32 or image.width*image.height*frames>50_000_000:
|
||||
raise ValueError("Image processing budget exceeded")
|
||||
if os.environ.get("SAFETYNET_OCR_ENGINE","rapidocr")=="rapidocr":
|
||||
import numpy as np
|
||||
engine=local_ocr();parts=[]
|
||||
for frame in ImageSequence.Iterator(image):
|
||||
output=engine(np.asarray(frame.convert("RGB")))
|
||||
parts.extend(output.txts or [])
|
||||
return "\n".join(parts),True
|
||||
configured=os.environ.get("TESSERACT_PATH", "")
|
||||
executable=configured if configured and Path(configured).is_file() else shutil.which("tesseract")
|
||||
if not executable: return "", False
|
||||
parts=[]
|
||||
with tempfile.TemporaryDirectory(prefix="safetynet-ocr-") as temp:
|
||||
for index, frame in enumerate(ImageSequence.Iterator(image)):
|
||||
file=Path(temp)/f"frame-{index}.png"
|
||||
frame.convert("RGB").save(file)
|
||||
result=subprocess.run([executable,str(file),"stdout","--psm","3"],capture_output=True,timeout=20,check=True)
|
||||
parts.append(result.stdout.decode("utf-8"))
|
||||
return "\n".join(parts), True
|
||||
|
||||
|
||||
def extract(data, kind):
|
||||
if kind=="text": return data.decode("utf-8"), True, []
|
||||
if kind=="image":
|
||||
text,complete=ocr_image(data)
|
||||
return text,complete,[("image",data)]
|
||||
if kind=="pdf":
|
||||
from pypdf import PdfReader
|
||||
reader=PdfReader(io.BytesIO(data),strict=True)
|
||||
if reader.is_encrypted: raise ValueError("Encrypted PDF cannot be inspected")
|
||||
if len(reader.pages)>100: raise ValueError("PDF page budget exceeded")
|
||||
text=[];complete=True;media=[]
|
||||
for page in reader.pages:
|
||||
value=page.extract_text() or ""
|
||||
text.append(value)
|
||||
images=list(page.images)
|
||||
if not value.strip() and not images: complete=False
|
||||
for image in images:
|
||||
if len(image.data)>MAX_BYTES: raise ValueError("PDF image budget exceeded")
|
||||
scanned,ok=ocr_image(image.data);text.append(scanned);complete=complete and ok
|
||||
media.append(("image",image.data))
|
||||
if len(media)>100: raise ValueError("PDF media budget exceeded")
|
||||
# Attachments, JavaScript, forms, actions and vector drawings need deeper inspection.
|
||||
if page.get("/Annots"): complete=False
|
||||
root=reader.trailer["/Root"]
|
||||
if root.get("/AcroForm") or root.get("/OpenAction") or root.get("/Names"): complete=False
|
||||
# Text extraction is useful, but does not prove all visible/vector PDF content was covered.
|
||||
return "\n".join(text),False,media
|
||||
if kind=="office":
|
||||
from defusedxml.ElementTree import fromstring
|
||||
text=[];media=[];complete=True
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
members=archive.infolist()
|
||||
if len(members)>2000 or sum(x.file_size for x in members)>MAX_BYTES:
|
||||
raise ValueError("Archive expansion budget exceeded")
|
||||
if not any(x.filename in ["word/document.xml","ppt/presentation.xml","xl/workbook.xml"] for x in members):
|
||||
raise ValueError("Only OOXML document archives are supported")
|
||||
for entry in members:
|
||||
if entry.flag_bits&1 or entry.file_size>MAX_BYTES or entry.file_size>max(entry.compress_size,1)*200:
|
||||
raise ValueError("Unsafe document archive")
|
||||
name=entry.filename
|
||||
if any(s in name.lower() for s in ["vbaproject","embeddings/","activex/","customxml/"]): complete=False
|
||||
if name.endswith(".xml"):
|
||||
root=fromstring(archive.read(entry))
|
||||
text.extend(element.text for element in root.iter() if element.tag.rsplit("}",1)[-1] in ["t","v"] and element.text)
|
||||
elif "/media/" in name:
|
||||
payload=archive.read(entry)
|
||||
if content_kind(payload)!="image": complete=False; continue
|
||||
scanned,ok=ocr_image(payload);text.append(scanned);complete=complete and ok;media.append(("image",payload))
|
||||
if len(media)>100: raise ValueError("Office media budget exceeded")
|
||||
# Charts, relationships, equations and rendered layout are not yet fully inspected.
|
||||
return "\n".join(text),False,media
|
||||
if kind in ["audio","video"]: return "",False,[(kind,data)]
|
||||
raise ValueError("Unsupported content type")
|
||||
|
||||
|
||||
def get_model():
|
||||
global _MODEL
|
||||
directory=os.environ.get("EMBEDDING_MODEL_DIR", "")
|
||||
worker=os.environ.get("EMBEDDING_WORKER_PATH", "")
|
||||
if not directory or not Path(directory).is_dir() or not worker: raise ValueError("Verified local EmbeddingGemma 2 runtime is not configured")
|
||||
if _MODEL is None:
|
||||
from semantic import NativeSemanticModel
|
||||
_MODEL=NativeSemanticModel(directory,worker)
|
||||
return _MODEL
|
||||
|
||||
|
||||
def embed(data, kind):
|
||||
model=get_model()
|
||||
if kind=="text": vectors=model.text_vectors(data.decode("utf-8"))
|
||||
elif kind=="image": vectors=model.image_vectors(data)
|
||||
elif kind=="audio": vectors=model.audio_vectors(data)
|
||||
else: raise ValueError("Native semantic modality is not configured")
|
||||
return aggregate_vectors(vectors)
|
||||
|
||||
|
||||
def aggregate_vectors(vectors):
|
||||
from semantic import validate_vectors,DIMENSIONS
|
||||
validate_vectors(vectors)
|
||||
vector=[sum(item[index] for item in vectors)/len(vectors) for index in range(DIMENSIONS)]
|
||||
norm=math.sqrt(sum(value*value for value in vector))
|
||||
if norm<1e-12: raise ValueError("Degenerate semantic aggregate")
|
||||
return [value/norm for value in vector]
|
||||
|
||||
|
||||
def semantic_vector_features(text,media):
|
||||
model=get_model();vectors=model.text_vectors(text) if text.strip() else []
|
||||
text_chunks=len(vectors)
|
||||
for kind,payload in media:
|
||||
if kind=="image": vectors.extend(model.image_vectors(payload))
|
||||
elif kind=="audio": vectors.extend(model.audio_vectors(payload))
|
||||
else: raise ValueError("Native semantic modality is not configured")
|
||||
if len(vectors)>64: raise ValueError("Semantic vector budget exceeded")
|
||||
if not vectors: raise ValueError("No semantic inputs")
|
||||
return vectors,text_chunks
|
||||
|
||||
|
||||
def semantic_vectors(text,media):
|
||||
return semantic_vector_features(text,media)[0]
|
||||
|
||||
|
||||
def embedding_features(data):
|
||||
kind=content_kind(data)
|
||||
if len(data)>MAX_BYTES: raise ValueError("Embedding input budget exceeded")
|
||||
text,complete,media=extract(data,kind)
|
||||
if len(text)>MAX_TEXT: raise ValueError("Extracted text budget exceeded")
|
||||
vectors,text_chunks=semantic_vector_features(text,media)
|
||||
return {**get_model().info(),"vector":aggregate_vectors(vectors),"vectors":vectors,
|
||||
"coverage":{"complete":complete,"extractionComplete":complete,"textChunks":text_chunks,"mediaItems":len(media)}}
|
||||
|
||||
|
||||
def deterministic(text, data, policy):
|
||||
digest=hashlib.sha256(data).hexdigest()
|
||||
normalized=unicodedata.normalize("NFKC",text).casefold()
|
||||
patterns=[("private-key",r"-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----"),
|
||||
("aws-access-key",r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b"),
|
||||
("api-secret",r"\b(?:sk-[A-Za-z0-9_-]{20,}|gh[pousr]_[A-Za-z0-9]{20,})\b")]
|
||||
for rule,pattern in patterns:
|
||||
if re.search(pattern,text): return rule
|
||||
fragments=None
|
||||
for rule in policy["dlp"]["rules"]:
|
||||
if rule["kind"]=="sha256" and rule["value"].lower()==digest: return rule["id"]
|
||||
if rule["kind"]=="literal" and unicodedata.normalize("NFKC",rule["value"]).casefold() in normalized: return rule["id"]
|
||||
if rule["kind"]=="fragment-sha256" and text:
|
||||
if fragments is None: fragments=fragment_hashes(text)
|
||||
if rule["value"].lower() in fragments: return rule["id"]
|
||||
return None
|
||||
|
||||
|
||||
def inspect_content(data, policy, encoder=embed):
|
||||
kind=content_kind(data)
|
||||
result={"decision":"block","ruleId":"uninspectable","contentType":kind,"coverage":{"complete":False},"model":None}
|
||||
if len(data)>min(MAX_BYTES,policy["dlp"]["maxBytes"]): result["ruleId"]="size-limit";return result
|
||||
if policy.get("security",{}).get("yara",{}).get("enabled"):
|
||||
malware=scan_download(data,policy)
|
||||
if malware["decision"]!="allow": return {**result,"ruleId":malware["ruleId"],"malware":malware}
|
||||
# Fingerprints can block any bytes before invoking parsers.
|
||||
rule=deterministic("",data,policy)
|
||||
if rule: result["ruleId"]=rule;return result
|
||||
try:
|
||||
text,complete,media=extract(data,kind)
|
||||
if len(text)>MAX_TEXT: raise ValueError("Extracted text budget exceeded")
|
||||
result["coverage"]={"complete":complete,"textCharacters":len(text),"mediaItems":len(media)}
|
||||
rule=deterministic(text,data,policy)
|
||||
if rule: result["ruleId"]=rule;return result
|
||||
semantic=policy["dlp"].get("semantic",{})
|
||||
if semantic.get("enabled"):
|
||||
if encoder is embed:
|
||||
info=get_model().info()
|
||||
if any(semantic.get(key)!=info[key] for key in ["model","revision","profile","dimensions","artifactFormat","quantization","runtimeRevision"]): raise ValueError("Pinned model profile unavailable")
|
||||
elif semantic.get("model")!="google/embeddinggemma-2" or semantic.get("revision")!=os.environ.get("EMBEDDING_MODEL_REVISION"): raise ValueError("Pinned test model revision unavailable")
|
||||
if not semantic.get("categories"): raise ValueError("Semantic policy has no reference categories")
|
||||
result["model"]=semantic["model"];result["similarity"]=[]
|
||||
if encoder is embed: vectors=semantic_vectors(text,media)
|
||||
else:
|
||||
inputs=[("text",text[i:i+4000].encode()) for i in range(0,len(text),3500)]+media
|
||||
if not inputs or len(inputs)>64: raise ValueError("Inference budget exceeded")
|
||||
vectors=[encoder(payload,modality) for modality,payload in inputs]
|
||||
if sum(len(category.get("prototypes") or [category["prototype"]]) for category in semantic["categories"])>256: raise ValueError("Semantic reference budget exceeded")
|
||||
for category in semantic["categories"]:
|
||||
prototypes=category.get("prototypes") or [category["prototype"]]
|
||||
if not isinstance(category.get("threshold"),(int,float)) or not math.isfinite(category["threshold"]) or not 0<category["threshold"]<=1: raise ValueError("Invalid semantic threshold")
|
||||
scores=[cosine(vector,prototype) for vector in vectors for prototype in prototypes]
|
||||
score=max(scores);result["similarity"].append({"categoryId":category["id"],"score":round(score,6)})
|
||||
if score>=category["threshold"]: result["ruleId"]=category["id"];return result
|
||||
if complete: result["decision"]="allow";result["ruleId"]=None
|
||||
return result
|
||||
except Exception:
|
||||
# Parser/model errors never release content and never serialize raw exception text.
|
||||
result["ruleId"]="inspection-incomplete";return result
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Installer-only ACL provisioning for this exact SafetyNet installation.
|
||||
|
||||
Run with the installed private Python. No caller-controlled grant roots exist.
|
||||
Normal service startup verifies ACLs without changing them.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
sys.dont_write_bytecode=True
|
||||
from windows_appcontainer import AppContainer, SandboxError
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--provision', action='store_true', help='Installer/owner only: establish read/execute ACLs')
|
||||
args = parser.parse_args()
|
||||
source = Path(__file__).absolute().parent
|
||||
development = source.parent.name == 'apps'
|
||||
installation = source.parent.parent if development else source.parent
|
||||
runtime = installation / ('.runtime/windows/python' if development else 'python')
|
||||
semantic = installation / ('.runtime/windows/semantic' if development else 'semantic')
|
||||
if Path(sys.executable).resolve().parent != runtime.resolve():
|
||||
raise SandboxError('Use the private Python belonging to this installation')
|
||||
roots = [source, runtime]
|
||||
if semantic.is_dir(): roots.append(semantic)
|
||||
sandbox = AppContainer(roots, provision=args.provision)
|
||||
print(json.dumps({'verified': True, 'provisioned': args.provision, 'rootCount': len(roots), 'profile': sandbox.name}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try: main()
|
||||
except (SandboxError, OSError):
|
||||
print('Approved installation ACL verification/provisioning failed.', file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,3 @@
|
||||
# Native offline EmbeddingGemma 2 inference uses the verified GGUF worker.
|
||||
# No sentence-transformers, PyTorch or model-download client is needed at runtime.
|
||||
-r requirements.txt
|
||||
@@ -0,0 +1,26 @@
|
||||
certifi==2026.7.22 --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775
|
||||
charset-normalizer==3.5.2 --hash=sha256:195c26fb65950f8fce54e26349852b7bdd7c5f120aeefbcc440b8a20faaed4a3 --hash=sha256:9373ad13ef0d2c0fb761e04e55bfdee5a08b52cef2c882c8fbe9935b1517152e --hash=sha256:ddf19c062bea7a0cc80f519243d2c01dd091be0cf952a0750d4ad576709559f5 --hash=sha256:3d14b50de6bf4d0edf857a9386836846f982b8f524e188e2e68b96d702bcf4aa --hash=sha256:28a15fdad492a99b6eccfaaed66ef3f74050680545ea61ec8b2f4c538f1f1320 --hash=sha256:8a893cc101149f80a653f82062ebc95b34525a2614382e1da5458fe7c6997249 --hash=sha256:619799369eeef6366ed3e8755a5670f4f2f0fb6b30a0fd7264dc0fdc2357058e --hash=sha256:447441e76ec720b15e64418d32e092297340387053047c7c694f579efb0ee1d9 --hash=sha256:62588a277bfb59def052abd940703fa35107152bf479781a878617d60faf8fb5 --hash=sha256:44bd4fbb29dfbeba60e7d2bd000c59e4b21ddb3cc53912b14048d37092706d7c --hash=sha256:30fcd120b732aa79317f08dee04d7de0847822e4cf7ee0e9f445bb958832252c --hash=sha256:50e3adfb96fc189eb27b1cf62d3b598b89b4bb0420d93a3d3e42e137409011be --hash=sha256:b736353c0a625bbd5fcec108576e2385db3496f4f771f785ff32e108d3c3bc45 --hash=sha256:f5833ad231be5eb6553de524a70f48d71b2c8563101750531e0b80184e175cd4 --hash=sha256:1461ac396c4fdb983a675f20aa555624f0ee18ac83d832b9244ffff3d8055275 --hash=sha256:c6708715abcf3c73b99508253e961a9967f02fe536532834149574eda6de0d1c --hash=sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6 --hash=sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5 --hash=sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74 --hash=sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab --hash=sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c --hash=sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f --hash=sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288 --hash=sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400 --hash=sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd --hash=sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37 --hash=sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac --hash=sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a --hash=sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640 --hash=sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d --hash=sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96 --hash=sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1 --hash=sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491 --hash=sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c --hash=sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0 --hash=sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51 --hash=sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5 --hash=sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649 --hash=sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e --hash=sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346 --hash=sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1 --hash=sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875 --hash=sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1 --hash=sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413 --hash=sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869 --hash=sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e --hash=sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc --hash=sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3 --hash=sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5 --hash=sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e --hash=sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a --hash=sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d --hash=sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055 --hash=sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858 --hash=sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234 --hash=sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21 --hash=sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718 --hash=sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4 --hash=sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3 --hash=sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c --hash=sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429 --hash=sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f --hash=sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad710Line truncated
|
||||
colorama==0.4.6 --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
|
||||
colorlog==6.12.0 --hash=sha256:30d392604e9110045a2c2aeefc27d7a017abbab63f3a8aee594eac0801df784e
|
||||
defusedxml==0.7.1 --hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61
|
||||
flatbuffers==25.12.19 --hash=sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4
|
||||
idna==3.20 --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c
|
||||
mpmath==1.3.0 --hash=sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c
|
||||
numpy==2.5.4 --hash=sha256:c6342f54c67093cae5c0227eb0eb772fdb79f2a2c37a6eb278b9909ee06aa356 --hash=sha256:b11e8fda06a7d69f15ebf542660b74466c2e51094800c1fb794f47ad4faeef17 --hash=sha256:9cb18a327b49c5c337f972b03682f6a49855525faaf3c0d3e9c96cd0fd8880a8 --hash=sha256:aec3fc4b32ff82421274f5d205c559c51c840c8df66a78efd7f3612dd005a26a --hash=sha256:fe4d21ab149f15e4e6043dfb0de87e6e5f34ac176cde83060e9802981fca2ac2 --hash=sha256:fbde6962867ee75b48b0ee29b2b9372ec5d617799dbaf38e82dc0596f2f7738a --hash=sha256:381a7a3d2e65e64c0ec302795ab9dc12bb1e73f150904699c153716177eebdaf --hash=sha256:b89d0aaae2fe498c648f4c4795c084db535af5bd98ef942b2a3681fb74ce8645 --hash=sha256:9968ab7e49b93ac6e1c3b2239732183152c9150f16308d30b66a372cffe3483c --hash=sha256:a7b1b6353e36a7e50de2973a38d705c88ee93adcf120673cee7f45a4a3fa223a --hash=sha256:aa1cce2ff3f8d953de38b76bf44602caeb69f101430208f64a10067f7cb4b1d3 --hash=sha256:2377da2dd3ba2c1200956acbab2a358c83b8e1f8531191672d1cd6ad83250d53 --hash=sha256:7415db95818b39ec475a5eea54d9e3b6bc83e3912158e46da3438cdce399804d --hash=sha256:6d6a71b9d9a97c03633aa12565ef2825ffa036cc1d99cfd50dacf0f128af4fe2 --hash=sha256:d8200f16437b289a5bb927c6e184eccc3e8389bc0070fea4cd5b9e13c1757959 --hash=sha256:1c2e71b04c6cad90026e544501bbe0ab9290fa8a4d845e7e8c0d124fb429c988 --hash=sha256:6ffa07666f8da0eef81d149934a626d0d95fbd6838432a33e66245423a9062c0 --hash=sha256:2fa3328f784fc8277fc48026f6cad516f5c561c5d8e2e39b3c9e0c8f23223b34 --hash=sha256:b86966fbe4ad7de710422175572bcdc75fdedadfb54bc6fab7deabccddd7780b --hash=sha256:5258bc06526964be5face2fc6f756857a3f24f21ec3e72ca131337a75b165d6c --hash=sha256:8b4d2fd2d34e5f8c9235ee787de5631a37a28402b15cb80814df973d2be54129 --hash=sha256:bc39ac66a7a9a3fbd6134fda43136b60ffde99c8f4501e64e0d2b24da137babf --hash=sha256:c668b2f0d651605b58892644b0e302c7157f7159544227758c896982ef384b18 --hash=sha256:ffa6ce09a1c6a08e9667dd9c97aa0b14184e8d18f2a14b78b2a2328c9147f076 --hash=sha256:956555e0603a4d38019ae6925711cb9dc43195c076a928accf7ea5d50bddfe53 --hash=sha256:2c2c4afffdeb7920e445028dd71eb932cac3e704792e964bc2a232426d4f1255 --hash=sha256:4054173604cd8658796053f1f3bc0befb68ec1c0762c57fdad61e199256a8617 --hash=sha256:d549420b8858885cea8838a727842249218b9c1da24dd517e25c9c7a948310a3 --hash=sha256:823874a507a84af050493b622affde94b6f7c3a0dc22cb2801381bc03b871c00 --hash=sha256:4e263278bfb5ee6409db8aedbc4cc32973b1b82bc1e8d3c668551d04d83a7e37 --hash=sha256:1ef3aa6d7e29bb13677323114280b05acc57607fa2300e66432d665d5418a162 --hash=sha256:98b053943e5a0474ec0da309d2cb9d3f18ea57f8a2067c2ab7b5f763d1068380 --hash=sha256:b64a85f40e154983960a4167d4c1d57a50c7f109b3d3264a3a984154e90a8454 --hash=sha256:a813ed7719bf45463c51779e6a98d0385fe905e48447526938a4b8337333d551 --hash=sha256:c9b80cdf5cedba0e90d93fa5f9a333c4d65bd545cd669b71bb97ce2b703c9d73 --hash=sha256:2199ed071f460487c8db2c0e5c0b564494190edb4772fe80f9aad88b2604def5 --hash=sha256:64f9c9878c1938476365e11ccfb6b770f3b9e5f045ccddc514235041e6959365 --hash=sha256:64d1c8ac28a4077cf987e0a71a7a0ef7e2df70722f07f0baa42dbb7eb6938647 --hash=sha256:067374eb538c34c745436365cf7b0112595c1d326f21ce4ff340f61230239fbb --hash=sha256:e94aef2c639da4a960ad0db8e06471208d8589974953d78b61d345b4eb99e394 --hash=sha256:cfd73180400042a7c532d30c5e287bdd03c59ff9ee1b4c0316af0539e29dfe23 --hash=sha256:2ca144f15135b6212a5c47b1e2aeca6e412f102f95a2d5d88d8aec77eb255de3 --hash=sha256:468397ba3c64427474706e5c9123fe266395496714dc684294eac75cd4930d1e --hash=sha256:8dddfbee2e68d26d0d7d7d9cb247b1fd4409241cce32d815a11d97ec2cfde179 --hash=sha256:81e3420b27048b65eb14c3acf0c174a8cb0e023277716110347d2dcb26026dad --hash=sha256:0b4724a19de67bea8cfc4970798efa78bcbbe2ac2613cfac16721a42d44de2a5 --hash=sha256:2132418bf8dd124a427ca9e6a1daf9ee1a87185344c95119ceae868b99466da1 --hash=sha256:325518d4245b9e331387702aa58c2ce1dc4cdcbb41dfb4ccd5dcbc7e08db1266 --hash=sha256:56733449d2544178beaa4545cee357370440cf056c197f9c7bfb19dbfdd0e86d --hash=sha256:5ec3753760c1a6d8bb91200666e545c3a9728e6269dfb5d6ce02340996698aa3 --hash=sha256:b1185012870173de7ae33d370bd45b1cf5baee747ea4b97036b65f4e93016877 --hash=sha256:950ea81d57ef070665581b6e1b5f6a029306423cd1739c5b95fe78aa30db6b9d --hash=sha256:c05ede731b03fb1b7591faca9389ade3267d2bddf1ad8882bb3f2cc5e101694f --hash=sha256:5fbf7141bbfd63aea22f435c9062a032b9ea0082fe9845dad7f021d3f1234e71 --hash=sha256:3573cd22564692a5b899ec344e5d5b9cc4576f2985b96f22af3564ed54f2710f --hash=sha256:6c109eac9cd439193678f69d70733c1108487546ca8eafc107b510ae10c1aecd --hash=sha256:80d6ef6e8620eb2c2b4c4caad50b5935d6db3cde2d51581b55dcc79e14016d1d --hash=sha256:77045a4b175bbf5316ec08003880804336c78f92281a1b72222b274ea85ec5ac --hash=sha256:0f02a46e49cfb6c73bdb7aea1c0d3461dbae9aba613542b65f657cd3d17b9fab --hash=sha256:ad62a416ddcf863bf44bba76fbf6b53366ab0692e294f51cae4b5fbe0d246788 --hash=sha256:38f47be9f74ab870d2633b5456ae519c43758a8d1fd05342f0ce4ecc034396ee --hash=sha256:7a14a461d9340f1b46b8648578aed9cdb8b3b018a8fac6c1dde2c9192a01a87f --hash=sha256:298eca75243f2cbbfdb460560b9fb2a1792a33cf2ab4286efd43d92e8d3df508 --hash=shLine truncated
|
||||
omegaconf==2.4.0 --hash=sha256:f141a664c777582405e3cc6e0545ecc79429e79f844995b1a6e71c35946a025c
|
||||
onnxruntime==1.24.4 --hash=sha256:0bdfce8e9a6497cec584aab407b71bf697dac5e1b7b7974adc50bf7533bdb3a2 --hash=sha256:046ff290045a387676941a02a8ae5c3ebec6b4f551ae228711968c4a69d8f6b7 --hash=sha256:e54ad52e61d2d4618dcff8fa1480ac66b24ee2eab73331322db1049f11ccf330 --hash=sha256:b43b63eb24a2bc8fc77a09be67587a570967a412cccb837b6245ccb546691153 --hash=sha256:e26478356dba25631fb3f20112e345f8e8bf62c499bb497e8a559f7d69cf7e7b --hash=sha256:cad1c2b3f455c55678ab2a8caa51fb420c25e6e3cf10f4c23653cdabedc8de78 --hash=sha256:1a5c5a544b22f90859c88617ecb30e161ee3349fcc73878854f43d77f00558b5 --hash=sha256:0d640eb9f3782689b55cfa715094474cd5662f2f137be6a6f847a594b6e9705c --hash=sha256:535b29475ca42b593c45fbb2152fbf1cdf3f287315bf650e6a724a0a1d065cdb --hash=sha256:e6214096e14b7b52e3bee1903dc12dc7ca09cb65e26664668a4620cc5e6f9a90 --hash=sha256:e99a48078baaefa2b50fe5836c319499f71f13f76ed32d0211f39109147a49e0 --hash=sha256:dc4aaed1e5e1aaacf2343c838a30a7c3ade78f13eeb16817411f929d04040a13 --hash=sha256:e30c972bc02e072911aabb6891453ec73795386c0af2b761b65444b8a4c4745f --hash=sha256:1700f559c8086d06b2a4d5de51e62cb4ff5e2631822f71a36db8c72383db71ee --hash=sha256:4c74e268dc808e61e63784d43f9ddcdaf50a776c2819e8bd1d1b11ef64bf7e36 --hash=sha256:3b6ba8b0181a3aa88edab00eb01424ffc06f42e71095a91186c2249415fcff93 --hash=sha256:71d6a5c1821d6e8586a024000ece458db8f2fc0ecd050435d45794827ce81e19 --hash=sha256:fbff2a248940e3398ae78374c5a839e49a2f39079b488bc64439fa0ec327a3e4 --hash=sha256:e2b7969e72d8cb53ffc88ab6d49dd5e75c1c663bda7be7eb0ece192f127343d1 --hash=sha256:14ed1f197fab812b695a5eaddb536c635e58a2fbbe50a517c78f082cc6ce9177 --hash=sha256:aa12ddc54c9c4594073abcaa265cd9681e95fb89dae982a6f508a794ca42e661 --hash=sha256:e1cc6a518255f012134bc791975a6294806be9a3b20c4a54cca25194c90cf731 --hash=sha256:311e309f573bf3c12aa5723e23823077f83d5e412a18499d4485c7eb41040858 --hash=sha256:3f0b910e86b759a4732663ec61fd57ac42ee1b0066f68299de164220b660546d
|
||||
opencv-python==5.0.0.93 --hash=sha256:198a75138241810206a17c829dbcc40a7cb1841cda538ca86cbbfc6c7d95f898 --hash=sha256:6bbc32f59e1b1a7db7b39c81f63d00625f041d333037fd8702f6da52cc39108b --hash=sha256:e2b4272e736836f66c2d176e43ab8101f3a00d45654916399f52e150c58981ac --hash=sha256:f8b6d0a212253dd26ad338c812f1f23ca118fdf05a9c8c6b9444f161aa8c5881 --hash=sha256:08d5d91d967b58d6db86073b2ad3eaef88ca4ebdfd45c9059bf59f5ded0c7ad2 --hash=sha256:c8de2dec111122a02e8beb28e16c31904992dfd6186560b142a92c71403c1039 --hash=sha256:4b4b1a34c79bf8d3738e3cfe9a9e67b51a79663f6b692cbdad8c31f570da4157 --hash=sha256:f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2
|
||||
packaging==26.3 --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
|
||||
pillow==12.3.0 --hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a --hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 --hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f --hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec --hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 --hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed --hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 --hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb --hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f --hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 --hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 --hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd --hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd --hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c --hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 --hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b --hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a --hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 --hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 --hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7 --hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 --hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 --hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c --hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df --hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f --hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 --hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 --hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 --hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace --hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec --hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 --hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 --hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 --hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 --hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a --hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e --hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f --hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 --hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b --hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 --hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 --hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 --hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 --hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 --hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 --hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b --hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 --hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 --hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e --hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 --hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 --hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c --hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d --hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 --hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 --hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c --hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 --hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a --hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d --hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 --hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 --hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 --hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c --hash=Line truncated
|
||||
protobuf==7.36.2 --hash=sha256:cbc70b17ee27e28894c7fee8bb04be1abead49e936bc70eb60052531eee2079e --hash=sha256:e11e1f0180583a2af89db6a2ecd9e8dc40aa6d2988ca175bfd0e6d12ea72d74e --hash=sha256:f4fee11ec330d238b34a05c9b675f693c20415d1c5bd7d5320cc2f8a798eb9cf --hash=sha256:89f23aa53c24553a2416fd4fd1ec06f74fa42b14b546d8883128813f775bbfd2 --hash=sha256:912c1221170e16c08d1f086762f563dd61ff83c18b5fa6652952dfaded66f728 --hash=sha256:a300819d441e078a5608c0d3c709796bb548136058fda017ae51d425b44fd353 --hash=sha256:bdb3a345d48db958e6ce1f18e508beb0cc981d64f24088427549c866cd039f1e
|
||||
pyclipper==1.4.0 --hash=sha256:bafad70d2679c187120e8c44e1f9a8b06150bad8c0aecf612ad7dfbfa9510f73 --hash=sha256:0b74a9dd44b22a7fd35d65fb1ceeba57f3817f34a97a28c3255556362e491447 --hash=sha256:0a4d2736fb3c42e8eb1d38bf27a720d1015526c11e476bded55138a977c17d9d --hash=sha256:b3b3630051b53ad2564cb079e088b112dd576e3d91038338ad1cc7915e0f14dc --hash=sha256:8d42b07a2f6cfe2d9b87daf345443583f00a14e856927782fde52f3a255e305a --hash=sha256:6a97b961f182b92d899ca88c1bb3632faea2e00ce18d07c5f789666ebb021ca4 --hash=sha256:adcb7ca33c5bdc33cd775e8b3eadad54873c802a6d909067a57348bcb96e7a2d --hash=sha256:fd24849d2b94ec749ceac7c34c9f01010d23b6e9d9216cf2238b8481160e703d --hash=sha256:1b6c8d75ba20c6433c9ea8f1a0feb7e4d3ac06a09ad1fd6d571afc1ddf89b869 --hash=sha256:58e29d7443d7cc0e83ee9daf43927730386629786d00c63b04fe3b53ac01462c --hash=sha256:a8d2b5fb75ebe57e21ce61e79a9131edec2622ff23cc665e4d1d1f201bc1a801 --hash=sha256:e9b973467d9c5fa9bc30bb6ac95f9f4d7c3d9fc25f6cf2d1cc972088e5955c01 --hash=sha256:222ac96c8b8281b53d695b9c4fedc674f56d6d4320ad23f1bdbd168f4e316140 --hash=sha256:f3672dbafbb458f1b96e1ee3e610d174acb5ace5bd2ed5d1252603bb797f2fc6 --hash=sha256:d1f807e2b4760a8e5c6d6b4e8c1d71ef52b7fe1946ff088f4fa41e16a881a5ca --hash=sha256:ce1f83c9a4e10ea3de1959f0ae79e9a5bd41346dff648fee6228ba9eaf8b3872 --hash=sha256:3ef44b64666ebf1cb521a08a60c3e639d21b8c50bfbe846ba7c52a0415e936f4 --hash=sha256:d1e5498d883b706a4ce636247f0d830c6eb34a25b843a1b78e2c969754ca9037 --hash=sha256:d49df13cbb2627ccb13a1046f3ea6ebf7177b5504ec61bdef87d6a704046fd6e --hash=sha256:37bfec361e174110cdddffd5ecd070a8064015c99383d95eb692c253951eee8a --hash=sha256:14c8bdb5a72004b721c4e6f448d2c2262d74a7f0c9e3076aeff41e564a92389f --hash=sha256:f2a50c22c3a78cb4e48347ecf06930f61ce98cf9252f2e292aa025471e9d75b1 --hash=sha256:c9a3faa416ff536cee93417a72bfb690d9dea136dc39a39dbbe1e5dadf108c9c --hash=sha256:d4b2d7c41086f1927d14947c563dfc7beed2f6c0d9af13c42fe3dcdc20d35832 --hash=sha256:7c87480fc91a5af4c1ba310bdb7de2f089a3eeef5fe351a3cedc37da1fcced1c --hash=sha256:81d8bb2d1fb9d66dc7ea4373b176bb4b02443a7e328b3b603a73faec088b952e --hash=sha256:773c0e06b683214dcfc6711be230c83b03cddebe8a57eae053d4603dd63582f9 --hash=sha256:9bc45f2463d997848450dbed91c950ca37c6cf27f84a49a5cad4affc0b469e39 --hash=sha256:f160a2c6ba036f7eaf09f1f10f4fbfa734234af9112fb5187877efed78df9303 --hash=sha256:a9f11ad133257c52c40d50de7a0ca3370a0cdd8e3d11eec0604ad3c34ba549e9 --hash=sha256:bbc827b77442c99deaeee26e0e7f172355ddb097a5e126aea206d447d3b26286 --hash=sha256:29dae3e0296dff8502eeb7639fcfee794b0eec8590ba3563aee28db269da6b04 --hash=sha256:0b8c2105b3b3c44dbe1a266f64309407fe30bf372cf39a94dc8aaa97df00da5b --hash=sha256:6c317e182590c88ec0194149995e3d71a979cfef3b246383f4e035f9d4a11826 --hash=sha256:98b2a40f98e1fc1b29e8a6094072e7e0c7dfe901e573bf6cfc6eb7ce84a7ae87
|
||||
pypdf==6.20.1 --hash=sha256:aa5a55ddcffdc5e5ab291d5decb23f6383f4e56f8e3263dc39af41fff03885ad
|
||||
PyYAML==6.0.3 --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b --hash=sLine truncated
|
||||
rapidocr==3.8.1 --hash=sha256:650044b1fbce9e6bae5cae462dcf8be754cde11e2f23fc51f65dcc08deae2c46
|
||||
requests==2.34.2 --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0
|
||||
shapely==2.2.0 --hash=sha256:596b7994ceafa526b6e0522ca29fbc41d19f86459161d6efe1f251d0acd49f3f --hash=sha256:7c0b262116bb75b86751440b42e19673911bc0a8f0d5ce723ce294c3d6e4d5c0 --hash=sha256:7765e0e5d51d63eae0a911861cbda87165a01677bc9bce6ed20d06858ccde99f --hash=sha256:5d61088e2ef71dafad0dd4fae8a521cc1f20da4a89d3096bab5b3260b39b3052 --hash=sha256:0edec813c81effaf4e20c18b1aa86827925ce27c0315621f2a1a080e22e0de5e --hash=sha256:8d6ffe94710f37535a47161120cd5f7f0f0d9bb800c2fddebbd089cb7f1b3453 --hash=sha256:ce858295be3947143a3f44f145fa6dbacd5dcc5c4103801d42cd3be4a2034614 --hash=sha256:806d399418b23eee7241736d572ad1e0b784782f9241d7c8e2cfceb00787831d --hash=sha256:5b740c9a197e5feb30bdc6e64a5eb3ca2a7324d11498844136dfc317daac6a99 --hash=sha256:626fe4c0d32860a98e75ecffabf5a62254c6168eac96b633ad313cd62a38bb2b --hash=sha256:c36ccbff5c3374c349c370bfdac22c7676b268b4a707c98e9031f498965aa02d --hash=sha256:a9a380624cdd7a7e661bf15a4d1625082766f07ccd2540cb0a9e0df1ad4f6c11 --hash=sha256:650a5f4d8a8e3c96982079d8c99b6ddbe6602bbd1e34c75c2b95dbc0d28ac997 --hash=sha256:a851e077f0f02a3383923e02eca5447a29ddbf234e39593b91c8b7ac75218133 --hash=sha256:dc5faa593948aa64d9afae48331b80f43f7aacc68425d99064a4d6772f53f1ad --hash=sha256:da47a0cc9e630b4dff0db46e8972b29d2d27f337425ce9d4c77fd046ce48eabd --hash=sha256:90895df6542ae039fc6557dec6194e3509e883fbd6f5788e3c3e7a38fe46b257 --hash=sha256:7cf5b3a801b9b4febf774efde2e31280e647388deae8452693d8e6420b3a1ff2 --hash=sha256:c037369c35510f51100dd6d386ee3203bac32f164d53e27ca12c3cea5bb643b1 --hash=sha256:d75957716368f919c63016dae1977a0d007e15f06861cd178701edb91b08d2b0 --hash=sha256:4ed79beb8d4b6cc7c67780fd381feed25848a5f9b8a2385ac5711eccd115647a --hash=sha256:f340e7f99aaee3df5acd6b247cddf723051a7c93d1e1ef09025b80d84e4c0ded --hash=sha256:17434cb9819c9974c3331333a3b878fa5bf8f85dd69cc3fb7ff5d260f6fbc102 --hash=sha256:b2338ac40e6652c8bfb857936ea9be9a16f43a362c6f67eb3bad741b05fd5683 --hash=sha256:40871d7135cd723f965d200181aa28418e9ec029fd85bdd010488259d1c01906 --hash=sha256:1eaa2cb64cdedaf65d6bc86f2819c9cd7d6d68f969aa3ebfdc93743ab581f437 --hash=sha256:f79b3b34ad2d067207f21f821489c720b14ce40f3bfda931987a193165f80133 --hash=sha256:000c0ce2a3ba49427e6288b7add9de5d8525d4e65d6ebc8840103040d4d57b86 --hash=sha256:0a63e6b68ec785ef3aae3935c4aa9fb8edccced94e23c79d5d85276442c60859 --hash=sha256:770d4db5cf0bfeed931a1c4aaf4f4eadad0f43f5fc72c27c88fe1f07904ae767 --hash=sha256:74f4313af38d6e49ea83532d6cedfb4fe5e6c5485d7c40202bd61b19d6ff09bf --hash=sha256:9ee11aeba1759d15a525ded58e17916d3edfa60d52110fd8df6a7609a871f066 --hash=sha256:24b175c570efc91d1180ac6cd527dc80e863bb7de37f8b2771703d822c65e023 --hash=sha256:c2915ae1b858e73d5832be7fb5e89497cc5140fa505da40a45223029dc6deace --hash=sha256:74028f468e05e461b30a479b08c1fb5094fa45062abeeec8e7905a6711761436 --hash=sha256:6ec5178a39803fa8626322f69d298037f182461dd28e3ae96c2c7a4309a6bf30 --hash=sha256:593e51cd04fe1122f1ab3fae87b306c36b2be0184a5e0d9c26849c55ff4580dc --hash=sha256:3575a323b7665d7a2e391b16a626caa6b6f6348f399183aca3fc656febd7cf04 --hash=sha256:776cc8571d53e42be8fa6d42ad52a599b8e2186dd0c752922831508099af71e2 --hash=sha256:f8cd733a66a2a10f461a70dde9fad7b2b62c6a48c7a66cea57ee6f1cd9f2bd2f --hash=sha256:7f68c1fbacab81c0c066d1c3051eeb0f680b7a7a2c511e741f77741640187896 --hash=sha256:9147ebc3b116a0511dca043937f85caf1a41690815643d5b89c8bc472f51c850 --hash=sha256:4e5830637c080bdc646c5982ad6f7cc296b93038879649f7a6acd8e0f1c4db04 --hash=sha256:48dd1d961391f314ab7fa8812c86ca2a727bee2bdca1478730eacaea007da18e --hash=sha256:c4127c064bc71f8b7f9b3f341d6627ed39977fd0b61a17c68d09179f5e0089ae --hash=sha256:715561ceda03b09ca1c6baf9922179392d8c2bc53a1b877965225f0dfb487a58 --hash=sha256:556f20346a7d96fefbb71b74640d84ca14041703d60f0d2ff47b29d9b3e0093d --hash=sha256:ff9e87b534edf35af65758fafb31ad3b797354cba9323899e263f450c69a2ff2 --hash=sha256:fdb599ec540cea5b635ac47bf24fca4cdfd1c39730ffc0b6cf0d2666b0dd9a33 --hash=sha256:b8cb04906b74db26f848f76744fa995cd6abeae9145d27cc405277de1f949660 --hash=sha256:d9b11d712ac72f1d869f2b6964dea5bd9f20b89901adcd796d6712496144ab22 --hash=sha256:c88b21a0e9599ebb741e08f71a95c8f07a434af909efb088828a9874d234d06d --hash=sha256:cbe184e1946cfe115a9dfeadd2effd88ab4a237ab1a4335d106defa80fbc2d82 --hash=sha256:8bc985ad731da2f2cedde9c3cfb3c3d946fe6fc63d2ca557673dc33dd1e389b9 --hash=sha256:c3caa4c6308e7eaf18f4661134a1575eb290a56df78d0ae1b02f919a4cc7bd9d --hash=sha256:2fd87e55d7a7d310553b527378545cdc6ef8702473ed9294926b892c3cfb2ba0 --hash=sha256:7416db8ff3a1003687d4118e741343b3cf9ac2a4a925a59d44d98a865ac4e9e7 --hash=sha256:778421a19085bef1fb38bc0699db1ee9b08fdd0e30a8768788d601a4371f2de0 --hash=sha256:287ec7602f7a114b862ae0123880e57160cebe059843a4c7028aaee9e74287f6 --hash=sha256:e414c78bc81aadd76a429111a350f4ef3d05fc13019805617b524951258468e5 --hash=sha256:1af6935acde1db0b6a1bcbea30cbad5ae900723dfd398367ae1488470dc53667 --hash=sha256:96e5101ad2d73df869255bae4c55537f372d32066e2328c376e09841f0f66800 --hash=sha256:446b2d5a323bddd1c2a27f41325fdb3a3e8e33c1f8f0f840bdb63e8c1515b29e
|
||||
six==1.17.0 --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274
|
||||
sympy==1.14.0 --hash=sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5
|
||||
tqdm==4.70.1 --hash=sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73
|
||||
urllib3==2.8.0 --hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3
|
||||
yara-python==4.5.4 --hash=sha256:721d341bd2013fbada4df5aba0eb79a9e4e21c4b86441f7f111ab8c31671f125 --hash=sha256:c785fd16475f2a3191cd4fae0cd608b1ba6271f495ec86fa26a3c5ac53f5f2e1 --hash=sha256:5eefa3b157cd5f4454a317907bab334036f61385324b70cb61dbc656c44168d5 --hash=sha256:bbc0c5a2ee67e6043e4c2622093ebfc7d2c173dc643dd089742aedbdea48d6a4 --hash=sha256:1b091f1bd6c5d2a9b5c0c682ba67ba31b87bb71b27876430775998b71c8e3f97 --hash=sha256:91276c90bb5e148e10050015fec8a1d4009a95eee9eb832d154f80355d0b4080 --hash=sha256:7e20e1f69b6239fe4f4da97e9ff361d9be25d6f1d747589ea44b8a9ec412a12d --hash=sha256:a83773b561727fc360f7a6874f7fac1409bc9c391134dc3e070f1c2515c0db98 --hash=sha256:14f203bd9fb33ad591e046429560133127fa4a6201dac28c525fa7c6c7ca36a7 --hash=sha256:e632daf9f38f8d4b433f08f798b07a45756e6c396e9e0ec54aac10045f6d241d --hash=sha256:a3866830f7f2d071f94cbce7c41d91444ac29e2cbbe279914abf518d57a2d41f --hash=sha256:135c1097ea0445a323038acd509162675ce6d5e21f848aa856779632d48dec42 --hash=sha256:e3e2a5575d61adc2b4ff2007737590783a43d16386b061ac12e6e70a82e5d1de --hash=sha256:f79a27dbdafb79fc2dc03c7c3ba66751551e3e0b350ab69cc499870b78a6cb95 --hash=sha256:9d9acf6f8135bcee03f47b1096ad69f4a2788abe37dd070aab6e9dd816742ecc --hash=sha256:0e762e6c5b47ddf30b0128ba723da46fcc2aa7959a252748497492cb452d1c84 --hash=sha256:adcfac4b225e76ab6dcbeaf10101f0de2731fdbee51610dbc77b96e667e85a3a --hash=sha256:a82c87038f0da2d90051bfd6449cf9a4b977a15ee8372f3512ce0a413ef822fd --hash=sha256:1a1721b61ee4e625a143e8e5bf32fa6774797c06724c45067f3e8919a8e5f8f3 --hash=sha256:57d80c7591bbc6d9e73934e0fa4cbbb35e3e733b2706c5fd6756edf495f42678 --hash=sha256:3872b5f5575d6f5077f86e2b8bcdfe8688f859a50854334a4085399331167abc --hash=sha256:fd84af5b6da3429236b61f3ad8760fdc739d0e1d6a08b8f3d90cd375e71594df --hash=sha256:491c9de854e4a47dfbef7b3a38686c574459779915be19dcf4421b65847a57ce --hash=sha256:2a1bf52cb7b9178cc1ee2acd1697a0c8468af0c76aa1beffe22534bd4f62698b --hash=sha256:ade234700c492bce0efda96c1cdcd763425016e40df4a8d30c4c4e6897be5ace --hash=sha256:e1dedd149be61992781f085b592d169d1d813f9b5ffc7c8c2b74e429b443414c --hash=sha256:92b233aae320ee9e59728ee23f9faf4a423ae407d4768b47c8f0e472a34dbae2 --hash=sha256:1f238f10d26e4701559f73a69b22e1e192a6fa20abdd76f57a7054566780aa89 --hash=sha256:d29d0e137e0d77dd110186369276e88381f784bdc45b5932a2fb3463e2a1b1c7 --hash=sha256:e7d0039d734705b123494acad7a00b67df171dd5b1c16ff7b18ff07578efd4cd --hash=sha256:e5eae935b05a9f8dc71df55a79c38f52abd93f8840310fe4e0d75fbd78284f24 --hash=sha256:30fc7959394532c6e3f48faf59337f5da124f1630668258276b6cfa54e555a6e --hash=sha256:e6d8c2acaf33931338fdb78aba8a68462b0151d833b2eeda712db87713ac2abf --hash=sha256:a3c7bc8cd0db5fb87ab579c755de83723030522f3c0cd5b3374044055a8ce6c6 --hash=sha256:d12e57101683e9270738a1bccf676747f93e86b5bc529e7a7fb7adf94f20bd77 --hash=sha256:bf14a8af06b2b980a889bdc3f9e8ccd6e703d2b3fa1c98da5fd3a1c3b551eb47 --hash=sha256:fe8ad189843c729eae74be3b8447a4753fac2cebe705e5e2a7280badfcc7e3b4 --hash=sha256:94e290d5035be23059d0475bff3eac8228acd51145bf0cabe355b1ddabab742b --hash=sha256:4537f8499d166d22a54739f440fb306f65b0438be2c6c4ecb2352ecb5adb5f1c --hash=sha256:ab5133a16e466db6fe9c1a08d1b171013507896175010fb85fc1b92da32e558c --hash=sha256:93f5f5aba88e2ed2aaebfbb697433a0c8020c6a6c6a711e900a29e9b512d5c3a --hash=sha256:473c52b53c39d5daedc1912bd8a82a1c88702a3e393688879d77f9ff5f396543 --hash=sha256:9d9a58b7dc87411a2443d2e0382a111bd892aef9f6db2a1ebb4a9215eef0db71 --hash=sha256:0b9de86fbe8a646c0644df9e1396d6941dc6ed0f89be2807e6c52ab39161fd9f --hash=sha256:973f0bc24470ac86b6009baf2800ad3eadfa4ab653b6546ba5c65e9239850f47 --hash=sha256:cb0f0e7183165426b09e2b1235e70909e540ac18e2c6be96070dfe17d7db4d78 --hash=sha256:7707b144c8fcdb30c069ea57b94799cd7601f694ba01b696bbd1832721f37fd0 --hash=sha256:5f1288448991d63c1f6351c9f6d112916b0177ceefaa27d1419427a6ff09f829 --hash=sha256:7205c36a6798251925e4c1763eba0737fec7a95360df8aaa4e74e2f2f6b5cdcf --hash=sha256:9c77711a4e469ea97bdd16e85b5ab0720d6a481862a540ea7216897a10e93d19 --hash=sha256:9e6f0ca64cf9b0125be8fe8b821ba7e0f80427bcee136f83914dff0d81b4f27a --hash=sha256:8caad9de64dc4fc9614f331a04ea220d57aea3fbf997f3e23a298ee67cf4a69c --hash=sha256:9d5fcf99187cb6ec0a27c755aec22774a1ea578fdc2a5734f484c601de4ad6c0 --hash=sha256:bdbb62003cced7739d5c98c5af7a08c819baedf12e09276b2bbf0f3cb47828af --hash=sha256:5f901ec61db76a326f77882c8000139b29f218a7c6b8dd997195bab265602345 --hash=sha256:99f8d1145c11f61340fcd19fd6f3f3ef6306910829f4218daece45e831ceb54e --hash=sha256:da826f12c6fa459c6b2f9e7dff3a57416ac3a6536264f7a10d1ff839d4bc943f --hash=sha256:ebacf0b3325b3fb6f15bc3526f39f39c3576bafa4857493cc90afd2651ec8d36 --hash=sha256:e6eba7d4387f8123dd69852ba6776799150c4019fcb3e1212a3b053d42e151ab --hash=sha256:9addd1d6fe9d3b1efe40c7a37fa5d88fe6cd7e22c7e454617beb382c9c74b11b
|
||||
@@ -0,0 +1,6 @@
|
||||
pypdf>=6,<8
|
||||
Pillow>=12,<14
|
||||
defusedxml>=0.7,<1
|
||||
yara-python==4.5.4
|
||||
rapidocr==3.8.1
|
||||
onnxruntime==1.24.4
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Single-request parser entrypoint, launched only inside the AppContainer."""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def attest_appcontainer():
|
||||
import ctypes
|
||||
from ctypes import wintypes as W
|
||||
if sys.platform != 'win32': raise RuntimeError('Windows AppContainer required')
|
||||
kernel=ctypes.WinDLL('kernel32',use_last_error=True)
|
||||
advapi=ctypes.WinDLL('advapi32',use_last_error=True)
|
||||
kernel.GetCurrentProcess.restype=W.HANDLE
|
||||
kernel.CloseHandle.argtypes=[W.HANDLE]
|
||||
advapi.OpenProcessToken.argtypes=[W.HANDLE,W.DWORD,ctypes.POINTER(W.HANDLE)]
|
||||
advapi.GetTokenInformation.argtypes=[W.HANDLE,ctypes.c_int,ctypes.c_void_p,W.DWORD,ctypes.POINTER(W.DWORD)]
|
||||
token=W.HANDLE();value=W.DWORD();length=W.DWORD()
|
||||
if not advapi.OpenProcessToken(kernel.GetCurrentProcess(),8,ctypes.byref(token)):
|
||||
raise RuntimeError('Process token unavailable')
|
||||
try:
|
||||
if not advapi.GetTokenInformation(token,29,ctypes.byref(value),4,ctypes.byref(length)) or value.value!=1:
|
||||
raise RuntimeError('Uncontained parser process rejected')
|
||||
# TokenCapabilities is TOKEN_GROUPS; no capability SID may be enabled.
|
||||
advapi.GetTokenInformation(token,30,None,0,ctypes.byref(length))
|
||||
if not 4<=length.value<=65536: raise RuntimeError('Capability token unavailable')
|
||||
capabilities=ctypes.create_string_buffer(length.value)
|
||||
if not advapi.GetTokenInformation(token,30,capabilities,length.value,ctypes.byref(length)) or ctypes.cast(capabilities,ctypes.POINTER(W.DWORD)).contents.value!=0:
|
||||
raise RuntimeError('Unexpected parser process capability')
|
||||
finally:kernel.CloseHandle(token)
|
||||
|
||||
|
||||
def main():
|
||||
attest_appcontainer()
|
||||
# Preserve only the broker response pipe; parser/native diagnostics cannot
|
||||
# contaminate the protocol or leak document content through stderr.
|
||||
response = os.fdopen(os.dup(sys.stdout.fileno()), 'w', encoding='utf-8', buffering=1)
|
||||
sink = os.open(os.devnull, os.O_WRONLY)
|
||||
os.dup2(sink,sys.stdout.fileno());os.dup2(sink,sys.stderr.fileno());os.close(sink)
|
||||
# No socket listener, deployment credential or filesystem document input.
|
||||
from engine import MAX_BYTES, inspect_content, scan_download, reference_document, embedding_features
|
||||
request = json.loads(sys.stdin.buffer.read(MAX_BYTES*2+1))
|
||||
data = base64.b64decode(request['data'], validate=True)
|
||||
if len(data)>MAX_BYTES: raise ValueError('Document exceeds inspection budget')
|
||||
route = request['route']
|
||||
if route == '/inspect': result = inspect_content(data, request['policy'])
|
||||
elif route == '/scan-download': result = scan_download(data, request['policy'])
|
||||
elif route == '/reference-document': result = reference_document(data)
|
||||
elif route == '/embed': result = embedding_features(data)
|
||||
else: raise ValueError('Unknown inspection operation')
|
||||
response.write(json.dumps(result, allow_nan=False))
|
||||
response.flush()
|
||||
response.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try: main()
|
||||
except Exception: sys.exit(2)
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Pinned offline EmbeddingGemma 2 GGUF worker. No downloads or HTTP inference."""
|
||||
import atexit
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import wave
|
||||
|
||||
MODEL_ID="google/embeddinggemma-2"
|
||||
REVISION="bfcd298762cc34d0357ece5ebdd31791a3a374d8"
|
||||
DIMENSIONS=768
|
||||
PROFILE="document-v1"
|
||||
RUNTIME_REVISION="b11461"
|
||||
RUNTIME_BUILD="b11461-no-logits-v1"
|
||||
MAX_CHUNKS=64
|
||||
MAX_LINE=4*1024*1024
|
||||
MAX_REQUEST=16*1024*1024
|
||||
MODEL_FILES={
|
||||
"embeddinggemma-2-Q8_0.gguf":(309855456,"2188ac1deca4b77dffefd603c2776a9d76d9d74ec01841392982ebb840b09135"),
|
||||
"mmproj-embeddinggemma-2-Q8_0.gguf":(554821024,"c4a8a52691ecef40618438928bdf9e68379b854e24166f292592353db0aab64f")}
|
||||
|
||||
|
||||
def verify_file(directory,name,size,digest):
|
||||
if not isinstance(name,str) or not name or Path(name).is_absolute() or ".." in Path(name).parts: raise ValueError("Invalid local model path")
|
||||
file=(directory/name).resolve()
|
||||
if not file.is_relative_to(directory) or not file.is_file() or file.stat().st_size!=size: raise ValueError("Local semantic artifact unavailable")
|
||||
if not re.fullmatch(r"[0-9a-f]{64}",digest): raise ValueError("Invalid artifact digest")
|
||||
calculated=hashlib.sha256()
|
||||
with file.open("rb") as source:
|
||||
while part:=source.read(1024*1024): calculated.update(part)
|
||||
if calculated.hexdigest()!=digest: raise ValueError("Local semantic artifact verification failed")
|
||||
return file
|
||||
|
||||
|
||||
def validate_vectors(vectors,expected=None):
|
||||
if not isinstance(vectors,list) or not 1<=len(vectors)<=MAX_CHUNKS or expected is not None and len(vectors)!=expected: raise ValueError("Invalid semantic vector batch")
|
||||
for vector in vectors:
|
||||
if not isinstance(vector,list) or len(vector)!=DIMENSIONS or any(not isinstance(value,(int,float)) or isinstance(value,bool) or not math.isfinite(value) for value in vector): raise ValueError("Invalid semantic output")
|
||||
norm=math.sqrt(sum(value*value for value in vector))
|
||||
if not 0.99<=norm<=1.01: raise ValueError("Semantic output is not normalized")
|
||||
return vectors
|
||||
|
||||
|
||||
def validate_readiness(ready):
|
||||
if not isinstance(ready,dict) or ready.get("ready") is not True or ready.get("model")!=MODEL_ID or ready.get("dimensions")!=DIMENSIONS or ready.get("runtime")!=RUNTIME_BUILD or ready.get("device")!="cpu" or ready.get("vulkan_module_loaded") is not False:
|
||||
raise ValueError("Local semantic worker identity mismatch")
|
||||
|
||||
|
||||
class NativeSemanticModel:
|
||||
def __init__(self,directory,worker_path):
|
||||
self.directory=Path(directory).resolve(); self.worker_path=Path(worker_path).resolve()
|
||||
manifest_path=self.directory/"model-manifest.json"
|
||||
if not manifest_path.is_file() or manifest_path.stat().st_size>65536: raise ValueError("Local model manifest unavailable")
|
||||
manifest=json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
if manifest.get("model_id")!=MODEL_ID or manifest.get("revision")!=REVISION or manifest.get("dimensions")!=DIMENSIONS or manifest.get("quantization")!="Q8_0" or manifest.get("runtime_tag")!=RUNTIME_REVISION: raise ValueError("Pinned Google model identity mismatch")
|
||||
self.model=verify_file(self.directory,*next((name,*value) for name,value in MODEL_FILES.items() if not name.startswith("mmproj")))
|
||||
self.projector=verify_file(self.directory,*next((name,*value) for name,value in MODEL_FILES.items() if name.startswith("mmproj")))
|
||||
runtime_path=self.worker_path.parent/"runtime-manifest.json"
|
||||
if not runtime_path.is_file() or runtime_path.stat().st_size>65536: raise ValueError("Verified native runtime manifest unavailable")
|
||||
runtime=json.loads(runtime_path.read_text(encoding="utf-8")); files=runtime.get("files")
|
||||
if not isinstance(files,list) or not 1<=len(files)<=64 or not any(item.get("path")==self.worker_path.name for item in files): raise ValueError("Invalid native runtime manifest")
|
||||
for item in files: verify_file(self.worker_path.parent,item["path"],item["bytes"],item["sha256"])
|
||||
# All DLLs load from the private, verified runtime directory.
|
||||
verified={item["path"].casefold() for item in files}
|
||||
if any(file.name.casefold() not in verified for file in self.worker_path.parent.glob("*.dll")): raise ValueError("Unverified runtime module")
|
||||
self.lock=threading.Lock();self.process=None;self.responses=None
|
||||
atexit.register(self.close)
|
||||
|
||||
def info(self):
|
||||
return {"model":MODEL_ID,"revision":REVISION,"dimensions":DIMENSIONS,"profile":PROFILE,"artifactFormat":"gguf","quantization":"Q8_0","runtimeRevision":RUNTIME_REVISION,"local":True,"backend":"native-gguf","device":"cpu","modalities":["text","image","audio"]}
|
||||
|
||||
def close(self):
|
||||
process=self.process;self.process=None
|
||||
if process is not None:
|
||||
try: process.kill();process.wait(timeout=3)
|
||||
except Exception: pass
|
||||
for stream in (process.stdin,process.stdout):
|
||||
try: stream.close()
|
||||
except Exception: pass
|
||||
|
||||
def _read(self,process,responses):
|
||||
try:
|
||||
while True:
|
||||
line=process.stdout.readline(MAX_LINE+1)
|
||||
if not line or len(line)>MAX_LINE or not line.endswith(b"\n"): raise ValueError()
|
||||
response=json.loads(line)
|
||||
responses.put(response,timeout=1)
|
||||
except Exception:
|
||||
try: responses.put({"protocolFailure":True},timeout=1)
|
||||
except Exception: pass
|
||||
|
||||
def _receive(self,timeout):
|
||||
try:
|
||||
response=self.responses.get(timeout=timeout)
|
||||
if not isinstance(response,dict) or response.get("protocolFailure"): raise ValueError()
|
||||
return response
|
||||
except Exception:
|
||||
self.close();raise ValueError("Local semantic worker failed or timed out") from None
|
||||
|
||||
def _start(self):
|
||||
if self.process is not None and self.process.poll() is None: return
|
||||
self.close();self.responses=queue.Queue(maxsize=2)
|
||||
environment={key:value for key,value in os.environ.items() if key.upper() in {"SYSTEMROOT","WINDIR","TEMP","TMP"}}
|
||||
environment.update({"SEARCHI_FORCE_CPU":"1","GGML_CPU_ONLY":"1","GGML_DISABLE_VULKAN":"1"})
|
||||
self.process=subprocess.Popen([str(self.worker_path),"--model",str(self.model),"--threads","2","--device","cpu"],
|
||||
cwd=str(self.worker_path.parent),stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,
|
||||
env=environment,creationflags=getattr(subprocess,"CREATE_NO_WINDOW",0))
|
||||
threading.Thread(target=self._read,args=(self.process,self.responses),daemon=True).start()
|
||||
ready=self._receive(20)
|
||||
try: validate_readiness(ready)
|
||||
except Exception: self.close();raise
|
||||
|
||||
def _request(self,request,timeout=30):
|
||||
payload=json.dumps(request,ensure_ascii=False,allow_nan=False).encode("utf-8")+b"\n"
|
||||
if len(payload)>=MAX_REQUEST: raise ValueError("Semantic request budget exceeded")
|
||||
with self.lock:
|
||||
self._start()
|
||||
process=self.process
|
||||
# A blocked pipe write is also bounded and tears down the process.
|
||||
timer=threading.Timer(timeout,lambda:process.kill() if process.poll() is None else None);timer.daemon=True;timer.start()
|
||||
try:
|
||||
process.stdin.write(payload);process.stdin.flush();response=self._receive(timeout)
|
||||
if response.get("ok") is not True or response.get("device")!="cpu": raise ValueError("Local semantic inference failed")
|
||||
return response
|
||||
except Exception:
|
||||
self.close();raise ValueError("Local semantic inference failed") from None
|
||||
finally: timer.cancel();payload=None
|
||||
|
||||
def text_vectors(self,text):
|
||||
if not isinstance(text,str) or not text.strip() or len(text.encode("utf-8"))>1024*1024: raise ValueError("Semantic text budget exceeded")
|
||||
encoded=text.encode("utf-8");windows=self._request({"op":"windows","text":text,"title":"none"}).get("windows")
|
||||
if not isinstance(windows,list) or not 1<=len(windows)<=MAX_CHUNKS: raise ValueError("Semantic window budget exceeded")
|
||||
previous_end=0;parts=[]
|
||||
for item in windows:
|
||||
start=item.get("start_byte");end=item.get("end_byte");part=item.get("text")
|
||||
if not isinstance(start,int) or not isinstance(end,int) or not isinstance(part,str) or start<0 or start>previous_end or end<=previous_end or end>len(encoded) or part.encode("utf-8")!=encoded[start:end]: raise ValueError("Semantic source coverage invalid")
|
||||
previous_end=end;parts.append(part)
|
||||
if previous_end!=len(encoded): raise ValueError("Semantic source tail missing")
|
||||
vectors=[];deadline=time.monotonic()+60
|
||||
for index in range(0,len(parts),8):
|
||||
remaining=deadline-time.monotonic()
|
||||
if remaining<=0: raise ValueError("Semantic inference budget exceeded")
|
||||
batch=parts[index:index+8];response=self._request({"op":"documents","texts":batch,"title":"none"},min(30,remaining))
|
||||
vectors.extend(validate_vectors(response.get("vectors"),len(batch)))
|
||||
return vectors
|
||||
|
||||
def image_vectors(self,data):
|
||||
from PIL import Image,ImageSequence,ImageOps
|
||||
image=Image.open(io.BytesIO(data));frames=getattr(image,"n_frames",1)
|
||||
if frames>32 or image.width*image.height*frames>50_000_000: raise ValueError("Semantic image budget exceeded")
|
||||
vectors=[]
|
||||
for frame in ImageSequence.Iterator(image):
|
||||
frame=ImageOps.exif_transpose(frame).convert("RGBA")
|
||||
background=Image.new("RGBA",frame.size,"white");background.alpha_composite(frame);frame=background.convert("RGB")
|
||||
scale=min(1,672/max(frame.size));width=max(48,round(frame.width*scale/48)*48);height=max(48,round(frame.height*scale/48)*48)
|
||||
frame=frame.resize((width,height),Image.Resampling.LANCZOS)
|
||||
request={"op":"image","projector":str(self.projector),"width":width,"height":height,"data":base64.b64encode(frame.tobytes()).decode("ascii")}
|
||||
vectors.extend(validate_vectors(self._request(request).get("vectors"),1))
|
||||
return vectors
|
||||
|
||||
def audio_vectors(self,data):
|
||||
import numpy as np
|
||||
with wave.open(io.BytesIO(data),"rb") as source:
|
||||
if source.getnchannels()!=1 or source.getframerate()!=16000 or source.getsampwidth()!=2 or not 0<source.getnframes()<=480000: raise ValueError("Only bounded mono16kHz PCM16 WAV is supported")
|
||||
payload=source.readframes(source.getnframes())
|
||||
if len(payload)!=source.getnframes()*2: raise ValueError("Truncated audio data")
|
||||
pcm=np.frombuffer(payload,dtype="<i2").astype("<f4")/32768
|
||||
request={"op":"audio","projector":str(self.projector),"data":base64.b64encode(pcm.tobytes()).decode("ascii")}
|
||||
return validate_vectors(self._request(request).get("vectors"),1)
|
||||
@@ -0,0 +1,109 @@
|
||||
import base64
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
# Embedded Python uses an isolated ._pth and does not add the script directory.
|
||||
# Resolve sibling modules from this service's source, not an old runtime copy.
|
||||
sys.path.insert(0,os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.dont_write_bytecode=True
|
||||
from windows_containment import contain_inspector
|
||||
# Apply limits before loading parsers, OCR libraries or native model workers.
|
||||
# An unavailable Windows boundary fails startup rather than silently weakening it.
|
||||
CONTAINMENT=contain_inspector() if __name__=="__main__" else {"active":False,"kind":"not-service-entrypoint"}
|
||||
# Broker imports no parser, OCR or model library. All untrusted document work
|
||||
# runs under a capability-free Windows AppContainer, with no network permission.
|
||||
MAX_BYTES = 20 * 1024 * 1024
|
||||
SANDBOX = None
|
||||
SANDBOX_ENV = {}
|
||||
if __name__ == "__main__":
|
||||
if sys.platform != 'win32': raise SystemExit('Production parser isolation requires Windows')
|
||||
from pathlib import Path
|
||||
from windows_appcontainer import AppContainer
|
||||
source = Path(__file__).resolve().parent
|
||||
python_root = Path(sys.executable).resolve().parent
|
||||
# Development and packaged roots are derived from our own installation;
|
||||
# externally configured model paths must already be copied into that tree.
|
||||
installation = source.parent.parent if source.name == 'inspector' and source.parent.name == 'apps' else source.parent
|
||||
semantic_root = installation / ('.runtime/windows/semantic' if source.parent.name == 'apps' else 'semantic')
|
||||
expected_python = installation / ('.runtime/windows/python' if source.parent.name == 'apps' else 'python')
|
||||
if python_root != expected_python.resolve():
|
||||
raise SystemExit('Parser runtime must reside in the private installed runtime')
|
||||
roots = [source, python_root]
|
||||
if semantic_root.is_dir(): roots.append(semantic_root)
|
||||
for key in ['EMBEDDING_MODEL_DIR', 'EMBEDDING_WORKER_PATH']:
|
||||
value = os.environ.get(key)
|
||||
if value:
|
||||
resolved = Path(value).resolve()
|
||||
if not resolved.is_relative_to(semantic_root.resolve()):
|
||||
raise SystemExit('Model artifacts must reside in the private installed runtime')
|
||||
SANDBOX_ENV[key] = str(resolved)
|
||||
SANDBOX_ENV.update({'SEARCHI_FORCE_CPU':'1', 'GGML_CPU_ONLY':'1', 'GGML_DISABLE_VULKAN':'1'})
|
||||
SANDBOX = AppContainer(roots)
|
||||
CONTAINMENT['parserIsolation'] = {'active':True, 'kind':'windows-appcontainer-v1', 'networkCapabilities':0, 'privatePipes':True, 'readOnlyRuntime':True}
|
||||
|
||||
|
||||
def dispatch(route, request):
|
||||
if SANDBOX is None: raise RuntimeError('Isolated parser unavailable')
|
||||
payload = json.dumps({'route':route, 'data':request['data'], 'policy':request.get('policy',{})}, allow_nan=False).encode('utf-8')
|
||||
code, output = SANDBOX.run(sys.executable, [str(Path(__file__).with_name('sandbox_worker.py'))],
|
||||
payload, environment=SANDBOX_ENV, timeout=29, max_output=8*1024*1024)
|
||||
if code: raise RuntimeError('Isolated parser failed')
|
||||
result = json.loads(output)
|
||||
if not isinstance(result, dict): raise RuntimeError('Isolated parser response invalid')
|
||||
if route in ['/inspect','/scan-download']:
|
||||
if result.get('decision') not in ['allow','block'] or not isinstance(result.get('coverage',{}).get('complete'),bool):
|
||||
raise RuntimeError('Isolated parser decision invalid')
|
||||
if result['decision']=='allow' and not result['coverage']['complete']:
|
||||
raise RuntimeError('Incomplete parser coverage cannot release content')
|
||||
return result
|
||||
|
||||
TOKEN=os.environ.get("INSPECTOR_TOKEN", "")
|
||||
if len(TOKEN)<32: raise SystemExit("INSPECTOR_TOKEN must contain at least 32 characters")
|
||||
LOCK=threading.Lock()
|
||||
|
||||
def watch_parent():
|
||||
parent=os.environ.get('INSPECTOR_PARENT_PID')
|
||||
if not parent or sys.platform!='win32': return
|
||||
import ctypes
|
||||
kernel=ctypes.WinDLL('kernel32',use_last_error=True)
|
||||
kernel.OpenProcess.argtypes=[ctypes.c_uint32,ctypes.c_int,ctypes.c_uint32]
|
||||
kernel.OpenProcess.restype=ctypes.c_void_p
|
||||
kernel.WaitForSingleObject.argtypes=[ctypes.c_void_p,ctypes.c_uint32]
|
||||
handle=kernel.OpenProcess(0x00100000,False,int(parent))
|
||||
if not handle: os._exit(0)
|
||||
while kernel.WaitForSingleObject(handle,1000)==258: pass
|
||||
os._exit(0)
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_args): pass
|
||||
def do_POST(self):
|
||||
if not hmac.compare_digest(self.headers.get("Authorization", ""), "Bearer "+TOKEN): return self.reply(401,{"error":"Inspector authentication required"})
|
||||
request=None;data=None
|
||||
try:
|
||||
length=int(self.headers.get("Content-Length", "0"))
|
||||
if length<=0 or length>MAX_BYTES*2: return self.reply(413,{"error":"Inspection request too large"})
|
||||
self.connection.settimeout(30)
|
||||
request=json.loads(self.rfile.read(length))
|
||||
data=base64.b64decode(request["data"],validate=True)
|
||||
if len(data)>MAX_BYTES: return self.reply(413,{"error":"Content too large"})
|
||||
with LOCK:
|
||||
if self.path not in ['/inspect','/scan-download','/reference-document','/embed']:
|
||||
return self.reply(404,{"error":"Not found"})
|
||||
result=dispatch(self.path,request)
|
||||
return self.reply(200,result)
|
||||
except Exception: return self.reply(422,{"error":"Content inspection failed"})
|
||||
finally:
|
||||
if isinstance(request,dict): request.clear()
|
||||
data=None
|
||||
def reply(self,status,data):
|
||||
body=json.dumps(data,allow_nan=False).encode();self.send_response(status);self.send_header("Content-Type","application/json");self.send_header("Content-Length",str(len(body)));self.send_header("Cache-Control","no-store");self.end_headers();self.wfile.write(body)
|
||||
|
||||
if __name__=="__main__":
|
||||
threading.Thread(target=watch_parent,daemon=True).start()
|
||||
server=HTTPServer(("127.0.0.1",int(os.environ.get("INSPECTOR_PORT","4311"))),Handler)
|
||||
server.timeout=30
|
||||
print(json.dumps({"port":server.server_address[1],"containment":CONTAINMENT}),flush=True)
|
||||
server.serve_forever()
|
||||
@@ -0,0 +1,41 @@
|
||||
import io
|
||||
import os
|
||||
import unittest
|
||||
import zipfile
|
||||
from engine import cosine, inspect_content, content_kind
|
||||
|
||||
def policy(): return {"dlp":{"maxBytes":1048576,"rules":[{"id":"secret","kind":"literal","value":"Secret Roadmap"}],"semantic":{"enabled":False}}}
|
||||
|
||||
class InspectionTests(unittest.TestCase):
|
||||
def test_text_and_binary(self):
|
||||
self.assertEqual(inspect_content(b"Public information",policy())["decision"],"allow")
|
||||
self.assertEqual(inspect_content(b"Secret Roadmap",policy())["ruleId"],"secret")
|
||||
self.assertEqual(inspect_content(b"\xff\x00",policy())["decision"],"block")
|
||||
def test_office_extraction(self):
|
||||
output=io.BytesIO()
|
||||
with zipfile.ZipFile(output,"w") as archive:
|
||||
archive.writestr("word/document.xml",'<document xmlns="urn:test"><t>Secret Roadmap</t></document>')
|
||||
result=inspect_content(output.getvalue(),policy())
|
||||
self.assertEqual(result["contentType"],"office");self.assertEqual(result["ruleId"],"secret")
|
||||
def test_pdf_extraction(self):
|
||||
from pypdf import PdfWriter
|
||||
from pypdf.generic import DecodedStreamObject,NameObject,DictionaryObject
|
||||
writer=PdfWriter();page=writer.add_blank_page(300,300)
|
||||
font=DictionaryObject({NameObject('/Type'):NameObject('/Font'),NameObject('/Subtype'):NameObject('/Type1'),NameObject('/BaseFont'):NameObject('/Helvetica')})
|
||||
page[NameObject('/Resources')]=DictionaryObject({NameObject('/Font'):DictionaryObject({NameObject('/F1'):writer._add_object(font)})})
|
||||
content=DecodedStreamObject();content.set_data(b'BT /F1 12 Tf 10 100 Td (Secret Roadmap) Tj ET');page[NameObject('/Contents')]=writer._add_object(content)
|
||||
output=io.BytesIO();writer.write(output)
|
||||
result=inspect_content(output.getvalue(),policy());self.assertEqual(result['contentType'],'pdf');self.assertEqual(result['ruleId'],'secret')
|
||||
def test_semantic_threshold_and_missing_model(self):
|
||||
p=policy();p['dlp']['semantic']={'enabled':True,'model':'google/embeddinggemma-2','revision':'test-revision','categories':[{'id':'semantic-secret','threshold':.8,'prototype':[1.,0.]}]}
|
||||
os.environ['EMBEDDING_MODEL_REVISION']='test-revision'
|
||||
result=inspect_content(b'Confidential paraphrase',p,encoder=lambda *_:[1.,0.]);self.assertEqual(result['ruleId'],'semantic-secret')
|
||||
result=inspect_content(b'Confidential paraphrase',p,encoder=lambda *_: (_ for _ in ()).throw(ValueError()));self.assertEqual(result['decision'],'block')
|
||||
def test_cosine_validates(self):
|
||||
self.assertAlmostEqual(cosine([1,0],[1,0]),1)
|
||||
with self.assertRaises(ValueError):cosine([0,0],[0,0])
|
||||
def test_video_has_no_false_safe_coverage(self):
|
||||
video=b'\x00\x00\x00\x18ftypisom'+b'\x00'*20
|
||||
self.assertEqual(content_kind(video),'video');result=inspect_content(video,policy());self.assertEqual(result['decision'],'block');self.assertFalse(result['coverage']['complete'])
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,68 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import io
|
||||
from engine import scan_download, reference_document, inspect_content
|
||||
|
||||
def policy(source='rule malicious { strings: $a = "MALWARE_TEST_PAYLOAD" condition: $a }'):
|
||||
return {'security':{'yara':{'enabled':True,'rules':[{'id':'central-malware','source':source}], 'maxScanBytes':1048576}},
|
||||
'dlp':{'maxBytes':1048576,'rules':[],'semantic':{'enabled':False}}}
|
||||
|
||||
class SecurityTests(unittest.TestCase):
|
||||
def test_real_yara_match(self):
|
||||
import yara
|
||||
result=scan_download(b'MZ\x00MALWARE_TEST_PAYLOAD',policy())
|
||||
self.assertEqual(result['decision'],'block')
|
||||
self.assertEqual(result['ruleId'],'central-malware')
|
||||
self.assertTrue(result['coverage']['complete'])
|
||||
def test_binary_malware_coverage_separate_from_dlp(self):
|
||||
data=b'MZ\x00\xffbenign'
|
||||
self.assertEqual(scan_download(data,policy())['decision'],'allow')
|
||||
self.assertEqual(inspect_content(data,policy())['decision'],'block')
|
||||
def test_invalid_or_empty_rules_fail_closed(self):
|
||||
self.assertEqual(scan_download(b'safe',policy('invalid'))['decision'],'block')
|
||||
p=policy();p['security']['yara']['rules']=[]
|
||||
self.assertEqual(scan_download(b'safe',p)['decision'],'block')
|
||||
self.assertEqual(scan_download(b'safe',{})['decision'],'block')
|
||||
def test_include_and_console_import_rejected(self):
|
||||
self.assertEqual(scan_download(b'safe',policy('include "C:/secret"'))['decision'],'block')
|
||||
self.assertEqual(scan_download(b'safe',policy('import "console" rule leak { condition: console.log("secret") }'))['decision'],'block')
|
||||
def test_size_failure(self):
|
||||
p=policy();p['security']['yara']['maxScanBytes']=2
|
||||
self.assertEqual(scan_download(b'large',p)['ruleId'],'yara-size-limit')
|
||||
def test_reference_registration_contains_no_plaintext(self):
|
||||
text=b'Internal confidential roadmap for new product launch during next quarter'
|
||||
result=reference_document(text)
|
||||
self.assertTrue(result['registrationReady']);self.assertTrue(result['fragmentHashes'])
|
||||
self.assertNotIn('confidential',str(result))
|
||||
self.assertFalse(reference_document(b'\xff\x00')['registrationReady'])
|
||||
def test_training_truncation_never_claims_complete(self):
|
||||
result=reference_document(' '.join('distinctword'+str(index) for index in range(1200)).encode())
|
||||
self.assertEqual(len(result['fragmentHashes']),1000)
|
||||
self.assertGreater(result['coverage']['fragmentHashesTotal'],1000)
|
||||
self.assertTrue(result['coverage']['fragmentHashesTruncated'])
|
||||
self.assertTrue(result['coverage']['extractionComplete'])
|
||||
self.assertFalse(result['coverage']['complete'])
|
||||
self.assertFalse(result['registrationReady'])
|
||||
def test_text_training_does_not_create_temporary_content_files(self):
|
||||
with patch('engine.tempfile.TemporaryDirectory',side_effect=AssertionError('Raw content persistence forbidden')):
|
||||
result=reference_document(b'Confidential strategic roadmap contains enough words for fingerprint training')
|
||||
self.assertTrue(result['registrationReady'])
|
||||
def test_trained_fragments_block_reformatted_excerpt(self):
|
||||
text=b'Internal confidential roadmap for new product launch during next quarter'
|
||||
reference=reference_document(text)
|
||||
p=policy();p['dlp']['rules']=[{'id':'trained','kind':'fragment-sha256','value':value} for value in reference['fragmentHashes']]
|
||||
result=inspect_content(b'Introduction. INTERNAL confidential roadmap for new product launch during next quarter. Ending.',p)
|
||||
self.assertEqual(result['ruleId'],'trained')
|
||||
def test_real_bundled_ocr_offline_detects_image_secret(self):
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
image=Image.new('RGB',(1000,180),'white')
|
||||
ImageDraw.Draw(image).text((30,45),'Secret Roadmap',font=ImageFont.truetype('C:/Windows/Fonts/arial.ttf',60),fill='black')
|
||||
output=io.BytesIO();image.save(output,format='PNG')
|
||||
p=policy();p['dlp']['rules']=[{'id':'image-secret','kind':'literal','value':'Secret Roadmap'}]
|
||||
# Inference and lazy initialization must not enter a model-download path.
|
||||
with patch('rapidocr.utils.download_file.DownloadFile.run',side_effect=AssertionError('Network model download forbidden')):
|
||||
result=inspect_content(output.getvalue(),p)
|
||||
self.assertEqual(result['ruleId'],'image-secret')
|
||||
self.assertTrue(result['coverage']['complete'])
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,87 @@
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import queue
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from semantic import NativeSemanticModel,validate_vectors,validate_readiness,verify_file,DIMENSIONS,PROFILE,RUNTIME_BUILD
|
||||
from engine import aggregate_vectors,embedding_features,inspect_content
|
||||
|
||||
def vector(index=0):
|
||||
result=[0.0]*DIMENSIONS;result[index]=1.0;return result
|
||||
|
||||
class SemanticProtocolTests(unittest.TestCase):
|
||||
def model(self,windows):
|
||||
model=NativeSemanticModel.__new__(NativeSemanticModel)
|
||||
def request(payload,timeout=30):
|
||||
if payload['op']=='windows':return {'windows':windows}
|
||||
return {'vectors':[vector() for _ in payload['texts']]}
|
||||
model._request=request
|
||||
return model
|
||||
def test_unicode_windows_cover_exact_source_and_tail(self):
|
||||
text='Confidential café 中央 secret';encoded=text.encode()
|
||||
windows=[{'text':text,'start_byte':0,'end_byte':len(encoded)}]
|
||||
self.assertEqual(self.model(windows).text_vectors(text),[vector()])
|
||||
windows[0]['end_byte']-=1
|
||||
with self.assertRaises(ValueError):self.model(windows).text_vectors(text)
|
||||
def test_missing_tail_gaps_and_substitution_are_refused(self):
|
||||
for windows in [[{'text':'hello','start_byte':0,'end_byte':5}],
|
||||
[{'text':'hello','start_byte':0,'end_byte':5},{'text':'world','start_byte':6,'end_byte':11}],
|
||||
[{'text':'altered text','start_byte':0,'end_byte':11}]]:
|
||||
with self.assertRaises(ValueError):self.model(windows).text_vectors('hello world')
|
||||
def test_full_window_batch_is_not_silently_truncated(self):
|
||||
text='a'*65
|
||||
windows=[{'text':'a','start_byte':index,'end_byte':index+1} for index in range(65)]
|
||||
with self.assertRaises(ValueError):self.model(windows).text_vectors(text)
|
||||
def test_output_dimensions_finite_norm_and_aggregation(self):
|
||||
self.assertEqual(validate_vectors([vector()]),[vector()])
|
||||
for invalid in [[0.0]*768,[1.0]*767,[float('nan')]*768,[float('inf')]*768]:
|
||||
with self.assertRaises(ValueError):validate_vectors([invalid])
|
||||
aggregate=aggregate_vectors([vector(0),vector(1)])
|
||||
self.assertAlmostEqual(sum(value*value for value in aggregate),1)
|
||||
def test_only_exact_hardened_cpu_worker_build_is_accepted(self):
|
||||
ready={'ready':True,'model':'google/embeddinggemma-2','dimensions':768,'runtime':RUNTIME_BUILD,'device':'cpu','vulkan_module_loaded':False}
|
||||
validate_readiness(ready)
|
||||
for changed in [{'runtime':'b11461'},{'runtime':'b11461-no-logits-v2'},{'device':'gpu'},{'vulkan_module_loaded':True},{'dimensions':128}]:
|
||||
with self.assertRaises(ValueError):validate_readiness({**ready,**changed})
|
||||
def test_artifact_hash_size_and_escape_checks(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root=Path(directory).resolve();file=root/'artifact';file.write_bytes(b'public model fixture')
|
||||
digest=hashlib.sha256(file.read_bytes()).hexdigest()
|
||||
self.assertEqual(verify_file(root,'artifact',file.stat().st_size,digest),file)
|
||||
with self.assertRaises(ValueError):verify_file(root,'artifact',file.stat().st_size,'0'*64)
|
||||
with self.assertRaises(ValueError):verify_file(root,'artifact',1,digest)
|
||||
with self.assertRaises(ValueError):verify_file(root,'../escape',1,digest)
|
||||
def test_training_features_return_only_vectors_and_metadata(self):
|
||||
class FixtureModel:
|
||||
def text_vectors(self,text):return [vector(),vector(1)]
|
||||
def info(self):return {'model':'google/embeddinggemma-2','revision':'a'*40,'dimensions':768,'profile':PROFILE,'local':True}
|
||||
with patch('engine.get_model',return_value=FixtureModel()):result=embedding_features(b'Internal secret not to retain in reference data')
|
||||
self.assertTrue(result['coverage']['complete']);self.assertEqual(result['coverage']['textChunks'],2)
|
||||
self.assertEqual(len(result['vectors']),2);self.assertNotIn('Internal secret',json.dumps(result))
|
||||
def test_category_matching_compares_all_reference_prototypes(self):
|
||||
policy={'dlp':{'maxBytes':1024,'rules':[],'semantic':{'enabled':True,'model':'google/embeddinggemma-2','revision':'a'*40,'categories':[{'id':'reference','prototype':vector(0),'prototypes':[vector(0),vector(1)],'threshold':.9}]}}}
|
||||
with patch.dict('os.environ',{'EMBEDDING_MODEL_REVISION':'a'*40}):
|
||||
result=inspect_content(b'Paraphrased protected concept',policy,encoder=lambda *_:vector(1))
|
||||
self.assertEqual(result['ruleId'],'reference')
|
||||
def test_worker_timeout_kills_and_sanitizes_errors(self):
|
||||
model=NativeSemanticModel.__new__(NativeSemanticModel);model.responses=queue.Queue()
|
||||
with patch.object(model,'close') as close:
|
||||
with self.assertRaisesRegex(ValueError,'timed out'):model._receive(.001)
|
||||
close.assert_called_once()
|
||||
def test_worker_private_exception_text_is_never_returned(self):
|
||||
import threading
|
||||
class Process:
|
||||
stdin=io.BytesIO()
|
||||
def poll(self):return None
|
||||
def kill(self):pass
|
||||
model=NativeSemanticModel.__new__(NativeSemanticModel);model.process=Process();model.lock=threading.Lock()
|
||||
model._start=lambda:None
|
||||
model._receive=lambda timeout:{'ok':False,'device':'cpu','error':'PRIVATE DOC CONTENT'}
|
||||
with patch.object(model,'close') as close:
|
||||
with self.assertRaises(ValueError) as error:model._request({'op':'documents','texts':['public fixture']})
|
||||
self.assertNotIn('PRIVATE',str(error.exception));close.assert_called_once()
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Opt-in operational fixtures using staged real Google weights, CPU only."""
|
||||
import io
|
||||
import math
|
||||
import os
|
||||
import unittest
|
||||
import wave
|
||||
from engine import get_model,embedding_features,inspect_content,cosine
|
||||
from semantic import validate_vectors,DIMENSIONS
|
||||
|
||||
@unittest.skipUnless(os.environ.get('SAFETYNET_TEST_NATIVE_SEMANTIC')=='1','Opt in to verified native CPU fixtures')
|
||||
class NativeCPUFixtures(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):cls.model=get_model()
|
||||
@classmethod
|
||||
def tearDownClass(cls):cls.model.close()
|
||||
def test_real_paraphrase_blocks_and_unrelated_text_allows(self):
|
||||
reference=embedding_features(b'Our confidential launch plan schedules the new product announcement for next October.')
|
||||
protected=b'The secret product rollout will be announced in October next year.'
|
||||
public=b'The public cafeteria menu includes tomato soup and bread.'
|
||||
related=embedding_features(protected);unrelated=embedding_features(public)
|
||||
self.assertGreater(cosine(reference['vector'],related['vector']),.8)
|
||||
self.assertLess(cosine(reference['vector'],unrelated['vector']),.8)
|
||||
policy={'dlp':{'maxBytes':1048576,'rules':[],'semantic':{**self.model.info(),'enabled':True,'categories':[{'id':'native-reference','prototype':reference['vector'],'prototypes':reference['vectors'],'threshold':.8}]}}}
|
||||
self.assertEqual(inspect_content(protected,policy)['ruleId'],'native-reference')
|
||||
self.assertEqual(inspect_content(public,policy)['decision'],'allow')
|
||||
def test_native_token_windows_cover_long_document_tail(self):
|
||||
text='Public synthetic office lunch menu and team meeting schedule. '*600+'Final unique coverage marker.'
|
||||
result=embedding_features(text.encode())
|
||||
self.assertGreater(result['coverage']['textChunks'],1)
|
||||
self.assertTrue(result['coverage']['complete']);validate_vectors(result['vectors'])
|
||||
def test_native_image_encoder_returns_real_normalized_vector(self):
|
||||
from PIL import Image,ImageDraw
|
||||
image=Image.new('RGB',(192,192),'white');draw=ImageDraw.Draw(image)
|
||||
draw.rectangle((20,20,90,170),fill='blue');draw.ellipse((110,50,175,115),fill='red')
|
||||
output=io.BytesIO();image.save(output,format='PNG')
|
||||
vectors=self.model.image_vectors(output.getvalue())
|
||||
self.assertEqual(len(vectors),1);validate_vectors(vectors)
|
||||
def test_native_audio_encoder_returns_real_normalized_vector(self):
|
||||
import numpy as np
|
||||
samples=(np.sin(np.arange(16000)*2*math.pi*440/16000)*12000).astype('<i2')
|
||||
output=io.BytesIO()
|
||||
with wave.open(output,'wb') as stream:
|
||||
stream.setnchannels(1);stream.setsampwidth(2);stream.setframerate(16000);stream.writeframes(samples.tobytes())
|
||||
vectors=self.model.audio_vectors(output.getvalue())
|
||||
self.assertEqual(len(vectors),1);validate_vectors(vectors)
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,48 @@
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform=='win32','Production inspector isolation requires Windows')
|
||||
class InspectorHTTPTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.token=secrets.token_urlsafe(32)
|
||||
env={key:value for key,value in os.environ.items() if key.upper() in {'SYSTEMROOT','WINDIR','USERPROFILE','LOCALAPPDATA','APPDATA','TEMP','TMP'}}
|
||||
env.update({'INSPECTOR_TOKEN':cls.token,'INSPECTOR_PORT':'0'})
|
||||
cls.server=subprocess.Popen([sys.executable,str(Path(__file__).with_name('server.py'))],stdout=subprocess.PIPE,stderr=subprocess.PIPE,env=env,creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
lines=queue.Queue();threading.Thread(target=lambda:lines.put(cls.server.stdout.readline()),daemon=True).start()
|
||||
try:
|
||||
cls.ready=json.loads(lines.get(timeout=40));cls.port=cls.ready['port']
|
||||
if cls.ready['containment']['parserIsolation']['kind']!='windows-appcontainer-v1':raise RuntimeError('Production sandbox attestation missing')
|
||||
except Exception:
|
||||
cls.server.kill();cls.server.wait(timeout=5);cls.server.stdout.close();cls.server.stderr.close();raise
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.server.kill();cls.server.wait(timeout=5);cls.server.stdout.close();cls.server.stderr.close()
|
||||
def request(self,data,credential):
|
||||
policy={'dlp':{'maxBytes':1048576,'rules':[{'id':'secret','kind':'literal','value':'Secret Roadmap'}],'semantic':{'enabled':False}}}
|
||||
body=json.dumps({'data':base64.b64encode(data).decode(),'policy':policy}).encode()
|
||||
request=urllib.request.Request(f'http://127.0.0.1:{self.port}/inspect',data=body,headers={'Content-Type':'application/json','Authorization':'Bearer '+credential})
|
||||
return urllib.request.urlopen(request,timeout=30)
|
||||
def test_authenticated_content_inspection(self):
|
||||
with self.request(b'Secret Roadmap',self.token) as response:
|
||||
result=json.load(response);self.assertEqual(result['decision'],'block');self.assertEqual(result['ruleId'],'secret')
|
||||
with self.request(b'Public information',self.token) as response:self.assertEqual(json.load(response)['decision'],'allow')
|
||||
def test_rejects_unauthenticated_content(self):
|
||||
with self.assertRaises(urllib.error.HTTPError) as error:self.request(b'Public information','wrong')
|
||||
self.assertEqual(error.exception.code,401);error.exception.close()
|
||||
def test_actual_parser_isolation_attestation(self):
|
||||
isolation=self.ready['containment']['parserIsolation'];self.assertTrue(isolation['active']);self.assertEqual(isolation['networkCapabilities'],0);self.assertTrue(isolation['privatePipes']);self.assertTrue(isolation['readOnlyRuntime'])
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Native OS boundary tests; assertions inspect actual Windows tokens/access."""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform == 'win32', 'Windows AppContainer native integration')
|
||||
class WindowsAppContainerTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
from windows_appcontainer import AppContainer
|
||||
cls.source = Path(__file__).resolve().parent
|
||||
cls.runtime = Path(sys.executable).resolve().parent
|
||||
cls.sandbox = AppContainer([cls.source, cls.runtime], provision=True)
|
||||
|
||||
def test_read_only_startup_never_mutates_permissions(self):
|
||||
from unittest.mock import patch
|
||||
from windows_appcontainer import AppContainer
|
||||
with patch('windows_appcontainer.subprocess.run', side_effect=AssertionError('Startup must not invoke icacls')):
|
||||
sandbox = AppContainer([self.source, self.runtime])
|
||||
code, output = sandbox.run(sys.executable, ['-c', 'print("read-only-startup")'])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(output.strip(), b'read-only-startup')
|
||||
|
||||
def test_unprovisioned_runtime_fails_closed(self):
|
||||
from unittest.mock import patch
|
||||
from windows_appcontainer import AppContainer, SandboxError
|
||||
with tempfile.TemporaryDirectory(prefix='safetynet-unprovisioned-') as folder:
|
||||
with patch('windows_appcontainer.subprocess.run', side_effect=AssertionError('No automatic grants')):
|
||||
with self.assertRaisesRegex(SandboxError, 'provisioning is required'):
|
||||
AppContainer([folder])
|
||||
|
||||
def test_real_token_filesystem_network_and_secret_boundary(self):
|
||||
source = '''import ctypes,json,os,socket,sys
|
||||
from ctypes import wintypes as W
|
||||
k=ctypes.WinDLL('kernel32');a=ctypes.WinDLL('advapi32')
|
||||
k.GetCurrentProcess.restype=W.HANDLE
|
||||
a.OpenProcessToken.argtypes=[W.HANDLE,W.DWORD,ctypes.POINTER(W.HANDLE)]
|
||||
a.GetTokenInformation.argtypes=[W.HANDLE,ctypes.c_int,ctypes.c_void_p,W.DWORD,ctypes.POINTER(W.DWORD)]
|
||||
t=W.HANDLE();a.OpenProcessToken(k.GetCurrentProcess(),8,ctypes.byref(t));v=W.DWORD();n=W.DWORD();a.GetTokenInformation(t,29,ctypes.byref(v),4,ctypes.byref(n))
|
||||
r={'appContainer':v.value==1,'secretAbsent':'INSPECTOR_TOKEN' not in os.environ,'echo':sys.stdin.buffer.read().decode()}
|
||||
for key,path,mode in [('privateDenied',sys.argv[1],'rb'),('runtimeWriteDenied',sys.argv[2],'wb')]:
|
||||
try:open(path,mode).close();r[key]=False
|
||||
except PermissionError:r[key]=True
|
||||
for key,kind in [('tcpDenied',socket.SOCK_STREAM),('udpDenied',socket.SOCK_DGRAM)]:
|
||||
try:
|
||||
s=socket.socket(type=kind);s.settimeout(2)
|
||||
if kind==socket.SOCK_STREAM:s.connect(('127.0.0.1',int(sys.argv[3])))
|
||||
else:s.sendto(b'probe',('127.0.0.1',int(sys.argv[4])))
|
||||
r[key]=False
|
||||
except OSError:r[key]=True
|
||||
finally:s.close()
|
||||
print(json.dumps(r))
|
||||
'''
|
||||
listener = socket.socket();listener.bind(('127.0.0.1',0));listener.listen()
|
||||
udp = socket.socket(type=socket.SOCK_DGRAM);udp.bind(('127.0.0.1',0));udp.settimeout(0.2)
|
||||
with tempfile.TemporaryDirectory(prefix='safetynet-private-boundary-') as folder:
|
||||
private = Path(folder)/'private.txt';private.write_text('synthetic private fixture')
|
||||
target = self.runtime/'appcontainer-unauthorized-write.txt'
|
||||
try:
|
||||
code, output = self.sandbox.run(sys.executable,['-c',source,str(private),str(target),str(listener.getsockname()[1]),str(udp.getsockname()[1])], b'pipe-only fixture')
|
||||
self.assertEqual(code,0,output.decode(errors='replace'))
|
||||
result=json.loads(output)
|
||||
for key in ['appContainer','secretAbsent','privateDenied','runtimeWriteDenied','tcpDenied']:self.assertTrue(result[key],key)
|
||||
self.assertEqual(result['echo'],'pipe-only fixture')
|
||||
self.assertFalse(target.exists())
|
||||
# Windows firewall can silently discard UDP, so verify the live
|
||||
# receiver instead of trusting sendto's immediate return value.
|
||||
with self.assertRaises(socket.timeout): udp.recvfrom(1024)
|
||||
finally:listener.close();udp.close()
|
||||
|
||||
def test_child_process_inherits_sandbox(self):
|
||||
child = "import ctypes,json;from ctypes import wintypes as W;k=ctypes.WinDLL('kernel32');a=ctypes.WinDLL('advapi32');k.GetCurrentProcess.restype=W.HANDLE;a.OpenProcessToken.argtypes=[W.HANDLE,W.DWORD,ctypes.POINTER(W.HANDLE)];a.GetTokenInformation.argtypes=[W.HANDLE,ctypes.c_int,ctypes.c_void_p,W.DWORD,ctypes.POINTER(W.DWORD)];t=W.HANDLE();a.OpenProcessToken(k.GetCurrentProcess(),8,ctypes.byref(t));v=W.DWORD();n=W.DWORD();a.GetTokenInformation(t,29,ctypes.byref(v),4,ctypes.byref(n));print(v.value)"
|
||||
parent='import subprocess,sys; result=subprocess.run([sys.executable,"-c",'+repr(child)+'],capture_output=True,text=True,timeout=5);print(result.stdout.strip());sys.exit(result.returncode)'
|
||||
code, output=self.sandbox.run(sys.executable,['-c',parent])
|
||||
self.assertEqual(code,0,output.decode());self.assertEqual(output.strip(),b'1')
|
||||
|
||||
def test_worker_cannot_open_broker_memory(self):
|
||||
# Test access rights without reading any actual parent memory or secrets.
|
||||
source='''import ctypes,json,sys
|
||||
from ctypes import wintypes as W
|
||||
k=ctypes.WinDLL('kernel32',use_last_error=True)
|
||||
k.OpenProcess.argtypes=[W.DWORD,W.BOOL,W.DWORD];k.OpenProcess.restype=W.HANDLE
|
||||
k.CloseHandle.argtypes=[W.HANDLE]
|
||||
handle=k.OpenProcess(0x0010,False,int(sys.argv[1]))
|
||||
result={'brokerMemoryReadDenied':not bool(handle),'error':ctypes.get_last_error()}
|
||||
if handle:k.CloseHandle(handle)
|
||||
print(json.dumps(result))
|
||||
'''
|
||||
code, output=self.sandbox.run(sys.executable,['-c',source,str(os.getpid())])
|
||||
self.assertEqual(code,0,output.decode())
|
||||
result=json.loads(output)
|
||||
self.assertTrue(result['brokerMemoryReadDenied'])
|
||||
self.assertEqual(result['error'],5)
|
||||
|
||||
def test_real_parser_crosses_only_pipe(self):
|
||||
request={'route':'/inspect','data':base64.b64encode(b'Public text document').decode(),'policy':{'security':{},'dlp':{'rules':[], 'maxBytes':20971520}}}
|
||||
code,output=self.sandbox.run(sys.executable,[str(self.source/'sandbox_worker.py')],json.dumps(request).encode())
|
||||
self.assertEqual(code,0,output.decode(errors='replace'))
|
||||
result=json.loads(output);self.assertEqual(result['decision'],'allow');self.assertTrue(result['coverage']['complete'])
|
||||
|
||||
def test_real_yara_upload_and_download_inside_sandbox(self):
|
||||
rule={'id':'test-marker','source':'rule Block_Test { strings: $a = "SAFETYNET_BLOCK_TEST" condition: $a }'}
|
||||
policy={'security':{'yara':{'enabled':True,'rules':[rule],'maxScanBytes':1048576}},'dlp':{'maxBytes':1048576,'rules':[]}}
|
||||
for route,data,decision in [('/inspect',b'Public information','allow'),('/inspect',b'SAFETYNET_BLOCK_TEST','block'),('/scan-download',bytes([0,1,2,3,255]),'allow'),('/scan-download',b'SAFETYNET_BLOCK_TEST','block')]:
|
||||
request={'route':route,'data':base64.b64encode(data).decode(),'policy':policy}
|
||||
code,output=self.sandbox.run(sys.executable,[str(self.source/'sandbox_worker.py')],json.dumps(request).encode())
|
||||
self.assertEqual(code,0,output.decode(errors='replace'));result=json.loads(output)
|
||||
self.assertEqual(result['decision'],decision,(route,result))
|
||||
if route=='/inspect' and decision=='block':self.assertTrue(result['malware']['coverage']['complete'])
|
||||
else:self.assertTrue(result['coverage']['complete'])
|
||||
if decision=='block':self.assertEqual(result['ruleId'],'test-marker')
|
||||
|
||||
def test_orphan_worker_is_killed_when_request_ends(self):
|
||||
import ctypes
|
||||
from ctypes import wintypes as W
|
||||
source='import subprocess,sys;child=subprocess.Popen([sys.executable,"-c","import time;time.sleep(60)"]);print(child.pid,flush=True)'
|
||||
code,output=self.sandbox.run(sys.executable,['-c',source])
|
||||
self.assertEqual(code,0,output.decode());pid=int(output.strip())
|
||||
kernel=ctypes.WinDLL('kernel32',use_last_error=True)
|
||||
kernel.OpenProcess.argtypes=[W.DWORD,W.BOOL,W.DWORD];kernel.OpenProcess.restype=W.HANDLE
|
||||
kernel.WaitForSingleObject.argtypes=[W.HANDLE,W.DWORD];kernel.CloseHandle.argtypes=[W.HANDLE]
|
||||
handle=kernel.OpenProcess(0x00100000,False,pid)
|
||||
if handle:
|
||||
try:self.assertEqual(kernel.WaitForSingleObject(handle,3000),0)
|
||||
finally:kernel.CloseHandle(handle)
|
||||
|
||||
def test_large_binary_pipe_complete(self):
|
||||
import hashlib
|
||||
data=bytes(range(256))*65536
|
||||
code,output=self.sandbox.run(sys.executable,['-c','import hashlib,sys;print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())'],data)
|
||||
self.assertEqual(code,0);self.assertEqual(output.strip().decode(),hashlib.sha256(data).hexdigest())
|
||||
|
||||
def test_worker_rejects_unsandboxed_execution(self):
|
||||
result=subprocess.run([sys.executable,str(self.source/'sandbox_worker.py')],input=b'{}',capture_output=True,timeout=5)
|
||||
self.assertNotEqual(result.returncode,0);self.assertEqual(result.stdout,b'');self.assertEqual(result.stderr,b'')
|
||||
|
||||
def test_broker_dispatch_remains_authenticated_and_isolated(self):
|
||||
import queue
|
||||
import secrets
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
token=secrets.token_urlsafe(32)
|
||||
env={key:value for key,value in os.environ.items() if key.upper() in {'SYSTEMROOT','WINDIR','USERPROFILE','APPDATA','LOCALAPPDATA','TEMP','TMP'}}
|
||||
env.update({'INSPECTOR_TOKEN':token,'INSPECTOR_PORT':'0'})
|
||||
process=subprocess.Popen([sys.executable,str(self.source/'server.py')],stdout=subprocess.PIPE,stderr=subprocess.PIPE,env=env,creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
lines=queue.Queue();threading.Thread(target=lambda:lines.put(process.stdout.readline()),daemon=True).start()
|
||||
try:
|
||||
ready=json.loads(lines.get(timeout=40))
|
||||
self.assertEqual(ready['containment']['parserIsolation']['kind'],'windows-appcontainer-v1')
|
||||
request={'data':base64.b64encode(b'Public allowed document').decode(),'policy':{'security':{},'dlp':{'maxBytes':20971520,'rules':[]}}}
|
||||
url='http://127.0.0.1:'+str(ready['port'])+'/inspect'
|
||||
body=json.dumps(request).encode()
|
||||
with self.assertRaises(urllib.error.HTTPError) as denied:urllib.request.urlopen(urllib.request.Request(url,data=body),timeout=5)
|
||||
self.assertEqual(denied.exception.code,401)
|
||||
with urllib.request.urlopen(urllib.request.Request(url,data=body,headers={'Authorization':'Bearer '+token}),timeout=30) as response:result=json.load(response)
|
||||
self.assertEqual(result['decision'],'allow');self.assertTrue(result['coverage']['complete'])
|
||||
finally:
|
||||
process.kill();process.wait(timeout=5)
|
||||
process.stdout.close();process.stderr.close()
|
||||
|
||||
def test_deadline_and_response_bound_fail_closed(self):
|
||||
from windows_appcontainer import SandboxError
|
||||
with self.assertRaises(SandboxError):self.sandbox.run(sys.executable,['-c','import time;time.sleep(30)'],timeout=0.2)
|
||||
with self.assertRaises(SandboxError):self.sandbox.run(sys.executable,['-c','print("x"*10000)'],max_output=100)
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,83 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform == "win32", "Windows Job Object integration")
|
||||
class WindowsContainmentTests(unittest.TestCase):
|
||||
def run_child(self, source):
|
||||
source = f"import sys; sys.path.insert(0, {str(Path(__file__).parent)!r});\n" + source
|
||||
return subprocess.run([sys.executable, "-c", source], cwd=Path(__file__).parent,
|
||||
capture_output=True, text=True, timeout=20,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
|
||||
def test_process_limit_and_no_breakaway(self):
|
||||
result = self.run_child("""
|
||||
import json, subprocess, sys
|
||||
from windows_containment import contain_inspector
|
||||
status=contain_inspector()
|
||||
children=[]
|
||||
try:
|
||||
for i in range(3):
|
||||
children.append(subprocess.Popen([sys.executable,'-c','import time; time.sleep(15)'],creationflags=subprocess.CREATE_NO_WINDOW))
|
||||
denied=False
|
||||
try:
|
||||
extra=subprocess.Popen([sys.executable,'-c','pass'],creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
extra.wait()
|
||||
except OSError:
|
||||
denied=True
|
||||
breakaway=False
|
||||
try:
|
||||
escaped=subprocess.Popen([sys.executable,'-c','pass'],creationflags=subprocess.CREATE_NO_WINDOW | subprocess.CREATE_BREAKAWAY_FROM_JOB)
|
||||
escaped.wait()
|
||||
except OSError:
|
||||
breakaway=True
|
||||
print(json.dumps({'status':status,'denied':denied,'breakawayDenied':breakaway}),flush=True)
|
||||
finally:
|
||||
for child in children:
|
||||
child.terminate();child.wait()
|
||||
""")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
proof = json.loads(result.stdout)
|
||||
self.assertTrue(proof["status"]["active"])
|
||||
self.assertEqual(proof["status"]["memoryBytes"], 4 * 1024 ** 3)
|
||||
self.assertTrue(proof["denied"])
|
||||
self.assertTrue(proof["breakawayDenied"])
|
||||
|
||||
def test_descendant_is_killed_when_owner_is_terminated(self):
|
||||
source = """
|
||||
import subprocess,sys,time
|
||||
from windows_containment import contain_inspector
|
||||
contain_inspector()
|
||||
child=subprocess.Popen([sys.executable,'-c','import time; time.sleep(60)'],creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
print(child.pid,flush=True)
|
||||
time.sleep(60)
|
||||
"""
|
||||
source = f"import sys; sys.path.insert(0, {str(Path(__file__).parent)!r});\n" + source
|
||||
owner = subprocess.Popen([sys.executable, "-c", source], cwd=Path(__file__).parent,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
handle = None
|
||||
try:
|
||||
# Opening the descendant before termination permits a reliable wait,
|
||||
# avoiding PID reuse or process enumeration heuristics.
|
||||
child_pid = int(owner.stdout.readline())
|
||||
import ctypes
|
||||
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel.OpenProcess.argtypes = [ctypes.c_uint32, ctypes.c_int, ctypes.c_uint32]
|
||||
kernel.OpenProcess.restype = ctypes.c_void_p
|
||||
kernel.WaitForSingleObject.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
|
||||
kernel.CloseHandle.argtypes = [ctypes.c_void_p]
|
||||
handle = kernel.OpenProcess(0x00100000, False, child_pid)
|
||||
self.assertTrue(handle)
|
||||
owner.terminate();owner.wait(timeout=10)
|
||||
self.assertEqual(kernel.WaitForSingleObject(handle, 10000), 0)
|
||||
finally:
|
||||
if owner.poll() is None:
|
||||
owner.terminate();owner.wait(timeout=10)
|
||||
if handle:
|
||||
kernel.CloseHandle(handle)
|
||||
owner.stdout.close();owner.stderr.close()
|
||||
@@ -0,0 +1,363 @@
|
||||
{
|
||||
"purpose": "Unused abandoned ONNX-tokenizer experiment; do not delete locked dependencies",
|
||||
"distributions": [
|
||||
{
|
||||
"name": "anyio",
|
||||
"version": "4.15.1",
|
||||
"requires": [
|
||||
"exceptiongroup>=1.0.2; python_version < \"3.11\"",
|
||||
"idna>=2.8",
|
||||
"typing_extensions>=4.16.0; python_version < \"3.15\"",
|
||||
"trio>=0.32.0; extra == \"trio\""
|
||||
],
|
||||
"topLevelPaths": [
|
||||
"anyio",
|
||||
"anyio-4.15.1.dist-info"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "click",
|
||||
"version": "8.5.0",
|
||||
"requires": [],
|
||||
"topLevelPaths": [
|
||||
"click",
|
||||
"click-8.5.0.dist-info"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "filelock",
|
||||
"version": "4.1.1",
|
||||
"requires": [],
|
||||
"topLevelPaths": [
|
||||
"filelock",
|
||||
"filelock-4.1.1.dist-info"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "fsspec",
|
||||
"version": "2026.9.0",
|
||||
"requires": [
|
||||
"adlfs; extra == 'abfs'",
|
||||
"adlfs; extra == 'adl'",
|
||||
"pyarrow>=1; extra == 'arrow'",
|
||||
"dask; extra == 'dask'",
|
||||
"distributed; extra == 'dask'",
|
||||
"pre-commit; extra == 'dev'",
|
||||
"ruff>=0.5; extra == 'dev'",
|
||||
"numpydoc; extra == 'doc'",
|
||||
"sphinx; extra == 'doc'",
|
||||
"sphinx-design; extra == 'doc'",
|
||||
"sphinx-rtd-theme; extra == 'doc'",
|
||||
"yarl; extra == 'doc'",
|
||||
"dropbox; extra == 'dropbox'",
|
||||
"dropboxdrivefs; extra == 'dropbox'",
|
||||
"requests; extra == 'dropbox'",
|
||||
"adlfs; extra == 'full'",
|
||||
"aiohttp!=4.0.0a0,!=4.0.0a1; extra == 'full'",
|
||||
"dask; extra == 'full'",
|
||||
"distributed; extra == 'full'",
|
||||
"dropbox; extra == 'full'",
|
||||
"dropboxdrivefs; extra == 'full'",
|
||||
"fusepy; extra == 'full'",
|
||||
"gcsfs>=2026.4.0; extra == 'full'",
|
||||
"libarchive-c; extra == 'full'",
|
||||
"ocifs; extra == 'full'",
|
||||
"panel; extra == 'full'",
|
||||
"paramiko; extra == 'full'",
|
||||
"pyarrow>=1; extra == 'full'",
|
||||
"pygit2; extra == 'full'",
|
||||
"requests; extra == 'full'",
|
||||
"s3fs>=2026.6.0; extra == 'full'",
|
||||
"smbprotocol; extra == 'full'",
|
||||
"tqdm; extra == 'full'",
|
||||
"fusepy; extra == 'fuse'",
|
||||
"gcsfs>=2026.4.0; extra == 'gcs'",
|
||||
"pygit2; extra == 'git'",
|
||||
"requests; extra == 'github'",
|
||||
"gcsfs>=2026.4.0; extra == 'gs'",
|
||||
"panel; extra == 'gui'",
|
||||
"pyarrow>=1; extra == 'hdfs'",
|
||||
"aiohttp!=4.0.0a0,!=4.0.0a1; extra == 'http'",
|
||||
"libarchive-c; extra == 'libarchive'",
|
||||
"ocifs; extra == 'oci'",
|
||||
"s3fs>=2026.6.0; extra == 's3'",
|
||||
"paramiko; extra == 'sftp'",
|
||||
"smbprotocol; extra == 'smb'",
|
||||
"paramiko; extra == 'ssh'",
|
||||
"aiohttp!=4.0.0a0,!=4.0.0a1; extra == 'test'",
|
||||
"numpy; extra == 'test'",
|
||||
"pytest; extra == 'test'",
|
||||
"pytest-asyncio!=0.22.0; extra == 'test'",
|
||||
"pytest-benchmark; extra == 'test'",
|
||||
"pytest-cov; extra == 'test'",
|
||||
"pytest-mock; extra == 'test'",
|
||||
"pytest-recording; extra == 'test'",
|
||||
"pytest-rerunfailures; extra == 'test'",
|
||||
"requests; extra == 'test'",
|
||||
"aiobotocore<3.0.0,>=2.5.4; extra == 'test-downstream'",
|
||||
"dask[dataframe,test]; extra == 'test-downstream'",
|
||||
"moto[server]<5,>4; extra == 'test-downstream'",
|
||||
"pytest-timeout; extra == 'test-downstream'",
|
||||
"xarray; extra == 'test-downstream'",
|
||||
"zarr; extra == 'test-downstream'",
|
||||
"adlfs; extra == 'test-full'",
|
||||
"aiohttp!=4.0.0a0,!=4.0.0a1; extra == 'test-full'",
|
||||
"backports-zstd; (python_version < '3.14') and extra == 'test-full'",
|
||||
"cloudpickle; extra == 'test-full'",
|
||||
"dask; extra == 'test-full'",
|
||||
"distributed; extra == 'test-full'",
|
||||
"dropbox; extra == 'test-full'",
|
||||
"dropboxdrivefs; extra == 'test-full'",
|
||||
"fastparquet; extra == 'test-full'",
|
||||
"fusepy; extra == 'test-full'",
|
||||
"gcsfs>=2026.4.0; extra == 'test-full'",
|
||||
"jinja2; extra == 'test-full'",
|
||||
"kerchunk; extra == 'test-full'",
|
||||
"libarchive-c; extra == 'test-full'",
|
||||
"lz4; extra == 'test-full'",
|
||||
"notebook; extra == 'test-full'",
|
||||
"numpy; extra == 'test-full'",
|
||||
"ocifs; extra == 'test-full'",
|
||||
"pandas<3.0.0; extra == 'test-full'",
|
||||
"panel; extra == 'test-full'",
|
||||
"paramiko; extra == 'test-full'",
|
||||
"pyarrow>=1; extra == 'test-full'",
|
||||
"pyftpdlib; extra == 'test-full'",
|
||||
"pygit2; extra == 'test-full'",
|
||||
"pytest; extra == 'test-full'",
|
||||
"pytest-asyncio!=0.22.0; extra == 'test-full'",
|
||||
"pytest-benchmark; extra == 'test-full'",
|
||||
"pytest-cov; extra == 'test-full'",
|
||||
"pytest-mock; extra == 'test-full'",
|
||||
"pytest-recording; extra == 'test-full'",
|
||||
"pytest-rerunfailures; extra == 'test-full'",
|
||||
"python-snappy; extra == 'test-full'",
|
||||
"requests; extra == 'test-full'",
|
||||
"s3fs>=2026.6.0; extra == 'test-full'",
|
||||
"smbprotocol; extra == 'test-full'",
|
||||
"tqdm; extra == 'test-full'",
|
||||
"urllib3; extra == 'test-full'",
|
||||
"zarr<3.2.0; extra == 'test-full'",
|
||||
"zstandard; (python_version < '3.14') and extra == 'test-full'",
|
||||
"tqdm; extra == 'tqdm'"
|
||||
],
|
||||
"topLevelPaths": [
|
||||
"fsspec",
|
||||
"fsspec-2026.9.0.dist-info"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "h11",
|
||||
"version": "0.16.0",
|
||||
"requires": [],
|
||||
"topLevelPaths": [
|
||||
"h11",
|
||||
"h11-0.16.0.dist-info"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "hf-xet",
|
||||
"version": "1.7.0",
|
||||
"requires": [
|
||||
"pytest ; extra == 'tests'"
|
||||
],
|
||||
"topLevelPaths": [
|
||||
"hf_xet",
|
||||
"hf_xet-1.7.0.dist-info"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "httpcore",
|
||||
"version": "1.0.9",
|
||||
"requires": [
|
||||
"certifi",
|
||||
"h11>=0.16",
|
||||
"anyio<5.0,>=4.0; extra == 'asyncio'",
|
||||
"h2<5,>=3; extra == 'http2'",
|
||||
"socksio==1.*; extra == 'socks'",
|
||||
"trio<1.0,>=0.22.0; extra == 'trio'"
|
||||
],
|
||||
"topLevelPaths": [
|
||||
"httpcore",
|
||||
"httpcore-1.0.9.dist-info"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "httpx",
|
||||
"version": "0.28.1",
|
||||
"requires": [
|
||||
"anyio",
|
||||
"certifi",
|
||||
"httpcore==1.*",
|
||||
"idna",
|
||||
"brotli; (platform_python_implementation == 'CPython') and extra == 'brotli'",
|
||||
"brotlicffi; (platform_python_implementation != 'CPython') and extra == 'brotli'",
|
||||
"click==8.*; extra == 'cli'",
|
||||
"pygments==2.*; extra == 'cli'",
|
||||
"rich<14,>=10; extra == 'cli'",
|
||||
"h2<5,>=3; extra == 'http2'",
|
||||
"socksio==1.*; extra == 'socks'",
|
||||
"zstandard>=0.18.0; extra == 'zstd'"
|
||||
],
|
||||
"topLevelPaths": [
|
||||
"httpx",
|
||||
"httpx-0.28.1.dist-info"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "huggingface_hub",
|
||||
"version": "1.33.0",
|
||||
"requires": [
|
||||
"click<9.0.0,>=8.4.2",
|
||||
"filelock>=3.10.0",
|
||||
"fsspec>=2023.5.0",
|
||||
"hf-xet<2.0.0,>=1.6.0; platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"arm64\" or platform_machine == \"aarch64\"",
|
||||
"httpx<1,>=0.23.0",
|
||||
"packaging>=20.9",
|
||||
"pyyaml>=5.1",
|
||||
"tomli>=1.1.0; python_version < \"3.11\"",
|
||||
"tqdm>=4.42.1",
|
||||
"typing-extensions>=4.1.0",
|
||||
"authlib>=1.3.2; extra == \"oauth\"",
|
||||
"fastapi; extra == \"oauth\"",
|
||||
"httpx; extra == \"oauth\"",
|
||||
"itsdangerous; extra == \"oauth\"",
|
||||
"torch; extra == \"torch\"",
|
||||
"safetensors[torch]; extra == \"torch\"",
|
||||
"toml; extra == \"fastai\"",
|
||||
"fastai>=2.4; extra == \"fastai\"",
|
||||
"fastcore>=1.3.27; extra == \"fastai\"",
|
||||
"hf-xet<2.0.0,>=1.6.0; extra == \"hf-xet\"",
|
||||
"mcp<2.0.0,>=1.9.4; extra == \"mcp\"",
|
||||
"authlib>=1.3.2; extra == \"testing\"",
|
||||
"fastapi; extra == \"testing\"",
|
||||
"httpx; extra == \"testing\"",
|
||||
"itsdangerous; extra == \"testing\"",
|
||||
"jedi; extra == \"testing\"",
|
||||
"Jinja2; extra == \"testing\"",
|
||||
"pytest>=8.4.2; extra == \"testing\"",
|
||||
"pytest-cov; extra == \"testing\"",
|
||||
"pytest-env; extra == \"testing\"",
|
||||
"pytest-xdist; extra == \"testing\"",
|
||||
"pytest-vcr; extra == \"testing\"",
|
||||
"pytest-asyncio; extra == \"testing\"",
|
||||
"pytest-rerunfailures>=16.2; extra == \"testing\"",
|
||||
"pytest-mock; extra == \"testing\"",
|
||||
"urllib3<2.0; extra == \"testing\"",
|
||||
"soundfile; extra == \"testing\"",
|
||||
"Pillow; extra == \"testing\"",
|
||||
"numpy; extra == \"testing\"",
|
||||
"duckdb; extra == \"testing\"",
|
||||
"fastapi; extra == \"testing\"",
|
||||
"gradio>=5.0.0; extra == \"gradio\"",
|
||||
"requests; extra == \"gradio\"",
|
||||
"typing-extensions>=4.8.0; extra == \"typing\"",
|
||||
"types-PyYAML; extra == \"typing\"",
|
||||
"types-simplejson; extra == \"typing\"",
|
||||
"types-toml; extra == \"typing\"",
|
||||
"types-tqdm; extra == \"typing\"",
|
||||
"types-urllib3; extra == \"typing\"",
|
||||
"ruff>=0.9.0; extra == \"quality\"",
|
||||
"mypy==1.15.0; extra == \"quality\"",
|
||||
"libcst>=1.4.0; extra == \"quality\"",
|
||||
"ty; extra == \"quality\"",
|
||||
"authlib>=1.3.2; extra == \"all\"",
|
||||
"fastapi; extra == \"all\"",
|
||||
"httpx; extra == \"all\"",
|
||||
"itsdangerous; extra == \"all\"",
|
||||
"jedi; extra == \"all\"",
|
||||
"Jinja2; extra == \"all\"",
|
||||
"pytest>=8.4.2; extra == \"all\"",
|
||||
"pytest-cov; extra == \"all\"",
|
||||
"pytest-env; extra == \"all\"",
|
||||
"pytest-xdist; extra == \"all\"",
|
||||
"pytest-vcr; extra == \"all\"",
|
||||
"pytest-asyncio; extra == \"all\"",
|
||||
"pytest-rerunfailures>=16.2; extra == \"all\"",
|
||||
"pytest-mock; extra == \"all\"",
|
||||
"urllib3<2.0; extra == \"all\"",
|
||||
"soundfile; extra == \"all\"",
|
||||
"Pillow; extra == \"all\"",
|
||||
"numpy; extra == \"all\"",
|
||||
"duckdb; extra == \"all\"",
|
||||
"fastapi; extra == \"all\"",
|
||||
"ruff>=0.9.0; extra == \"all\"",
|
||||
"mypy==1.15.0; extra == \"all\"",
|
||||
"libcst>=1.4.0; extra == \"all\"",
|
||||
"ty; extra == \"all\"",
|
||||
"typing-extensions>=4.8.0; extra == \"all\"",
|
||||
"types-PyYAML; extra == \"all\"",
|
||||
"types-simplejson; extra == \"all\"",
|
||||
"types-toml; extra == \"all\"",
|
||||
"types-tqdm; extra == \"all\"",
|
||||
"types-urllib3; extra == \"all\"",
|
||||
"authlib>=1.3.2; extra == \"dev\"",
|
||||
"fastapi; extra == \"dev\"",
|
||||
"httpx; extra == \"dev\"",
|
||||
"itsdangerous; extra == \"dev\"",
|
||||
"jedi; extra == \"dev\"",
|
||||
"Jinja2; extra == \"dev\"",
|
||||
"pytest>=8.4.2; extra == \"dev\"",
|
||||
"pytest-cov; extra == \"dev\"",
|
||||
"pytest-env; extra == \"dev\"",
|
||||
"pytest-xdist; extra == \"dev\"",
|
||||
"pytest-vcr; extra == \"dev\"",
|
||||
"pytest-asyncio; extra == \"dev\"",
|
||||
"pytest-rerunfailures>=16.2; extra == \"dev\"",
|
||||
"pytest-mock; extra == \"dev\"",
|
||||
"urllib3<2.0; extra == \"dev\"",
|
||||
"soundfile; extra == \"dev\"",
|
||||
"Pillow; extra == \"dev\"",
|
||||
"numpy; extra == \"dev\"",
|
||||
"duckdb; extra == \"dev\"",
|
||||
"fastapi; extra == \"dev\"",
|
||||
"ruff>=0.9.0; extra == \"dev\"",
|
||||
"mypy==1.15.0; extra == \"dev\"",
|
||||
"libcst>=1.4.0; extra == \"dev\"",
|
||||
"ty; extra == \"dev\"",
|
||||
"typing-extensions>=4.8.0; extra == \"dev\"",
|
||||
"types-PyYAML; extra == \"dev\"",
|
||||
"types-simplejson; extra == \"dev\"",
|
||||
"types-toml; extra == \"dev\"",
|
||||
"types-tqdm; extra == \"dev\"",
|
||||
"types-urllib3; extra == \"dev\""
|
||||
],
|
||||
"topLevelPaths": [
|
||||
"huggingface_hub",
|
||||
"huggingface_hub-1.33.0.dist-info"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "tokenizers",
|
||||
"version": "0.22.2",
|
||||
"requires": [
|
||||
"huggingface-hub>=0.16.4,<2.0",
|
||||
"pytest ; extra == 'testing'",
|
||||
"pytest-asyncio ; extra == 'testing'",
|
||||
"requests ; extra == 'testing'",
|
||||
"numpy ; extra == 'testing'",
|
||||
"datasets ; extra == 'testing'",
|
||||
"ruff ; extra == 'testing'",
|
||||
"ty ; extra == 'testing'",
|
||||
"sphinx ; extra == 'docs'",
|
||||
"sphinx-rtd-theme ; extra == 'docs'",
|
||||
"setuptools-rust ; extra == 'docs'",
|
||||
"tokenizers[testing] ; extra == 'dev'"
|
||||
],
|
||||
"topLevelPaths": [
|
||||
"tokenizers",
|
||||
"tokenizers-0.22.2.dist-info"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "typing_extensions",
|
||||
"version": "4.16.0",
|
||||
"requires": [],
|
||||
"topLevelPaths": [
|
||||
"__pycache__",
|
||||
"typing_extensions-4.16.0.dist-info",
|
||||
"typing_extensions.py"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Capability-free Windows AppContainer process launcher.
|
||||
|
||||
The HTTP broker must stay outside this boundary. Only approved immutable runtime,
|
||||
parser source and model directories receive read/execute access. Documents travel
|
||||
through anonymous pipes, never a temporary plaintext file. No network capability
|
||||
or loopback exemption is assigned. Windows supplies the package-private storage.
|
||||
"""
|
||||
import ctypes
|
||||
import hashlib
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from ctypes import wintypes as W
|
||||
|
||||
|
||||
class SandboxError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _verify_read_acl(path, sid):
|
||||
"""Read the DACL directly; startup never needs WRITE_DAC or icacls."""
|
||||
a = ctypes.WinDLL('advapi32', use_last_error=True)
|
||||
k = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||
a.GetNamedSecurityInfoW.argtypes = [W.LPWSTR, W.DWORD, W.DWORD, ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p), ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p)]
|
||||
a.GetNamedSecurityInfoW.restype = W.DWORD
|
||||
a.GetAce.argtypes = [ctypes.c_void_p, W.DWORD, ctypes.POINTER(ctypes.c_void_p)]
|
||||
a.GetAce.restype = W.BOOL
|
||||
a.EqualSid.argtypes = [ctypes.c_void_p, ctypes.c_void_p]; a.EqualSid.restype = W.BOOL
|
||||
k.LocalFree.argtypes = [ctypes.c_void_p]; k.LocalFree.restype = ctypes.c_void_p
|
||||
dacl = ctypes.c_void_p(); descriptor = ctypes.c_void_p()
|
||||
if a.GetNamedSecurityInfoW(str(path), 1, 4, None, None, ctypes.byref(dacl), None, ctypes.byref(descriptor)):
|
||||
raise SandboxError('Approved runtime ACL could not be inspected')
|
||||
try:
|
||||
if not dacl: raise SandboxError('Approved runtime has unrestricted access')
|
||||
count = ctypes.c_ushort.from_address(dacl.value + 4).value
|
||||
allow = 0
|
||||
required = 0x1200a9 # FILE_GENERIC_READ | FILE_GENERIC_EXECUTE
|
||||
for index in range(count):
|
||||
ace = ctypes.c_void_p()
|
||||
if not a.GetAce(dacl, index, ctypes.byref(ace)): raise SandboxError('Runtime ACL is invalid')
|
||||
kind = ctypes.c_ubyte.from_address(ace.value).value
|
||||
flags = ctypes.c_ubyte.from_address(ace.value + 1).value
|
||||
if flags & 8: continue # INHERIT_ONLY does not apply to this object.
|
||||
mask = ctypes.c_uint32.from_address(ace.value + 4).value
|
||||
# Conservative: a relevant deny ACE requires administrator review.
|
||||
if kind == 1 and mask & (required | 0x90000000):
|
||||
raise SandboxError('Approved runtime has conflicting deny permissions')
|
||||
if kind == 0 and a.EqualSid(ctypes.c_void_p(ace.value + 8), sid):
|
||||
if mask & 0x10000000: mask |= 0x1f01ff
|
||||
if mask & 0x80000000: mask |= 0x120089
|
||||
if mask & 0x20000000: mask |= 0x1200a0
|
||||
allow |= mask
|
||||
if allow & required != required:
|
||||
raise SandboxError('Runtime ACL provisioning is required before inspector startup')
|
||||
if allow & 0x400d0116: # write/append/EA/attributes/delete/WRITE_DAC/WRITE_OWNER
|
||||
raise SandboxError('AppContainer runtime access must be read-only')
|
||||
finally:
|
||||
k.LocalFree(descriptor)
|
||||
|
||||
|
||||
def _api():
|
||||
if sys.platform != 'win32':
|
||||
raise SandboxError('AppContainer requires Windows')
|
||||
k = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||
u = ctypes.WinDLL('userenv', use_last_error=True)
|
||||
a = ctypes.WinDLL('advapi32', use_last_error=True)
|
||||
for name, args, result in [
|
||||
('CloseHandle', [W.HANDLE], W.BOOL),
|
||||
('CreatePipe', [ctypes.POINTER(W.HANDLE), ctypes.POINTER(W.HANDLE), ctypes.c_void_p, W.DWORD], W.BOOL),
|
||||
('SetHandleInformation', [W.HANDLE, W.DWORD, W.DWORD], W.BOOL),
|
||||
('InitializeProcThreadAttributeList', [ctypes.c_void_p, W.DWORD, W.DWORD, ctypes.POINTER(ctypes.c_size_t)], W.BOOL),
|
||||
('UpdateProcThreadAttribute', [ctypes.c_void_p, W.DWORD, ctypes.c_size_t, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p, ctypes.c_void_p], W.BOOL),
|
||||
('DeleteProcThreadAttributeList', [ctypes.c_void_p], None),
|
||||
('CreateProcessW', [W.LPCWSTR, W.LPWSTR, ctypes.c_void_p, ctypes.c_void_p, W.BOOL, W.DWORD, ctypes.c_void_p, W.LPCWSTR, ctypes.c_void_p, ctypes.c_void_p], W.BOOL),
|
||||
('WaitForSingleObject', [W.HANDLE, W.DWORD], W.DWORD),
|
||||
('TerminateProcess', [W.HANDLE, W.UINT], W.BOOL),
|
||||
('GetExitCodeProcess', [W.HANDLE, ctypes.POINTER(W.DWORD)], W.BOOL),
|
||||
('LocalFree', [ctypes.c_void_p], ctypes.c_void_p),
|
||||
('CreateJobObjectW', [ctypes.c_void_p, W.LPCWSTR], W.HANDLE),
|
||||
('SetInformationJobObject', [W.HANDLE, ctypes.c_int, ctypes.c_void_p, W.DWORD], W.BOOL),
|
||||
('AssignProcessToJobObject', [W.HANDLE, W.HANDLE], W.BOOL),
|
||||
('ResumeThread', [W.HANDLE], W.DWORD),
|
||||
]:
|
||||
fn = getattr(k, name); fn.argtypes = args; fn.restype = result
|
||||
u.CreateAppContainerProfile.argtypes = [W.LPCWSTR, W.LPCWSTR, W.LPCWSTR, ctypes.c_void_p, W.DWORD, ctypes.POINTER(ctypes.c_void_p)]
|
||||
u.CreateAppContainerProfile.restype = ctypes.c_long
|
||||
u.DeriveAppContainerSidFromAppContainerName.argtypes = [W.LPCWSTR, ctypes.POINTER(ctypes.c_void_p)]
|
||||
u.DeriveAppContainerSidFromAppContainerName.restype = ctypes.c_long
|
||||
a.ConvertSidToStringSidW.argtypes = [ctypes.c_void_p, ctypes.POINTER(W.LPWSTR)]
|
||||
a.ConvertSidToStringSidW.restype = W.BOOL
|
||||
a.FreeSid.argtypes = [ctypes.c_void_p]; a.FreeSid.restype = ctypes.c_void_p
|
||||
return k, u, a
|
||||
|
||||
|
||||
class AppContainer:
|
||||
"""Broker-owned launcher. Caller supplies trusted deployment directories."""
|
||||
def __init__(self, read_directories, *, provision=False):
|
||||
self.k, u, self.a = _api()
|
||||
roots_input = [Path(p).absolute() for p in read_directories]
|
||||
for root in roots_input:
|
||||
for component in [root, *root.parents]:
|
||||
if component.lstat().st_file_attributes & 0x400:
|
||||
raise SandboxError('Reparse point in approved sandbox root')
|
||||
roots = tuple(sorted({str(p.resolve(strict=True)) for p in roots_input}))
|
||||
if not roots or any(not Path(p).is_dir() or Path(p).parent == Path(p) for p in roots):
|
||||
raise SandboxError('Invalid approved sandbox runtime directories')
|
||||
self.name = 'SafetyNet.Parser.' + hashlib.sha256('\0'.join(roots).encode()).hexdigest()[:24]
|
||||
self.sid = ctypes.c_void_p()
|
||||
result = u.CreateAppContainerProfile(self.name, 'SafetyNet parser', 'Offline content inspection', None, 0, ctypes.byref(self.sid))
|
||||
if result != 0 and u.DeriveAppContainerSidFromAppContainerName(self.name, ctypes.byref(self.sid)) != 0:
|
||||
raise SandboxError('AppContainer profile could not be established')
|
||||
sid_text = W.LPWSTR()
|
||||
if not self.a.ConvertSidToStringSidW(self.sid, ctypes.byref(sid_text)):
|
||||
raise SandboxError('AppContainer identity unavailable')
|
||||
try:
|
||||
self.sid_string = sid_text.value
|
||||
finally:
|
||||
self.k.LocalFree(sid_text)
|
||||
# Package SID grants are specific to this deployment, never Everyone or
|
||||
# ALL APPLICATION PACKAGES. No writable permission is added to source.
|
||||
for root in roots:
|
||||
# Reject junctions/symlinks before recursive grants; /L alone is not
|
||||
# sufficient evidence that every possible reparse target stays local.
|
||||
entries = [Path(root)]
|
||||
for directory, folders, files in os.walk(root, followlinks=False):
|
||||
for name in folders + files:
|
||||
entry = Path(directory) / name
|
||||
if entry.lstat().st_file_attributes & 0x400:
|
||||
raise SandboxError('Reparse point in approved sandbox runtime')
|
||||
entries.append(entry)
|
||||
if provision:
|
||||
completed = subprocess.run(['icacls.exe', root, '/grant:r', '*' + self.sid_string + ':(RX)', '/T', '/L', '/Q'],
|
||||
capture_output=True, timeout=60, creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
if completed.returncode:
|
||||
raise SandboxError('Approved runtime access could not be established')
|
||||
for entry in entries:
|
||||
_verify_read_acl(entry, self.sid)
|
||||
self.roots = roots
|
||||
|
||||
def run(self, executable, arguments, data=b'', environment=None, timeout=30, max_output=8*1024*1024):
|
||||
"""One bounded pipe exchange. Child descendants remain in broker Job."""
|
||||
import msvcrt
|
||||
executable = str(Path(executable).resolve(strict=True))
|
||||
if not any(Path(executable).is_relative_to(Path(root)) for root in self.roots):
|
||||
raise SandboxError('Executable is outside approved runtime')
|
||||
class SA(ctypes.Structure):
|
||||
_fields_ = [('length', W.DWORD), ('descriptor', ctypes.c_void_p), ('inherit', W.BOOL)]
|
||||
class SI(ctypes.Structure):
|
||||
_fields_ = [('cb', W.DWORD), ('reserved', W.LPWSTR), ('desktop', W.LPWSTR), ('title', W.LPWSTR),
|
||||
('x', W.DWORD), ('y', W.DWORD), ('xs', W.DWORD), ('ys', W.DWORD), ('xc', W.DWORD), ('yc', W.DWORD),
|
||||
('fill', W.DWORD), ('flags', W.DWORD), ('show', W.WORD), ('reserved2size', W.WORD),
|
||||
('reserved2', ctypes.c_void_p), ('stdin', W.HANDLE), ('stdout', W.HANDLE), ('stderr', W.HANDLE)]
|
||||
class SIX(ctypes.Structure):
|
||||
_fields_ = [('startup', SI), ('attributes', ctypes.c_void_p)]
|
||||
class PI(ctypes.Structure):
|
||||
_fields_ = [('process', W.HANDLE), ('thread', W.HANDLE), ('pid', W.DWORD), ('tid', W.DWORD)]
|
||||
class SC(ctypes.Structure):
|
||||
_fields_ = [('sid', ctypes.c_void_p), ('capabilities', ctypes.c_void_p), ('count', W.DWORD), ('reserved', W.DWORD)]
|
||||
security = SA(ctypes.sizeof(SA), None, True)
|
||||
handles = []
|
||||
attribute = None
|
||||
process = PI()
|
||||
job = None
|
||||
owned_files = []
|
||||
try:
|
||||
ir, iw, or_, ow = W.HANDLE(), W.HANDLE(), W.HANDLE(), W.HANDLE()
|
||||
for read, write in [(ir, iw), (or_, ow)]:
|
||||
if not self.k.CreatePipe(ctypes.byref(read), ctypes.byref(write), ctypes.byref(security), 0):
|
||||
raise SandboxError('Sandbox pipes unavailable')
|
||||
handles.extend([read.value, write.value])
|
||||
for parent_handle in [iw, or_]:
|
||||
if not self.k.SetHandleInformation(parent_handle, 1, 0):
|
||||
raise SandboxError('Sandbox pipe inheritance unavailable')
|
||||
size = ctypes.c_size_t()
|
||||
self.k.InitializeProcThreadAttributeList(None, 2, 0, ctypes.byref(size))
|
||||
attribute = ctypes.create_string_buffer(size.value)
|
||||
if not self.k.InitializeProcThreadAttributeList(attribute, 2, 0, ctypes.byref(size)):
|
||||
raise SandboxError('Sandbox launch attributes unavailable')
|
||||
caps = SC(self.sid, None, 0, 0)
|
||||
inheritance = (W.HANDLE * 2)(ir.value, ow.value)
|
||||
for key, value in [(0x20009, caps), (0x20002, inheritance)]:
|
||||
if not self.k.UpdateProcThreadAttribute(attribute, 0, key, ctypes.byref(value), ctypes.sizeof(value), None, None):
|
||||
raise SandboxError('Sandbox launch boundary unavailable')
|
||||
startup = SIX(); startup.startup.cb = ctypes.sizeof(SIX)
|
||||
startup.startup.flags = 0x100; startup.startup.stdin = ir; startup.startup.stdout = ow; startup.startup.stderr = ow
|
||||
startup.attributes = ctypes.cast(attribute, ctypes.c_void_p)
|
||||
# Explicit environment: no broker credential or inherited deployment secrets.
|
||||
env = {'SystemRoot': os.environ.get('SystemRoot', 'C:\\Windows'), 'PYTHONDONTWRITEBYTECODE': '1', 'PYTHONIOENCODING':'utf-8'}
|
||||
for key in ['USERPROFILE','LOCALAPPDATA','APPDATA','TEMP','TMP','WINDIR']:
|
||||
if key in os.environ: env[key] = os.environ[key]
|
||||
allowed = {'EMBEDDING_MODEL_DIR','EMBEDDING_WORKER_PATH','SEARCHI_FORCE_CPU','GGML_CPU_ONLY','GGML_DISABLE_VULKAN'}
|
||||
if any(key not in allowed or not isinstance(value,str) or '\0' in value for key,value in (environment or {}).items()):
|
||||
raise SandboxError('Unapproved AppContainer environment field')
|
||||
env.update(environment or {})
|
||||
environment_block = ctypes.create_unicode_buffer('\0'.join(f'{key}={value}' for key, value in sorted(env.items())) + '\0\0')
|
||||
command = ctypes.create_unicode_buffer(subprocess.list2cmdline([executable, *arguments]))
|
||||
if not self.k.CreateProcessW(executable, command, None, None, True, 0x80000|0x400|0x8000000|0x4,
|
||||
environment_block, self.roots[0], ctypes.byref(startup), ctypes.byref(process)):
|
||||
raise SandboxError('AppContainer process launch failed: ' + str(ctypes.get_last_error()))
|
||||
# Suspended launch avoids an execution window before descendant cleanup.
|
||||
class BasicLimits(ctypes.Structure):
|
||||
_fields_ = [('processTime', ctypes.c_int64), ('jobTime', ctypes.c_int64), ('flags', W.DWORD),
|
||||
('minWorking', ctypes.c_size_t), ('maxWorking', ctypes.c_size_t), ('processes', W.DWORD),
|
||||
('affinity', ctypes.c_size_t), ('priority', W.DWORD), ('scheduling', W.DWORD)]
|
||||
class Extended(ctypes.Structure):
|
||||
_fields_ = [('basic', BasicLimits), ('io', ctypes.c_uint64 * 6), ('processMemory', ctypes.c_size_t), ('jobMemory', ctypes.c_size_t), ('peakProcess', ctypes.c_size_t), ('peakJob', ctypes.c_size_t)]
|
||||
limits = Extended(); limits.basic.flags = 0x2000 | 0x8; limits.basic.processes = 2
|
||||
job = self.k.CreateJobObjectW(None, None)
|
||||
if not job or not self.k.SetInformationJobObject(job, 9, ctypes.byref(limits), ctypes.sizeof(limits)) or not self.k.AssignProcessToJobObject(job, process.process):
|
||||
self.k.TerminateProcess(process.process, 1)
|
||||
raise SandboxError('AppContainer descendant containment unavailable')
|
||||
if self.k.ResumeThread(process.thread) == 0xffffffff:
|
||||
raise SandboxError('AppContainer launch resume failed')
|
||||
self.k.CloseHandle(process.thread); process.thread = None
|
||||
for child_handle in [ir.value, ow.value]:
|
||||
self.k.CloseHandle(child_handle); handles.remove(child_handle)
|
||||
writer = os.fdopen(msvcrt.open_osfhandle(iw.value, os.O_WRONLY|os.O_BINARY), 'wb', buffering=0)
|
||||
reader = os.fdopen(msvcrt.open_osfhandle(or_.value, os.O_RDONLY|os.O_BINARY), 'rb', buffering=0)
|
||||
handles.remove(iw.value); handles.remove(or_.value); owned_files = [writer, reader]
|
||||
chunks = []; exceeded = threading.Event(); errors = []
|
||||
def write():
|
||||
try:
|
||||
position = 0
|
||||
while position < len(data):
|
||||
count = writer.write(memoryview(data)[position:])
|
||||
if not count: raise OSError()
|
||||
position += count
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
writer.close()
|
||||
def read():
|
||||
total = 0
|
||||
try:
|
||||
while True:
|
||||
chunk = reader.read(65536)
|
||||
if not chunk: break
|
||||
total += len(chunk)
|
||||
if total > max_output:
|
||||
exceeded.set(); self.k.TerminateProcess(process.process, 1); break
|
||||
chunks.append(chunk)
|
||||
except OSError:
|
||||
errors.append(True)
|
||||
threads = [threading.Thread(target=write, daemon=True), threading.Thread(target=read, daemon=True)]
|
||||
for thread in threads: thread.start()
|
||||
if self.k.WaitForSingleObject(process.process, int(timeout*1000)) != 0:
|
||||
self.k.TerminateProcess(process.process, 1)
|
||||
self.k.WaitForSingleObject(process.process, 5000)
|
||||
raise SandboxError('AppContainer inspection deadline exceeded')
|
||||
self.k.CloseHandle(job);job=None
|
||||
for thread in threads: thread.join(2)
|
||||
if exceeded.is_set() or errors or any(thread.is_alive() for thread in threads):
|
||||
raise SandboxError('AppContainer response failed bounded exchange')
|
||||
code = W.DWORD()
|
||||
if not self.k.GetExitCodeProcess(process.process, ctypes.byref(code)):
|
||||
raise SandboxError('AppContainer process status unavailable')
|
||||
return code.value, b''.join(chunks)
|
||||
finally:
|
||||
if job: self.k.CloseHandle(job);job=None
|
||||
for file in owned_files:
|
||||
if not file.closed: file.close()
|
||||
for handle in handles: self.k.CloseHandle(handle)
|
||||
if job: self.k.CloseHandle(job)
|
||||
if process.thread: self.k.CloseHandle(process.thread)
|
||||
if process.process: self.k.CloseHandle(process.process)
|
||||
if attribute is not None: self.k.DeleteProcThreadAttributeList(attribute)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Windows resource containment for the inspector and all descendant workers.
|
||||
|
||||
This is a Job Object, not a restricted-token or filesystem/network sandbox.
|
||||
The inspector owns the handle: terminating it also terminates its model workers.
|
||||
"""
|
||||
import ctypes
|
||||
import sys
|
||||
from ctypes import wintypes
|
||||
|
||||
_job_handle = None
|
||||
MEMORY_LIMIT = 4 * 1024 * 1024 * 1024
|
||||
PROCESS_LIMIT = 4
|
||||
|
||||
|
||||
def contain_inspector():
|
||||
global _job_handle
|
||||
if sys.platform != "win32":
|
||||
return {"active": False, "kind": "unsupported-platform"}
|
||||
if _job_handle:
|
||||
return {"active": True, "kind": "windows-job-v1"}
|
||||
|
||||
class BasicLimits(ctypes.Structure):
|
||||
_fields_ = [("PerProcessUserTimeLimit", ctypes.c_int64),
|
||||
("PerJobUserTimeLimit", ctypes.c_int64), ("LimitFlags", wintypes.DWORD),
|
||||
("MinimumWorkingSetSize", ctypes.c_size_t), ("MaximumWorkingSetSize", ctypes.c_size_t),
|
||||
("ActiveProcessLimit", wintypes.DWORD), ("Affinity", ctypes.c_size_t),
|
||||
("PriorityClass", wintypes.DWORD), ("SchedulingClass", wintypes.DWORD)]
|
||||
|
||||
class IOCounters(ctypes.Structure):
|
||||
_fields_ = [(name, ctypes.c_uint64) for name in
|
||||
("ReadOperationCount", "WriteOperationCount", "OtherOperationCount",
|
||||
"ReadTransferCount", "WriteTransferCount", "OtherTransferCount")]
|
||||
|
||||
class ExtendedLimits(ctypes.Structure):
|
||||
_fields_ = [("BasicLimitInformation", BasicLimits), ("IoInfo", IOCounters),
|
||||
("ProcessMemoryLimit", ctypes.c_size_t), ("JobMemoryLimit", ctypes.c_size_t),
|
||||
("PeakProcessMemoryUsed", ctypes.c_size_t), ("PeakJobMemoryUsed", ctypes.c_size_t)]
|
||||
|
||||
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR]
|
||||
kernel.CreateJobObjectW.restype = wintypes.HANDLE
|
||||
kernel.SetInformationJobObject.argtypes = [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD]
|
||||
kernel.SetInformationJobObject.restype = wintypes.BOOL
|
||||
kernel.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
|
||||
kernel.AssignProcessToJobObject.restype = wintypes.BOOL
|
||||
kernel.GetCurrentProcess.restype = wintypes.HANDLE
|
||||
kernel.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
|
||||
handle = kernel.CreateJobObjectW(None, None)
|
||||
if not handle:
|
||||
raise RuntimeError("Inspector resource containment could not be created")
|
||||
limits = ExtendedLimits()
|
||||
# KILL_ON_JOB_CLOSE | JOB_MEMORY | ACTIVE_PROCESS. No BREAKAWAY flags.
|
||||
limits.BasicLimitInformation.LimitFlags = 0x2000 | 0x200 | 0x8
|
||||
limits.BasicLimitInformation.ActiveProcessLimit = PROCESS_LIMIT
|
||||
limits.JobMemoryLimit = MEMORY_LIMIT
|
||||
if not kernel.SetInformationJobObject(handle, 9, ctypes.byref(limits), ctypes.sizeof(limits)):
|
||||
kernel.CloseHandle(handle)
|
||||
raise RuntimeError("Inspector resource containment limits could not be applied")
|
||||
if not kernel.AssignProcessToJobObject(handle, kernel.GetCurrentProcess()):
|
||||
kernel.CloseHandle(handle)
|
||||
raise RuntimeError("Inspector resource containment could not be attached")
|
||||
# Retain a non-inheritable owner handle until process exit. Do not close early:
|
||||
# KILL_ON_JOB_CLOSE also terminates the current inspector process.
|
||||
_job_handle = handle
|
||||
return {"active": True, "kind": "windows-job-v1", "memoryBytes": MEMORY_LIMIT,
|
||||
"maxProcesses": PROCESS_LIMIT, "killsDescendants": True}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Route permissions are enforced before handlers, including routes installed early.
|
||||
// Permissions from a session payload are never accepted as authority.
|
||||
export const adminRoles=Object.freeze({
|
||||
admin:Object.freeze(['evidence.read','incidents.read','incidents.triage','overview.read','audit.read','policy.read','policy.prepare','policy.write','devices.manage','bookmarks.read','bookmarks.write','teaching.read','teaching.write','integrations.test','sessions.manage']),
|
||||
operator:Object.freeze(['incidents.read','incidents.triage','overview.read','audit.read','policy.read','policy.prepare','devices.manage','bookmarks.read','bookmarks.write','teaching.read','teaching.write','integrations.test']),
|
||||
auditor:Object.freeze(['incidents.read','overview.read','audit.read','policy.read','teaching.read'])
|
||||
});
|
||||
export function bootstrapAllowed(config){
|
||||
try{const url=new URL(config.publicUrl);return url.protocol==='http:'&&['127.0.0.1','localhost','[::1]'].includes(url.hostname);}catch{return false;}
|
||||
}
|
||||
export function normalizePrincipal(principal){
|
||||
const role=principal.role??(principal.admin===true?'admin':null);
|
||||
if(!Object.hasOwn(adminRoles,role))return null;
|
||||
return {sub:principal.sub,name:principal.name,admin:role==='admin',role,permissions:[...adminRoles[role]]};
|
||||
}
|
||||
export function principalForGroups(identity,groups,config){
|
||||
const list=Array.isArray(groups)?groups:[];
|
||||
const role=list.includes(config.oidcAdminGroup)?'admin':config.oidcOperatorGroup&&list.includes(config.oidcOperatorGroup)?'operator':config.oidcAuditorGroup&&list.includes(config.oidcAuditorGroup)?'auditor':null;
|
||||
return role?normalizePrincipal({...identity,role}):{...identity,admin:false};
|
||||
}
|
||||
export function adminRoutePermission(method,url){
|
||||
const path=new URL(url,'http://localhost').pathname;
|
||||
if(method==='GET'){
|
||||
if(/^\/api\/admin\/evidence\/[^/]+$/.test(path))return 'evidence.read';
|
||||
if(/^\/api\/admin\/incidents(?:\/[^/]+)?$/.test(path))return 'incidents.read';
|
||||
if(path==='/api/admin/user-profiles')return 'policy.read';
|
||||
if(path==='/api/admin/policy-groups')return 'policy.read';
|
||||
if(/^\/api\/admin\/policy-reviews(?:\/[^/]+)?$/.test(path))return 'policy.read';
|
||||
if(path==='/api/admin/policy/history'||/^\/api\/admin\/policy\/history\/\d+$/.test(path)||path==='/api/admin/policy/diff')return 'policy.read';
|
||||
if(path==='/api/admin/sessions')return 'sessions.manage';
|
||||
if(path==='/api/admin/overview'||path==='/api/admin/session')return 'overview.read';
|
||||
if(path==='/api/admin/events/export')return 'audit.read';
|
||||
if(path==='/api/admin/teaching')return 'teaching.read';
|
||||
if(/^\/api\/admin\/devices\/[^/]+\/bookmarks$/.test(path))return 'bookmarks.read';
|
||||
if(['/api/admin/management-channel','/api/admin/presets','/api/admin/integrations'].includes(path))return 'policy.read';
|
||||
}
|
||||
if(method==='POST'&&path==='/api/admin/incidents')return 'incidents.triage';
|
||||
if(method==='PATCH'&&/^\/api\/admin\/incidents\/[^/]+$/.test(path))return 'incidents.triage';
|
||||
if(method==='POST'&&path==='/api/admin/policy-reviews')return 'policy.prepare';
|
||||
if(method==='POST'&&path==='/api/admin/user-profiles/preview')return 'policy.read';
|
||||
if(method==='PUT'&&path==='/api/admin/user-profile-configuration')return 'policy.write';
|
||||
if(['PUT','DELETE'].includes(method)&&/^\/api\/admin\/user-profiles\/[^/]+$/.test(path))return 'policy.write';
|
||||
if(method==='POST'&&path==='/api/admin/policy-groups/preview')return 'policy.read';
|
||||
if(['PUT','DELETE'].includes(method)&&/^\/api\/admin\/policy-groups\/[^/]+$/.test(path))return 'policy.write';
|
||||
if(method==='POST'&&/^\/api\/admin\/policy-reviews\/[^/]+\/(approve|reject)$/.test(path))return 'policy.write';
|
||||
if(method==='POST'&&path==='/api/admin/policy/restore')return 'policy.write';
|
||||
if(method==='POST'&&/^\/api\/admin\/sessions\/[^/]+\/revoke$/.test(path))return 'sessions.manage';
|
||||
if(method==='PUT'&&/^\/api\/admin\/devices\/[^/]+\/bookmarks$/.test(path))return 'bookmarks.write';
|
||||
if(method==='POST'&&(path==='/api/admin/enrollments'||/^\/api\/admin\/devices\/[^/]+\/revoke$/.test(path)))return 'devices.manage';
|
||||
// Per-device inspection changes publish the shared policy version.
|
||||
if(method==='PATCH'&&/^\/api\/admin\/devices\/[^/]+\/inspection$/.test(path))return 'policy.write';
|
||||
if(method==='PUT'&&['/api/admin/policy','/api/admin/security/blocklists'].includes(path))return 'policy.write';
|
||||
if(method==='POST'&&/^\/api\/admin\/teaching\/[^/]+\/publish$/.test(path))return 'policy.write';
|
||||
if(method==='POST'&&path==='/api/admin/integrations/icap/probe')return 'integrations.test';
|
||||
if(method==='POST'&&['/api/admin/teaching','/api/admin/semantic/reference','/api/admin/semantic/document-reference','/api/admin/security/visual-reference'].includes(path))return 'teaching.write';
|
||||
if(method==='POST'&&['/api/admin/policy/test','/api/admin/presets/preview'].includes(path))return 'policy.read';
|
||||
return null; // New endpoints require an explicit permission; never infer from HTTP method.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import express from 'express';
|
||||
import transferFacts from '../../packages/core/transfer-facts.cjs';
|
||||
import {installEvidence} from './evidence.mjs';
|
||||
import {installIncidents} from './incidents.mjs';
|
||||
import { installBookmarkRoutes,globalBookmarksValid } from './bookmarks.mjs';
|
||||
import { z } from 'zod';
|
||||
import { randomUUID,timingSafeEqual } from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PolicySchema, inspect, inspectForAI, allowsConfidentialLocal } from '../../packages/core/policy.mjs';
|
||||
import { digest, token } from './store.mjs';
|
||||
import {policyPublisher} from './policy-review.mjs';
|
||||
import {installPolicyHistory} from './policy-history.mjs';
|
||||
import {installUserProfiles} from './user-profiles.mjs';
|
||||
import {installPolicyGroups} from './policy-groups.mjs';
|
||||
import { adminMiddleware, authRoutes,cookie } from './auth.mjs';
|
||||
import {bootstrapAllowed,normalizePrincipal} from './admin-access.mjs';
|
||||
import { tracer } from './telemetry.mjs';
|
||||
import { toECS,toCEF } from '../../packages/core/siem.mjs';
|
||||
import { inspector } from './inspection.mjs';
|
||||
import { SecuritySchema, VisualBrandSchema, VisualOriginSchema } from '../../packages/core/security.mjs';
|
||||
import { defaultSemantic } from '../../packages/core/semantic.mjs';
|
||||
import { installPolicyChannel } from './policy-channel.mjs';
|
||||
import enterpriseInspection from '../browser/enterprise-inspection.cjs';
|
||||
import icapClient from '../browser/icap-client.cjs';
|
||||
import {EnterpriseSchema} from '../../packages/core/enterprise.mjs';
|
||||
import { policyPresets, previewPreset } from '../../packages/core/presets.mjs';
|
||||
import { enterpriseReadiness } from '../../packages/core/enterprise.mjs';
|
||||
|
||||
export function createApp(store,config) {
|
||||
const app=express();app.disable('x-powered-by');
|
||||
const limits=new Map();
|
||||
app.use((req,res,next)=>{
|
||||
res.set({'X-Content-Type-Options':'nosniff','Referrer-Policy':'no-referrer','Cache-Control':'no-store',
|
||||
'Content-Security-Policy':"default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"});
|
||||
if(!['GET','HEAD','OPTIONS'].includes(req.method)) {
|
||||
if(req.headers.origin && req.headers.origin!==config.publicUrl) return res.status(403).json({error:'Origin rejected'});
|
||||
if(req.headers['sec-fetch-site']==='cross-site') return res.status(403).json({error:'Cross-site request rejected'});
|
||||
}
|
||||
const now=Date.now(),auth=(req.headers.authorization||'').replace(/^Bearer /,'');
|
||||
// Corporate NATs share an IP. Only an authenticated, active device gets its
|
||||
// own bucket; arbitrary bearer strings cannot evade the IP limit.
|
||||
const enrolled=auth.length>=32&&auth.length<=200?store.db.prepare('SELECT id FROM devices WHERE token_hash=? AND revoked=0').get(digest(auth)):null;
|
||||
const bootstrap=bootstrapAllowed(config)&&auth&&config.adminToken&&timingSafeEqual(Buffer.from(digest(auth)),Buffer.from(digest(config.adminToken)));
|
||||
const sessionCredential=cookie(req,'sn_session');
|
||||
const sessionIdentity=!enrolled&&!bootstrap&&sessionCredential.length>=32&&sessionCredential.length<=200?store.db.prepare('SELECT hash FROM sessions WHERE hash=? AND expires>?').get(digest(sessionCredential),now):null;
|
||||
const key=enrolled?`device:${enrolled.id}`:bootstrap?'admin:bootstrap':sessionIdentity?`admin:${sessionIdentity.hash}`:`ip:${req.socket.remoteAddress||'unknown'}`;
|
||||
let count=limits.get(key);if(!count || count.until<now) {count={until:now+60000,n:0};limits.set(key,count);}
|
||||
if(++count.n>300) return res.status(429).json({error:'Rate limit exceeded'});
|
||||
if(limits.size>10000)for(const [k,v] of limits)if(v.until<now)limits.delete(k);
|
||||
next();
|
||||
});
|
||||
app.use(express.json({limit:'2mb'}));
|
||||
app.get('/health',(_req,res)=>res.json({status:'ok',version:'0.1.0'}));
|
||||
app.get('/api/auth/status',(_req,res)=>res.json({sso:!!config.oidcIssuer,publicUrl:config.publicUrl,bootstrapAllowed:bootstrapAllowed(config)}));
|
||||
authRoutes(app,store,config);
|
||||
const admin=adminMiddleware(store,config);
|
||||
const device=(req,res,next)=>{
|
||||
const auth=(req.headers.authorization||'').replace(/^Bearer /,'');
|
||||
const d=store.db.prepare('SELECT * FROM devices WHERE token_hash=? AND revoked=0').get(digest(auth));
|
||||
if(!d)return res.status(401).json({error:'Device is unenrolled or revoked'});
|
||||
req.device=d;next();
|
||||
};
|
||||
installBookmarkRoutes(app,store,device,admin);
|
||||
installEvidence(app,store,config,device,admin);
|
||||
const managementChannel=installPolicyChannel(app,store,device);
|
||||
app.get('/api/admin/management-channel',admin,(_req,res)=>res.json({version:managementChannel.version,signatureKey:managementChannel.signatureKey,requirePQManagement:!!config.requirePQManagement}));
|
||||
function enroll(user,body,protectedBootstrap=false,verified=false) {
|
||||
const b=z.object({name:z.string().min(1).max(100),platform:z.enum(['win32','darwin','linux'])}).parse(body);
|
||||
const id=randomUUID(),credential=token();
|
||||
store.db.prepare('INSERT INTO devices(id,name,platform,user,token_hash,last_seen) VALUES(?,?,?,?,?,?)').run(id,b.name,b.platform,user,digest(credential),new Date().toISOString());
|
||||
if(verified)store.db.prepare('UPDATE devices SET verified_subject=? WHERE id=?').run(user,id);
|
||||
store.event(id,'device.enrolled',{platform:b.platform,actor:user});
|
||||
return {id,token:credential,publicKey:store.publicKey(),...(!config.requirePQManagement&&!protectedBootstrap?{policy:store.envelope(id)}:{}),managementChannel};
|
||||
}
|
||||
app.post('/api/device/enroll',(req,res)=>{
|
||||
const code=z.string().min(20).max(200).parse(req.body.code);
|
||||
const e=store.db.prepare('SELECT * FROM enrollments WHERE hash=? AND expires>?').get(digest(code),Date.now());
|
||||
if(!e)return res.status(401).json({error:'Enrollment code invalid or expired'});
|
||||
// Validate before consuming the one-use code.
|
||||
z.object({name:z.string().min(1).max(100),platform:z.enum(['win32','darwin','linux'])}).parse(req.body);
|
||||
store.db.prepare('DELETE FROM enrollments WHERE hash=?').run(e.hash);res.json(enroll(e.user,req.body,req.headers['x-safetynet-pq-bootstrap']==='1'));
|
||||
});
|
||||
app.post('/api/device/sso/start',(req,res)=>{
|
||||
if(!config.oidcIssuer)return res.status(409).json({error:'SSO not configured'});
|
||||
const id=token(),secret=token();store.db.prepare('INSERT INTO tickets VALUES(?,?,?,?)').run(id,digest(secret),Date.now()+300000,null);
|
||||
res.json({id,secret,url:`${config.publicUrl}/auth/login?ticket=${id}`});
|
||||
});
|
||||
app.post('/api/device/sso/poll',(req,res)=>{
|
||||
const b=z.object({id:z.string().max(100),secret:z.string().max(100),name:z.string().min(1).max(100),platform:z.enum(['win32','darwin','linux'])}).parse(req.body);
|
||||
const t=store.db.prepare('SELECT * FROM tickets WHERE id=? AND secret_hash=? AND expires>?').get(b.id,digest(b.secret),Date.now());
|
||||
if(!t)return res.status(401).json({error:'Device login expired'});
|
||||
if(!t.credential)return res.status(202).json({pending:true});
|
||||
store.db.prepare('DELETE FROM tickets WHERE id=?').run(b.id);res.json(enroll(JSON.parse(t.credential).sub,b,req.headers['x-safetynet-pq-bootstrap']==='1',true));
|
||||
});
|
||||
app.get('/api/device/policy',device,(req,res)=>config.requirePQManagement?res.status(403).json({error:'Protected management channel required'}):res.json(store.envelope(req.device.id)));
|
||||
app.post('/api/device/heartbeat',device,(req,res)=>{
|
||||
const b=z.object({policyVersion:z.number().int().nonnegative()}).parse(req.body);
|
||||
store.db.prepare('UPDATE devices SET last_seen=?,policy_version=? WHERE id=?').run(new Date().toISOString(),b.policyVersion,req.device.id);res.json({ok:true});
|
||||
});
|
||||
app.post('/api/device/events',device,(req,res)=>{
|
||||
const b=z.object({eventId:z.string().uuid(),kind:z.enum(['dlp.block','navigation.block','download.block','policy.applied','browser.started','agent.action','agent.denied','devtools.open-requested','devtools.opened','devtools.closed','devtools.denied','download.scan','download.released','upload.authorized','upload.block','dns.block','content.block','vault.created','vault.unlocked','vault.locked','vault.saved','vault.deleted','vault.filled','vault.fill-requested','phishing.block','phishing.error','phishing.scanned','clipboard.block','clipboard.copy','clipboard.cut','clipboard.paste']),ruleId:z.string().max(100).optional(),host:z.string().max(253).optional(),policyVersion:z.number().int().positive(),traceId:z.string().regex(/^[a-f0-9]{32}$/).optional(),spanId:z.string().regex(/^[a-f0-9]{16}$/).optional(),file:transferFacts.FileFactsSchema.optional()}).strict().parse(req.body);
|
||||
if(b.file&&!b.kind.startsWith('download.')&&!b.kind.startsWith('upload.'))return res.status(400).json({error:'File facts require a transfer event'});
|
||||
if(b.kind.startsWith('vault.')&&(b.host!==undefined||b.ruleId!==undefined))return res.status(400).json({error:'Vault audit events accept lifecycle metadata only'});
|
||||
store.db.exec('BEGIN IMMEDIATE');try{
|
||||
const inserted=store.db.prepare('INSERT OR IGNORE INTO device_events VALUES(?,?)').run(req.device.id,b.eventId);
|
||||
if(inserted.changes)store.event(req.device.id,b.kind,b);store.db.exec('COMMIT');res.json({ok:true});
|
||||
}catch(e){store.db.exec('ROLLBACK');throw e;}
|
||||
});
|
||||
app.post('/api/device/ai',device,async(req,res,next)=>{
|
||||
await tracer.startActiveSpan('ai.gateway',async span=>{
|
||||
try {
|
||||
const b=z.object({prompt:z.string().min(1).max(100000)}).strict().parse(req.body),p=store.effectivePolicy(req.device.id);
|
||||
if(Date.parse(p.expiresAt)<=Date.now())return res.status(403).json({error:'Policy expired'});
|
||||
const local=p.ai.trustMode==='local';
|
||||
if(!p.ai.enabled || !config.aiBaseUrl || (!local&&!config.aiKey) || p.ai.model!==config.aiModel || p.ai.endpointId!==(config.aiEndpointId||'default'))return res.status(409).json({error:'Approved AI provider is not configured'});
|
||||
const endpoint=new URL(`${config.aiBaseUrl.replace(/\/$/,'')}/${p.ai.provider==='ollama'?'api/chat':'chat/completions'}`);
|
||||
if(endpoint.username||endpoint.password||endpoint.search||endpoint.hash)throw Error('AI endpoint credentials and query forbidden');
|
||||
if(local ? !(['127.0.0.1','[::1]'].includes(endpoint.hostname)&&['http:','https:'].includes(endpoint.protocol)) : endpoint.protocol!=='https:')throw Error('AI endpoint trust policy rejected');
|
||||
async function aiInspect(text){let verdict=inspectForAI(Buffer.from(text),p);if(verdict.decision==='allow'&&(p.dlp.semantic.enabled||p.security.yara.enabled)){const semantic=await inspector(config,'/inspect',{data:Buffer.from(text).toString('base64'),policy:p});if(semantic.decision==='block')verdict=allowsConfidentialLocal(semantic,p)?{...semantic,decision:'allow',classification:'local-only'}:semantic;}if(verdict.decision==='allow'){const external=await enterpriseInspection.scanEnterprise(Buffer.from(text),p,'ai');if(external.decision!=='allow')verdict=external;}return verdict;}
|
||||
const verdict=await aiInspect(b.prompt);
|
||||
store.event(req.device.id,'ai.decision',{...verdict,policyVersion:p.version});
|
||||
span.setAttribute('dlp.decision',verdict.decision);
|
||||
if(verdict.decision==='block')return res.status(403).json({error:'Company policy blocked this prompt',ruleId:verdict.ruleId});
|
||||
const upstream=await fetch(endpoint,{method:'POST',redirect:'error',headers:{'content-type':'application/json',...(config.aiKey?{authorization:`Bearer ${config.aiKey}`}:{})},body:JSON.stringify({model:p.ai.model,messages:[{role:'user',content:b.prompt}],stream:false}),signal:AbortSignal.timeout(60000)});
|
||||
if(!upstream.ok)return res.status(502).json({error:'AI provider request failed'});
|
||||
const text=await readLimited(upstream,2*1024*1024);const result=JSON.parse(text);
|
||||
const answer=z.string().max(100000).parse(p.ai.provider==='ollama'?result.message?.content:result.choices?.[0]?.message?.content);
|
||||
const output=await aiInspect(answer);
|
||||
store.event(req.device.id,'ai.output-decision',{...output,policyVersion:p.version});
|
||||
if(output.decision==='block') {store.event(req.device.id,'ai.output-block',{ruleId:output.ruleId,policyVersion:p.version});return res.status(403).json({error:'AI response blocked by company policy',ruleId:output.ruleId});}
|
||||
res.json({answer});
|
||||
}catch(e){store.event(req.device.id,'ai.failure',{policyVersion:store.get('policy').version});next(e);}finally{span.end();}
|
||||
});
|
||||
});
|
||||
app.use('/api/admin',admin);
|
||||
installIncidents(app,store);
|
||||
app.get('/api/admin/session',(req,res)=>res.json({principal:req.principal}));
|
||||
app.get('/api/admin/sessions',(req,res)=>{
|
||||
const rows=store.db.prepare('SELECT hash,expires,principal FROM sessions WHERE expires>? ORDER BY expires DESC LIMIT 501').all(Date.now());
|
||||
const sessions=[];store.db.exec('BEGIN IMMEDIATE');
|
||||
try{
|
||||
for(const row of rows.slice(0,500)){
|
||||
const principal=normalizePrincipal(JSON.parse(row.principal));if(!principal)continue;
|
||||
let handle=store.db.prepare('SELECT handle FROM management_session_handles WHERE session_hash=?').get(row.hash)?.handle;
|
||||
if(!handle){handle=randomUUID();store.db.prepare('INSERT INTO management_session_handles VALUES(?,?)').run(handle,row.hash);}
|
||||
sessions.push({id:handle,subject:principal.sub,name:principal.name||principal.sub,role:principal.role,expiresAt:new Date(row.expires).toISOString()});
|
||||
}
|
||||
store.event(null,'management.sessions-reviewed',{actor:req.principal.sub,count:sessions.length});store.db.exec('COMMIT');
|
||||
}catch(error){store.db.exec('ROLLBACK');throw error;}
|
||||
res.json({sessions,truncated:rows.length>500});
|
||||
});
|
||||
app.post('/api/admin/sessions/:id/revoke',(req,res)=>{
|
||||
const id=z.string().uuid().parse(req.params.id);z.object({}).strict().parse(req.body);
|
||||
const target=store.db.prepare('SELECT session_hash FROM management_session_handles WHERE handle=?').get(id);
|
||||
if(!target)return res.status(404).json({error:'Management session not found'});
|
||||
store.db.exec('BEGIN IMMEDIATE');
|
||||
try{const result=store.db.prepare('DELETE FROM sessions WHERE hash=?').run(target.session_hash);if(!result.changes)throw Error('Session revocation conflict');store.event(null,'management.session-revoked',{actor:req.principal.sub,count:1});store.db.exec('COMMIT');}
|
||||
catch(error){store.db.exec('ROLLBACK');throw error;}
|
||||
res.json({ok:true,revoked:1});
|
||||
});
|
||||
app.post('/api/admin/integrations/icap/probe',async(req,res,next)=>{
|
||||
try{const settings=EnterpriseSchema.parse({icap:req.body}).icap;const result=await icapClient.probeIcap(settings,enterpriseInspection.deploymentTLS());store.event(null,'integration.tested',{actor:req.principal.sub,integration:'icap',ready:result.ready});res.json({...result,scope:'management-server',check:'options-only',contentSent:false});}
|
||||
catch{res.status(400).json({error:'ICAP configuration or deployment certificates are unavailable'});}
|
||||
});
|
||||
app.get('/api/admin/presets',(_req,res)=>res.json({presets:policyPresets}));
|
||||
app.get('/api/admin/integrations',(_req,res)=>res.json({readiness:enterpriseReadiness(store.get('policy'))}));
|
||||
app.post('/api/admin/presets/preview',(req,res)=>{const b=z.object({id:z.string().max(80),policy:PolicySchema}).strict().parse(req.body);if(!policyPresets.some(p=>p.id===b.id))return res.status(400).json({error:'Unknown enterprise preset'});res.json(previewPreset(b.policy,b.id));});
|
||||
const publisher=policyPublisher(app,store,config);
|
||||
installPolicyGroups(app,store,publisher);
|
||||
installUserProfiles(app,store,publisher);
|
||||
installPolicyHistory(app,store,publisher);
|
||||
app.put('/api/admin/security/blocklists',(req,res)=>{
|
||||
const b=z.object({expectedVersion:z.number().int().positive(),blockedHosts:z.array(z.string()).max(10000),blockedIPs:z.array(z.string()).max(10000)}).strict().parse(req.body);
|
||||
SecuritySchema.parse({dns:{blockedHosts:b.blockedHosts,blockedIPs:b.blockedIPs}});
|
||||
const p=PolicySchema.parse(store.get('policy'));if(p.version!==b.expectedVersion)return res.status(409).json({error:'Policy version conflict'});
|
||||
p.security.dns.blockedHosts=b.blockedHosts;p.security.dns.blockedIPs=b.blockedIPs;p.version++;
|
||||
publisher.submit(req,res,p,'security.blocklists-published');
|
||||
});
|
||||
app.post('/api/admin/security/visual-reference',async(req,res,next)=>{
|
||||
try{
|
||||
const b=z.object({expectedVersion:z.number().int().positive(),brandId:z.string().min(1).max(80),label:z.string().min(1).max(100),allowedOrigins:z.array(VisualOriginSchema).min(1).max(50),data:z.string().max(1800000).regex(/^[A-Za-z0-9+/]*={0,2}$/),scope:z.enum(['viewport','login-form'])}).strict().parse(req.body);
|
||||
if(store.get('policy').version!==b.expectedVersion)return res.status(409).json({error:'Policy version conflict'});
|
||||
const bytes=Buffer.from(b.data,'base64');if(!bytes.length||bytes.length>1024*1024||bytes.toString('base64')!==b.data)return res.status(400).json({error:'Reference must be canonical base64 PNG up to 1 MB'});
|
||||
const {fingerprintPNG}=await import('../../packages/core/visual-image.cjs');const result=fingerprintPNG(bytes);
|
||||
if(result.usable!==true)return res.status(400).json({error:'Reference has insufficient visual detail; use a trusted nonblank login screenshot'});
|
||||
const reference={hash:result.hash,algorithm:'phash-dct32-v1',scope:b.scope,viewport:{width:result.width,height:result.height}};
|
||||
const brand=VisualBrandSchema.parse({id:b.brandId,label:b.label,allowedOrigins:b.allowedOrigins,references:[reference]});
|
||||
store.event(null,'phishing.reference-derived',{actor:req.principal.sub,ruleId:b.brandId,version:b.expectedVersion});
|
||||
const quality=z.object({contrast:z.number().finite(),stddev:z.number().finite(),dynamicRange:z.number().finite(),entropy:z.number().finite(),detail:z.number().finite()}).parse(result.quality);
|
||||
res.json({brand,quality:{...quality,width:result.width,height:result.height},warning:'Draft only. Explicitly publish signed policy to enable this reference. Perceptual similarity is not proof of a phishing page; use trusted reference images and validate false positives.'});
|
||||
}catch(e){next(e);}
|
||||
});
|
||||
app.get('/api/admin/teaching',(_req,res)=>res.json({drafts:store.db.prepare('SELECT * FROM teaching ORDER BY time DESC LIMIT 100').all().map(r=>({...r,reference:JSON.parse(r.reference)}))}));
|
||||
app.post('/api/admin/teaching',async(req,res,next)=>{
|
||||
try{const b=z.object({label:z.string().min(1).max(100),data:z.string().max(1400000).regex(/^[A-Za-z0-9+/]*={0,2}$/),filename:z.string().max(200)}).strict().parse(req.body);const bytes=Buffer.from(b.data,'base64');if(!bytes.length||bytes.length>1024*1024)return res.status(413).json({error:'Reference document limit is 1 MB'});
|
||||
const reference=await inspector(config,'/reference-document',{data:b.data,filename:b.filename});
|
||||
const id=randomUUID();store.db.prepare('INSERT INTO teaching VALUES(?,?,?,?,?,?,?,?)').run(id,new Date().toISOString(),req.principal.sub,b.label,'draft',JSON.stringify(reference),null,null);store.event(null,'teaching.drafted',{actor:req.principal.sub,referenceId:id});res.json({id,reference,status:'draft'});
|
||||
}catch(e){next(e);}
|
||||
});
|
||||
app.post('/api/admin/teaching/:id/publish',(req,res)=>{
|
||||
const b=z.object({expectedVersion:z.number().int().positive(),approve:z.literal(true),mode:z.enum(['complete','available-fingerprints']).default('complete')}).strict().parse(req.body),draft=store.db.prepare("SELECT * FROM teaching WHERE id=? AND status='draft'").get(req.params.id);
|
||||
if(!draft)return res.status(404).json({error:'Draft not found'});const p=PolicySchema.parse(store.get('policy'));if(p.version!==b.expectedVersion)return res.status(409).json({error:'Policy version conflict'});
|
||||
const reference=JSON.parse(draft.reference);if(!reference.registrationReady&&b.mode==='complete')return res.status(409).json({error:'Complete local inspection required before publication; available-fingerprints mode can explicitly approve partial coverage'});p.dlp.rules.push({id:draft.id,label:draft.label,kind:'sha256',value:reference.sha256,action:'block'});
|
||||
for(const [index,value] of reference.fragmentHashes.slice(0,1000).entries())p.dlp.rules.push({id:`${draft.id}:${index}`,label:draft.label,kind:'fragment-sha256',value,action:'block'});p.version++;PolicySchema.parse(p);
|
||||
const coverage=reference.registrationReady?'complete':'partial';publisher.submit(req,res,p,'teaching.published',{type:'teaching',id:draft.id,coverage,mode:b.mode},{coverage});
|
||||
});
|
||||
app.get('/api/admin/overview',(req,res)=>{
|
||||
let policy,migrationRequired=false;try{policy=store.get('policy');}catch(error){if(!(error instanceof z.ZodError))throw error;const raw=store.getRaw('policy');policy=PolicySchema.parse({...raw,dlp:{...raw.dlp,semantic:defaultSemantic()}});migrationRequired=true;}
|
||||
const devices=store.db.prepare('SELECT id,name,platform,user,revoked,last_seen,policy_version,document_analysis_enabled FROM devices ORDER BY last_seen DESC').all().map(d=>({...d,documentAnalysisEnabled:d.document_analysis_enabled===null?policy.inspection.documentAnalysisEnabled:!!d.document_analysis_enabled}));
|
||||
const alerts=store.db.prepare('SELECT * FROM alerts ORDER BY id DESC LIMIT 100').all();
|
||||
const events=store.db.prepare('SELECT * FROM events ORDER BY id DESC LIMIT 100').all().map(e=>({...e,metadata:JSON.parse(e.metadata)}));
|
||||
res.json({policy,migrationRequired,publicKey:store.publicKey(),devices,events,alerts,principal:req.principal,siem:config.siemStatus?.()||null,auditRetention:config.auditRetentionStatus?.()||{enabled:false},governance:{requireIndependentPolicyApproval:!!config.requireIndependentPolicyApproval}});
|
||||
});
|
||||
app.get('/api/admin/events/export',(req,res)=>{
|
||||
const query=z.object({after:z.coerce.number().int().nonnegative().default(0),limit:z.coerce.number().int().min(1).max(1000).default(100),format:z.enum(['ecs','cef']).default('ecs')}).parse(req.query);
|
||||
const rows=store.db.prepare('SELECT * FROM events WHERE id>? ORDER BY id LIMIT ?').all(query.after,query.limit).map(e=>({...e,metadata:JSON.parse(e.metadata)}));
|
||||
res.setHeader('X-SafetyNet-Next-Cursor',String(rows.at(-1)?.id??query.after));
|
||||
res.type(query.format==='ecs'?'application/x-ndjson':'text/plain').send(rows.map(e=>query.format==='ecs'?JSON.stringify(toECS(e,store.get('installationId'))):toCEF(e,store.get('installationId'))).join('\n')+(rows.length?'\n':''));
|
||||
});
|
||||
app.put('/api/admin/policy',(req,res)=>{
|
||||
const p=PolicySchema.parse(req.body),previous=store.getRaw('policy');
|
||||
if(!globalBookmarksValid(store,p))return res.status(400).json({error:'Global policies require empty personal bookmarks and distinct company IDs'});
|
||||
if(p.version!==previous.version+1)return res.status(409).json({error:'Refresh the console: version must increase by exactly one'});
|
||||
const expires=Date.parse(p.expiresAt);if(expires<=Date.now() || expires>Date.now()+30*86400000)return res.status(400).json({error:'Policy expiry must be within the next 30 days'});
|
||||
publisher.submit(req,res,p);
|
||||
});
|
||||
app.post('/api/admin/enrollments',(req,res)=>{
|
||||
const user=z.string().min(1).max(200).parse(req.body.user),code=token();
|
||||
store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,user);
|
||||
store.event(null,'enrollment.created',{actor:req.principal.sub,user});res.json({code,expiresInSeconds:600});
|
||||
});
|
||||
app.post('/api/admin/devices/:id/revoke',(req,res)=>{
|
||||
const r=store.db.prepare('UPDATE devices SET revoked=1 WHERE id=? AND revoked=0').run(req.params.id);
|
||||
if(!r.changes)return res.status(404).json({error:'Active device not found'});
|
||||
store.event(req.params.id,'device.revoked',{actor:req.principal.sub});res.json({ok:true});
|
||||
});
|
||||
app.patch('/api/admin/devices/:id/inspection',(req,res)=>{
|
||||
const b=z.object({expectedVersion:z.number().int().positive(),documentAnalysisEnabled:z.boolean()}).strict().parse(req.body),p=store.get('policy');
|
||||
if(p.version!==b.expectedVersion)return res.status(409).json({error:'Policy version conflict'});
|
||||
if(!store.db.prepare('SELECT id FROM devices WHERE id=? AND revoked=0').get(req.params.id))return res.status(404).json({error:'Active device not found'});
|
||||
p.version++;publisher.submit(req,res,p,'inspection.policy-published',{type:'inspection',deviceId:req.params.id,enabled:b.documentAnalysisEnabled},{documentAnalysisEnabled:b.documentAnalysisEnabled});
|
||||
});
|
||||
app.post('/api/admin/policy/test',async(req,res,next)=>{
|
||||
try{const b=z.object({text:z.string().max(100000),policy:PolicySchema}).parse(req.body);let result=inspect(Buffer.from(b.text),b.policy);
|
||||
if(result.decision==='allow'&&b.policy.dlp.semantic.enabled)result=await inspector(config,'/inspect',{data:Buffer.from(b.text).toString('base64'),policy:b.policy});res.json(result);
|
||||
}catch(e){next(e);}
|
||||
});
|
||||
app.post('/api/admin/semantic/reference',async(req,res,next)=>{
|
||||
try{
|
||||
const b=z.object({description:z.string().min(10).max(4000)}).strict().parse(req.body);
|
||||
const result=await inspector(config,'/embed',{data:Buffer.from(b.description).toString('base64')});
|
||||
if(!result.coverage.complete||!result.coverage.extractionComplete)return res.status(409).json({error:'Complete local text inspection is required to create a semantic reference'});
|
||||
store.event(null,'semantic.reference-created',{actor:req.principal.sub,model:result.model,revision:result.revision,dimensions:result.dimensions});res.json(result);
|
||||
}catch(e){next(e);}
|
||||
});
|
||||
app.post('/api/admin/semantic/document-reference',async(req,res,next)=>{
|
||||
try{
|
||||
const b=z.object({data:z.string().max(1400000).regex(/^[A-Za-z0-9+/]*={0,2}$/),filename:z.string().min(1).max(200)}).strict().parse(req.body),bytes=Buffer.from(b.data,'base64');
|
||||
if(!bytes.length||bytes.length>1048576||bytes.toString('base64')!==b.data)return res.status(413).json({error:'Semantic reference document must be canonical base64 up to 1 MiB'});
|
||||
const result=await inspector(config,'/embed',{data:b.data});
|
||||
if(!result.coverage.complete||!result.coverage.extractionComplete)return res.status(409).json({error:'Complete extraction and chunk coverage are required for a semantic document reference'});
|
||||
store.event(null,'semantic.document-reference-created',{actor:req.principal.sub,model:result.model,revision:result.revision,dimensions:result.dimensions});res.json(result);
|
||||
}catch(e){next(e);}
|
||||
});
|
||||
app.use(express.static(fileURLToPath(new URL('./public',import.meta.url)),{index:'index.html'}));
|
||||
app.use((error,_req,res,_next)=>{
|
||||
config.onError?.(error);
|
||||
const invalid=error instanceof z.ZodError || error.type==='entity.parse.failed';
|
||||
res.status(invalid?400:error.type==='entity.too.large'?413:[400,403,404,409].includes(error.status)?error.status:500).json({error:invalid?'Request validation failed':'Request failed',...(error instanceof z.ZodError?{issues:error.issues.map(v=>({path:v.path.join('.'),message:v.message}))}:{})});
|
||||
});
|
||||
return app;
|
||||
}
|
||||
async function readLimited(response,maxBytes) {
|
||||
const chunks=[];let size=0;
|
||||
for await(const chunk of response.body){size+=chunk.length;if(size>maxBytes){await response.body.cancel().catch(()=>{});throw Error('AI response too large');}chunks.push(chunk);}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import {z} from 'zod';
|
||||
|
||||
const Options=z.object({days:z.number().int().min(1).max(3650),limit:z.number().int().min(1).max(1000).default(500)}).strict();
|
||||
// Only the active exporter's durable cursor permits deletion. Old cursor keys
|
||||
// from an earlier SIEM configuration never authorize the current destination.
|
||||
export function pruneAuditEvents(store,{days,limit=500,exportedThrough,now=Date.now(),dryRun=true}){
|
||||
const options=Options.parse({days,limit});
|
||||
if(!Number.isSafeInteger(exportedThrough)||exportedThrough<0)throw Error('Verified SIEM export cursor required');
|
||||
if(!Number.isFinite(now))throw Error('Invalid retention clock');
|
||||
const cutoff=new Date(now-options.days*86400000).toISOString();
|
||||
const db=store.db;db.exec('SAVEPOINT audit_retention');
|
||||
try{
|
||||
const hasCases=!!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='incident_cases'").get();
|
||||
const protectedCase=hasCases?'AND NOT EXISTS (SELECT 1 FROM incident_cases c WHERE c.event_id=e.id)':'';
|
||||
const hasEvidence=!!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='protected_evidence'").get();
|
||||
const protectedEvidence=hasEvidence?'AND NOT EXISTS (SELECT 1 FROM protected_evidence p WHERE p.source_event_id=e.id)':'';
|
||||
const candidates=db.prepare(`SELECT e.id FROM events e WHERE e.time<? AND e.id<=? ${protectedCase} ${protectedEvidence} ORDER BY e.id LIMIT ?`).all(cutoff,exportedThrough,options.limit);
|
||||
const ids=new Set(candidates.map(r=>r.id));
|
||||
// A batch boundary must not leave a retained correlation pointing at an event
|
||||
// we removed. It is safe to remove children first and their source next run.
|
||||
const bound=candidates.map(()=>'?').join(',');
|
||||
const children=candidates.length?db.prepare(`SELECT id,json_extract(metadata,'$.sourceEventId') AS source FROM events WHERE kind='sigma.alert' AND id IN (${bound})`).all(...ids):[];
|
||||
const retainedSources=candidates.length?db.prepare(`SELECT DISTINCT json_extract(metadata,'$.sourceEventId') AS source FROM events WHERE kind='sigma.alert' AND json_extract(metadata,'$.sourceEventId') IN (${bound}) AND id NOT IN (${bound})`).all(...ids,...ids):[];
|
||||
for(const row of retainedSources)ids.delete(row.source);
|
||||
let changed;do{changed=false;for(const child of children){if(ids.has(child.source)&&!ids.has(child.id)){ids.delete(child.source);changed=true;}}}while(changed);
|
||||
const selected=[...ids];
|
||||
if(!dryRun&&selected.length){
|
||||
const placeholders=selected.map(()=>'?').join(',');
|
||||
db.prepare(`DELETE FROM alerts WHERE event_id IN (${placeholders})`).run(...selected);
|
||||
db.prepare(`DELETE FROM events WHERE id IN (${placeholders})`).run(...selected);
|
||||
// Device event UUIDs deliberately survive: removing them would let a replay
|
||||
// recreate expired events. No signing/history/enrollment state is pruned.
|
||||
store.event(null,'audit.retention-applied',{count:selected.length,retentionDays:days,exportedThrough});
|
||||
}
|
||||
db.exec('RELEASE audit_retention');
|
||||
return {dryRun,cutoff,eligible:selected.length,exportedThrough,incidentEvidencePreserved:true,deduplicationPreserved:true};
|
||||
}catch(error){db.exec('ROLLBACK TO audit_retention');db.exec('RELEASE audit_retention');throw error;}
|
||||
}
|
||||
|
||||
export function startAuditRetention(store,siem,{days=0,intervalMs=3600000}={}){
|
||||
if(!Number.isInteger(days)||days<0||days>3650)throw Error('AUDIT_RETENTION_DAYS must be 0 or 1..3650');
|
||||
if(days)store.db.exec("CREATE INDEX IF NOT EXISTS audit_retention_time ON events(time,id); CREATE INDEX IF NOT EXISTS audit_sigma_source ON events(json_extract(metadata,'$.sourceEventId')) WHERE kind='sigma.alert';");
|
||||
let running=false,lastRun=null,lastFailure=null,timer;
|
||||
function run(){
|
||||
if(!days||running)return;running=true;
|
||||
try{const status=siem.status();lastRun={...pruneAuditEvents(store,{days,exportedThrough:status.cursor,dryRun:false}),at:new Date().toISOString()};lastFailure=null;}
|
||||
catch{lastFailure=new Date().toISOString();}
|
||||
finally{running=false;}
|
||||
}
|
||||
if(days){timer=setInterval(run,intervalMs);timer.unref();}
|
||||
return {run,stop:()=>clearInterval(timer),status:()=>({enabled:!!days,days,lastRun,lastFailure,scope:'exported-unreferenced-database-events'})};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
import { createRemoteJWKSet, jwtVerify, customFetch } from 'jose';
|
||||
import { digest, token } from './store.mjs';
|
||||
import { normalizePrincipal,principalForGroups,adminRoutePermission,bootstrapAllowed } from './admin-access.mjs';
|
||||
export function authRoutes(app, store, config) {
|
||||
const {db} = store;
|
||||
const oidcFetch=config.oidcFetch||fetch;
|
||||
let discovery;
|
||||
async function metadata() {
|
||||
if(!config.oidcIssuer || !config.oidcClientId) throw Error('SSO is not configured');
|
||||
if(!discovery) {
|
||||
const issuer=config.oidcIssuer.replace(/\/$/,'');
|
||||
if(new URL(issuer).protocol!=='https:') throw Error('OIDC issuer requires HTTPS');
|
||||
const response=await oidcFetch(`${issuer}/.well-known/openid-configuration`,{redirect:'error',signal:AbortSignal.timeout(10000)});
|
||||
if(!response.ok) throw Error('OIDC discovery failed');
|
||||
const m=await response.json();
|
||||
if(m.issuer!==config.oidcIssuer || ['authorization_endpoint','token_endpoint','jwks_uri'].some(k=>new URL(m[k]).protocol!=='https:')) throw Error('OIDC metadata rejected');
|
||||
discovery=m;
|
||||
}
|
||||
return discovery;
|
||||
}
|
||||
app.get('/auth/login',async(req,res,next)=>{
|
||||
try {
|
||||
const m=await metadata();const state=token(),verifier=token(),nonce=token();
|
||||
const ticket=typeof req.query.ticket==='string'?req.query.ticket:'';
|
||||
if(ticket && !db.prepare('SELECT id FROM tickets WHERE id=? AND expires>?').get(ticket,Date.now())) return res.status(400).send('Invalid device login');
|
||||
db.prepare('INSERT INTO logins VALUES(?,?,?,?,?)').run(state,Date.now()+300000,verifier,nonce,ticket);
|
||||
res.cookie('sn_login',state,{httpOnly:true,secure:config.secure,sameSite:'lax',maxAge:300000,path:'/auth'});
|
||||
const url=new URL(m.authorization_endpoint);
|
||||
for(const [k,v] of Object.entries({client_id:config.oidcClientId,response_type:'code',scope:'openid profile email',redirect_uri:`${config.publicUrl}/auth/callback`,state,nonce,code_challenge:createHash('sha256').update(verifier).digest('base64url'),code_challenge_method:'S256'})) url.searchParams.set(k,v);
|
||||
res.redirect(url.toString());
|
||||
} catch(e){next(e);}
|
||||
});
|
||||
app.get('/auth/callback',async(req,res,next)=>{
|
||||
try {
|
||||
const state=typeof req.query.state==='string'?req.query.state:'';
|
||||
if(cookie(req,'sn_login')!==state || !state) return res.status(400).send('Login state rejected');
|
||||
const login=db.prepare('SELECT * FROM logins WHERE state=? AND expires>?').get(state,Date.now());
|
||||
if(!login || typeof req.query.code!=='string') return res.status(400).send('Login expired');
|
||||
db.prepare('DELETE FROM logins WHERE state=?').run(state);
|
||||
res.clearCookie('sn_login',{path:'/auth'});
|
||||
const m=await metadata();
|
||||
const form=new URLSearchParams({grant_type:'authorization_code',code:req.query.code,client_id:config.oidcClientId,redirect_uri:`${config.publicUrl}/auth/callback`,code_verifier:login.verifier});
|
||||
const headers={'content-type':'application/x-www-form-urlencoded'};
|
||||
if(config.oidcClientSecret) {
|
||||
headers.authorization=`Basic ${Buffer.from(`${encodeURIComponent(config.oidcClientId)}:${encodeURIComponent(config.oidcClientSecret)}`).toString('base64')}`;
|
||||
}
|
||||
const r=await oidcFetch(m.token_endpoint,{method:'POST',redirect:'error',headers,body:form,signal:AbortSignal.timeout(10000)});
|
||||
if(!r.ok) throw Error('Token exchange failed');
|
||||
const result=await r.json();
|
||||
const {payload}=await jwtVerify(result.id_token,createRemoteJWKSet(new URL(m.jwks_uri),{[customFetch]:oidcFetch}),{issuer:config.oidcIssuer,audience:config.oidcClientId,algorithms:['RS256','ES256','PS256','EdDSA'],requiredClaims:['exp','iat','sub','nonce']});
|
||||
if(payload.nonce!==login.nonce || (payload.azp && payload.azp!==config.oidcClientId)) throw Error('Identity claims rejected');
|
||||
const groups=payload[config.oidcGroupsClaim];
|
||||
const principal=principalForGroups({sub:payload.sub,name:typeof payload.name==='string'?payload.name:payload.sub},groups,config);
|
||||
if(login.ticket) {
|
||||
if(config.oidcDeviceGroup&&(!Array.isArray(groups)||!groups.includes(config.oidcDeviceGroup)))return res.status(403).send('Your account is not authorized for browser enrollment.');
|
||||
const ticket=db.prepare('SELECT * FROM tickets WHERE id=? AND expires>?').get(login.ticket,Date.now());
|
||||
if(!ticket || ticket.credential) return res.status(400).send('Device login expired or already authorized');
|
||||
db.prepare('UPDATE tickets SET credential=? WHERE id=?').run(JSON.stringify(principal),login.ticket);
|
||||
store.event(null,'sso.device-authorized',{actor:principal.sub});
|
||||
return res.type('text').send('Signed in. Return to SafetyNet to complete device enrollment.');
|
||||
}
|
||||
if(!principal.role) return res.status(403).send('Your account is not in an authorized management group.');
|
||||
const credential=token();db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+8*3600000,JSON.stringify(principal));
|
||||
res.cookie('sn_session',credential,{httpOnly:true,secure:config.secure,sameSite:'lax',maxAge:8*3600000,path:'/'});
|
||||
store.event(null,'sso.admin-login',{actor:principal.sub});res.redirect('/');
|
||||
}catch(e){next(e);}
|
||||
});
|
||||
app.post('/auth/logout',(req,res)=>{const s=cookie(req,'sn_session');if(s&&req.headers.origin!==config.publicUrl)return res.status(403).json({error:'Same-origin logout required'});if(s)db.prepare('DELETE FROM sessions WHERE hash=?').run(digest(s));res.clearCookie('sn_session',{path:'/'});res.json({ok:true});});
|
||||
}
|
||||
export function cookie(req,name) {
|
||||
const part=(req.headers.cookie||'').split(';').map(v=>v.trim()).find(v=>v.startsWith(`${name}=`));
|
||||
return part?part.slice(name.length+1):'';
|
||||
}
|
||||
export function adminMiddleware(store,config) {
|
||||
return (req,res,next)=>{
|
||||
const supplied=(req.headers.authorization||'').replace(/^Bearer /,'');
|
||||
let principal,cookieAuthenticated=false;
|
||||
if(bootstrapAllowed(config)&&supplied && config.adminToken && timingSafeEqual(Buffer.from(digest(supplied)),Buffer.from(digest(config.adminToken))))principal=normalizePrincipal({sub:'bootstrap-admin',admin:true});
|
||||
else {
|
||||
const session=store.db.prepare('SELECT principal FROM sessions WHERE hash=? AND expires>?').get(digest(cookie(req,'sn_session')),Date.now());
|
||||
if(session){principal=normalizePrincipal(JSON.parse(session.principal));cookieAuthenticated=true;}
|
||||
}
|
||||
if(!principal)return res.status(401).json({error:'Administrator authentication required'});
|
||||
if(cookieAuthenticated&&!['GET','HEAD','OPTIONS'].includes(req.method)&&req.headers.origin!==config.publicUrl)return res.status(403).json({error:'Same-origin management request required'});
|
||||
let permission=adminRoutePermission(req.method,req.originalUrl);
|
||||
if(config.requireIndependentPolicyApproval&&permission==='policy.write'&&!req.originalUrl.startsWith('/api/admin/policy-reviews/'))permission='policy.prepare';
|
||||
if(!permission||!principal.permissions.includes(permission))return res.status(403).json({error:'Management permission required',permission});
|
||||
req.principal=principal;next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { z } from 'zod';
|
||||
import { BookmarkSchema,BookmarkItemsSchema } from '../../packages/core/bookmarks.mjs';
|
||||
import { PolicySchema,destinationAllowed } from '../../packages/core/policy.mjs';
|
||||
import { settingsSectionAllowed } from '../../packages/core/managed-settings.mjs';
|
||||
const mutation=z.discriminatedUnion('action',[
|
||||
z.object({action:z.literal('add'),expectedRevision:z.number().int().nonnegative(),policyVersion:z.number().int().positive(),bookmark:BookmarkSchema}).strict(),
|
||||
z.object({action:z.literal('update'),expectedRevision:z.number().int().nonnegative(),policyVersion:z.number().int().positive(),bookmark:BookmarkSchema}).strict(),
|
||||
z.object({action:z.literal('delete'),expectedRevision:z.number().int().nonnegative(),policyVersion:z.number().int().positive(),id:BookmarkSchema.shape.id}).strict()
|
||||
]);
|
||||
export function globalBookmarksValid(store,policy){
|
||||
if(policy.browser.bookmarks.personal.revision||policy.browser.bookmarks.personal.items.length)return false;
|
||||
const managed=policy.browser.bookmarks.managed.map(b=>b.id);
|
||||
if(!managed.length)return true;
|
||||
// Let SQLite examine IDs without copying every employee's bookmark collection
|
||||
// into the management process on each company-policy publication.
|
||||
return !store.db.prepare(`SELECT 1 AS found FROM bookmarks AS b, json_each(b.items) AS item WHERE json_extract(item.value,'$.id') IN (${managed.map(()=>'?').join(',')}) LIMIT 1`).get(...managed);
|
||||
}
|
||||
export function installBookmarkRoutes(app,store,device,admin){
|
||||
function active(id){return store.db.prepare('SELECT id FROM devices WHERE id=? AND revoked=0').get(id);}
|
||||
function commit(id,state,items,policy,actor,action){
|
||||
policy=PolicySchema.parse(policy);
|
||||
store.db.exec('BEGIN IMMEDIATE');
|
||||
try{
|
||||
// Requests are synchronous: version and revision comparisons cannot interleave before BEGIN.
|
||||
store.db.prepare('INSERT INTO bookmarks VALUES(?,?,?) ON CONFLICT(device_id) DO UPDATE SET revision=excluded.revision,items=excluded.items').run(id,state.revision+1,JSON.stringify(items));
|
||||
|
||||
store.event(id,'bookmarks.changed',{actor,action,count:items.length,revision:state.revision+1,policyVersion:policy.version});store.db.exec('COMMIT');
|
||||
}catch(error){store.db.exec('ROLLBACK');throw error;}
|
||||
return {revision:state.revision+1,items,version:policy.version};
|
||||
}
|
||||
app.post('/api/device/bookmarks',device,(req,res)=>{
|
||||
const b=mutation.parse(req.body),p=store.effectivePolicy(req.device.id),s=store.personalBookmarks(req.device.id),rules=p.browser.bookmarks;
|
||||
if(b.policyVersion!==p.version||b.expectedRevision!==s.revision)return res.status(409).json({error:'Bookmark or policy version conflict'});
|
||||
if(Date.parse(p.expiresAt)<=Date.now()||!settingsSectionAllowed(p,'library')||!rules[{add:'allowUserAdd',update:'allowUserEdit',delete:'allowUserDelete'}[b.action]])return res.status(403).json({error:'Bookmarks are managed by your administrator'});
|
||||
const id=b.bookmark?.id||b.id;
|
||||
if(rules.managed.some(i=>i.id===id))return res.status(403).json({error:'Company bookmarks cannot be modified'});
|
||||
if(b.bookmark&&!destinationAllowed(b.bookmark.url,p))return res.status(403).json({error:'Bookmark destination blocked by policy'});
|
||||
const index=s.items.findIndex(i=>i.id===id),items=structuredClone(s.items);
|
||||
if(b.action==='add'){if(index!==-1)return res.status(409).json({error:'Bookmark ID already exists'});items.push(b.bookmark);}
|
||||
else {if(index===-1)return res.status(404).json({error:'Bookmark not found'});if(b.action==='delete')items.splice(index,1);else items[index]=b.bookmark;}
|
||||
BookmarkItemsSchema.parse(items);res.json(commit(req.device.id,s,items,p,`device:${req.device.id}`,b.action));
|
||||
});
|
||||
app.get('/api/admin/devices/:id/bookmarks',admin,(req,res)=>{
|
||||
if(!active(req.params.id))return res.status(404).json({error:'Active device not found'});
|
||||
const p=store.effectivePolicy(req.params.id);if(!p.browser.bookmarks.adminReadUserBookmarks)return res.status(403).json({error:'Remote bookmark review disabled by policy'});
|
||||
const s=store.personalBookmarks(req.params.id);store.event(req.params.id,'bookmarks.reviewed',{actor:req.principal.sub,count:s.items.length,revision:s.revision});res.json({...s,version:p.version});
|
||||
});
|
||||
app.put('/api/admin/devices/:id/bookmarks',admin,(req,res)=>{
|
||||
const b=z.object({expectedRevision:z.number().int().nonnegative(),policyVersion:z.number().int().positive(),items:BookmarkItemsSchema}).strict().parse(req.body);
|
||||
if(!active(req.params.id))return res.status(404).json({error:'Active device not found'});
|
||||
const p=store.effectivePolicy(req.params.id),s=store.personalBookmarks(req.params.id);
|
||||
if(!p.browser.bookmarks.adminReadUserBookmarks)return res.status(403).json({error:'Remote bookmark review disabled by policy'});
|
||||
if(b.policyVersion!==p.version||b.expectedRevision!==s.revision)return res.status(409).json({error:'Bookmark or policy version conflict'});
|
||||
if(b.items.some(i=>p.browser.bookmarks.managed.some(m=>m.id===i.id)))return res.status(400).json({error:'Personal and company bookmark IDs conflict'});
|
||||
res.json(commit(req.params.id,s,b.items,p,req.principal.sub,'administrator-update'));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import {z} from 'zod';
|
||||
import {readFileSync,lstatSync} from 'node:fs';
|
||||
import {isAbsolute,dirname} from 'node:path';
|
||||
import {randomBytes,randomUUID,createCipheriv,createDecipheriv,createHash} from 'node:crypto';
|
||||
|
||||
const text=z.string().max(16000).refine(v=>!/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(v),'Control characters forbidden');
|
||||
const url=z.string().max(4096).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;}},'Evidence URLs require HTTP(S), no credentials, query or fragment');
|
||||
const digest=z.string().regex(/^[a-f0-9]{64}$/);
|
||||
// Typed allowlists prevent password, cookie, authorization and arbitrary fields.
|
||||
const payloads={
|
||||
download:z.object({sourceUrl:url,name:z.string().min(1).max(255),sha256:digest,bytes:z.number().int().nonnegative(),mime:z.string().max(100),decision:z.enum(['allow','block']),ruleId:z.string().max(100).optional()}).strict(),
|
||||
interaction:z.object({pageUrl:url,action:z.enum(['click','copy','cut','paste','submit']),target:z.object({tag:z.string().max(32),role:z.string().max(64).optional(),label:text.optional(),x:z.number().finite().optional(),y:z.number().finite().optional()}).strict().optional(),content:text.optional(),contentSha256:digest.optional(),sensitiveField:z.literal(false)}).strict()
|
||||
};
|
||||
const input=z.object({eventId:z.string().uuid(),type:z.enum(['download','interaction']),payload:z.unknown()}).strict();
|
||||
// Conservatively reject recognizable credentials even in otherwise allowed
|
||||
// prose. This is a second boundary; native capture must still exclude secret
|
||||
// controls because arbitrary passwords cannot be inferred from their value.
|
||||
const credentialPatterns=[
|
||||
/-----BEGIN (?:[A-Z0-9 ]*PRIVATE KEY|OPENSSH PRIVATE KEY)-----/i,
|
||||
/\b(?:bearer|basic)\s+[A-Za-z0-9+/_=-]{8,}/i,
|
||||
/\b(?:password|passwd|pwd|api[ _-]?key|access[ _-]?token|refresh[ _-]?token|client[ _-]?secret|authorization)\b["']?\s*[:=]\s*["']?[^\s"',;]{4,}/i,
|
||||
/\bsk-[A-Za-z0-9_-]{16,}\b/,
|
||||
/\bgh[pousr]_[A-Za-z0-9]{20,}\b/,
|
||||
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/,
|
||||
/\bAKIA[A-Z0-9]{16}\b/,
|
||||
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/
|
||||
];
|
||||
function includesCredential(payload){
|
||||
const strings=[];if(payload.content)strings.push(payload.content);if(payload.target?.label)strings.push(payload.target.label);if(payload.name)strings.push(payload.name);
|
||||
for(const field of ['sourceUrl','pageUrl'])if(payload[field]){strings.push(payload[field]);try{strings.push(decodeURIComponent(new URL(payload[field]).pathname));}catch{return true;}}
|
||||
return strings.some(value=>credentialPatterns.some(pattern=>pattern.test(value)));
|
||||
}
|
||||
function aad(row){return Buffer.from(JSON.stringify([1,row.id,row.source_event_id,row.device_id,row.type,row.key_id,row.created_at,row.expires_at]));}
|
||||
function transaction(db,fn){db.exec('SAVEPOINT protected_evidence');try{const result=fn();db.exec('RELEASE protected_evidence');return result;}catch(e){db.exec('ROLLBACK TO protected_evidence');db.exec('RELEASE protected_evidence');throw e;}}
|
||||
export function installEvidence(app,store,config,device,admin){
|
||||
const db=store.db;let key=null,keyId=null;
|
||||
const days=config.evidenceRetentionDays??30;if(!Number.isInteger(days)||days<1||days>365)throw Error('Evidence retention must be 1..365 days');
|
||||
if(config.evidenceKeyFile){
|
||||
if(!isAbsolute(config.evidenceKeyFile))throw Error('Evidence key requires an absolute deployment file');
|
||||
for(let path=config.evidenceKeyFile;;path=dirname(path)){if(lstatSync(path).isSymbolicLink())throw Error('Linked evidence key paths are forbidden');if(dirname(path)===path)break;}
|
||||
const stat=lstatSync(config.evidenceKeyFile);if(!stat.isFile()||stat.size!==32)throw Error('Evidence key must be a regular file containing exactly 32 random bytes');
|
||||
if(process.platform!=='win32'&&(stat.mode&0o077))throw Error('Evidence key must be restricted to its service owner');
|
||||
key=readFileSync(config.evidenceKeyFile);if(key.length!==32)throw Error('Evidence key changed during loading');keyId=createHash('sha256').update(key).digest('hex').slice(0,32);
|
||||
}
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS protected_evidence(id TEXT PRIMARY KEY,source_event_id INTEGER NOT NULL REFERENCES events(id),device_id TEXT NOT NULL REFERENCES devices(id),type TEXT NOT NULL,key_id TEXT NOT NULL,created_at TEXT NOT NULL,expires_at TEXT NOT NULL,nonce BLOB NOT NULL,ciphertext BLOB NOT NULL,tag BLOB NOT NULL,UNIQUE(source_event_id,type)); CREATE INDEX IF NOT EXISTS evidence_source ON protected_evidence(source_event_id);`);
|
||||
const enabled=(_req,res,next)=>key?next():res.status(503).json({error:'Protected evidence storage is not configured'});
|
||||
app.post('/api/device/evidence',device,enabled,(req,res)=>{
|
||||
const b=input.parse(req.body),payload=payloads[b.type].parse(b.payload);if(includesCredential(payload))return res.status(400).json({error:'Recognizable credentials are excluded from evidence'});const plain=Buffer.from(JSON.stringify(payload));if(plain.length>65536)return res.status(413).json({error:'Evidence too large'});
|
||||
const source=db.prepare('SELECT id,kind FROM events WHERE device_id=? AND json_extract(metadata,\'$.eventId\')=?').get(req.device.id,b.eventId);if(!source)return res.status(404).json({error:'Own source event not found'});
|
||||
const matching=b.type==='download'?['download.scan','download.released','download.block']:['clipboard.copy','clipboard.cut','clipboard.paste','clipboard.block','agent.action'];if(!matching.includes(source.kind))return res.status(400).json({error:'Evidence type does not match source event'});
|
||||
const prior=db.prepare('SELECT id FROM protected_evidence WHERE source_event_id=? AND type=?').get(source.id,b.type);if(prior)return res.status(409).json({error:'Evidence already recorded'});
|
||||
const row={id:randomUUID(),source_event_id:source.id,device_id:req.device.id,type:b.type,key_id:keyId,created_at:new Date().toISOString(),expires_at:new Date(Date.now()+days*86400000).toISOString()},nonce=randomBytes(12),cipher=createCipheriv('aes-256-gcm',key,nonce);cipher.setAAD(aad(row));const ciphertext=Buffer.concat([cipher.update(plain),cipher.final()]);plain.fill(0);
|
||||
transaction(db,()=>{db.prepare('INSERT INTO protected_evidence VALUES(?,?,?,?,?,?,?,?,?,?)').run(row.id,source.id,row.device_id,row.type,keyId,row.created_at,row.expires_at,nonce,ciphertext,cipher.getAuthTag());store.event(row.device_id,'evidence.recorded',{evidenceId:row.id,sourceEventId:source.id,evidenceType:row.type,keyId});});res.status(201).json({id:row.id,sourceEventId:source.id,expiresAt:row.expires_at});
|
||||
});
|
||||
app.get('/api/admin/evidence/:id',admin,enabled,(req,res)=>{
|
||||
const id=z.string().uuid().parse(req.params.id),reason=z.enum(['incident-response','investigation','compliance']).parse(req.query.reason),row=db.prepare('SELECT * FROM protected_evidence WHERE id=?').get(id);if(!row)return res.status(404).json({error:'Evidence not found'});
|
||||
const incident=db.prepare("SELECT id FROM incident_cases WHERE event_id=? AND status IN ('open','investigating')").get(row.source_event_id);if(row.expires_at<=new Date().toISOString()&&!incident)return res.status(410).json({error:'Evidence access expired'});if(row.key_id!==keyId)return res.status(503).json({error:'Evidence key unavailable'});
|
||||
let payload;try{const cipher=createDecipheriv('aes-256-gcm',key,row.nonce);cipher.setAAD(aad(row));cipher.setAuthTag(row.tag);const plain=Buffer.concat([cipher.update(row.ciphertext),cipher.final()]);try{payload=payloads[row.type].parse(JSON.parse(plain.toString('utf8')));}finally{plain.fill(0);}}catch{return res.status(500).json({error:'Evidence integrity verification failed'});}
|
||||
// Access must be durably attributable before plaintext leaves the service.
|
||||
store.event(row.device_id,'evidence.accessed',{actor:req.principal.sub,evidenceId:row.id,sourceEventId:row.source_event_id,evidenceType:row.type,reason});
|
||||
res.json({id:row.id,sourceEventId:row.source_event_id,type:row.type,createdAt:row.created_at,expiresAt:row.expires_at,incidentId:incident?.id??null,payload});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {z} from 'zod';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
const statuses=['open','investigating','resolved','dismissed'],severities=['low','medium','high','critical'];
|
||||
const positive=z.coerce.number().int().positive().max(Number.MAX_SAFE_INTEGER);
|
||||
const note=z.string().trim().min(1).max(1000).refine(v=>!/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(v),'Control characters are forbidden');
|
||||
const allowedMetadata=['decision','ruleId','policyVersion','version','host','level','sourceEventId','contentType','classification','coverage'];
|
||||
function safeEvent(row){if(!row)return null;const input=JSON.parse(row.metadata),metadata={};for(const key of allowedMetadata){const v=input[key];if(typeof v==='string'&&v.length<=256)metadata[key]=v;else if(typeof v==='number'&&Number.isSafeInteger(v))metadata[key]=v;}return {id:row.id,time:row.time,kind:row.kind,deviceId:row.device_id,metadata};}
|
||||
export function installIncidents(app,store){
|
||||
const db=store.db;
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS incident_cases(seq INTEGER PRIMARY KEY AUTOINCREMENT,id TEXT UNIQUE NOT NULL,event_id INTEGER UNIQUE NOT NULL REFERENCES events(id),revision INTEGER NOT NULL,status TEXT NOT NULL,severity TEXT NOT NULL,assignee TEXT,created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS incident_changes(case_id TEXT NOT NULL REFERENCES incident_cases(id),revision INTEGER NOT NULL,time TEXT NOT NULL,actor TEXT NOT NULL,status TEXT NOT NULL,severity TEXT NOT NULL,assignee TEXT,comment TEXT,PRIMARY KEY(case_id,revision));
|
||||
CREATE INDEX IF NOT EXISTS incident_case_status ON incident_cases(status,seq);`);
|
||||
const view=row=>({id:row.id,revision:row.revision,status:row.status,severity:row.severity,assignee:row.assignee,createdAt:row.created_at,updatedAt:row.updated_at,eventId:row.event_id,event:safeEvent(db.prepare('SELECT * FROM events WHERE id=?').get(row.event_id))});
|
||||
const find=id=>db.prepare('SELECT * FROM incident_cases WHERE id=?').get(z.string().uuid().parse(id));
|
||||
const transaction=fn=>{db.exec('SAVEPOINT incident_change');try{const value=fn();db.exec('RELEASE incident_change');return value;}catch(e){db.exec('ROLLBACK TO incident_change');db.exec('RELEASE incident_change');throw e;}};
|
||||
const append=(row,actor,comment)=>db.prepare('INSERT INTO incident_changes VALUES(?,?,?,?,?,?,?,?)').run(row.id,row.revision,row.updated_at,actor,row.status,row.severity,row.assignee,comment??null);
|
||||
app.get('/api/admin/incidents',(req,res)=>{const q=z.object({status:z.enum(statuses).optional(),severity:z.enum(severities).optional(),before:positive.optional(),limit:z.coerce.number().int().min(1).max(100).default(50)}).strict().parse(req.query),conditions=[],values=[];for(const key of ['status','severity'])if(q[key]){conditions.push(`${key}=?`);values.push(q[key]);}if(q.before){conditions.push('seq<?');values.push(q.before);}const rows=db.prepare(`SELECT * FROM incident_cases ${conditions.length?'WHERE '+conditions.join(' AND '):''} ORDER BY seq DESC LIMIT ?`).all(...values,q.limit+1),more=rows.length>q.limit;rows.length=Math.min(rows.length,q.limit);res.json({incidents:rows.map(view),nextBefore:more?rows.at(-1).seq:null});});
|
||||
app.get('/api/admin/incidents/:id',(req,res)=>{const row=find(req.params.id);if(!row)return res.status(404).json({error:'Incident not found'});res.json({...view(row),history:db.prepare('SELECT revision,time,actor,status,severity,assignee,comment FROM incident_changes WHERE case_id=? ORDER BY revision').all(row.id)});});
|
||||
app.post('/api/admin/incidents',(req,res)=>{const b=z.object({eventId:positive,severity:z.enum(severities).default('medium')}).strict().parse(req.body),source=db.prepare('SELECT * FROM events WHERE id=?').get(b.eventId);if(!source)return res.status(404).json({error:'Security event not found'});if(source.kind.startsWith('incident.'))return res.status(400).json({error:'Incident audit events cannot create incidents'});if(db.prepare('SELECT id FROM incident_cases WHERE event_id=?').get(b.eventId))return res.status(409).json({error:'Event already linked to an incident'});const time=new Date().toISOString(),row={id:randomUUID(),event_id:b.eventId,revision:1,status:'open',severity:b.severity,assignee:null,created_at:time,updated_at:time};transaction(()=>{db.prepare('INSERT INTO incident_cases(id,event_id,revision,status,severity,assignee,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)').run(row.id,row.event_id,1,row.status,row.severity,null,time,time);append(row,req.principal.sub);store.event(source.device_id,'incident.created',{actor:req.principal.sub,incidentId:row.id,sourceEventId:b.eventId,revision:1,status:'open',severity:row.severity});});res.status(201).json(view(row));});
|
||||
app.patch('/api/admin/incidents/:id',(req,res)=>{const b=z.object({expectedRevision:positive,status:z.enum(statuses).optional(),severity:z.enum(severities).optional(),assignee:z.string().trim().min(1).max(160).refine(v=>!/[\u0000-\u001f\u007f]/.test(v),'Control characters are forbidden').nullable().optional(),comment:note.optional()}).strict().parse(req.body),row=find(req.params.id);if(!row)return res.status(404).json({error:'Incident not found'});if(row.revision!==b.expectedRevision)return res.status(409).json({error:'Incident revision conflict',currentRevision:row.revision});if(row.revision>=1000)return res.status(409).json({error:'Incident history limit reached'});const next={...row,...Object.fromEntries(['status','severity','assignee'].filter(k=>Object.hasOwn(b,k)).map(k=>[k,b[k]])),revision:row.revision+1,updated_at:new Date().toISOString()};if(!b.comment&&['status','severity','assignee'].every(k=>next[k]===row[k]))return res.status(400).json({error:'No incident change supplied'});transaction(()=>{const change=db.prepare('UPDATE incident_cases SET revision=?,status=?,severity=?,assignee=?,updated_at=? WHERE id=? AND revision=?').run(next.revision,next.status,next.severity,next.assignee,next.updated_at,row.id,b.expectedRevision);if(change.changes!==1)throw Object.assign(Error('Incident revision conflict'),{status:409});append(next,req.principal.sub,b.comment);store.event(null,'incident.updated',{actor:req.principal.sub,incidentId:row.id,sourceEventId:row.event_id,revision:next.revision,status:next.status,severity:next.severity,...(b.comment?{commentLength:b.comment.length}:{})});});res.json(view(next));});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { createApp } from './app.mjs';
|
||||
import { openStore } from './store.mjs';
|
||||
import { startTelemetry } from './telemetry.mjs';
|
||||
import { startSIEM } from './siem.mjs';
|
||||
import {startAuditRetention} from './audit-retention.mjs';
|
||||
const env=process.env;
|
||||
const publicUrl=(env.PUBLIC_URL||'http://127.0.0.1:4310').replace(/\/$/,'');
|
||||
const u=new URL(publicUrl);
|
||||
if(u.protocol!=='https:' && !['127.0.0.1','localhost','[::1]'].includes(u.hostname))throw Error('Remote management requires HTTPS');
|
||||
if(u.protocol==='http:'&&(!env.ADMIN_TOKEN||env.ADMIN_TOKEN.length<32))throw Error('Local development requires ADMIN_TOKEN of at least 32 characters');
|
||||
if(u.protocol==='http:'&&env.HOST&&!['127.0.0.1','localhost','::1'].includes(env.HOST))throw Error('Development HTTP management must bind only to loopback');
|
||||
if(u.protocol==='https:'&&(!env.OIDC_ISSUER||!env.OIDC_CLIENT_ID))throw Error('HTTPS management requires enterprise OIDC configuration; shared bootstrap administrator tokens are disabled');
|
||||
const config={publicUrl,secure:u.protocol==='https:',adminToken:env.ADMIN_TOKEN,oidcIssuer:env.OIDC_ISSUER,oidcClientId:env.OIDC_CLIENT_ID,oidcClientSecret:env.OIDC_CLIENT_SECRET,oidcAdminGroup:env.OIDC_ADMIN_GROUP||'safetynet-admins',oidcGroupsClaim:env.OIDC_GROUPS_CLAIM||'groups',aiBaseUrl:env.AI_BASE_URL,aiKey:env.AI_API_KEY,aiModel:env.AI_MODEL,inspectorUrl:env.INSPECTOR_URL,inspectorToken:env.INSPECTOR_TOKEN};
|
||||
const store=openStore(resolve(env.DATA_DIR||'data'));
|
||||
config.evidenceKeyFile=env.EVIDENCE_KEY_FILE;
|
||||
config.evidenceRetentionDays=Number(env.EVIDENCE_RETENTION_DAYS||30);
|
||||
config.oidcDeviceGroup=env.OIDC_DEVICE_GROUP;
|
||||
config.oidcOperatorGroup=env.OIDC_OPERATOR_GROUP;
|
||||
config.oidcAuditorGroup=env.OIDC_AUDITOR_GROUP;
|
||||
config.requireIndependentPolicyApproval=env.REQUIRE_INDEPENDENT_POLICY_APPROVAL==='true';
|
||||
config.requirePQManagement=env.REQUIRE_PQ_MANAGEMENT_CHANNEL!=='false';
|
||||
if(u.protocol==='https:'&&!config.requirePQManagement)throw Error('Remote deployment requires the protected management channel');
|
||||
config.aiEndpointId=env.AI_ENDPOINT_ID||'default';
|
||||
const telemetry=startTelemetry();
|
||||
const siem=startSIEM(store,resolve(env.DATA_DIR||'data'));
|
||||
config.siemStatus=()=>siem.status();
|
||||
const retention=startAuditRetention(store,siem,{days:Number(env.AUDIT_RETENTION_DAYS||0)});
|
||||
config.auditRetentionStatus=()=>retention.status();
|
||||
const server=createApp(store,config).listen(Number(env.PORT||4310),env.HOST||'127.0.0.1',()=>console.log(`SafetyNet management: ${publicUrl}`));
|
||||
const timer=setInterval(()=>{
|
||||
for(const table of ['sessions','logins','tickets','enrollments'])store.db.prepare(`DELETE FROM ${table} WHERE expires<?`).run(Date.now());
|
||||
},60000);timer.unref();
|
||||
async function stop(){clearInterval(timer);retention.stop();siem.stop();server.close(async()=>{store.db.close();await telemetry?.shutdown();process.exit(0);});}
|
||||
process.on('SIGINT',stop);process.on('SIGTERM',stop);
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
import { EmbeddingReferenceSchema } from '../../packages/core/semantic.mjs';
|
||||
const Result=z.object({decision:z.enum(['allow','block']),ruleId:z.string().nullable(),contentType:z.string(),coverage:z.object({complete:z.boolean(),textCharacters:z.number().optional(),mediaItems:z.number().optional()}),model:z.string().nullable(),similarity:z.array(z.object({categoryId:z.string(),score:z.number()})).optional()});
|
||||
export async function inspector(config,route,input){
|
||||
if(!config.inspectorUrl||!config.inspectorToken)throw Error('Company content inspector is not configured');
|
||||
const url=new URL(config.inspectorUrl);
|
||||
if(url.username||url.password||!(url.protocol==='https:'||(url.protocol==='http:'&&['127.0.0.1','localhost','[::1]'].includes(url.hostname))))throw Error('Inspector endpoint requires private HTTPS or loopback');
|
||||
const r=await fetch(`${url.origin}${route}`,{method:'POST',redirect:'error',headers:{authorization:`Bearer ${config.inspectorToken}`,'content-type':'application/json'},body:JSON.stringify(input),signal:AbortSignal.timeout(30000)});
|
||||
if(!r.ok)throw Error('Inspector unavailable or content cannot be inspected');
|
||||
const data=await r.json();if(route==='/reference-document')return z.object({sha256:z.string().regex(/^[a-f0-9]{64}$/),bytes:z.number().int().positive(),contentType:z.string().max(100),fragmentHashes:z.array(z.string().regex(/^[a-f0-9]{64}$/)).max(1000),coverage:z.object({complete:z.boolean(),textCharacters:z.number().int().nonnegative().optional(),mediaItems:z.number().int().nonnegative().optional(),fragmentHashesTotal:z.number().int().nonnegative().optional(),fragmentHashesRetained:z.number().int().nonnegative().optional(),fragmentHashesTruncated:z.boolean().optional(),extractionComplete:z.boolean().optional(),featuresComplete:z.boolean().optional()}),registrationReady:z.boolean()}).parse(data);
|
||||
return route==='/inspect'?Result.parse(data):EmbeddingReferenceSchema.parse(data);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {generateKeyPairSync} from 'node:crypto';
|
||||
import channel from '../../packages/core/policy-channel.cjs';
|
||||
|
||||
export function installPolicyChannel(app,store,device){
|
||||
let keys=store.get('channelSigningKeys');
|
||||
if(!keys){const pair=generateKeyPairSync('ml-dsa-87');keys={privateKey:pair.privateKey.export({type:'pkcs8',format:'pem'}),publicKey:pair.publicKey.export({type:'spki',format:'pem'})};store.put('channelSigningKeys',keys);}
|
||||
const ed=store.get('keys').privateKey,identity=channel.identity(ed,keys.privateKey,keys.publicKey),pending=new Map();
|
||||
app.get('/api/device/channel/identity',device,(_req,res)=>res.json(identity));
|
||||
app.post('/api/device/channel/challenge',device,(req,res)=>{
|
||||
for(const [id,item] of pending)if(item.bundle.expiresAt<=Date.now()||item.bundle.deviceId===req.device.id)pending.delete(id);
|
||||
if(pending.size>=10000)return res.status(503).json({error:'Protected management channel unavailable'});
|
||||
const item=channel.challenge(req.device.id,ed,keys.privateKey);pending.set(item.bundle.id,item);res.json(item.bundle);
|
||||
});
|
||||
app.post('/api/device/channel/policy',device,(req,res)=>{
|
||||
const item=pending.get(req.body?.id);
|
||||
if(!item||item.bundle.deviceId!==req.device.id)return res.status(403).json({error:'Protected management channel refused'});
|
||||
// Consume before processing: every challenge is one use, including failed ciphertext.
|
||||
pending.delete(item.bundle.id);let context;
|
||||
try{context=channel.receive(req.body,item.bundle,item.privateKey);res.json(channel.response(store.envelope(req.device.id),item.bundle,context));}
|
||||
catch{return res.status(403).json({error:'Protected management channel refused'});}
|
||||
finally{context?.responseKey.fill(0);}
|
||||
});
|
||||
return identity;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {z} from 'zod';
|
||||
import {PolicyGroupSchema,applyPolicyGroups,groupApplies} from '../../packages/core/policy-groups.mjs';
|
||||
import {PolicySchema} from '../../packages/core/policy.mjs';
|
||||
import {policyDiff} from './policy-history.mjs';
|
||||
|
||||
const conflict=message=>Object.assign(Error(message),{status:409});
|
||||
export function validateGroupConfiguration(store,policy,groups=store.getRaw('policyGroups')||[],operation={}){
|
||||
if(policy.assignment)throw Object.assign(Error('Global policy cannot include a device assignment'),{status:400});
|
||||
groups=z.array(PolicyGroupSchema).max(100).parse(groups);
|
||||
if(new Set(groups.map(g=>g.id)).size!==groups.length)throw conflict('Duplicate policy group IDs');
|
||||
const deviceIds=[...new Set(groups.flatMap(g=>g.memberDeviceIds))];
|
||||
if(deviceIds.length>10000)throw conflict('Group assignment device budget exceeded');
|
||||
const group=operation.type==='group-upsert'?PolicyGroupSchema.parse(operation.group):null;
|
||||
if(group)for(const id of group.memberDeviceIds){if(!store.db.prepare('SELECT id FROM devices WHERE id=? AND revoked=0').get(id))throw conflict('Group includes an unknown or revoked device');}
|
||||
for(const id of deviceIds){
|
||||
let baseline=structuredClone(policy),device=store.db.prepare('SELECT document_analysis_enabled FROM devices WHERE id=?').get(id);
|
||||
if(device?.document_analysis_enabled!==null&&device?.document_analysis_enabled!==undefined)baseline.inspection.documentAnalysisEnabled=!!device.document_analysis_enabled;
|
||||
if(operation.type==='inspection'&&operation.deviceId===id)baseline.inspection.documentAnalysisEnabled=operation.enabled;
|
||||
let effective;try{effective=PolicySchema.parse(applyPolicyGroups(baseline,groups,id));}catch(error){if(error instanceof z.ZodError)throw error;throw conflict(error.message);}
|
||||
const personal=store.personalBookmarks(id);
|
||||
if(effective.browser.bookmarks.managed.some(b=>personal.items.some(p=>p.id===b.id)))throw conflict('Group company bookmark IDs conflict with personal bookmarks');
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
export function groupsForOperation(store,operation){
|
||||
if(operation.type==='group-restore')return z.array(PolicyGroupSchema).max(100).parse(operation.groups);
|
||||
const groups=structuredClone(store.getRaw('policyGroups')||[]);
|
||||
if(operation.type==='group-upsert'){const group=PolicyGroupSchema.parse(operation.group),index=groups.findIndex(g=>g.id===group.id);if(index<0)groups.push(group);else groups[index]=group;}
|
||||
if(operation.type==='group-delete'){if(!groups.some(g=>g.id===operation.groupId))throw conflict('Policy group no longer exists');return groups.filter(g=>g.id!==operation.groupId);}
|
||||
return groups.sort((a,b)=>a.id.localeCompare(b.id,'en'));
|
||||
}
|
||||
export function installPolicyGroups(app,store,publisher){
|
||||
app.get('/api/admin/policy-groups',(_req,res)=>{
|
||||
const groups=store.getRaw('policyGroups')||[],version=store.get('policy').version;
|
||||
const devices=store.db.prepare('SELECT id,name,revoked,last_seen,policy_version FROM devices ORDER BY name,id').all().map(d=>({id:d.id,name:d.name,revoked:!!d.revoked,last_seen:d.last_seen,policyVersion:d.policy_version,targetVersion:version,acknowledged:d.policy_version===version,groups:groups.filter(g=>groupApplies(g,d.id)).map(g=>g.id),memberGroups:groups.filter(g=>g.memberDeviceIds.includes(d.id)).map(g=>g.id)}));
|
||||
res.json({version,groups,devices});
|
||||
});
|
||||
app.post('/api/admin/policy-groups/preview',(req,res)=>{
|
||||
const b=z.object({deviceId:z.string().min(1).max(80),group:PolicyGroupSchema.optional()}).strict().parse(req.body);
|
||||
if(!store.db.prepare('SELECT id FROM devices WHERE id=? AND revoked=0').get(b.deviceId))return res.status(404).json({error:'Active device not found'});
|
||||
const baseline=store.get('policy'),operation=b.group?{type:'group-upsert',group:b.group}:{},groups=groupsForOperation(store,operation);
|
||||
validateGroupConfiguration(store,baseline,groups,operation);
|
||||
const device=store.db.prepare('SELECT document_analysis_enabled FROM devices WHERE id=?').get(b.deviceId);
|
||||
if(device.document_analysis_enabled!==null)baseline.inspection.documentAnalysisEnabled=!!device.document_analysis_enabled;
|
||||
const policy=PolicySchema.parse(applyPolicyGroups(baseline,groups,b.deviceId));
|
||||
res.json({policy,groups:policy.assignment.groups,diff:policyDiff(baseline,policy),currentDiff:policyDiff(store.effectivePolicy(b.deviceId),policy)});
|
||||
});
|
||||
app.put('/api/admin/policy-groups/:id',(req,res)=>{
|
||||
const b=z.object({expectedVersion:z.number().int().positive(),group:PolicyGroupSchema}).strict().parse(req.body);
|
||||
if(b.group.id!==req.params.id)return res.status(400).json({error:'Group ID does not match route'});
|
||||
const p=store.get('policy');if(p.version!==b.expectedVersion)return res.status(409).json({error:'Policy version conflict'});
|
||||
p.version++;publisher.submit(req,res,p,'policy.group-published',{type:'group-upsert',group:b.group});
|
||||
});
|
||||
app.delete('/api/admin/policy-groups/:id',(req,res)=>{
|
||||
const b=z.object({expectedVersion:z.number().int().positive()}).strict().parse(req.body),p=store.get('policy');
|
||||
if(p.version!==b.expectedVersion)return res.status(409).json({error:'Policy version conflict'});
|
||||
p.version++;publisher.submit(req,res,p,'policy.group-deleted',{type:'group-delete',groupId:req.params.id});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {z} from 'zod';
|
||||
import {PolicySchema} from '../../packages/core/policy.mjs';
|
||||
import {globalBookmarksValid} from './bookmarks.mjs';
|
||||
|
||||
const version=z.coerce.number().int().positive().max(Number.MAX_SAFE_INTEGER);
|
||||
function snapshot(store,id){
|
||||
const row=store.db.prepare('SELECT version,time,actor,policy FROM policies WHERE version=?').get(id);
|
||||
if(!row)return null;
|
||||
const policy=PolicySchema.parse(JSON.parse(row.policy));
|
||||
// Global history never carries device personal collections, even from old stores.
|
||||
policy.browser.bookmarks.personal={revision:0,items:[]};
|
||||
const groupsRow=store.db.prepare('SELECT groups FROM policy_group_history WHERE version=?').get(id);
|
||||
const profilesRow=store.db.prepare('SELECT configuration FROM user_profile_history WHERE version=?').get(id);
|
||||
return {...row,policy,userProfiles:profilesRow?JSON.parse(profilesRow.configuration):null,groups:groupsRow?JSON.parse(groupsRow.groups):[],groupHistoryAvailable:!!groupsRow};
|
||||
}
|
||||
export function policyDiff(before,after,max=500){
|
||||
const changes=[];let truncated=false;
|
||||
function walk(a,b,path){
|
||||
if(JSON.stringify(a)===JSON.stringify(b))return;
|
||||
if(a&&b&&typeof a==='object'&&typeof b==='object'&&!Array.isArray(a)&&!Array.isArray(b)){
|
||||
for(const key of [...new Set([...Object.keys(a),...Object.keys(b)])].sort())walk(a[key],b[key],`${path}/${key.replaceAll('~','~0').replaceAll('/','~1')}`);
|
||||
return;
|
||||
}
|
||||
if(changes.length>=max){truncated=true;return;}
|
||||
changes.push({path,kind:a===undefined?'added':b===undefined?'removed':'changed',...(a===undefined?{}:{before:a}),...(b===undefined?{}:{after:b})});
|
||||
}
|
||||
walk(before,after,'');return {changes,truncated};
|
||||
}
|
||||
export function installPolicyHistory(app,store,publisher){
|
||||
app.get('/api/admin/policy/history',(req,res)=>{
|
||||
const query=z.object({before:version.optional(),limit:z.coerce.number().int().min(1).max(100).default(20)}).strict().parse(req.query);
|
||||
const currentVersion=store.getRaw('policy').version;
|
||||
const rows=store.db.prepare('SELECT version,time,actor FROM policies WHERE version<? ORDER BY version DESC LIMIT ?').all(query.before??Number.MAX_SAFE_INTEGER,query.limit+1);
|
||||
const more=rows.length>query.limit,versions=rows.slice(0,query.limit).map(row=>({...row,current:row.version===currentVersion}));
|
||||
res.json({currentVersion,versions,nextBefore:more?versions.at(-1).version:null});
|
||||
});
|
||||
app.get('/api/admin/policy/history/:version',(req,res)=>{
|
||||
const item=snapshot(store,version.parse(req.params.version));
|
||||
if(!item)return res.status(404).json({error:'Policy version not found'});
|
||||
res.json(item);
|
||||
});
|
||||
app.get('/api/admin/policy/diff',(req,res)=>{
|
||||
const query=z.object({from:version,to:version}).strict().parse(req.query);
|
||||
const from=snapshot(store,query.from),to=snapshot(store,query.to);
|
||||
if(!from||!to)return res.status(404).json({error:'Policy version not found'});
|
||||
res.json({...query,...policyDiff(from.policy,to.policy),groupDiff:policyDiff(from.groups,to.groups),profileDiff:policyDiff(from.userProfiles,to.userProfiles)});
|
||||
});
|
||||
app.post('/api/admin/policy/restore',(req,res)=>{
|
||||
const body=z.object({sourceVersion:version,expectedVersion:version,approve:z.literal(true)}).strict().parse(req.body);
|
||||
const current=store.getRaw('policy');
|
||||
if(current.version!==body.expectedVersion)return res.status(409).json({error:'Policy version conflict'});
|
||||
const source=snapshot(store,body.sourceVersion);
|
||||
if(!source)return res.status(404).json({error:'Policy version not found'});
|
||||
const policy=PolicySchema.parse({...source.policy,version:current.version+1,expiresAt:new Date(Date.now()+7*86400000).toISOString()});
|
||||
if(!globalBookmarksValid(store,policy))return res.status(409).json({error:'Historical company bookmark IDs conflict with current personal bookmarks'});
|
||||
if(!source.groupHistoryAvailable&&(store.getRaw('policyGroups')||[]).length)return res.status(409).json({error:'Historical group assignment snapshot unavailable; review group changes explicitly'});
|
||||
if(!source.userProfiles&&(store.getRaw('userProfiles')?.profiles?.length||store.getRaw('userProfiles')?.categories?.length))return res.status(409).json({error:'Historical user profile snapshot unavailable'});
|
||||
publisher.submit(req,res,policy,'policy.restored',{type:'group-restore',sourceVersion:body.sourceVersion,groups:source.groups,configuration:source.userProfiles||{profiles:[],categories:[]}},{sourceVersion:body.sourceVersion,expiresAt:policy.expiresAt});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {profilesForOperation,validateProfiles} from './user-profiles.mjs';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {z} from 'zod';
|
||||
import {PolicySchema} from '../../packages/core/policy.mjs';
|
||||
import {digest} from './store.mjs';
|
||||
import {globalBookmarksValid} from './bookmarks.mjs';
|
||||
import {policyDiff} from './policy-history.mjs';
|
||||
import {groupsForOperation,validateGroupConfiguration} from './policy-groups.mjs';
|
||||
|
||||
export function policyPublisher(app,store,config){
|
||||
store.db.exec(`CREATE TABLE IF NOT EXISTS policy_reviews(id TEXT PRIMARY KEY,time TEXT NOT NULL,author TEXT NOT NULL,expected_version INTEGER NOT NULL,payload_hash TEXT NOT NULL,policy TEXT NOT NULL,kind TEXT NOT NULL,operation TEXT NOT NULL,status TEXT NOT NULL,reviewer TEXT,reviewed_at TEXT,published_version INTEGER)`);
|
||||
function validate(policy,expected,operation={}){
|
||||
const p=PolicySchema.parse(policy);
|
||||
if(store.getRaw('policy').version!==expected||p.version!==expected+1)throw Object.assign(Error('Policy version conflict'),{status:409});
|
||||
if(!globalBookmarksValid(store,p))throw Object.assign(Error('Company bookmark IDs conflict with personal bookmarks'),{status:409});
|
||||
if(Date.parse(p.expiresAt)<=Date.now()||Date.parse(p.expiresAt)>Date.now()+30*86400000)throw Object.assign(Error('Policy expiry must be within the next 30 days'),{status:400});
|
||||
validateGroupConfiguration(store,p,groupsForOperation(store,operation),operation);
|
||||
validateProfiles(store,p,operation,groupsForOperation(store,operation));
|
||||
return p;
|
||||
}
|
||||
function apply(p,actor,kind,operation){
|
||||
if(operation.type?.startsWith('profile-')||operation.configuration)store.put('userProfiles',profilesForOperation(store,operation.configuration?{type:'profile-restore',configuration:operation.configuration}:operation));
|
||||
if(['group-upsert','group-delete','group-restore'].includes(operation.type))store.put('policyGroups',groupsForOperation(store,operation));
|
||||
if(operation.type==='inspection'){
|
||||
if(!store.db.prepare('UPDATE devices SET document_analysis_enabled=? WHERE id=? AND revoked=0').run(operation.enabled?1:0,operation.deviceId).changes)throw Object.assign(Error('Active device not found'),{status:409});
|
||||
store.event(operation.deviceId,'inspection.setting-changed',{actor,version:p.version,documentAnalysisEnabled:operation.enabled});
|
||||
}
|
||||
if(operation.type==='teaching'){
|
||||
if(!store.db.prepare("UPDATE teaching SET status='published',reviewer=?,policy_version=? WHERE id=? AND status='draft'").run(actor,p.version,operation.id).changes)throw Object.assign(Error('Teaching draft no longer available'),{status:409});
|
||||
store.event(null,'teaching.approved',{actor,referenceId:operation.id,version:p.version,coverage:operation.coverage,mode:operation.mode});
|
||||
}
|
||||
store.put('policy',p);store.db.prepare('INSERT INTO policies VALUES(?,?,?,?)').run(p.version,new Date().toISOString(),actor,JSON.stringify(p));
|
||||
store.db.prepare('INSERT INTO policy_group_history(version,groups) VALUES(?,?)').run(p.version,JSON.stringify(store.getRaw('policyGroups')||[]));
|
||||
store.db.prepare('INSERT INTO user_profile_history(version,configuration) VALUES(?,?)').run(p.version,JSON.stringify(store.getRaw('userProfiles')||{profiles:[],categories:[]}));
|
||||
store.event(null,kind,{actor,version:p.version,...(operation.sourceVersion?{sourceVersion:operation.sourceVersion}:{}),...(operation.type==='group-upsert'?{groupId:operation.group.id,stage:operation.group.rollout.stage,count:operation.group.memberDeviceIds.length}:{}),...(operation.type==='group-delete'?{groupId:operation.groupId}:{})});
|
||||
}
|
||||
function prepare(p,author,kind='policy.published',operation={}){
|
||||
p=validate(p,p.version-1,operation);const id=randomUUID(),payloadHash=digest(JSON.stringify({policy:p,kind,operation})),expectedVersion=p.version-1;
|
||||
if(store.db.prepare("SELECT COUNT(*) AS count FROM policy_reviews WHERE status='pending' AND expected_version=?").get(expectedVersion).count>=1000)throw Object.assign(Error('Pending policy review limit reached'),{status:409});
|
||||
store.db.exec('BEGIN IMMEDIATE');try{
|
||||
store.db.prepare('INSERT INTO policy_reviews(id,time,author,expected_version,payload_hash,policy,kind,operation,status) VALUES(?,?,?,?,?,?,?,?,?)').run(id,new Date().toISOString(),author,expectedVersion,payloadHash,JSON.stringify(p),kind,JSON.stringify(operation),'pending');
|
||||
store.event(null,'policy.review-requested',{actor:author,reviewId:id,version:expectedVersion,payloadHash});store.db.exec('COMMIT');
|
||||
}catch(e){store.db.exec('ROLLBACK');throw e;}
|
||||
return {reviewRequired:true,reviewId:id,status:'pending',expectedVersion,payloadHash};
|
||||
}
|
||||
function submit(req,res,p,kind='policy.published',operation={},extra={}){
|
||||
if(config.requireIndependentPolicyApproval)return res.status(202).json(prepare(p,req.principal.sub,kind,operation));
|
||||
store.db.exec('BEGIN IMMEDIATE');try{p=validate(p,p.version-1,operation);apply(p,req.principal.sub,kind,operation);store.db.exec('COMMIT');}catch(e){store.db.exec('ROLLBACK');throw e;}
|
||||
return res.json({ok:true,version:p.version,...extra});
|
||||
}
|
||||
const view=row=>({id:row.id,time:row.time,author:row.author,expectedVersion:row.expected_version,payloadHash:row.payload_hash,kind:row.kind,status:row.status==='pending'&&row.expected_version!==store.getRaw('policy').version?'stale':row.status,reviewer:row.reviewer,reviewedAt:row.reviewed_at,publishedVersion:row.published_version});
|
||||
app.get('/api/admin/policy-reviews',(_req,res)=>res.json({requireIndependentPolicyApproval:!!config.requireIndependentPolicyApproval,reviews:store.db.prepare('SELECT * FROM policy_reviews ORDER BY time DESC LIMIT 100').all().map(view)}));
|
||||
app.get('/api/admin/policy-reviews/:id',(req,res)=>{const row=store.db.prepare('SELECT * FROM policy_reviews WHERE id=?').get(req.params.id);if(!row)return res.status(404).json({error:'Policy review not found'});const policy=PolicySchema.parse(JSON.parse(row.policy));res.json({...view(row),policy,operation:JSON.parse(row.operation),diff:policyDiff(store.get('policy'),policy)});});
|
||||
app.post('/api/admin/policy-reviews',(req,res)=>{const b=z.object({policy:PolicySchema}).strict().parse(req.body);res.status(202).json(prepare(b.policy,req.principal.sub));});
|
||||
for(const action of ['approve','reject'])app.post(`/api/admin/policy-reviews/:id/${action}`,(req,res)=>{
|
||||
const b=(action==='approve'?z.object({payloadHash:z.string().regex(/^[a-f0-9]{64}$/),expectedVersion:z.number().int().positive(),approve:z.literal(true)}):z.object({payloadHash:z.string().regex(/^[a-f0-9]{64}$/),reject:z.literal(true)})).strict().parse(req.body);
|
||||
store.db.exec('BEGIN IMMEDIATE');try{
|
||||
const row=store.db.prepare('SELECT * FROM policy_reviews WHERE id=?').get(req.params.id);
|
||||
if(!row)throw Object.assign(Error('Policy review not found'),{status:404});
|
||||
if(row.author===req.principal.sub)throw Object.assign(Error('Independent administrator required'),{status:403});
|
||||
if(row.status!=='pending'||b.payloadHash!==row.payload_hash)throw Object.assign(Error('Policy review conflict'),{status:409});
|
||||
const operation=JSON.parse(row.operation),policy=PolicySchema.parse(JSON.parse(row.policy));
|
||||
if(digest(JSON.stringify({policy,kind:row.kind,operation}))!==row.payload_hash)throw Object.assign(Error('Policy review integrity conflict'),{status:409});
|
||||
if(action==='approve'){if(b.expectedVersion!==row.expected_version)throw Object.assign(Error('Policy version conflict'),{status:409});validate(policy,row.expected_version,operation);apply(policy,req.principal.sub,row.kind,operation);}
|
||||
const status=action==='approve'?'approved':'rejected';
|
||||
store.db.prepare('UPDATE policy_reviews SET status=?,reviewer=?,reviewed_at=?,published_version=? WHERE id=?').run(status,req.principal.sub,new Date().toISOString(),action==='approve'?policy.version:null,row.id);
|
||||
store.event(null,`policy.review-${status}`,{actor:req.principal.sub,author:row.author,reviewId:row.id,payloadHash:row.payload_hash,version:action==='approve'?policy.version:row.expected_version});
|
||||
store.db.exec('COMMIT');res.json({ok:true,id:row.id,status,...(action==='approve'?{version:policy.version}:{})});
|
||||
}catch(e){store.db.exec('ROLLBACK');res.status(e.status||400).json({error:e.message});}
|
||||
});
|
||||
return {submit};
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
const $=id=>document.getElementById(id);
|
||||
|
||||
let publishedSnapshot,operatorPermissions=null,currentPrincipal,independentApprovalRequired=false,selectedIndependentReview=null;
|
||||
|
||||
let credential='',policy,presetPreview,managedBookmarks=[],personalBookmarks=[],bookmarkRevision,bookmarkDeviceId='';
|
||||
|
||||
const managedSections=['appearance','library','ai','passwords','documentScan','agents'];
|
||||
|
||||
const agentScopes=['sessions','navigate','read','click','type','screenshot','ai','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group'];
|
||||
|
||||
function status(message,error=false){$('status').textContent=message;$('status').className=error?'error':'';}
|
||||
|
||||
async function api(path,body,method=body?'POST':'GET'){
|
||||
|
||||
const headers={};if(credential)headers.authorization=`Bearer ${credential}`;if(body)headers['content-type']='application/json';
|
||||
|
||||
const r=await fetch(path,{method,headers,body:body?JSON.stringify(body):undefined});const data=await r.json();if(!r.ok)throw Error(data.error+(data.issues?': '+data.issues.map(v=>`${v.path}: ${v.message}`).join('; '):''));return data;
|
||||
|
||||
}
|
||||
|
||||
function task(fn){return async()=>{try{await fn();}catch(e){status(e.message,true);}finally{if(currentPrincipal)applyOperatorPermissions(currentPrincipal);updateDraftStatus();}};}
|
||||
|
||||
function cell(row,value){const td=document.createElement('td');td.textContent=String(value??'—');row.append(td);return td;}
|
||||
|
||||
function lines(id){return $(id).value.split('\n').map(v=>v.trim()).filter(Boolean);}
|
||||
|
||||
function fill(p){
|
||||
|
||||
const bookmarks=p.browser.bookmarks||{managed:[],allowUserAdd:true,allowUserEdit:true,allowUserDelete:true,adminReadUserBookmarks:false};managedBookmarks=structuredClone(bookmarks.managed);for(const [id,key] of bookmarkPermissions)$(id).checked=bookmarks[key];renderBookmarkRows('managed-bookmark-rows',managedBookmarks);clearDeviceBookmarks();
|
||||
|
||||
$('visual-enabled').checked=p.security.visualPhishing.enabled;
|
||||
|
||||
const e=p.enterprise;$('enterprise-dns-mode').value=e.dns.mode;$('enterprise-dns-templates').value=e.dns.templates.join('\n');$('enterprise-vpn-required').checked=e.vpn.required;$('enterprise-vpn-interfaces').value=e.vpn.interfaceNames.join('\n');$('enterprise-vpn-dns').value=e.vpn.requiredDnsServers.join('\n');$('enterprise-icap-enabled').checked=e.icap.enabled;$('enterprise-icap-url').value=e.icap.url;$('enterprise-icap-method').value=e.icap.method;$('enterprise-icap-timeout').value=e.icap.timeoutMs;$('enterprise-icap-max').value=e.icap.maxBytes;for(const [id,key] of [['uploads','scanUploads'],['downloads','scanDownloads'],['clipboard','scanClipboard'],['ai','scanAI'],['insecure','allowInsecureTransport']])$(`enterprise-icap-${id}`).checked=e.icap[key];presetPreview=null;$('preset-apply').disabled=true;$('preset-description').textContent='';$('preset-changes').replaceChildren();
|
||||
|
||||
const settings=p.browser.settings||{visible:true,hiddenSections:[],lockedSections:[],appearance:{mode:'system',accent:'blue',tabMode:'separate'}};
|
||||
|
||||
$('settings-visible').checked=settings.visible;for(const section of managedSections){$(`settings-hide-${section}`).checked=settings.hiddenSections.includes(section);$(`settings-lock-${section}`).checked=settings.lockedSections.includes(section);}
|
||||
|
||||
$('settings-theme').value=settings.appearance.mode;$('settings-accent').value=settings.appearance.accent;$('settings-tabs').value=settings.appearance.tabMode;
|
||||
|
||||
$('security-json').value=JSON.stringify(p.security,null,2);$('agents-json').value=JSON.stringify(p.agents,null,2);$('ai-provider').value=p.ai.provider;$('ai-trust').value=p.ai.trustMode;$('ai-endpoint').value=p.ai.endpointId;
|
||||
|
||||
fillSecurityControls(p.security,p.agents);
|
||||
|
||||
$('ai-confidential-local').checked=p.ai.allowConfidentialLocal;
|
||||
|
||||
$('vault-enabled').checked=p.passwordManager.enabled;$('vault-saving').checked=p.passwordManager.allowSaving;$('vault-autofill').checked=p.passwordManager.allowAutofill;$('vault-idle').value=p.passwordManager.idleLockSeconds;
|
||||
|
||||
for(const [id,key] of [['clipboard-enabled','enabled'],['clipboard-copy','allowCopy'],['clipboard-cut','allowCut'],['clipboard-paste','allowPaste'],['clipboard-command','blockSuspiciousCommands']])$(id).checked=p.security.clipboard[key];$('clipboard-max').value=p.security.clipboard.maxTextBytes;
|
||||
|
||||
$('brand-name').value=p.brand.name;$('brand-color').value=p.brand.color;$('home-url').value=p.browser.homeUrl;
|
||||
|
||||
$('allowed-hosts').value=p.browser.allowedHosts.join('\n');$('blocked-hosts').value=p.browser.blockedHosts.join('\n');
|
||||
|
||||
$('proxy-mode').value=p.proxy.mode;$('proxy-value').value=p.proxy.rules||p.proxy.pacUrl||'';$('proxy-bypass').value=p.proxy.bypass||'';
|
||||
|
||||
const date=new Date(p.expiresAt);$('expiry').value=new Date(date.getTime()-date.getTimezoneOffset()*60000).toISOString().slice(0,16);
|
||||
|
||||
$('downloads').checked=p.browser.allowDownloads;$('devtools').checked=p.browser.allowDevTools;$('websockets').checked=p.browser.allowWebSockets;
|
||||
|
||||
$('ai-enabled').checked=p.ai.enabled;$('ai-model').value=p.ai.model;$('max-bytes').value=p.dlp.maxBytes;$('semantic-enabled').checked=p.dlp.semantic.enabled;renderRules();renderSemantic();
|
||||
|
||||
}
|
||||
|
||||
function draft(){
|
||||
|
||||
const p=structuredClone(policy);p.version+=1;p.expiresAt=new Date($('expiry').value).toISOString();
|
||||
|
||||
p.enterprise={dns:{mode:$('enterprise-dns-mode').value,templates:lines('enterprise-dns-templates')},vpn:{required:$('enterprise-vpn-required').checked,interfaceNames:lines('enterprise-vpn-interfaces'),requiredDnsServers:lines('enterprise-vpn-dns')},icap:{enabled:$('enterprise-icap-enabled').checked,url:$('enterprise-icap-url').value.trim(),method:$('enterprise-icap-method').value,allow204:true,allowInsecureTransport:$('enterprise-icap-insecure').checked,scanUploads:$('enterprise-icap-uploads').checked,scanDownloads:$('enterprise-icap-downloads').checked,scanClipboard:$('enterprise-icap-clipboard').checked,scanAI:$('enterprise-icap-ai').checked,timeoutMs:Number($('enterprise-icap-timeout').value),maxBytes:Number($('enterprise-icap-max').value),failClosed:true}};
|
||||
|
||||
p.passwordManager={enabled:$('vault-enabled').checked,allowSaving:$('vault-saving').checked,allowAutofill:$('vault-autofill').checked,idleLockSeconds:Number($('vault-idle').value)};
|
||||
|
||||
p.brand={name:$('brand-name').value,color:$('brand-color').value};
|
||||
|
||||
p.browser={homeUrl:$('home-url').value,allowedHosts:lines('allowed-hosts'),blockedHosts:lines('blocked-hosts'),allowDownloads:$('downloads').checked,allowDevTools:$('devtools').checked,allowWebSockets:$('websockets').checked};
|
||||
|
||||
p.browser.settings={visible:$('settings-visible').checked,hiddenSections:managedSections.filter(section=>$(`settings-hide-${section}`).checked),lockedSections:managedSections.filter(section=>$(`settings-lock-${section}`).checked),appearance:{mode:$('settings-theme').value,accent:$('settings-accent').value,tabMode:$('settings-tabs').value}};
|
||||
|
||||
p.browser.bookmarks={managed:structuredClone(managedBookmarks),personal:structuredClone(policy.browser.bookmarks.personal)};for(const [id,key] of bookmarkPermissions)p.browser.bookmarks[key]=$(id).checked;
|
||||
|
||||
const mode=$('proxy-mode').value;p.proxy={mode};if(mode==='fixed_servers'){p.proxy.rules=$('proxy-value').value;p.proxy.bypass=$('proxy-bypass').value;}if(mode==='pac_script')p.proxy.pacUrl=$('proxy-value').value;
|
||||
|
||||
p.security=JSON.parse($('security-json').value);p.security.clipboard={enabled:$('clipboard-enabled').checked,allowCopy:$('clipboard-copy').checked,allowCut:$('clipboard-cut').checked,allowPaste:$('clipboard-paste').checked,blockSuspiciousCommands:$('clipboard-command').checked,failClosed:true,maxTextBytes:Number($('clipboard-max').value)};
|
||||
|
||||
p.security.visualPhishing.enabled=$('visual-enabled').checked;
|
||||
|
||||
p.security.dns={...p.security.dns,enabled:$('security-dns-enabled').checked,blockedHosts:lines('security-dns-hosts'),blockedIPs:lines('security-dns-ips'),failClosed:true};p.security.content.blockedPhrases=lines('security-content-phrases');p.security.yara.enabled=$('security-yara-enabled').checked;p.security.yara.maxScanBytes=Number($('security-yara-max').value);
|
||||
|
||||
p.agents={enabled:$('agent-enabled').checked,scopes:agentScopes.filter(scope=>$(`agent-scope-${scope}`).checked),allowedHosts:lines('agent-hosts'),allowSensitiveRead:$('agent-sensitive').checked};p.ai={enabled:$('ai-enabled').checked,model:$('ai-model').value,provider:$('ai-provider').value,trustMode:$('ai-trust').value,endpointId:$('ai-endpoint').value,allowConfidentialLocal:$('ai-confidential-local').checked};p.dlp.maxBytes=Number($('max-bytes').value);p.dlp.semantic.enabled=$('semantic-enabled').checked;return p;
|
||||
|
||||
}
|
||||
|
||||
function renderRules(){
|
||||
|
||||
$('rules').replaceChildren();for(const rule of policy.dlp.rules){const div=document.createElement('div');div.className='rule';const text=document.createElement('span');text.textContent=`${rule.label} · ${rule.kind} · ${rule.value}`;const button=document.createElement('button');button.className='secondary';button.textContent='Remove';button.onclick=()=>{policy.dlp.rules=policy.dlp.rules.filter(r=>r.id!==rule.id);renderRules();};div.append(text,button);$('rules').append(div);}
|
||||
|
||||
}
|
||||
|
||||
async function load(){
|
||||
|
||||
const data=await api('/api/admin/overview');clearHistoryReview();independentApprovalRequired=data.governance?.requireIndependentPolicyApproval===true;clearIndependentReview();policy=data.policy;publishedSnapshot=structuredClone(policy);fill(policy);applyOperatorPermissions(data.principal);renderPosture(policy);updateDraftStatus();$('workspace').hidden=false;$('login').hidden=true;$('admin-logout').hidden=false;
|
||||
|
||||
$('identity').textContent=`${data.principal.sub} · ${data.principal.role||'administrator'}`;$('device-count').textContent=data.devices.length;$('policy-version').textContent=`v${policy.version}`;$('block-count').textContent=data.events.filter(e=>e.kind.includes('block')||e.metadata.decision==='block').length;$('ai-status').textContent=policy.ai.enabled?'Enabled':'Off';
|
||||
|
||||
$('bookmark-device').replaceChildren();for(const d of data.devices.filter(d=>!d.revoked)){const option=document.createElement('option');option.value=d.id;option.textContent=`${d.name} · ${d.user}`;$('bookmark-device').append(option);}
|
||||
|
||||
$('device-rows').replaceChildren();for(const d of data.devices){const row=document.createElement('tr');row.dataset.access=d.revoked?'revoked':'active';cell(row,d.name);cell(row,d.platform);cell(row,d.user);cell(row,d.policy_version);cell(row,new Date(d.last_seen).toLocaleString());const action=cell(row,d.revoked?'Revoked':'');if(!d.revoked){const b=document.createElement('button');b.className='danger';b.textContent='Revoke';b.onclick=task(async()=>{await api(`/api/admin/devices/${encodeURIComponent(d.id)}/revoke`,{});await load();status('Device revoked. Client access stops at its next management refresh.');});action.append(b);const scan=document.createElement('button');scan.className='secondary';scan.textContent=d.documentAnalysisEnabled?'Disable manual document scans':'Enable manual document scans';scan.onclick=task(async()=>{const result=await api(`/api/admin/devices/${encodeURIComponent(d.id)}/inspection`,{expectedVersion:policy.version,documentAnalysisEnabled:!d.documentAnalysisEnabled},'PATCH');await load();policyMutationStatus(result,'Device document analysis setting published. Mandatory DLP and YARA remain enforced.');});action.append(scan);}$('device-rows').append(row);}
|
||||
|
||||
$('event-rows').replaceChildren();for(const e of data.events){const row=document.createElement('tr');row.dataset.decision=e.metadata.decision|| (e.kind.includes('block')?'block':'');cell(row,new Date(e.time).toLocaleString());cell(row,e.kind);cell(row,e.device_id?.slice(0,8)||'Control plane');const metadataCell=cell(row,JSON.stringify(e.metadata));const triage=document.createElement('button');triage.className='secondary';triage.textContent='Create incident';triage.dataset.incidentCreate='true';triage.hidden=e.kind.startsWith('incident.');triage.onclick=task(async()=>{const created=await api('/api/admin/incidents',{eventId:Number(e.id),severity:'medium'});await loadIncidents();await openIncident(created.incident?.id||created.id);$('incidents').scrollIntoView({behavior:'smooth'});status('Incident created for investigation.');});metadataCell.append(triage);$('event-rows').append(row);}
|
||||
|
||||
filterTable('device');filterTable('event');await loadTeaching();await loadIndependentReviews();await loadPolicyGroups();await loadUserProfiles();await loadIncidents();status(data.migrationRequired?'Legacy semantic policy remains blocked. Recreate references with the approved local model and explicitly publish a replacement policy.':'Connected. Policy and fleet loaded.',data.migrationRequired);
|
||||
|
||||
renderReadiness((await api('/api/admin/integrations')).readiness);
|
||||
|
||||
const retention=data.auditRetention||{enabled:false};$('audit-retention-status').textContent=retention.enabled?`${retention.days} days · exported, unreferenced database events only${retention.lastRun?.at?' · last run '+new Date(retention.lastRun.at).toLocaleString():''}${retention.lastFailure?' · last attempt failed at '+new Date(retention.lastFailure).toLocaleString():''}`:'Automatic database event deletion is disabled.';applyOperatorPermissions(data.principal);if(data.siem)$('siem-status').textContent=`SIEM ${data.siem.configured?'HTTPS push':'local durable export'} · ${data.siem.format.toUpperCase()} · ${data.siem.pending} pending · ${data.siem.failures} consecutive delivery failures`;
|
||||
|
||||
}
|
||||
|
||||
$('connect').onclick=task(async()=>{credential=$('token').value;$('token').value='';await load();});
|
||||
|
||||
$('refresh').onclick=task(load);
|
||||
|
||||
$('publish').onclick=task(async()=>{const result=await api('/api/admin/policy',draft(),'PUT');await load();policyMutationStatus(result,`Signed policy v${result.version} published.`);});
|
||||
|
||||
$('channel-key-download').onclick=task(async()=>{const channel=await api('/api/admin/management-channel');const link=document.createElement('a'),url=URL.createObjectURL(new Blob([channel.signatureKey],{type:'application/x-pem-file'}));link.href=url;link.download='safetynet-management-mldsa87-public.pem';link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);status('Public management signing key downloaded. Deploy through your trusted device-management channel.');});
|
||||
|
||||
$('add-rule').onclick=task(async()=>{const label=$('rule-label').value.trim(),value=$('rule-value').value.trim();if(!label||!value)throw Error('Enter a rule name and phrase');policy.dlp.rules.push({id:crypto.randomUUID(),label,kind:'literal',value,action:'block'});renderRules();$('rule-value').value='';status('Rule added to draft. Publish to apply it.');});
|
||||
|
||||
$('fingerprint').onchange=task(async()=>{const file=$('fingerprint').files[0];if(!file)return;if(file.size>20*1024*1024)throw Error('Fingerprint input limit is 20 MB');const hash=await crypto.subtle.digest('SHA-256',await file.arrayBuffer());const value=Array.from(new Uint8Array(hash),b=>b.toString(16).padStart(2,'0')).join('');policy.dlp.rules.push({id:crypto.randomUUID(),label:file.name,kind:'sha256',value,action:'block'});renderRules();$('fingerprint').value='';status('Fingerprint added to draft. Publish to apply it.');});
|
||||
|
||||
$('test').onclick=task(async()=>{$('test-result').textContent=JSON.stringify(await api('/api/admin/policy/test',{text:$('test-text').value,policy:draft()}),null,2);});
|
||||
|
||||
$('enroll').onclick=task(async()=>{const result=await api('/api/admin/enrollments',{user:$('enroll-user').value});$('enrollment').textContent=`One-use code (expires in 10 minutes):\n${result.code}`;});
|
||||
|
||||
function renderSemantic(){
|
||||
|
||||
$('semantic-rules').replaceChildren();for(const r of policy.dlp.semantic.categories){const div=document.createElement('div');div.className='rule';const text=document.createElement('span');text.textContent=`${r.label} · threshold ${r.threshold}`;const button=document.createElement('button');button.className='secondary';button.textContent='Remove';button.onclick=()=>{policy.dlp.semantic.categories=policy.dlp.semantic.categories.filter(v=>v.id!==r.id);renderSemantic();};div.append(text,button);$('semantic-rules').append(div);}
|
||||
|
||||
}
|
||||
|
||||
function addSemanticReference(reference,label,threshold){
|
||||
|
||||
if(policy.dlp.semantic.categories.length&&['revision','model','profile','artifactFormat','quantization','runtimeRevision'].some(key=>policy.dlp.semantic[key]!==reference[key]))throw Error('Model revision or profile changed; recreate existing references before publishing');
|
||||
|
||||
for(const key of ['model','revision','dimensions','profile','artifactFormat','quantization','runtimeRevision'])policy.dlp.semantic[key]=reference[key];policy.dlp.semantic.categories.push({id:crypto.randomUUID(),label,threshold,prototype:reference.vector,prototypes:reference.vectors});renderSemantic();status(`Local semantic reference added (${reference.coverage.textChunks} chunks, ${reference.dimensions} dimensions). Calibrate and explicitly publish to apply.`);
|
||||
|
||||
}
|
||||
|
||||
$('semantic-add').onclick=task(async()=>{
|
||||
|
||||
const label=$('semantic-label').value.trim(),threshold=Number($('semantic-threshold').value);if(!label||threshold<=0||threshold>1)throw Error('Enter a category name and a threshold greater than 0 and at most 1');
|
||||
|
||||
const reference=await api('/api/admin/semantic/reference',{description:$('semantic-description').value});
|
||||
|
||||
addSemanticReference(reference,label,threshold);
|
||||
|
||||
});
|
||||
|
||||
$('semantic-document-add').onclick=task(async()=>{
|
||||
|
||||
const file=$('semantic-document').files[0],label=$('semantic-label').value.trim(),threshold=Number($('semantic-threshold').value);if(!file||file.size>1048576)throw Error('Choose a local reference document up to 1 MiB');if(!label||threshold<=0||threshold>1)throw Error('Enter a category name and threshold greater than 0 and at most 1');const bytes=new Uint8Array(await file.arrayBuffer());let raw='';for(let n=0;n<bytes.length;n+=8192)raw+=String.fromCharCode(...bytes.subarray(n,n+8192));const reference=await api('/api/admin/semantic/document-reference',{data:btoa(raw),filename:file.name});addSemanticReference(reference,label,threshold);$('semantic-document').value='';
|
||||
|
||||
});
|
||||
|
||||
for(const format of ['ecs','cef'])$(`export-${format}`).onclick=task(async()=>{
|
||||
|
||||
const r=await fetch(`/api/admin/events/export?format=${format}&limit=1000`,{headers:credential?{authorization:`Bearer ${credential}`}:{}});if(!r.ok)throw Error('Export failed');
|
||||
|
||||
const url=URL.createObjectURL(await r.blob()),a=document.createElement('a');a.href=url;a.download=`safetynet-security.${format==='ecs'?'ndjson':'cef'}`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||||
|
||||
});
|
||||
|
||||
fetch('/api/auth/status').then(r=>r.json()).then(data=>{$('sso').hidden=!data.sso;$('token').hidden=data.bootstrapAllowed===false;$('connect').hidden=data.bootstrapAllowed===false;});
|
||||
|
||||
load().catch(()=>status('Sign in to manage your browser fleet.'));
|
||||
|
||||
async function loadTeaching(){
|
||||
|
||||
const data=await api('/api/admin/teaching');$('teach-drafts').replaceChildren();for(const d of data.drafts){const row=document.createElement('div');row.className='rule';const label=document.createElement('span');label.textContent=`${d.label} · ${d.status} · ${d.reference.registrationReady?'inspection complete':'partial coverage: unextracted media is not fingerprinted'}`;row.append(label);if(d.status==='draft'){const button=document.createElement('button');button.textContent=independentApprovalRequired?(d.reference.registrationReady?'Prepare fingerprints for independent approval':'Prepare available fingerprints (partial coverage)'):(d.reference.registrationReady?'Approve and publish fingerprints':'Approve available fingerprints (partial coverage)');button.onclick=task(async()=>{const result=await api(`/api/admin/teaching/${encodeURIComponent(d.id)}/publish`,{expectedVersion:policy.version,approve:true,mode:d.reference.registrationReady?'complete':'available-fingerprints'});await load();policyMutationStatus(result,`Reference approved and signed policy published; coverage ${result.coverage}.`);});row.append(button);}$('teach-drafts').append(row);}
|
||||
|
||||
}
|
||||
|
||||
$('teach-submit').onclick=task(async()=>{const file=$('teach-file').files[0];if(!file||file.size>1024*1024)throw Error('Choose a reference file up to 1 MB');const bytes=new Uint8Array(await file.arrayBuffer());let raw='';for(let n=0;n<bytes.length;n+=8192)raw+=String.fromCharCode(...bytes.subarray(n,n+8192));await api('/api/admin/teaching',{label:$('teach-label').value,data:btoa(raw),filename:file.name});await loadTeaching();status('Reference draft created. Review and approve to publish.');});
|
||||
|
||||
$('visual-add').onclick=task(async()=>{
|
||||
|
||||
const file=$('visual-image').files[0];if(!file||file.size>1024*1024)throw Error('Choose a trusted PNG reference up to 1 MB');const bytes=new Uint8Array(await file.arrayBuffer());let raw='';for(let n=0;n<bytes.length;n+=8192)raw+=String.fromCharCode(...bytes.subarray(n,n+8192));
|
||||
|
||||
const result=await api('/api/admin/security/visual-reference',{expectedVersion:policy.version,brandId:$('visual-id').value,label:$('visual-label').value,allowedOrigins:lines('visual-origins'),scope:$('visual-scope').value,data:btoa(raw)});
|
||||
|
||||
const security=JSON.parse($('security-json').value),brands=security.visualPhishing.brands,current=brands.find(b=>b.id===result.brand.id);if(current){if(current.references.length>=20)throw Error('Each brand supports at most 20 references');current.references.push(result.brand.references[0]);}else brands.push(result.brand);$('security-json').value=JSON.stringify(security,null,2);$('visual-image').value='';status(result.warning);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
function renderReadiness(rows){$('integration-readiness').replaceChildren();for(const r of rows){const item=document.createElement('div');item.className='integration-item';const title=document.createElement('strong');title.textContent=`${r.label} · ${r.state}`;const detail=document.createElement('p');detail.textContent=r.detail;item.append(title,detail);$('integration-readiness').append(item);}}
|
||||
|
||||
$('preset-preview').onclick=task(async()=>{const result=await api('/api/admin/presets/preview',{id:$('preset-id').value,policy:draft()});result.sourceDraft=JSON.stringify(draft());presetPreview=result;$('preset-description').textContent=`${result.preset.description} ${result.warning}`;$('preset-changes').replaceChildren();for(const change of result.changes){const row=document.createElement('div');row.className='rule';row.textContent=`${change.field}: ${JSON.stringify(change.before)} → ${JSON.stringify(change.after)}`;$('preset-changes').append(row);}if(!result.changes.length)$('preset-changes').textContent='No changes to existing policy controls.';renderReadiness(result.readiness);$('preset-apply').disabled=false;status('Template preview created. Nothing has been published.');});
|
||||
|
||||
$('preset-apply').onclick=task(async()=>{if(!presetPreview)throw Error('Preview a template first');if(JSON.stringify(draft())!==presetPreview.sourceDraft)throw Error('Draft changed after preview; preview the template again before applying');const preview=presetPreview;policy=preview.policy;policy.version-=1;fill(policy);renderReadiness(preview.readiness);status('Template added to draft. Review controls and publish the signed policy to deploy.');});
|
||||
|
||||
$('preset-id').onchange=()=>{presetPreview=null;$('preset-apply').disabled=true;$('preset-description').textContent='';$('preset-changes').replaceChildren();};
|
||||
|
||||
$('enterprise-icap-probe').onclick=task(async()=>{const result=await api('/api/admin/integrations/icap/probe',draft().enterprise.icap);$('enterprise-icap-status').textContent=result.ready?'Management server OPTIONS check passed. Verify scanning from the deployed browser network too.':'Management server OPTIONS check failed. Check service, method, certificates and network reachability.';status('ICAP capability check completed; no company document was sent and nothing was published.');});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const bookmarkPermissions=[['bookmarks-add','allowUserAdd'],['bookmarks-edit','allowUserEdit'],['bookmarks-delete','allowUserDelete'],['bookmarks-admin-read','adminReadUserBookmarks']];
|
||||
|
||||
function clearDeviceBookmarks(){bookmarkRevision=undefined;bookmarkDeviceId='';personalBookmarks=[];$('bookmark-device-editor').hidden=true;$('bookmark-device-status').textContent='Select a device and read its bookmarks. Published policy must permit administrator access.';}
|
||||
|
||||
function renderBookmarkRows(target,items){
|
||||
|
||||
$(target).replaceChildren();for(const item of items){const row=document.createElement('tr');for(const [key,label,max] of [['title','Bookmark title',120],['url','Bookmark URL',2048],['folder','Bookmark folder',80]]){const td=document.createElement('td'),input=document.createElement('input');input.value=item[key]||'';input.maxLength=max;input.setAttribute('aria-label',label);if(key==='url')input.type='url';input.oninput=()=>{item[key]=input.value;};td.append(input);row.append(td);}const td=document.createElement('td'),remove=document.createElement('button');remove.className='danger';remove.textContent='Remove';remove.onclick=()=>{items.splice(items.indexOf(item),1);renderBookmarkRows(target,items);};td.append(remove);row.append(td);$(target).append(row);}
|
||||
|
||||
if(!items.length){const row=document.createElement('tr'),td=document.createElement('td');td.colSpan=4;td.textContent='No bookmarks.';row.append(td);$(target).append(row);}
|
||||
|
||||
}
|
||||
|
||||
function addBookmark(items,target){if(items.length>=100)throw Error('A maximum of 100 bookmarks is supported');items.push({id:crypto.randomUUID(),title:'',url:'https://',folder:''});renderBookmarkRows(target,items);$(target).lastElementChild?.querySelector('input')?.focus();}
|
||||
|
||||
$('managed-bookmark-add').onclick=task(async()=>{addBookmark(managedBookmarks,'managed-bookmark-rows');status('Company bookmark added to draft. Complete its fields, then publish the signed policy.');});
|
||||
|
||||
$('personal-bookmark-add').onclick=task(async()=>addBookmark(personalBookmarks,'personal-bookmark-rows'));
|
||||
|
||||
$('bookmark-device').onchange=clearDeviceBookmarks;
|
||||
|
||||
async function readDeviceBookmarks(){
|
||||
|
||||
const id=$('bookmark-device').value;if(!id)throw Error('Select an active device');clearDeviceBookmarks();const data=await api(`/api/admin/devices/${encodeURIComponent(id)}/bookmarks`);if($('bookmark-device').value!==id)return;bookmarkDeviceId=id;bookmarkRevision=data.revision;personalBookmarks=structuredClone(data.items);renderBookmarkRows('personal-bookmark-rows',personalBookmarks);$('bookmark-device-editor').hidden=false;$('bookmark-device-status').textContent=`Loaded device revision ${bookmarkRevision}. Remote edits are not saved yet.`;
|
||||
|
||||
}
|
||||
|
||||
$('bookmark-device-load').onclick=task(readDeviceBookmarks);
|
||||
|
||||
$('bookmark-device-refresh').onclick=task(readDeviceBookmarks);
|
||||
|
||||
$('bookmark-device-save').onclick=task(async()=>{
|
||||
|
||||
if(!bookmarkDeviceId||bookmarkRevision===undefined||bookmarkDeviceId!==$('bookmark-device').value)throw Error('Read the selected device bookmarks first');
|
||||
|
||||
try{const data=await api(`/api/admin/devices/${encodeURIComponent(bookmarkDeviceId)}/bookmarks`,{expectedRevision:bookmarkRevision,policyVersion:policy.version,items:personalBookmarks},'PUT');bookmarkRevision=data.revision;personalBookmarks=structuredClone(data.items);renderBookmarkRows('personal-bookmark-rows',personalBookmarks);$('bookmark-device-status').textContent=`Saved revision ${bookmarkRevision}. The browser applies this at its next management refresh.`;status('Device bookmarks updated.');}catch(error){$('bookmark-device-status').textContent=`Save failed: ${error.message}. Refresh and review the latest revision before retrying.`;throw error;}
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Console preferences affect this interface only, never managed browser policy.
|
||||
|
||||
function setConsoleTheme(value){document.documentElement.dataset.theme=value;try{localStorage.setItem('safetynet.admin.theme',value);}catch{}}
|
||||
|
||||
try{$('console-theme').value=localStorage.getItem('safetynet.admin.theme')||'system';}catch{}
|
||||
|
||||
setConsoleTheme($('console-theme').value);$('console-theme').onchange=()=>setConsoleTheme($('console-theme').value);
|
||||
|
||||
function policyChanges(){if(!publishedSnapshot)return [];const next=draft();const changes=[];function walk(before,after,path){if(JSON.stringify(before)===JSON.stringify(after))return;if(path==='version')return;if(path==='expiresAt'&&Math.floor(new Date(before).getTime()/60000)===Math.floor(new Date(after).getTime()/60000))return;if(before&&after&&typeof before==='object'&&typeof after==='object'&&!Array.isArray(before)&&!Array.isArray(after)){for(const key of new Set([...Object.keys(before),...Object.keys(after)]))walk(before[key],after[key],path?`${path}.${key}`:key);}else changes.push({path,before,after});}walk(publishedSnapshot,next,'');return changes;}
|
||||
|
||||
function updateDraftStatus(){if(!policy)return;try{const count=policyChanges().length;$('draft-state').textContent=count?`${count} unpublished policy ${count===1?'change':'changes'}`:`Published policy v${publishedSnapshot.version}`;$('draft-detail').textContent=count?'Review before publishing. Refresh discards unpublished edits.':'Changes stay in your draft until publication.';$('draft-state').classList.toggle('draft-dirty',count>0);}catch{$('draft-state').textContent='Draft requires correction';$('draft-detail').textContent='Check expiry and advanced JSON before publication.';}}
|
||||
|
||||
$('workspace').addEventListener('input',updateDraftStatus);$('workspace').addEventListener('change',updateDraftStatus);$('workspace').addEventListener('click',()=>queueMicrotask(updateDraftStatus));
|
||||
|
||||
$('publish-shortcut').onclick=()=>{if(!$('publish').disabled)$('publish').click();};
|
||||
|
||||
$('review-changes').onclick=task(async()=>{const changes=policyChanges();$('change-review-list').replaceChildren();for(const change of changes){const item=document.createElement('article');item.className='change-item';const title=document.createElement('strong');title.textContent=change.path;const before=document.createElement('pre'),after=document.createElement('pre');before.textContent=`Published: ${JSON.stringify(change.before)??'unset'}`;after.textContent=`Draft: ${JSON.stringify(change.after)??'unset'}`;item.append(title,before,after);$('change-review-list').append(item);}$('change-review-summary').textContent=changes.length?`${changes.length} changed paths. Nothing has been published by this review.`:'No policy changes.';$('change-review').showModal();});$('review-close').onclick=()=>$('change-review').close();
|
||||
|
||||
function renderPosture(p){$('policy-posture').replaceChildren();const entries=[['Clipboard inspection',p.security.clipboard.enabled?'Enabled':'Supplemental controls off'],['Semantic protection',p.dlp.semantic.enabled?`${p.dlp.semantic.categories.length} categories`:'Not enabled'],['Visual impersonation',p.security.visualPhishing.enabled?'Enabled':'Not enabled'],['Download scanning',p.enterprise.icap.enabled&&p.enterprise.icap.scanDownloads?'External ICAP required':p.security.yara?.enabled?'Local YARA required':'Verify required scanner configuration'],['Network',p.enterprise.vpn.required?'Corporate VPN required':'Device-managed connection'],['Policy expiry',new Date(p.expiresAt).toLocaleString()]];for(const [name,value]of entries){const line=document.createElement('div');line.className='posture-item';const label=document.createElement('span'),text=document.createElement('strong');label.textContent=name;text.textContent=value;line.append(label,text);$('policy-posture').append(line);}}
|
||||
|
||||
function filterTable(kind){const query=$(`${kind}-filter`).value.toLowerCase().trim(),value=$(kind==='device'?'device-access-filter':'event-decision-filter').value,rows=Array.from($(`${kind}-rows`).children);let visible=0;for(const row of rows){const match=(!query||row.textContent.toLowerCase().includes(query))&&(value==='all'||row.dataset[kind==='device'?'access':'decision']===value);row.hidden=!match;if(match)visible++;}$(`${kind}-filter-count`).textContent=`${visible} of ${rows.length} ${kind==='device'?'devices':'events'}`;}
|
||||
|
||||
for(const kind of ['device','event']){$(`${kind}-filter`).oninput=()=>filterTable(kind);$(kind==='device'?'device-access-filter':'event-decision-filter').onchange=()=>filterTable(kind);}
|
||||
|
||||
function applyOperatorPermissions(principal){currentPrincipal=principal;if(typeof applyGroupPermissions==='function')applyGroupPermissions(principal);if(typeof applyProfilePermissions==='function')applyProfilePermissions(principal);if(typeof applyIncidentPermissions==='function')applyIncidentPermissions(principal);operatorPermissions=Array.isArray(principal.permissions)?new Set(principal.permissions):null;if(!operatorPermissions)return;const allowed=permission=>operatorPermissions.has(permission)||(permission==='policy.write'&&independentApprovalRequired&¤tPrincipal.role==='operator');$('access-sessions').hidden=!allowed('sessions.manage');document.querySelector('nav a[href="#access-sessions"]').hidden=!allowed('sessions.manage');$('sessions-load').disabled=!allowed('sessions.manage');const policySections=['policy','managed-settings','security-controls','password-vault','visual-protection','clipboard-controls','enterprise-integrations'];for(const id of policySections)for(const field of $(id).querySelectorAll('input,textarea,select,button'))field.disabled=!allowed('policy.write');for(const id of ['publish','publish-shortcut','preset-preview','managed-bookmark-add','add-rule','fingerprint','semantic-add','semantic-document-add','visual-add'])$(id).disabled=!allowed('policy.write');for(const field of $('bookmarks').querySelectorAll('input,button')){const personal=field.closest('#bookmark-device-editor');field.disabled=!allowed(personal?'bookmarks.write':'policy.write');}$('preset-apply').disabled=!allowed('policy.write')||!presetPreview;$('history-approve').disabled=!allowed('policy.write')||!historyReview||historyReview.sourceVersion===historyReview.expectedVersion;$('history-restore').disabled=!allowed('policy.write')||!historyReview||!$('history-approve').checked||historyReview.sourceVersion===historyReview.expectedVersion;$('bookmark-device-load').disabled=!allowed('bookmarks.read');$('enroll').disabled=!allowed('devices.manage');$('enterprise-icap-probe').disabled=!allowed('integrations.test');$('teach-submit').disabled=!allowed('teaching.write');for(const button of $('device-rows').querySelectorAll('button'))button.disabled=!allowed(button.textContent.includes('document scans')?'policy.write':'devices.manage');for(const button of $('teach-drafts').querySelectorAll('button'))button.disabled=!allowed('policy.write');for(const id of ['bookmarks-add','bookmarks-edit','bookmarks-delete','bookmarks-admin-read'])$(id).disabled=!allowed('policy.write');}
|
||||
|
||||
|
||||
|
||||
// Track the current reading section without removing or replacing any policy form.
|
||||
|
||||
if('IntersectionObserver' in window){const observer=new IntersectionObserver(entries=>{for(const entry of entries)if(entry.isIntersecting){for(const link of document.querySelectorAll('nav a')){const active=link.hash===`#${entry.target.id}`;if(active)link.setAttribute('aria-current','true');else link.removeAttribute('aria-current');}}},{rootMargin:'-120px 0px -65% 0px',threshold:0});for(const section of document.querySelectorAll('#workspace section[id]'))observer.observe(section);}
|
||||
|
||||
|
||||
|
||||
$('admin-logout').onclick=task(async()=>{await api('/auth/logout',{});credential='';location.reload();});
|
||||
|
||||
async function loadSessions(){const data=await api('/api/admin/sessions');$('session-rows').replaceChildren();for(const session of data.sessions){const row=document.createElement('tr');cell(row,session.name===session.subject?session.subject:`${session.name} · ${session.subject}`);cell(row,session.role);cell(row,new Date(session.expiresAt).toLocaleString());const action=cell(row,''),button=document.createElement('button');button.className='danger';button.textContent='Revoke session';button.onclick=task(async()=>{await api(`/api/admin/sessions/${encodeURIComponent(session.id)}/revoke`,{});row.remove();$('sessions-status').textContent='Session revoked immediately. Identity-provider authorization may permit another sign-in.';});action.append(button);$('session-rows').append(row);}$('sessions-status').textContent=`${data.sessions.length} active management sessions${data.truncated?' · first 500 shown':''}.`;}
|
||||
|
||||
$('sessions-load').onclick=task(loadSessions);
|
||||
|
||||
|
||||
|
||||
function fillSecurityControls(security,agents){$('security-dns-enabled').checked=security.dns.enabled;$('security-dns-hosts').value=security.dns.blockedHosts.join('\n');$('security-dns-ips').value=security.dns.blockedIPs.join('\n');$('security-content-phrases').value=security.content.blockedPhrases.join('\n');$('security-yara-enabled').checked=security.yara.enabled;$('security-yara-max').value=security.yara.maxScanBytes;$('agent-enabled').checked=agents.enabled;$('agent-hosts').value=agents.allowedHosts.join('\n');$('agent-sensitive').checked=agents.allowSensitiveRead;for(const scope of agentScopes)$(`agent-scope-${scope}`).checked=agents.scopes.includes(scope);}
|
||||
|
||||
$('security-json').addEventListener('change',()=>{try{const security=JSON.parse($('security-json').value);fillSecurityControls(security,draft().agents);}catch(error){status(`Advanced security configuration: ${error.message}`,true);}});
|
||||
|
||||
|
||||
|
||||
// Persisted history is loaded on demand; a review captures the current version for compare-and-swap restoration.
|
||||
|
||||
let historyNextBefore=null,historyReview=null;
|
||||
|
||||
function canWritePolicy(){return !operatorPermissions||operatorPermissions.has('policy.write')||(independentApprovalRequired&&operatorPermissions.has('policy.prepare'));}
|
||||
|
||||
function clearHistoryReview(){historyReview=null;$('history-review').hidden=true;$('history-approve').checked=false;$('history-restore').disabled=true;}
|
||||
|
||||
async function loadHistory(older=false){
|
||||
|
||||
const data=await api(`/api/admin/policy/history?limit=20${older&&historyNextBefore?`&before=${historyNextBefore}`:''}`);if(!older)$('history-rows').replaceChildren();clearHistoryReview();
|
||||
|
||||
for(const item of data.versions){const row=document.createElement('tr');cell(row,`v${item.version}${item.current?' · current':''}`);cell(row,new Date(item.time).toLocaleString());cell(row,item.actor);const action=cell(row,''),button=document.createElement('button');button.className='secondary';button.textContent=item.current?'Review current version':'Compare with current';button.onclick=task(()=>reviewHistory(item.version));action.append(button);$('history-rows').append(row);}
|
||||
|
||||
historyNextBefore=data.nextBefore;$('history-more').hidden=!historyNextBefore;$('history-status').textContent=`${$('history-rows').children.length} persisted versions shown. Current signed policy: v${data.currentVersion}.`;
|
||||
|
||||
}
|
||||
|
||||
function renderHistoryDiff(changes){$('history-diff-list').replaceChildren();for(const change of changes){const item=document.createElement('article');item.className='change-item';const title=document.createElement('strong');title.textContent=`${change.path} · ${change.kind}`;const before=document.createElement('pre'),after=document.createElement('pre');before.textContent=`Selected version: ${JSON.stringify(change.before)??'unset'}`;after.textContent=`Current version: ${JSON.stringify(change.after)??'unset'}`;item.append(title,before,after);$('history-diff-list').append(item);}}
|
||||
|
||||
async function reviewHistory(version){
|
||||
|
||||
clearHistoryReview();const current=await api('/api/admin/policy/history?limit=1');if(current.currentVersion!==publishedSnapshot.version)throw Error('Current policy changed. Refresh the workspace before comparing or restoring.');
|
||||
|
||||
const comparison=await api(`/api/admin/policy/diff?from=${version}&to=${current.currentVersion}`);if(comparison.truncated||comparison.groupDiff?.truncated)throw Error('Policy difference exceeds the supported review size. Review the policy through the API before restoration.');
|
||||
|
||||
historyReview={sourceVersion:version,expectedVersion:current.currentVersion};renderHistoryDiff([...comparison.changes,...(comparison.groupDiff?.changes||[]).map(change=>({...change,path:`/policyGroups${change.path}`}))]);$('history-review-title').textContent=`Review v${version} against current v${current.currentVersion}`;$('history-review-summary').textContent=`${comparison.changes.length} baseline paths and ${comparison.groupDiff?.changes.length||0} group paths changed. Restoration publishes v${current.currentVersion+1} with a fresh seven-day expiry. Your unpublished draft will be replaced after successful restoration.`;$('history-review').hidden=false;$('history-approve').disabled=!canWritePolicy()||version===current.currentVersion;$('history-restore').disabled=true;$('history-review').scrollIntoView({block:'nearest',behavior:'smooth'});
|
||||
|
||||
}
|
||||
|
||||
$('history-load').onclick=task(()=>loadHistory());$('history-more').onclick=task(()=>loadHistory(true));
|
||||
|
||||
$('history-approve').onchange=()=>{$('history-restore').disabled=!historyReview||!$('history-approve').checked||!canWritePolicy()||historyReview.sourceVersion===historyReview.expectedVersion;};
|
||||
|
||||
$('history-restore').onclick=task(async()=>{if(!historyReview||!$('history-approve').checked||!canWritePolicy())throw Error('Review and approve the restoration first');const selection={...historyReview};$('history-restore').disabled=true;const result=await api('/api/admin/policy/restore',{...selection,approve:true});await load();await loadHistory();policyMutationStatus(result,`Configuration from v${result.sourceVersion} restored and signed as new policy v${result.version}.`);});
|
||||
|
||||
|
||||
|
||||
for(const id of ['agent-enabled','agent-hosts','agent-sensitive',...agentScopes.map(scope=>`agent-scope-${scope}`)])$(id).addEventListener('input',()=>{$('agents-json').value=JSON.stringify({enabled:$('agent-enabled').checked,scopes:agentScopes.filter(scope=>$(`agent-scope-${scope}`).checked),allowedHosts:lines('agent-hosts'),allowSensitiveRead:$('agent-sensitive').checked},null,2);});
|
||||
|
||||
|
||||
|
||||
// The backend controls independent approval; renderer controls provide context only.
|
||||
|
||||
function policyMutationStatus(result,publishedMessage){status(result.reviewRequired?`Changes prepared for independent approval (${result.reviewId}). Published policy is unchanged.`:publishedMessage);}
|
||||
|
||||
function clearIndependentReview(){selectedIndependentReview=null;$('independent-review-detail').hidden=true;$('independent-review-confirm').checked=false;$('independent-review-approve').disabled=true;$('independent-review-reject').disabled=true;}
|
||||
|
||||
function canReviewIndependent(){return currentPrincipal?.role==='admin'&&selectedIndependentReview?.status==='pending'&&!selectedIndependentReview.diff?.truncated&&selectedIndependentReview.author!==currentPrincipal.sub;}
|
||||
|
||||
function updateIndependentReviewActions(){const allowed=canReviewIndependent();$('independent-review-confirm').disabled=!allowed;$('independent-review-approve').disabled=!allowed||!$('independent-review-confirm').checked;$('independent-review-reject').disabled=!allowed;}
|
||||
|
||||
async function loadIndependentReviews(){const data=await api('/api/admin/policy-reviews');clearIndependentReview();$('review-mode').textContent=data.requireIndependentPolicyApproval?'Independent approval is mandatory. Publish controls prepare changes for a different administrator.':'Independent approval is optional on this deployment. Direct publication remains enabled for authorized administrators.';for(const id of ['publish','publish-shortcut'])$(id).textContent=independentApprovalRequired?'Prepare for independent approval':'Publish signed policy';$('history-restore').textContent=independentApprovalRequired?'Prepare restoration for independent approval':'Restore as new signed version';$('reviews-rows').replaceChildren();for(const review of data.reviews){const row=document.createElement('tr');cell(row,new Date(review.time).toLocaleString());cell(row,review.author);cell(row,review.kind);cell(row,`v${review.expectedVersion}`);cell(row,review.status);const action=cell(row,''),button=document.createElement('button');button.className='secondary';button.textContent='Review changes';button.onclick=task(async()=>{selectedIndependentReview=await api(`/api/admin/policy-reviews/${encodeURIComponent(review.id)}`);$('independent-review-confirm').checked=false;$('independent-review-title').textContent=`${selectedIndependentReview.kind} | ${selectedIndependentReview.status}`;$('independent-review-metadata').textContent=`Author: ${selectedIndependentReview.author} | Base policy: v${selectedIndependentReview.expectedVersion} | Payload: ${selectedIndependentReview.payloadHash}${selectedIndependentReview.author===currentPrincipal.sub?' | You cannot approve your own proposal.':''}${selectedIndependentReview.status==='stale'?' | Refresh and prepare a new proposal against the current policy.':''}`;$('independent-review-operation').textContent=independentOperationDescription(selectedIndependentReview.operation);$('independent-review-diff').replaceChildren();for(const change of selectedIndependentReview.diff.changes){const item=document.createElement('article');item.className='change-item';const title=document.createElement('strong'),before=document.createElement('pre'),after=document.createElement('pre');title.textContent=change.path;before.textContent=`Current: ${JSON.stringify(change.before)??'unset'}`;after.textContent=`Proposed: ${JSON.stringify(change.after)??'unset'}`;item.append(title,before,after);$('independent-review-diff').append(item);}if(selectedIndependentReview.diff.truncated){const warning=document.createElement('p');warning.textContent='Diff is truncated. Approval is disabled here; review the complete proposed policy using the authenticated review API.';$('independent-review-diff').append(warning);}$('independent-review-detail').hidden=false;updateIndependentReviewActions();});action.append(button);$('reviews-rows').append(row);}$('reviews-status').textContent=`${data.reviews.length} proposals loaded.`;}
|
||||
|
||||
$('reviews-load').onclick=task(loadIndependentReviews);$('independent-review-confirm').onchange=updateIndependentReviewActions;
|
||||
|
||||
$('independent-review-approve').onclick=task(async()=>{if(!canReviewIndependent()||!$('independent-review-confirm').checked)throw Error('A different administrator must explicitly review and approve');const review=selectedIndependentReview;const result=await api(`/api/admin/policy-reviews/${encodeURIComponent(review.id)}/approve`,{payloadHash:review.payloadHash,expectedVersion:review.expectedVersion,approve:true});await load();status(`Independent approval complete. Signed policy v${result.version} published.`);});
|
||||
|
||||
$('independent-review-reject').onclick=task(async()=>{if(!canReviewIndependent())throw Error('Only a different administrator may reject this proposal');const review=selectedIndependentReview;await api(`/api/admin/policy-reviews/${encodeURIComponent(review.id)}/reject`,{payloadHash:review.payloadHash,reject:true});await loadIndependentReviews();status('Proposal rejected. Published policy is unchanged.');});
|
||||
|
||||
|
||||
function independentOperationDescription(operation={}){
|
||||
if(operation.type?.startsWith('profile')||operation.type?.startsWith('user-profile'))return `User-profile operation; exact subjects, device assignments, website categories and delegated flags: ${JSON.stringify(operation)}`;
|
||||
if(operation.type==='group-upsert'||operation.type==='group-delete')return `Group operation: ${operation.type}. Exact group and rollout: ${JSON.stringify(operation)}`;
|
||||
if(operation.type==='inspection')return `Device: ${operation.deviceId}. Proposed manual document analysis: ${operation.enabled?'Enable':'Disable'}. Mandatory upload DLP and download inspection remain enforced.`;
|
||||
if(operation.type==='teaching')return `Document reference: ${operation.id}. Coverage: ${operation.coverage}. Publication mode: ${operation.mode}. Only derived protection features will be published.`;
|
||||
if(operation.type==='group-restore')return `Restore baseline and group assignments from v${operation.sourceVersion}. Exact restored groups: ${JSON.stringify(operation.groups)}`;
|
||||
if(Number.isInteger(operation.sourceVersion))return `Restore configuration from policy v${operation.sourceVersion} as a new signed version.`;
|
||||
return 'Global browser policy changes shown below.';
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Central diagnostics use the same draft, roles and independent publisher.
|
||||
const diagnosticsFields=['diagnostics-enabled','diagnostics-mode','diagnostics-endpoint','diagnostics-sample','diagnostics-debug-sample'];
|
||||
function fillDiagnostics(p){const d=p.diagnostics||{};$('diagnostics-enabled').checked=d.enabled===true;$('diagnostics-mode').value=d.mode||'operational';$('diagnostics-endpoint').value=d.endpoint||'';$('diagnostics-sample').value=d.sampleRate??0.1;$('diagnostics-debug-sample').value=d.debugSampleRate??1;}
|
||||
const fillBeforeDiagnostics=fill;fill=function(p){fillBeforeDiagnostics(p);fillDiagnostics(p);};
|
||||
const draftBeforeDiagnostics=draft;draft=function(){const p=draftBeforeDiagnostics();p.diagnostics={...(p.diagnostics||{}),enabled:$('diagnostics-enabled').checked,mode:$('diagnostics-mode').value,endpoint:$('diagnostics-endpoint').value.trim(),sampleRate:Number($('diagnostics-sample').value),debugSampleRate:Number($('diagnostics-debug-sample').value)};if(!p.diagnostics.endpoint)delete p.diagnostics.endpoint;return p;};
|
||||
const permissionsBeforeDiagnostics=applyOperatorPermissions;applyOperatorPermissions=function(principal){permissionsBeforeDiagnostics(principal);const allowed=principal.permissions?.includes('policy.write')||(independentApprovalRequired&&principal.role==='operator');for(const id of diagnosticsFields)$(id).disabled=!allowed;};
|
||||
for(const id of diagnosticsFields)$(id).addEventListener('change',()=>updateDraftStatus());
|
||||
if(policy)fillDiagnostics(policy);if(currentPrincipal)applyOperatorPermissions(currentPrincipal);
|
||||
@@ -0,0 +1,17 @@
|
||||
/* Group restrictions are additive. Preview and acknowledgment describe actual backend state. */
|
||||
let groupState={groups:[],devices:[],version:0},selectedGroup='',groupBookmarks=[];
|
||||
const groupFlags=[['group-disable-downloads','disableDownloads'],['group-disable-ai','disableAI'],['group-disable-agents','disableAgents'],['group-hide-settings','hideSettings'],['group-require-scan','requireDocumentScan']];
|
||||
function groupChecks(container,devices,selected){$(container).replaceChildren();for(const device of devices){const label=document.createElement('label'),input=document.createElement('input');label.className='checks';input.type='checkbox';input.value=device.id;input.checked=selected.includes(device.id);input.disabled=device.revoked;label.append(input,document.createTextNode(`${device.name}${device.revoked?' (revoked)':''}`));$(container).append(label);}}
|
||||
function groupSelected(container){return Array.from($(container).querySelectorAll('input:checked'),input=>input.value);}
|
||||
function groupDraft(){const stage=$('group-stage').value,restrictions={blockedHosts:lines('group-blocked-hosts'),blockedPhrases:lines('group-blocked-phrases'),hiddenSections:managedSections.filter(section=>$(`group-hide-${section}`).checked),lockedSections:managedSections.filter(section=>$(`group-lock-${section}`).checked),managedBookmarks:structuredClone(groupBookmarks)};for(const [id,key]of groupFlags)restrictions[key]=$(id).checked;return {id:$('group-id').value.trim(),name:$('group-name').value.trim(),memberDeviceIds:groupSelected('group-members'),restrictions,rollout:{stage,deviceIds:stage==='all'?[]:groupSelected('group-cohort')}};}
|
||||
function editGroup(group){selectedGroup=group?.id||'';groupBookmarks=structuredClone(group?.restrictions.managedBookmarks||[]);renderBookmarkRows('group-bookmark-rows',groupBookmarks);$('group-editor-title').textContent=group?`Edit ${group.name}`:'Create a device group';$('group-id').value=group?.id||'';$('group-id').readOnly=!!group;$('group-name').value=group?.name||'';$('group-stage').value=group?.rollout.stage||'pilot';groupChecks('group-members',groupState.devices,group?.memberDeviceIds||[]);groupChecks('group-cohort',groupState.devices,group?.rollout.deviceIds||[]);$('group-blocked-hosts').value=(group?.restrictions.blockedHosts||[]).join('\n');$('group-blocked-phrases').value=(group?.restrictions.blockedPhrases||[]).join('\n');for(const [id,key]of groupFlags)$(id).checked=group?.restrictions[key]===true;for(const section of managedSections){$(`group-hide-${section}`).checked=group?.restrictions.hiddenSections?.includes(section)||false;$(`group-lock-${section}`).checked=group?.restrictions.lockedSections?.includes(section)||false;}$('group-preview-summary').textContent='';$('group-preview-diff').replaceChildren();updateGroupCohort();applyGroupPermissions(currentPrincipal);}
|
||||
function updateGroupCohort(){const members=groupSelected('group-members'),all=$('group-stage').value==='all';for(const checkbox of $('group-cohort').querySelectorAll('input')){if(!members.includes(checkbox.value))checkbox.checked=false;checkbox.disabled=all||!members.includes(checkbox.value);} }
|
||||
function applyGroupPermissions(principal){if(!principal)return;const permissions=new Set(principal.permissions||[]),write=permissions.has('policy.write')||(independentApprovalRequired&&permissions.has('policy.prepare'));for(const input of $('policy-groups').querySelectorAll('input,textarea,select'))input.disabled=!write;for(const id of ['groups-refresh','group-preview-device','group-preview'])$(id).disabled=!permissions.has('policy.read');for(const id of ['group-save','group-new','group-bookmark-add'])$(id).disabled=!write;$('group-delete').disabled=!write||!selectedGroup;for(const button of $('group-bookmark-rows').querySelectorAll('button'))button.disabled=!write;for(const button of $('groups-rows').querySelectorAll('button'))button.disabled=!permissions.has('policy.read');$('group-save').textContent=independentApprovalRequired?'Prepare group for independent approval':'Publish group and rollout';if(write)updateGroupCohort();}
|
||||
async function loadPolicyGroups(){groupState=await api('/api/admin/policy-groups');$('groups-rows').replaceChildren();for(const group of groupState.groups){const row=document.createElement('tr');cell(row,group.name);cell(row,group.memberDeviceIds.length);cell(row,group.rollout.stage==='all'?'All members':`${group.rollout.stage} · ${group.rollout.deviceIds.length} devices`);const action=cell(row,''),button=document.createElement('button');button.className='secondary';button.textContent='Edit / expand rollout';button.onclick=()=>editGroup(group);action.append(button);$('groups-rows').append(row);}$('group-device-rows').replaceChildren();$('group-preview-device').replaceChildren();for(const device of groupState.devices){const row=document.createElement('tr');cell(row,device.name);cell(row,(device.groups||[]).join(', ')||'Baseline');cell(row,`${device.policyVersion??0} / ${device.targetVersion??groupState.version}`);cell(row,device.revoked?'Revoked':device.acknowledged?'Reported target version':'Awaiting target version');cell(row,device.last_seen?new Date(device.last_seen).toLocaleString():'No contact');$('group-device-rows').append(row);if(!device.revoked){const option=document.createElement('option');option.value=device.id;option.textContent=device.name;$('group-preview-device').append(option);}}$('groups-status').textContent=`${groupState.groups.length} groups · signed baseline v${groupState.version}`;editGroup(groupState.groups.find(group=>group.id===selectedGroup));}
|
||||
$('group-sections').replaceChildren();for(const section of managedSections){const row=document.createElement('div');row.className='checks';for(const [kind,label]of [['hide','Hide'],['lock','Lock']]){const element=document.createElement('label'),input=document.createElement('input');input.type='checkbox';input.id=`group-${kind}-${section}`;element.append(input,document.createTextNode(`${label} ${section}`));row.append(element);}$('group-sections').append(row);}
|
||||
$('groups-refresh').onclick=task(loadPolicyGroups);$('group-new').onclick=()=>editGroup();$('group-members').onchange=updateGroupCohort;$('group-stage').onchange=updateGroupCohort;
|
||||
$('group-save').onclick=task(async()=>{const group=groupDraft();if(!group.id||!group.name)throw Error('Enter a group identifier and name');const result=await api(`/api/admin/policy-groups/${encodeURIComponent(group.id)}`,{expectedVersion:groupState.version,group},'PUT');selectedGroup=group.id;await load();policyMutationStatus(result,`Group and ${group.rollout.stage} rollout published as signed policy v${result.version}.`);});
|
||||
$('group-delete').onclick=task(async()=>{if(!selectedGroup)throw Error('Select an existing group');const result=await api(`/api/admin/policy-groups/${encodeURIComponent(selectedGroup)}`,{expectedVersion:groupState.version},'DELETE');selectedGroup='';await load();policyMutationStatus(result,`Group removed in signed policy v${result.version}.`);});
|
||||
$('group-preview').onclick=task(async()=>{const group=groupDraft(),body={deviceId:$('group-preview-device').value};if(group.id)body.group=group;const result=await api('/api/admin/policy-groups/preview',body);$('group-preview-summary').textContent=`Effective groups: ${(result.groups||[]).join(', ')||'baseline only'} · ${result.diff?.changes?.length||0} differences from baseline`;$('group-preview-diff').replaceChildren();for(const change of result.diff?.changes||[]){const item=document.createElement('article');item.className='change-item';const title=document.createElement('strong'),before=document.createElement('pre'),after=document.createElement('pre');title.textContent=change.path;before.textContent=`Baseline: ${JSON.stringify(change.before)}`;after.textContent=`Effective: ${JSON.stringify(change.after)}`;item.append(title,before,after);$('group-preview-diff').append(item);}if(result.diff?.truncated){const warning=document.createElement('p');warning.textContent='Preview differences truncated; inspect the complete effective policy before publishing.';$('group-preview-diff').append(warning);}});
|
||||
|
||||
$('group-bookmark-add').onclick=task(async()=>addBookmark(groupBookmarks,'group-bookmark-rows'));
|
||||
@@ -0,0 +1,7 @@
|
||||
/* Incident actions operate only on metadata. Server roles and revision CAS remain authoritative. */
|
||||
let incidentSelection=null,incidentCursor=null;
|
||||
function applyIncidentPermissions(principal){if(!principal)return;const permissions=new Set(principal.permissions||[]),read=permissions.has('incidents.read'),write=permissions.has('incidents.triage');$('incidents').hidden=!read;document.querySelector('nav a[href="#incidents"]').hidden=!read;for(const id of ['incident-state','incident-severity','incident-assignee','incident-comment','incident-save'])$(id).disabled=!write;for(const button of document.querySelectorAll('[data-incident-create]'))button.disabled=!write;}
|
||||
async function loadIncidents(append=false){const query=new URLSearchParams({limit:'50'});if($('incident-status-filter').value)query.set('status',$('incident-status-filter').value);if($('incident-severity-filter').value)query.set('severity',$('incident-severity-filter').value);if(append&&incidentCursor!==null)query.set('before',String(incidentCursor));const result=await api(`/api/admin/incidents?${query}`);if(!append)$('incident-rows').replaceChildren();for(const incident of result.incidents){const row=document.createElement('tr');row.dataset.incidentId=incident.id;cell(row,new Date(incident.createdAt).toLocaleString());cell(row,incident.event.kind);cell(row,incident.status);cell(row,incident.severity);cell(row,incident.assignee||'Unassigned');const button=document.createElement('button');button.textContent='Review';button.className='secondary';button.onclick=task(()=>openIncident(incident.id));cell(row,'').append(button);$('incident-rows').append(row);}incidentCursor=result.nextBefore??null;$('incidents-more').hidden=incidentCursor===null;$('incidents-status').textContent=`${$('incident-rows').children.length} incidents shown${incidentCursor===null?'':' · older incidents available'}`;applyIncidentPermissions(currentPrincipal);}
|
||||
async function openIncident(id){const result=await api(`/api/admin/incidents/${encodeURIComponent(id)}`),incident=result.incident||result;incidentSelection=incident;$('incident-detail').hidden=false;$('incident-title').textContent=`Incident · ${incident.event.kind}`;$('incident-revision').textContent=`${incident.id} · revision ${incident.revision} · updated ${new Date(incident.updatedAt).toLocaleString()}`;$('incident-event').textContent=JSON.stringify(incident.event,null,2);$('incident-state').value=incident.status;$('incident-severity').value=incident.severity;$('incident-assignee').value=incident.assignee||'';$('incident-comment').value='';$('incident-detail-status').textContent='';$('incident-history').replaceChildren();for(const entry of incident.history||[]){const item=document.createElement('article');item.className='change-item';const title=document.createElement('strong'),note=document.createElement('p');title.textContent=`Revision ${entry.revision} · ${entry.status} · ${entry.severity} · ${entry.actor} · ${new Date(entry.time).toLocaleString()}`;note.textContent=`Assignee: ${entry.assignee||'Unassigned'}${entry.comment?' · '+entry.comment:''}`;item.append(title,note);$('incident-history').append(item);}applyIncidentPermissions(currentPrincipal);}
|
||||
$('incidents-refresh').onclick=task(()=>loadIncidents());for(const id of ['incident-status-filter','incident-severity-filter'])$(id).onchange=task(()=>loadIncidents());$('incidents-more').onclick=task(()=>loadIncidents(true));$('incident-detail-refresh').onclick=task(async()=>{if(incidentSelection)await openIncident(incidentSelection.id);});
|
||||
$('incident-save').onclick=task(async()=>{if(!incidentSelection)throw Error('Select an incident first.');try{await api(`/api/admin/incidents/${encodeURIComponent(incidentSelection.id)}`,{expectedRevision:incidentSelection.revision,status:$('incident-state').value,severity:$('incident-severity').value,assignee:$('incident-assignee').value.trim()||null,...($('incident-comment').value.trim()?{comment:$('incident-comment').value.trim()}:{})},'PATCH');await openIncident(incidentSelection.id);await loadIncidents();$('incident-detail-status').textContent='Investigation update saved.';}catch(error){$('incident-detail-status').textContent=`Update was not saved: ${error.message}. Your edits are preserved. Refresh the incident before trying again.`;throw error;}});
|
||||
@@ -0,0 +1,32 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SafetyNet · Management</title><link rel="stylesheet" href="/style.css"></head><body>
|
||||
<a class="skip-link" href="#overview">Skip to workspace</a><aside aria-label="Management navigation"><div class="logo"><b>S</b><span>SafetyNet<small>Enterprise management</small></span></div><nav aria-label="Workspace"><span class="nav-group">WORKSPACE</span><a href="#overview">Overview</a><a href="#devices">Device fleet</a><a href="#events">Security events</a><a href="#incidents">Incident triage</a><span class="nav-group">POLICY & PROTECTION</span><a href="#policy">Browser policy</a><a href="#user-profiles">User classes & website access</a><a href="#policy-groups">Groups & pilot rollouts</a><a href="#policy-history">Policy history</a><a href="#policy-reviews">Independent approvals</a><a href="#managed-settings">User permissions</a><a href="#bookmarks">Company bookmarks</a><a href="#teaching">Data protection & AI</a><a href="#document-teaching">Document references</a><a href="#diagnostics-controls">Browser diagnostics</a><a href="#security-controls">Security rules & agents</a><a href="#visual-protection">Visual impersonation</a><a href="#clipboard-controls">Clipboard & ClickFix</a><a href="#password-vault">Password vault</a><span class="nav-group">DEPLOYMENT</span><a href="#enterprise-setup">Policy templates</a><a href="#enterprise-integrations">Network & scanners</a><a href="#access-sessions">Access & sessions</a></nav><div class="aside-note"><label for="console-theme">Console appearance</label><select id="console-theme"><option value="system">Use system theme</option><option value="light">Light</option><option value="dark">Dark</option></select><span class="pill">Development build · 0.1</span></div></aside><main><header><div><p class="eyebrow">SECURE ENTERPRISE BROWSING</p><h1>Management console</h1><p class="header-description">Policies, people and protection in one workspace.</p></div><div class="header-actions"><button id="refresh" class="secondary">Refresh workspace</button><button id="admin-logout" class="secondary" hidden>Sign out</button></div></header>
|
||||
<section id="login" class="card"><h2>Administrator access</h2><p>Sign in with your enterprise identity, or use the bootstrap token configured on the server.</p><div class="row"><input id="token" type="password" placeholder="Bootstrap administrator token" autocomplete="off"><button id="connect">Connect</button><a class="button secondary" href="/auth/login" id="sso">Enterprise SSO</a></div></section>
|
||||
<p id="status" role="status" aria-live="polite"></p><p id="siem-status" class="muted"></p>
|
||||
<div id="workspace" hidden><div class="publish-bar" role="region" aria-label="Policy publication"><div><strong id="draft-state">Published policy loaded</strong><span id="draft-detail">Changes stay in your draft until publication.</span></div><div class="row"><button id="review-changes" class="secondary">Review draft changes</button><button id="publish-shortcut">Publish signed policy</button></div></div>
|
||||
<section id="overview" class="metrics"><article><span>Enrolled devices</span><strong id="device-count">0</strong></article><article><span>Active policy</span><strong id="policy-version">v1</strong></article><article><span>Recent blocks</span><strong id="block-count">0</strong></article><article><span>AI workspace</span><strong id="ai-status">Off</strong></article></section><section class="overview-grid" aria-label="Current policy posture"><article class="card"><p class="eyebrow">POLICY POSTURE</p><h2>Current enforcement</h2><div id="policy-posture"></div><p class="muted">Based on the published policy. Configuration is not a device health attestation.</p></article><article class="card"><p class="eyebrow">OPERATOR WORKFLOW</p><h2>Configure, review, publish</h2><ol class="workflow"><li>Choose a template or adjust your controls.</li><li>Test data protection with representative examples.</li><li>Review changes and publish a signed policy.</li><li>Check device versions and security events.</li></ol><a href="#enterprise-setup" class="button secondary">Configure a policy template</a></article></section><section class="card" id="enterprise-setup"><p class="eyebrow">ENTERPRISE SETUP</p><h2>Start with a reviewed policy template</h2><p>Templates tighten selected controls while keeping your existing rules and infrastructure. Preview changes, add them to your draft, then publish when ready.</p><div class="row"><select id="preset-id"><option value="baseline">Enterprise baseline</option><option value="managed-ai">Managed AI workspace</option><option value="restricted">Restricted information</option><option value="existing-stack">Preserve existing security stack</option></select><button id="preset-preview" class="secondary">Preview template</button><button id="preset-apply" disabled>Add preview to draft</button></div><p id="preset-description"></p><div id="preset-changes"></div><h3>Integration configuration</h3><p>These checks describe policy configuration. They do not certify that an enrolled device can reach the service.</p><div id="integration-readiness"></div></section>
|
||||
<section class="card" id="enterprise-integrations"><h2>Your existing network and security services</h2><p>Keep Windows-managed proxy and DNS for existing enterprise deployments, or mandate corporate resolvers and VPN interfaces. ICAP connects compatible antivirus and sandbox services before content is released.</p><div class="grid"><label>DNS mode<select id="enterprise-dns-mode"><option value="system">Windows / VPN DNS</option><option value="secure">Required corporate HTTPS DNS</option></select></label><label>Corporate HTTPS DNS resolvers <small>One HTTPS endpoint per line; clear when using Windows DNS</small><textarea id="enterprise-dns-templates" placeholder="https://dns.company.example/dns-query"></textarea></label><label class="checks"><input type="checkbox" id="enterprise-vpn-required"> Require corporate VPN interface</label><label>Approved Windows interface names <small>One exact adapter name per line</small><textarea id="enterprise-vpn-interfaces" placeholder="Company VPN"></textarea></label><label>Required VPN DNS server IPs <small>Optional; one IPv4 or IPv6 address per line</small><textarea id="enterprise-vpn-dns" placeholder="10.0.0.53"></textarea></label><label class="checks"><input type="checkbox" id="enterprise-icap-enabled"> Require external ICAP verdicts</label><button type="button" id="enterprise-icap-probe" class="secondary">Test ICAP capabilities</button><output id="enterprise-icap-status" aria-live="polite"></output><label>ICAP service URL<input id="enterprise-icap-url" placeholder="icaps://scanner.company.example:11344/avscan"></label><label>ICAP service method<select id="enterprise-icap-method"><option value="RESPMOD">Response modification service</option><option value="REQMOD">Request modification service</option></select></label><label>Inspection timeout (milliseconds)<input id="enterprise-icap-timeout" type="number" min="1000" max="60000"></label><label>Maximum inspection bytes<input id="enterprise-icap-max" type="number" min="1024" max="20971520"></label></div><div class="checks"><label><input type="checkbox" id="enterprise-icap-uploads"> Inspect uploads</label><label><input type="checkbox" id="enterprise-icap-downloads"> Inspect downloads</label><label><input type="checkbox" id="enterprise-icap-clipboard"> Inspect clipboard</label><label><input type="checkbox" id="enterprise-icap-ai"> Inspect AI requests</label><label><input type="checkbox" id="enterprise-icap-insecure"> Approve remote plaintext ICAP transport</label></div><p class="muted">TLS certificate validation stays enabled. Customer CA certificates and mutual TLS credentials belong in protected deployment configuration. Plaintext ICAP exposes submitted content; approve it only for a separately protected trusted transport. VPN adapter checks do not prove tunnel routing. Deferred verdicts and modified payloads are rejected. Publish the signed policy to apply changes.</p></section>
|
||||
|
||||
<section id="policy" class="card"><div class="section-head"><div><p class="eyebrow">CENTRAL CONFIGURATION</p><h2>One policy. Every managed browser.</h2></div><span class="pill" id="identity"></span></div>
|
||||
<div class="grid"><label>Brand name<input id="brand-name" maxlength="40"></label><label>Brand accent<input id="brand-color" type="color"></label><label class="wide">Home URL<input id="home-url" type="url"></label><label>Allowed hosts <small>Empty allows all HTTP(S) destinations</small><textarea id="allowed-hosts" placeholder="example.com *.company.com"></textarea></label><label>Blocked hosts <small>One host per line</small><textarea id="blocked-hosts" placeholder="unapproved-ai.example"></textarea></label><label>Proxy mode<select id="proxy-mode"><option value="system">System proxy</option><option value="direct">Direct connection</option><option value="fixed_servers">Fixed proxy servers</option><option value="pac_script">HTTPS PAC script</option></select></label><label>Proxy rules / PAC URL<input id="proxy-value" placeholder="http=proxy.company.com:8080;https=proxy.company.com:8080"></label><label>Proxy bypass list<input id="proxy-bypass" placeholder="*.company.com"></label><label>Policy expiry<input id="expiry" type="datetime-local"></label><label>AI model<input id="ai-model" placeholder="Must match server-approved model"></label><label>Maximum inspected bytes<input id="max-bytes" type="number" min="1024" max="10485760"></label></div>
|
||||
<div class="checks"><label><input id="downloads" type="checkbox"> Allow downloads</label><label><input id="devtools" type="checkbox"> Allow developer tools</label><label><input id="websockets" type="checkbox"> Allow WebSockets (messages not inspected)</label><label><input id="ai-enabled" type="checkbox"> Enable approved AI gateway</label></div><button id="publish">Publish signed policy</button><p class="muted">Devices refresh every 15 seconds. Changes and actor identity are recorded in the audit log.</p></section>
|
||||
<section id="user-profiles" class="card"><p class="eyebrow">IDENTITY AND FEATURE ACCESS</p><h2>User classes and website access</h2><p>Create profiles for any user class with your own names and controls. Employee, developer and high-protection templates are optional starting points. Assign profiles to verified enterprise subjects or explicit managed devices. Asset-owner labels are not authenticated identities. Website denials and category restrictions accumulate across matching profiles; an allowlist does not bypass a denial.</p><button id="profiles-refresh" class="secondary">Refresh profiles</button><p id="profiles-status" role="status" aria-live="polite"></p><div class="table-wrap"><table><thead><tr><th>Profile</th><th>Assignments</th><th>Developer tools</th><th>Action</th></tr></thead><tbody id="profiles-rows"></tbody></table></div><div class="row"><button id="profile-new" class="secondary">Create your own profile</button><label>Starting class<select id="profile-template"><option value="employee">Employee</option><option value="developer">Developer</option><option value="high-protection">High protection</option></select></label><button id="profile-use-template" class="secondary">Use class in draft</button></div><h3 id="profile-editor-title">Create profile</h3><div class="grid"><label>Identifier<input id="profile-id" maxlength="80"></label><label>Name<input id="profile-name" maxlength="100"></label><label>Verified enterprise subject identifiers<textarea id="profile-subjects" placeholder="Exact issuer-verified subject; one per line"></textarea></label><fieldset><legend>Explicit device assignments</legend><div id="profile-devices" class="device-checklist"></div></fieldset><label>Allowed website hosts<textarea id="profile-allowed" placeholder="Empty adds no allowlist restriction"></textarea></label><label>Denied website hosts<textarea id="profile-blocked" placeholder="*.unapproved.example"></textarea></label><fieldset><legend>Denied domain categories</legend><div id="profile-categories" class="device-checklist"></div></fieldset><label>Developer tools / browser console<select id="profile-devtools"><option value="inherit">Inherit baseline</option><option value="deny">Deny</option><option value="allow">Allow within delegated baseline</option></select></label><label>Downloads<select id="profile-downloads"><option value="inherit">Inherit</option><option value="deny">Deny</option></select></label><label>AI workspace<select id="profile-ai"><option value="inherit">Inherit</option><option value="deny">Deny</option></select></label><label>Local agents<select id="profile-agents"><option value="inherit">Inherit</option><option value="deny">Deny</option></select></label><label class="checks"><input id="profile-scan" type="checkbox"> Require document analysis</label></div><p class="muted">Developer grants require central delegation; any applicable deny wins. No profile can override mandatory scanning or unlock centrally hidden settings.</p><div class="row"><button id="profile-save">Publish profile</button><button id="profile-delete" class="danger" disabled>Delete profile</button></div><h3>Preview effective device policy</h3><div class="row"><select id="profile-preview-device" aria-label="Preview device"></select><button id="profile-preview" class="secondary">Preview current draft</button></div><p id="profile-preview-summary" aria-live="polite"></p><div id="profile-preview-diff"></div><details><summary>Central categories and developer delegation</summary><p>Categories contain your approved domain lists. This is not an automatic external website reputation feed. Enabling delegation permits reviewed developer-profile grants; denying profiles still win.</p><label class="checks"><input id="profile-delegation" type="checkbox"> Permit profiles to grant developer tools</label><label>Category definitions JSON<textarea id="profile-category-json" rows="8" spellcheck="false" placeholder='[{"id":"social","name":"Social sites","hosts":["*.social.example"]}]'></textarea></label><button id="profile-config-save">Publish categories and delegation</button></details><h3>Assignment visibility</h3><div class="table-wrap"><table><thead><tr><th>Device</th><th>Verified subject</th><th>Effective profiles</th></tr></thead><tbody id="profile-device-rows"></tbody></table></div></section><section class="card" id="policy-groups"><p class="eyebrow">CONTROLLED DEPLOYMENT</p><h2>Groups and pilot rollouts</h2><p>Apply additional restrictions to explicit device groups. Start with selected pilot devices, inspect their reported policy versions, then explicitly expand the cohort. Group rules can tighten the baseline; they cannot weaken it. No automatic promotion occurs.</p><button id="groups-refresh" class="secondary">Refresh groups and reported versions</button><p id="groups-status" role="status" aria-live="polite"></p><div class="table-wrap"><table><thead><tr><th>Group</th><th>Members</th><th>Stage</th><th>Actions</th></tr></thead><tbody id="groups-rows"></tLine truncated
|
||||
<section class="card" id="bookmarks"><p class="eyebrow">COMPANY LIBRARY</p><h2>Managed bookmarks</h2><p>Publish intranet and approved service links to every browser. Company bookmarks are read-only for employees. Personal bookmarks remain separate; configure whether employees may add, edit or delete them.</p><div class="checks"><label><input type="checkbox" id="bookmarks-add"> Allow personal additions</label><label><input type="checkbox" id="bookmarks-edit"> Allow personal edits</label><label><input type="checkbox" id="bookmarks-delete"> Allow personal deletion</label><label><input type="checkbox" id="bookmarks-admin-read"> Allow administrators to read and edit personal bookmarks</label></div><div class="table-wrap"><table><thead><tr><th>Title</th><th>HTTPS / HTTP URL (no query or fragment)</th><th>Folder</th><th>Actions</th></tr></thead><tbody id="managed-bookmark-rows"></tbody></table></div><button class="secondary" id="managed-bookmark-add">Add company bookmark</button><p class="muted">Company changes are drafts until you publish the signed policy. Personal permissions also respect the centrally locked or hidden Library section.</p><h3>Device personal bookmarks</h3><p>Authorized administrators can review and remotely edit a device's synchronized personal bookmarks. Changes are applied when the browser next refreshes management state. Titles and URLs are never included in security audit events.</p><div class="row"><select id="bookmark-device" aria-label="Device to review"></select><button id="bookmark-device-load" class="secondary">Read device bookmarks</button></div><p id="bookmark-device-status" role="status" aria-live="polite"></p><div id="bookmark-device-editor" hidden><div class="table-wrap"><table><thead><tr><th>Title</th><th>HTTPS / HTTP URL (no query or fragment)</th><th>Folder</th><th>Actions</th></tr></thead><tbody id="personal-bookmark-rows"></tbody></table></div><div class="row"><button id="personal-bookmark-add" class="secondary">Add personal bookmark</button><button id="bookmark-device-save">Save remote changes</button><button id="bookmark-device-refresh" class="secondary">Discard edits and refresh</button></div><p class="muted">Saving compares the loaded revision so another user's or administrator's updates cannot be overwritten silently.</p></div></section>
|
||||
<section id="teaching" class="card"><p class="eyebrow">TEACH YOUR BROWSER</p><h2>Define what must stay inside</h2><p>Add phrases that must never leave, or register an exact file fingerprint. Reference files stay on your computer; only their SHA-256 fingerprint enters policy.</p><div class="row"><input id="rule-label" placeholder="Rule name"><input id="rule-value" placeholder="Confidential phrase"><button id="add-rule">Add phrase</button></div><label class="file">Register a confidential file fingerprint<input id="fingerprint" type="file"></label><div id="rules"></div><div class="grid"><label class="wide">Try a policy example<textarea id="test-text" placeholder="Test content is sent to your management server, never to an AI provider."></textarea></label></div><button id="test" class="secondary">Test draft policy</button><output id="test-result"></output><h2>Local semantic reference protection</h2><p>Describe a confidential category. Your approved local EmbeddingGemma 2 runtime creates pinned text reference vectors. Document extraction and local OCR text can be compared against these references. This workflow does not train a model or provide native video inspection.</p><div class="grid"><label>Category name<input id="semantic-label" placeholder="Unreleased product designs"></label><label>Similarity threshold<input id="semantic-threshold" type="number" min="0.000001" max="1" step="0.01" value="0.8"></label><label class="wide">Confidential category description<textarea id="semantic-description" placeholder="Describe the protected topic with representative examples."></textarea></label></div><button id="semantic-add" class="secondary">Create semantic reference</button><label>Internal semantic reference document (up to 1 MiB)<input id="semantic-document" type="file"></label><button id="semantic-document-add" class="secondary">Derive local document reference</button><p>Only normalized reference vectors and coverage features enter the policy draft. No document, extracted text or model fine tuning is retained. Complete extraction and chunk coverage are required; publish explicitly to approve.</p><label class="checks"><input id="semantic-enabled" type="checkbox"> Enforce semantic categories</label><div id="semantic-rules"></div><p class="muted">Requires locally provisioned model weights, an exact model revision, matching dimensions and the document-v1 profile. Similarity is a detector score, not a confidence guarantee. Incomplete extraction or chunk coverage always blocks.</p></section>
|
||||
<section class="card" id="security-controls"><h2>Enterprise security and local agents</h2><p>Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.</p><div class="grid"><label class="checks"><input id="security-dns-enabled" type="checkbox"> Enforce DNS blocklists</label><label>Blocked DNS hosts<textarea id="security-dns-hosts" placeholder="*.unapproved.example"></textarea></label><label>Blocked IP addresses<textarea id="security-dns-ips" placeholder="203.0.113.10"></textarea></label><label>Blocked content phrases<textarea id="security-content-phrases" placeholder="One phrase per line"></textarea></label><label class="checks"><input id="security-yara-enabled" type="checkbox"> Require local YARA scanning</label><label>YARA maximum scanned bytes<input id="security-yara-max" type="number" min="1024" max="20971520"></label><label class="checks"><input id="agent-enabled" type="checkbox"> Enable paired local agents</label><label>Agent destination allowlist<textarea id="agent-hosts" placeholder="*.company.example"></textarea></label><label class="checks"><input id="agent-sensitive" type="checkbox"> Permit sensitive page reads within approved scopes</label><fieldset class="wide"><legend>Agent capabilities</legend><div class="checks"><label><input id="agent-scope-sessions" type="checkbox"> Sessions</label><label><input id="agent-scope-navigate" type="checkbox"> Navigate</label><label><input id="agent-scope-read" type="checkbox"> Read</label><label><input id="agent-scope-click" type="checkbox"> Click</label><label><input id="agent-scope-type" type="checkbox"> Type</label><label><input id="agent-scope-screenshot" type="checkbox"> Screenshot</label><label><input id="agent-scope-ai" type="checkbox"> AI gateway</label><label><input id="agent-scope-tabs:close" type="checkbox"> Close tabs</label><label><input id="agent-scope-bookmarks:list" type="checkbox"> Read bookmarks</label><label><input id="agent-scope-tabs:group" type="checkbox"> Group tabs</label><label><input id="agent-scope-tabs:activate" type="checkbox"> Activate tabs</label><label><input id="agent-scope-bookmarks:mutate" type="checkbox"> Organize user bookmarks</label><label><input id="agent-scope-application:state" type="checkbox"> Application state</label><label><input id="agent-scope-tabs:create" type="checkbox"> Create native tabs</label><label><input id="agent-scope-settings:get" type="checkbox"> Read managed settings</label><label><input id="agent-scope-settings:update" type="checkbox"> Change permitted appearance</label></div></fieldset><details class="wide"><summary>Advanced source configuration: YARA, Sigma and visual references</summary><p class="muted">Structured controls above are authoritative for DNS, content and agent permissions. Preserve advanced rule sources when making other changes.</p><label>Security policy JSON<textarea id="security-json" rows="14"></textarea></label><label>Local agent policy JSON<textarea id="agents-json" rows="6" readonly></textarea></label></details><label>AI provider<select id="ai-provider"><option value="openai-compatible">OpenAI compatible</option><option value="ollama">Local Ollama</option></select></label><label>AI trust mode<select id="ai-trust"><option value="external">Approved external provider</option><option value="local">Numeric loopback local model only</option></select></label><label class="checks"><input id="ai-confidential-local" type="checkbox"> Allow taught confidential text in approved local AI for all currently enrolled policy users. Credentials, unsafe content and uninspectable data remain blocked.</label><label>Approved endpoint identifier<input id="ai-endpoint"></label></div><p>Publish signed policy above to apply. Downloads require configured local YARA or external ICAP. Every enabled scanner is mandatory; unavailable inspection blocks downloads. Scan limit is 20 MB. Sigma supports exact metadata selection only.</p></section>
|
||||
<section class="card" id="document-teaching"><h2>Governed document teaching</h2><p>Submit a reference document to the private local inspector, then explicitly approve its fingerprint for publication. For incomplete extraction, explicitly approve available fingerprints with partial coverage; unextracted media is not fingerprinted. Only hashes, coverage counts and the reference label are retained. Full documents, extracted text and base64 are discarded after local processing and are never persisted by management.</p><div class="row"><input id="teach-label" placeholder="Confidential reference label"><input id="teach-file" type="file"><button id="teach-submit">Create reviewed draft</button></div><div id="teach-drafts"></div></section><section class="card" id="password-vault"><h2>Local password vault</h2><p>Credentials stay on the browser device. Management stores permissions and lifecycle audit events only; passwords, usernames and credential identifiers are not uploaded.</p><div class="checks"><label><input id="vault-enabled" type="checkbox"> Enable password vault</label><label><input id="vault-saving" type="checkbox"> Allow saving credentials</label><label><input id="vault-autofill" type="checkbox"> Allow autofill</label></div><label>Idle lock in seconds (60–900)<input id="vault-idle" type="number" min="60" max="900"></label><p>Publish signed policy to apply these controls.</p></section><section class="card" id="visual-protection"><h2>Visual impersonation protection</h2><label class="checks"><input id="visual-enabled" type="checkbox"> Block protected login designs on unauthorized origins</label><p>For example, register a trusted Google login screenshot and allow only https://accounts.google.com. A matching login on another origin can then be blocked even when its domain is absent from every blocklist.</p><p>Upload your own trusted PNG of a brand login page or cropped login form. Only a derived perceptual hash, dimensions and allowed origins enter the policy draft. Reference pixels are discarded; no automatic Google or other brand profile is supplied.</p><div class="grid"><label>Brand identifier<input id="visual-id" maxlength="80" placeholder="corporate-login"></label><label>Brand label<input id="visual-label" maxlength="100" placeholder="Corporate login"></label><label class="wide">Exact allowed HTTPS origins<textarea id="visual-origins" placeholder="https://login.company.example"></textarea></label><label>Reference scope<select id="visual-scope"><option value="viewport">Whole viewport</option><option value="login-form">Cropped login form</option></select></label><label>Trusted PNG reference<input id="visual-image" type="file" accept="image/png"></label></div><button id="visual-add">Derive reference into draft</button><p>Add trusted references, enable the control above and publish the signed policy. Use multiple references for different themes, languages and login steps. False positives, viewport changes and visual evasion require validation. The browser checks rendered pixels; this is not pre-render response inspection.</p></section><section class="card" id="clipboard-controls"><h2>Managed clipboard and ClickFix protection</h2><p>Control supported browser copy, cut and paste actions centrally. High-risk shell command patterns are blocked without sending clipboard text to an AI provider. Clipboard text and credentials never enter security logs.</p><div class="checks"><label><input id="clipboard-enabled" type="checkbox"> Enable clipboard controls</label><label><input id="clipboard-copy" type="checkbox"> Allow copy</label><label><input id="clipboard-cut" type="checkbox"> Allow cut</label><label><input id="clipboard-paste" type="checkbox"> Allow paste</label><label><input id="clipboard-command" type="checkbox"> Block suspicious execution commands</label></div><label>Maximum clipboard text bytes<input id="clipboard-max" type="number" min="1024" max="1048576"></label><p>Publish signed policy to apply. Mandatory DLP remains active when supplemental clipboard controls are disabled. Unsupported formats and unavailable required inspection fail closed. Pattern detection covers selected execution chains; it does not prove text or commands safe.</p></section><section id="devices" class="card"><div class="section-head"><div><p class="eyebrow">DEVICE FLEET</p><h2>Enrollment and access</h2><p>Enable or disable supplemental manual document analysis per browser. Mandatory upload DLP and download YARA continue regardless of this setting.</p></div></div><div class="row"><input id="enroll-user" placeholder="Employee identity / asset owner"><button id="enroll">Create one-use enrollment code</button></div><output id="enrollment"></output><div class="row filter-row"><label>Find a device<input id="device-filter" type="search" placeholder="Name, identity or platform"></label><label>Access<select id="device-access-filter"><option value="all">All devices</option><option value="active">Active</option><option value="revoked">Revoked</Line truncated
|
||||
<section id="access-sessions" class="card"><p class="eyebrow">ADMINISTRATOR ACCESS</p><h2>Active management sessions</h2><p>Administrators can review current SSO sessions and immediately revoke a session. Identity-provider group membership controls whether that person can sign in again. Bootstrap access has no stored browser session.</p><button id="sessions-load" class="secondary">Refresh active sessions</button><p id="sessions-status" role="status" aria-live="polite">Session inventory requires the administrator role.</p><div class="table-wrap"><table><thead><tr><th>Identity</th><th>Role</th><th>Expires</th><th>Action</th></tr></thead><tbody id="session-rows"></tbody></table></div></section><section id="incidents" class="card"><p class="eyebrow">SECURITY OPERATIONS</p><h2>Incident triage</h2><p>Review security event metadata, assign an investigation and record its outcome. Documents and prompts are not retained here. Keep investigation notes free of secrets and personal data.</p><div class="row filter-row"><label>Status<select id="incident-status-filter"><option value="">All statuses</option><option>open</option><option>investigating</option><option>resolved</option><option>dismissed</option></select></label><label>Severity<select id="incident-severity-filter"><option value="">All severities</option><option>low</option><option>medium</option><option>high</option><option>critical</option></select></label><button id="incidents-refresh" class="secondary">Refresh incidents</button></div><p id="incidents-status" role="status" aria-live="polite"></p><div class="table-wrap"><table><thead><tr><th>Created</th><th>Security event</th><th>Status</th><th>Severity</th><th>Assignee</th><th>Review</th></tr></thead><tbody id="incident-rows"></tbody></table></div><button id="incidents-more" class="secondary" hidden>Load older incidents</button><div id="incident-detail" hidden><div class="section-head"><h3 id="incident-title">Incident</h3><button id="incident-detail-refresh" class="secondary">Discard edits and refresh</button></div><p id="incident-revision" class="muted"></p><pre id="incident-event"></pre><div class="grid"><label>Investigation status<select id="incident-state"><option>open</option><option>investigating</option><option>resolved</option><option>dismissed</option></select></label><label>Severity<select id="incident-severity"><option>low</option><option>medium</option><option>high</option><option>critical</option></select></label><label>Assignee<input id="incident-assignee" maxlength="160" placeholder="Team or analyst identity"></label><label class="wide">Investigation note<textarea id="incident-comment" maxlength="1000" placeholder="Metadata and investigation outcome only; do not paste secrets."></textarea></label></div><button id="incident-save">Save investigation update</button><p id="incident-detail-status" role="status" aria-live="polite"></p><h3>Investigation history</h3><div id="incident-history"></div></div></section><section class="card" id="audit-retention"><p class="eyebrow">AUDIT LIFECYCLE</p><h2>Database event retention</h2><p id="audit-retention-status" role="status">Loading deployment configuration.</p><p class="muted">Retention applies only to exported database events that are not referenced by incidents. Investigation history is retained. External SIEM, file exports and backups have separate retention policies. Deployment configuration controls this setting.</p></section><section id="events" class="card"><p class="eyebrow">SECURITY MONITORING</p><h2>Recent audit events</h2><div class="row"><button id="export-ecs" class="secondary">Export ECS JSON</button><button id="export-cef" class="secondary">Export CEF</button></div><div class="row filter-row"><label>Find an event<input id="event-filter" type="search" placeholder="Event type, device or metadata"></label><label>Decision<select id="event-decision-filter"><option value="all">All events</option><option value="block">Blocks</option><option value="allow">Allows</option></select></label><output id="event-filter-count" aria-live="polite"></output></div><div class="table-wrap"><table><thead><tr><th>Time</th><th>Event</th><th>Device</th><th>Metadata</th></tr></thead><tbody id="event-rows"></tbody></table></div><p class="muted">This view shows the latest 100 events. Prompts and document contents are excluded. Exports contain up to 1,000 events; cursor-based APIs and durable HTTPS delivery support continuous SIEM ingestion.</p></section>
|
||||
<section id="diagnostics-controls" class="card"><p class="eyebrow">OBSERVABILITY</p><h2>Browser OpenTelemetry</h2><p>Publish centrally signed operational or detailed debug collection. Spans report code stages, timing and outcomes. Confidential security evidence needs its separate protected storage; passwords and authentication tokens are excluded.</p><div class="grid"><label><input id="diagnostics-enabled" type="checkbox"> Enable browser diagnostics</label><label>Mode<select id="diagnostics-mode"><option value="operational">Operational</option><option value="debug">Detailed debug</option></select></label><label class="wide">Collector endpoint<input id="diagnostics-endpoint" type="url" placeholder="https://collector.company.example"></label><label>Operational sampling<input id="diagnostics-sample" type="number" min="0" max="1" step="0.01"></label><label>Debug sampling<input id="diagnostics-debug-sample" type="number" min="0" max="1" step="0.01"></label></div><p>HTTPS is required except for a numeric loopback collector. Queue and export limits remain enforced. Endpoint credentials do not belong in policy URLs. Disable and publish to stop new collection.</p></section></div></main><dialog id="change-review" aria-labelledby="change-review-title"><div class="section-head"><h2 id="change-review-title">Review policy changes</h2><button id="review-close" class="secondary">Close</button></div><p>Review the changed policy paths before publication. Existing server validation and version checks remain authoritative.</p><div id="change-review-list"></div><p id="change-review-summary" class="muted"></p></dialog><script src="/admin.js"></script><script src="/group-admin.js"></script><script src="/profile-admin.js"></script><script src="/incident-admin.js"></script><script src="/diagnostics-admin.js"></script></body></html>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/* Profiles add restrictions; reviewed developer grants require central delegation. */
|
||||
let profileState={version:0,profiles:[],categories:[],delegation:{devTools:false},devices:[]},selectedProfile='';
|
||||
const profileFeatureNames=['devTools','downloads','ai','agents'];
|
||||
function profileDraft(){return {id:$('profile-id').value.trim(),name:$('profile-name').value.trim(),subjects:lines('profile-subjects'),deviceIds:groupSelected('profile-devices'),allowedHosts:lines('profile-allowed'),blockedHosts:lines('profile-blocked'),blockedCategoryIds:groupSelected('profile-categories'),features:Object.fromEntries(profileFeatureNames.map(key=>[key,$(key==='devTools'?'profile-devtools':`profile-${key}`).value])),requireDocumentScan:$('profile-scan').checked};}
|
||||
function editProfile(profile){selectedProfile=profile?.id||'';$('profile-editor-title').textContent=profile?`Edit ${profile.name}`:'Create profile';$('profile-id').value=profile?.id||'';$('profile-id').readOnly=!!profile;$('profile-name').value=profile?.name||'';$('profile-subjects').value=(profile?.subjects||[]).join('\n');$('profile-allowed').value=(profile?.allowedHosts||[]).join('\n');$('profile-blocked').value=(profile?.blockedHosts||[]).join('\n');groupChecks('profile-devices',profileState.devices,profile?.deviceIds||[]);groupChecks('profile-categories',profileState.categories,profile?.blockedCategoryIds||[]);for(const key of profileFeatureNames)$(key==='devTools'?'profile-devtools':`profile-${key}`).value=profile?.features?.[key]||'inherit';$('profile-scan').checked=profile?.requireDocumentScan===true;$('profile-preview-summary').textContent='';$('profile-preview-diff').replaceChildren();applyProfilePermissions(currentPrincipal);}
|
||||
function applyProfilePermissions(principal){if(!principal)return;const permissions=new Set(principal.permissions||[]),write=permissions.has('policy.write')||(independentApprovalRequired&&permissions.has('policy.prepare'));for(const field of $('user-profiles').querySelectorAll('input,textarea,select,button'))field.disabled=!write;for(const id of ['profiles-refresh','profile-preview-device','profile-preview'])$(id).disabled=!permissions.has('policy.read');for(const button of $('profiles-rows').querySelectorAll('button'))button.disabled=!permissions.has('policy.read');$('profile-delete').disabled=!write||!selectedProfile;$('profile-save').textContent=independentApprovalRequired?'Prepare profile for independent approval':'Publish profile';$('profile-config-save').textContent=independentApprovalRequired?'Prepare categories and delegation for approval':'Publish categories and delegation';}
|
||||
async function loadUserProfiles(){profileState=await api('/api/admin/user-profiles');$('profiles-rows').replaceChildren();for(const profile of profileState.profiles){const row=document.createElement('tr');cell(row,profile.name);cell(row,`${profile.subjects.length} verified subjects / ${profile.deviceIds.length} devices`);cell(row,profile.features.devTools);const button=document.createElement('button');button.className='secondary';button.textContent='Edit profile';button.onclick=()=>editProfile(profile);cell(row,'').append(button);$('profiles-rows').append(row);}$('profile-preview-device').replaceChildren();$('profile-device-rows').replaceChildren();for(const device of profileState.devices){const row=document.createElement('tr');cell(row,device.name);cell(row,device.verifiedSubject?device.subject:'No verified subject; explicit device assignment only');cell(row,(device.profiles||[]).join(', ')||'Baseline');$('profile-device-rows').append(row);if(!device.revoked){const option=document.createElement('option');option.value=device.id;option.textContent=device.name;$('profile-preview-device').append(option);}}$('profiles-status').textContent=`${profileState.profiles.length} profiles / signed policy v${profileState.version}`;$('profile-delegation').checked=profileState.delegation.devTools===true;$('profile-category-json').value=JSON.stringify(profileState.categories,null,2);editProfile(profileState.profiles.find(profile=>profile.id===selectedProfile));}
|
||||
$('profiles-refresh').onclick=task(loadUserProfiles);$('profile-new').onclick=()=>editProfile();
|
||||
$('profile-use-template').onclick=()=>{const kind=$('profile-template').value;editProfile({id:kind,name:kind==='developer'?'Developers':kind==='employee'?'Employees':'High protection',features:{devTools:kind==='developer'?'allow':'deny',downloads:kind==='high-protection'?'deny':'inherit',ai:'inherit',agents:kind==='high-protection'?'deny':'inherit'},requireDocumentScan:kind==='high-protection'});status('Class loaded into local draft. Assign verified subjects or managed devices and review before publication.');};
|
||||
$('profile-save').onclick=task(async()=>{const profile=profileDraft();if(!profile.id||!profile.name)throw Error('Enter profile identifier and name');const result=await api(`/api/admin/user-profiles/${encodeURIComponent(profile.id)}`,{expectedVersion:profileState.version,profile},'PUT');selectedProfile=profile.id;await load();policyMutationStatus(result,`Profile published as signed policy v${result.version}.`);});
|
||||
$('profile-delete').onclick=task(async()=>{if(!selectedProfile)throw Error('Select a published profile');const result=await api(`/api/admin/user-profiles/${encodeURIComponent(selectedProfile)}`,{expectedVersion:profileState.version},'DELETE');selectedProfile='';await load();policyMutationStatus(result,`Profile removed as signed policy v${result.version}.`);});
|
||||
$('profile-config-save').onclick=task(async()=>{const categories=JSON.parse($('profile-category-json').value),result=await api('/api/admin/user-profile-configuration',{expectedVersion:profileState.version,categories,delegation:{devTools:$('profile-delegation').checked}},'PUT');await load();policyMutationStatus(result,`Categories and delegation published as signed policy v${result.version}.`);});
|
||||
$('profile-preview').onclick=task(async()=>{const profile=profileDraft(),body={deviceId:$('profile-preview-device').value};if(profile.id)body.profile=profile;const result=await api('/api/admin/user-profiles/preview',body);$('profile-preview-summary').textContent=`Effective profiles: ${(result.profiles||[]).join(', ')||'baseline only'} / ${result.diff?.changes?.length||0} policy differences`;$('profile-preview-diff').replaceChildren();for(const change of result.diff?.changes||[]){const row=document.createElement('article');row.className='change-item';const title=document.createElement('strong'),before=document.createElement('pre'),after=document.createElement('pre');title.textContent=change.path;before.textContent=`Baseline: ${JSON.stringify(change.before)}`;after.textContent=`Effective: ${JSON.stringify(change.after)}`;row.append(title,before,after);$('profile-preview-diff').append(row);}if(result.diff?.truncated){const warning=document.createElement('p');warning.textContent='Diff truncated. Review the complete effective policy through the authenticated API.';$('profile-preview-diff').append(warning);}});
|
||||
@@ -0,0 +1,15 @@
|
||||
/* The administration console uses quiet glass surfaces; website content is untouched. */
|
||||
:root{font-family:"Segoe UI",system-ui,-apple-system,sans-serif;color-scheme:light;--bg:#f4f6fa;--surface:#fff;--surface-soft:#f7f9fc;--text:#202936;--muted:#66758a;--border:#dde4ed;--accent:#176cce;--accent-soft:#eaf2ff;--sidebar:#f0f3f9;--shadow:0 3px 20px #1a36580a;color:var(--text);background:var(--bg)}
|
||||
:root[data-theme=dark]{color-scheme:dark;--bg:#10151e;--surface:#1a2230;--surface-soft:#151d29;--text:#e6edf8;--muted:#a0b0c5;--border:#303c50;--accent:#79b6ff;--accent-soft:#253d5b;--sidebar:#151c29;--shadow:0 4px 24px #0002}
|
||||
@media(prefers-color-scheme:dark){:root:not([data-theme=light]){color-scheme:dark;--bg:#10151e;--surface:#1a2230;--surface-soft:#151d29;--text:#e6edf8;--muted:#a0b0c5;--border:#303c50;--accent:#79b6ff;--accent-soft:#253d5b;--sidebar:#151c29;--shadow:0 4px 24px #0002}}
|
||||
*{box-sizing:border-box}body{margin:0}html{scroll-behavior:smooth}aside{position:fixed;inset:0 auto 0 0;width:252px;background:var(--sidebar);border-right:1px solid var(--border);padding:28px 18px;display:flex;flex-direction:column;z-index:20;overflow:auto}.logo{display:flex;gap:12px;align-items:center;padding:0 9px 18px;font-weight:650;font-size:19px}.logo>b{display:grid;place-items:center;width:38px;height:38px;border-radius:12px;background:var(--accent);color:var(--bg);font-size:25px}.logo small{display:block;font-size:10px;font-weight:400;margin-top:4px;color:var(--muted)}nav{display:grid;gap:3px}.nav-group{font-size:10px;font-weight:700;letter-spacing:1.3px;color:var(--muted);margin:22px 12px 7px}nav a{color:var(--text);text-decoration:none;padding:10px 12px;border-radius:9px;font-size:13px}nav a:hover,nav a[aria-current=true]{background:var(--accent-soft);color:var(--accent)}.aside-note{margin-top:auto;padding:26px 10px 0;display:grid;gap:12px;font-size:12px;color:var(--muted)}.aside-note label{font-size:11px}.aside-note select{font-size:12px;padding:8px}.pill{font-size:11px;background:var(--accent-soft);color:var(--accent);padding:6px 10px;border-radius:20px;display:inline-block;width:fit-content}.skip-link{position:fixed;top:-100px;left:260px;z-index:100;padding:12px;background:var(--surface)}.skip-link:focus{top:8px}main{margin-left:252px;padding:36px 42px 64px;max-width:1740px}header,.section-head{display:flex;justify-content:space-between;align-items:center;gap:18px}header{margin-bottom:25px}.header-description{margin:8px 0 0;color:var(--muted);font-size:14px}h1{font-size:30px;letter-spacing:-1px;margin:7px 0;font-weight:650}h2{font-size:21px;letter-spacing:-.5px;margin:8px 0 16px;font-weight:650}h3{font-size:16px;margin-top:28px}p{line-height:1.65;font-size:13px}.eyebrow{font-size:10px;font-weight:750;letter-spacing:1.4px;color:var(--accent);margin:0}.muted,small{color:var(--muted);font-size:12px}.card{background:var(--surface);border:1px solid var(--border);border-radius:18px;padding:28px;margin-bottom:24px;scroll-margin-top:120px;box-shadow:var(--shadow)}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:24px 0;scroll-margin-top:110px}.metrics article{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:22px;box-shadow:var(--shadow)}.metrics span{font-size:12px;color:var(--muted)}.metrics strong{display:block;font-size:30px;letter-spacing:-1px;margin-top:13px;font-weight:650}.overview-grid{display:grid;grid-template-columns:1.2fr 1fr;gap:20px}.overview-grid .card{min-width:0}.posture-item{display:flex;justify-content:space-between;gap:20px;padding:12px 0;border-bottom:1px solid var(--border);font-size:12px}.posture-item span{color:var(--muted)}.posture-item strong{font-weight:600;text-align:right}.workflow{padding-left:20px;font-size:13px;line-height:1.8;color:var(--muted)}.workflow li{padding:5px 0}.publish-bar{position:sticky;top:10px;z-index:10;background:var(--surface);border:1px solid var(--border);box-shadow:0 6px 25px #18345814;border-radius:14px;padding:14px 18px;display:flex;justify-content:space-between;align-items:center;gap:16px}.publish-bar strong{font-size:13px;display:block}.publish-bar span{display:block;font-size:11px;color:var(--muted);margin-top:4px}.publish-bar .row{margin:0}.draft-dirty{color:var(--accent)}.grid{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin:24px 0}.wide{grid-column:1/-1}label{font-size:12px;font-weight:600;display:grid;gap:8px}small{font-weight:400}.row{display:flex;gap:12px;align-items:center;margin:20px 0}.row input{flex:1;min-width:100px}input,textarea,select{width:100%;font:inherit;border:1px solid var(--border);border-radius:9px;padding:11px 12px;background:var(--surface-soft);color:var(--text);transition:border-color .2s}input:hover,textarea:hover,select:hover{border-color:var(--muted)}input[type=color]{height:43px;padding:5px}input[type=checkbox]{accent-color:var(--accent);width:16px;height:16px}textarea{min-height:88px;resize:vertical}button,.button{font:inherit;font-size:12px;font-weight:600;background:var(--accent);color:var(--surface);border:1px solid transparent;border-radius:9px;padding:11px 16px;cursor:pointer;white-space:nowrap;text-decoration:none;display:inline-block;transition:filter .2s,transform .15s}button:hover,.button:hover{filter:brightness(.93)}button:active,.button:active{transform:translateY(1px)}.secondary{background:var(--surface-soft);color:var(--text);border-color:var(--border)}.danger{background:#b82e2812;color:#c44842;border-color:#c4484230}.checks{display:flex;gap:20px;flex-wrap:wrap;margin-bottom:24px}.checks label,label.checks{display:flex;align-items:center;font-weight:400}.checks input{width:auto}.file{margin:22px 0;max-width:480px}.rule{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px;background:var(--surface-soft);border:1px solid var(--border);border-radius:10px;margin:8px 0;font-size:12px}.table-wrap{overflow:autoLine truncated
|
||||
@supports(backdrop-filter:blur(20px)){.publish-bar{background:color-mix(in srgb,var(--surface) 90%,transparent);backdrop-filter:blur(20px) saturate(140%)}}
|
||||
@media(max-width:1100px){aside{width:212px;padding:24px 12px}main{margin-left:212px;padding:28px}.metrics{grid-template-columns:repeat(2,1fr)}.row{flex-wrap:wrap}.overview-grid{grid-template-columns:1fr}.publish-bar{align-items:flex-start}}@media(max-width:760px){aside{position:relative;width:auto;padding:18px;border-right:0;border-bottom:1px solid var(--border)}nav{display:flex;overflow:auto;gap:4px}nav a{white-space:nowrap}.nav-group,.aside-note{display:none}.logo{padding-bottom:12px}main{margin:0;padding:20px}.grid{grid-template-columns:1fr}.wide{grid-column:auto}h1{font-size:26px}.card{padding:20px}.publish-bar{position:relative;top:0;flex-direction:column}.publish-bar .row{width:100%}.metrics{gap:12px}.metrics article{padding:18px}.filter-row label{width:100%}header{align-items:flex-start}.checks{gap:14px}.card{scroll-margin-top:20px}}@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,*::before,*::after{transition:none!important}}
|
||||
|
||||
@media(max-width:760px){.aside-note{display:flex;align-items:center;padding:12px 8px 0;gap:12px}.aside-note select{width:170px}.aside-note .pill{display:none}.aside-note label{white-space:nowrap}}
|
||||
|
||||
.header-actions{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}
|
||||
|
||||
#history-diff-list{max-height:360px;overflow:auto;padding-right:8px}fieldset{border:1px solid var(--border);border-radius:12px;padding:18px;min-width:0}legend{font-size:12px;font-weight:650;color:var(--muted);padding:0 8px}fieldset .checks{margin:4px 0}details{border:1px solid var(--border);border-radius:12px;padding:16px}summary{cursor:pointer;font-size:12px;font-weight:650}details label{margin-top:16px}
|
||||
#history-review{scroll-margin-top:120px}
|
||||
.device-checklist{display:grid;gap:4px;max-height:240px;overflow:auto;margin-top:16px}.device-checklist label{margin:0;padding:8px;border-radius:8px;background:var(--surface-soft)}#group-preview-diff{max-height:320px;overflow:auto}#policy-groups fieldset .checks{gap:10px}
|
||||