Implement managed browser security, individual agents and observability
This commit is contained in:
1 parent
ff482ebe23
commit
ea448de2d4
172 files changed
+5404
-347
No files matched your search
+30
-31
@@ -1,8 +1,13 @@
|
||||
import express from 'express';
|
||||
import {installRequestTelemetry,traceOperation} from './telemetry.mjs';
|
||||
import transferFacts from '../../packages/core/transfer-facts.cjs';
|
||||
import requestTransferFacts from '../../packages/core/request-transfer-facts.cjs';
|
||||
import agentAudit from '../../packages/core/agent-audit.cjs';
|
||||
import {installEvidence} from './evidence.mjs';
|
||||
import {installIncidents} from './incidents.mjs';
|
||||
import { installBookmarkRoutes,globalBookmarksValid } from './bookmarks.mjs';
|
||||
import {installLibraryRoutes} from './library.mjs';
|
||||
import {runApprovedAI} from './ai-gateway.mjs';
|
||||
import { z } from 'zod';
|
||||
import { randomUUID,timingSafeEqual } from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -16,6 +21,8 @@ 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 {installSIEMRoutes} from './siem.mjs';
|
||||
import {createCompanyNewsReader} from './company-news.mjs';
|
||||
import { inspector } from './inspection.mjs';
|
||||
import { SecuritySchema, VisualBrandSchema, VisualOriginSchema } from '../../packages/core/security.mjs';
|
||||
import { defaultSemantic } from '../../packages/core/semantic.mjs';
|
||||
@@ -27,11 +34,12 @@ 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 app=express();app.disable('x-powered-by');installRequestTelemetry(app);
|
||||
const readCompanyNews=createCompanyNewsReader({allowedHosts:config.companyNewsAllowedHosts||[]});
|
||||
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'"});
|
||||
'Content-Security-Policy':"default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; 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'});
|
||||
@@ -61,6 +69,7 @@ export function createApp(store,config) {
|
||||
req.device=d;next();
|
||||
};
|
||||
installBookmarkRoutes(app,store,device,admin);
|
||||
installLibraryRoutes(app,store,config,device);
|
||||
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}));
|
||||
@@ -98,41 +107,26 @@ export function createApp(store,config) {
|
||||
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);
|
||||
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','library.ai.proposed','library.ai.applied','library.ai.reconcile','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(),request:requestTransferFacts.RequestFactsSchema.optional(),agent:agentAudit.AgentAuditSchema.optional()}).strict().parse(req.body);
|
||||
if(b.agent&&!agentAudit.agentFactsForEvent(b.kind,b.agent))return res.status(400).json({error:'Agent facts require a matching agent lifecycle event'});
|
||||
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.request&&!b.kind.startsWith('upload.'))return res.status(400).json({error:'Request facts require an upload event'});
|
||||
// Older clients reported body hashes as file facts. Never persist those hashes.
|
||||
if(b.kind.startsWith('upload.')&&b.file){b.request=requestTransferFacts.requestTransferFacts({bytes:b.file.bytes,detectedType:b.file.detectedType});delete b.file;}
|
||||
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.get('/api/device/start-page/news',device,async(req,res)=>{try{const p=store.effectivePolicy(req.device.id);if(Date.parse(p.expiresAt)<=Date.now())return res.status(403).json({error:'Policy expired'});const news=await readCompanyNews(p);res.json({policyVersion:p.version,news});}catch{res.status(502).json({error:'Company news unavailable'});}});
|
||||
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();}
|
||||
});
|
||||
try{const b=z.object({prompt:z.string().min(1).max(100000)}).strict().parse(req.body),p=store.effectivePolicy(req.device.id);
|
||||
const answer=await runApprovedAI({store,config,deviceId:req.device.id,policy:p,prompt:b.prompt});res.json({answer});
|
||||
}catch(e){if([403,409,502].includes(e.status))return res.status(e.status).json({error:e.message,...(e.ruleId?{ruleId:e.ruleId}:{})});next(e);}
|
||||
});
|
||||
app.use('/api/admin',admin);
|
||||
installSIEMRoutes(app,store,config);
|
||||
installIncidents(app,store);
|
||||
app.get('/api/admin/session',(req,res)=>res.json({principal:req.principal}));
|
||||
app.get('/api/admin/sessions',(req,res)=>{
|
||||
@@ -193,7 +187,7 @@ export function createApp(store,config) {
|
||||
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 reference=await inspector(config,'/reference-document',{data:b.data,filename:b.filename,policy:store.get('policy')});
|
||||
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);}
|
||||
});
|
||||
@@ -211,9 +205,14 @@ export function createApp(store,config) {
|
||||
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/audit/integrity',async(_req,res)=>res.json(await traceOperation('audit.verify',{},()=>store.auditIntegrity.verify())));
|
||||
app.get('/api/admin/audit/entries',(req,res)=>{
|
||||
const q=z.object({after:z.coerce.number().int().nonnegative().default(0),limit:z.coerce.number().int().min(1).max(1000).default(100)}).strict().parse(req.query);
|
||||
res.json(store.auditIntegrity.entries(q));
|
||||
});
|
||||
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)}));
|
||||
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),audit:store.auditIntegrity.linkage(e.id)}));
|
||||
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':''));
|
||||
});
|
||||
@@ -248,7 +247,7 @@ export function createApp(store,config) {
|
||||
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')});
|
||||
const result=await inspector(config,'/embed',{data:Buffer.from(b.description).toString('base64'),policy:store.get('policy')});
|
||||
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);}
|
||||
@@ -257,7 +256,7 @@ export function createApp(store,config) {
|
||||
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});
|
||||
const result=await inspector(config,'/embed',{data:b.data,policy:store.get('policy')});
|
||||
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);}
|
||||
|
||||
Reference in new issue
Block a user