Add protected interaction evidence and measured debugging phases
This commit is contained in:
1 parent
ea448de2d4
commit
244f01630b
42 files changed
+640
-87
No files matched your search
+44
-7
@@ -1,11 +1,36 @@
|
||||
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';
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import {mkdtempSync,writeFileSync,readFileSync,rmSync} from 'node:fs';import {execFileSync} from 'node:child_process';import {tmpdir} from 'node:os';import {join} from 'node:path';import {randomBytes,randomUUID,createCipheriv} 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}});
|
||||
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,captureInteractions: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,interaction:{action:'copy',outcome:'completed',sessionId:'00000000-0000-4000-8000-000000000001'}});return {store,api,id,eventId,config};}
|
||||
const evidence=eventId=>({eventId,type:'interaction',payload:{pageUrl:'https://intranet.example/document',action:'copy',sessionId:'00000000-0000-4000-8000-000000000001',outcome:'completed',captureSource:'guarded-clipboard',sensitiveField:false}});
|
||||
|
||||
test('interaction collection is independent and binds completed action, session and capture source',async t=>{
|
||||
const {api,store,id,eventId}=await fixture(t),body=evidence(eventId),p=store.get('policy');
|
||||
p.evidence.captureInteractions=false;store.put('policy',p);assert.equal((await api('/api/device/evidence','POST',body)).status,403);
|
||||
p.evidence.captureInteractions=true;p.evidence.captureDownloads=false;store.put('policy',p);
|
||||
for(const payload of [{...body.payload,sessionId:randomUUID()},{...body.payload,captureSource:'agent-adapter'},{...body.payload,outcome:'authorized'},{...body.payload,content:'any content'},{...body.payload,target:{tag:'BUTTON',label:'any label'}}])assert.equal((await api('/api/device/evidence','POST',{...body,payload})).status,400);
|
||||
assert.equal((await api('/api/device/evidence','POST',body)).status,201);
|
||||
for(const [action,outcome]of [['read','completed'],['click','authorized'],['click','failed']]){
|
||||
const event=randomUUID();store.event(id,'agent.action',{eventId:event,ruleId:'click:completed',agent:{action,outcome,sessionId:body.payload.sessionId}});
|
||||
assert.equal((await api('/api/device/evidence','POST',{eventId:event,type:'interaction',payload:{...body.payload,action:'click',captureSource:'agent-adapter'}})).status,400);
|
||||
}
|
||||
const click=randomUUID();store.event(id,'agent.action',{eventId:click,agent:{action:'click',outcome:'completed',sessionId:body.payload.sessionId}});
|
||||
assert.equal((await api('/api/device/evidence','POST',{eventId:click,type:'interaction',payload:{...body.payload,action:'click',captureSource:'agent-adapter',target:{tag:'BUTTON',role:'button',x:10,y:20}}})).status,201);
|
||||
const blocked=randomUUID();store.event(id,'clipboard.block',{eventId:blocked,interaction:{action:'copy',outcome:'denied',sessionId:body.payload.sessionId}});
|
||||
assert.equal((await api('/api/device/evidence','POST',{...body,eventId:blocked})).status,400);
|
||||
});
|
||||
|
||||
test('ordinary interaction audit facts require matching lifecycle and reject content',async t=>{
|
||||
const {api,store}=await fixture(t),sessionId=evidence(randomUUID()).payload.sessionId;
|
||||
const input={eventId:randomUUID(),kind:'clipboard.copy',policyVersion:1,interaction:{action:'copy',outcome:'completed',sessionId}};
|
||||
assert.equal((await api('/api/device/events','POST',input)).status,200);
|
||||
for(const interaction of [{...input.interaction,action:'paste'},{...input.interaction,outcome:'denied'},{...input.interaction,content:'secret'}])assert.equal((await api('/api/device/events','POST',{...input,eventId:randomUUID(),interaction})).status,400);
|
||||
assert.equal((await api('/api/device/events','POST',{...input,eventId:randomUUID(),kind:'clipboard.block',interaction:{...input.interaction,outcome:'denied'}})).status,200);
|
||||
const events=store.db.prepare("SELECT metadata FROM events WHERE json_extract(metadata,'$.interaction') IS NOT NULL").all();assert.equal(events.some(row=>row.metadata.includes('secret')),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);
|
||||
@@ -22,11 +47,11 @@ test('incident discovery lists only linked metadata without decrypting or releas
|
||||
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 analystRead=await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'analyst');assert.equal(analystRead.status,200);assert.equal(analystRead.body.payload.pageUrl,evidence(eventId).payload.pageUrl);
|
||||
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);
|
||||
assert.equal(JSON.stringify(result.body).includes(evidence(eventId).payload.pageUrl),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);
|
||||
@@ -49,7 +74,7 @@ test('recognizable credentials in free text, target labels, filenames and encode
|
||||
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('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.pageUrl)),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.pageUrl,body.payload.pageUrl);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.pageUrl)),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;});
|
||||
@@ -65,6 +90,18 @@ test('centrally disabled and expired evidence policies reject new capture while
|
||||
});
|
||||
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((await api('/api/device/evidence','POST',{...body,payload:{...body.payload,bytes:17}})).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);
|
||||
});
|
||||
|
||||
// Migration must keep already encrypted, authorized historical records readable.
|
||||
test('historical interaction content remains readable but cannot be submitted as new capture',async t=>{
|
||||
const {api,store,eventId,config}=await fixture(t),created=await api('/api/device/evidence','POST',evidence(eventId));
|
||||
const row=store.db.prepare('SELECT * FROM protected_evidence').get(),legacy={pageUrl:'https://intranet.example/document',action:'copy',content:'Synthetic historical evidence',sensitiveField:false};
|
||||
const key=readFileSync(config.evidenceKeyFile),nonce=randomBytes(12),cipher=createCipheriv('aes-256-gcm',key,nonce);
|
||||
cipher.setAAD(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])));
|
||||
const plain=Buffer.from(JSON.stringify(legacy)),encrypted=Buffer.concat([cipher.update(plain),cipher.final()]);plain.fill(0);key.fill(0);
|
||||
store.db.prepare('UPDATE protected_evidence SET nonce=?,ciphertext=?,tag=? WHERE id=?').run(nonce,encrypted,cipher.getAuthTag(),row.id);
|
||||
const revealed=await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'analyst');assert.equal(revealed.status,200);assert.deepEqual(revealed.body.payload,legacy);
|
||||
assert.equal((await api('/api/device/evidence','POST',{eventId,type:'interaction',payload:legacy})).status,400);
|
||||
});
|
||||
Reference in new issue
Block a user