63 lines
7.9 KiB
JavaScript
63 lines
7.9 KiB
JavaScript
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});
|
||
|
|
});
|
||
|
|
}
|