71 lines
17 KiB
JavaScript
71 lines
17 KiB
JavaScript
import test from 'node:test';import assert from 'node:assert/strict';import {mkdtempSync,writeFileSync,rmSync} from 'node:fs';import {execFileSync} from 'node:child_process';import {tmpdir} from 'node:os';import {join} from 'node:path';import {randomBytes,randomUUID} from 'node:crypto';import {openStore,digest} from '../apps/server/store.mjs';import {createApp} from '../apps/server/app.mjs';
|
|
function protectFixtureKey(file){
|
|
if(process.platform!=='win32')return;
|
|
const script="$ErrorActionPreference='Stop';$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User;$acl=New-Object System.Security.AccessControl.FileSecurity;$acl.SetOwner($sid);$acl.SetAccessRuleProtection($true,$false);$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl','Allow')));Set-Acl -LiteralPath $env:TEST_EVIDENCE_KEY -AclObject $acl";
|
|
execFileSync(join(process.env.SystemRoot,'System32/WindowsPowerShell/v1.0/powershell.exe'),['-NoProfile','-NonInteractive','-Command',script],{windowsHide:true,env:{SystemRoot:process.env.SystemRoot,TEST_EVIDENCE_KEY:file},stdio:'pipe'});
|
|
}
|
|
async function fixture(t,{configured=true}={}){const dir=mkdtempSync(join(tmpdir(),'safetynet-evidence-')),keyFile=join(dir,'evidence.key');writeFileSync(keyFile,randomBytes(32),{mode:0o600});protectFixtureKey(keyFile);const store=openStore(dir);const evidencePolicy=store.get('policy');evidencePolicy.evidence={enabled:true,captureDownloads:true};store.put('policy',evidencePolicy);const deviceToken='synthetic-device-evidence-credential-1234567890',id=randomUUID();store.db.prepare('INSERT INTO devices(id,token_hash,revoked) VALUES(?,?,0)').run(id,digest(deviceToken));const config={publicUrl:'http://127.0.0.1:4310',...(configured?{evidenceKeyFile:keyFile}:{})},server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));t.after(async()=>{await new Promise(r=>server.close(r));store.db.close();rmSync(dir,{recursive:true,force:true});});const api=async(path,method='GET',body,role='device')=>{const credential=`synthetic-${role}-evidence-credential-1234567890`;if(role!=='device')store.db.prepare('INSERT OR REPLACE INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+60000,JSON.stringify({sub:role,role}));const r=await fetch(`http://127.0.0.1:${server.address().port}${path}`,{method,headers:{...(role==='device'?{authorization:`Bearer ${deviceToken}`}:{cookie:`sn_session=${credential}`}),origin:config.publicUrl,'content-type':'application/json'},body:body?JSON.stringify(body):undefined});return {status:r.status,body:await r.json()};};const eventId=randomUUID();store.event(id,'clipboard.copy',{eventId,policyVersion:1});return {store,api,id,eventId,config};}
|
|
const evidence=eventId=>({eventId,type:'interaction',payload:{pageUrl:'https://intranet.example/document',action:'copy',content:'Synthetic confidential engineering detail',sensitiveField:false}});
|
|
test('protected evidence cannot contradict source actions, decisions, hosts or file facts',async t=>{
|
|
const {api,store,id,eventId}=await fixture(t);
|
|
assert.equal((await api('/api/device/evidence','POST',{...evidence(eventId),payload:{...evidence(eventId).payload,action:'paste'}})).status,400);
|
|
assert.equal((await api('/api/device/evidence','POST',{...evidence(eventId),payload:{...evidence(eventId).payload,contentSha256:'0'.repeat(64)}})).status,400);
|
|
const download=randomUUID();store.event(id,'download.released',{eventId:download,host:'downloads.example',file:{sha256:'a'.repeat(64),bytes:10,detectedType:'text'}});
|
|
const payload={sourceUrl:'https://downloads.example/file.txt',name:'file.txt',sha256:'a'.repeat(64),bytes:10,mime:'text/plain',decision:'allow'};
|
|
for(const change of [{sha256:'b'.repeat(64)},{bytes:11},{decision:'block'},{sourceUrl:'https://other.example/file.txt'}])assert.equal((await api('/api/device/evidence','POST',{eventId:download,type:'download',payload:{...payload,...change}})).status,400);
|
|
assert.equal((await api('/api/device/evidence','POST',{eventId:download,type:'download',payload})).status,201);
|
|
});
|
|
test('incident discovery lists only linked metadata without decrypting or releasing content',async t=>{
|
|
const {api,store,eventId}=await fixture(t),created=await api('/api/device/evidence','POST',evidence(eventId));
|
|
const source=store.db.prepare('SELECT source_event_id FROM protected_evidence').get().source_event_id,incident=await api('/api/admin/incidents','POST',{eventId:source},'admin');
|
|
const path=`/api/admin/incidents/${incident.body.id}/evidence`;
|
|
for(const role of ['operator','auditor'])assert.equal((await api(path,'GET',undefined,role)).status,403);
|
|
const result=await api(path,'GET',undefined,'admin');assert.equal(result.status,200);assert.equal(result.body.incidentId,incident.body.id);
|
|
assert.equal((await api(path,'GET',undefined,'analyst')).status,200);
|
|
const analystRead=await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'analyst');assert.equal(analystRead.status,200);assert.equal(analystRead.body.payload.content,evidence(eventId).payload.content);
|
|
const access=store.db.prepare("SELECT metadata FROM events WHERE kind='evidence.accessed'").get();assert.equal(JSON.parse(access.metadata).actor,'analyst');
|
|
assert.deepEqual(Object.keys(result.body.evidence[0]).sort(),['id','sourceEventId','eventId','type','createdAt','expiresAt','retentionHold','availability'].sort());
|
|
assert.equal(result.body.evidence[0].id,created.body.id);assert.equal(result.body.evidence[0].eventId,eventId);assert.equal(result.body.evidence[0].availability,'available');assert.equal(result.body.evidence[0].retentionHold,true);
|
|
assert.equal(JSON.stringify(result.body).includes(evidence(eventId).payload.content),false);assert.equal(store.db.prepare("SELECT COUNT(*) n FROM events WHERE kind='evidence.accessed'").get().n,1);
|
|
// A damaged ciphertext does not prevent safe discovery; reveal still authenticates.
|
|
store.db.prepare('UPDATE protected_evidence SET ciphertext=?').run(Buffer.from('synthetic corruption'));
|
|
assert.equal((await api(path,'GET',undefined,'admin')).status,200);assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'admin')).status,500);
|
|
assert.equal((await api('/api/admin/incidents/not-a-uuid/evidence','GET',undefined,'admin')).status,400);assert.equal((await api(`/api/admin/incidents/${randomUUID()}/evidence`,'GET',undefined,'admin')).status,404);
|
|
});
|
|
test('incident discovery reports expiry, active hold and unavailable keys without exposing key identifiers',async t=>{
|
|
const now=Date.now();t.mock.timers.enable({apis:['Date'],now});const {api,store,eventId}=await fixture(t);await api('/api/device/evidence','POST',evidence(eventId));
|
|
const source=store.db.prepare('SELECT source_event_id FROM protected_evidence').get().source_event_id,incident=await api('/api/admin/incidents','POST',{eventId:source},'admin'),path=`/api/admin/incidents/${incident.body.id}/evidence`;
|
|
t.mock.timers.setTime(now+31*86400000);assert.equal((await api(path,'GET',undefined,'admin')).body.evidence[0].availability,'available');
|
|
await api(`/api/admin/incidents/${incident.body.id}`,'PATCH',{expectedRevision:1,status:'resolved'},'admin');assert.equal((await api(path,'GET',undefined,'admin')).body.evidence[0].availability,'expired');
|
|
t.mock.timers.setTime(now);store.db.prepare('UPDATE protected_evidence SET key_id=?').run('synthetic-other-key-id');const result=await api(path,'GET',undefined,'admin');assert.equal(result.body.evidence[0].availability,'key-unavailable');assert.equal(JSON.stringify(result.body).includes('synthetic-other-key-id'),false);
|
|
});
|
|
test('recognizable credentials in free text, target labels, filenames and encoded paths never persist',async t=>{
|
|
const {api,store,eventId}=await fixture(t);
|
|
const values=['-----BEGIN PRIVATE KEY-----\nsynthetic','Bearer syntheticAuthorizationCredential123','Basic c3ludGhldGljOnBhc3N3b3Jk','password="synthetic-secret"','passwd=synthetic-secret','api_key: synthetic-secret','access-token=synthetic-secret','refresh_token=synthetic-secret','client_secret=synthetic-secret','sk-syntheticCredential1234567890','ghp_syntheticCredential1234567890','AKIAABCDEFGHIJKLMNOP','eyJsyntheticHeader.syntheticPayload.syntheticSignature'];
|
|
for(const content of values){assert.equal((await api('/api/device/evidence','POST',{...evidence(eventId),payload:{...evidence(eventId).payload,content}})).status,400,content.split(/[=: ]/)[0]);assert.equal((await api('/api/device/evidence','POST',{...evidence(eventId),payload:{...evidence(eventId).payload,content:undefined,target:{tag:'button',label:content}}})).status,400);}
|
|
assert.equal((await api('/api/device/evidence','POST',{...evidence(eventId),payload:{...evidence(eventId).payload,pageUrl:'https://example.test/password%3Dsynthetic-secret'}})).status,400);
|
|
const downloadEvent=randomUUID();store.event(store.db.prepare('SELECT id FROM devices LIMIT 1').get().id,'download.scan',{eventId:downloadEvent});
|
|
assert.equal((await api('/api/device/evidence','POST',{eventId:downloadEvent,type:'download',payload:{sourceUrl:'https://example.test/download',name:'api_key=synthetic-secret.txt',sha256:'a'.repeat(64),bytes:10,mime:'text/plain',decision:'block'}})).status,400);
|
|
assert.equal(store.db.prepare('SELECT COUNT(*) n FROM protected_evidence').get().n,0);
|
|
assert.equal((await api('/api/device/evidence','POST',evidence(eventId))).status,201);
|
|
});
|
|
test('encrypted evidence only decrypts for explicit admin permission and audited purpose',async t=>{const {api,store,eventId}=await fixture(t),body=evidence(eventId),created=await api('/api/device/evidence','POST',body);assert.equal(created.status,201);const row=store.db.prepare('SELECT * FROM protected_evidence').get();assert.equal(Buffer.from(row.ciphertext).includes(Buffer.from(body.payload.content)),false);for(const role of ['operator','auditor'])assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,role)).status,403);assert.equal((await api(`/api/admin/evidence/${created.body.id}`,'GET',undefined,'admin')).status,400);const got=await api(`/api/admin/evidence/${created.body.id}?reason=incident-response`,'GET',undefined,'admin');assert.equal(got.status,200);assert.equal(got.body.payload.content,body.payload.content);const audit=store.db.prepare("SELECT metadata FROM events WHERE kind='evidence.accessed'").get();assert.equal(JSON.parse(audit.metadata).reason,'incident-response');assert.equal(store.db.prepare('SELECT metadata FROM events').all().some(r=>r.metadata.includes(body.payload.content)),false);assert.equal((await api('/api/device/evidence','POST',body)).status,200);});
|
|
test('unknown credentials, password fields, token fields, unowned events and credential URLs reject',async t=>{const {api,eventId}=await fixture(t);for(const extra of [{password:'synthetic'},{authorization:'Bearer synthetic'},{sensitiveField:true},{pageUrl:'https://user:[email protected]/'},{pageUrl:'https://example.test/?token=secret'}])assert.equal((await api('/api/device/evidence','POST',{...evidence(eventId),payload:{...evidence(eventId).payload,...extra}})).status,400);assert.equal((await api('/api/device/evidence','POST',evidence(randomUUID()))).status,404);assert.equal((await api('/api/device/evidence','POST',{...evidence(eventId),type:'download'})).status,400);});
|
|
test('missing key fails closed and ciphertext tampering fails authentication',async t=>{const disabled=await fixture(t,{configured:false});assert.equal((await disabled.api('/api/device/evidence','POST',evidence(disabled.eventId))).status,503);const {api,store,eventId}=await fixture(t),created=await api('/api/device/evidence','POST',evidence(eventId));store.db.prepare('UPDATE protected_evidence SET expires_at=?').run('2099-01-01T00:00:00.000Z');assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'admin')).status,500);});
|
|
test('audit failure rolls back evidence and prevents plaintext response',async t=>{const {api,store,eventId}=await fixture(t),emit=store.event;store.event=()=>{throw Error('synthetic audit failure');};assert.equal((await api('/api/device/evidence','POST',evidence(eventId))).status,500);assert.equal(store.db.prepare('SELECT COUNT(*) n FROM protected_evidence').get().n,0);store.event=emit;const created=await api('/api/device/evidence','POST',evidence(eventId));store.event=()=>{throw Error('synthetic audit failure');};const got=await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'admin');assert.equal(got.status,500);assert.equal(got.body.payload,undefined);store.event=emit;});
|
|
test('retention preserves encrypted evidence source events',async t=>{const {api,store,eventId}=await fixture(t);await api('/api/device/evidence','POST',evidence(eventId));const {pruneAuditEvents}=await import('../apps/server/audit-retention.mjs');pruneAuditEvents(store,{now:Date.now()+60*86400000,days:1,exportedThrough:1000,dryRun:false});assert.ok(store.db.prepare("SELECT id FROM events WHERE kind='clipboard.copy'").get());});
|
|
test('download facts roundtrip while a different enrolled device cannot attach evidence',async t=>{const {api,store,id}=await fixture(t),eventId=randomUUID();store.event(id,'download.released',{eventId});const payload={sourceUrl:'https://downloads.example/package.exe',name:'package.exe',sha256:'a'.repeat(64),bytes:1024,mime:'application/octet-stream',decision:'allow'};const result=await api('/api/device/evidence','POST',{eventId,type:'download',payload});assert.equal(result.status,201);const detail=await api(`/api/admin/evidence/${result.body.id}?reason=compliance`,'GET',undefined,'admin');assert.deepEqual(detail.body.payload,payload);const other=randomUUID();store.db.prepare('INSERT INTO devices(id,revoked) VALUES(?,0)').run(other);const otherEvent=randomUUID();store.event(other,'clipboard.copy',{eventId:otherEvent});assert.equal((await api('/api/device/evidence','POST',evidence(otherEvent))).status,404);});
|
|
test('expired access is denied; incident legal hold does not bypass ciphertext integrity',async t=>{const {api,store,eventId}=await fixture(t),created=await api('/api/device/evidence','POST',evidence(eventId));store.db.prepare('UPDATE protected_evidence SET expires_at=?').run('2000-01-01T00:00:00.000Z');assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'admin')).status,410);const source=store.db.prepare('SELECT source_event_id FROM protected_evidence').get().source_event_id,incident=await api('/api/admin/incidents','POST',{eventId:source},'admin');assert.equal(incident.status,201);assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=incident-response`,'GET',undefined,'admin')).status,500);});
|
|
test('genuine expiry preserves readability only while the linked incident is active',async t=>{const now=Date.now();t.mock.timers.enable({apis:['Date'],now});const {api,store,eventId}=await fixture(t),created=await api('/api/device/evidence','POST',evidence(eventId));t.mock.timers.setTime(now+31*86400000);assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'admin')).status,410);const source=store.db.prepare('SELECT source_event_id FROM protected_evidence').get().source_event_id,incident=await api('/api/admin/incidents','POST',{eventId:source},'admin');assert.equal(incident.status,201);assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=incident-response`,'GET',undefined,'admin')).status,200);assert.equal((await api(`/api/admin/incidents/${incident.body.id}`,'PATCH',{expectedRevision:1,status:'resolved'},'admin')).status,200);assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'admin')).status,410);});
|
|
test('centrally disabled and expired evidence policies reject new capture while retaining prior access',async t=>{
|
|
const {api,store,eventId}=await fixture(t),body=evidence(eventId),created=await api('/api/device/evidence','POST',body);assert.equal(created.status,201);
|
|
const p=store.get('policy');p.evidence.enabled=false;store.put('policy',p);assert.equal((await api('/api/device/evidence','POST',body)).status,403);
|
|
assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'analyst')).status,200);
|
|
p.evidence.enabled=true;p.expiresAt=new Date(Date.now()-1000).toISOString();store.put('policy',p);assert.equal((await api('/api/device/evidence','POST',body)).status,403);
|
|
});
|
|
test('exact evidence replay is idempotent but a changed payload remains a conflict',async t=>{
|
|
const {api,store,eventId}=await fixture(t),body=evidence(eventId),created=await api('/api/device/evidence','POST',body),replayed=await api('/api/device/evidence','POST',body);assert.equal(replayed.status,200);assert.equal(replayed.body.id,created.body.id);assert.equal(replayed.body.replayed,true);
|
|
assert.equal((await api('/api/device/evidence','POST',{...body,payload:{...body.payload,content:'Different synthetic content'}})).status,409);
|
|
assert.equal(store.db.prepare('SELECT count(*) n FROM protected_evidence').get().n,1);assert.equal(store.db.prepare("SELECT count(*) n FROM events WHERE kind='evidence.recorded'").get().n,1);
|
|
});
|