first commit

This commit is contained in:
Karl committed 2026-10-13 10:34:48 +02:00
commit ff482ebe23
251 files changed
+19126

No files matched your search

+59
View File
@@ -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.
}
+285
View File
@@ -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');
}
+52
View File
@@ -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'})};
}
+91
View File
@@ -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();
};
}
+59
View File
@@ -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'));
});
}
+63
View File
@@ -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});
});
}
+22
View File
@@ -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));});
}
+35
View File
@@ -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);
+12
View File
@@ -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);
}
+24
View File
@@ -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;
}
+59
View File
@@ -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});
});
}
+60
View File
@@ -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});
});
}
+72
View File
@@ -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};
}
+372
View File
@@ -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&&currentPrincipal.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.';
}
+8
View File
@@ -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);
+17
View File
@@ -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'));
+7
View File
@@ -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;}});
+32
View File
@@ -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 &amp; PROTECTION</span><a href="#policy">Browser policy</a><a href="#user-profiles">User classes &amp; website access</a><a href="#policy-groups">Groups &amp; 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 &amp; AI</a><a href="#document-teaching">Document references</a><a href="#diagnostics-controls">Browser diagnostics</a><a href="#security-controls">Security rules &amp; agents</a><a href="#visual-protection">Visual impersonation</a><a href="#clipboard-controls">Clipboard &amp; 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 &amp; scanners</a><a href="#access-sessions">Access &amp; 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&#10;*.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>
+13
View File
@@ -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);}});
+15
View File
@@ -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}
+35
View File
@@ -0,0 +1,35 @@
import { appendFileSync,readFileSync } from 'node:fs';
import { join } from 'node:path';
import { request } from 'node:https';
import { toECS,toCEF } from '../../packages/core/siem.mjs';
import { digest } from './store.mjs';
export function startSIEM(store,directory,env=process.env) {
let stopped=false,running=false,failures=0,nextAttempt=0;
const url=env.SIEM_HTTP_URL?new URL(env.SIEM_HTTP_URL):null;
if(url&&(url.protocol!=='https:'||url.username||url.password))throw Error('SIEM push requires an HTTPS URL without embedded credentials');
const tlsOptions={...(env.SIEM_CA_FILE?{ca:readFileSync(env.SIEM_CA_FILE)}:{}),...(env.SIEM_CERT_FILE?{cert:readFileSync(env.SIEM_CERT_FILE)}:{}),...(env.SIEM_KEY_FILE?{key:readFileSync(env.SIEM_KEY_FILE)}:{})};
const format=env.SIEM_FORMAT||'ecs';if(!['ecs','cef'].includes(format))throw Error('SIEM_FORMAT must be ecs or cef');
const cursorKey=url?`siem.remote.${digest(url.href+':'+format)}`:`siem.file.${format}`;
async function tick(){
if(stopped||running||Date.now()<nextAttempt)return;running=true;
try{
const after=store.get(cursorKey)||0;
const rows=store.db.prepare('SELECT * FROM events WHERE id>? ORDER BY id LIMIT 100').all(after).map(e=>({...e,metadata:JSON.parse(e.metadata)}));
if(!rows.length)return;
const body=rows.map(e=>format==='ecs'?JSON.stringify(toECS(e,store.get('installationId'))):toCEF(e,store.get('installationId'))).join('\n')+'\n';
if(url)await push(url,body,format,env.SIEM_HTTP_TOKEN,tlsOptions);
else appendFileSync(join(directory,`security.${format==='ecs'?'ndjson':'cef'}`),body,{mode:0o600,flush:true});
if(!stopped)store.put(cursorKey,rows.at(-1).id);failures=0;nextAttempt=0;
}catch{failures++;nextAttempt=Date.now()+Math.min(300000,1000*2**Math.min(failures,8));}
finally{running=false;}
}
const timer=setInterval(tick,2000);timer.unref();tick();
return {stop:()=>{stopped=true;clearInterval(timer);},flush:tick,status:()=>({configured:!!url,format,failures,cursor:store.get(cursorKey)||0,pending:store.db.prepare('SELECT count(*) AS n FROM events WHERE id>?').get(store.get(cursorKey)||0).n})};
}
function push(url,body,format,credential,tlsOptions) {
return new Promise((resolve,reject)=>{
const req=request(url,{method:'POST',...tlsOptions,headers:{'content-type':format==='ecs'?'application/x-ndjson':'text/plain','content-length':Buffer.byteLength(body),...(credential?{authorization:`Bearer ${credential}`}:{})}},res=>{
res.resume();res.on('error',reject);res.on('end',()=>res.statusCode>=200&&res.statusCode<300?resolve():reject(Error('SIEM rejected batch')));
});req.setTimeout(10000,()=>req.destroy(Error('SIEM timeout')));req.on('error',reject);req.end(body);
});
}
+61
View File
@@ -0,0 +1,61 @@
import { DatabaseSync } from 'node:sqlite';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { randomBytes, createHash, generateKeyPairSync } from 'node:crypto';
import { defaultPolicy, signPolicy, PolicySchema } from '../../packages/core/policy.mjs';
import { recordSecurityEvent } from './telemetry.mjs';
import { evaluateSigma } from '../../packages/core/security.mjs';
import { signBookmarkSnapshot } from '../../packages/core/bookmarks.mjs';
import {applyUserProfiles} from '../../packages/core/user-profiles.mjs';
import {applyPolicyGroups} from '../../packages/core/policy-groups.mjs';
export const token = () => randomBytes(32).toString('base64url');
export const digest = value => createHash('sha256').update(value).digest('hex');
export function openStore(directory) {
mkdirSync(directory,{recursive:true,mode:0o700});
const db = new DatabaseSync(join(directory,'safetynet.sqlite'));
db.exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS devices(id TEXT PRIMARY KEY,name TEXT,platform TEXT,user TEXT,token_hash TEXT UNIQUE,revoked INTEGER DEFAULT 0,last_seen TEXT,policy_version INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS enrollments(hash TEXT PRIMARY KEY,expires INTEGER,user TEXT);
CREATE TABLE IF NOT EXISTS events(id INTEGER PRIMARY KEY AUTOINCREMENT,time TEXT,device_id TEXT,kind TEXT,metadata TEXT);
CREATE TABLE IF NOT EXISTS sessions(hash TEXT PRIMARY KEY,expires INTEGER,principal TEXT);
CREATE TABLE IF NOT EXISTS management_session_handles(handle TEXT PRIMARY KEY,session_hash TEXT UNIQUE REFERENCES sessions(hash) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS logins(state TEXT PRIMARY KEY,expires INTEGER,verifier TEXT,nonce TEXT,ticket TEXT);
CREATE TABLE IF NOT EXISTS tickets(id TEXT PRIMARY KEY,secret_hash TEXT,expires INTEGER,credential TEXT);
CREATE TABLE IF NOT EXISTS policies(version INTEGER PRIMARY KEY,time TEXT,actor TEXT,policy TEXT);
CREATE TABLE IF NOT EXISTS policy_group_history(version INTEGER PRIMARY KEY REFERENCES policies(version),groups TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS device_events(device_id TEXT,event_id TEXT,PRIMARY KEY(device_id,event_id));
CREATE TABLE IF NOT EXISTS teaching(id TEXT PRIMARY KEY,time TEXT,actor TEXT,label TEXT,status TEXT,reference TEXT,reviewer TEXT,policy_version INTEGER);
CREATE TABLE IF NOT EXISTS bookmarks(device_id TEXT PRIMARY KEY REFERENCES devices(id),revision INTEGER NOT NULL,items TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS alerts(id INTEGER PRIMARY KEY AUTOINCREMENT,event_id INTEGER,rule_id TEXT,level TEXT,time TEXT);
`);
if(!db.prepare('PRAGMA table_info(devices)').all().some(c=>c.name==='document_analysis_enabled'))db.exec('ALTER TABLE devices ADD COLUMN document_analysis_enabled INTEGER');
if(!db.prepare('PRAGMA table_info(devices)').all().some(c=>c.name==='verified_subject'))db.exec('ALTER TABLE devices ADD COLUMN verified_subject TEXT');
db.exec('CREATE TABLE IF NOT EXISTS user_profile_history(version INTEGER PRIMARY KEY REFERENCES policies(version),configuration TEXT NOT NULL)');
const getRaw=key=>{const r=db.prepare('SELECT value FROM settings WHERE key=?').get(key);return r?JSON.parse(r.value):null;};
const get = key => {const value=getRaw(key);return key==='policy'&&value?PolicySchema.parse(value):value;};
const put = (key,value) => db.prepare('INSERT INTO settings VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(key,JSON.stringify(value));
if(!get('keys')) {const k=generateKeyPairSync('ed25519'); put('keys',{privateKey:k.privateKey.export({type:'pkcs8',format:'pem'}),publicKey:k.publicKey.export({type:'spki',format:'pem'})});}
if(!getRaw('policy')) {const p=defaultPolicy();put('policy',p);db.prepare('INSERT INTO policies VALUES(?,?,?,?)').run(p.version,new Date().toISOString(),'bootstrap',JSON.stringify(p));}
db.prepare('INSERT OR IGNORE INTO policy_group_history(version,groups) VALUES(?,?)').run(getRaw('policy').version,JSON.stringify(getRaw('policyGroups')||[]));
db.prepare('INSERT OR IGNORE INTO user_profile_history(version,configuration) VALUES(?,?)').run(getRaw('policy').version,JSON.stringify(getRaw('userProfiles')||{profiles:[],categories:[]}));
if(!get('installationId'))put('installationId',token());
function event(device,kind,metadata) {
const time=new Date().toISOString(),records=[];db.exec('SAVEPOINT security_event');
try {
const insert=(eventKind,eventMetadata)=>{const result=db.prepare('INSERT INTO events(time,device_id,kind,metadata) VALUES(?,?,?,?)').run(time,device,eventKind,JSON.stringify(eventMetadata));records.push({kind:eventKind,metadata:eventMetadata,id:String(result.lastInsertRowid)});return Number(result.lastInsertRowid);};
const sourceEventId=insert(kind,metadata);
for(const alert of evaluateSigma({kind,metadata},getRaw('policy')?.security?.sigma?.rules||[])) {
db.prepare('INSERT INTO alerts(event_id,rule_id,level,time) VALUES(?,?,?,?)').run(sourceEventId,alert.ruleId,alert.level,time);
insert('sigma.alert',{ruleId:alert.ruleId,level:alert.level,sourceEventId});
}
db.exec('RELEASE security_event');
}catch(error){db.exec('ROLLBACK TO security_event');db.exec('RELEASE security_event');throw error;}
for(const record of records)recordSecurityEvent(record.kind,record.metadata,record.id);
}
function personalBookmarks(deviceId){const row=db.prepare('SELECT revision,items FROM bookmarks WHERE device_id=?').get(deviceId);return row?{revision:row.revision,items:JSON.parse(row.items)}:{revision:0,items:[]};}
function effectivePolicy(deviceId){let p=get('policy');if(deviceId){const d=db.prepare('SELECT document_analysis_enabled FROM devices WHERE id=?').get(deviceId);if(d?.document_analysis_enabled!==null&&d?.document_analysis_enabled!==undefined)p.inspection.documentAnalysisEnabled=!!d.document_analysis_enabled;p=PolicySchema.parse(applyPolicyGroups(applyUserProfiles(p,getRaw('userProfiles')||{},db.prepare('SELECT * FROM devices WHERE id=?').get(deviceId)||{id:deviceId}),getRaw('policyGroups')||[],deviceId));}return p;}
return {db,get,getRaw,put,event,effectivePolicy,personalBookmarks,envelope:deviceId=>{const p=effectivePolicy(deviceId),keys=get('keys'),envelope=signPolicy(p,keys.privateKey);if(deviceId)envelope.bookmarks=signBookmarkSnapshot({deviceId,policyVersion:p.version,...personalBookmarks(deviceId),expiresAt:p.expiresAt},keys.privateKey);return envelope;},publicKey:()=>get('keys').publicKey};
}
+23
View File
@@ -0,0 +1,23 @@
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { trace, metrics } from '@opentelemetry/api';
import { logs } from '@opentelemetry/api-logs';
import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs';
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
export function startTelemetry() {
if(!process.env.OTEL_EXPORTER_OTLP_ENDPOINT) return null;
const sdk = new NodeSDK({serviceName:'safetynet-control-plane',traceExporter:new OTLPTraceExporter(),
metricReaders:[new PeriodicExportingMetricReader({exporter:new OTLPMetricExporter(),exportIntervalMillis:10000})],
logRecordProcessors:[new BatchLogRecordProcessor({exporter:new OTLPLogExporter()})]});
sdk.start(); return sdk;
}
export const tracer=trace.getTracer('safetynet','0.1.0');
export function recordSecurityEvent(kind,metadata,eventId){
// Only bounded, allowlisted fields. Never emit body text, rules, vectors or prompts.
const decision=metadata.decision??(kind.includes('block')?'block':'other');
metrics.getMeter('safetynet').createCounter('safetynet.security.events').add(1,{'event.action':kind,'dlp.decision':decision});
logs.getLogger('safetynet.security').emit({severityNumber:kind.includes('block')?13:9,severityText:kind.includes('block')?'WARN':'INFO',body:kind,
attributes:{'event.id':eventId,'event.action':kind,'dlp.decision':decision,...(metadata.ruleId?{'rule.id':metadata.ruleId}:{}),...(metadata.policyVersion?{'safetynet.policy.version':metadata.policyVersion}:{})}});
}
+28
View File
@@ -0,0 +1,28 @@
import {z} from 'zod';
import {UserProfileSchema,UserProfileConfigurationSchema,DomainCategorySchema,profileApplies,applyUserProfiles} from '../../packages/core/user-profiles.mjs';
import {PolicySchema} from '../../packages/core/policy.mjs';
import {applyPolicyGroups} from '../../packages/core/policy-groups.mjs';
import {policyDiff} from './policy-history.mjs';
export function profilesForOperation(store,operation={}){
const c=UserProfileConfigurationSchema.parse(store.getRaw('userProfiles')||{});
if(operation.type==='profile-restore'||operation.configuration)return UserProfileConfigurationSchema.parse(operation.configuration);
if(operation.type==='profile-upsert'){const p=UserProfileSchema.parse(operation.profile);c.profiles=c.profiles.filter(x=>x.id!==p.id);c.profiles.push(p);}
if(operation.type==='profile-delete')c.profiles=c.profiles.filter(x=>x.id!==operation.profileId);
if(operation.type==='profile-configuration')c.categories=z.array(DomainCategorySchema).max(100).parse(operation.categories);
return c;
}
export function validateProfiles(store,policy,operation={},groups=store.getRaw('policyGroups')||[]){
const c=profilesForOperation(store,operation),fail=m=>{throw Object.assign(Error(m),{status:409});};
if(new Set(c.profiles.map(p=>p.id)).size!==c.profiles.length||new Set(c.categories.map(p=>p.id)).size!==c.categories.length)fail('Duplicate profile/category ID');
for(const p of c.profiles){for(const id of p.blockedCategoryIds)if(!c.categories.some(x=>x.id===id))fail('Unknown domain category');for(const id of p.deviceIds)if(!store.db.prepare('SELECT id FROM devices WHERE id=? AND revoked=0').get(id))fail('Unknown active profile device');}
for(const subject of new Set(c.profiles.flatMap(p=>p.subjects)))PolicySchema.parse(applyUserProfiles(policy,c,{id:'future-enrollment',verified_subject:subject}));
for(const d of store.db.prepare('SELECT * FROM devices WHERE revoked=0').all())PolicySchema.parse(applyPolicyGroups(applyUserProfiles(policy,c,d),groups,d.id));
return c;
}
export function installUserProfiles(app,store,publisher){
app.get('/api/admin/user-profiles',(_req,res)=>{const c=profilesForOperation(store),p=store.get('policy');res.json({version:p.version,...c,delegation:{devTools:p.userAccess.delegatableFeatures.devTools},devices:store.db.prepare('SELECT id,name,user,verified_subject,revoked FROM devices').all().map(d=>({id:d.id,name:d.name,revoked:!!d.revoked,subject:d.verified_subject||null,verifiedSubject:!!d.verified_subject,profiles:c.profiles.filter(p=>profileApplies(p,d)).map(p=>p.id)}))});});
app.post('/api/admin/user-profiles/preview',(req,res)=>{const b=z.object({deviceId:z.string().min(1).max(80),profile:UserProfileSchema.optional()}).strict().parse(req.body),d=store.db.prepare('SELECT * FROM devices WHERE id=? AND revoked=0').get(b.deviceId);if(!d)return res.status(404).json({error:'Active device not found'});const p=store.get('policy'),c=profilesForOperation(store,b.profile?{type:'profile-upsert',profile:b.profile}:{}),policy=PolicySchema.parse(applyPolicyGroups(applyUserProfiles(p,c,d),store.getRaw('policyGroups')||[],d.id));res.json({policy,profiles:policy.assignment.profiles||[],diff:policyDiff(p,policy)});});
app.put('/api/admin/user-profile-configuration',(req,res)=>{const b=z.object({expectedVersion:z.number().int().positive(),categories:z.array(DomainCategorySchema).max(100),delegation:z.object({devTools:z.boolean()}).strict()}).strict().parse(req.body),p=store.get('policy');if(p.version!==b.expectedVersion)return res.status(409).json({error:'Policy version conflict'});p.version++;p.userAccess.delegatableFeatures.devTools=b.delegation.devTools;publisher.submit(req,res,p,'policy.profile-configuration',{type:'profile-configuration',categories:b.categories});});
app.put('/api/admin/user-profiles/:id',(req,res)=>{const b=z.object({expectedVersion:z.number().int().positive(),profile:UserProfileSchema}).strict().parse(req.body),p=store.get('policy');if(b.profile.id!==req.params.id)return res.status(400).json({error:'Profile ID mismatch'});if(p.version!==b.expectedVersion)return res.status(409).json({error:'Policy version conflict'});p.version++;publisher.submit(req,res,p,'policy.profile-published',{type:'profile-upsert',profile:b.profile});});
app.delete('/api/admin/user-profiles/: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.profile-deleted',{type:'profile-delete',profileId:req.params.id});});
}