Files
safetynet/scripts/interaction-evidence-smoke.mjs

63 lines
10 KiB
JavaScript

import { createServer } from 'node:http';
import { createRequire } from 'node:module';
import { mkdtempSync,rmSync,mkdirSync,writeFileSync,readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join,resolve } from 'node:path';
import { spawn,execFileSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { createApp } from '../apps/server/app.mjs';
import {startTelemetry} from '../apps/server/telemetry.mjs';
import { openStore,digest } from '../apps/server/store.mjs';
const require=createRequire(import.meta.url),electron=require('electron');
if(process.env.SAFETYNET_TEST_OTLP_ENDPOINT)process.env.OTEL_EXPORTER_OTLP_ENDPOINT=process.env.SAFETYNET_TEST_OTLP_ENDPOINT;
const smokeTelemetry=startTelemetry();
const temporary=mkdtempSync(join(tmpdir(),'safetynet-smoke-'));mkdirSync('artifacts',{recursive:true});
const evidenceKeyFile=join(temporary,'evidence.key');writeFileSync(evidenceKeyFile,randomBytes(32),{mode:0o600});
if(process.platform==='win32')execFileSync(join(process.env.SystemRoot,'System32/WindowsPowerShell/v1.0/powershell.exe'),['-NoProfile','-NonInteractive','-Command',"$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"],{windowsHide:true,env:{SystemRoot:process.env.SystemRoot,TEST_EVIDENCE_KEY:evidenceKeyFile},stdio:'pipe'});
const sink=createServer((req,res)=>{if(req.url==='/download-safe'){res.setHeader('Content-Type','application/octet-stream');res.setHeader('Content-Disposition','attachment; filename="safe.bin"');res.end(Buffer.from([0,1,2,3,255]));return;}res.setHeader('Content-Type','text/html');res.end('<!doctype html><title>Interaction fixture</title><pre id="clipboard-source">Synthetic public selection</pre><input id="public-input" type="text"><input id="private-input" type="password" value="never-export-this"><button id="public-button" onclick="this.dataset.clicked=String(Number(this.dataset.clicked||0)+1)">Synthetic control label</button><form id="public-form" onsubmit="event.preventDefault();this.dataset.submitted=String(Number(this.dataset.submitted||0)+1)"><input type="text" value="Synthetic private form field"><button id="submit-button" type="submit">Send form</button></form><form id="private-form" onsubmit="event.preventDefault()"><input type="password" value="never-export-this"><button id="private-submit">Private submit</button></form><a id="safe-download" href="/download-safe">Download</a>');}).listen(0,'127.0.0.1');await new Promise(r=>sink.once('listening',r));
const store=openStore(join(temporary,'db')),code=randomBytes(32).toString('base64url'),adminToken=randomBytes(32).toString('base64url');
const inspectorToken=randomBytes(32).toString('base64url');
const inspectorProcess=spawn(resolve('.runtime/windows/python/python.exe'),['apps/inspector/server.py'],{windowsHide:true,stdio:['ignore','pipe','pipe'],env:{...process.env,INSPECTOR_TOKEN:inspectorToken,INSPECTOR_PORT:'0',INSPECTOR_PARENT_PID:String(process.pid)}});
const inspectorPort=await new Promise((resolve,reject)=>{
let output='';const deadline=setTimeout(()=>{inspectorProcess.kill();reject(Error('Fixture inspector did not start'));},40000);
inspectorProcess.once('exit',()=>{clearTimeout(deadline);reject(Error('Fixture inspector exited before startup'));});
inspectorProcess.on('error',reject);
inspectorProcess.stdout.on('data',chunk=>{
output+=chunk;
if(output.includes('\n')){
clearTimeout(deadline);
try{resolve(JSON.parse(output.split('\n')[0]).port);}catch{reject(Error('Fixture inspector startup rejected'));}
}
});
inspectorProcess.stderr.on('data',()=>{});
});
const p=store.get('policy');p.browser.homeUrl=`http://127.0.0.1:${sink.address().port}`;p.brand={name:'Acme Secure Browser',color:'#245fbc'};p.dlp.rules=[{id:'secret-roadmap',label:'Roadmap',kind:'literal',value:'Secret Roadmap',action:'block'}];if(process.env.SAFETYNET_TEST_OTLP_ENDPOINT)p.diagnostics={...p.diagnostics,enabled:true,mode:'debug',endpoint:process.env.SAFETYNET_TEST_OTLP_ENDPOINT,sampleRate:1,debugSampleRate:1,flushIntervalMs:250};store.put('policy',p);store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,'smoke-user');
p.evidence={enabled:true,captureDownloads:true,captureInteractions:true};p.browser.allowDownloads=true;p.security.yara={enabled:true,rules:[{id:'test-marker',name:'Controlled malicious marker',source:'rule Block_Test { strings: $a = "SAFETYNET_BLOCK_TEST" condition: $a }'}],maxScanBytes:1048576};p.agents={enabled:true,scopes:['sessions','navigate','back','forward','reload','read','inspect','scroll','click','type','select','check','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group'],allowedHosts:['127.0.0.1'],allowSensitiveRead:false};if(process.env.SAFETYNET_TEST_OTLP_ENDPOINT)p.diagnostics={...p.diagnostics,enabled:true,mode:'debug',endpoint:process.env.SAFETYNET_TEST_OTLP_ENDPOINT,sampleRate:1,debugSampleRate:1,flushIntervalMs:250};store.put('policy',p);
const config={publicUrl:'http://127.0.0.1',secure:false,adminToken,evidenceKeyFile,requirePQManagement:true,requireIndependentPolicyApproval:true,inspectorUrl:`http://127.0.0.1:${inspectorPort}`,inspectorToken};const management=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>management.on('listening',r));config.publicUrl=`http://127.0.0.1:${management.address().port}`;
const reviewerToken=randomBytes(32).toString('base64url');store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(reviewerToken),Date.now()+600000,JSON.stringify({sub:'independent-fixture-reviewer',role:'admin'}));
const child=spawn(electron,['scripts/interaction-evidence-electron.cjs'],{stdio:'inherit',windowsHide:true,env:{...process.env,SAFETYNET_SMOKE_SERVER:config.publicUrl,SAFETYNET_SMOKE_CODE:code,SAFETYNET_SMOKE_USERDATA:join(temporary,'browser'),SAFETYNET_SMOKE_ARTIFACTS:resolve('artifacts'),SAFETYNET_SMOKE_ADMIN:adminToken,SAFETYNET_SMOKE_REVIEWER:reviewerToken}});
const timeout=setTimeout(()=>child.kill(),180000);const exitCode=await new Promise(r=>{child.once('exit',code=>r(code??1));child.once('error',()=>r(1));});clearTimeout(timeout);
for(const server of [management,sink])server.closeAllConnections();await Promise.all([new Promise(r=>management.close(r)),new Promise(r=>sink.close(r))]);store.db.close();inspectorProcess.kill();
// Only this mkdtemp-created fixture directory is removed.
if(!temporary.startsWith(join(tmpdir(),'safetynet-smoke-')))throw Error('Fixture cleanup boundary invalid');rmSync(temporary,{recursive:true,force:true});await smokeTelemetry?.shutdown();
if(exitCode===0&&process.env.SAFETYNET_TEST_OTLP_ENDPOINT){
const proof=JSON.parse(readFileSync(resolve('artifacts/interaction-evidence-native-proof.json'),'utf8')),endpoint=process.env.SAFETYNET_TEST_OTLP_ENDPOINT;
const health=await fetch(endpoint+'/health').then(r=>r.json());if(health.rejectedWrites!==0)throw Error('Collector reports rejected writes');
await new Promise(resolve=>setTimeout(resolve,3000));
const trace=await fetch(endpoint+'/api/trace/'+proof.traceIds[0]).then(r=>r.json()),spans=trace.spans||[],root=spans.find(s=>s.operation==='agent.request');
if(!root||!spans.some(s=>s.operation==='agent.dispatch'&&s.parentSpanId===root.spanId)||!['evidence.challenge','evidence.decrypt','evidence.store','evidence.encrypt'].every(name=>spans.some(s=>s.name===name&&spans.some(parent=>parent.spanId===s.parentSpanId))))throw Error('Actual interaction evidence trace correlation missing');
const browserStages=['evidence.capture','evidence.challenge','evidence.encrypt','evidence.send','evidence.ack'];if(!browserStages.every(operation=>spans.some(s=>s.name==='browser.evidence'&&s.operation===operation&&spans.some(parent=>parent.spanId===s.parentSpanId))))throw Error('Browser evidence stages missing or uncorrelated');
const humanTraceProof=[];
for(let i=0;i<proof.humanTraceIds.length;i++){
const item=await fetch(endpoint+'/api/trace/'+proof.humanTraceIds[i]).then(r=>r.json()),humanSpans=item.spans||[];
const capture=humanSpans.find(span=>span.name==='browser.evidence'&&span.operation==='evidence.capture');
if(!capture||!humanSpans.some(span=>span.name==='evidence.store')||!['evidence.challenge','evidence.encrypt','evidence.send','evidence.ack'].every(operation=>humanSpans.some(span=>span.name==='browser.evidence'&&span.operation===operation))){writeFileSync(resolve('artifacts/human-interaction-trace-error.json'),JSON.stringify({traceId:item.traceId,eventId:proof.humanEventIds[i],spans:humanSpans.map(({name,operation,spanId,parentSpanId,durationMs})=>({name,operation,spanId,parentSpanId,durationMs}))},null,2));throw Error('Physical human source evidence trace phases missing');}
if(!humanSpans.filter(span=>span.parentSpanId).every(span=>humanSpans.some(parent=>parent.spanId===span.parentSpanId)))throw Error('Physical human evidence trace parent missing');
humanTraceProof.push({traceId:item.traceId,eventId:proof.humanEventIds[i],spanCount:humanSpans.length,captureMs:capture.durationMs,parentCorrelation:true,phases:humanSpans.filter(span=>span.name==='browser.evidence').map(({operation,durationMs})=>({operation,durationMs}))});
}
writeFileSync(resolve('artifacts/human-interaction-trace-proof.json'),JSON.stringify({humanTraceProof,collectorRejectedWrites:health.rejectedWrites,collectorStoredBytes:health.storedBytes},null,2));
writeFileSync(resolve('artifacts/interaction-evidence-trace-proof.json'),JSON.stringify({traceId:trace.traceId,linkedNativeEventId:proof.eventIds[0],spanCount:spans.length,agentRootMs:root.durationMs,browserEvidenceStages:spans.filter(s=>s.name==='browser.evidence').map(({operation,durationMs,parentSpanId})=>({operation,durationMs,parentSpanId})),protectedEvidenceStages:spans.filter(s=>s.name.startsWith('evidence.')).map(({name,durationMs,parentSpanId})=>({name,durationMs,parentSpanId})),parentCorrelation:true,collectorRejectedWrites:health.rejectedWrites,collectorStoredBytes:health.storedBytes},null,2));
}
process.exit(exitCode);