286 lines
27 KiB
JavaScript
286 lines
27 KiB
JavaScript
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');
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|