From c8b6ff9cdd1908d62101e5348b4a6f58d7685a4e Mon Sep 17 00:00:00 2001 From: Karl Date: Tue, 13 Oct 2026 21:00:41 +0200 Subject: [PATCH] Add policy-bound credential submission and native DLP audit proofs --- AGENT-API.md | 52 +++++- README.md | 6 +- apps/browser/agent-adapters.cjs | 2 +- apps/browser/agent-control.cjs | 9 +- apps/browser/credential-submission.cjs | 35 ++++ apps/browser/diagnostics.cjs | 3 +- apps/browser/index.html | 2 +- apps/browser/main.cjs | 87 +++++++--- apps/browser/password-fill.cjs | 25 ++- apps/browser/preload.cjs | 2 + apps/browser/renderer.js | 3 +- apps/browser/vault-agent-controls.cjs | 22 +-- apps/server/app.mjs | 2 +- apps/server/public/admin.js | 6 +- apps/server/public/agent-grants-admin.js | 2 +- apps/server/public/index.html | 4 +- docs/agent-vault-controls.md | 14 +- docs/controlled-credential-policy.md | 135 ++++++++++++++++ docs/development-observability.md | 54 +++++++ docs/requirements-audit.md | 159 +++++++++++++++++-- docs/worker-ownership.md | 25 +++ packages/core/agent-audit.cjs | 2 +- packages/core/credential-policy.mjs | 15 ++ packages/core/policy.mjs | 3 +- packages/core/security.mjs | 2 +- packages/core/siem.mjs | 4 +- scripts/agent-grants-history-admin-smoke.cjs | 8 +- scripts/agent-grants-history-admin-smoke.mjs | 5 +- scripts/controlled-login-smoke.cjs | 27 ++++ scripts/controlled-login-smoke.mjs | 25 +++ scripts/credential-native-facts-smoke.cjs | 10 ++ scripts/credential-native-facts-smoke.mjs | 22 +++ scripts/verify-package.mjs | 2 +- tests/admin-controls-ui.test.mjs | 8 + tests/controlled-login-audit.test.mjs | 16 ++ tests/credential-policy.test.mjs | 5 + tests/credential-stage-diagnostics.test.mjs | 5 + tests/credential-submission.test.mjs | 30 ++++ tests/header-gate-timeout.test.mjs | 3 +- tests/password-fill.test.mjs | 13 +- tests/policy-stage-diagnostics.test.mjs | 4 +- tests/request-gate-timeout.test.mjs | 3 +- tests/request-transfer-facts.test.mjs | 5 +- tests/security.test.mjs | 2 +- tests/vault-agent-controls.test.mjs | 7 +- 45 files changed, 777 insertions(+), 98 deletions(-) create mode 100644 apps/browser/credential-submission.cjs create mode 100644 docs/controlled-credential-policy.md create mode 100644 docs/worker-ownership.md create mode 100644 packages/core/credential-policy.mjs create mode 100644 scripts/controlled-login-smoke.cjs create mode 100644 scripts/controlled-login-smoke.mjs create mode 100644 scripts/credential-native-facts-smoke.cjs create mode 100644 scripts/credential-native-facts-smoke.mjs create mode 100644 tests/controlled-login-audit.test.mjs create mode 100644 tests/credential-policy.test.mjs create mode 100644 tests/credential-stage-diagnostics.test.mjs create mode 100644 tests/credential-submission.test.mjs diff --git a/AGENT-API.md b/AGENT-API.md index 5ca978e..8ffd0e3 100644 --- a/AGENT-API.md +++ b/AGENT-API.md @@ -36,6 +36,10 @@ Use `agent-transport-client.cjs` or the trusted Electron helper. The generated n | sessions:save | name, sessionIds, optional id and expectedRevision | sessions:save | sessions:save | owned collection ID, revision, tabCount | | sessions:list | none | sessions:list | sessions:list | revision and DLP-inspected owned collections | | sessions:restore | id OR planId | sessions:restore | sessions:restore | one-use plan, then restored native tab IDs | +| vault:status | optional sessionId | vault:status | vault:status | bounded exists/locked/enabled/autofill flags | +| vault:list | optional sessionId | vault:list | vault:list | approved origins and one-use credential references | +| vault:fill | sessionId, credentialRef | vault:fill | vault:fill | `{completed}`; does not authorize submission | +| vault:prepare-submit | sessionId, credentialRef | vault:prepare-submit | vault:prepare-submit | `{completed,prepared}`; never submits the form | | navigate | sessionId, url | navigate | browser:navigate | `{completed}` | | back | sessionId | back | browser:navigate | `{completed}` | | forward | sessionId | forward | browser:navigate | `{completed}` | @@ -74,7 +78,7 @@ Inventory works on the start page. Read and AI can target an explicitly identifi Prefer `persistCredential({address,token,edPublic,pqPublic,protocol})` backed by protected trusted storage; Windows integration uses DPAPI. Plaintext `credentialFile` persistence is rejected; use the protected persistence callback. Never accept callback/path configuration from pages. Audit contains action/scope/session/outcome metadata, not raw content. Audit failure prevents API adapter execution. Optional trusted `onError` reports static allowlisted reasons only. `revoke()` permanently disables the instance; `close()` shuts it down. Node deadlines cannot forcibly stop adapters that ignore cancellation. -Debug spans separately measure challenge, decryption, authorization, audit, dispatch, projection and encryption. Separate named per-agent identities and individual delegated revocation remain open; current pairing trusts processes of the paired Windows user. +Debug spans separately measure challenge, decryption, authorization, audit, dispatch, projection and encryption. Individually signed grants, generation rotation and targeted revocation are implemented below; protected pairing still trusts processes of the paired Windows user. `select` and `check` are separate opt-in policy scopes and require a visible interactive foreground tab. Selection chooses one existing enabled option; sensitive/hidden options or groups are excluded. Checkbox control is idempotent, uses page interaction semantics and verifies the requested state after handlers. Duplicate selectors, unknown options, radio inputs and credential targets are denied. Inspect exposes up to 20 safe options per select and checkbox states; option values longer than 128 characters are omitted instead of truncated. All returned metadata passes the existing DLP gate. @@ -92,8 +96,8 @@ failure audit phases now have explicit recording spans and trace correlation. ECS preserves these fields under `safetynet.agent`; CEF uses bounded standard custom-string fields without overwriting trace or audit-integrity references. Legacy events without this object remain readable; they are not retroactively -attributed. Individual credential identities and targeted revocation remain -planned in [individual-agent-identities.md](docs/individual-agent-identities.md). +attributed. Individual credential identities and targeted revocation are implemented in +[individual agent administration](docs/individual-agent-grants.md). ## Individually managed local identities @@ -143,3 +147,45 @@ guarded native tab owner, including unattributed service-worker/background traff This containment remains after human navigation clears an agent tab restriction. Supporting such traffic requires reliable actor ownership or isolated sessions; that compatibility work is open and is not satisfied by inferring the active tab. + +## Controlled credential preparation + +All vault scopes require explicit central permission. Unlock the vault through the +trusted human UI, then obtain a fresh `vault:list` reference for the exact native +login tab. `vault:fill` fills only. The separate `vault:prepare-submit` consumes its +reference, fills the approved form and issues a single-use local allowance; its +reply contains only completion/preparation booleans. Neither action submits the +form, unlocks the vault or exports username/password values. A later explicit +website submission still needs normal interaction permission and network checks. + +An administrator must additionally enable +`passwordManager.controlledSubmission` and approve exact canonical HTTPS action +URLs plus distinct username/password field names. The current allowance expires +after 15 seconds and covers one bounded native main-frame, same-origin POST using +`application/x-www-form-urlencoded` (64 KiB, at most 128 distinct fields). +Exact credential values, document/frame, actor/generation, foreground activation, +policy and vault state are checked again. Other fields, request destination and +headers retain mandatory inspection. A mismatched candidate consumes permission; +never retry automatically. JS fetch/XHR, JSON, multipart and arbitrary login +protocols are outside this allowance. A 302-style GET receives ordinary inspection; +307/308 credential-body replay is blocked. + +The approved website receives credentials in its DOM, where its JavaScript can +read them. The lease is a narrow network authorization mechanism, not password +isolation, form attestation or a guarantee against phishing. Website authentication +success is not returned by preparation or inferred from HTTP completion. + +Security audit separates prepared, authorized, denied, HTTP-completed and +transaction-failure events. Authorization does not prove transmission; completion +means the native HTTP transaction finished, including error responses. Ordinary +metadata excludes credential values, form IDs, hosts, action URLs and body hashes. +Protected debug spans name `credential.issue`, `credential.claim`, +`credential.headers`, `credential.authorize`, `credential.redirect`, +`credential.complete` and `credential.failure`, with correlated inspection/audit +phases. Diagnostics remain centrally disableable and are not durable security audit. + +The isolated native fixture has exercised approved delivery, safe 302, blocked307, +residual/duplicate/action/header refusals, human handoff and central revocation. +The latest source increment's final native rerun, full suite and Windows package +verification are pending; historical green builds do not prove this source state. +See [controlled credential policy](docs/controlled-credential-policy.md). diff --git a/README.md b/README.md index 7bc0a85..aea96b0 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,11 @@ S Browser uses translucent materials, rounded controls, light/dark themes, and a | Administration | Policy templates, diffs and history, independent publication review, groups, profiles, teaching workflows, incidents and configurable event retention. | | AI and agents | Approved AI routing with server-held credentials; scoped local agent navigation, tabs, page interaction, settings, bookmarks, inspected history and bounded browser window controls. Separate centrally approved agent identities, scoped credentials, expiry, generation rotation and targeted revocation; administrators can disable all agent access. | | Observability | OpenTelemetry traces, metrics and sanitized logs; durable security events; ECS/CEF exports and HTTPS/mTLS SIEM delivery. Governed protected download metadata, completed clipboard/agent-click metadata and opt-in observed foreground clicks/form gestures. Full replay and content recording remain incomplete. | -| Additional controls | Local encrypted password vault with explicit autofill and separately approved agent status/masked inventory/fill after human unlock; protected-brand visual similarity checks paired with trusted HTTPS origins. These features require further independent evaluation. | +| Additional controls | Local encrypted password vault with explicit autofill and separately approved agent status/masked inventory/fill after human unlock. Default-off controlled login preparation can authorize one approved native form POST; protected-brand visual similarity checks pair with trusted HTTPS origins. These features require further independent evaluation. | -Individual agent mode currently blocks browser-session requests without a known guarded tab owner, including unattributed background-worker traffic. Reliable agent ownership for these requests remains open. See [individual agent administration](docs/individual-agent-grants.md) and [agent API](AGENT-API.md). Separately approved [saved sessions](docs/agent-session-controls.md) retain locally encrypted URL/title/group collections; restoration rechecks current policy and creates managed tabs without saving form or authentication state. +Individual agent mode currently blocks browser-session requests without a known guarded tab owner, including unattributed background-worker traffic. Reliable agent ownership for these requests remains open. The browser does not assign an unattributed request to whichever tab happens to be foreground. See [individual agent administration](docs/individual-agent-grants.md) and [agent API](AGENT-API.md). Separately approved [saved sessions](docs/agent-session-controls.md) retain locally encrypted URL/title/group collections; restoration rechecks current policy and creates managed tabs without saving form or authentication state. + +[Controlled vault login preparation](docs/controlled-credential-policy.md) is separate from ordinary autofill. Administrators approve exact HTTPS action URLs and credential field names; human unlock is required, and agents need the separate `vault:prepare-submit` capability. Preparation fills the form but does not submit it. Supported native same-origin URL-encoded POSTs remain subject to current policy and inspection of every other field; arbitrary JavaScript/XHR/JSON login flows remain unsupported. The approved site can read filled credentials in its DOM. This is not password isolation or proof of website identity. Permissions are enforced by the backend and privileged browser code, not merely by hiding controls. Matching restrictive policies accumulate. Domain categories are curated company lists, not a subscription categorization feed. diff --git a/apps/browser/agent-adapters.cjs b/apps/browser/agent-adapters.cjs index 138086d..cd8d5e0 100644 --- a/apps/browser/agent-adapters.cjs +++ b/apps/browser/agent-adapters.cjs @@ -153,7 +153,7 @@ function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,his await onInteraction({action:'click',sessionId:initial.sessionId,pageUrl:initial.url,outcome:'completed',sensitiveField:false,...(target?{target}:{})});check(initial,args,signal);} return {completed:result?.completed===true}; }; - const appActions=['vault:status','vault:list','vault:fill','window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply']; + const appActions=['vault:prepare-submit','vault:status','vault:list','vault:fill','window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply']; // Native history owns target resolution and pre-release destination checks. A // refreshed token permits intentional navigation but never a different tab. const historyAdapters=Object.fromEntries(['back','forward','reload'].map(action=>[action,async(args={}, {signal}={})=>{ diff --git a/apps/browser/agent-control.cjs b/apps/browser/agent-control.cjs index e729c5a..3657e85 100644 --- a/apps/browser/agent-control.cjs +++ b/apps/browser/agent-control.cjs @@ -3,7 +3,7 @@ const http = require('node:http'); const crypto = require('node:crypto'); const channel=require('../../packages/core/policy-channel.cjs'); -const APP_ACTIONS=Object.freeze(['sessions:save','sessions:list','sessions:restore','vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply']); +const APP_ACTIONS=Object.freeze(['vault:prepare-submit','sessions:save','sessions:list','sessions:restore','vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply']); const SCOPES = Object.freeze({ sessions: 'sessions:list', navigate: 'browser:navigate', back:'browser:navigate',forward:'browser:navigate',reload:'browser:navigate', read: 'browser:read', inspect:'browser:read', scroll:'browser:interact', click: 'browser:interact', type: 'browser:interact', select:'browser:interact', check:'browser:interact', ai: 'ai:request',...Object.fromEntries(APP_ACTIONS.map(action=>[action,action])) }); const fail = (status, message) => Object.assign(new Error(message), { status }); const bounded = (value, max, name) => { @@ -16,7 +16,7 @@ function validate(body) { if (!Object.hasOwn(SCOPES, action)) throw fail(400, 'Unsupported action'); const args = body.args ?? {}; if (!args || typeof args !== 'object' || Array.isArray(args)) throw fail(400, 'Invalid arguments'); - const keys = { 'sessions:save':['name','sessionIds','id','expectedRevision'],'sessions:list':[],'sessions:restore':['id','planId'],'vault:status':['sessionId'],'vault:list':['sessionId'],'vault:fill':['sessionId','credentialRef'],'history:list':['sessionId'],'history:go':['sessionId','snapshotId','entryId'],'window:state':[],'window:control':['command'],sessions: [], navigate: ['sessionId', 'url'],back:['sessionId'],forward:['sessionId'],reload:['sessionId'], read: ['sessionId'], inspect:['sessionId'], scroll:['sessionId','x','y'], click: ['sessionId', 'selector'], type: ['sessionId', 'selector', 'text'], select:['sessionId','selector','value'], check:['sessionId','selector','checked'], ai: ['sessionId', 'prompt'],'application:state':[],'settings:get':[],'settings:update':['appearance'],'bookmarks:list':[],'bookmarks:mutate':['action','bookmark','id','expectedRevision','policyVersion'],'tabs:create':['group','url'],'tabs:activate':['sessionId'],'tabs:close':['sessionId'],'tabs:group':['sessionId','group'],'library:organize-plan':[],'library:organize-apply':['proposalId','confirm','autoApply'] }[action]; + const keys = { 'sessions:save':['name','sessionIds','id','expectedRevision'],'sessions:list':[],'sessions:restore':['id','planId'],'vault:status':['sessionId'],'vault:list':['sessionId'],'vault:fill':['sessionId','credentialRef'],'vault:prepare-submit':['sessionId','credentialRef'],'history:list':['sessionId'],'history:go':['sessionId','snapshotId','entryId'],'window:state':[],'window:control':['command'],sessions: [], navigate: ['sessionId', 'url'],back:['sessionId'],forward:['sessionId'],reload:['sessionId'], read: ['sessionId'], inspect:['sessionId'], scroll:['sessionId','x','y'], click: ['sessionId', 'selector'], type: ['sessionId', 'selector', 'text'], select:['sessionId','selector','value'], check:['sessionId','selector','checked'], ai: ['sessionId', 'prompt'],'application:state':[],'settings:get':[],'settings:update':['appearance'],'bookmarks:list':[],'bookmarks:mutate':['action','bookmark','id','expectedRevision','policyVersion'],'tabs:create':['group','url'],'tabs:activate':['sessionId'],'tabs:close':['sessionId'],'tabs:group':['sessionId','group'],'library:organize-plan':[],'library:organize-apply':['proposalId','confirm','autoApply'] }[action]; if (Object.keys(args).some(k => !keys.includes(k))) throw fail(400, 'Unsupported argument'); const clean = {}; if(action==='sessions:save'){ @@ -31,8 +31,8 @@ function validate(body) { } if(action.startsWith('sessions:'))for(const key of ['id','expectedRevision','planId'])if(clean[key]!==undefined&&!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(clean[key]))throw fail(400,'Invalid session handle'); if(action.startsWith('vault:')){ - if(args.sessionId!==undefined||action==='vault:fill')clean.sessionId=bounded(args.sessionId,128,'sessionId'); - if(action==='vault:fill'){clean.credentialRef=bounded(args.credentialRef,36,'credentialRef');if(!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(clean.credentialRef))throw fail(400,'Invalid credential reference');} + if(args.sessionId!==undefined||['vault:fill','vault:prepare-submit'].includes(action))clean.sessionId=bounded(args.sessionId,128,'sessionId'); + if(['vault:fill','vault:prepare-submit'].includes(action)){clean.credentialRef=bounded(args.credentialRef,36,'credentialRef');if(!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(clean.credentialRef))throw fail(400,'Invalid credential reference');} } if(action.startsWith('history:')){ clean.sessionId=bounded(args.sessionId,128,'sessionId'); @@ -89,6 +89,7 @@ function project(action, result) { if(!Array.isArray(result)||result.length>32)throw fail(502,'Invalid vault response'); return result.map(row=>{let origin;try{origin=new URL(row.origin);}catch{throw fail(502,'Invalid vault response');}if(typeof row.credentialRef!=='string'||!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(row.credentialRef)||typeof row.origin!=='string'||row.origin.length>2048||origin.protocol!=='https:'||origin.username||origin.password||origin.origin!==row.origin)throw fail(502,'Invalid vault response');return {credentialRef:row.credentialRef,origin:row.origin};}); } + if(action==='vault:prepare-submit')return {completed:result?.completed===true,prepared:result?.prepared===true}; if(action==='vault:fill')return {completed:result?.completed===true}; if(action==='history:list'){ const uuid=value=>{if(typeof value!=='string'||!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(value))throw fail(502,'Invalid history response');return value;}; diff --git a/apps/browser/credential-submission.cjs b/apps/browser/credential-submission.cjs new file mode 100644 index 0000000..28eacd8 --- /dev/null +++ b/apps/browser/credential-submission.cjs @@ -0,0 +1,35 @@ +'use strict'; +const {randomBytes,createHmac,createHash,timingSafeEqual}=require('node:crypto'); +const fail=()=>{throw Error('Controlled credential submission refused');}; +const field=/^[\x21-\x7e]{1,128}$/; +const hash=bytes=>createHash('sha256').update(bytes).digest('hex'); +function createCredentialSubmissionBroker({isCurrent,traceOperation=(_operation,fn)=>fn(),now=Date.now,setIntervalImpl=setInterval,clearIntervalImpl=clearInterval}={}){ + if(typeof isCurrent!=='function'||typeof now!=='function')throw TypeError('Credential authority callback required'); + const stage=(operation,fn)=>{let called=false,value,error;const once=()=>{if(called){if(error)throw error;return value;}called=true;try{value=fn();return value;}catch(e){error=e;throw e;}};try{traceOperation(operation,once);}catch{}if(!called)return once();if(error)throw error;return value;}; + let closed=false;const issued=new Map(),pending=new Map(),redirected=new Map(),blocked=new Map(); + const wipe=lease=>{lease.key.fill(0);lease.username.digest.fill(0);lease.password.digest.fill(0);}; + const expire=()=>{for(const [id,expiry] of blocked)if(expiry<=now())blocked.delete(id);for(const [id,l]of redirected)if(l.expires<=now()){redirected.delete(id);blocked.set(id,now()+60000);wipe(l);}for(const [id,l] of issued)if(l.expires<=now()){issued.delete(id);wipe(l);}for(const [id,l]of pending)if(l.expires<=now()){pending.delete(id);blocked.set(id,now()+60000);wipe(l);}}; + const expiryTimer=setIntervalImpl(expire,1000);expiryTimer?.unref?.(); + const digest=(key,value)=>createHmac('sha256',key).update(value,'utf8').digest(); + const match=(l,part,value)=>{const candidate=digest(l.key,value);try{return timingSafeEqual(candidate,l[part].digest);}finally{candidate.fill(0);}}; + const authority=(l,context,phase,details)=>stage('credential.authority',()=>{if(l.expires<=now()||!context.policy?.passwordManager?.controlledSubmission?.enabled||l.vault.status().locked||context.policy!==l.context.policy||context.webContents!==l.context.webContents||context.mainFrame!==l.context.mainFrame||JSON.stringify(context.actor??null)!==l.actor||context.vault!==undefined&&context.vault!==l.vault||l.vault.accessGeneration()!==l.vaultGeneration||isCurrent(l.context,{phase},details)!==true)fail();}); + const containsCredential=(l,text)=>stage('credential.match',()=>{if(typeof text!=='string'||text.length>131072)fail();let decoded=text;for(let round=0;round<3;round++){for(const part of ['username','password']){const length=l[part].length;if(length)for(let i=0;i+length<=decoded.length;i++)if(match(l,part,decoded.slice(i,i+length)))return true;}try{const next=decodeURIComponent(decoded);if(next===decoded)break;decoded=next;}catch{break;}}return false;}); + // Native headers can be reordered and unrelated fields interleaved. This + // bounded check covers two whole header-value fragments in either order. + const pairCredential=(l,values)=>stage('credential.match',()=>{if(!Array.isArray(values)||values.length>128)fail();let chars=0;const variants=[];for(let index=0;index131072)fail();variants.push({index,value});try{const next=decodeURIComponent(value);if(next===value)break;value=next;}catch{break;}}}let work=0;for(const a of variants)for(const b of variants){if(a.index===b.index)continue;if(++work>65536)fail();const length=a.value.length+b.value.length;for(const part of ['username','password'])if(length===l[part].length&&match(l,part,a.value+b.value))return true;}return false;}); + const native=(l,details,context)=>stage('credential.native',()=>{if(!Number.isSafeInteger(details.id)||details.id<0||details.method!=='POST'||details.resourceType!=='mainFrame'||details.url!==l.form.actionUrl||details.webContentsId!==context.webContents.id||!details.frame||details.frame!==context.mainFrame||details.initiatorOrigin!==new URL(l.context.sourceURL).origin)fail();}); + function issue({context,formId,username,password,vault,vaultGeneration}){if(closed)fail();expire();if(blocked.size>=1024)fail();if(!context?.policy?.passwordManager?.controlledSubmission?.enabled||!context.token||!context.webContents||!context.mainFrame||context.webContents.mainFrame!==context.mainFrame||!vault||vault.status().locked||vault.accessGeneration()!==vaultGeneration||isCurrent(context,{phase:'issued'})!==true)fail();const forms=context.policy.passwordManager.controlledSubmission.forms;if(!Array.isArray(forms)||forms.length>32)fail();const form=forms.find(f=>f.id===formId);if(!form||!field.test(form.usernameField)||!field.test(form.passwordField)||form.usernameField===form.passwordField)fail();let action,source;try{action=new URL(form.actionUrl);source=new URL(context.sourceURL);}catch{fail();}if(action.protocol!=='https:'||source.protocol!=='https:'||action.origin!==source.origin||action.username||action.password||action.search||action.hash||action.href!==form.actionUrl)fail();if(typeof username!=='string'||typeof password!=='string'||!username||!password||username.length>512||password.length>4096||/[\u0000\uD800-\uDFFF]/u.test(username+password))fail();if(issued.size+pending.size+redirected.size>=32&&!issued.has(context.webContents.id))fail();const old=issued.get(context.webContents.id);if(old)wipe(old);const key=randomBytes(32),lease={context,actor:JSON.stringify(context.actor??null),form:{...form},vault,vaultGeneration,key,username:{digest:digest(key,username),length:username.length},password:{digest:digest(key,password),length:password.length},expires:now()+15000};issued.set(context.webContents.id,lease);return {prepared:true,expiresAt:lease.expires};} + function claim(details,bytes,context){expire();if(redirected.has(details.id)){validateRedirect(details,context);return null;}if(blocked.has(details.id)||pending.has(details.id))fail();const l=issued.get(details.webContentsId);if(!l)return null;const candidate=details.method==='POST'&&details.resourceType==='mainFrame'&&details.url===l.form.actionUrl;let observed=false;try{observed=containsCredential(l,details.url);if(!candidate&&bytes?.length){if(!Buffer.isBuffer(bytes)||bytes.length>65536)fail();observed=observed||containsCredential(l,new TextDecoder('utf-8',{fatal:true}).decode(bytes));}}catch{observed=true;}if(!candidate&&!observed)return null;issued.delete(details.webContentsId);blocked.set(details.id,now()+60000);try{authority(l,context,'request',details);native(l,details,context);if(!Buffer.isBuffer(bytes)||bytes.length<1||bytes.length>65536||!Array.isArray(details.uploadData)||details.uploadData.some(p=>!p.bytes||p.file||p.blobUUID)||hash(Buffer.concat(details.uploadData.map(p=>Buffer.from(p.bytes))))!==hash(bytes))fail();const text=new TextDecoder('utf-8',{fatal:true}).decode(bytes);if(/[\u0000-\u001f\u007f]/.test(text)||text.split('&').length>128)fail();const entries=stage('credential.parse',()=>text.split('&').map(raw=>{const index=raw.indexOf('=');if(index<1||/%(?![0-9a-f]{2})/i.test(raw))fail();let name,value;try{name=decodeURIComponent(raw.slice(0,index).replace(/\+/g,' '));value=decodeURIComponent(raw.slice(index+1).replace(/\+/g,' '));}catch{fail();}if(!field.test(name)||/[\u0000-\u001f\u007f]/.test(value))fail();return {name,value,raw};}));if(new Set(entries.map(e=>e.name)).size!==entries.length)fail();const u=entries.find(e=>e.name===l.form.usernameField),p=entries.find(e=>e.name===l.form.passwordField);if(!u||!p||!stage('credential.match',()=>match(l,'username',u.value)&&match(l,'password',p.value)))fail();const others=entries.filter(e=>e!==u&&e!==p);if(others.some(e=>containsCredential(l,e.name+'\n'+e.value))||containsCredential(l,others.map(e=>e.value).join(''))||containsCredential(l,others.map(e=>e.name).join('')))fail();const residual=stage('credential.residual',()=>Buffer.from([others.map(e=>e.raw).join('&'),others.map(e=>e.name+'\n'+e.value).join('\n'),others.map(e=>e.value).join(''),entries.map(e=>e.name).join('\n')].join('\n')));l.bodyHash=hash(bytes);l.requestId=details.id;l.headersValidated=false;pending.set(details.id,l);return {requestId:details.id,residual,bytes:bytes.length};}catch(error){wipe(l);throw error;}} + function validateHeaders(details,context){expire();const l=pending.get(details.id);if(!l||l.finalized)fail();try{authority(l,context,'headers',details);native(l,details,context);const headers=details.requestHeaders;if(!headers||Object.keys(headers).length>128)fail();const names=Object.keys(headers);if(new Set(names.map(k=>k.toLowerCase())).size!==names.length)fail();const get=name=>{const found=names.filter(k=>k.toLowerCase()===name);if(found.length!==1)fail();const value=headers[found[0]];if(typeof value!=='string')fail();return value;};if(!/^application\/x-www-form-urlencoded(?:;\s*charset=utf-8)?$/i.test(get('content-type'))||names.some(k=>k.toLowerCase()==='content-encoding'))fail();if(details.uploadData&&hash(Buffer.concat(details.uploadData.map(p=>Buffer.from(p.bytes||[]))))!==l.bodyHash)fail();if(stage('credential.header-values',()=>containsCredential(l,details.url)||names.some(k=>containsCredential(l,k)||containsCredential(l,headers[k]))||containsCredential(l,names.map(k=>headers[k]).join(''))||containsCredential(l,names.join(''))||pairCredential(l,names.map(k=>headers[k]))||pairCredential(l,names)))fail();l.headersValidated=true;return {requestId:details.id};}catch(error){pending.delete(details.id);wipe(l);throw error;}} + function finalize(id,context,details){expire();const l=pending.get(id);if(!l||l.finalized||!l.headersValidated)fail();try{authority(l,context,'headers',details);native(l,details,context);l.finalized=true;return {authorized:true};}catch(error){pending.delete(id);wipe(l);throw error;}} + function validateRedirect(details,context){expire();const l=redirected.get(details.id);if(!l){if(blocked.has(details.id))fail();return false;}authority(l,context,'redirect',details);if(!['GET','HEAD'].includes(details.method)||details.uploadData?.length||details.resourceType!=='mainFrame'||details.webContentsId!==l.context.webContents.id||!details.frame||details.frame!==l.context.mainFrame||details.initiatorOrigin!==new URL(l.context.sourceURL).origin)fail();if(containsCredential(l,details.url))fail();if(details.requestHeaders){const headers=details.requestHeaders,names=Object.keys(headers);if(names.length>128||new Set(names.map(k=>k.toLowerCase())).size!==names.length)fail();for(const name of names)if(containsCredential(l,name)||containsCredential(l,headers[name]))fail();if(containsCredential(l,names.map(k=>headers[k]).join(''))||containsCredential(l,names.join(''))||pairCredential(l,names.map(k=>headers[k]))||pairCredential(l,names))fail();}return true;} + function guardIssued(details,context){expire();if(pending.has(details.id)||redirected.has(details.id))return false;if(blocked.has(details.id))fail();const l=issued.get(details.webContentsId);if(!l)return false;let matched=false;try{authority(l,context,'watch',details);if(details.webContentsId!==l.context.webContents.id)fail();matched=containsCredential(l,details.url);if(details.requestHeaders){const entries=Object.entries(Object.getOwnPropertyDescriptors(details.requestHeaders));if(entries.length>128)fail();const names=[],values=[];for(const [name,descriptor]of entries){if(!('value'in descriptor)||typeof descriptor.value!=='string')fail();names.push(name);values.push(descriptor.value);if(containsCredential(l,name)||containsCredential(l,descriptor.value))matched=true;}if(new Set(names.map(n=>n.toLowerCase())).size!==names.length)fail();if(containsCredential(l,values.join(''))||containsCredential(l,names.join(''))||pairCredential(l,values)||pairCredential(l,names))matched=true;}if(!matched)return false;}catch(error){issued.delete(details.webContentsId);blocked.set(details.id,now()+60000);wipe(l);throw error;}issued.delete(details.webContentsId);blocked.set(details.id,now()+60000);wipe(l);fail();} + function complete(id){let released=false;for(const map of [pending,redirected]){const l=map.get(id);if(l){released=released||l.finalized===true;map.delete(id);wipe(l);}}blocked.delete(id);return {released};} + function redirect(id){const l=pending.get(id);if(!l)return;pending.delete(id);if(!l.finalized){blocked.set(id,now()+60000);wipe(l);return;}redirected.set(id,l);blocked.delete(id);} + function revoke(){for(const l of issued.values())wipe(l);for(const map of [pending,redirected]){for(const [id,l]of map){blocked.set(id,now()+60000);wipe(l);}map.clear();}issued.clear();} + function cancel(context){const matches=l=>l.context.webContents===context.webContents&&l.actor===JSON.stringify(context.actor??null);for(const [id,l]of issued)if(matches(l)){issued.delete(id);wipe(l);}for(const map of [pending,redirected])for(const [id,l]of map)if(matches(l)){map.delete(id);blocked.set(id,now()+60000);wipe(l);}} + const has=id=>{expire();return pending.has(id);},isBlocked=id=>{expire();return blocked.has(id);}; + const close=()=>{if(closed)return;closed=true;clearIntervalImpl(expiryTimer);revoke();blocked.clear();}; + return {issue,claim,validateHeaders,validateRedirect,guardIssued,finalize,complete,redirect,revoke,cancel,has,isBlocked,close}; +} +module.exports={createCredentialSubmissionBroker}; diff --git a/apps/browser/diagnostics.cjs b/apps/browser/diagnostics.cjs index 5dbe484..abe2c98 100644 --- a/apps/browser/diagnostics.cjs +++ b/apps/browser/diagnostics.cjs @@ -14,7 +14,8 @@ const names=new Set(['browser.window','browser.network','browser.ipc','browser.a const operations=new Set(['audit.deliver','evidence.observer.install','evidence.observer.drain','evidence.observer.dispose','policy.verify','policy.bookmarks','policy.agent-reconcile','policy.page-configure','policy.audit','evidence.capture','evidence.authorize','evidence.challenge','evidence.encrypt','evidence.send','evidence.ack','agent.request','agent.challenge','agent.decrypt','agent.transport','agent.authorize','agent.audit','agent.dispatch','agent.response','agent.project','agent.encrypt','library:organize-plan','library:organize-apply','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','state','authorize-setting','settings-update','window-control','chrome-layout','share-url','bookmarks-mutate','enroll','sso','navigate','back','forward','reload','ai','inspect-file','agent-connection','ai-panel','vault-panel','vault-status','vault-create','vault-unlock','vault-lock','vault-list','vault-save','vault-delete','vault-fill','vault-generate','tab-create','tab-activate','tab-close','tab-group','sessions','read','inspect','scroll','click','type','select','check','screenshot','copy','cut','paste','scan','download','sync','create','activate','close','group','policy','request','dns','parse','embed','yara','icap']); const components=new Set(['browser','ipc','agent','management','navigation','network','dns','dlp','inspector','parser','semantic','yara','icap','download','clipboard','visual','vault','ai','policy','tabs']); for(const operation of ['history:list','history:go','window:state','window:control'])operations.add(operation); -names.add('browser.vault'); +names.add('browser.vault');names.add('browser.credential'); +for(const operation of ['vault:prepare-submit','vault-prepare-submit','credential.issue','credential.claim','credential.headers','credential.authorize','credential.redirect','credential.complete','credential.failure','credential.authority','credential.native','credential.parse','credential.match','credential.residual','credential.header-values'])operations.add(operation); for(const operation of ['vault:status','vault:list','vault:fill','vault.inventory.inspect','vault.fill.visual','vault.fill.native'])operations.add(operation); // Session collection stages use fixed identifiers; names and URLs are never attributes. names.add('browser.session'); diff --git a/apps/browser/index.html b/apps/browser/index.html index bdfeb68..589e1ca 100644 --- a/apps/browser/index.html +++ b/apps/browser/index.html @@ -464,7 +464,7 @@ - Logins fill only when you click Fill. Automatic page-load filling is disabled. + Fill login fills only. Prepare approved login also permits one approved POST for 15 seconds. Neither button submits the form. diff --git a/apps/browser/main.cjs b/apps/browser/main.cjs index e15abbf..03590af 100644 --- a/apps/browser/main.cjs +++ b/apps/browser/main.cjs @@ -179,9 +179,9 @@ async function pageTheme(tab=activeTab()){ if(!currentTabContext(context)||!result)return;const hex=/^#[a-f0-9]{6}$/i.test(result.color)?result.color:null,rgb=/^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/.exec(result.background||'');tab.color=hex||(rgb&&rgb.slice(1).every(v=>+v<=255)?'#'+rgb.slice(1).map(v=>(+v).toString(16).padStart(2,'0')).join(''):'#edf1f6');tab.title=contents.getTitle().slice(0,200);if(tab===activeTab()){pageColor=tab.color;pageTitle=tab.title;}emit(); }catch{} } -function lockVault(){const unlocked=vault&&!vault.status().locked;vault?.lock();if(unlocked)audit('vault.locked');emit();} +function lockVault(){const unlocked=vault&&!vault.status().locked;credentialBroker?.revoke();vault?.lock();if(unlocked)audit('vault.locked');emit();} function quarantine(reason){ready=false;fileUploadGate?.cancelAll();if(policy&&Date.parse(policy.expiresAt)<=Date.now()){configureDiagnostics({enabled:false});const expiredService=agentService;agentService=null;agentServiceMode=undefined;expiredService?.revoke();void expiredService?.close().catch(()=>{});}for(const [controller,route] of managementRequests)if(route==='/api/device/ai')controller.abort();enforceEvidencePolicy();message=reason;vaultPanel=false;vault?.lock();tabs?.quarantine();emit();} -function vaultSettings(){return {enabled:ready&&!!policy?.passwordManager?.enabled,allowSaving:!!policy?.passwordManager?.allowSaving,allowAutofill:!!policy?.passwordManager?.allowAutofill,idleLockSeconds:policy?.passwordManager?.idleLockSeconds||300};} +function vaultSettings(){return {enabled:ready&&!!policy?.passwordManager?.enabled,allowSaving:!!policy?.passwordManager?.allowSaving,allowAutofill:!!policy?.passwordManager?.allowAutofill,allowControlledSubmission:!!policy?.passwordManager?.controlledSubmission?.enabled,idleLockSeconds:policy?.passwordManager?.idleLockSeconds||300};} function vaultStatus(){return {...(vault?.status()||{exists:fs.existsSync(path.join(app.getPath('userData'),'password-vault.json')),locked:true}),...vaultSettings()};} function requireVault(){ requireFeatureSection('passwords'); @@ -207,12 +207,12 @@ async function flushAudit(){ try{for(let n=0;n<100&&auditQueue.length;n++){const record=auditQueue[0];await diagnostics.withCorrelation(record,()=>diagnosticSpan('browser.management',{operation:'audit.deliver'},()=>require('./evidence-delivery.cjs').deliverAuditRecord({record,send:(route,body)=>request(device.server,route,body,device.token),getContext:()=>({ready,policy,deviceId:device.id})})));auditQueue.shift();persistAudit();}} catch{}finally{flushing=false;} } -async function audit(kind,ruleId,host,fileFacts,requestFacts,evidencePayload,agentFacts,interactionFacts){ +async function audit(kind,ruleId,host,fileFacts,requestFacts,evidencePayload,agentFacts,interactionFacts,origin){ if(!device||!policy)return; // Operational correlation contains no rule text, hostname or forensic payload. try{diagnostics.event('browser.security',diagnosticContext({decision:kind.endsWith('.block')?'block':kind.endsWith('.denied')?'deny':kind.endsWith('.authorized')||kind.endsWith('.released')?'allow':'unknown',reason:ruleId}));}catch{} if(auditQueue.length>=10000){quarantine('Security event queue is full. Contact your administrator.');return;} - try{const file=fileFacts?require('../../packages/core/transfer-facts.cjs').transferFacts(fileFacts):undefined;const request=requestFacts?require('../../packages/core/request-transfer-facts.cjs').requestTransferFacts(requestFacts):undefined;const agent=agentFacts?require('../../packages/core/agent-audit.cjs').agentAuditFacts(agentFacts):undefined;const interaction=interactionFacts?require('../../packages/core/interaction-facts.cjs').InteractionFactsSchema.parse(interactionFacts):undefined;const evidenceType=evidencePayload?.action?'interaction':'download';auditQueue.push({eventId:require('node:crypto').randomUUID(),kind,...(ruleId?{ruleId}:{}),...(host?{host}:{}),...(file?{file}:{}),...(request?{request}:{}),...(agent?{agent}:{}),...(interaction?{interaction}:{}),...(evidencePayload&&require('./evidence-delivery.cjs').collectionAllowed({ready,policy,type:evidenceType})?{protectedEvidence:{deviceId:device.id,type:evidenceType,payload:require('./evidence-delivery.cjs').validatedEvidence(evidencePayload,evidenceType)}}:{}),...diagnosticCorrelation(),policyVersion:policy.version});persistAudit();const eventId=auditQueue.at(-1).eventId;flushAudit();return eventId;} + try{const file=fileFacts?require('../../packages/core/transfer-facts.cjs').transferFacts(fileFacts):undefined;const request=requestFacts?require('../../packages/core/request-transfer-facts.cjs').requestTransferFacts(requestFacts):undefined;const agent=agentFacts?require('../../packages/core/agent-audit.cjs').agentAuditFacts(agentFacts):undefined;const interaction=interactionFacts?require('../../packages/core/interaction-facts.cjs').InteractionFactsSchema.parse(interactionFacts):undefined;const evidenceType=evidencePayload?.action?'interaction':'download';auditQueue.push({eventId:require('node:crypto').randomUUID(),kind,...(ruleId?{ruleId}:{}),...(host?{host}:{}),...(file?{file}:{}),...(request?{request}:{}),...(agent?{agent}:{}),...(interaction?{interaction}:{}),...(evidencePayload&&require('./evidence-delivery.cjs').collectionAllowed({ready,policy,type:evidenceType})?{protectedEvidence:{deviceId:device.id,type:evidenceType,payload:require('./evidence-delivery.cjs').validatedEvidence(evidencePayload,evidenceType)}}:{}),...diagnosticCorrelation(),policyVersion:Number.isSafeInteger(origin?.policyVersion)&&origin.policyVersion>0?origin.policyVersion:policy.version});persistAudit();const eventId=auditQueue.at(-1).eventId;flushAudit();return eventId;} catch{quarantine('Security events cannot be persisted. Contact your administrator.');} } function auditAgent(event){ @@ -245,7 +245,7 @@ async function applyPolicyImpl(envelope){ app.configureHostResolver(resolverOptions(next.enterprise?.dns)); await browserSession.clearHostResolverCache();recentRequests.clear(); await managementSession.clearHostResolverCache(); - policy=next;enforceEvidencePolicy();device.minimumVersion=next.version;acceptBookmarks();configureDiagnostics(next.diagnostics); + credentialBroker?.revoke();policy=next;enforceEvidencePolicy();device.minimumVersion=next.version;acceptBookmarks();configureDiagnostics(next.diagnostics); await diagnosticSpan('browser.policy',{operation:'policy.page-configure'},async()=>{for(const tab of tabs.all()){await configureVisualPage(next.security.visualPhishing.enabled,tab);if(!next.browser.allowDevTools){tab.security.developerToolsGuard?.close();tab.view.webContents.closeDevTools();}}}); await diagnosticSpan('browser.policy',{operation:'policy.agent-reconcile'},()=>reconcileAgentPolicy()); await diagnosticSpan('browser.policy',{operation:'policy.audit'},()=>audit('policy.applied')); @@ -419,7 +419,7 @@ async function ensureAgentAPI(){ Object.assign(actions,agentSessionControls()); for(const action of ['click','type','select','check']){const original=actions[action];actions[action]=async(...args)=>{const tab=tabs.get(args[0]?.sessionId||activeTab().id),context=tabContext(tab.id);const checked=await checkVisual(tab);if(!currentTabContext(context)||checked.decision!=='allow')throw Error('Visual login protection has not approved this page');return original(...args);};} for(const action of Object.keys(actions)){const original=actions[action];actions[action]=(args,context={})=>agentActorStorage.run({...context,action},()=>diagnosticSpan('browser.agent',{operation:action},async()=>{ - if(context.principalId&&['navigate','back','forward','reload','history:go','vault:fill','click','type','select','check'].includes(action)){const tab=tabs.get(args.sessionId||activeTab().id);tab.security.agentAuthority={principalId:context.principalId,generation:context.generation,action};} + if(context.principalId&&['navigate','back','forward','reload','history:go','vault:fill','vault:prepare-submit','click','type','select','check'].includes(action)){const tab=tabs.get(args.sessionId||activeTab().id);tab.security.agentAuthority={principalId:context.principalId,generation:context.generation,action};} return original(args,context); }));} agentServiceMode=policy.agents.identityMode;agentService=await startAgentControl({actions,principalMode:agentServiceMode,principals:agentServiceMode==='individual'?effectiveAgentPolicy().agents.grants:[], @@ -485,9 +485,12 @@ function hooks(){ return diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanEnterprise(bytes,p,'upload')); })}); for(const event of ['onCompleted','onErrorOccurred'])browserSession.webRequest[event]({urls:['']},details=>{ + credentialBroker?.complete(details.id);const record=credentialRequestCorrelations.get(details.id);credentialRequestCorrelations.delete(details.id); + if(record?.released&&record.deviceId===device?.id)diagnostics.withCorrelation(record.ids,()=>diagnosticSpan('browser.credential',{operation:event==='onCompleted'?'credential.complete':'credential.failure',policyVersion:record.policyVersion,sessionId:record.sessionId},()=>audit(event==='onCompleted'?'vault.submission-completed':'vault.submission.failure',undefined,undefined,undefined,undefined,undefined,undefined,undefined,record))).catch(()=>{}); if(!fileUploadGate.has(details.id))return; diagnosticSpan('browser.upload.release',{},()=>fileUploadGate.terminal(details.id)).catch(()=>{uploadLeaseFatal=true;quarantine('Upload protection cleanup failed. Restart the browser.');diagnosticSpan('browser.upload.fault',{decision:'block',reason:'upload-lease-release-failed'},()=>audit('upload.block','upload-lease-release-failed')).catch(()=>{});}); }); + browserSession.webRequest.onBeforeRedirect({urls:['']},details=>credentialBroker?.redirect(details.id)); const download=createDownloadGate({getContext:id=>{const tab=tabs.fromContents(id);return tab?{ready,policy,webContentsId:id,token:tabContext(tab.id,{foreground:false})}:null;},isCurrent:context=>!!context&¤tTabContext(context.token)&&policy===context.policy, fetchResponse:(url,options)=>diagnosticSpan('browser.request',{operation:'download'},()=>browserSession.fetch(url,options)),scan:(bytes,p)=>diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanDownloadEnterprise(bytes,p,localInspector?{inspect:(data,pol)=>diagnosticSpan('browser.inspect.local',{bytes:data.length},()=>localInspector.inspect(data,pol)),scanDownload:(data,pol)=>diagnosticSpan('browser.yara',{operation:'scan',bytes:data.length},()=>localInspector.scanDownload(data,pol))}:null)), choosePath:async name=>{const chosen=await dialog.showSaveDialog(win,{title:'Save scanned download (choose a new file)',defaultPath:path.join(app.getPath('downloads'),name)});return chosen.canceled?null:chosen.filePath;}, @@ -510,10 +513,11 @@ function hooks(){ const initialPolicy=policy,source=details.webContentsId>0?tabs.fromContents(details.webContentsId):null,sourceContext=source?tabContext(source.id,{foreground:false}):null; const sourceCurrent=()=>require('./request-source.cjs').requestSourceCurrent({details,source,token:sourceContext,registry:tabs,currentDocument:currentTabContext});let timer; // Async gates complete before Chromium may transmit the request. - diagnosticSpan('browser.request',{requestType:details.resourceType,...(source?{sessionId:source.id}:{})},async()=>{try{ + requestDiagnostic(details,{requestType:details.resourceType,...(source?{sessionId:source.id}:{})},async()=>{try{ timer=setTimeout(()=>{if(called)return;finish(true);audit('navigation.block','request-inspection-timeout',new URL(details.url).hostname).catch(()=>{});},40000); if(!ready||!policy||Date.parse(policy.expiresAt)<=Date.now()||!agentRequestOwnerAllowed(source)||(details.webContentsId>0&&(!source||!source.guarded))){if(ready&&policy&&(!agentRequestOwnerAllowed(source)||details.webContentsId>0&&(!source||!source.guarded)))await audit('navigation.block','invalid-source-owner',new URL(details.url).hostname);finish(true);return;} if(!core.destinationAllowed(details.url,policy)||(source?.security.agentAuthority&&!actorDestinationAllowed(details.url,source.security.agentAuthority))){audit('navigation.block',null,new URL(details.url).hostname);finish(true);return;} + if(credentialBroker&&source){try{credentialBroker.guardIssued(details,credentialContext(source));credentialBroker.validateRedirect(details,credentialContext(source));}catch{await audit('vault.submission-denied');message='Login submission blocked by company policy. Contact your administrator.';emit();finish(true);return;}} recentRequests.set(`${details.webContentsId}:${details.url}`,{method:details.method,time:Date.now()});if(recentRequests.size>2000)for(const [url,value]of recentRequests)if(value.time2000)recentRequests.delete(url); try{await dnsGate(details.url,initialPolicy);}catch(error){audit('dns.block',error.message,new URL(details.url).hostname);finish(true);return;} if(called||!ready||policy!==initialPolicy||!sourceCurrent()){if(!called&&ready&&policy===initialPolicy&&!sourceCurrent())await audit('navigation.block','request-source-context-changed',new URL(details.url).hostname);finish(true);return;} @@ -544,17 +548,21 @@ function hooks(){ const requestFacts=require('../../packages/core/request-transfer-facts.cjs').requestFactsForTransfer(transfer); const releaseUpload=async()=>{if(called)return;await audit('upload.authorized',null,new URL(details.url).hostname,undefined,requestFacts);finish(!ready||policy!==initialPolicy||!sourceCurrent());}; if(transfer.decision!=='allow'){await audit('upload.block',transfer.ruleId,new URL(details.url).hostname,undefined,requestFacts);requestBytes.fill(0);finish(true);return;} - const verdict=core.inspect(requestBytes,policy);requestBytes.fill(0); - if(verdict.decision==='block'&&verdict.ruleId!=='uninspectable'){audit('dlp.block',verdict.ruleId,new URL(details.url).hostname);finish(true);return;} + let claim; + try{claim=credentialBroker&&source?await diagnosticSpan('browser.credential',{operation:'credential.claim'},()=>credentialBroker.claim(details,requestBytes,credentialContext(source))):null;if(claim){for(const [id,row]of credentialRequestCorrelations)if(!row.released&&row.expires<=Date.now())credentialRequestCorrelations.delete(id);if(credentialRequestCorrelations.size>=256){credentialBroker.cancel(credentialContext(source));throw Error('Controlled login audit capacity reached');}credentialRequestCorrelations.set(details.id,{ids:diagnosticCorrelation(),policyVersion:initialPolicy.version,deviceId:device.id,sessionId:source.id,expires:Date.now()+60000,released:false});}}catch{requestBytes.fill(0);claim?.residual.fill(0);await audit('vault.submission-denied');message='Login submission blocked by company policy. Contact your administrator.';emit();finish(true);return;} + try{const scanBytes=claim?claim.residual:requestBytes; + const verdict=core.inspect(scanBytes,policy);requestBytes.fill(0); + if(verdict.decision==='block'&&verdict.ruleId!=='uninspectable'){claim?.residual.fill(0);const reference=await audit('dlp.block',verdict.ruleId,new URL(details.url).hostname);if(claim)await audit('vault.submission-denied');if(details.resourceType==='mainFrame'&&details.method==='POST'){message='Submission blocked by company data policy.'+(reference?' Event: '+reference:'');emit();}finish(true);return;} if(verdict.ruleId==='uninspectable'||policy.dlp.semantic.enabled||policy.security.yara.enabled||icapRequired(policy,'upload')){ const needsLocal=verdict.ruleId==='uninspectable'||policy.dlp.semantic.enabled||policy.security.yara.enabled; - if(needsLocal&&!localInspector){audit('dlp.block','inspector-unavailable',new URL(details.url).hostname);finish(true);return;} - const snapshot=Buffer.concat(uploads.map(p=>Buffer.from(p.bytes))),currentPolicy=policy; + if(needsLocal&&!localInspector){claim?.residual.fill(0);audit('dlp.block','inspector-unavailable',new URL(details.url).hostname);finish(true);return;} + const snapshot=claim?Buffer.from(claim.residual):Buffer.concat(uploads.map(p=>Buffer.from(p.bytes))),currentPolicy=policy;claim?.residual.fill(0); try{let result=needsLocal?await diagnosticSpan('browser.inspect.local',{bytes:snapshot.length},()=>localInspector.inspect(snapshot,currentPolicy)):{decision:'allow'};if(result.decision==='allow')result=await diagnosticSpan('browser.inspect.enterprise',{bytes:snapshot.length},()=>scanEnterprise(snapshot,currentPolicy,'upload')); const cancel=called||!ready||policy.version!==currentPolicy.version||!sourceCurrent()||result.decision!=='allow'; if(cancel){audit('dlp.block',result.ruleId||'policy-changed',new URL(details.url).hostname);finish(true);}else await releaseUpload(); }catch{audit('dlp.block','inspection-incomplete',new URL(details.url).hostname);finish(true);}finally{snapshot.fill(0);}return; - }else{await releaseUpload();return;} + }else{claim?.residual.fill(0);await releaseUpload();return;} + }finally{requestBytes.fill(0);claim?.residual.fill(0);} } finish(false); }catch{finish(true);}finally{clearTimeout(timer);}}).catch(()=>finish(true)); @@ -566,10 +574,12 @@ function hooks(){ const sourceCurrent=()=>require('./request-source.cjs').requestSourceCurrent({details,source,token:sourceContext,registry:tabs,currentDocument:currentTabContext}); const current=()=>!called&&ready&&policy===initialPolicy&&Date.parse(initialPolicy?.expiresAt)>Date.now()&&sourceCurrent(); let timer; - diagnosticSpan('browser.request',{requestType:details.resourceType,...(source?{sessionId:source.id}:{})},async()=>{ + requestDiagnostic(details,{requestType:details.resourceType,...(source?{sessionId:source.id}:{})},async()=>{ timer=setTimeout(()=>{if(called)return;finish(true);audit('dlp.block','headers-inspection-timeout',new URL(details.url).hostname).catch(()=>{});},40000); try{ if(!current()||!agentRequestOwnerAllowed(source)||(details.webContentsId>0&&(!source||!source.guarded))||!core.destinationAllowed(details.url,initialPolicy)||(source?.security.agentAuthority&&!actorDestinationAllowed(details.url,source.security.agentAuthority))){if(ready&&policy===initialPolicy&&(!agentRequestOwnerAllowed(source)||details.webContentsId>0&&(!source||!source.guarded)))await audit('navigation.block','invalid-source-owner',new URL(details.url).hostname);finish(true);return;} + const controlled=credentialBroker?.has(details.id)===true; + if(credentialBroker&&source)await diagnosticSpan('browser.credential',{operation:controlled?'credential.headers':'credential.redirect'},()=>{if(controlled)return credentialBroker.validateHeaders(details,credentialContext(source));credentialBroker.guardIssued(details,credentialContext(source));return credentialBroker.validateRedirect(details,credentialContext(source));}); const {snapshotHeaders,inspectHeaders}=require('./header-inspection.cjs'); let snapshot;try{snapshot=snapshotHeaders(details.requestHeaders,initialPolicy);}catch(error){await audit('dlp.block',error.message==='headers-size-limit'?'headers-size-limit':'headers-uninspectable',new URL(details.url).hostname);finish(true);return;} const verdict=await diagnosticSpan('browser.request.headers',{bytes:snapshot.bytes,count:snapshot.count,requestType:details.resourceType},()=>inspectHeaders(snapshot,{policy:initialPolicy,inspect:core.inspect,isCurrent:current, @@ -577,6 +587,7 @@ function hooks(){ inspectEnterprise:(bytes,p)=>diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanEnterprise(bytes,p,'upload'))})); if(!current()){if(ready&&policy===initialPolicy&&!sourceCurrent())await audit('navigation.block','header-source-context-changed',new URL(details.url).hostname);finish(true);return;} if(verdict.decision!=='allow'){await audit('dlp.block',verdict.ruleId||'headers-inspection-incomplete',new URL(details.url).hostname);finish(true);return;} + if(controlled){await audit('vault.submission-authorized');if(!current())throw Error('Controlled login state changed');diagnostics.spanSync('browser.credential',diagnosticContext({operation:'credential.authorize'}),()=>{if(!current())throw Error('Controlled login state changed');credentialBroker.finalize(details.id,credentialContext(source),details);finish(false,snapshot.headers);const record=credentialRequestCorrelations.get(details.id);if(record)record.released=true;});return;} // Chromium receives the exact immutable snapshot that was approved. finish(false,snapshot.headers); }catch{if(current())await audit('dlp.block','headers-inspection-incomplete',new URL(details.url).hostname);finish(true);} @@ -647,6 +658,35 @@ function updateManagedAppearance(input){requireManagedSection('appearance');appe async function closeNativeTab(id){tabs.remove(id);if(!tabs.all().length)await createNativeTab();else selectTab(tabs.active().id);emit();return state();} function groupNativeTab(input){requireManagedSection('library');if(libraryOrganizer?.isBusy())throw Error('Library organization is running');if(!input||typeof input.group!=='string'||input.group.length>40)throw Error('Invalid native tab group');tabs.get(input.id).group=input.group;emit();return state();} let sessionSnapshotStore; +let credentialBroker; +const credentialRequestCorrelations=new Map(); +function requestDiagnostic(details,attributes,fn){const record=credentialRequestCorrelations.get(details.id);return record?diagnostics.withCorrelation(record.ids,()=>diagnosticSpan('browser.request',{...attributes,policyVersion:record.policyVersion},fn)):diagnosticSpan('browser.request',attributes,fn);} +function credentialContext(tab){ + const contents=tab.contents,authority=tab.security.agentAuthority,actor=authority?.principalId?{agentId:authority.principalId,generation:authority.generation}:tab.security.credentialActor||{agentId:'human-ui',generation:0}; + return {ready,policy,token:tabContext(tab.id),sessionId:tab.id,webContents:contents,mainFrame:contents.mainFrame,sourceURL:contents.getURL(),actor,vault}; +} +function credentialAuthorityCurrent(context,{phase},details){ + try{ + const tab=tabs.get(context.sessionId),contents=tab.contents,live=tabContext(tab.id),settings=managedSettings(); + if(!ready||policy!==context.policy||Date.parse(policy.expiresAt)<=Date.now()||!policy.passwordManager.enabled||!policy.passwordManager.allowAutofill||!policy.passwordManager.controlledSubmission.enabled||!featureAllowed(settings,'passwords')||tab!==activeTab()||contents!==context.webContents||contents.isDestroyed()||contents.mainFrame!==context.mainFrame||contents.mainFrame.processId!==context.frameProcessId||contents.mainFrame.routingId!==context.frameRoutingId||contents.getURL()!==context.sourceURL||live.activation!==context.token.activation||live.paused||vault!==context.vault||vault?.status().locked)return false; + if(JSON.stringify(credentialContext(tab).actor)!==JSON.stringify(context.actor))return false; + if(context.actor.agentId!=='human-ui'&&!actorGrantAllowed(context.authority,'vault:prepare-submit',details?.url||context.sourceURL))return false; + if(phase==='issued')return currentTabContext(context.token); + if(phase==='watch')return tab.security.interactionDocumentGeneration===context.documentGeneration||tab.security.interactionDocumentGeneration===context.documentGeneration+1; + return phase==='redirect'?tab.security.interactionDocumentGeneration>=context.documentGeneration+1:tab.security.interactionDocumentGeneration===context.documentGeneration+1; + }catch{return false;} +} +function getCredentialBroker(){return credentialBroker??=require('./credential-submission.cjs').createCredentialSubmissionBroker({isCurrent:credentialAuthorityCurrent,traceOperation:(operation,fn)=>diagnostics.spanSync('browser.credential',diagnosticContext({operation}),fn)});} +async function armCredentialSubmission({context,formId,credential,vault:current,vaultGeneration}){ + const tab=tabs.get(context.sessionId||context.token.tabId),actor=context.actor||{agentId:'human-ui',generation:0};tab.security.credentialActor=Object.freeze({...actor}); + const snapshot={...credentialContext(tab),documentGeneration:tab.security.interactionDocumentGeneration,frameProcessId:tab.contents.mainFrame.processId,frameRoutingId:tab.contents.mainFrame.routingId,vault:current,authority:actor.agentId==='human-ui'?undefined:actor.agentId==='legacy-paired-agent'?{action:'vault:prepare-submit'}:{principalId:actor.agentId,generation:actor.generation,action:'vault:prepare-submit'}}; + const result=await diagnosticSpan('browser.credential',{operation:'credential.issue',sessionId:tab.id},()=>getCredentialBroker().issue({context:snapshot,formId,username:credential.username,password:credential.password,vault:current,vaultGeneration})); + await audit('vault.submission-prepared');if(!credentialAuthorityCurrent(snapshot,{phase:'issued'})){getCredentialBroker().cancel(snapshot);throw Error('Controlled login state changed');}return {...result,cancel:()=>getCredentialBroker().cancel(snapshot)}; +} +async function handoffVault(){ + const token=require('node:crypto').randomUUID(); + await new Promise((resolve,reject)=>{const handler=(event,value)=>{if(win&&!win.isDestroyed()&&event.sender===win.webContents&&event.senderFrame===win.webContents.mainFrame&&event.senderFrame?.url===uiUrl&&value===token){clearTimeout(timer);ipcMain.removeListener('managed:vault-handoff-ack',handler);resolve();}};const timer=setTimeout(()=>{ipcMain.removeListener('managed:vault-handoff-ack',handler);reject(Error('Trusted vault interface unavailable'));},1500);ipcMain.on('managed:vault-handoff-ack',handler);win.webContents.send('managed:vault-handoff',token);}); +} function agentSessionControls(){ requireEncryption();sessionSnapshotStore??=require('./session-snapshot.cjs').createSessionSnapshotStore({filePath:path.join(app.getPath('userData'),'managed-sessions.bin'),protect:bytes=>safeStorage.encryptString(bytes.toString('base64')),unprotect:bytes=>Buffer.from(safeStorage.decryptString(bytes),'base64')}); const sessionContext=id=>{const context=agentContext(id),actor=agentActorStorage.getStore();return {...context,ready:context.ready&&actorGrantAllowed(actor,actor?.action),actor:context.actor||Object.freeze({agentId:'legacy-paired-agent',generation:1})};}; @@ -663,6 +703,7 @@ function agentAppControls(){ const vaultControls=require('./vault-agent-controls.cjs').createVaultAgentControls({ getContext:id=>{const context=agentContext(id);return {...context,ready:context.ready&&featureAllowed(managedSettings(),'passwords'),actor:context.actor||Object.freeze({agentId:'legacy-paired-agent',generation:1})};}, contextCurrent:currentTabContext,getVault:requireVault,checkVisual:context=>checkVisual(tabs.get(context.sessionId)),inspect:inspectManagedAgentText, + beforeFill:handoffVault,afterFill:()=>{vaultPanel=false;emit();},prepareSubmission:armCredentialSubmission, audit:event=>audit(event.action),traceOperation:(operation,fn)=>diagnosticSpan('browser.vault',{operation},fn) }); const checked=(operation)=>async(args,{signal,policyVersion}={})=>{ @@ -673,6 +714,7 @@ function agentAppControls(){ 'vault:status':checked((args,options)=>vaultControls.status(args,options)), 'vault:list':checked((args,options)=>vaultControls.list(args,options)), 'vault:fill':checked((args,options)=>vaultControls.fill(args,options)), + 'vault:prepare-submit':checked((args,options)=>vaultControls.prepare(args,options)), 'window:state':checked((args,options)=>windowControls.state(args,options)), 'window:control':checked((args,options)=>windowControls.control(args,options)), 'application:state':checked(()=>({ready,policyVersion:policy.version,activeTabId:activeTab()?.id,tabCount:tabs.all().length})), @@ -798,25 +840,32 @@ app.whenReady().then(async()=>{ ipc('managed:vault-save',async entry=>{if(!vaultSettings().allowSaving)throw Error('Saving passwords is disabled by company policy');const result=requireVault().upsert(entry);await audit('vault.saved');emit();return result;}); ipc('managed:vault-delete',async id=>{if(!vaultSettings().allowSaving)throw Error('Password changes are disabled by company policy');const result=requireVault().delete(id);await audit('vault.deleted');return result;}); ipc('managed:vault-generate',()=>{const current=requireVault();if(!vaultSettings().allowSaving||current.status().locked)throw Error('Unlock the vault before generating a password');return current.generatePassword();}); - ipc('managed:vault-fill',async id=>{ + const fillManagedVault=async(id,controlled=false)=>{ const context=tabContext(),tab=tabs.get(context.tabId),contents=context.webContents; if(startPage||overviewPanel||toolsOpen)throw Error('Show the saved login website before filling credentials'); if((await checkVisual(tab)).decision!=='allow'||!currentTabContext(context))throw Error('Visual login protection has not approved this page'); if(startPage||overviewPanel||toolsOpen)throw Error('The login website is no longer visible'); const current=requireVault(),initial=policy,url=context.url; if(!initial.passwordManager.allowAutofill||!core.destinationAllowed(url,initial)||new URL(url).protocol!=='https:')throw Error('Password filling requires an approved HTTPS destination'); + if(controlled&&!initial.passwordManager.controlledSubmission.enabled)throw Error('Controlled login submission is disabled by company policy'); let credential; try{ const origin=new URL(url).origin;credential={...current.credential(id,origin),origin}; await audit('vault.fill-requested'); if(!currentTabContext(context)||startPage||overviewPanel||toolsOpen||policy!==initial||Date.parse(initial.expiresAt)<=Date.now()||current.status().locked)throw Error('Browser state changed'); + await handoffVault();if(!currentTabContext(context)||policy!==initial||current.status().locked)throw Error('Browser state changed');tab.security.agentAuthority=null;tab.security.credentialActor=Object.freeze({agentId:'human-ui',generation:0}); contents.focus();if(!currentTabContext(context))throw Error('Browser state changed'); - const result=await diagnosticSpan('browser.password.fill',{sessionId:context.tabId},()=>contents.executeJavaScriptInIsolatedWorld(999,[{code:fillScript(url,credential,Date.parse(initial.expiresAt))}],false)); + const code=controlled?require('./password-fill.cjs').prepareFillScript(url,credential,initial.passwordManager.controlledSubmission.forms,Date.parse(initial.expiresAt)):fillScript(url,credential,Date.parse(initial.expiresAt)); + const result=await diagnosticSpan('browser.password.fill',{sessionId:context.tabId},()=>contents.executeJavaScriptInIsolatedWorld(999,[{code}],false)); if(!currentTabContext(context)||startPage||overviewPanel||toolsOpen||policy!==initial||current.status().locked||result?.completed!==true)throw Error('Browser state changed'); - await audit('vault.filled');return {completed:true}; - }catch{throw Error('Cannot fill this login. Unlock the vault and use its exact HTTPS origin with a single visible same-origin POST login form.');} + await audit('vault.filled');if(!currentTabContext(context)||policy!==initial||current.status().locked)throw Error('Browser state changed'); + if(controlled)await armCredentialSubmission({context:{sessionId:tab.id,token:context},formId:result.formId,credential,vault:current,vaultGeneration:current.accessGeneration()}); + vaultPanel=false;emit();return {completed:true,...(controlled?{prepared:true}:{})}; + }catch{credentialBroker?.revoke();vaultPanel=false;emit();throw Error('Cannot fill this login. Unlock the vault and use its exact approved HTTPS origin with a single visible same-origin POST login form.');} finally{if(credential){credential.username='';credential.password='';}} - }); + }; + ipc('managed:vault-fill',id=>fillManagedVault(id)); + ipc('managed:vault-prepare-submit',id=>fillManagedVault(id,true)); ipc('managed:ai',managedAI); ipc('managed:agent-connection',async principalId=>{ requireFeatureSection('agents'); @@ -849,7 +898,7 @@ app.whenReady().then(async()=>{ return {name:path.basename(file),...result}; }); win.on('close',()=>tabs.close()); - win.on('resize',layout);win.on('maximize',emit);win.on('unmaximize',emit);win.on('blur',lockVault);win.on('minimize',lockVault);win.on('closed',()=>{vault?.lock();clearInterval(vaultTimer);win=null;clearInterval(syncTimer);localInspector?.stop();agentService?.close();}); + win.on('resize',layout);win.on('maximize',emit);win.on('unmaximize',emit);win.on('blur',lockVault);win.on('minimize',lockVault);win.on('closed',()=>{credentialBroker?.close();vault?.lock();clearInterval(vaultTimer);win=null;clearInterval(syncTimer);localInspector?.stop();agentService?.close();}); loadAppearance(); await win.loadFile(path.join(__dirname,'index.html'));layout(); try{device=loadDevice();const queuePath=path.join(app.getPath('userData'),'audit-queue.bin');if(device&&fs.existsSync(queuePath)){auditQueue=require('../../packages/core/request-transfer-facts.cjs').migrateQueuedRequestFacts(JSON.parse(safeStorage.decryptString(fs.readFileSync(queuePath))));persistAudit();}if(device){await sync();await audit('browser.started');}}catch(e){quarantine(`Enrollment could not load: ${e.message}`);} diff --git a/apps/browser/password-fill.cjs b/apps/browser/password-fill.cjs index a1f8307..775ff94 100644 --- a/apps/browser/password-fill.cjs +++ b/apps/browser/password-fill.cjs @@ -1,12 +1,12 @@ 'use strict'; // Runs only in the isolated main-frame world. No arbitrary selectors or scripts from callers. -function fillDocument(expectedUrl,origin,username,password,expiresAt){ +function fillDocument(expectedUrl,origin,username,password,expiresAt,approvedForms){ if(Date.now()>=expiresAt||window.top!==window||!window.isSecureContext||location.href!==expectedUrl||location.origin!==origin)throw Error('Login destination changed'); const visible=element=>{ - const style=getComputedStyle(element); - return !element.disabled&&!element.readOnly&&!element.closest('[hidden],[inert],[aria-hidden="true"]')&&style.display!=='none'&&style.visibility!=='hidden'&&Number(style.opacity)!==0&&element.getClientRects().length>0; + if(element.disabled||element.readOnly||element.closest('[hidden],[inert],[aria-hidden="true"],[aria-disabled="true"],fieldset[disabled]')||element.getClientRects().length===0)return false; + for(let node=element;node;node=node.parentElement){const style=getComputedStyle(node);if(style.display==='none'||style.visibility==='hidden'||Number(style.opacity)===0)return false;}return true; }; - const passwords=[...document.querySelectorAll('input[type="password"]')].filter(element=>visible(element)&&element.autocomplete!=='new-password'); + const passwords=[...document.querySelectorAll('input[type="password"]')].filter(element=>visible(element)&&!String(element.autocomplete||'').toLowerCase().split(/\s+/).includes('new-password')); if(passwords.length!==1)throw Error('A single visible login password field is required'); const target=passwords[0],form=target.form; if(!form||form.method.toLowerCase()!=='post'||!['','_self'].includes(form.target.toLowerCase()))throw Error('A same-window POST login form is required'); @@ -25,6 +25,14 @@ function fillDocument(expectedUrl,origin,username,password,expiresAt){ if(username&&candidates.length!==1)throw Error('A single login username field is required'); const user=username?candidates[0]:null; if(user&&user.form!==form)throw Error('Login fields belong to different forms'); + let approved; + if(approvedForms!==undefined){ + if(!Array.isArray(approvedForms)||approvedForms.length>32||!user||form.enctype.toLowerCase()!=='application/x-www-form-urlencoded')throw Error('Controlled login form is unsupported'); + const matches=approvedForms.filter(rule=>rule.actionUrl===action.href&&rule.usernameField===user.name&&rule.passwordField===target.name); + if(matches.length!==1||[...(form.elements||[])].filter(element=>element.name===user.name).length!==1||[...(form.elements||[])].filter(element=>element.name===target.name).length!==1)throw Error('Login form is not explicitly approved'); + approved=matches[0]; + for(const control of [...(form.elements||[])]){if(control.disabled||typeof control.getAttribute!=='function')continue;const destination=control.getAttribute('formaction'),encoding=control.getAttribute('formenctype');if(destination!==null&&new URL(destination||location.href,location.href).href!==action.href||encoding!==null&&encoding.toLowerCase()!=='application/x-www-form-urlencoded')throw Error('Controlled login submit differs');} + } const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set; // Recheck inside the target renderer, after queued native dispatch. A hidden // or blurred document must not receive credentials after a tab switch. @@ -33,7 +41,7 @@ function fillDocument(expectedUrl,origin,username,password,expiresAt){ if(user)setter.call(user,username);setter.call(target,password); if(user)user.dispatchEvent(new Event('input',{bubbles:true})); target.dispatchEvent(new Event('input',{bubbles:true})); - return {completed:true}; + return {completed:true,...(approved?{formId:approved.id}:{})}; } function fillScript(expectedUrl,credential,expiresAt=Date.now()+10000){ const url=new URL(expectedUrl); @@ -42,3 +50,10 @@ function fillScript(expectedUrl,credential,expiresAt=Date.now()+10000){ return `(${fillDocument.toString()})(${JSON.stringify(url.href)},${JSON.stringify(credential.origin)},${JSON.stringify(credential.username)},${JSON.stringify(credential.password)},${JSON.stringify(expiresAt)})`; } module.exports={fillDocument,fillScript}; +function prepareFillScript(expectedUrl,credential,forms,expiresAt=Date.now()+10000){ + const url=new URL(expectedUrl);if(url.protocol!=='https:'||url.username||url.password||url.origin!==credential.origin||!Array.isArray(forms)||!Number.isFinite(expiresAt)||expiresAt<=Date.now())throw Error('Approved controlled login required'); + const rules=forms.filter(row=>new URL(row.actionUrl).origin===credential.origin).map(({id,actionUrl,usernameField,passwordField})=>({id,actionUrl,usernameField,passwordField})); + if(!rules.length)throw Error('No approved login endpoint'); + return `(${fillDocument.toString()})(${JSON.stringify(url.href)},${JSON.stringify(credential.origin)},${JSON.stringify(credential.username)},${JSON.stringify(credential.password)},${JSON.stringify(expiresAt)},${JSON.stringify(rules)})`; +} +module.exports.prepareFillScript=prepareFillScript; diff --git a/apps/browser/preload.cjs b/apps/browser/preload.cjs index 360689f..05c019c 100644 --- a/apps/browser/preload.cjs +++ b/apps/browser/preload.cjs @@ -30,6 +30,8 @@ contextBridge.exposeInMainWorld('safetynet', { vaultSave:entry=>ipcRenderer.invoke('managed:vault-save',entry), vaultDelete:id=>ipcRenderer.invoke('managed:vault-delete',id), vaultFill:id=>ipcRenderer.invoke('managed:vault-fill',id), + vaultPrepareSubmit:id=>ipcRenderer.invoke('managed:vault-prepare-submit',id), vaultGenerate:()=>ipcRenderer.invoke('managed:vault-generate'), + onVaultHandoff:callback=>{ipcRenderer.on('managed:vault-handoff',(_event,token)=>{callback();ipcRenderer.send('managed:vault-handoff-ack',token);});}, onState:callback=>{const listener=(_event,data)=>callback(data);ipcRenderer.on('managed:state',listener);return()=>ipcRenderer.removeListener('managed:state',listener);} }); diff --git a/apps/browser/renderer.js b/apps/browser/renderer.js index 92cbf1a..4cdeafd 100644 --- a/apps/browser/renderer.js +++ b/apps/browser/renderer.js @@ -1,4 +1,5 @@ const $=id=>document.getElementById(id);let panel=false,vaultPanel=false,lastState,vaultState,vaultBusy=false,vaultRevision=0; +window.safetynet.onVaultHandoff(()=>{vaultRevision++;vaultPanel=false;$('vault').hidden=true;$('passwords').setAttribute('aria-expanded','false');clearVaultFields();$('vault-list').replaceChildren();}); let sidebarOpen=false,startPageOpen=false,overviewOpen=false,tabs=[],activeTab=null,groupFilter='',navigationRevision=0,pendingNavigation=null,lastLayoutKey='',lastTabsKey='',lastLibraryKey='',nativeTabsRevision=-1; const preference=(key,fallback)=>{try{return JSON.parse(localStorage.getItem(`sbrowser.${key}`))??fallback;}catch{return fallback;}}; const remember=(key,value)=>{try{localStorage.setItem(`sbrowser.${key}`,JSON.stringify(value));}catch{}}; @@ -213,7 +214,7 @@ async function refreshVault(){ if(!status.enabled||status.locked||!status.exists){if(!status.enabled||previous?.locked===false)clearVaultFields();$('vault-list').replaceChildren();return;} const entries=await call('vaultList');if(!vaultPanel||revision!==vaultRevision)return;$('vault-list').replaceChildren(); if(!entries.length){const empty=document.createElement('div');empty.className='empty-vault';empty.textContent='No saved logins yet.';$('vault-list').append(empty);} - for(const item of entries){const card=document.createElement('div');card.className='login-card';const label=document.createElement('strong');label.textContent=item.label||item.origin;const origin=document.createElement('p');origin.textContent=item.origin;const username=document.createElement('p');username.textContent=item.username||'No username';const buttons=document.createElement('div');buttons.className='login-buttons';const fill=document.createElement('button');fill.textContent='Fill login';fill.disabled=!status.allowAutofill;fill.onclick=vaultTask(async()=>{await call('vaultFill',item.id);$('vault-error').textContent='Login filled in the current page.';});const remove=document.createElement('button');remove.className='secondary';remove.textContent='Delete';remove.disabled=!status.allowSaving;remove.onclick=vaultTask(async()=>{await call('vaultDelete',item.id);await refreshVault();});buttons.append(fill,remove);card.append(label,origin,username,buttons);$('vault-list').append(card);} + for(const item of entries){const card=document.createElement('div');card.className='login-card';const label=document.createElement('strong');label.textContent=item.label||item.origin;const origin=document.createElement('p');origin.textContent=item.origin;const username=document.createElement('p');username.textContent=item.username||'No username';const buttons=document.createElement('div');buttons.className='login-buttons';const fill=document.createElement('button');fill.textContent='Fill login';fill.disabled=!status.allowAutofill;fill.onclick=vaultTask(async()=>{await call('vaultFill',item.id);$('vault-error').textContent='Login filled. Submission remains subject to company DLP policy.';});const remove=document.createElement('button');remove.className='secondary';remove.textContent='Delete';remove.disabled=!status.allowSaving;remove.onclick=vaultTask(async()=>{await call('vaultDelete',item.id);await refreshVault();});const prepare=document.createElement('button');prepare.textContent='Prepare approved login';prepare.title='Fill and permit one approved login POST for up to 15 seconds. Submit on the website; other fields still pass company DLP.';prepare.hidden=!status.allowControlledSubmission;prepare.disabled=!status.allowAutofill;prepare.onclick=vaultTask(async()=>{await call('vaultPrepareSubmit',item.id);});buttons.append(fill,prepare,remove);card.append(label,origin,username,buttons);$('vault-list').append(card);} } $('passwords').onclick=vaultTask(async()=>{requireSection('passwords',true);if(vaultPanel){await closeVault();return;}await closeAI();vaultPanel=true;$('vault').hidden=false;$('passwords').setAttribute('aria-expanded','true');await call('toggleVault',true);await refreshVault();}); $('vault-close').onclick=vaultTask(closeVault); diff --git a/apps/browser/vault-agent-controls.cjs b/apps/browser/vault-agent-controls.cjs index c2db7a2..b5e839c 100644 --- a/apps/browser/vault-agent-controls.cjs +++ b/apps/browser/vault-agent-controls.cjs @@ -1,10 +1,10 @@ 'use strict'; const {randomUUID}=require('node:crypto'); -const {fillScript}=require('./password-fill.cjs'); +const {fillScript,prepareFillScript}=require('./password-fill.cjs'); const refused=()=>{throw Error('Managed vault operation refused');}; const idPattern=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const hostMatch=(name,pattern)=>pattern.startsWith('*.')?name.endsWith(pattern.slice(1))&&name!==pattern.slice(2):name===pattern; -function createVaultAgentControls({getContext,contextCurrent,getVault,checkVisual,inspect,audit,traceOperation=async(_name,fn)=>fn(),now=Date.now}={}) { +function createVaultAgentControls({getContext,contextCurrent,getVault,checkVisual,inspect,audit,beforeFill=async()=>{},afterFill=()=>{},prepareSubmission,traceOperation=async(_name,fn)=>fn(),now=Date.now}={}) { if([getContext,contextCurrent,getVault,checkVisual,inspect,audit,traceOperation,now].some(fn=>typeof fn!=='function'))throw TypeError('Vault boundary callbacks required'); const references=new Map(); const allowed=(origin,c)=>{ @@ -13,7 +13,7 @@ function createVaultAgentControls({getContext,contextCurrent,getVault,checkVisua return u.protocol==='https:'&&!u.username&&!u.password&&u.origin===origin&&Array.isArray(hosts)&&(!hosts.length||hosts.some(p=>typeof p==='string'&&hostMatch(u.hostname.toLowerCase(),p.toLowerCase())))&&c.destinationAllowed(origin,c.policy)===true; }; function snapshot(args,signal,action){ - const fields=action==='vault:fill'?['sessionId','credentialRef']:['sessionId']; + const fields=['vault:fill','vault:prepare-submit'].includes(action)?['sessionId','credentialRef']:['sessionId']; if(!args||typeof args!=='object'||Array.isArray(args)||Object.keys(args).some(key=>!fields.includes(key))||(args.sessionId!==undefined&&!idPattern.test(args.sessionId)))refused(); const c=getContext(args.sessionId); if(signal?.aborted||!c?.ready||!c.token||(!c.actor||typeof c.actor.agentId!=='string'||!c.actor.agentId||!Number.isSafeInteger(c.actor.generation))||!c.policy?.agents?.enabled||!c.policy.agents.scopes?.includes(action)||!c.policy.passwordManager?.enabled||!Number.isFinite(Date.parse(c.policy.expiresAt))||Date.parse(c.policy.expiresAt)<=now()||typeof c.destinationAllowed!=='function')refused(); @@ -45,10 +45,11 @@ function createVaultAgentControls({getContext,contextCurrent,getVault,checkVisua if(references.size+Math.min(safe.length,32)>32)refused(); return safe.slice(0,32).map(row=>{const credentialRef=randomUUID();references.set(credentialRef,{...row,sessionId:c.sessionId,webContents:c.webContents,actor:{...c.actor},generation:vault.accessGeneration(),policy:c.policy,token:c.token,vault,expires:now()+60000});return {credentialRef,origin:row.origin};}); }; - const fill=async(args={}, {signal}={})=>{ + const fill=async(args={}, {signal}={},action='vault:fill')=>{ let credential; try{ - const c=snapshot(args,signal,'vault:fill'),vault=unlocked(c),ref=references.get(args.credentialRef); + const c=snapshot(args,signal,action),vault=unlocked(c),ref=references.get(args.credentialRef); + const controlled=action==='vault:prepare-submit';if(controlled&&(!c.policy.passwordManager.controlledSubmission?.enabled||typeof prepareSubmission!=='function'))refused(); if(!idPattern.test(args.credentialRef||'')||!ref||ref.sessionId!==c.sessionId||ref.webContents!==c.webContents||ref.expires<=now()||(ref.actor.agentId!==c.actor.agentId||ref.actor.generation!==c.actor.generation)||ref.generation!==vault.accessGeneration()||ref.policy!==c.policy||ref.vault!==vault||!contextCurrent(ref.token)||c.foreground!==true||c.interactive===false||!c.policy.passwordManager.allowAutofill||!c.webContents||c.webContents.isDestroyed?.())refused(); const url=c.webContents.getURL(),u=new URL(url); if(u.username||u.password||u.protocol!=='https:'||u.origin!==ref.origin||!allowed(ref.origin,c))refused(); @@ -58,13 +59,16 @@ function createVaultAgentControls({getContext,contextCurrent,getVault,checkVisua // Consume once before the native effect: failures never authorize an automatic retry. references.delete(args.credentialRef); credential={...vault.credential(ref.id,ref.origin),origin:ref.origin};recheck(); - c.webContents.focus();recheck(); - const result=await traceOperation('vault.fill.native',()=>c.webContents.executeJavaScriptInIsolatedWorld(999,[{code:fillScript(url,credential,Date.parse(c.policy.expiresAt))}],false)); + await beforeFill(c);recheck();c.webContents.focus();recheck(); + const code=controlled?prepareFillScript(url,credential,c.policy.passwordManager.controlledSubmission.forms,Date.parse(c.policy.expiresAt)):fillScript(url,credential,Date.parse(c.policy.expiresAt)); + const result=await traceOperation('vault.fill.native',()=>c.webContents.executeJavaScriptInIsolatedWorld(999,[{code}],false)); recheck();if(result?.completed!==true)refused(); - await audit({action:'vault.filled',outcome:'completed',sessionId:c.sessionId});recheck();return {completed:true}; + await audit({action:'vault.filled',outcome:'completed',sessionId:c.sessionId});recheck(); + if(controlled){const resultLease=await prepareSubmission({context:c,formId:result.formId,credential,vault,vaultGeneration:ref.generation});try{recheck();if(resultLease?.prepared!==true)refused();afterFill();return {completed:true,prepared:true};}catch(error){resultLease?.cancel?.();throw error;}} + afterFill();return {completed:true}; }catch{refused();}finally{if(credential){credential.username='';credential.password='';credential.origin='';}} }; const safe=fn=>async(...args)=>{try{return await fn(...args);}catch{refused();}}; - return {status:safe(status),list:safe(list),fill:safe(fill),clear:()=>references.clear()}; + return {status:safe(status),list:safe(list),fill:safe(fill),prepare:safe((args,options)=>fill(args,options,'vault:prepare-submit')),clear:()=>references.clear()}; } module.exports={createVaultAgentControls}; diff --git a/apps/server/app.mjs b/apps/server/app.mjs index 3474639..44f4cfa 100644 --- a/apps/server/app.mjs +++ b/apps/server/app.mjs @@ -108,7 +108,7 @@ export function createApp(store,config) { 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(['browser.interaction','browser.interaction.dropped','dlp.block','navigation.block','download.block','policy.applied','browser.started','agent.action','agent.denied','library.ai.proposed','library.ai.applied','library.ai.reconcile','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(),request:requestTransferFacts.RequestFactsSchema.optional(),agent:agentAudit.AgentAuditSchema.optional(),interaction:interactionAudit.InteractionFactsSchema.optional()}).strict().parse(req.body); + const b=z.object({eventId:z.string().uuid(),kind:z.enum(['browser.interaction','browser.interaction.dropped','dlp.block','navigation.block','download.block','policy.applied','browser.started','agent.action','agent.denied','library.ai.proposed','library.ai.applied','library.ai.reconcile','devtools.open-requested','devtools.opened','devtools.closed','devtools.denied','download.scan','download.released','upload.authorized','upload.block','dns.block','content.block','vault.submission-completed','vault.submission.failure','vault.submission-prepared','vault.submission-authorized','vault.submission-denied','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(),request:requestTransferFacts.RequestFactsSchema.optional(),agent:agentAudit.AgentAuditSchema.optional(),interaction:interactionAudit.InteractionFactsSchema.optional()}).strict().parse(req.body); if(b.interaction&&!interactionAudit.interactionFactsForEvent(b.kind,b.interaction))return res.status(400).json({error:'Interaction facts require a matching lifecycle event'}); if(b.agent&&!agentAudit.agentFactsForEvent(b.kind,b.agent))return res.status(400).json({error:'Agent facts require a matching agent lifecycle event'}); if(b.file&&!b.kind.startsWith('download.')&&!b.kind.startsWith('upload.'))return res.status(400).json({error:'File facts require a transfer event'}); diff --git a/apps/server/public/admin.js b/apps/server/public/admin.js index 27bbb4e..a3d37b5 100644 --- a/apps/server/public/admin.js +++ b/apps/server/public/admin.js @@ -6,7 +6,7 @@ let credential='',policy,presetPreview,managedBookmarks=[],personalBookmarks=[], const managedSections=['appearance','library','ai','passwords','documentScan','agents']; -const agentScopes=['sessions','navigate','back','forward','reload','read','click','type','select','check','screenshot','ai','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply','inspect','scroll','history:list','history:go','window:state','window:control','vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore']; +const agentScopes=['sessions','navigate','back','forward','reload','read','click','type','select','check','screenshot','ai','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply','inspect','scroll','history:list','history:go','window:state','window:control','vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit']; function status(message,error=false){$('status').textContent=message;$('status').className=error?'error':'';} @@ -46,7 +46,7 @@ function fill(p){ $('ai-confidential-local').checked=p.ai.allowConfidentialLocal; - $('vault-enabled').checked=p.passwordManager.enabled;$('vault-saving').checked=p.passwordManager.allowSaving;$('vault-autofill').checked=p.passwordManager.allowAutofill;$('vault-idle').value=p.passwordManager.idleLockSeconds; + $('vault-enabled').checked=p.passwordManager.enabled;$('vault-saving').checked=p.passwordManager.allowSaving;$('vault-autofill').checked=p.passwordManager.allowAutofill;$('vault-idle').value=p.passwordManager.idleLockSeconds;$('vault-controlled-submit').checked=p.passwordManager.controlledSubmission?.enabled===true;$('vault-submit-forms').value=JSON.stringify(p.passwordManager.controlledSubmission?.forms||[],null,2); for(const [id,key] of [['clipboard-enabled','enabled'],['clipboard-copy','allowCopy'],['clipboard-cut','allowCut'],['clipboard-paste','allowPaste'],['clipboard-command','blockSuspiciousCommands']])$(id).checked=p.security.clipboard[key];$('clipboard-max').value=p.security.clipboard.maxTextBytes; @@ -70,7 +70,7 @@ function draft(){ p.enterprise={dns:{mode:$('enterprise-dns-mode').value,templates:lines('enterprise-dns-templates')},vpn:{required:$('enterprise-vpn-required').checked,interfaceNames:lines('enterprise-vpn-interfaces'),requiredDnsServers:lines('enterprise-vpn-dns')},icap:{enabled:$('enterprise-icap-enabled').checked,url:$('enterprise-icap-url').value.trim(),method:$('enterprise-icap-method').value,allow204:true,allowInsecureTransport:$('enterprise-icap-insecure').checked,scanUploads:$('enterprise-icap-uploads').checked,scanDownloads:$('enterprise-icap-downloads').checked,scanClipboard:$('enterprise-icap-clipboard').checked,scanAI:$('enterprise-icap-ai').checked,timeoutMs:Number($('enterprise-icap-timeout').value),maxBytes:Number($('enterprise-icap-max').value),failClosed:true}}; - p.passwordManager={enabled:$('vault-enabled').checked,allowSaving:$('vault-saving').checked,allowAutofill:$('vault-autofill').checked,idleLockSeconds:Number($('vault-idle').value)}; + const forms=JSON.parse($('vault-submit-forms').value||'[]');if(!Array.isArray(forms)||forms.length>32)throw Error('Use at most 32 approved login POST rules');p.passwordManager={...p.passwordManager,enabled:$('vault-enabled').checked,allowSaving:$('vault-saving').checked,allowAutofill:$('vault-autofill').checked,idleLockSeconds:Number($('vault-idle').value),controlledSubmission:{enabled:$('vault-controlled-submit').checked,forms}}; p.brand={...p.brand,name:$('brand-name').value,color:$('brand-color').value}; diff --git a/apps/server/public/agent-grants-admin.js b/apps/server/public/agent-grants-admin.js index 602545d..d1c69a9 100644 --- a/apps/server/public/agent-grants-admin.js +++ b/apps/server/public/agent-grants-admin.js @@ -13,7 +13,7 @@ function renderAgentGrants(){ const enabled=agentGrantField(grid,'Enabled');enabled.type='checkbox';enabled.checked=grant.enabled;enabled.disabled=!agentGrantsEditable;enabled.addEventListener('change',()=>{grant.enabled=enabled.checked;changedAgentGrants();}); const expiry=agentGrantField(grid,'Expiry (UTC)');expiry.type='text';expiry.value=grant.expiresAt;expiry.placeholder='2026-12-31T23:59:59Z';expiry.disabled=!agentGrantsEditable;expiry.addEventListener('input',()=>{grant.expiresAt=expiry.value.trim();changedAgentGrants();}); const hosts=agentGrantField(grid,'Additional destination allowlist','textarea');hosts.value=grant.allowedHosts.join('\n');hosts.placeholder='Empty adds no destination restriction';hosts.disabled=!agentGrantsEditable;hosts.addEventListener('input',()=>{grant.allowedHosts=hosts.value.split('\n').map(v=>v.trim()).filter(Boolean);changedAgentGrants();}); - const scopeBox=document.createElement('fieldset');const scopeLegend=document.createElement('legend');scopeLegend.textContent='Additional capability limit';scopeBox.append(scopeLegend);const scopeHelp=document.createElement('p');scopeHelp.className='muted';scopeHelp.textContent='Session saving, listing and restoration require separate capabilities. Saved URLs and tab group metadata never include passwords, form values or page state. Restoration rechecks current policy and does not restore revoked agent authority. History reads and navigation require separate capabilities. Window controls are limited to supported browser operations. Vault status, masked listing and exact-origin autofill require separate capabilities and human unlock. Agents cannot unlock the vault or export passwords. All granted capabilities remain restricted by the central baseline, destination rules and DLP.';scopeBox.append(scopeHelp);const scopes=document.createElement('div');scopes.className='checks';scopeBox.append(scopes);box.append(scopeBox); + const scopeBox=document.createElement('fieldset');const scopeLegend=document.createElement('legend');scopeLegend.textContent='Additional capability limit';scopeBox.append(scopeLegend);const scopeHelp=document.createElement('p');scopeHelp.className='muted';scopeHelp.textContent='Session saving, listing and restoration require separate capabilities. Saved URLs and tab group metadata never include passwords, form values or page state. Restoration rechecks current policy and does not restore revoked agent authority. History reads and navigation require separate capabilities. Window controls are limited to supported browser operations. Vault status, masked listing and exact-origin autofill require separate capabilities and human unlock. Preparing a controlled login POST requires its separate capability, approved form policy and human unlock; preparation does not submit the form. Agents cannot unlock the vault or export passwords. All granted capabilities remain restricted by the central baseline, destination rules and DLP.';scopeBox.append(scopeHelp);const scopes=document.createElement('div');scopes.className='checks';scopeBox.append(scopes);box.append(scopeBox); for(const scope of agentScopes){const label=document.createElement('label');const check=document.createElement('input');check.type='checkbox';check.checked=grant.scopes.includes(scope);check.disabled=!agentGrantsEditable;label.append(check,document.createTextNode(scope));scopes.append(label);check.addEventListener('change',()=>{grant.scopes=agentScopes.filter(s=>s===scope?check.checked:grant.scopes.includes(s));changedAgentGrants();});} const actions=document.createElement('div');actions.className='row';const generation=document.createElement('span');generation.textContent=`Credential generation: ${grant.generation}`;const rotate=document.createElement('button');rotate.type='button';rotate.className='secondary';rotate.textContent='Invalidate previous credentials';rotate.disabled=!agentGrantsEditable||grant.generation>=Number.MAX_SAFE_INTEGER;rotate.addEventListener('click',()=>{grant.generation+=1;renderAgentGrants();changedAgentGrants();agentGrantStatus('Credential generation changed in draft. Publish after review, then pair this identity again locally.');});const revoke=document.createElement('button');revoke.type='button';revoke.className='danger';revoke.textContent='Revoke grant';revoke.disabled=!agentGrantsEditable||!grant.enabled;revoke.addEventListener('click',()=>{grant.enabled=false;renderAgentGrants();changedAgentGrants();agentGrantStatus('Grant disabled in draft. Publish after review to apply revocation.');});actions.append(generation,rotate,revoke); if(!agentGrantsPublishedIds.has(grant.id)){const remove=document.createElement('button');remove.type='button';remove.className='secondary';remove.textContent='Remove unpublished grant';remove.disabled=!agentGrantsEditable;remove.addEventListener('click',()=>{agentGrantsDraft=agentGrantsDraft.filter(g=>g!==grant);renderAgentGrants();changedAgentGrants();});actions.append(remove);} diff --git a/apps/server/public/index.html b/apps/server/public/index.html index 20b5dc6..475cf79 100644 --- a/apps/server/public/index.html +++ b/apps/server/public/index.html @@ -12,8 +12,8 @@

IDENTITY AND FEATURE ACCESS

User classes and website access

Create profiles for any user class with your own names and controls. Employee, developer and high-protection templates are optional starting points. Assign profiles to verified enterprise subjects or explicit managed devices. Asset-owner labels are not authenticated identities. Website denials and category restrictions accumulate across matching profiles; an allowlist does not bypass a denial.

ProfileAssignmentsDeveloper toolsAction

Create profile

Explicit device assignments
Denied domain categories

Developer grants require central delegation; any applicable deny wins. No profile can override mandatory scanning or unlock centrally hidden settings.

Preview effective device policy

Central categories and developer delegation

Categories contain your approved domain lists. This is not an automatic external website reputation feed. Enabling delegation permits reviewed developer-profile grants; denying profiles still win.

Website categories

Use exact hosts or wildcard subdomains such as *.social.example. Changes stay in this local draft until explicit publication or independent approval.

Assignment visibility

DeviceVerified subjectEffective profiles

CONTROLLED DEPLOYMENT

Groups and pilot rollouts

Apply additional restrictions to explicit device groups. Start with selected pilot devices, inspect their reported policy versions, then explicitly expand the cohort. Group rules can tighten the baseline; they cannot weaken it. No automatic promotion occurs.

GroupMembersStageActions

Create a device group

Group members
Rollout cohort

Pilot and expanded devices must belong to the group. All means all group members, not the entire fleet.

Additional restrictions
Settings section restrictions

Membership, restrictions and cohort changes are signed policy changes. Mandatory independent approval applies to these changes too.

Additional company bookmarks

These managed bookmarks are added for the selected rollout cohort. They do not replace baseline company bookmarks.

TitleURLFolderAction

Effective policy preview

Reported rollout state

DeviceEffective groupsReported / target versionVersion acknowledgmentLast contact

Acknowledgment compares the device-reported version with its target. It is not security attestation or proof that every control is functioning. Last contact is shown separately.

INDEPENDENT CHANGE CONTROL

Policy approval queue

Loading approval requirements.

Prepared changes remain inactive until approved. A different administrator must review the exact payload and base version. Stale changes must be prepared again against the latest policy.

PreparedAuthorChangeBase versionStatusReview

CHANGE CONTROL

Policy history and recovery

Review persisted signed versions and compare changes. Restoring reapplies the selected security rules, permissions, bookmarks and network settings, group memberships and rollout cohorts. The original version stays preserved; restoration publishes a new version with a fresh expiry. Devices never receive a lower policy version.

VersionPublishedActorReview

Managed browser settings

Hide settings or deny changes centrally. These permissions are enforced in the native browser process as well as the interface. Hiding a section denies its user actions; publishing a new signed policy applies changes to connected devices at refresh.

SectionHide and deny accessLock user actions
Appearance
Bookmarks and tab groups
AI workspace
Password manager
Manual document scan
Local agent controls

Appearance values become mandatory when appearance is hidden, locked, or settings access is disabled. Otherwise users may choose local preferences. Vault locking remains available to keep credentials safe. Mandatory security inspection continues independently of settings visibility. Publish signed policy above to apply.

COMPANY LIBRARY

Managed bookmarks

Publish intranet and approved service links to every browser. Company bookmarks are read-only for employees. Personal bookmarks remain separate; configure whether employees may add, edit or delete them.

AI library organization

Suggestions require the approved AI gateway and Library permissions. Agents also require the organization scopes below. Company bookmarks remain protected. Keep automatic application disabled to require explicit confirmation before changes.

TitleHTTPS / HTTP URL (no query or fragment)FolderActions

Company changes are drafts until you publish the signed policy. Personal permissions also respect the centrally locked or hidden Library section.

Device personal bookmarks

Authorized administrators can review and remotely edit a device's synchronized personal bookmarks. Changes are applied when the browser next refreshes management state. Titles and URLs are never included in security audit events.

TEACH YOUR BROWSER

Define what must stay inside

Add phrases that must never leave, or register an exact file fingerprint. Reference files stay on your computer; only their SHA-256 fingerprint enters policy.

Local semantic reference protection

Describe a confidential category. Your approved local EmbeddingGemma 2 runtime creates pinned text reference vectors. Document extraction and local OCR text can be compared against these references. This workflow does not train a model or provide native video inspection.

Only normalized reference vectors and coverage features enter the policy draft. No document, extracted text or model fine tuning is retained. Complete extraction and chunk coverage are required; publish explicitly to approve.

Requires locally provisioned model weights, an exact model revision, matching dimensions and the document-v1 profile. Similarity is a detector score, not a confidence guarantee. Incomplete extraction or chunk coverage always blocks.

-

Enterprise security and local agents

Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.

Agent capabilities

History read and history navigation are separate capabilities. History navigation remains subject to destination rules and DLP. Window state exposes bounded browser state; window control is limited to supported browser window operations and never grants OS or administrator access. Enable only the capabilities each agent requires.

Saving, listing and restoring sessions require separate capabilities. Saved sessions contain approved URLs and tab group metadata only: no passwords, form values or page state. Restoration rechecks current policy, destination permissions and DLP; it never restores revoked agent authority.

Vault status, masked listing and autofill are separate capabilities. A person must unlock the local vault; agents cannot unlock it or export passwords. Listing and filling are restricted to the current page and the credential's exact origin. Autofill also requires the central vault and autofill permissions. Grant only the capabilities an agent needs.

Individual agent identities

Individual mode requires an enrolled local identity matching an enabled, unexpired grant. Grant scopes and destinations further restrict the central baseline. Credentials stay on the device; no private keys or tokens are stored here. Revocation and generation changes take effect after the new signed policy reaches the browser.

Changes remain in this draft. Publish with the usual policy review. Disabling a grant revokes it; advancing its generation invalidates previously paired credentials and requires local pairing again. Empty capabilities permit no actions.

Advanced source configuration: YARA, Sigma and visual references

Structured controls above are authoritative for DNS, content and agent permissions. Preserve advanced rule sources when making other changes.

Agents require paired keys and authenticated post-quantum sessions; capabilities are further restricted by each token, destination rules and DLP. Disable paired local agents and publish to revoke control. Agent permissions never grant administrator privileges or unlock centrally hidden settings.

Publish signed policy above to apply. Downloads require configured local YARA or external ICAP. Every enabled scanner is mandatory; unavailable inspection blocks downloads. Scan limit is 20 MB. Sigma supports exact metadata selection only.

-

Governed document teaching

Submit a reference document to the private local inspector, then explicitly approve its fingerprint for publication. For incomplete extraction, explicitly approve available fingerprints with partial coverage; unextracted media is not fingerprinted. Only hashes, coverage counts and the reference label are retained. Full documents, extracted text and base64 are discarded after local processing and are never persisted by management.

Local password vault

Credentials stay on the browser device. Management stores permissions and lifecycle audit events only; passwords, usernames and credential identifiers are not uploaded.

Publish signed policy to apply these controls.

Visual impersonation protection

For example, register a trusted Google login screenshot and allow only https://accounts.google.com. A matching login on another origin can then be blocked even when its domain is absent from every blocklist.

Upload your own trusted PNG of a brand login page or cropped login form. Only a derived perceptual hash, dimensions and allowed origins enter the policy draft. Reference pixels are discarded; no automatic Google or other brand profile is supplied.

Add trusted references, enable the control above and publish the signed policy. Use multiple references for different themes, languages and login steps. False positives, viewport changes and visual evasion require validation. The browser checks rendered pixels; this is not pre-render response inspection.

Managed clipboard and ClickFix protection

Control supported browser copy, cut and paste actions centrally. High-risk shell command patterns are blocked without sending clipboard text to an AI provider. Clipboard text and credentials never enter security logs.

Publish signed policy to apply. Mandatory DLP remains active when supplemental clipboard controls are disabled. Unsupported formats and unavailable required inspection fail closed. Pattern detection covers selected execution chains; it does not prove text or commands safe.

DEVICE FLEET

Enrollment and access

Enable or disable supplemental manual document analysis per browser. Mandatory upload DLP and download YARA continue regardless of this setting.

DevicePlatformIdentityPolicyLast seenAccess
+

Enterprise security and local agents

Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.

Agent capabilities

History read and history navigation are separate capabilities. History navigation remains subject to destination rules and DLP. Window state exposes bounded browser state; window control is limited to supported browser window operations and never grants OS or administrator access. Enable only the capabilities each agent requires.

Saving, listing and restoring sessions require separate capabilities. Saved sessions contain approved URLs and tab group metadata only: no passwords, form values or page state. Restoration rechecks current policy, destination permissions and DLP; it never restores revoked agent authority.

Vault status, masked listing and autofill are separate capabilities. A person must unlock the local vault; agents cannot unlock it or export passwords. Listing and filling are restricted to the current page and the credential's exact origin. Autofill also requires the central vault and autofill permissions. Grant only the capabilities an agent needs.

Individual agent identities

Individual mode requires an enrolled local identity matching an enabled, unexpired grant. Grant scopes and destinations further restrict the central baseline. Credentials stay on the device; no private keys or tokens are stored here. Revocation and generation changes take effect after the new signed policy reaches the browser.

Changes remain in this draft. Publish with the usual policy review. Disabling a grant revokes it; advancing its generation invalidates previously paired credentials and requires local pairing again. Empty capabilities permit no actions.

Advanced source configuration: YARA, Sigma and visual references

Structured controls above are authoritative for DNS, content and agent permissions. Preserve advanced rule sources when making other changes.

Agents require paired keys and authenticated post-quantum sessions; capabilities are further restricted by each token, destination rules and DLP. Disable paired local agents and publish to revoke control. Agent permissions never grant administrator privileges or unlock centrally hidden settings.

Publish signed policy above to apply. Downloads require configured local YARA or external ICAP. Every enabled scanner is mandatory; unavailable inspection blocks downloads. Scan limit is 20 MB. Sigma supports exact metadata selection only.

+

Governed document teaching

Submit a reference document to the private local inspector, then explicitly approve its fingerprint for publication. For incomplete extraction, explicitly approve available fingerprints with partial coverage; unextracted media is not fingerprinted. Only hashes, coverage counts and the reference label are retained. Full documents, extracted text and base64 are discarded after local processing and are never persisted by management.

Local password vault

Credentials stay on the browser device. Management stores permissions and lifecycle audit events only; passwords, usernames and credential identifiers are not uploaded.

Controlled credential submission

Disabled by default. A person must unlock the local vault. Ordinary autofill does not authorize submission. This permits a short-lived, one-use allowance for exact credential fields at an approved same-origin HTTPS POST target; every other field remains subject to DLP. Preparation fills the form but never submits it or exports passwords. This is not proof of website identity or a guarantee against phishing. Agent preparation additionally requires its separate capability. Changes require signed policy publication and independent review when configured.

Use exact canonical HTTPS action URLs without query strings, fragments or URL credentials. Rule identifiers and target/field signatures must be unique; username and password field names must differ and use printable ASCII (maximum 128 characters). Never enter usernames, passwords, tokens or other credential values here.

Publish signed policy to apply these controls.

Visual impersonation protection

For example, register a trusted Google login screenshot and allow only https://accounts.google.com. A matching login on another origin can then be blocked even when its domain is absent from every blocklist.

Upload your own trusted PNG of a brand login page or cropped login form. Only a derived perceptual hash, dimensions and allowed origins enter the policy draft. Reference pixels are discarded; no automatic Google or other brand profile is supplied.

Add trusted references, enable the control above and publish the signed policy. Use multiple references for different themes, languages and login steps. False positives, viewport changes and visual evasion require validation. The browser checks rendered pixels; this is not pre-render response inspection.

Managed clipboard and ClickFix protection

Control supported browser copy, cut and paste actions centrally. High-risk shell command patterns are blocked without sending clipboard text to an AI provider. Clipboard text and credentials never enter security logs.

Publish signed policy to apply. Mandatory DLP remains active when supplemental clipboard controls are disabled. Unsupported formats and unavailable required inspection fail closed. Pattern detection covers selected execution chains; it does not prove text or commands safe.

DEVICE FLEET

Enrollment and access

Enable or disable supplemental manual document analysis per browser. Mandatory upload DLP and download YARA continue regardless of this setting.

DevicePlatformIdentityPolicyLast seenAccess

ADMINISTRATOR ACCESS

Active management sessions

Administrators can review current SSO sessions and immediately revoke a session. Identity-provider group membership controls whether that person can sign in again. Bootstrap access has no stored browser session.

Session inventory requires the administrator role.

IdentityRoleExpiresAction

SECURITY OPERATIONS

Incident triage

Review security event metadata, assign an investigation and record its outcome. Protected evidence is shown separately only with explicit, audited access. Keep investigation notes free of secrets and personal data.

CreatedSecurity eventStatusSeverityAssigneeReview

AUDIT LIFECYCLE

Database event retention

Loading deployment configuration.

Retention applies only to exported database events that are not referenced by incidents. Investigation history is retained. External SIEM, file exports and backups have separate retention policies. Deployment configuration controls this setting.

SECURITY MONITORING

Recent audit events

TimeEventDeviceMetadata

This view shows the latest 100 events. Prompts and document contents are excluded. Exports contain up to 1,000 events; cursor-based APIs and durable HTTPS delivery support continuous SIEM ingestion.

COMPANY EXPERIENCE

Company logo and start page

Roll out your company identity through signed policy. Name and accent above apply alongside this logo.

Logo pixels are re-encoded locally; SVG is not accepted. News is retrieved by management only from deployment-approved feed hosts. Ask your server operator to allow the exact host. Feed URLs cannot contain credentials or fragments. Use server-configured authentication; do not put secrets in URLs. Publish and review these changes with your normal policy workflow.

Download inspection

Document DLP is required by default. Disabling it permits incoming documents after every enabled malware scanner approves them; upload protection remains enforced. Without any antivirus scanner, complete document DLP is still mandatory. Office macros are not executed. Unreadable or unsupported macro extraction blocks when local macro scanning is enabled. ICAP remains mandatory whenever enabled.

PROTECTED SECURITY EVIDENCE

Protected evidence collection

Opt in through centrally signed policy. All collection controls are off by default. Ordinary security audit records and OpenTelemetry traces remain separate.

Download evidence contains the source URL without its query or fragment, filename, SHA-256, byte count, MIME type and verdict. It does not contain the file body or the OS save path. Passwords and recognizable authentication credentials are excluded. A deployment-protected evidence key is required before enabling recording.

Disabling recording prevents new device evidence writes and removes pending native evidence payloads; ordinary security events remain. Existing encrypted evidence retains its access and retention controls. Interaction capture records the website origin, session, action and bounded target metadata. Guarded clipboard and agent effects are marked completed; trusted foreground page clicks and activated form events are marked observed, which does not prove network transmission. Sensitive forms, page paths, values, queries and fragments are excluded. It excludes clipboard contents, entered values and control labels. Collection is bounded and limited to the active main-frame page; it is not a complete replay. Publish and review this draft through the normal policy workflow.

OBSERVABILITY

Browser OpenTelemetry

Publish centrally signed operational or detailed debug collection. Spans report code stages, timing and outcomes. Confidential security evidence needs its separate protected storage; passwords and authentication tokens are excluded.

HTTPS is required except for a numeric loopback collector. Queue and export limits remain enforced. Browser logs and traces use OTLP. Durable security events are separately delivered by the management service in ECS or CEF; its HTTPS destination, TLS certificates and credentials are configured by the server operator, not exposed in browser policy. Backend service traces use the server OpenTelemetry configuration. Endpoint credentials do not belong in policy URLs. Disable and publish to stop new collection.

Review policy changes

Review the changed policy paths before publication. Existing server validation and version checks remain authoritative.

diff --git a/docs/agent-vault-controls.md b/docs/agent-vault-controls.md index 74fa681..ec4282b 100644 --- a/docs/agent-vault-controls.md +++ b/docs/agent-vault-controls.md @@ -48,9 +48,11 @@ certificate deployment or successful login submission. The separate tab-binding unit test holds the vault unlocked to verify binding independently of automatic tab-change locking (`artifacts/vault-agent-controls-native-proof.json`). -Outbound DLP still scans a later form submission. A credential matching a secret -rule can therefore block an otherwise legitimate login; there is no generic -password-field exception. A future governed allowance must bind one exact -submission and inspect every remaining field. Persistent sessions, file/vault -mutations, safe screenshots, broader administration and production key custody -remain separate open requirements. +Outbound DLP still scans a later ordinary autofill submission. A credential +matching a secret rule can therefore block that login; there is no generic +password-field exception. A separate default-off, policy-bound preparation now +supports one exact native submission while inspecting every remaining field: +see [controlled credential policy](controlled-credential-policy.md). Saved session +restoration is also implemented; see [session controls](agent-session-controls.md). +File/vault mutations, safe screenshots, broader administration and production key +custody remain separate open requirements. diff --git a/docs/controlled-credential-policy.md b/docs/controlled-credential-policy.md new file mode 100644 index 0000000..b0149a8 --- /dev/null +++ b/docs/controlled-credential-policy.md @@ -0,0 +1,135 @@ +# Approved vault credential submission policy + +Ordinary autofill only fills a form. It does not authorize sending passwords +through DLP. Administrators can separately enable controlled submission in the +Local password vault section. The new policy defaults off and requires explicit +approved form rules. Human vault unlock remains required. No agent can unlock the +vault or export passwords through this feature. + +An approved rule contains metadata only: + +```json +{ + "id": "company-login", + "actionUrl": "https://login.company.example/signin", + "usernameField": "username", + "passwordField": "password" +} +``` + +Use an exact canonical HTTPS action URL, including its path. Queries, fragments, +URL userinfo, wildcard targets and non-HTTPS URLs are rejected. Username and +password field names must be different printable ASCII strings of at most 128 +characters. At most 32 rules are permitted; rule identifiers and exact target/field +signatures must be unique. Never enter credential values in the rule editor. + +Review the complete changed policy through normal publication. When independent +approval is configured, another authorized administrator must approve it. Auditor +controls remain read-only. Disabling the toggle and publishing revokes permission +once the signed policy reaches the device; pending allowances must recheck policy +before release. + +Agent preparation additionally requires `vault:prepare-submit` in the central +baseline and its individual grant. This scope starts unchecked. Its purpose is +to fill and authorize one approved login POST with a human-unlocked vault; +preparation does not itself submit the form. Ordinary `vault:fill` does not grant +this separate permission. + +A controlled allowance must be short-lived, single-use and bound to the current +native document, exact POST destination, credential fields/values, verified actor, +policy and vault state. Every other field remains subject to DLP. The approved site receives the filled username and password in its DOM; site +JavaScript can read those values. The lease controls a supported native network +submission and does not isolate passwords from that site. This is not +cryptographic attestation of a webpage's form, proof of website identity or a +phishing guarantee. Do not treat the presence of a password field as an exemption. +Preparation currently expires after 15 seconds and is consumed by one candidate +submission; a mismatched attempt also consumes it. Request bytes are limited to +64 KiB and 128 distinct fields. Do not retry automatically after failure. + +Supported submission is a bounded native main-frame POST using standard +`application/x-www-form-urlencoded` bytes. JavaScript fetch/XHR logins, multipart, +JSON and arbitrary login protocols are not covered by this allowance. Unsupported +or ambiguous submissions remain blocked. A subsequent 302-style GET is inspected +as ordinary navigation; a 307/308 redirect that would resend the credential body +must be blocked. The allowance never grants a redirect destination permission. + +## Verification status + +Schema and admin tests prove defaults, exact URL and field constraints, strict +metadata shape, uniqueness and policy round-trip. Actual Windows/Electron admin publication verifies controlled submission starts +off, reviewed form metadata and the separate preparation scope reach the signed +baseline and individual grant, self-approval is denied, auditor controls are read-only, +and rotation/history restoration follow independent review. Completed API requests +export correlated traces (`artifacts/agent-grants-history-admin-proof.json`). +The isolated Windows/Electron fixture proves approved native POST delivery, safe +302 GET navigation, blocked 307 credential replay, denied residual confidential +data, duplicate credential fields and changed actions, plus vault lock, foreground +swap, central disable and targeted revocation. The trusted human UI handoff is +exercised (`artifacts/controlled-login-native-proof.json`). Terminal completion/ +failure audit ingestion and ECS/CEF semantics have separate backend tests; actual +terminal export correlation must be verified against the final native run. These +proofs do not establish compatibility with arbitrary login pages. Ordinary telemetry must contain fixed reasons and event references, never +passwords, usernames, form bodies or secrets. + +## Interpreting lifecycle audit + +`vault.submission-prepared` means a local, short-lived allowance was issued. +`vault.submission-authorized` means current policy approved the request; it does +not prove bytes reached the destination. `vault.submission-completed` means the +native HTTP transaction finished, including an HTTP error response: it does not +mean website authentication succeeded. `vault.submission.failure` marks a native +transaction failure, and `vault.submission-denied` marks a security refusal. + +These events retain only fixed lifecycle names, policy/version and trace linkage. +Passwords, usernames, form IDs, hosts, action URLs and request bodies are not +accepted in vault lifecycle metadata. Analysts can export authenticated ECS/CEF +records. ECS represents transaction failure as an error/failure; CEF retains its +fixed failure action at the existing informational severity 3. Security denial +exports severity 7. Ordinary lifecycle success is not an authentication assertion. + +The actual rendered password panel was inspected: Fill login, Prepare approved +login and Delete fit the card without overlap. Native fixture checks show the +preparation button is hidden when centrally disabled, visible when enabled, and +trusted handoff clears master/save fields before webpage focus. The screenshot +uses an isolated synthetic page with its username paragraph hidden for capture; +it does not prove arbitrary webpage layout or secret isolation. + + +## Credential escape checks and limits + +A prepared lease also watches ordinary URL/header requests from its WebContents; +this watch grants no DLP exemption. Public subframe requests retain the lease, +while detected credential leakage consumes it and cancels the request. Header +checks include bounded two-value combinations in both orders so unrelated native +headers do not hide that split. Arbitrary encodings and fragmentation across many +requests are not proven covered. Filled credentials remain accessible to website +JavaScript; this increment is not credential isolation from a malicious page. + +The first native split-header regression failed despite passing broker tests. +The failed run is retained in local evidence; a corrected final native run and +new package verification are required before claiming the refinement verified. + + +The subsequent final native regression now passes both escape cases using a vault +password absent from core DLP rules: GET URL and actual split-header fetch are +canceled, with zero receiver leaks. Native metadata confirms an intervening +Chromium header, false all-values match and true two-fragment match. Common trace +`6709b228cb8ce71a88acc7584a27c5a1` verifies terminal ancestry, inspection and audit +children; original and alternate values are absent from ordinary ECS/OTLP output. +The complete suite passes 668 tests. Final package verification remains separate. + + +Final stable Windows packaging and exact packaged-source/runtime verification +pass (`artifacts/controlled-login-release-windows-build.log` and +`artifacts/controlled-login-final-package-proof.log`). The executable remains +`NotSigned`; this is not a signed production installer or security certification. + + +The receiver-strengthened final native run also passes: exactly four approved +credential POSTs, zero public POSTs, zero replays and zero credential URL/header +leaks at the independent HTTPS receiver. It privately checks any two header +fragments rather than relying on all-values concatenation. Current trace +`050f7f44bc009f949c838f8a067649e4` has 101 spans with verified request ancestry; +collector rejected writes remain zero. Evidence: `artifacts/controlled-login-native-proof.json` +and `artifacts/controlled-login-receiver-proof-native.log`. Earlier native failures +and the prior successful proof remain distinct local evidence. diff --git a/docs/development-observability.md b/docs/development-observability.md index 7d8b00f..362fa04 100644 --- a/docs/development-observability.md +++ b/docs/development-observability.md @@ -139,3 +139,57 @@ and never requests the second saved destination. Trace `583a1d4dbc80fb2cbcb135b1622a4993` records the failed native stage at 1410.0137 ms and owned-tab rollback at 0.7242 ms. The delay was intentional fixture behavior; it is not a production performance result. Collector rejected writes were zero. + + +On 2026-10-13, the protected live receiver was queried for controlled-login trace +`aedaae84014f4d5e14c6d7b441d7e0f7`. Eight credential phases were verified to +have the same native request ancestor: claim, parse, match, residual, headers, +header-values, authorization and completion. The body gate took 216.4937 ms; +local inspection took 178.5054 ms, while its inspector span took 12.283203 ms +and YARA 11.866699 ms. Enterprise inspection took 4.8818 ms. The remaining +local-inspection interval cannot be attributed to parsing or YARA from these +spans alone; investigate IPC, scheduling and containment before claiming a cause. +The receiver reported zero rejected writes and uses Windows DPAPI plus AES-GCM +for local archive storage. These are actual local fixture measurements, not +production latency guarantees. Credential values and form descriptors are +excluded from debug attributes; security evidence uses its separate policy and +audit pipeline. Trace exports remain centrally disableable. + +The follow-up review found credential substring HMAC checks outside the named +matching stage. They now use `credential.match`, including checks of additional +fields and unexpected destinations. A real OTLP receiver regression verifies +six internal stage parents, timestamps, secret exclusion and central disable. +The combined broker, OTLP, policy-transition and request/header deadline suite +passes 29 tests. The earlier native trace predates this final matching refinement; +its timings must not be presented as measurements of the refined code. + + +The subsequent real native split-header test failed although the broker suite was +green. A separate Electron 44.7 / Chromium 152 header-order probe established the +cause: `X-Credential-One` and `X-Credential-Two` arrived with `sec-ch-ua-mobile` +between them. Joining every header value did not reconstruct the synthetic +credential; joining the two fragments did. Evidence retains only header names, +indices and booleans (`artifacts/credential-header-order-proof.json`), never values. +This native fact motivates bounded two-fragment checks independent of header +iteration order. It does not prove coverage of arbitrary fragmentation/encoding. + + +After the bounded pair fix, the fresh actual Windows native run passes both +unregistered-credential escape cases. Trace +`6709b228cb8ce71a88acc7584a27c5a1` contains 101 spans; credential header checking +took 15.6353 ms (header-values child 14.4573 ms), authorization 0.9952 ms and +terminal audit completion 24.8035 ms. Actual request ancestry and original plus +alternate synthetic secret exclusion were verified. ICAP observed no credentials +in 54 scans; collector rejected writes remained zero. The full suite passes +668 tests. The failing predecessor remains separate evidence and is not counted +as a pass. These timings are local fixture observations, not general benchmarks. + + +The receiver-strengthened final native run also passes: exactly four approved +credential POSTs, zero public POSTs, zero replays and zero credential URL/header +leaks at the independent HTTPS receiver. It privately checks any two header +fragments rather than relying on all-values concatenation. Current trace +`050f7f44bc009f949c838f8a067649e4` has 101 spans with verified request ancestry; +collector rejected writes remain zero. Evidence: `artifacts/controlled-login-native-proof.json` +and `artifacts/controlled-login-receiver-proof-native.log`. Earlier native failures +and the prior successful proof remain distinct local evidence. diff --git a/docs/requirements-audit.md b/docs/requirements-audit.md index c27480a..e2a91cf 100644 --- a/docs/requirements-audit.md +++ b/docs/requirements-audit.md @@ -2,13 +2,23 @@ ## Aktuelle nächste Abnahmen -Die gespeicherte Agent-Sitzung ist inzwischen nativ integriert; die detaillierten -Nachweise stehen in `docs/agent-session-controls.md`. Der volle Produktumfang bleibt -offen. Als nächste Implementierungsarbeiten gelten sicher geprüfter visueller -Agent-Export, legitime kontrollierte Credential-Übertragung und nachvollziehbare -Ownership für Hintergrund-Worker. Danach folgen breitere lokale Medienprüfung und -Analysten-Evidence-Flows. Jede neue Freigabe braucht aktuelle Policy-/Identitäts- -Prüfungen sowie einen echten Receiver-/Browser-Nachweis und korrelierte Traces. +Gespeicherte Agent-Sitzungen und kontrollierte Credential-Vorbereitung sind für +ihre dokumentierten nativen Pfade integriert und nachgewiesen. Der aktuelle +Abgleich umfasst 668 bestandene Tests, Syntaxprüfung, stabilen Windows-Build, +exakten Paket-/Runtime-Vergleich und den abschließenden Receiver-/Trace-Proof +(`artifacts/controlled-login-final-suite.log`, +`artifacts/controlled-login-final-syntax.log`, +`artifacts/controlled-login-release-windows-build.log`, +`artifacts/controlled-login-final-package-proof.log`, +`artifacts/controlled-login-native-proof.json`). Das Executable ist unsigniert. + +Nächste Implementierungsabnahmen: sicher geprüfter visueller Agent-Export, +beweisbare Actor-Ownership für Hintergrund-Worker, breitere lokale Medienprüfung, +vollständige Analysten-Inhaltsbelege und weitergehende Datei-/Admin-Steuerung. +Bereits vorhanden sind begrenzte geschützte Browser-Metadaten und eine +zweckgebundene Analystenansicht; vollständige Aufzeichnung oder Replay bleiben +offen. Jede Erweiterung braucht aktuelle Policy-/Identitätsprüfungen, einen echten +Receiver-/Browser-Nachweis und korrelierte Traces. Produktionsabnahmen bleiben separat: signierter Installer/Updatekanal, unabhängige Sicherheitsprüfung, Geräteschlüssel/Enrollment-Vertrauen und Rotation, getestete @@ -29,7 +39,7 @@ Windows zuerst: einen zentral verwalteten Browser mit lokaler Inhaltsprüfung, f -## Statusmatrix +## Aktuelle Statusmatrix @@ -37,7 +47,7 @@ Windows zuerst: einen zentral verwalteten Browser mit lokaler Inhaltsprüfung, f |---|---|---| -| Chromium/Electron, Windows-Build | Teilweise | `apps/browser/main.cjs`, `scripts/package.mjs`, `scripts/verify-package.mjs`; sandboxierter Electron-Browser und geprüftes unpacked Windows-Paket. Kein eigener Chromium-Fork mit umfassenden nativen DLP-Hooks; kein signierter Produktionsinstaller oder Updatekanal. | +| Chromium/Electron, Windows-Build | Teilweise; aktuelles unpacked Paket geprüft | `apps/browser/main.cjs`, `scripts/package.mjs`, `scripts/verify-package.mjs`, `artifacts/controlled-login-release-windows-build.log`, `artifacts/controlled-login-final-package-proof.log`: sandboxierter Electron-Browser und exakter aktueller Paket-/Runtime-Vergleich. Kein eigener Chromium-Fork mit umfassenden nativen DLP-Hooks; Executable `NotSigned`, kein signierter Produktionsinstaller oder Updatekanal. | | Safari-artige Oberfläche, Light/Dark, Themes, kompakte/getrennte Tabs | Umgesetzt für Browser-Chrome | `apps/browser/index.html`, `style.css`, `renderer.js`, `scripts/ui-showcase.cjs`, `artifacts/sbrowser-ui-layout.json`. Websiteinhalte werden nicht umgestaltet. Echte native Tabs mit eigener DOM-/Scroll-/History-/Security-State-Verwaltung sind jetzt integriert; Tabwechsel lädt nicht neu. Tatsächliche DLP-/Revocation-Prüfung in `artifacts/native-tabs-proof.json`. Gemeinsame Unternehmens-Cookies bleiben absichtlich geteilt. | @@ -47,13 +57,13 @@ Windows zuerst: einen zentral verwalteten Browser mit lokaler Inhaltsprüfung, f | Sichere Policy-Verteilung | Teilweise | `packages/core/policy-channel.cjs`, `apps/server/policy-channel.mjs`, `apps/browser/management-channel.cjs`, `tests/policy-channel.test.mjs`: ML-KEM-1024/AES-GCM und ML-DSA/Ed25519, Replay-/Versions-/Ablaufprüfung. HTTPS und unabhängiges initiales Pinning bleiben nötig. Kein HSM, Schlüsselrotation oder unabhängiges Kryptografieaudit; keine behauptete „doppelt quantensichere“ Zertifizierung. | -| SSO | Teilweise | `apps/server/auth.mjs`, `tests/server.test.mjs`: OIDC-PKCE, Signatur/Issuer/Audience/Nonce, Admin-Gruppen. Mock-IdP getestet; echter Kunden-IdP noch nicht integriert. Drei serverseitige Managementrollen (Admin, Operator, Auditor) aus OIDC-Gruppen in `apps/server/admin-access.mjs`; kein direktes SAML/SCIM. Remote-Shared-Bootstrap-Token wird verweigert; Bootstrap nur HTTP-Loopback. | +| SSO | Teilweise | `apps/server/auth.mjs`, `tests/server.test.mjs`: OIDC-PKCE, Signatur/Issuer/Audience/Nonce, Admin-Gruppen. Mock-IdP getestet; echter Kunden-IdP noch nicht integriert. Vier serverseitige Managementrollen (Admin, Operator, Analyst, Auditor) aus OIDC-Gruppen in `apps/server/admin-access.mjs`; kein direktes SAML/SCIM. Remote-Shared-Bootstrap-Token wird verweigert; Bootstrap nur HTTP-Loopback. | | Admin-Management-Oberfläche | Teilweise | `apps/server/public/index.html`, `admin.js`, `style.css`; funktionierende Enrollment-, Policy-, Integrations-, Teaching-, Bookmark- und Event-Formulare. `scripts/enterprise-admin-smoke.mjs`, `scripts/bookmarks-admin-smoke.mjs`. In dieser Iteration ergänzt: gruppierte Navigation, Fleet-/Auditfilter, Draft-/Änderungsprüfung, Dark/Light-Konsole, responsive Layouts und rollengerechte Aktionen. Tatsächliche Electron-DOM-/Screenshot-Proofs in `artifacts/enterprise-admin-proof.json` und `admin-console-*.png`. Gruppen-/Pilotverwaltung, effektive Vorschau, Versionsmeldungen und verpflichtende Zweitfreigabe sind inzwischen mit echten UI-Proofs ergänzt. Incident-Verwaltung mit Rollen, Verlauf, Zuständigkeit und Revisionsprüfung ist ergänzt und im echten UI geprüft. Einige Security-/Agentenfelder bleiben Advanced JSON; externe Tickets, automatische Fälle und vollständiger Daten-Lifecycle bleiben offen. | | Fleet, Gruppen, Rollout | Teilweise | Gerätebestand, widerrufbare Credentials, globale signierte Policy, per-Gerät Dokument-/Bookmark-Steuerung und serverseitige Rollen vorhanden. `policy-history.mjs` liefert Historie/Diff/Restore als neue Version. `policy-review.mjs` erzwingt bei aktiviertem Deploymentflag unterschiedliche Autor-/Prüferidentität für alle Veröffentlichungswege, einschließlich Teaching und Gerätescan. Restriktive Gerätegruppen, explizite Pilot-Ausweitung und gerätgebundene effektive Policies sind inzwischen implementiert (`docs/group-policy-rollout.md`). Dynamische IdP-Gruppen, automatische Gesundheitsgates, prozentuale Rolloutringe und Mandantenisolation bleiben offen. | -| Admin-Bookmarks lesen/bearbeiten, Firmenlinks und erlaubte persönliche Links | Umgesetzt innerhalb des aktuellen Fleet-Modells | `packages/core/bookmarks.mjs`, `apps/server/bookmarks.mjs`, `apps/browser/bookmarks.cjs`, `tests/bookmarks.test.mjs`, `tests/bookmarks-native.test.mjs`, beide Bookmark-Smokes und Proof-Artefakte. Signierte Firmenlinks, per-Gerät persönliche Revisionen, native und Backend-Rechte. Kein geräteübergreifendes Benutzerroaming oder Gruppenzuweisung. | +| Admin-Bookmarks lesen/bearbeiten, Firmenlinks und erlaubte persönliche Links | Umgesetzt innerhalb des aktuellen Fleet-Modells | `packages/core/bookmarks.mjs`, `apps/server/bookmarks.mjs`, `apps/browser/bookmarks.cjs`, `tests/bookmarks.test.mjs`, `tests/bookmarks-native.test.mjs`, beide Bookmark-Smokes und Proof-Artefakte. Signierte Firmenlinks, per-Gerät persönliche Revisionen, native und Backend-Rechte. Gruppenspezifische Firmenlinks über effektive Policies in `packages/core/policy-groups.mjs`; kein geräteübergreifendes persönliches Benutzerroaming. | | Eigener Proxy, VPN, DNS | Teilweise | `apps/browser/network-posture.cjs`, `packages/core/enterprise.mjs`, `tests/network-posture.test.mjs`, `scripts/enterprise-browser-smoke.mjs`: Windows-DNS, Corporate DoH, Proxy/PAC, VPN-Adapter/DNS-Prüfung. Adapterpräsenz ist keine kryptografische Tunnel-/Routenattestation; Erkennung hat Polling-Latenz. Reale Kundentopologien müssen getestet werden. | @@ -79,14 +89,15 @@ Windows zuerst: einen zentral verwalteten Browser mit lokaler Inhaltsprüfung, f | KI als First Class, kontrolliertes Gateway | Teilweise | `apps/server/app.mjs`, `inspection.mjs`, Browser-AI-UI, `tests/enterprise-gateway.test.mjs`: freigegebene lokale Ollama/kompatible Provider, serverseitige Credentials, Prompt-/Outputprüfung. Kein vollständiges Berechtigungs-RAG, Modellinventar, Provider-Tenantnachweis oder umfassendes KI-Tool-Autorisierungsmodell. | -| Lokale Agenten steuern Sessions | Teilweise; geschützte unterstützte Aktionen geprüft | Individuelle zentral genehmigte Identitäten, Generation/Expiry, Zielhostbindung, DPAPI-Pairing, ML-KEM-1024/AES-GCM und gepinnte Ed25519/ML-DSA-87-Challenges, HMAC/Replay-Abwehr. Native Tab-UUIDs, Seiteninteraktion, Inspektion, Bookmarks, Appearance, gruppierte Tabs, geprüfte History-Navigation und begrenzte Fenstersteuerung sowie menschenentsperrtes Vault-Autofill implementiert. `docs/individual-agent-grants.md`, `docs/agent-history-window.md`, `docs/agent-vault-controls.md`, jeweilige echte Native-Proofs. Sichere Screenshots, Datei-/Vault-Mutationen, umfassende Widgets und attribuierte Worker-Steuerung bleiben offen; keine freie JS/CDP- oder Credential-Export-API. | +| Lokale Agenten steuern Sessions | Teilweise; geschützte unterstützte Aktionen geprüft | Individuelle zentral genehmigte Identitäten, Generation/Expiry, Zielhostbindung, DPAPI-Pairing, ML-KEM-1024/AES-GCM und gepinnte Ed25519/ML-DSA-87-Challenges, HMAC/Replay-Abwehr. Native Tab-UUIDs, Seiteninteraktion, Inspektion, Bookmarks, Appearance, gruppierte Tabs, geprüfte History-Navigation und begrenzte Fenstersteuerung sowie menschenentsperrtes Vault-Autofill, separat kontrollierte Login-Vorbereitung und verschlüsselte gespeicherte Sitzungen implementiert. `docs/individual-agent-grants.md`, `docs/agent-history-window.md`, `docs/agent-vault-controls.md`, `docs/agent-session-controls.md`, `docs/controlled-credential-policy.md`, jeweilige echte Native-Proofs. Sichere Screenshots, Datei-/Vault-Mutationen, umfassende Widgets und attribuierte Worker-Steuerung bleiben offen; keine freie JS/CDP- oder Credential-Export-API. | | Verschlüsselte gespeicherte Sitzungen | Native Integration und finales Windows-Paket geprüft | `session-snapshot.cjs`, `agent-session-adapters.cjs`, `session-restore.cjs`, `docs/agent-session-controls.md`: benannte lokale URL/Titel/Gruppen-Sammlungen, Gerät/Principal/Generation-Bindung, DLP vor Save/List/Restore, CAS und einmalige Restore-Pläne. Echter Zwei-Prozess-Windows-Proof mit erhaltenen bestehenden Tabs; keine Formulare, Passwörter oder pageState. Kein Cross-Device-Sync oder vollständiges Session-Backup. | -| Passwortmanager mit Post-Quantum-Kryptografie | Teilweise | `apps/browser/vault.cjs`, `password-fill.cjs`, `tests/vault.test.mjs`, `tests/password-fill.test.mjs`: ML-KEM-1024/AES-GCM/scrypt und Windows-DPAPI, Idle-/Blur-Lock, gebundene HTTPS-Formfüllung. Kein Sync/Recovery/Passkeys, keine unabhängige Kryptografieprüfung oder FIPS-Modulvalidierung. | +| Passwortmanager mit Post-Quantum-Kryptografie | Teilweise | `apps/browser/vault.cjs`, `password-fill.cjs`, `tests/vault.test.mjs`, `tests/password-fill.test.mjs`: ML-KEM-1024/AES-GCM/scrypt und Windows-DPAPI, Idle-/Blur-Lock, gebundene HTTPS-Formfüllung. Separat default-off kontrollierter nativer Login-POST in `apps/browser/credential-submission.cjs`, `tests/controlled-login-audit.test.mjs`, `artifacts/controlled-login-native-proof.json`; Vorbereitung sendet nicht. Gefüllte Passwörter sind für die freigegebene Webseite/deren JavaScript im DOM lesbar; keine Passwort-Isolation oder beliebige JS-Login-Unterstützung. Kein Sync/Recovery/Passkeys, keine unabhängige Kryptografieprüfung oder FIPS-Modulvalidierung. | | UI+pHash+korrekte Login-URL gegen unbekannte Domains | Teilweise | `packages/core/visual-hash.cjs`, `visual-image.cjs`, `apps/browser/visual-phishing.cjs`, `visual-capture.cjs`, `tests/visual-*.test.mjs`, Native-Smoke: echte Pixel, DCT-pHash, exakte erlaubte HTTPS-Origins, Quarantäne/Audit. Admin muss vertrauenswürdige Referenzen liefern; keine voreingestellte Google-Bibliothek. Kein Pre-Render-Block; Layout-/Sprache-/Iframe-/Evasion-Abdeckung und Fehlalarmrate nicht ausreichend evaluiert. | -| OpenTelemetry und SIEM-Standardexport | Teilweise | `apps/server/telemetry.mjs`, `siem.mjs`, `packages/core/siem.mjs`, `tests/telemetry.test.mjs`, `tests/siem.test.mjs`: OTLP Signals, dauerhafte Audit-/Dedup-Pfade, ECS/CEF-Cursor und HTTPS/mTLS-Push mit zentralem SIEM-Exportschalter. Keine Dokument-/Promptinhalte in regulären Events. Incident-Triage und standardmäßig deaktivierte Datenbank-Aufbewahrung implementiert. Incident-Belege und ausstehende SIEM-Exporte bleiben erhalten. Zentral signierte Browser-OTLP-Traces/Logs mit Laufzeiten und Trace-/Span-Korrelation sind jetzt integriert und mit realem Electron/Collector geprüft. Separater verschlüsselter Belegspeicher serverseitig vorhanden; Browser-Erfassung/Analystenansicht, detaillierte Python-Phasen, automatische Fälle, externe SIEM-/Datei-/Backup-Aufbewahrung und reale Kunden-SIEM-Tests bleiben offen. | +| OpenTelemetry und SIEM-Standardexport | Teilweise; unterstützte Debug-/Auditpfade nativ geprüft | `apps/server/telemetry.mjs`, `apps/browser/diagnostics.cjs`, `packages/core/siem.mjs`: zentral abschaltbare OTLP-Signals, langlebiger Exportfehlerzähler, korrelierte Browser-/Backend- und Audit-/Credential-Stufen, ECS/CEF-Cursor und HTTPS/mTLS-Push mit zentralem SIEM-Schalter. `apps/inspector/stage_timings.py`, `tests/stage_timings_test.py`, `artifacts/inspector-stages-export-proof.json` belegen begrenzte Parse/OCR/YARA/Makro/Semantik-Phasen mit Parent-Struktur; keine vollständigen Chromium-/Python-Profiler-Traces. Pflicht-Audit ist von Debug-Telemetrie getrennt. Automatische Fallbildung, externe SIEM-/Datei-/Backup-Aufbewahrung und reale Kunden-SIEM-Abnahme bleiben offen. | +| Geschützte Browser-Belege und Analystenansicht | Umgesetzt für begrenzte Metadaten; Inhalts-Replay offen | `apps/browser/evidence-capture.cjs`, `evidence-delivery.cjs`, `apps/server/evidence.mjs`, `apps/server/public/evidence-viewer.js`, `tests/evidence-viewer.test.mjs`, `artifacts/download-evidence-native-proof.json`, `artifacts/interaction-evidence-native-proof.json`, `artifacts/evidence-ui-proof.json`: default-off Download-, abgeschlossene Clipboard-/Agent-Klick- sowie beobachtete Foreground-Gestenmetadaten, atomic DPAPI-Queue, PQ-Transport, verschlüsselter Belegspeicher und zweckgebundene auditierte Analystenfreigabe. Anzeige löscht sich nach einer Minute; beobachtetes Submit ist kein Übertragungsbeweis. Vollständige Dokument-/Seiteninhalte, Replay und umfassender Evidence-Lifecycle bleiben offen. | | Frei anlegbare Nutzerprofile und Funktionsrechte | Umgesetzt für unterstützte Profileigenschaften | `tests/user-profiles.test.mjs`, native DevTools-Prüfung und `artifacts/profiles-admin-proof.json`, `profiles-review-admin-proof.json`, `devtools-profile-proof.json`: beliebige Profile, verifizierte SSO-Subjekte oder Geräte, Kategorien/Allow-/Denylists, restriktive Konfliktregeln, explizite DevTools-Delegationsobergrenze, Dokumentpflicht und Download-/AI-/Agentenrechte. Kategorien sind administrierte Domainlisten; keine umfassende kommerzielle URL-Kategorisierungsdatenbank. Freigegebene DevTools sind ein privilegierter Vertrauensbereich mit bekanntem Clipboard-Ausnahmepfad. | @@ -134,7 +145,7 @@ Windows zuerst: einen zentral verwalteten Browser mit lokaler Inhaltsprüfung, f - [ ] Automatische Rollout-Freigabe-/Stop-Regeln, prozentuale Ringe und dynamische IdP-/SCIM-Mitgliedschaft. -- [ ] Sichere immutable Freigabe von Dateiuploads und vollständige unterstützte Netzwerkkanäle im nativen Browser. Abnahme: kontrollierter Receiver erhält niemals nach dem Scan ausgetauschte Bytes; Frames/Worker/WebSockets/Streams sind explizit abgedeckt oder gesperrt. +- [ ] Vollständige Kanalabnahme über die bereits nachgewiesenen normalen NTFS-Dateiuploads hinaus. `docs/windows-file-uploads.md` beschreibt vorhandene immutable Handle-Leases und Receiver-Proofs; unterstützte zusätzliche Frames/Worker/WebSockets/Streams brauchen eigene native Zuordnung und Freigabe. Uninspectable Pfade bleiben gesperrt. - [ ] Vollständige Dokument-Render-/Attachmentprüfung, Audio-Transkription und Video-Frames im isolierten lokalen Worker. Abnahme: bekannte Secrets in jeder unterstützten Modalität und im Dateiende werden erkannt; unvollständige Coverage kann nicht freigegeben werden. @@ -180,7 +191,7 @@ Eine getrennte Weboberfläche ist die vorhandene und sinnvolle Verwaltungsarchit -## In dieser Iteration umgesetzt +## Historischer erster Admin-/Isolationsincrement @@ -196,7 +207,12 @@ Eine getrennte Weboberfläche ist die vorhandene und sinnvolle Verwaltungsarchit -## Verifikation +## Historische Verifikation und chronologische Increments + +Die folgenden Abschnitte dokumentieren den jeweils damaligen Stand. Frühere +Offen-Meldungen, Testzahlen und Paketnachweise sind historische Momentaufnahmen; +für den aktuellen Status gilt die Matrix oben und der abschließende aktuelle Proof. +Fehlgeschlagene Läufe werden nicht gelöscht oder als erfolgreich umgedeutet. @@ -675,3 +691,112 @@ The executable remains unsigned; no signed production installer is claimed. Die zentrale Admin-UI verifiziert separate standardmaessig ausgeschaltete Rechte, unabhaengige Freigabe, Auditor-Read-only und Policy-History-Restore. 154 abgeschlossene API-Requests exportierten korrelierte Traces; drei abgebrochene GETs sind getrennt dokumentiert. Save/List/Restore und Audit-Serialisierung/Verschluesselung/Write/fsync/Commit exportieren benannte echte OTLP-Stufen. Die fruehere 683-ms-Audit-Beobachtung bleibt ursachenseitig ungeklaert; neue Traces ersetzen keine fehlende historische Messung. Finaler stabiler Windows-Build und exakter Paketvergleich bestehen: artifacts/session-release-windows-build.log und artifacts/session-final-package-proof.log. Der erste Build wurde wegen geaenderter Quellen verworfen; nur der finale stabile Build gilt. Das Executable ist weiterhin NotSigned. Native Proofs: artifacts/session-agent-controls-native-proof.json und artifacts/agent-grants-history-admin-proof.json. Vollstaendige Produkt-/Produktionsabnahme bleibt offen. + + +## Kontrollierte Login-Vorbereitung, aktueller Integrationsstand + +Zentral gibt es standardmäßig ausgeschaltete `passwordManager.controlledSubmission` +und eine separate, ebenfalls ausgeschaltete Agent-Berechtigung `vault:prepare-submit`. +Bis zu 32 eindeutige Regeln enthalten ausschließlich exakte kanonische HTTPS- +Action-URLs und unterschiedliche begrenzte ASCII-Feldnamen. Human-Unlock bleibt +Pflicht. Die tatsächliche Admin-Weboberfläche prüfte unabhängige Freigabe, +Self-Approval-Verweigerung, Auditor-Read-only und signierte Baseline/Individual- +Grants einschließlich History-Restore. Vorbereiten füllt, sendet aber nicht. + +Der aktuelle privilegierte Broker bindet eine 15-Sekunden-Einmalfreigabe an exakte +Credential-Werte, Native Frame/Document, Actor/Generation, Policy, Vault und +Foreground-Aktivierung. Unterstützt ist nur ein begrenzter nativer same-origin +URL-encoded Main-Frame-POST; andere Felder und Header werden weiter geprüft. +302-GET-Navigation erhält normale Kontrolle, 307/308-Credential-Replay wird blockiert. +Die Webseite kann gefüllte Credentials im DOM per JavaScript lesen: keine Passwort- +Isolation, keine Form-Attestierung und keine allgemeine Phishing-Garantie. + +Das isolierte native Fixture hat bereits reale POST-Zustellung, sicheren 302, +blockierten 307, Residual-/Duplikat-/Action-/Header-Verweigerungen, menschlichen +UI-Handoff, Vault-Lock, Tabwechsel, zentrale Abschaltung und Target-Revoke gezeigt +(`artifacts/controlled-login-native-proof.json`). Die aktuellste Terminal-/Race- +Integration muss vor Freigabe erneut nativ getestet werden. Gesamttests, stabiler +Windows-Build und exakter Paketvergleich sind für diesen aktuellen Increment noch +nicht nachgewiesen; die früheren 624 Tests/Builds bleiben historische Nachweise. + +Vier aktuelle Backend-Tests belegen authentifizierte, deduplizierte Aufnahme aller +fünf Vault-Submission-Lifecycle-Events, integren Audit-Hashchain, Secret-Metadaten- +Verweigerung für jedes Event und rollenbeschränkten ECS-/CEF-Export. Prepared ist +lokale Vorbereitung, Authorized eine Policy-Entscheidung, Completed ein beendeter +nativer HTTP-Transfer (auch mit HTTP-Fehlerantwort), Failure ein Transportfehler. +Keines beweist erfolgreichen Website-Login. Denied exportiert Security-Denial; +ECS-Failure bleibt Error/Failure, CEF nutzt den festen Action-Namen mit bestehender +Severity 3. Credential-Werte, Form-IDs, Host/Action-URL und Request-Bodies bleiben +außerhalb gewöhnlicher Vault-Events. + +Benannte echte Debug-Stufen umfassen Issue/Claim/Headers/Authorize/Redirect und +Terminal-Complete/Failure plus Local-/Enterprise-Inspection und die fünf Audit- +Persistenzstufen. Die letzte native Trace-Zuordnung des aktuellen Codes ist noch +zu verifizieren; Startup und vorhandene historische Traces ersetzen diesen +Nachweis nicht. Collector-Ausfälle dürfen keine Wiederholung geschützter Requests +oder Aufweichung der Policy verursachen. Details: `AGENT-API.md` und +`docs/controlled-credential-policy.md`. + +Individual Mode blockiert weiter nicht zuordenbare Session-/Worker-Requests ohne +einen bekannten geschützten nativen Tab-Owner. Der aktive Tab ist kein Ownership- +Beweis. Legitime Worker-Unterstützung mit beweisbarer Actor-Zuordnung/Isolation, +volle JavaScript-Login-Kompatibilität, sicherer visueller Agent-Export und breitere +Datei-/Admin-Steuerung bleiben offen. Ebenso alle ursprünglichen Produktions- +Abnahmen: signierter Installer/Updates, Geräteschlüssel/Enrollment/Rotation, +HA/Backup/Recovery, echte Kunden-Integrationen und unabhängige Sicherheitsprüfung. +Das vollständige Ziel ist weiterhin nicht erreicht. + + +### Aktuelle Nachprüfung der Credential-Umgehungen + +Die vollständige Suite besteht jetzt mit 668 Tests. Die Prüfung fand zusätzlich +eine GET-URL-Lücke bei vorbereiteten Freigaben und einen echten nativen +Split-Header-Fehler: Chromium fügt einen eigenen Header zwischen die beiden +Fragmente ein. Die vorbereitete Freigabe prüft jetzt URLs und Header ohne neue +DLP-Ausnahme; Header-Prüfungen berücksichtigen begrenzte Zweierkombinationen in +beiden Reihenfolgen. Öffentliche Subframe-Requests behalten die Freigabe. +Der fehlgeschlagene native Test bleibt als lokaler Nachweis erhalten. +Arbiträre Fragmentierung/Encodings und Isolation vor Website-JavaScript sind +weiterhin nicht nachgewiesen. Finale native Prüfung und Paketprüfung laufen noch. + + +Der korrigierte finale native Test besteht: nicht in Core-DLP registrierte +synthetische Vault-Zugangsdaten werden sowohl in GET-URL als auch in zwei echten, +durch native Header getrennten Fetch-Headern blockiert. Der Receiver meldet null +Credential-URL-/Header-Leaks; ICAP prüft 54 Requests, blockiert genau den erwarteten +Residual-Marker und beobachtet keine Zugangsdaten. Die regulären POST-/302-/307-, +YARA-, UI-Handoff-, Widerrufs- und 17-Sekunden-Terminalfälle bestehen ebenfalls. +Trace `6709b228cb8ce71a88acc7584a27c5a1` exportiert 101 korrelierte Spans; der +Collector meldet null abgewiesene Schreibvorgänge. Die endgültige Paketprüfung +steht nach abschließender Quellnormalisierung noch aus. + + +Der endgültige Windows-Build aus stabilen Quellen und der exakte Paket-/Runtime- +Vergleich bestehen (`artifacts/controlled-login-release-windows-build.log`, +`artifacts/controlled-login-final-package-proof.log`). Enthalten sind der aktuelle +Credential-Broker, die zentrale Policy-Schemaergänzung, Upload-Leases, enthaltene +YARA-/OCR-/Semantik-Runtimes und beide geschützten Agent-Clients. Syntaxprüfungen +bestehen. Das Executable meldet weiterhin `NotSigned`; eine signierte Installation +oder ein produktiver Updatekanal ist damit nicht bewiesen. Repository-Beschreibung +und 13 fachliche Topics wurden über die Gitea-API erneut gelesen und bestätigt. + + +## Abschließender aktueller Controlled-Login-Proof + +668 Tests bestehen (`artifacts/controlled-login-final-suite.log`), ebenso Syntax, +stabiler Windows-Build und exakte Paket-/Runtime-Prüfung in den oben genannten +finalen Logs. Der verstärkte unabhängige native Receiver sieht vier Credential- +POSTs, null öffentliche POSTs, null Replay und null Credential-Leaks in URL oder +Headern; die Prüfung berücksichtigt beliebige private Name/Wert-Paare statt nur +erwarteter Feldnamen. ICAP prüft 54 Requests, blockiert genau einen erwarteten +Residual-Marker und erhält keine Credentials. Nachweise: +`artifacts/controlled-login-native-proof.json`, +`artifacts/controlled-login-browser-vault.png`. + +Trace `050f7f44bc009f949c838f8a067649e4` enthält 101 tatsächlich exportierte, +parent-korrelierte Spans einschließlich Native-Claim/Parse/Match/Residual/Header/ +Authorize/Complete, Local-/Enterprise-Inspection und Audit-Persistenz. Der +Collector meldet null abgewiesene Schreibvorgänge. Diese kontrollierten Fixtures +beweisen ihre Szenarien; sie schließen weder Worker-Ownership noch allgemeine +JavaScript-Logins, Passwort-Isolation, vollständige Exfiltrationsabdeckung oder +die ursprünglichen Produktions-/Kundenabnahmen. Das Gesamtziel bleibt offen. diff --git a/docs/worker-ownership.md b/docs/worker-ownership.md new file mode 100644 index 0000000..2a3804e --- /dev/null +++ b/docs/worker-ownership.md @@ -0,0 +1,25 @@ +# Native worker ownership: verified boundary and next implementation + +Current individual-agent mode blocks requests without a guarded native tab owner. This deliberately prevents service workers from borrowing an actor's authority through origin, Referer, renderer messages or a guessed tab. It also limits compatibility with worker-dependent applications. + +## Native evidence + +A local Windows feasibility experiment used Electron 44.7.0 / Chromium 152.0.7977.130, two independent in-memory native sessions, and the same synthetic loopback origin. Each session registered a service worker and initiated a POST from that worker. Each session's native webRequest listener received only its own request. + +The worker requests had no webContentsId or requesting frame. Both had the same initiator origin. Their request objects carried no worker version ID or process ID. ServiceWorkers.getAllRunning exposed different renderProcessIds, but those identifiers were absent from webRequest events. Runtime worker version IDs were available as dictionary keys; the worker information values omitted the versionId field advertised in the installed type definition. + +Local ignored evidence: `artifacts/worker-partition-feasibility.json` and `.log`. This isolated API experiment did not run production browser guards or establish an OpenTelemetry integration proof. + +Electron documents native [webRequest events](https://www.electronjs.org/docs/latest/api/web-request), [service-worker inventory](https://www.electronjs.org/docs/latest/api/service-workers), and [session partitions](https://www.electronjs.org/docs/latest/api/session). The installed `node_modules/electron/electron.d.ts` was checked against the actual runtime. + +## Defensible design + +Allocate a separate native session for each immutable device/principal/generation identity. Assign ownership in the main process before creating any native tab or initiating a request. Install the full network, DLP, download, permission, proxy, DNS and preload controls before the partition is used. A worker's owning principal then comes from the listener's native session closure, not page-supplied claims. + +Never reassign a partition to another actor or newer generation. Check the current signed grant, device status, policy expiry and destination scopes before inspection, after every awaited stage and immediately before release. Revocation must quarantine the owner's partition and close its connections. Cookies and authentication state should remain isolated unless a separate explicitly authorized sharing design is implemented. + +This attributes requests to a principal, not to a specific tab. Shared workers can serve multiple tabs, and service workers may outlive their clients. Any operation requiring an unavailable tab, exact document or visual approval must remain blocked until an independently verified context is available. Worker downloads, immutable streamed request-body inspection, background lifetimes, restart ownership and complete revocation behavior need native proofs. + +## Migration surface + +The current main process uses one `browserSession` for proxy updates, DNS resolution, permission handlers, upload terminal events, download fetching and will-download, webRequest gates, developer-tools frontends, preload registration, native tab construction, revocation cleanup and VPN quarantine. All relevant uses must become session-aware together. Merely selecting a new session for a tab while leaving these controls global would create an enforcement gap. diff --git a/packages/core/agent-audit.cjs b/packages/core/agent-audit.cjs index e90e647..4ebb235 100644 --- a/packages/core/agent-audit.cjs +++ b/packages/core/agent-audit.cjs @@ -1,6 +1,6 @@ 'use strict'; const {z}=require('zod'); -const application=['sessions:save','sessions:list','sessions:restore','vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply']; +const application=['vault:prepare-submit','sessions:save','sessions:list','sessions:restore','vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply']; const AGENT_SCOPES=Object.freeze({sessions:'sessions:list',navigate:'browser:navigate',back:'browser:navigate',forward:'browser:navigate',reload:'browser:navigate',read:'browser:read',inspect:'browser:read',scroll:'browser:interact',click:'browser:interact',type:'browser:interact',select:'browser:interact',check:'browser:interact',ai:'ai:request',...Object.fromEntries(application.map(action=>[action,action])),'agent-request':'agent:request'}); const AgentAuditSchema=z.object({action:z.enum(Object.keys(AGENT_SCOPES)),scope:z.enum([...new Set(Object.values(AGENT_SCOPES))]),outcome:z.enum(['authorized','completed','denied','failed']),sessionId:z.string().uuid().optional(),principalId:z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$/).optional(),credentialGeneration:z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).optional()}).strict().superRefine((value,ctx)=>{ if((value.principalId!==undefined)!==(value.credentialGeneration!==undefined))ctx.addIssue({code:'custom',message:'Agent principal attribution requires both identity and generation'}); diff --git a/packages/core/credential-policy.mjs b/packages/core/credential-policy.mjs new file mode 100644 index 0000000..984114b --- /dev/null +++ b/packages/core/credential-policy.mjs @@ -0,0 +1,15 @@ +import {z} from 'zod'; + +export function credentialActionUrlAllowed(value){ + try {const u=new URL(value);return u.protocol==='https:'&&!u.username&&!u.password&&!u.search&&!u.hash&&!value.includes('?')&&!value.includes('#')&&u.href===value&&!/[\u0000-\u0020\u007f]/.test(value);}catch{return false;} +} +const field=z.string().min(1).max(128).regex(/^[\x21-\x7e]+$/,'Credential field names require bounded printable ASCII'); +export const CredentialSubmissionFormSchema=z.object({ + id:z.string().min(1).max(80).regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/), + actionUrl:z.string().max(4096).refine(credentialActionUrlAllowed,'An exact canonical HTTPS action URL without credentials, query or fragment is required'), + usernameField:field,passwordField:field +}).strict().superRefine((rule,context)=>{if(rule.usernameField===rule.passwordField)context.addIssue({code:'custom',path:['passwordField'],message:'Username and password fields must differ'});}); +export const ControlledSubmissionSchema=z.object({enabled:z.boolean().default(false),forms:z.array(CredentialSubmissionFormSchema).max(32).default([])}).strict().superRefine((value,context)=>{ + const ids=new Set(),targets=new Set();value.forms.forEach((rule,index)=>{if(ids.has(rule.id))context.addIssue({code:'custom',path:['forms',index,'id'],message:'Credential rule identifiers must be unique'});ids.add(rule.id);const signature=JSON.stringify([rule.actionUrl,rule.usernameField,rule.passwordField]);if(targets.has(signature))context.addIssue({code:'custom',path:['forms',index],message:'Duplicate credential target and field signature'});targets.add(signature);}); + if(value.enabled&&!value.forms.length)context.addIssue({code:'custom',path:['forms'],message:'Enablement requires at least one approved login POST target'}); +}).prefault({}); diff --git a/packages/core/policy.mjs b/packages/core/policy.mjs index 3460cf0..95045a3 100644 --- a/packages/core/policy.mjs +++ b/packages/core/policy.mjs @@ -1,6 +1,7 @@ import { z } from 'zod'; import { createHash, createPrivateKey, createPublicKey, sign, verify } from 'node:crypto'; import { SecuritySchema, AgentsSchema } from './security.mjs'; +import {ControlledSubmissionSchema} from './credential-policy.mjs'; import { SemanticPolicySchema } from './semantic.mjs'; import { ManagedSettingsSchema } from './managed-settings.mjs'; import { EnterpriseSchema } from './enterprise.mjs'; @@ -47,7 +48,7 @@ export const PolicySchema = z.object({ }), security: SecuritySchema, inspection:z.object({documentAnalysisEnabled:z.boolean().default(true)}).prefault({}), - passwordManager:z.object({enabled:z.boolean().default(true),allowSaving:z.boolean().default(true),allowAutofill:z.boolean().default(true),idleLockSeconds:z.number().int().min(60).max(900).default(300)}).strict().prefault({}), + passwordManager:z.object({enabled:z.boolean().default(true),allowSaving:z.boolean().default(true),allowAutofill:z.boolean().default(true),controlledSubmission:ControlledSubmissionSchema,idleLockSeconds:z.number().int().min(60).max(900).default(300)}).strict().prefault({}), agents: AgentsSchema, enterprise: EnterpriseSchema, ai: z.object({ enabled: z.boolean(), model: z.string().max(100),provider:z.enum(['openai-compatible','ollama']).default('openai-compatible'),trustMode:z.enum(['external','local']).default('external'),endpointId:z.string().max(80).default('default'),allowConfidentialLocal:z.boolean().default(false) }) diff --git a/packages/core/security.mjs b/packages/core/security.mjs index 9a94ed7..1b0d9ea 100644 --- a/packages/core/security.mjs +++ b/packages/core/security.mjs @@ -1,6 +1,6 @@ import { z } from 'zod'; import { isIP } from 'node:net'; -export const AgentScopes=['sessions:save','sessions:list','sessions:restore','vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','sessions','navigate','back','forward','reload','read','inspect','scroll','click','type','select','check','screenshot','ai','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply']; +export const AgentScopes=['vault:prepare-submit','sessions:save','sessions:list','sessions:restore','vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','sessions','navigate','back','forward','reload','read','inspect','scroll','click','type','select','check','screenshot','ai','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply']; const host=z.string().max(253).regex(/^(\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i); export const VisualOriginSchema=z.string().max(2048).refine(value=>{try{const u=new URL(value);return u.protocol==='https:'&&!u.username&&!u.password&&!u.search&&!u.hash&&u.origin===value&&!u.hostname.includes('*')&&!u.hostname.endsWith('.')&&!isIP(u.hostname.replace(/^\[|\]$/g,''))&&/^[a-z0-9.-]+$/.test(u.hostname);}catch{return false;}},'Expected a canonical exact HTTPS domain origin'); export const VisualViewportSchema=z.object({width:z.number().int().min(16).max(8192),height:z.number().int().min(16).max(8192)}).strict(); diff --git a/packages/core/siem.mjs b/packages/core/siem.mjs index e5a4265..db62e04 100644 --- a/packages/core/siem.mjs +++ b/packages/core/siem.mjs @@ -5,7 +5,7 @@ import requestTransferFacts from './request-transfer-facts.cjs'; const requestFacts=(metadata,kind)=>{if(!kind?.startsWith('upload.'))return null;const old=metadata.file;const parsed=requestTransferFacts.RequestFactsSchema.safeParse(metadata.request||(old?{bytes:old.bytes,detectedType:old.detectedType}:undefined));return parsed.success?parsed.data:null;}; const facts=metadata=>{const parsed=transferFacts.FileFactsSchema.safeParse(metadata.file);return parsed.success?parsed.data:null;}; export function toECS(event,installationId='safetynet') { - const m=event.metadata,interaction=interactionAudit.interactionFactsForEvent(event.kind,event.metadata.interaction),agent=agentAudit.agentFactsForEvent(event.kind,event.metadata.agent),block=event.kind.includes('block')||event.kind.endsWith('.denied')||m.decision==='block',failure=event.kind.endsWith('.failure')||agent?.outcome==='failed',alert=event.kind==='sigma.alert'; + const m=event.metadata,interaction=interactionAudit.interactionFactsForEvent(event.kind,event.metadata.interaction),agent=agentAudit.agentFactsForEvent(event.kind,event.metadata.agent),block=event.kind.includes('block')||event.kind.endsWith('.denied')||event.kind==='vault.submission-denied'||m.decision==='block',failure=event.kind.endsWith('.failure')||agent?.outcome==='failed',alert=event.kind==='sigma.alert'; const config=event.kind.startsWith('policy.')||event.kind.startsWith('enrollment.')||event.kind==='device.revoked'; const auth=event.kind.startsWith('sso.'),clipboard=event.kind.startsWith('clipboard.'); return { @@ -29,7 +29,7 @@ const header=v=>String(v).replace(/\\/g,'\\\\').replace(/\|/g,'\\|').replace(/[\ const ext=v=>String(v).replace(/\\/g,'\\\\').replace(/=/g,'\\=').replace(/\r/g,'\\r').replace(/\n/g,'\\n'); const severity=level=>({low:3,medium:5,high:7,critical:10}[level]??3); export function toCEF(event,installationId='safetynet') { - const m=event.metadata,interaction=interactionAudit.interactionFactsForEvent(event.kind,event.metadata.interaction),agent=agentAudit.agentFactsForEvent(event.kind,event.metadata.agent),blocked=event.kind.includes('block')||event.kind.endsWith('.denied')||m.decision==='block',alert=event.kind==='sigma.alert'; + const m=event.metadata,interaction=interactionAudit.interactionFactsForEvent(event.kind,event.metadata.interaction),agent=agentAudit.agentFactsForEvent(event.kind,event.metadata.agent),blocked=event.kind.includes('block')||event.kind.endsWith('.denied')||event.kind==='vault.submission-denied'||m.decision==='block',alert=event.kind==='sigma.alert'; // suid identifies the verified agent capability principal, not an employee account. // suser, when present, remains the independently supplied human actor. const fields={...(interaction?{cs2Label:'InteractionAction',cs2:interaction.action,cs3Label:'InteractionOutcome',cs3:interaction.outcome,requestContext:interaction.sessionId}:{}),...(agent?{cs2Label:'AgentAction',cs2:agent.action,cs3Label:'AgentScope',cs3:agent.scope,cs6Label:'AgentOutcome',cs6:agent.outcome,...(agent.sessionId?{requestContext:agent.sessionId}:{}),...(agent.principalId?{suid:agent.principalId,flexNumber1Label:'AgentCredentialGeneration',flexNumber1:agent.credentialGeneration}:{})}:{}),...(requestFacts(m,event.kind)?{cn3Label:'RequestBytes',cn3:requestFacts(m,event.kind).bytes,cs6Label:'DetectedRequestType',cs6:requestFacts(m,event.kind).detectedType}:{}),...(event.audit?{flexString1Label:'AuditRecordHash',flexString1:event.audit.hash,flexString2Label:'AuditSequence',flexString2:event.audit.sequence}:{}),externalId:`${installationId}:${event.id}`,rt:Date.parse(event.time),act:event.kind, diff --git a/scripts/agent-grants-history-admin-smoke.cjs b/scripts/agent-grants-history-admin-smoke.cjs index 6e9056e..492b99c 100644 --- a/scripts/agent-grants-history-admin-smoke.cjs +++ b/scripts/agent-grants-history-admin-smoke.cjs @@ -8,12 +8,12 @@ async function capture(file,section){await evaluate(`document.getElementById('${ app.whenReady().then(async()=>{ window=new BrowserWindow({show:false,width:1500,height:1100,webPreferences:{nodeIntegration:false,contextIsolation:true,sandbox:true,offscreen:true,backgroundThrottling:false}}); const proofTraceIds=[],requestsById=new Map();session.defaultSession.webRequest.onCompleted({urls:[process.env.SAFETYNET_ADMIN_SMOKE_URL+'/api/*']},details=>{const row=requestsById.get(details.id);if(row){row.outcome='completed';row.statusCode=details.statusCode;}});session.defaultSession.webRequest.onErrorOccurred({urls:[process.env.SAFETYNET_ADMIN_SMOKE_URL+'/api/*']},details=>{const row=requestsById.get(details.id);if(row)row.outcome='failed';});session.defaultSession.webRequest.onBeforeSendHeaders({urls:[process.env.SAFETYNET_ADMIN_SMOKE_URL+'/api/*']},(details,callback)=>{const crypto=require('node:crypto'),traceId=crypto.randomBytes(16).toString('hex'),spanId=crypto.randomBytes(8).toString('hex');const path=new URL(details.url).pathname,route=path==='/api/admin/policy'?'/api/admin/policy':path==='/api/admin/policy/restore'?'/api/admin/policy/restore':/^\/api\/admin\/policy-reviews\/[^/]+\/approve$/.test(path)?'/api/admin/policy-reviews/:id/approve':/^\/api\/admin\/policy-history\/[^/]+\/restore$/.test(path)?'/api/admin/policy-history/:id/restore':'other-api';const row={traceId,parentSpanId:spanId,outcome:'pending',method:details.method,route};proofTraceIds.push(row);requestsById.set(details.id,row);callback({requestHeaders:{...details.requestHeaders,traceparent:'00-'+traceId+'-'+spanId+'-01'}});}); - await identity('grant-author');assert.equal(await evaluate('typeof require'),'undefined');for(const scope of ['history:list','history:go','window:state','window:control','vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore'])assert.deepEqual(await evaluate(`(()=>{const field=document.getElementById('agent-scope-${scope}');return {present:!!field,checked:field.checked,disabled:field.disabled};})()`),{present:true,checked:false,disabled:false});await evaluate(`document.getElementById('agent-enabled').checked=true;document.getElementById('agent-scope-sessions').checked=true;document.getElementById('agent-scope-read').checked=true;for(const scope of ['vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore'])document.getElementById('agent-scope-'+scope).checked=true;document.getElementById('agent-hosts').value='*.company.example';document.getElementById('agent-identity-mode').value='individual';document.getElementById('agent-identity-mode').dispatchEvent(new Event('change'));document.getElementById('agent-grant-add').click();`); + await identity('grant-author');assert.equal(await evaluate('typeof require'),'undefined');for(const scope of ['history:list','history:go','window:state','window:control','vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit'])assert.deepEqual(await evaluate(`(()=>{const field=document.getElementById('agent-scope-${scope}');return {present:!!field,checked:field.checked,disabled:field.disabled};})()`),{present:true,checked:false,disabled:false});assert.equal(await evaluate(`document.getElementById('vault-controlled-submit').checked`),false);await evaluate(`document.getElementById('agent-enabled').checked=true;document.getElementById('vault-controlled-submit').checked=true;document.getElementById('vault-submit-forms').value=JSON.stringify([{id:'company-login',actionUrl:'https://login.company.example/signin',usernameField:'username',passwordField:'password'}]);document.getElementById('agent-scope-sessions').checked=true;document.getElementById('agent-scope-read').checked=true;for(const scope of ['vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit'])document.getElementById('agent-scope-'+scope).checked=true;document.getElementById('agent-hosts').value='*.company.example';document.getElementById('agent-identity-mode').value='individual';document.getElementById('agent-identity-mode').dispatchEvent(new Event('change'));document.getElementById('agent-grant-add').click();`); assert.equal(await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor .checks input')).every(x=>!x.checked)`),true);assert.equal(await evaluate(`document.getElementById('agent-vault-help').textContent.includes('A person must unlock')&&document.getElementById('agent-vault-help').textContent.includes('cannot unlock it or export passwords')`),true);assert.equal(await evaluate(`document.getElementById('agent-grants-editor').textContent.includes('human unlock')`),true);assert.equal(await evaluate(`document.getElementById('agent-sessions-help').textContent.includes('no passwords, form values or page state')&&document.getElementById('agent-sessions-help').textContent.includes('never restores revoked agent authority')`),true); - await evaluate(`const box=document.querySelector('#agent-grants-editor>fieldset'),fields=box.querySelectorAll('.grid input,.grid textarea');fields[0].value='research-agent';fields[0].dispatchEvent(new Event('input'));fields[1].value='Research Agent';fields[1].dispatchEvent(new Event('input'));fields[2].checked=true;fields[2].dispatchEvent(new Event('change'));fields[3].value='2099-12-31T23:59:59Z';fields[3].dispatchEvent(new Event('input'));fields[4].value='*.company.example';fields[4].dispatchEvent(new Event('input'));for(const label of box.querySelectorAll('.checks label'))if(['sessions','read','vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore'].includes(label.textContent)){const input=label.querySelector('input');input.checked=true;input.dispatchEvent(new Event('change'));}document.getElementById('publish').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v1');await selectReview();assert.equal(await evaluate(`document.getElementById('independent-review-approve').disabled`),true); + await evaluate(`const box=document.querySelector('#agent-grants-editor>fieldset'),fields=box.querySelectorAll('.grid input,.grid textarea');fields[0].value='research-agent';fields[0].dispatchEvent(new Event('input'));fields[1].value='Research Agent';fields[1].dispatchEvent(new Event('input'));fields[2].checked=true;fields[2].dispatchEvent(new Event('change'));fields[3].value='2099-12-31T23:59:59Z';fields[3].dispatchEvent(new Event('input'));fields[4].value='*.company.example';fields[4].dispatchEvent(new Event('input'));for(const label of box.querySelectorAll('.checks label'))if(['sessions','read','vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit'].includes(label.textContent)){const input=label.querySelector('input');input.checked=true;input.dispatchEvent(new Event('change'));}document.getElementById('publish').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v1');await selectReview();assert.equal(await evaluate(`document.getElementById('independent-review-approve').disabled`),true); assert.equal(await evaluate(`fetch('/api/admin/policy-reviews/'+selectedIndependentReview.id+'/approve',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({payloadHash:selectedIndependentReview.payloadHash,expectedVersion:selectedIndependentReview.expectedVersion,approve:true})}).then(r=>r.status)`),403); - await identity('grant-reviewer');await selectReview();const diff=await evaluate(`document.getElementById('independent-review-diff').textContent`);for(const text of ['/agents/grants','research-agent','2099','company.example','sessions','read','generation','vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore'])assert.ok(diff.includes(text),text);await capture('artifacts/agent-grants-review-admin.png','independent-review-detail');await approve(2); - await identity('grant-auditor');for(const scope of ['vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore'])assert.equal(await evaluate(`document.getElementById('agent-scope-${scope}').disabled`),true);assert.equal(await evaluate(`document.getElementById('agent-grant-add').disabled&&document.getElementById('agent-identity-mode').disabled`),true);assert.equal(await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor input, #agent-grants-editor textarea, #agent-grants-editor button')).every(x=>x.disabled)`),true); + await identity('grant-reviewer');await selectReview();const diff=await evaluate(`document.getElementById('independent-review-diff').textContent`);for(const text of ['/agents/grants','research-agent','2099','company.example','sessions','read','generation','controlledSubmission','company-login','vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit'])assert.ok(diff.includes(text),text);await capture('artifacts/agent-grants-review-admin.png','independent-review-detail');await approve(2); + await capture('artifacts/credential-submission-admin-controls.png','password-vault');await identity('grant-auditor');for(const id of ['vault-controlled-submit','vault-submit-forms'])assert.equal(await evaluate(`document.getElementById('${id}').disabled`),true);for(const scope of ['vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit'])assert.equal(await evaluate(`document.getElementById('agent-scope-${scope}').disabled`),true);assert.equal(await evaluate(`document.getElementById('agent-grant-add').disabled&&document.getElementById('agent-identity-mode').disabled`),true);assert.equal(await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor input, #agent-grants-editor textarea, #agent-grants-editor button')).every(x=>x.disabled)`),true); await identity('grant-author');assert.equal(await evaluate(`document.querySelector('#agent-grants-editor .grid input').readOnly`),true);await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor button')).find(x=>x.textContent==='Invalidate previous credentials').click();document.getElementById('publish').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v2');await identity('grant-reviewer');await selectReview();assert.ok((await evaluate(`document.getElementById('independent-review-diff').textContent`)).includes('"generation":2'));await approve(3); await identity('grant-author');await evaluate(`document.getElementById('brand-name').value='Unpublished preserved draft';document.getElementById('history-load').click()`);await waitFor(`document.querySelectorAll('#history-rows tr').length===3`);await evaluate(`document.querySelectorAll('#history-rows button')[1].click()`);await waitFor(`!document.getElementById('history-review').hidden`);assert.equal(await evaluate(`document.getElementById('brand-name').value`),'Unpublished preserved draft');assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v3');assert.equal(await evaluate(`document.getElementById('history-restore').disabled`),true);const history=await evaluate(`document.getElementById('history-diff-list').textContent`);assert.ok(history.includes('/agents/grants'));assert.ok(history.includes('generation'));assert.ok((await evaluate(`document.getElementById('history-review-summary').textContent`)).includes('does not restore invalidated credentials'));await capture('artifacts/agent-grants-history-admin.png','history-review');await evaluate(`document.getElementById('history-approve').checked=true;document.getElementById('history-approve').dispatchEvent(new Event('change'));document.getElementById('history-restore').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v3');await selectReview();assert.equal(await evaluate(`document.getElementById('independent-review-approve').disabled`),true);await identity('grant-reviewer');await selectReview();assert.ok((await evaluate(`document.getElementById('independent-review-diff').textContent`)).includes('research-agent'));await approve(4); for(let n=0;n<100&&proofTraceIds.some(row=>row.outcome==='pending');n++)await new Promise(r=>setTimeout(r,50));assert.equal(proofTraceIds.some(row=>row.outcome==='pending'),false,'API requests must settle before telemetry shutdown'); diff --git a/scripts/agent-grants-history-admin-smoke.mjs b/scripts/agent-grants-history-admin-smoke.mjs index 60ed067..9a64386 100644 --- a/scripts/agent-grants-history-admin-smoke.mjs +++ b/scripts/agent-grants-history-admin-smoke.mjs @@ -16,6 +16,7 @@ try { const config={publicUrl:'http://127.0.0.1',secure:false,requireIndependentPolicyApproval:true};server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));config.publicUrl=`http://127.0.0.1:${server.address().port}`; const env={...process.env,SAFETYNET_ADMIN_SMOKE_PROFILE:join(directory,'browser'),SAFETYNET_ADMIN_SMOKE_URL:config.publicUrl};delete env.ELECTRON_RUN_AS_NODE; child=spawn(require('electron'),['scripts/agent-grants-history-admin-smoke.cjs'],{env,windowsHide:true,stdio:['ignore','pipe','pipe']});child.stdout.on('data',d=>{if(String(d).includes('GRANT_ADMIN_OK'))console.log('Grant admin actual UI passed');});child.stderr.on('data',d=>{if(String(d).includes('GRANT_ADMIN_FAILED'))console.error(String(d).trim());});const timer=setTimeout(()=>child.kill(),60000),code=await new Promise(r=>{child.once('exit',c=>r(c??1));child.once('error',()=>r(1));});clearTimeout(timer);assert.equal(code,0); - const p=verifyPolicy(store.envelope(),store.publicKey());assert.equal(p.version,4);assert.equal(p.agents.grants[0].generation,1);assert.equal(p.agents.identityMode,'individual');for(const scope of ['vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore']){assert.ok(p.agents.scopes.includes(scope));assert.ok(p.agents.grants[0].scopes.includes(scope));} - const traces=JSON.parse(readFileSync('artifacts/agent-grants-admin-request-trace-ids.json','utf8'));await sdk.shutdown();await new Promise(r=>setTimeout(r,100));const exported=[],failedAttempts=traces.filter(item=>item.outcome==='failed');assert.ok(traces.some(item=>item.outcome==='completed'),'Completed API requests required');assert.equal(traces.some(item=>item.outcome!=='completed'&&['POST','PUT','PATCH','DELETE'].includes(item.method)),false,'Every policy mutation must complete and export a correlated span');for(const item of traces.filter(item=>item.outcome==='completed')){const r=await fetch(`http://127.0.0.1:4318/api/trace/${item.traceId}`);assert.equal(r.ok,true,'Completed API request trace must be available');if(r.ok){const trace=await r.json();assert.ok(trace.spans.some(s=>s.name==='server.request'&&s.parentSpanId===item.parentSpanId&&s.durationMs>=0));exported.push({traceId:item.traceId,parentSpanId:item.parentSpanId,trace});}}assert.ok(exported.length,'Actual request traces required');mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/agent-grants-history-admin-traces.json',JSON.stringify(exported,null,2));writeFileSync('artifacts/agent-grants-history-admin-proof.json',JSON.stringify({actualUi:true,vaultScopesDefaultOffAndReviewed:true,sessionScopesDefaultOffAndReviewed:true,separateAuthorReviewer:true,selfApprovalDenied:true,auditorReadonly:true,exactGrantDiff:true,rotationReviewed:true,historyDraftPreserved:true,restoreReviewed:true,finalVersion:p.version,completedRequests:traces.filter(item=>item.outcome==='completed').length,failedAttempts:failedAttempts.map(item=>({traceId:item.traceId,outcome:item.outcome,method:item.method,route:item.route})),traceIds:exported.map(x=>x.traceId)},null,2));console.log('Grant publication/history and exported traces verified'); + const p=verifyPolicy(store.envelope(),store.publicKey());assert.equal(p.version,4);assert.equal(p.agents.grants[0].generation,1);assert.equal(p.agents.identityMode,'individual');for(const scope of ['vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit']){assert.ok(p.agents.scopes.includes(scope));assert.ok(p.agents.grants[0].scopes.includes(scope));} + assert.equal(p.passwordManager.controlledSubmission.enabled,true);assert.equal(p.passwordManager.controlledSubmission.forms[0].actionUrl,'https://login.company.example/signin'); + const traces=JSON.parse(readFileSync('artifacts/agent-grants-admin-request-trace-ids.json','utf8'));await sdk.shutdown();await new Promise(r=>setTimeout(r,100));const exported=[],failedAttempts=traces.filter(item=>item.outcome==='failed');assert.ok(traces.some(item=>item.outcome==='completed'),'Completed API requests required');assert.equal(traces.some(item=>item.outcome!=='completed'&&['POST','PUT','PATCH','DELETE'].includes(item.method)),false,'Every policy mutation must complete and export a correlated span');for(const item of traces.filter(item=>item.outcome==='completed')){const r=await fetch(`http://127.0.0.1:4318/api/trace/${item.traceId}`);assert.equal(r.ok,true,'Completed API request trace must be available');if(r.ok){const trace=await r.json();assert.ok(trace.spans.some(s=>s.name==='server.request'&&s.parentSpanId===item.parentSpanId&&s.durationMs>=0));exported.push({traceId:item.traceId,parentSpanId:item.parentSpanId,trace});}}assert.ok(exported.length,'Actual request traces required');mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/agent-grants-history-admin-traces.json',JSON.stringify(exported,null,2));writeFileSync('artifacts/agent-grants-history-admin-proof.json',JSON.stringify({actualUi:true,vaultScopesDefaultOffAndReviewed:true,sessionScopesDefaultOffAndReviewed:true,controlledCredentialPolicyReviewed:true,prepareSubmitScopeDefaultOffAndReviewed:true,separateAuthorReviewer:true,selfApprovalDenied:true,auditorReadonly:true,exactGrantDiff:true,rotationReviewed:true,historyDraftPreserved:true,restoreReviewed:true,finalVersion:p.version,completedRequests:traces.filter(item=>item.outcome==='completed').length,failedAttempts:failedAttempts.map(item=>({traceId:item.traceId,outcome:item.outcome,method:item.method,route:item.route})),traceIds:exported.map(x=>x.traceId)},null,2));console.log('Grant publication/history and exported traces verified'); } finally {child?.kill();if(server)await new Promise(r=>{server.closeAllConnections?.();server.close(r);});store?.db.close();const target=resolve(directory);if(!target.startsWith(resolve(tmpdir())+sep)||!basename(target).startsWith('safetynet-agent-grant-admin-'))throw Error('Unsafe cleanup');rmSync(target,{recursive:true,force:true,maxRetries:10,retryDelay:100});} diff --git a/scripts/controlled-login-smoke.cjs b/scripts/controlled-login-smoke.cjs new file mode 100644 index 0000000..bdc07ec --- /dev/null +++ b/scripts/controlled-login-smoke.cjs @@ -0,0 +1,27 @@ +'use strict'; +const {app,BrowserWindow,session}=require('electron'),fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'),{X509Certificate}=require('node:crypto'); +app.setPath('userData',process.env.SAFETYNET_SMOKE_USERDATA);app.disableHardwareAcceleration();let fixtureCredentialHeader=false;const headerFacts=[]; +// Inject one synthetic header at the native fixture boundary; retain the actual +// main-process listener and its original cancellation/release callback. +app.whenReady().then(()=>{const requests=session.fromPartition('persist:safetynet-managed').webRequest,original=requests.onBeforeSendHeaders.bind(requests);requests.onBeforeSendHeaders=(filter,listener)=>original(filter,(details,callback)=>{if(details.url.endsWith('/public')){const entries=Object.entries(details.requestHeaders),one=entries.find(([name])=>name.toLowerCase()==='x-credential-one'),two=entries.find(([name])=>name.toLowerCase()==='x-credential-two'),known='fixtureControlledCredentialDelta0123456789';if(one&&two){headerFacts.push({names:entries.map(([name])=>name),fragmentOneIndex:entries.indexOf(one),fragmentTwoIndex:entries.indexOf(two),fullJoinMatch:entries.map(([,value])=>value).join('').includes(known),pairJoinMatch:(one[1]+two[1])===known,reversePairJoinMatch:(two[1]+one[1])===known});fs.writeFileSync(path.resolve('artifacts/controlled-login-header-facts.json'),JSON.stringify(headerFacts,null,2));}}if(fixtureCredentialHeader&&details.resourceType==='mainFrame'&&details.method==='POST')details.requestHeaders['X-Synthetic-Credential']='sk-syntheticControlledLoginCredential0123456789';listener(details,callback);});});require('../apps/browser/main.cjs'); +const wait=ms=>new Promise(r=>setTimeout(r,ms)),base=process.env.SAFETYNET_SMOKE_SERVER,origin=process.env.SAFETYNET_SMOKE_ORIGIN,headers={authorization:`Bearer ${process.env.SAFETYNET_SMOKE_ADMIN}`,'content-type':'application/json'}; +async function until(fn){for(let i=0;i<200;i++){const r=await fn();if(r)return r;await wait(100);}throw Error('Controlled login fixture timeout');} +app.whenReady().then(async()=>{const pages=[];let stage='startup';try{const win=await until(()=>{const w=BrowserWindow.getAllWindows()[0];return w&&!w.webContents.isLoading()&&w.webContents.getURL().endsWith('index.html')&&w;});const fingerprint=new X509Certificate(fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem'))).fingerprint256;session.fromPartition('persist:safetynet-managed').setCertificateVerifyProc((req,callback)=>callback(req.hostname==='127.0.0.1'&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint?0:-2));const ui=async(method,value)=>{const result=await win.webContents.executeJavaScript(`window.safetynet.${method}(${value===undefined?'':JSON.stringify(value)})`);if(!result.ok)throw Object.assign(Error('Native UI operation failed'),{operation:method,reason:/not managed|not ready|paused/i.test(result.error||'')?'not-ready':/context.*changed/i.test(result.error||'')?'context-changed':/BLOCKED_BY_CLIENT/i.test(result.error||'')?'native-request-blocked':/ERR_ABORTED/i.test(result.error||'')?'native-navigation-aborted':'ui-operation-refused'});return result.data;};const overview=async()=>(await fetch(base+'/api/admin/overview',{headers})).json();stage='enrollment';await ui('enroll',{server:base,code:process.env.SAFETYNET_SMOKE_CODE});const page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;pages.push(page);await until(()=>!page.isLoading()&&page.getURL()===origin+'/login');const a=await ui('agentConnection','vault-a'),b=await ui('agentConnection','vault-b'),requestFile=path.join(app.getPath('userData'),'controlled-request.json');async function call(connection,action,args={}){fs.writeFileSync(requestFile,JSON.stringify({action,args}));const child=require('node:child_process').spawn(process.execPath,['scripts/agent-client.cjs','--connection',connection.credentialPath,'--request',requestFile],{windowsHide:true,stdio:['ignore','pipe','pipe']});let output='';child.stdout.on('data',v=>output+=v);child.stderr.on('data',()=>{});const exit=await new Promise(r=>{child.once('exit',r);child.once('error',()=>r(1));});return {exit,result:exit===0?JSON.parse(output).result:undefined};}const sessionId=(await call(a,'sessions')).result[0].id,master='Synthetic master password for controlled native proof',password='sk-syntheticControlledLoginCredential0123456789',username='public-native-user',alternatePassword='fixtureControlledCredentialDelta0123456789';stage='human unlock';await ui('vaultCreate',master);const saved=await ui('vaultSave',{origin,username,password,label:'Approved synthetic login'});const reset=async(mode='200')=>{await ui('navigate',origin+'/login/'+mode);await until(()=>!page.isLoading()&&page.getURL()===origin+'/login/'+mode);await ui('vaultUnlock',master);win.focus();page.focus();await wait(100);};const stats=()=>new Promise((resolve,reject)=>{const req=require('node:https').get(origin+'/stats',{ca:fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem')),checkServerIdentity:(_host,cert)=>new X509Certificate(cert.raw).fingerprint256===fingerprint?undefined:Error('Synthetic certificate mismatch')},res=>{let body='';res.on('data',v=>body+=v);res.on('end',()=>resolve(JSON.parse(body)));});req.on('error',reject);});const inventory=async()=>{const r=await call(a,'vault:list',{sessionId});assert.equal(r.exit,0);return r.result[0].credentialRef;};const submit=async()=>{const terminal=new Promise((resolve,reject)=>{const timer=setTimeout(()=>{page.removeListener('did-stop-loading',done);reject(Error('Native form navigation terminal timeout'));},22000);const done=()=>{clearTimeout(timer);resolve();};page.once('did-stop-loading',done);});await page.executeJavaScript("document.querySelector('form').requestSubmit()");await terminal;await wait(200);};const prepare=async()=>{const ref=await inventory(),r=await call(a,'vault:prepare-submit',{sessionId,credentialRef:ref});assert.equal(r.exit,0,'Agent prepare failed');assert.deepEqual(r.result,{completed:true,prepared:true});return ref;};const count=async()=>{const result=await fetch(base+'/api/admin/overview',{headers});assert.equal(result.status,200);return (await result.json()).events.filter(e=>e.kind==='vault.submission-authorized').length;}; + stage='ordinary fill remains DLP blocked';await reset();assert.equal((await call(a,'vault:fill',{sessionId,credentialRef:await inventory()})).exit,0);await submit();assert.equal((await stats()).submitted,0); + stage='agent prepare does not submit';await reset();let ref=await prepare();assert.equal((await stats()).submitted,0);assert.equal((await call(a,'vault:prepare-submit',{sessionId,credentialRef:ref})).exit,1);assert.equal((await call(b,'vault:prepare-submit',{sessionId,credentialRef:await inventory()})).exit,1);await submit();await until(()=>!page.isLoading()&&page.getURL()===origin+'/signin');assert.equal((await stats()).submitted,1); + stage='controlled302 safeGET';await reset('302');await prepare();await submit();await until(()=>!page.isLoading()&&page.getURL()===origin+'/final');assert.equal((await stats()).submitted,2);assert.equal((await stats()).finalGets,1); + stage='controlled307 body blocked';await reset('307');await prepare();await submit();assert.equal((await stats()).submitted,3);assert.equal((await stats()).replayed,0);stage='slow200 completes after lease expiry';await reset('slow');await prepare();await submit();await until(()=>!page.isLoading()&&page.getURL()===origin+'/signin');assert.equal((await stats()).submitted,4); + const negative=async(name,mutate)=>{stage=name;await reset();await prepare();await mutate();await submit();assert.equal((await stats()).submitted,4,name);}; + await negative('confidential residual blocked',()=>page.executeJavaScript("const e=document.createElement('input');e.name='note';e.value='synthetic-company-confidential-value';document.querySelector('form').append(e)")); + await negative('local YARA residual blocked',()=>page.executeJavaScript("const e=document.createElement('input');e.name='note';e.value='EXTRA_YARA_RESIDUAL_SECRET';document.querySelector('form').append(e)"));await negative('ICAP residual blocked',()=>page.executeJavaScript("const e=document.createElement('input');e.name='note';e.value='EXTRA_ICAP_RESIDUAL_SECRET';document.querySelector('form').append(e)"));assert.equal((await stats()).icap.blocks,1);assert.equal((await stats()).icap.credentialObserved,false);await negative('duplicate password blocked',()=>page.executeJavaScript("const e=document.createElement('input');e.name='password';e.value=document.querySelector('[name=password]').value;document.querySelector('form').append(e)")); + await negative('changed action blocked',()=>page.executeJavaScript("document.querySelector('form').action='/other'")); + await negative('vault lock invalidates',()=>ui('vaultLock'));await negative('credential header blocked',async()=>{fixtureCredentialHeader=true;});fixtureCredentialHeader=false; + stage='foreground swap invalidates';await reset();await prepare();await ui('createTab',{url:origin+'/login/200'});const other=win.contentView.children.find(v=>v.webContents!==win.webContents&&v.webContents!==page).webContents;pages.push(other);await until(()=>!other.isLoading());await ui('activateTab',sessionId);await ui('vaultUnlock',master);await submit();assert.equal((await stats()).submitted,4); + const change=async modify=>{const p=(await overview()).policy;modify(p);p.version++;const proposed=await fetch(base+'/api/admin/policy',{method:'PUT',headers,body:JSON.stringify(p)});assert.equal(proposed.status,202);const review=await proposed.json();const approval=await fetch(base+`/api/admin/policy-reviews/${review.reviewId}/approve`,{method:'POST',headers:{cookie:`sn_session=${process.env.SAFETYNET_SMOKE_REVIEWER}`,origin:base,'content-type':'application/json'},body:JSON.stringify({payloadHash:review.payloadHash,expectedVersion:review.expectedVersion,approve:true})});assert.equal(approval.status,200);await until(async()=>{const s=await ui('state');return s.ready&&s.policyVersion===p.version;});}; + stage='central disable invalidates';await reset();await prepare();await change(p=>p.passwordManager.controlledSubmission.enabled=false);await ui('vaultUnlock',master);await win.webContents.executeJavaScript("document.getElementById('passwords').click()");await until(()=>win.webContents.executeJavaScript("!document.getElementById('vault').hidden&&document.querySelectorAll('#vault-list .login-card').length>0"));assert.equal(await win.webContents.executeJavaScript("[...document.querySelectorAll('#vault-list button')].filter(b=>b.textContent==='Prepare approved login').every(b=>b.hidden)"),true);await win.webContents.executeJavaScript("document.getElementById('vault-close').click()");await until(()=>win.webContents.executeJavaScript("document.getElementById('vault').hidden"));await page.executeJavaScript(`document.querySelector('[name=username]').value=${JSON.stringify(username)};document.querySelector('[name=password]').value=${JSON.stringify(password)}`);await submit();assert.equal((await stats()).submitted,4);await change(p=>p.passwordManager.controlledSubmission.enabled=true); + stage='target revoke invalidates';await reset();await prepare();await change(p=>p.agents.grants.find(g=>g.id==='vault-a').enabled=false);assert.equal((await call(a,'vault:status',{sessionId})).exit,1);await reset();await page.executeJavaScript(`document.querySelector('[name=username]').value=${JSON.stringify(username)};document.querySelector('[name=password]').value=${JSON.stringify(password)}`);await submit();assert.equal((await stats()).submitted,4); + stage='unregistered credential URL escape blocked';await reset();await ui('vaultSave',{id:saved.id,origin,username,password:alternatePassword,label:'Approved synthetic login'});await ui('vaultPrepareSubmit',saved.id);const getTerminal=new Promise((resolve,reject)=>{const timer=setTimeout(()=>{page.removeListener('did-stop-loading',done);reject(Error('Native credential GET terminal timeout'));},15000);const done=()=>{clearTimeout(timer);resolve();};page.once('did-stop-loading',done);});await page.executeJavaScript(`location.href='/leak?value='+encodeURIComponent(${JSON.stringify(alternatePassword)})`);await getTerminal;await wait(200);assert.equal((await stats()).credentialUrlLeaks,0); + stage='unregistered split credential headers blocked';await reset();await ui('vaultPrepareSubmit',saved.id);const split=Math.floor(alternatePassword.length/2),headerResult=await page.executeJavaScript(`fetch('/public',{method:'POST',body:'note=public',headers:{'Content-Type':'application/x-www-form-urlencoded','X-Credential-One':${JSON.stringify(alternatePassword.slice(0,split))},'X-Credential-Two':${JSON.stringify(alternatePassword.slice(split))}}}).then(()=>true,()=>false)`);if(headerResult!==false){await wait(1500);const recent=(await overview()).events,last=recent.find(e=>e.kind==='upload.authorized'&&e.metadata.traceId);if(last){const escapedTrace=await(await fetch('http://127.0.0.1:4318/api/trace/'+last.metadata.traceId)).json();for(const sentinel of [master,password,username,alternatePassword])assert.ok(!JSON.stringify(escapedTrace).includes(sentinel));fs.writeFileSync(path.resolve('artifacts/controlled-login-escaped-header-trace.json'),JSON.stringify({traceId:last.metadata.traceId,spans:escapedTrace.spans.map(({name,operation,spanId,parentSpanId,durationMs})=>({name,operation,spanId,parentSpanId,durationMs}))},null,2));}}assert.equal(headerResult,false);assert.equal((await stats()).credentialHeaderLeaks,0);assert.equal((await stats()).publicPosts,0);assert.equal((await stats()).totalPosts,4);assert.equal((await stats()).submitted,4);await ui('vaultSave',{id:saved.id,origin,username,password,label:'Approved synthetic login'}); + stage='human rendered panel handoff';await reset();await win.webContents.executeJavaScript("document.getElementById('passwords').click()");await until(()=>win.webContents.executeJavaScript("!document.getElementById('vault').hidden"));await until(()=>win.webContents.executeJavaScript("[...document.querySelectorAll('#vault-list button')].some(b=>b.textContent==='Prepare approved login'&&!b.hidden)"));await win.webContents.executeJavaScript("document.querySelectorAll('#vault-list .login-card p:nth-of-type(2)').forEach(e=>e.style.color='transparent')");fs.writeFileSync(path.resolve('artifacts/controlled-login-browser-vault.png'),(await win.webContents.capturePage()).toPNG());await win.webContents.executeJavaScript("document.getElementById('vault-master').value='synthetic-unused-master-input';document.getElementById('vault-password').value='synthetic-unused-save-input'");await ui('vaultPrepareSubmit',saved.id);assert.equal(await win.webContents.executeJavaScript("document.getElementById('vault').hidden&&document.getElementById('vault-master').value===''&&document.getElementById('vault-password').value===''"),true);assert.equal((await ui('vaultStatus')).locked,false);assert.equal((await stats()).submitted,4);await ui('vaultLock'); + stage='audit and actual traces';const events=[];let after=0;for(let batch=0;batch<20;batch++){const response=await fetch(base+`/api/admin/events/export?after=${after}&limit=1000&format=ecs`,{headers});assert.equal(response.status,200);const rows=(await response.text()).trim().split('\n').filter(Boolean).map(line=>JSON.parse(line));for(const row of rows){for(const sentinel of [master,password,username,alternatePassword])assert.ok(!JSON.stringify(row).includes(sentinel));events.push({kind:row.event.action,metadata:{traceId:row.trace?.id,ruleId:row.rule?.id}});}const next=Number(response.headers.get('x-safetynet-next-cursor'));if(rows.length<1000)break;assert.ok(next>after);after=next;}assert.ok(events.some(e=>e.kind==='dlp.block'&&e.metadata.ruleId==='controlled-residual-yara'));for(const e of events)for(const sentinel of [master,password,username,alternatePassword])assert.ok(!JSON.stringify(e).includes(sentinel));await wait(1500);const completed=events.find(e=>e.kind==='vault.submission-completed'&&e.metadata.traceId);assert.ok(completed);const trace=await(await fetch('http://127.0.0.1:4318/api/trace/'+completed.metadata.traceId)).json(),required=['credential.claim','credential.parse','credential.match','credential.residual','credential.headers','credential.header-values','credential.authorize','credential.complete'];for(const operation of required)assert.ok(trace.spans.some(s=>s.name==='browser.credential'&&s.operation===operation),operation);const byId=new Map(trace.spans.map(s=>[s.spanId,s]));for(const operation of required){let span=trace.spans.find(s=>s.name==='browser.credential'&&s.operation===operation),found=false;const seen=new Set();while(span&&!seen.has(span.spanId)){if(span.name==='browser.request'){found=true;break;}seen.add(span.spanId);span=byId.get(span.parentSpanId);}assert.ok(found,'Missing request ancestor: '+operation);}assert.ok(trace.spans.some(s=>s.name==='browser.inspect.local'));assert.ok(trace.spans.some(s=>s.name==='browser.inspect.enterprise'));for(const operation of ['audit.serialize','audit.encrypt','audit.write','audit.fsync','audit.commit'])assert.ok(trace.spans.some(s=>s.name==='browser.security'&&s.operation===operation));for(const sentinel of [master,password,username,alternatePassword])assert.ok(!JSON.stringify(trace).includes(sentinel));const proofs=[{traceId:completed.metadata.traceId,commonLifecycleTrace:true,stages:trace.spans.map(({name,operation,spanId,parentSpanId,durationMs})=>({name,operation,spanId,parentSpanId,durationMs}))}];const terminal=events.filter(e=>e.kind==='vault.submission-completed');assert.ok(terminal.length>=3);const receiver=await stats(),receiverFacts={totalPosts:receiver.totalPosts,credentialPosts:receiver.submitted,publicPosts:receiver.publicPosts,replayedPosts:receiver.replayed,credentialUrlLeaks:receiver.credentialUrlLeaks,credentialHeaderLeaks:receiver.credentialHeaderLeaks};assert.equal(receiverFacts.totalPosts,4);assert.equal(receiverFacts.publicPosts,0);const scannerFacts=receiver.icap;assert.equal(scannerFacts.credentialObserved,false);const health=await(await fetch('http://127.0.0.1:4318/health')).json();assert.equal(health.rejectedWrites,0);const summary=await(await fetch('http://127.0.0.1:4318/api/summary')).json();fs.writeFileSync(path.resolve('artifacts/controlled-login-native-proof.json'),JSON.stringify({actualElectron:true,exactPinnedSyntheticHTTPS:true,ordinaryFillDLPBlocked:true,agentPrepareNeverSubmits:true,approvedPOST200:true,approvedPOST302SafeGET:true,redirect307BodyBlocked:true,residualBlocked:true,duplicatePasswordBlocked:true,changedActionBlocked:true,nativeCredentialHeaderBlocked:true,unregisteredCredentialUrlBlocked:true,unregisteredSplitHeadersBlocked:true,centralPrepareControlHidden:true,vaultLockInvalidates:true,foregroundSwapInvalidates:true,centralDisableInvalidates:true,targetRevokeInvalidates:true,referenceReplayDenied:true,humanUIFieldsCleared:true,handoffDoesNotLock:true,ordinaryAuditSecretFree:true,localYaraResidualBlocked:true,icapResidualBlocked:true,icapCredentialFree:true,slowTerminalAfterExpiry:true,receiverFacts,scannerFacts,headerFacts,summary,health:{storedBytes:health.storedBytes,rejectedWrites:health.rejectedWrites},proofs},null,2));console.log('Controlled login native proof passed');for(const p of pages)if(!p.isDestroyed())require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(p);app.exit(0); + }catch(error){fs.writeFileSync(path.resolve('artifacts/controlled-login-native-error.json'),JSON.stringify({stage,name:error.name,message:error.message,operation:error.operation,reason:error.reason,headerFacts},null,2));console.error('Controlled login native failed at '+stage+': '+error.name);for(const p of pages)if(!p.isDestroyed())require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(p);app.exit(1);}}); diff --git a/scripts/controlled-login-smoke.mjs b/scripts/controlled-login-smoke.mjs new file mode 100644 index 0000000..0771ad9 --- /dev/null +++ b/scripts/controlled-login-smoke.mjs @@ -0,0 +1,25 @@ +import {createServer} from 'node:https'; +import {createRequire} from 'node:module'; +import {mkdtempSync,mkdirSync,rmSync,readFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join,resolve,sep} from 'node:path'; +import {spawn} from 'node:child_process'; +import {randomBytes} from 'node:crypto'; +import net from 'node:net'; +import {createApp} from '../apps/server/app.mjs'; +import {openStore,digest} from '../apps/server/store.mjs'; +import {startTelemetry} from '../apps/server/telemetry.mjs'; +const collector='http://127.0.0.1:4318';if(!(await fetch(collector+'/health')).ok)throw Error('Protected collector unavailable'); +process.env.OTEL_EXPORTER_OTLP_ENDPOINT=collector;const telemetry=startTelemetry(); +const temporary=mkdtempSync(join(tmpdir(),'safetynet-controlled-login-'));mkdirSync('artifacts',{recursive:true}); +if(!resolve(temporary).startsWith(resolve(tmpdir())+sep)||!temporary.split(/[\\/]/).at(-1).startsWith('safetynet-controlled-login-'))throw Error('Unsafe temporary fixture path'); +const icapFacts={scans:0,blocks:0,credentialObserved:false},icapSockets=new Set();const icap=net.createServer(socket=>{icapSockets.add(socket);socket.on('close',()=>icapSockets.delete(socket));socket.on('error',()=>{});let input=Buffer.alloc(0),done=false;socket.on('data',chunk=>{input=Buffer.concat([input,chunk]);if(input.length>262144){socket.destroy();return;}if(done||input.indexOf('\r\n\r\n')<0)return;const options=input.subarray(0,7).toString()==='OPTIONS';if(!options&&!input.subarray(-5).equals(Buffer.from('0\r\n\r\n')))return;done=true;if(options){input.fill(0);socket.end('ICAP/1.0 200 OK\r\nMethods: RESPMOD, REQMOD\r\nAllow: 204\r\nISTag: "controlled-fixture"\r\nEncapsulated: null-body=0\r\n\r\n');return;}icapFacts.scans++;if(input.includes(Buffer.from('sk-syntheticControlledLoginCredential0123456789'))||input.includes(Buffer.from('public-native-user'))||input.includes(Buffer.from('fixtureControlledCredentialDelta0123456789')))icapFacts.credentialObserved=true;const blocked=input.includes(Buffer.from('EXTRA_ICAP_RESIDUAL_SECRET'));if(blocked)icapFacts.blocks++;input.fill(0);socket.end(blocked?'ICAP/1.0 204 No Content\r\nISTag: "controlled-fixture"\r\nX-Infection-Found: Type=0; Threat=synthetic;\r\n\r\n':'ICAP/1.0 204 No Content\r\nISTag: "controlled-fixture"\r\n\r\n');});}).listen(0,'127.0.0.1');await new Promise(r=>icap.once('listening',r)); +let mode='200',submitted=0,totalPosts=0,replayed=0,finalGets=0,credentialUrlLeaks=0,credentialHeaderLeaks=0,publicPosts=0;const sink=createServer({cert:readFileSync('tests/fixtures/icap/cert.pem'),key:readFileSync('tests/fixtures/icap/key.pem')},(req,res)=>{if(req.url.includes('fixtureControlledCredentialDelta0123456789'))credentialUrlLeaks++;const values=Object.values(req.headers).flat().map(value=>String(value)),known='fixtureControlledCredentialDelta0123456789';if(values.some(value=>value.includes(known))||values.some((a,i)=>values.some((b,j)=>i!==j&&a+b===known)))credentialHeaderLeaks++;if(req.url==='/stats'){res.setHeader('content-type','application/json');res.end(JSON.stringify({submitted,totalPosts,replayed,finalGets,credentialUrlLeaks,credentialHeaderLeaks,publicPosts,icap:icapFacts}));return;}if(req.method==='POST'){totalPosts++;if(req.url==='/public')publicPosts++;const chunks=[];let size=0;req.on('data',bytes=>{size+=bytes.length;if(size>65536){req.destroy();return;}chunks.push(bytes);});req.on('end',()=>{const body=Buffer.concat(chunks);const credentialPresent=body.includes(Buffer.from('sk-syntheticControlledLoginCredential0123456789'))||body.includes(Buffer.from('fixtureControlledCredentialDelta0123456789'));body.fill(0);for(const bytes of chunks)bytes.fill(0);if(req.url==='/replay'){replayed++;res.end('unexpected replay');return;}if(credentialPresent)submitted++;if(mode==='302'||mode==='307'){res.writeHead(Number(mode),{location:mode==='302'?'/final':'/replay'});res.end();return;}res.setHeader('content-type','text/html');if(mode==='slow'){setTimeout(()=>res.end('Login acceptedDone'),17000);return;}res.end('Login acceptedDone');});return;}if(req.url==='/final'){finalGets++;res.setHeader('content-type','text/html');res.end('Login acceptedDone');return;}mode=req.url.endsWith('/302')?'302':req.url.endsWith('/307')?'307':req.url.endsWith('/slow')?'slow':'200';res.setHeader('content-type','text/html');res.end('Approved login fixture
');}).listen(0,'127.0.0.1');await new Promise(r=>sink.once('listening',r));const origin=`https://127.0.0.1:${sink.address().port}`,store=openStore(join(temporary,'db')),code=randomBytes(32).toString('base64url'),adminToken=randomBytes(32).toString('base64url'); +const p=store.get('policy');p.browser.homeUrl=origin+'/login';p.passwordManager={enabled:true,allowSaving:true,allowAutofill:true,idleLockSeconds:300,controlledSubmission:{enabled:true,forms:[{id:'approved-login',actionUrl:origin+'/signin',usernameField:'username',passwordField:'password'}]}};p.dlp.rules.push({id:'fixture-credential',label:'Synthetic controlled credential',action:'block',kind:'literal',value:'sk-syntheticControlledLoginCredential0123456789'});p.dlp.rules.push({id:'fixture-confidential',label:'Synthetic residual secret',action:'block',kind:'literal',value:'synthetic-company-confidential-value'}); +p.security.yara.enabled=true;p.security.yara.rules=[{id:'controlled-residual-yara',name:'Synthetic residual YARA',source:'rule controlled_residual_yara { strings: $a = "EXTRA_YARA_RESIDUAL_SECRET" condition: $a }'}];p.enterprise.icap={...p.enterprise.icap,enabled:true,url:`icap://127.0.0.1:${icap.address().port}/scan`,scanUploads:true,scanDownloads:false,scanClipboard:false,scanAI:false}; +p.agents={...p.agents,enabled:true,identityMode:'individual',scopes:['sessions','vault:status','vault:list','vault:fill','vault:prepare-submit'],allowedHosts:['127.0.0.1','127.0.0.2'],grants:[{id:'vault-a',name:'Vault A',enabled:true,expiresAt:new Date(Date.now()+3600000).toISOString(),scopes:['sessions','vault:status','vault:list','vault:fill','vault:prepare-submit'],allowedHosts:['127.0.0.1'],generation:1},{id:'vault-b',name:'Vault B',enabled:true,expiresAt:new Date(Date.now()+3600000).toISOString(),scopes:['vault:status','vault:list'],allowedHosts:['127.0.0.1'],generation:1}]}; +p.diagnostics={...p.diagnostics,enabled:true,mode:'debug',endpoint:collector,sampleRate:1,debugSampleRate:1,flushIntervalMs:250};store.put('policy',p);store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,'vault-fixture'); +const config={secure:false,publicUrl:'http://127.0.0.1',adminToken,requirePQManagement:true,requireIndependentPolicyApproval:true};const management=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>management.once('listening',r));config.publicUrl=`http://127.0.0.1:${management.address().port}`; +const reviewer=randomBytes(32).toString('base64url');store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(reviewer),Date.now()+600000,JSON.stringify({sub:'independent-vault-reviewer',role:'admin'})); +const child=spawn(createRequire(import.meta.url)('electron'),['scripts/controlled-login-smoke.cjs'],{windowsHide:true,stdio:['ignore','pipe','pipe'],env:{...process.env,SAFETYNET_SMOKE_SERVER:config.publicUrl,SAFETYNET_SMOKE_CODE:code,SAFETYNET_SMOKE_ADMIN:adminToken,SAFETYNET_SMOKE_REVIEWER:reviewer,SAFETYNET_SMOKE_USERDATA:join(temporary,'browser'),SAFETYNET_SMOKE_ARTIFACTS:resolve('artifacts'),SAFETYNET_SMOKE_ORIGIN:origin}});const redact=chunk=>chunk.toString('utf8').replaceAll('sk-syntheticControlledLoginCredential0123456789','[synthetic-credential]').replaceAll('fixtureControlledCredentialDelta0123456789','[synthetic-credential]');child.stdout.on('data',chunk=>process.stdout.write(redact(chunk)));child.stderr.on('data',chunk=>process.stderr.write(redact(chunk)));const deadline=setTimeout(()=>child.kill(),180000);const exit=await new Promise(r=>{child.once('exit',v=>r(v??1));child.once('error',()=>r(1));});clearTimeout(deadline); +for(const socket of icapSockets)socket.destroy();await new Promise(r=>icap.close(r));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();await telemetry?.shutdown();rmSync(temporary,{recursive:true,force:true});if(exit===0&&(submitted!==4||replayed!==0||finalGets!==1||icapFacts.scans<4||icapFacts.blocks!==1||icapFacts.credentialObserved||credentialUrlLeaks||credentialHeaderLeaks||publicPosts||totalPosts!==4))throw Error('Unexpected controlled login receiver counts');process.exit(exit); diff --git a/scripts/credential-native-facts-smoke.cjs b/scripts/credential-native-facts-smoke.cjs new file mode 100644 index 0000000..bfe7824 --- /dev/null +++ b/scripts/credential-native-facts-smoke.cjs @@ -0,0 +1,10 @@ +'use strict'; +const {app,BrowserWindow,session,webContents}=require('electron'),fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'),{X509Certificate}=require('node:crypto'); +app.setPath('userData',process.env.SAFETYNET_SMOKE_USERDATA);app.disableHardwareAcceleration(); +const facts=[],origin=process.env.SAFETYNET_SMOKE_ORIGIN,base=process.env.SAFETYNET_SMOKE_SERVER; +const route=url=>url===origin+'/login'?'login':url===origin+'/submit'?'submit':url===origin+'/final'?'final':url?'other':'empty'; +const frame=f=>{try{return f?{processId:f.processId,routingId:f.routingId,urlKind:route(f.url)}:null;}catch{return {destroyed:true};}}; +app.whenReady().then(()=>{const ses=session.fromPartition('persist:safetynet-managed');for(const name of ['onBeforeRequest','onBeforeSendHeaders']){const original=ses.webRequest[name].bind(ses.webRequest);ses.webRequest[name]=function(filter,listener){if(typeof filter==='function'){listener=filter;filter=undefined;}return original(filter,(details,callback)=>{const wc=details.webContents??(details.webContentsId?webContents.fromId(details.webContentsId):null);if(details.method==='POST'&&details.resourceType==='mainFrame')facts.push({phase:name,time:performance.now(),requestId:details.id,urlKind:route(details.url),webContentsId:details.webContentsId??null,requestFrame:frame(details.frame),mainFrame:frame(wc?.mainFrame),currentUrlKind:route(wc?.getURL())});return listener(details,response=>{if(details.method==='POST'&&details.resourceType==='mainFrame')facts.push({phase:name+'.release',time:performance.now(),requestId:details.id,urlKind:route(details.url),cancel:response?.cancel===true});callback(response);});});};} }); +require('../apps/browser/main.cjs'); +const wait=ms=>new Promise(r=>setTimeout(r,ms));async function until(fn){for(let i=0;i<200;i++){const value=await fn();if(value)return value;await wait(100);}throw Error('Native credential facts timeout');} +app.whenReady().then(async()=>{let page,stage='startup';try{const win=await until(()=>{const w=BrowserWindow.getAllWindows()[0];return w&&!w.webContents.isLoading()&&w.webContents.getURL().endsWith('index.html')&&w;});const fingerprint=new X509Certificate(fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem'))).fingerprint256;session.fromPartition('persist:safetynet-managed').setCertificateVerifyProc((req,callback)=>callback(req.hostname==='127.0.0.1'&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint?0:-2));const ui=async(method,value)=>{const result=await win.webContents.executeJavaScript(`window.safetynet.${method}(${value===undefined?'':JSON.stringify(value)})`);assert.equal(result.ok,true,method);return result.data;};stage='enrollment';await ui('enroll',{server:base,code:process.env.SAFETYNET_SMOKE_CODE});page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;await until(()=>!page.isLoading()&&page.getURL()===origin+'/login');for(const name of ['will-navigate','did-start-navigation','will-redirect','did-redirect-navigation','did-frame-navigate','did-navigate'])page.on(name,(event,...args)=>{const info=typeof args[0]==='object'?args[0]:{};facts.push({phase:name,time:performance.now(),urlKind:route(info.url??args[0]??event.url),mainFrame:frame(page.mainFrame),currentUrlKind:route(page.getURL())});});stage='public form submit';await page.executeJavaScript(`document.querySelector('[name=username]').value='fixture-public-user';document.querySelector('[name=password]').value='fixture-public-value';document.querySelector('form').requestSubmit();`);await until(()=>!page.isLoading()&&page.getURL()===origin+'/final');assert.equal(facts.filter(f=>f.phase==='onBeforeRequest').length,2);assert.equal(facts.filter(f=>f.phase==='onBeforeSendHeaders').length,2);assert.equal(facts.some(f=>f.cancel),false);await wait(1000);const headers={authorization:'Bearer '+process.env.SAFETYNET_SMOKE_ADMIN};const overview=await(await fetch(base+'/api/admin/overview',{headers})).json();const event=overview.events.find(e=>e.kind==='upload.authorized'&&e.metadata.traceId);assert.ok(event);const trace=await(await fetch('http://127.0.0.1:4318/api/trace/'+event.metadata.traceId)).json();const requestSpan=trace.spans.find(s=>s.name==='browser.request');assert.ok(requestSpan);assert.ok(trace.spans.some(s=>s.parentSpanId===requestSpan.spanId&&s.name==='browser.security'));const summary=await(await fetch('http://127.0.0.1:4318/api/summary')).json();const health=await(await fetch('http://127.0.0.1:4318/health')).json();assert.equal(health.rejectedWrites,0);const proof={actualMainGuardsPreserved:true,exactPinnedSyntheticHTTPS:true,publicSyntheticBodyOnly:true,electron:process.versions.electron,chromium:process.versions.chrome,facts,summary,traceId:event.metadata.traceId,spans:trace.spans.map(({name,operation,spanId,parentSpanId,durationMs})=>({name,operation,spanId,parentSpanId,durationMs})),health:{storedBytes:health.storedBytes,rejectedWrites:health.rejectedWrites}};fs.writeFileSync(path.resolve('artifacts/credential-native-facts-proof.json'),JSON.stringify(proof,null,2));console.log('Native credential lifecycle facts passed');require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(0);}catch(error){fs.writeFileSync(path.resolve('artifacts/credential-native-facts-error.json'),JSON.stringify({stage,name:error.name,message:error.message,facts},null,2));console.error('Credential lifecycle facts failed at '+stage+': '+error.name);if(page&&!page.isDestroyed())require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(1);}}); diff --git a/scripts/credential-native-facts-smoke.mjs b/scripts/credential-native-facts-smoke.mjs new file mode 100644 index 0000000..93c4285 --- /dev/null +++ b/scripts/credential-native-facts-smoke.mjs @@ -0,0 +1,22 @@ +import {createServer} from 'node:https'; +import {createRequire} from 'node:module'; +import {mkdtempSync,mkdirSync,rmSync,readFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join,resolve,sep} from 'node:path'; +import {spawn} from 'node:child_process'; +import {randomBytes} from 'node:crypto'; +import {createApp} from '../apps/server/app.mjs'; +import {openStore,digest} from '../apps/server/store.mjs'; +import {startTelemetry} from '../apps/server/telemetry.mjs'; +const collector='http://127.0.0.1:4318';if(!(await fetch(collector+'/health')).ok)throw Error('Protected collector unavailable'); +process.env.OTEL_EXPORTER_OTLP_ENDPOINT=collector;const telemetry=startTelemetry(); +const temporary=mkdtempSync(join(tmpdir(),'safetynet-credential-facts-'));mkdirSync('artifacts',{recursive:true}); +if(!resolve(temporary).startsWith(resolve(tmpdir())+sep)||!temporary.split(/[\\/]/).at(-1).startsWith('safetynet-credential-facts-'))throw Error('Unsafe temporary fixture path'); +let submitted=0;const sink=createServer({cert:readFileSync('tests/fixtures/icap/cert.pem'),key:readFileSync('tests/fixtures/icap/key.pem')},(req,res)=>{if(req.method==='POST'){submitted++;req.resume();if(req.url==='/submit'){res.writeHead(307,{location:'/final'});res.end();return;}res.setHeader('content-type','text/html');res.end('Public responseDone');return;}res.setHeader('content-type','text/html');res.end('Public login fixture
');}).listen(0,'127.0.0.1');await new Promise(r=>sink.once('listening',r));const origin=`https://127.0.0.1:${sink.address().port}`,store=openStore(join(temporary,'db')),code=randomBytes(32).toString('base64url'),adminToken=randomBytes(32).toString('base64url'); +const p=store.get('policy');p.browser.homeUrl=origin+'/login';p.passwordManager={enabled:true,allowSaving:true,allowAutofill:true,idleLockSeconds:300}; +p.agents={...p.agents,enabled:true,identityMode:'individual',scopes:['sessions','vault:status','vault:list','vault:fill'],allowedHosts:['127.0.0.1','127.0.0.2'],grants:[{id:'vault-a',name:'Vault A',enabled:true,expiresAt:new Date(Date.now()+3600000).toISOString(),scopes:['sessions','vault:status','vault:list','vault:fill'],allowedHosts:['127.0.0.1'],generation:1},{id:'vault-b',name:'Vault B',enabled:true,expiresAt:new Date(Date.now()+3600000).toISOString(),scopes:['vault:status','vault:list'],allowedHosts:['127.0.0.1'],generation:1}]}; +p.diagnostics={...p.diagnostics,enabled:true,mode:'debug',endpoint:collector,sampleRate:1,debugSampleRate:1,flushIntervalMs:250};store.put('policy',p);store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,'vault-fixture'); +const config={secure:false,publicUrl:'http://127.0.0.1',adminToken,requirePQManagement:true,requireIndependentPolicyApproval:true};const management=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>management.once('listening',r));config.publicUrl=`http://127.0.0.1:${management.address().port}`; +const reviewer=randomBytes(32).toString('base64url');store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(reviewer),Date.now()+600000,JSON.stringify({sub:'independent-vault-reviewer',role:'admin'})); +const child=spawn(createRequire(import.meta.url)('electron'),['scripts/credential-native-facts-smoke.cjs'],{windowsHide:true,stdio:'inherit',env:{...process.env,SAFETYNET_SMOKE_SERVER:config.publicUrl,SAFETYNET_SMOKE_CODE:code,SAFETYNET_SMOKE_ADMIN:adminToken,SAFETYNET_SMOKE_REVIEWER:reviewer,SAFETYNET_SMOKE_USERDATA:join(temporary,'browser'),SAFETYNET_SMOKE_ARTIFACTS:resolve('artifacts'),SAFETYNET_SMOKE_ORIGIN:origin}});const deadline=setTimeout(()=>child.kill(),180000);const exit=await new Promise(r=>{child.once('exit',v=>r(v??1));child.once('error',()=>r(1));});clearTimeout(deadline); +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();await telemetry?.shutdown();rmSync(temporary,{recursive:true,force:true});if(exit===0&&submitted!==2)throw Error('Credential lifecycle fixture expected initial and redirected POST');process.exit(exit); diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index f682f40..b4b26f8 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -14,7 +14,7 @@ assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize for(const file of ['apps/browser/upload-lease.cjs','apps/browser/file-upload-gate.cjs','apps/browser/request-source.cjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged upload binding source is stale: ${file}`); assert.deepEqual(await fs.readFile(path.join(root,'resources/inspector/upload_lease.py')),await fs.readFile('apps/inspector/upload_lease.py'),'Packaged Windows upload lease helper is stale'); assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize('apps/browser/header-inspection.cjs')),await fs.readFile('apps/browser/header-inspection.cjs'),'Packaged outgoing header DLP source is stale'); -for(const file of ['apps/browser/session-snapshot.cjs','apps/browser/agent-session-adapters.cjs','apps/browser/session-restore.cjs','apps/browser/vault-agent-controls.cjs','apps/browser/vault.cjs','apps/browser/window-controls.cjs','apps/browser/history-snapshot.cjs','apps/browser/human-interaction.cjs','packages/core/siem.mjs','packages/core/evidence.mjs','packages/core/evidence-payload.cjs','packages/core/interaction-facts.cjs','apps/browser/evidence-capture.cjs','apps/browser/evidence-delivery.cjs','packages/core/request-transfer-facts.cjs','packages/core/transfer-facts.cjs','packages/core/inspection-stages.cjs','apps/browser/history-navigation.cjs','apps/browser/agent-transport-client.cjs','apps/browser/agent-client.cjs','apps/browser/library-organization.cjs','packages/core/library.mjs','packages/core/branding.mjs','packages/core/file-transfer.cjs','packages/core/file-transfer.mjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged new security/branding source is stale: ${file}`); +for(const file of ['apps/browser/credential-submission.cjs','packages/core/credential-policy.mjs','apps/browser/session-snapshot.cjs','apps/browser/agent-session-adapters.cjs','apps/browser/session-restore.cjs','apps/browser/vault-agent-controls.cjs','apps/browser/vault.cjs','apps/browser/window-controls.cjs','apps/browser/history-snapshot.cjs','apps/browser/human-interaction.cjs','packages/core/siem.mjs','packages/core/evidence.mjs','packages/core/evidence-payload.cjs','packages/core/interaction-facts.cjs','apps/browser/evidence-capture.cjs','apps/browser/evidence-delivery.cjs','packages/core/request-transfer-facts.cjs','packages/core/transfer-facts.cjs','packages/core/inspection-stages.cjs','apps/browser/history-navigation.cjs','apps/browser/agent-transport-client.cjs','apps/browser/agent-client.cjs','apps/browser/library-organization.cjs','packages/core/library.mjs','packages/core/branding.mjs','packages/core/file-transfer.cjs','packages/core/file-transfer.mjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged new security/branding source is stale: ${file}`); for(const file of ['apps/browser/agent-grants.cjs','apps/browser/policy-assignment.cjs','packages/core/policy-groups.mjs','apps/browser/tab-registry.cjs','apps/browser/agent-adapters.cjs','apps/browser/agent-control.cjs','apps/browser/password-fill.cjs','apps/browser/diagnostics.cjs','packages/core/diagnostics.mjs','packages/core/security.mjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged device/session policy source is stale: ${file}`); for(const file of ['apps/browser/main.cjs','apps/browser/preload.cjs','apps/browser/site-preload.cjs','apps/browser/clipboard-deny.cjs','apps/browser/clipboard-guard.cjs','apps/browser/clipboard-dom.cjs','apps/browser/page-clipboard-guard.cjs','apps/browser/startup-security.cjs','apps/browser/visual-capture.cjs','apps/browser/renderer.js','apps/browser/index.html','apps/browser/style.css','packages/core/clipboard.mjs']){ assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged source is stale: ${file}`); diff --git a/tests/admin-controls-ui.test.mjs b/tests/admin-controls-ui.test.mjs index daed0c3..c9f6f93 100644 --- a/tests/admin-controls-ui.test.mjs +++ b/tests/admin-controls-ui.test.mjs @@ -16,3 +16,11 @@ test('new history and window capability controls require explicit opt-in and exp test('vault agent capabilities start unchecked and describe human-unlocked exact-origin access',()=>{const source=readFileSync(new URL('../apps/server/public/admin.js',import.meta.url),'utf8'),scopes=vm.runInNewContext(source.match(/const agentScopes=(\[[^;]+\]);/)[1]);for(const scope of ['vault:status','vault:list','vault:fill']){assert.ok(scopes.includes(scope));const tag=html.match(new RegExp(`]*id="agent-scope-${scope}"[^>]*>`))[0];assert.equal(/\bchecked\b/.test(tag),false);}for(const text of ['A person must unlock the local vault','agents cannot unlock it or export passwords',"credential's exact origin"])assert.ok(html.includes(text));const editor=readFileSync(new URL('../apps/server/public/agent-grants-admin.js',import.meta.url),'utf8');for(const text of ['human unlock','Agents cannot unlock the vault or export passwords'])assert.ok(editor.includes(text));for(const unsupported of ['vault:unlock','vault:export','vault:mutate'])assert.ok(!scopes.includes(unsupported));}); test('session persistence capabilities are explicit opt-in with no credential state restoration',()=>{const source=readFileSync(new URL('../apps/server/public/admin.js',import.meta.url),'utf8'),scopes=vm.runInNewContext(source.match(/const agentScopes=(\[[^;]+\]);/)[1]);for(const scope of ['sessions:save','sessions:list','sessions:restore']){assert.ok(scopes.includes(scope));const tag=html.match(new RegExp(`]*id="agent-scope-${scope}"[^>]*>`))[0];assert.equal(/\bchecked\b/.test(tag),false);}for(const text of ['Saving, listing and restoring sessions require separate capabilities','no passwords, form values or page state','never restores revoked agent authority'])assert.ok(html.includes(text));const editor=readFileSync(new URL('../apps/server/public/agent-grants-admin.js',import.meta.url),'utf8');assert.ok(editor.includes('Restoration rechecks current policy'));}); + +test('controlled credential submission and agent preparation start off and roundtrip reviewed metadata',()=>{ + const source=readFileSync(new URL('../apps/server/public/admin.js',import.meta.url),'utf8'),scopes=vm.runInNewContext(source.match(/const agentScopes=(\[[^;]+\]);/)[1]);assert.ok(scopes.includes('vault:prepare-submit')); + for(const id of ['vault-controlled-submit','agent-scope-vault:prepare-submit']){const tag=html.match(new RegExp(`]*id="${id}"[^>]*>`))[0];assert.equal(/\bchecked\b/.test(tag),false);} + for(const text of ['Ordinary autofill does not authorize submission','every other field remains subject to DLP','never submits it or exports passwords','Never enter usernames, passwords, tokens'])assert.ok(html.includes(text)); + const fields=new Map(),get=id=>{if(!fields.has(id))fields.set(id,{value:'',checked:false});return fields.get(id);};const p={passwordManager:{enabled:true,allowSaving:true,allowAutofill:true,idleLockSeconds:300,controlledSubmission:{enabled:true,forms:[{id:'login',actionUrl:'https://login.example/signin',usernameField:'username',passwordField:'password'}]}}},context=vm.createContext({$:get,p}); + const fillLine=source.split('\n').find(line=>line.includes("$('vault-enabled').checked=p.passwordManager.enabled"));vm.runInContext(fillLine,context);assert.equal(get('vault-controlled-submit').checked,true);const draftLine=source.split('\n').find(line=>line.includes("const forms=JSON.parse($('vault-submit-forms')"));vm.runInContext(draftLine,context);assert.deepEqual(JSON.parse(JSON.stringify(p.passwordManager.controlledSubmission)),{enabled:true,forms:[{id:'login',actionUrl:'https://login.example/signin',usernameField:'username',passwordField:'password'}]});assert.equal(p.passwordManager.allowAutofill,true);get('vault-submit-forms').value='{}';assert.throws(()=>vm.runInContext(draftLine,vm.createContext({$:get,p})));assert.ok(source.includes("'password-vault'")); +}); diff --git a/tests/controlled-login-audit.test.mjs b/tests/controlled-login-audit.test.mjs new file mode 100644 index 0000000..84935e2 --- /dev/null +++ b/tests/controlled-login-audit.test.mjs @@ -0,0 +1,16 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {randomUUID} from 'node:crypto'; +import {mkdtempSync,rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join,resolve,sep,basename} from 'node:path'; +import {openStore,digest} from '../apps/server/store.mjs'; +import {createApp} from '../apps/server/app.mjs'; +import {toECS,toCEF} from '../packages/core/siem.mjs'; +const kinds=['vault.submission-prepared','vault.submission-authorized','vault.submission-denied','vault.submission-completed','vault.submission.failure']; +async function fixture(t){const dir=mkdtempSync(join(tmpdir(),'safetynet-controlled-login-audit-')),store=openStore(dir),token='synthetic-controlled-login-device-token';store.db.prepare('INSERT INTO devices(id,name,token_hash) VALUES(?,?,?)').run('device','Synthetic',digest(token));for(const role of ['analyst','auditor','operator'])store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest('synthetic-'+role),Date.now()+600000,JSON.stringify({sub:role,role}));store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest('synthetic-employee'),Date.now()+600000,JSON.stringify({sub:'employee',role:'employee',permissions:['audit.read']}));const server=createApp(store,{publicUrl:'http://127.0.0.1',secure:false}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));t.after(async()=>{server.closeAllConnections();await new Promise(r=>server.close(r));store.db.close();const target=resolve(dir);assert.ok(target.startsWith(resolve(tmpdir())+sep)&&basename(target).startsWith('safetynet-controlled-login-audit-'));rmSync(target,{recursive:true,force:true});});const base=`http://127.0.0.1:${server.address().port}`;return {store,base,send:(body,credential=token)=>fetch(base+'/api/device/events',{method:'POST',headers:{authorization:`Bearer ${credential}`,'content-type':'application/json'},body:JSON.stringify({eventId:randomUUID(),policyVersion:1,...body})})};} +test('authenticated controlled login lifecycle audit is deduplicated and rejects credential-bearing metadata',async t=>{const f=await fixture(t);const traceId='a'.repeat(32),spanId='b'.repeat(16);for(const kind of kinds){const eventId=randomUUID();assert.equal((await f.send({eventId,kind,traceId,spanId})).status,200);assert.equal((await f.send({eventId,kind,traceId,spanId})).status,200);}assert.equal((await f.send({kind:kinds[0]},'wrong-token')).status,401);for(const kind of kinds)for(const key of ['host','ruleId','username','password','formId','url','actionUrl','body','credentialRef','token','reason'])assert.equal((await f.send({kind,[key]:'PRIVATE_LOGIN_SENTINEL'})).status,400,kind+':'+key);for(const extra of [{file:{bytes:1,detectedType:'text',sha256:'c'.repeat(64)}},{request:{bytes:1,detectedType:'text'}},{agent:{action:'vault:prepare-submit',scope:'vault:prepare-submit',outcome:'completed'}},{interaction:{action:'submit',outcome:'observed',sessionId:randomUUID()}}])assert.equal((await f.send({kind:kinds[1],...extra})).status,400);const rows=f.store.db.prepare('SELECT * FROM events').all();assert.equal(rows.length,kinds.length);assert.equal(f.store.auditIntegrity.verify().valid,true);for(const row of rows){const m=JSON.parse(row.metadata);assert.equal(m.traceId,traceId);assert.equal(m.spanId,spanId);assert.ok(!row.metadata.includes('PRIVATE_LOGIN_SENTINEL'));}}); +test('security analysts can export lifecycle ECS and CEF without credentials; employee self-claimed permissions cannot',async t=>{const f=await fixture(t);for(const kind of kinds)assert.equal((await f.send({kind,traceId:'a'.repeat(32),spanId:'b'.repeat(16)})).status,200);for(const role of ['analyst','auditor','operator'])for(const format of ['ecs','cef']){const res=await fetch(f.base+'/api/admin/events/export?format='+format,{headers:{cookie:'sn_session=synthetic-'+role}});assert.equal(res.status,200);const body=await res.text();for(const kind of kinds)assert.ok(body.includes(kind));for(const value of ['PRIVATE_LOGIN_SENTINEL','synthetic-controlled-login-device-token','passwordField','credentialRef'])assert.ok(!body.includes(value));if(format==='ecs'){const rows=body.trim().split('\n').map(s=>JSON.parse(s));assert.equal(rows.length,kinds.length);assert.equal(rows[0].trace.id,'a'.repeat(32));assert.ok(rows[0].safetynet.audit.hash);}}assert.ok([401,403].includes((await fetch(f.base+'/api/admin/events/export',{headers:{cookie:'sn_session=synthetic-employee'}})).status));assert.equal((await fetch(f.base+'/api/admin/events/export')).status,401);f.store.db.prepare('UPDATE devices SET revoked=1 WHERE id=?').run('device');assert.equal((await f.send({kind:kinds[0]})).status,401);}); +test('denied credential submission exports denial rather than successful authorization',()=>{for(const kind of kinds){const event={id:1,time:new Date().toISOString(),kind,device_id:'device',metadata:{policyVersion:1,traceId:'a'.repeat(32)}};const ecs=toECS(event),cef=toCEF(event);assert.equal(ecs.event.action,kind);assert.ok(cef.includes('act='+kind));if(kind===kinds[2]){assert.deepEqual(ecs.event.type,['denied']);assert.equal(ecs.safetynet.decision,'block');assert.ok(cef.includes('|7|'));}}}); + +test('terminal HTTP lifecycle exports distinguish completion from transaction failure',()=>{for(const kind of ['vault.submission-completed','vault.submission.failure']){const event={id:5,time:new Date().toISOString(),kind,device_id:'device',metadata:{policyVersion:2,traceId:'c'.repeat(32),spanId:'d'.repeat(16)},audit:{hash:'e'.repeat(64),sequence:5}};const ecs=toECS(event),cef=toCEF(event);assert.equal(ecs.event.action,kind);assert.equal(ecs.event.category[0],'web');assert.equal(ecs.event.outcome,kind.endsWith('.failure')?'failure':'success');assert.deepEqual(ecs.event.type,[kind.endsWith('.failure')?'error':'allowed']);assert.equal(ecs.trace.id,event.metadata.traceId);assert.ok(cef.includes('|3|'));assert.ok(cef.includes('act='+kind));assert.ok(cef.includes('cs4Label=TraceId'));assert.equal(ecs.safetynet.agent,undefined);assert.equal(ecs.destination,undefined);assert.equal(ecs.rule,undefined);assert.ok(!JSON.stringify(ecs).includes('authenticated'));}}); diff --git a/tests/credential-policy.test.mjs b/tests/credential-policy.test.mjs new file mode 100644 index 0000000..077478d --- /dev/null +++ b/tests/credential-policy.test.mjs @@ -0,0 +1,5 @@ +import test from 'node:test';import assert from 'node:assert/strict';import {ControlledSubmissionSchema,credentialActionUrlAllowed} from '../packages/core/credential-policy.mjs'; +const rule=()=>({id:'company-login',actionUrl:'https://login.example/signin',usernameField:'username',passwordField:'password'}); +test('controlled submission defaults off and enabled configuration needs explicit forms',()=>{assert.deepEqual(ControlledSubmissionSchema.parse({}),{enabled:false,forms:[]});assert.throws(()=>ControlledSubmissionSchema.parse({enabled:true}));assert.equal(ControlledSubmissionSchema.parse({enabled:true,forms:[rule()]}).forms.length,1);}); +test('credential rules require exact HTTPS target and bounded metadata only',()=>{for(const url of ['http://login.example/signin','https://u:p@login.example/signin','https://login.example/signin?x=y','https://login.example/signin#fragment','https://login.example/signin?','https://login.example/signin#','https://LOGIN.example/signin','https://login.example','https://login.example/sign in'])assert.equal(credentialActionUrlAllowed(url),false,url);for(const extra of ['password','username','token','formValues'])assert.throws(()=>ControlledSubmissionSchema.parse({forms:[{...rule(),[extra]:'private'}]}));for(const value of ['','a'.repeat(129),'user name','üser','x\ny'])assert.throws(()=>ControlledSubmissionSchema.parse({forms:[{...rule(),usernameField:value}]}));}); +test('duplicate rule identities or target signatures and overlapping fields are rejected',()=>{for(const forms of [[rule(),{...rule(),actionUrl:'https://other.example/'}],[rule(),{...rule(),id:'other'}],[{...rule(),passwordField:'username'}],Array.from({length:33},(_,n)=>({...rule(),id:'rule'+n,actionUrl:'https://login.example/'+n}))])assert.throws(()=>ControlledSubmissionSchema.parse({forms}));assert.equal(ControlledSubmissionSchema.parse({forms:[rule(),{...rule(),id:'other',actionUrl:'https://other.example/'}]}).forms.length,2);}); diff --git a/tests/credential-stage-diagnostics.test.mjs b/tests/credential-stage-diagnostics.test.mjs new file mode 100644 index 0000000..70373fb --- /dev/null +++ b/tests/credential-stage-diagnostics.test.mjs @@ -0,0 +1,5 @@ +import test from 'node:test';import assert from 'node:assert/strict';import {createRequire} from 'node:module';import {createServer} from 'node:http';import {DiagnosticsSchema} from '../packages/core/diagnostics.mjs'; +const require=createRequire(import.meta.url),{createDiagnostics}=require('../apps/browser/diagnostics.cjs'),{createCredentialSubmissionBroker}=require('../apps/browser/credential-submission.cjs'); +async function fixture(t){const payloads=[],server=createServer((req,res)=>{const chunks=[];req.on('data',c=>chunks.push(c));req.on('end',()=>{payloads.push(JSON.parse(Buffer.concat(chunks).toString()));res.setHeader('content-type','application/json');res.end('{}');});}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));const diagnostics=createDiagnostics();diagnostics.configure(DiagnosticsSchema.parse({enabled:true,mode:'debug',endpoint:`http://127.0.0.1:${server.address().port}`,sampleRate:1,debugSampleRate:1,flushIntervalMs:10000}));const broker=createCredentialSubmissionBroker({isCurrent:()=>true,traceOperation:(operation,fn)=>diagnostics.spanSync('browser.credential',{operation},fn)});t.after(async()=>{broker.close();await diagnostics.shutdown();server.closeAllConnections();await new Promise(r=>server.close(r));});const frame={},wc={id:7,mainFrame:frame},vault={status:()=>({locked:false}),accessGeneration:()=>1},context={policy:{passwordManager:{controlledSubmission:{enabled:true,forms:[{id:'secret-form',actionUrl:'https://example.com/session',usernameField:'user',passwordField:'pass'}]}}},token:{},webContents:wc,mainFrame:frame,sourceURL:'https://example.com/login',actor:{id:'private-actor'}},body=Buffer.from('user=private-user&pass=PrivateVault123%21¬e=public'),details={id:1,url:'https://example.com/session',method:'POST',resourceType:'mainFrame',webContentsId:7,frame,initiatorOrigin:'https://example.com',uploadData:[{bytes:body}],requestHeaders:{'Content-Type':'application/x-www-form-urlencoded'}};const stage=(operation,fn)=>diagnostics.spanSync('browser.credential',{operation,token:'private-token',password:'PrivateVault123!',url:details.url,formId:'secret-form'},fn);return {payloads,diagnostics,broker,context,details,body,vault,stage,spans:()=>payloads.flatMap(p=>p.resourceSpans||[]).flatMap(p=>p.scopeSpans||[]).flatMap(p=>p.spans||[])};} +test('real OTLP credential phase spans correlate and never export secrets or form descriptors',async t=>{const f=await fixture(t);await f.diagnostics.span('browser.agent',{operation:'vault:prepare-submit'},async()=>{f.stage('credential.issue',()=>f.broker.issue({context:f.context,formId:'secret-form',username:'private-user',password:'PrivateVault123!',vault:f.vault,vaultGeneration:1}));const claim=f.stage('credential.claim',()=>f.broker.claim(f.details,f.body,f.context));try{f.stage('credential.headers',()=>f.broker.validateHeaders(f.details,f.context));f.stage('credential.authorize',()=>f.broker.finalize(1,f.context,f.details));f.broker.redirect(1);f.stage('credential.redirect',()=>f.broker.validateRedirect({...f.details,method:'GET',url:'https://example.com/home',uploadData:[],requestHeaders:{}},f.context));}finally{claim.residual.fill(0);}});await f.diagnostics.flush();const spans=f.spans(),parent=spans.find(s=>s.name==='browser.agent');for(const operation of ['credential.issue','credential.claim','credential.headers','credential.authorize','credential.redirect']){const s=spans.find(s=>s.attributes.some(a=>a.key==='safetynet.operation'&&a.value.stringValue===operation));assert.ok(s,operation);assert.equal(s.name,'browser.credential');assert.equal(s.parentSpanId,parent.spanId);assert.equal(s.traceId,parent.traceId);assert.ok(BigInt(s.endTimeUnixNano)>=BigInt(s.startTimeUnixNano));}const text=JSON.stringify(f.payloads);for(const secret of ['private-user','PrivateVault123','private-actor','private-token','secret-form','example.com'])assert.equal(text.includes(secret),false,secret);for(const operation of ['credential.authority','credential.native','credential.parse','credential.match','credential.residual','credential.header-values']){const children=spans.filter(s=>s.attributes.some(a=>a.key==='safetynet.operation'&&a.value.stringValue===operation));assert.ok(children.length,operation);for(const child of children){assert.equal(child.traceId,parent.traceId);assert.ok(child.parentSpanId);assert.ok(spans.some(s=>s.spanId===child.parentSpanId));}}assert.equal(f.diagnostics.status().lifetimeFailedExports,0);}); +test('admin diagnostics disable preserves one-use credential enforcement without exports',async t=>{const f=await fixture(t);f.diagnostics.configure({enabled:false});f.stage('credential.issue',()=>f.broker.issue({context:f.context,formId:'secret-form',username:'private-user',password:'PrivateVault123!',vault:f.vault,vaultGeneration:1}));const claim=f.stage('credential.claim',()=>f.broker.claim(f.details,f.body,f.context));claim.residual.fill(0);f.stage('credential.headers',()=>f.broker.validateHeaders(f.details,f.context));assert.equal(f.stage('credential.authorize',()=>f.broker.finalize(1,f.context,f.details)).authorized,true);assert.throws(()=>f.stage('credential.authorize',()=>f.broker.finalize(1,f.context,f.details)));await f.diagnostics.flush();assert.equal(f.spans().length,0);}); diff --git a/tests/credential-submission.test.mjs b/tests/credential-submission.test.mjs new file mode 100644 index 0000000..4461b58 --- /dev/null +++ b/tests/credential-submission.test.mjs @@ -0,0 +1,30 @@ +import test from 'node:test';import assert from 'node:assert/strict';import {createRequire} from 'node:module';const require=createRequire(import.meta.url),{createCredentialSubmissionBroker}=require('../apps/browser/credential-submission.cjs'); +function fixture(extra={}){let time=1000,current=true,generation=1;const frame={},wc={id:7,mainFrame:frame},vault={status:()=>({locked:false}),accessGeneration:()=>generation},policy={passwordManager:{controlledSubmission:{enabled:true,forms:[{id:'login',actionUrl:'https://example.com/session',usernameField:'user',passwordField:'pass'}]}}},context={policy,token:{},webContents:wc,mainFrame:frame,sourceURL:'https://example.com/login',actor:{id:'agent1'}};const broker=createCredentialSubmissionBroker({isCurrent:()=>current,now:()=>time,...extra}),body=Buffer.from('user=alice&pass=Strong%21Vault123¬e=public'),details={id:1,url:'https://example.com/session',method:'POST',resourceType:'mainFrame',webContentsId:7,frame,initiatorOrigin:'https://example.com',uploadData:[{bytes:body}],requestHeaders:{'Content-Type':'application/x-www-form-urlencoded','Accept':'text/html'}};const issue=()=>broker.issue({context,formId:'login',username:'alice',password:'Strong!Vault123',vault,vaultGeneration:1});return {broker,body,details,context,issue,expire:()=>time+=15001,revoke:()=>current=false,rotate:()=>generation++};} +test('exact native submission residual inspects all other fields without credentials',()=>{const f=fixture();f.issue();const result=f.broker.claim(f.details,f.body,f.context);assert.equal(result.residual.includes(Buffer.from('alice')),false);assert.equal(result.residual.includes(Buffer.from('Strong')),false);assert.match(result.residual.toString(),/note=public/);assert.match(result.residual.toString(),/note\npublic/);assert.match(result.residual.toString(),/user\npass\nnote/);assert.throws(()=>f.broker.finalize(1,f.context,f.details));f.broker.validateHeaders(f.details,f.context);assert.equal(f.broker.finalize(1,f.context,f.details).authorized,true);assert.throws(()=>f.broker.finalize(1,f.context,f.details));f.broker.complete(1);assert.equal(f.broker.claim({...f.details,id:2},f.body,f.context),null);}); +test('unrelated GET retains one-use prepared lease',()=>{const f=fixture();f.issue();assert.equal(f.broker.claim({...f.details,method:'GET',resourceType:'image'},Buffer.alloc(0),f.context),null);assert.ok(f.broker.claim(f.details,f.body,f.context));}); +test('duplicate and malformed fields consume failed lease and remain blocked',()=>{for(const text of ['user=alice&pass=Strong%21Vault123&pass=x','user=alice&pass=Strong%ZZVault123','user=alice&pass=wrong','user=alice&pass=Strong%21Vault123¬e=Strong%21Vault123']){const f=fixture();f.issue();const bytes=Buffer.from(text),details={...f.details,uploadData:[{bytes}]};assert.throws(()=>f.broker.claim(details,bytes,f.context));assert.throws(()=>f.broker.claim(details,bytes,f.context));}}); +test('request native frame, origin, policy and vault races fail closed',()=>{for(const change of [f=>f.details.frame={},f=>f.details.initiatorOrigin='https://evil.example',f=>f.context={...f.context,policy:{...f.context.policy}},f=>f.revoke(),f=>f.rotate()]){const f=fixture();f.issue();change(f);assert.throws(()=>f.broker.claim(f.details,f.body,f.context));}}); +test('header content type, encoding, body mutation and embedded credentials fail closed',()=>{for(const change of [f=>f.details.requestHeaders['Content-Type']='application/json',f=>f.details.requestHeaders['Content-Encoding']='gzip',f=>f.details.requestHeaders['X-Secret']='prefix-Strong!Vault123-suffix',f=>f.details.uploadData=[{bytes:Buffer.from('modified')}],f=>f.revoke()]){const f=fixture();f.issue();f.broker.claim(f.details,f.body,f.context);change(f);assert.throws(()=>f.broker.validateHeaders(f.details,f.context));assert.throws(()=>f.broker.finalize(1,f.context,f.details));}}); +test('expiration, disable and redirect never grant replay exemption',()=>{const f=fixture();f.issue();f.broker.claim(f.details,f.body,f.context);f.broker.validateHeaders(f.details,f.context);f.broker.finalize(1,f.context,f.details);f.broker.redirect(1);assert.throws(()=>f.broker.claim(f.details,f.body,f.context));const g=fixture();g.issue();g.expire();assert.equal(g.broker.claim(g.details,g.body,g.context),null);const h=fixture();h.context.policy.passwordManager.controlledSubmission.enabled=false;assert.throws(()=>h.issue());}); +test('extra confidential fields remain intact in residual for normal DLP',()=>{const f=fixture();f.issue();const bytes=Buffer.from('user=alice&pass=Strong%21Vault123¬e=CORPORATE_SECRET&part1=CORPO&part2=RATE_SECRET');const r=f.broker.claim({...f.details,uploadData:[{bytes}]},bytes,f.context);assert.match(r.residual.toString(),/CORPORATE_SECRET/);assert.match(r.residual.toString(),/CORPORATE_SECRETCORPORATE_SECRET/);}); +test('central disable and authority change immediately before release invalidate authorization',()=>{for(const change of [f=>f.context.policy.passwordManager.controlledSubmission.enabled=false,f=>f.revoke(),f=>f.rotate()]){const f=fixture();f.issue();f.broker.claim(f.details,f.body,f.context);f.broker.validateHeaders(f.details,f.context);change(f);assert.throws(()=>f.broker.finalize(1,f.context,f.details));assert.throws(()=>f.broker.finalize(1,f.context,f.details));}}); +function redirectFixture(){const f=fixture();f.issue();f.broker.claim(f.details,f.body,f.context);f.broker.validateHeaders(f.details,f.context);f.broker.finalize(1,f.context,f.details);f.broker.redirect(1);f.details={...f.details,method:'GET',url:'https://example.com/home',uploadData:[],requestHeaders:{Accept:'text/html'}};return f;} +test('302-style bodyless GET passes ordinary inspection without credential exemption',()=>{const f=redirectFixture();assert.equal(f.broker.has(1),false);assert.equal(f.broker.isBlocked(1),false);assert.equal(f.broker.validateRedirect(f.details,f.context),true);assert.equal(f.broker.claim(f.details,Buffer.alloc(0),f.context),null);assert.throws(()=>f.broker.finalize(1,f.context,f.details));f.broker.complete(1);assert.equal(f.broker.validateRedirect(f.details,f.context),false);}); +test('307-style POST replay and redirected credential URL/header remain denied',()=>{for(const change of [f=>f.details.method='POST',f=>f.details.uploadData=[{bytes:f.body}],f=>f.details.url+='?secret=Strong%21Vault123',f=>f.details.requestHeaders['X-Value']='Strong!Vault123',f=>f.details.requestHeaders.accept='duplicate']){const f=redirectFixture();change(f);assert.throws(()=>f.broker.validateRedirect(f.details,f.context));assert.throws(()=>f.broker.claim(f.details,Buffer.alloc(0),f.context));}}); +test('ordinary redirects do not become blocked and stale tombstones expire boundedly',()=>{const f=fixture();f.broker.redirect(999);assert.equal(f.broker.isBlocked(999),false);const g=redirectFixture();g.expire();assert.equal(g.broker.isBlocked(1),true);g.broker.complete(1);assert.equal(g.broker.isBlocked(1),false);}); +test('duplicate header case and printable ASCII field names follow strict parsing',()=>{const f=fixture();f.issue();f.broker.claim(f.details,f.body,f.context);f.details.requestHeaders['content-type']='application/x-www-form-urlencoded';assert.throws(()=>f.broker.validateHeaders(f.details,f.context));const g=fixture();g.context.policy.passwordManager.controlledSubmission.forms[0].usernameField='user[email]';g.context.policy.passwordManager.controlledSubmission.forms[0].passwordField='user[password]';g.issue();const bytes=Buffer.from('user%5Bemail%5D=alice&user%5Bpassword%5D=Strong%21Vault123');assert.ok(g.broker.claim({...g.details,uploadData:[{bytes}]},bytes,g.context));}); +test('public background POST and XHR preserve prepared lease; credential XHR consumes and denies',()=>{const f=fixture();f.issue();const noise=Buffer.from('note=public');assert.equal(f.broker.claim({...f.details,id:2,url:'https://example.com/metrics',resourceType:'xhr',uploadData:[{bytes:noise}]},noise,f.context),null);assert.ok(f.broker.claim(f.details,f.body,f.context));const g=fixture();g.issue();assert.throws(()=>g.broker.claim({...g.details,resourceType:'xhr'},g.body,g.context));assert.equal(g.broker.isBlocked(1),true);assert.equal(g.broker.claim({...g.details,id:3},g.body,g.context),null);}); +test('redirect checks invoke redirect authority phase and expiry timer wipes without traffic',()=>{const phases=[],f=fixture();let tick,cleared=false,time=1000;const broker=createCredentialSubmissionBroker({isCurrent:(_c,{phase})=>{phases.push(phase);return true;},now:()=>time,setIntervalImpl:callback=>{tick=callback;return {unref(){}};},clearIntervalImpl:()=>cleared=true});const issue=()=>broker.issue({context:f.context,formId:'login',username:'alice',password:'Strong!Vault123',vault:{status:()=>({locked:false}),accessGeneration:()=>1},vaultGeneration:1});issue();broker.claim(f.details,f.body,f.context);broker.validateHeaders(f.details,f.context);broker.finalize(1,f.context,f.details);broker.redirect(1);broker.validateRedirect({...f.details,method:'GET',url:'https://example.com/home',uploadData:[],requestHeaders:{}},f.context);assert.equal(phases.at(-1),'redirect');time+=15001;tick();assert.equal(broker.isBlocked(1),true);broker.close();assert.equal(cleared,true);assert.equal(broker.isBlocked(1),false);}); +test('narrow creator cancel refuses cross-actor cancellation and blocks its pending request',()=>{const f=fixture();f.issue();f.broker.cancel({...f.context,actor:{id:'other'}});assert.ok(f.broker.claim(f.details,f.body,f.context));f.broker.cancel({...f.context,webContents:{id:7}});assert.equal(f.broker.has(1),true);f.broker.cancel(f.context);assert.equal(f.broker.has(1),false);assert.equal(f.broker.isBlocked(1),true);assert.throws(()=>f.broker.validateHeaders(f.details,f.context));const g=fixture();g.issue();g.broker.cancel(g.context);assert.equal(g.broker.claim(g.details,g.body,g.context),null);}); +test('known credentials split across other fields cannot use the credential exemption',()=>{const f=fixture();f.issue();const bytes=Buffer.from('user=alice&pass=Strong%21Vault123&part1=Strong%21&part2=Vault123');assert.throws(()=>f.broker.claim({...f.details,uploadData:[{bytes}]},bytes,f.context));}); + +test('fixed internal diagnostic stages never replay or alter protected checks',()=>{for(const mode of ['before','after','twice']){const stages=[];const f=fixture({traceOperation:(operation,fn)=>{stages.push(operation);if(mode==='before')throw Error('Diagnostic failed');const result=fn();if(mode==='after')throw Error('Diagnostic failed');fn();return result;}});f.issue();const claim=f.broker.claim(f.details,f.body,f.context);assert.ok(claim);f.broker.validateHeaders(f.details,f.context);assert.equal(f.broker.finalize(1,f.context,f.details).authorized,true);for(const operation of ['credential.authority','credential.native','credential.parse','credential.match','credential.residual','credential.header-values'])assert.ok(stages.includes(operation),operation);assert.deepEqual(f.broker.complete(1),{released:true});assert.deepEqual(f.broker.complete(1),{released:false});}}); +test('terminal released fact distinguishes no-release and finalized redirect completion',()=>{const f=fixture();f.issue();f.broker.claim(f.details,f.body,f.context);assert.deepEqual(f.broker.complete(1),{released:false});const g=redirectFixture();assert.deepEqual(g.broker.complete(1),{released:true});assert.deepEqual(g.broker.complete(1),{released:false});}); +test('credential split across headers remains denied in original and redirected requests',()=>{for(const redirect of [false,true]){const f=redirect?redirectFixture():fixture();if(!redirect){f.issue();f.broker.claim(f.details,f.body,f.context);}f.details.requestHeaders['X-Part1']='Strong!';f.details.requestHeaders['X-Part2']='Vault123';assert.throws(()=>redirect?f.broker.validateRedirect(f.details,f.context):f.broker.validateHeaders(f.details,f.context));}}); +test('issued watch preserves public GET but consumes credential URL/header leak without exemption',()=>{for(const mode of ['query','header','split']){const f=fixture();f.issue();const d={...f.details,id:2,method:'GET',resourceType:'image',url:'https://example.com/image',uploadData:[],requestHeaders:{Accept:'image/png'}};assert.equal(f.broker.guardIssued(d,f.context),false);if(mode==='query')d.url+='?x=Strong%21Vault123';if(mode==='header')d.requestHeaders['X-Value']='Strong!Vault123';if(mode==='split'){d.requestHeaders['X-Part1']='Strong!';d.requestHeaders['X-Part2']='Vault123';}assert.throws(()=>f.broker.guardIssued(d,f.context));assert.equal(f.broker.isBlocked(2),true);assert.equal(f.broker.claim({...f.details,id:3},f.body,f.context),null);}}); +test('issued watch rechecks live authority and invokes watch phase',()=>{let phase;const f=fixture({isCurrent:(_context,arg)=>{phase=arg.phase;return true;}});f.issue();assert.equal(f.broker.guardIssued({...f.details,id:2,method:'GET',url:'https://example.com/public',requestHeaders:{}},f.context),false);assert.equal(phase,'watch');const g=fixture();g.issue();g.revoke();assert.throws(()=>g.broker.guardIssued({...g.details,id:2,method:'GET',url:'https://example.com/public'},g.context));assert.equal(g.broker.isBlocked(2),true);}); +test('issued watcher does not block an already claimed controlled request header phase',()=>{const f=fixture();f.issue();f.broker.claim(f.details,f.body,f.context);assert.equal(f.broker.guardIssued(f.details,f.context),false);f.broker.validateHeaders(f.details,f.context);assert.equal(f.broker.finalize(1,f.context,f.details).authorized,true);}); +test('public iframe requests preserve prepared lease while iframe credential URL is denied',()=>{const f=fixture();f.issue();const child={...f.details,id:2,resourceType:'subFrame',method:'GET',url:'https://example.com/embed',frame:{},uploadData:[],requestHeaders:{Accept:'text/html'}};assert.equal(f.broker.guardIssued(child,f.context),false);child.url+='?value=Strong%21Vault123';assert.throws(()=>f.broker.guardIssued(child,f.context));assert.equal(f.broker.isBlocked(2),true);assert.equal(f.broker.claim({...f.details,id:3},f.body,f.context),null);}); +test('native reordered and interleaved two-fragment headers fail closed in all broker phases',()=>{for(const phase of ['issued','headers','redirect'])for(const reverse of [false,true]){const f=phase==='redirect'?redirectFixture():fixture();if(phase!=='redirect')f.issue();if(phase==='headers')f.broker.claim(f.details,f.body,f.context);f.details.requestHeaders={...(phase==='headers'?{'Content-Type':'application/x-www-form-urlencoded'}:{}),'X-Credential-One':reverse?'Vault123':'Strong!','sec-ch-ua-mobile':'?0','X-Credential-Two':reverse?'Strong!':'Vault123'};if(phase==='issued'){f.details.method='GET';f.details.url='https://example.com/image';assert.throws(()=>f.broker.guardIssued(f.details,f.context));}if(phase==='headers')assert.throws(()=>f.broker.validateHeaders(f.details,f.context));if(phase==='redirect')assert.throws(()=>f.broker.validateRedirect(f.details,f.context));}}); +test('bounded percent-decoded pair checking rejects encoded split credentials',()=>{const f=fixture();f.issue();f.broker.claim(f.details,f.body,f.context);f.details.requestHeaders={'Content-Type':'application/x-www-form-urlencoded','X-Second':'%56ault123','Accept':'text/html','X-First':'Strong%21'};assert.throws(()=>f.broker.validateHeaders(f.details,f.context));}); +test('pair matching work and aggregate header size overflow fail closed',()=>{for(const mode of ['work','chars']){const f=fixture();f.issue();f.broker.claim(f.details,f.body,f.context);f.details.requestHeaders={'Content-Type':'application/x-www-form-urlencoded'};if(mode==='chars')f.details.requestHeaders['X-Large']='x'.repeat(131073);else for(let i=0;i<127;i++)f.details.requestHeaders['X-'+i]='%2541'+i;assert.throws(()=>f.broker.validateHeaders(f.details,f.context));}}); diff --git a/tests/header-gate-timeout.test.mjs b/tests/header-gate-timeout.test.mjs index 6d68c54..7ac9282 100644 --- a/tests/header-gate-timeout.test.mjs +++ b/tests/header-gate-timeout.test.mjs @@ -10,13 +10,14 @@ function fixture({auditFails=false}={}){ let listener,completeScan; const timers=[],callbacks=[],events=[]; const policy={expiresAt:new Date(Date.now()+60000).toISOString(),dlp:{maxBytes:65536,semantic:{enabled:false}},security:{yara:{enabled:false}}}; - const context=vm.createContext({require:specifier=>require('../apps/browser/'+specifier.slice(2)),currentTabContext:()=>true,URL,Date,ready:true,policy,localInspector:null, + const context=vm.createContext({require:specifier=>require('../apps/browser/'+specifier.slice(2)),credentialBroker:undefined,credentialRequestCorrelations:new Map(),currentTabContext:()=>true,URL,Date,ready:true,policy,localInspector:null, browserSession:{webRequest:{onBeforeSendHeaders(_filter,fn){listener=fn;}}}, developerAssetRequestAllowed:()=>false,tabs:{all:()=>[]},core:{destinationAllowed:()=>true,inspect:()=>({decision:'allow'})}, diagnosticSpan:(_name,_attrs,fn)=>fn(),audit:async(...args)=>{events.push(args);if(auditFails)throw Error('fixture persistence failure');}, scanEnterprise:()=>new Promise(resolve=>{completeScan=resolve;}),setTimeout:fn=>{timers.push(fn);return timers.length;},clearTimeout(){}, }); vm.runInContext(source.slice(source.indexOf('function agentRequestOwnerAllowed(source)'),source.indexOf('function actorDestinationAllowed')),context); + vm.runInContext(source.slice(source.indexOf('function requestDiagnostic('),source.indexOf('function credentialContext(')),context); vm.runInContext(hook,context); listener({url:'https://example.test/path',requestHeaders:{X:'public'},webContentsId:0,resourceType:'xhr'},response=>callbacks.push(response)); return {timers,callbacks,events,context,finish:()=>completeScan({decision:'allow',coverage:{complete:true}})}; diff --git a/tests/password-fill.test.mjs b/tests/password-fill.test.mjs index 2a9c682..189d3f6 100644 --- a/tests/password-fill.test.mjs +++ b/tests/password-fill.test.mjs @@ -2,15 +2,15 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import vm from 'node:vm'; import {createRequire} from 'node:module'; -const require=createRequire(import.meta.url),{fillScript}=require('../apps/browser/password-fill.cjs'); +const require=createRequire(import.meta.url),{fillScript,prepareFillScript}=require('../apps/browser/password-fill.cjs'); const credential={origin:'https://work.example',username:'employee',password:'fixture-only-generated-password'}; function fixture({url='https://work.example/login',action='https://work.example/signin',method='post',target='',visible=true,frame=false,multiple=false,override,focused=true,visibility='visible'}={}){ class Input{constructor(type){this.type=type;this.name=type==='password'?'password':'username';this.autocomplete='';this.disabled=false;this.readOnly=false;}set value(value){this.stored=value;}get value(){return this.stored;}closest(){return null;}getClientRects(){return visible?[{}]:[];}dispatchEvent(){}} const user=new Input('text'),password=new Input('password'); - const form={action,method,target,querySelectorAll:()=>[user,password]};user.form=form;password.form=form; + const form={action,method,target,enctype:'application/x-www-form-urlencoded',querySelectorAll:()=>[user,password]};user.form=form;password.form=form; form.elements=override?[user,password,{getAttribute:name=>override[name]??null}]:[user,password]; const window={isSecureContext:true};window.top=frame?{}:window; - return {user,password,context:vm.createContext({window,location:{href:url,origin:new URL(url).origin},document:{hasFocus:()=>focused,visibilityState:visibility,querySelectorAll:()=>multiple?[password,new Input('password')]:[password]},getComputedStyle:()=>({display:'block',visibility:'visible',opacity:'1'}),HTMLInputElement:Input,Event:class{},URL})}; + return {user,password,context:vm.createContext({window,location:{href:url,origin:new URL(url).origin},document:{hasFocus:()=>focused,visibilityState:visibility,querySelectorAll:()=>multiple?[password,new Input('password')]:[password]},getComputedStyle:element=>({display:'block',visibility:'visible',opacity:element._opacity??'1'}),HTMLInputElement:Input,Event:class{},URL})}; } test('explicit fill sets exact-origin visible login fields without submitting',()=>{ const f=fixture();assert.equal(vm.runInContext(fillScript('https://work.example/login',credential),f.context).completed,true);assert.equal(f.user.stored,credential.username);assert.equal(f.password.stored,credential.password); @@ -28,3 +28,10 @@ test('expired policy cannot inject a password',()=>{assert.throws(()=>fillScript test('queued fill cannot write into a hidden or blurred target document',()=>{ for(const options of [{focused:false},{visibility:'hidden'}]){const f=fixture(options);assert.throws(()=>vm.runInContext(fillScript('https://work.example/login',credential),f.context),/foreground/);assert.equal(f.user.stored,undefined);assert.equal(f.password.stored,undefined);} }); + +const approved=[{id:'company-login',actionUrl:'https://work.example/signin',usernameField:'username',passwordField:'password'}]; +test('transparent ancestors and section-prefixed new-password fields cannot receive saved login secrets',()=>{for(const mutate of [f=>f.password.parentElement={_opacity:'0'},f=>f.password.autocomplete='section-login NEW-PASSWORD',f=>f.password.closest=()=>({disabled:true})]){const f=fixture();mutate(f);assert.throws(()=>vm.runInContext(fillScript('https://work.example/login',credential),f.context));assert.equal(f.password.stored,undefined);}}); +test('controlled fill reports only an approved form ID without submitting or returning secrets',()=>{const f=fixture(),result=vm.runInContext(prepareFillScript('https://work.example/login',credential,approved),f.context);assert.deepEqual(JSON.parse(JSON.stringify(result)),{completed:true,formId:'company-login'});assert.equal(f.password.stored,credential.password);assert.equal(JSON.stringify(result).includes(credential.password),false);}); +test('controlled fill rejects changed action, encoding, duplicated fields and submit overrides before writing',()=>{ + for(const mutate of [f=>f.password.form.action='https://work.example/other',f=>f.password.form.enctype='multipart/form-data',f=>f.password.form.elements.push({name:'password'}),f=>f.password.form.elements.push({getAttribute:n=>n==='formaction'?'https://work.example/other':null}),f=>f.password.form.elements.push({getAttribute:n=>n==='formenctype'?'text/plain':null})]){const f=fixture();mutate(f);assert.throws(()=>vm.runInContext(prepareFillScript('https://work.example/login',credential,approved),f.context));assert.equal(f.password.stored,undefined);} +}); diff --git a/tests/policy-stage-diagnostics.test.mjs b/tests/policy-stage-diagnostics.test.mjs index aa9695f..1312d22 100644 --- a/tests/policy-stage-diagnostics.test.mjs +++ b/tests/policy-stage-diagnostics.test.mjs @@ -20,7 +20,7 @@ async function fixture(t){ const calls=[],next=defaultPolicy();next.diagnostics=config;const keys=generateKeyPairSync('ed25519'),privateKey=keys.privateKey.export({type:'pkcs8',format:'pem'}),publicKey=keys.publicKey.export({type:'spki',format:'pem'}),deviceId='00000000-0000-4000-8000-000000000001'; const envelope=()=>({...signPolicy(next,privateKey),bookmarks:signBookmarkSnapshot({deviceId,policyVersion:next.version,revision:0,expiresAt:next.expiresAt,items:[]},privateKey)}); const tracked=name=>async()=>{calls.push({name,enabled:diagnostics.status().enabled});}; - const c=vm.createContext({require,process:{env:{}},core:{verifyPolicy},assertPolicyAssignment:require('../apps/browser/policy-assignment.cjs').assertPolicyAssignment,device:{id:deviceId,publicKey,minimumVersion:0},policy:next,personalBookmarks:{revision:0,items:[]},bookmarkSnapshotJSON:'',bookmarkModule:{verifyBookmarkSnapshot},saveDevice:()=>calls.push({name:'save',enabled:diagnostics.status().enabled}),diagnosticSpan:(name,attributes,fn)=>diagnostics.span(name,attributes,fn),configureDiagnostics:v=>{calls.push({name:'configure',enabled:v.enabled});diagnostics.configure(v);},reconcileAgentPolicy:tracked('agent'),enforceEvidencePolicy:()=>{},emit:()=>{},quarantine:()=>{},browserSession:{closeAllConnections:tracked('browser-close'),setProxy:tracked('browser-proxy'),clearHostResolverCache:tracked('browser-dns')},managementSession:{closeAllConnections:tracked('management-close'),setProxy:tracked('management-proxy'),clearHostResolverCache:tracked('management-dns')},app:{configureHostResolver:()=>calls.push({name:'resolver',enabled:diagnostics.status().enabled})},resolverOptions:v=>v,recentRequests:new Map(),tabs:{all:()=>[{security:{developerToolsGuard:{close:()=>{}}},view:{webContents:{closeDevTools:()=>{}}}}]},configureVisualPage:tracked('page'),audit:tracked('audit')}); + const c=vm.createContext({require,process:{env:{}},core:{verifyPolicy},assertPolicyAssignment:require('../apps/browser/policy-assignment.cjs').assertPolicyAssignment,credentialBroker:{revoke:()=>calls.push({name:'credential-revoke',enabled:diagnostics.status().enabled})},device:{id:deviceId,publicKey,minimumVersion:0},policy:next,personalBookmarks:{revision:0,items:[]},bookmarkSnapshotJSON:'',bookmarkModule:{verifyBookmarkSnapshot},saveDevice:()=>calls.push({name:'save',enabled:diagnostics.status().enabled}),diagnosticSpan:(name,attributes,fn)=>diagnostics.span(name,attributes,fn),configureDiagnostics:v=>{calls.push({name:'configure',enabled:v.enabled});diagnostics.configure(v);},reconcileAgentPolicy:tracked('agent'),enforceEvidencePolicy:()=>{},emit:()=>{},quarantine:()=>{},browserSession:{closeAllConnections:tracked('browser-close'),setProxy:tracked('browser-proxy'),clearHostResolverCache:tracked('browser-dns')},managementSession:{closeAllConnections:tracked('management-close'),setProxy:tracked('management-proxy'),clearHostResolverCache:tracked('management-dns')},app:{configureHostResolver:()=>calls.push({name:'resolver',enabled:diagnostics.status().enabled})},resolverOptions:v=>v,recentRequests:new Map(),tabs:{all:()=>[{security:{developerToolsGuard:{close:()=>{}}},view:{webContents:{closeDevTools:()=>{}}}}]},configureVisualPage:tracked('page'),audit:tracked('audit')}); vm.runInContext(extracted,c);return {c,next,envelope,calls,diagnostics,payloads,spans:()=>payloads.flatMap(v=>v.resourceSpans||[]).flatMap(v=>v.scopeSpans||[]).flatMap(v=>v.spans||[])}; } test('actual unchanged policy exports correlated verification, bookmark and agent child stages',async t=>{ @@ -30,7 +30,7 @@ test('actual unchanged policy exports correlated verification, bookmark and agen }); test('new signed policy disables diagnostics throughout network transition and honors final disable',async t=>{ const f=await fixture(t);f.c.policy={...f.next,version:0};f.next.diagnostics={enabled:false};await f.c.applyPolicy(f.envelope());await f.diagnostics.flush(); - for(const name of ['browser-close','browser-proxy','management-close','management-proxy','browser-dns','management-dns','resolver','save','page','agent','audit'])assert.equal(f.calls.find(v=>v.name===name).enabled,false,name); + for(const name of ['browser-close','browser-proxy','management-close','management-proxy','browser-dns','management-dns','credential-revoke','resolver','save','page','agent','audit'])assert.equal(f.calls.find(v=>v.name===name).enabled,false,name); assert.deepEqual(f.calls.filter(v=>v.name==='configure').map(v=>v.enabled),[false,false]);assert.equal(f.diagnostics.status().enabled,false);assert.equal(f.spans().length,0,'Retired old generation must not export'); }); test('enabled new policy exports only post-configuration stages after protected network transition',async t=>{ diff --git a/tests/request-gate-timeout.test.mjs b/tests/request-gate-timeout.test.mjs index 519c424..7313f46 100644 --- a/tests/request-gate-timeout.test.mjs +++ b/tests/request-gate-timeout.test.mjs @@ -9,12 +9,13 @@ const hook=source.slice(source.indexOf(" browserSession.webRequest.onBeforeRequ function fixture(){ let listener,completeDNS;const timers=[],events=[],callbacks=[],policy=defaultPolicy(); const context=vm.createContext({require:specifier=>require('../apps/browser/'+specifier.slice(2)),URL,Date,ready:true,policy, - browserSession:{webRequest:{onBeforeRequest(_filter,fn){listener=fn;}}},tabs:{all:()=>[]},currentTabContext:()=>true, + browserSession:{webRequest:{onBeforeRequest(_filter,fn){listener=fn;}}},tabs:{all:()=>[]},credentialBroker:undefined,credentialRequestCorrelations:new Map(),currentTabContext:()=>true, developerAssetRequestAllowed:()=>false,diagnosticSpan:(_name,_attrs,fn)=>fn(),core:{destinationAllowed:()=>true,inspect:()=>({decision:'allow'})}, audit:async(...args)=>events.push(args),recentRequests:new Map(),dnsGate:()=>new Promise(resolve=>completeDNS=resolve), setTimeout:fn=>{timers.push(fn);return timers.length;},clearTimeout(){},Buffer, }); vm.runInContext(source.slice(source.indexOf('function agentRequestOwnerAllowed(source)'),source.indexOf('function actorDestinationAllowed')),context); + vm.runInContext(source.slice(source.indexOf('function requestDiagnostic('),source.indexOf('function credentialContext(')),context); vm.runInContext(hook,context);listener({id:1,url:'https://example.test/',webContentsId:0,resourceType:'mainFrame',method:'GET'},r=>callbacks.push(r)); return {timers,events,callbacks,complete:()=>completeDNS()}; } diff --git a/tests/request-transfer-facts.test.mjs b/tests/request-transfer-facts.test.mjs index 3603e83..a6b6016 100644 --- a/tests/request-transfer-facts.test.mjs +++ b/tests/request-transfer-facts.test.mjs @@ -28,7 +28,10 @@ test('privileged request gate routes upload audit through request facts without assert.match(source,/requestFactsForTransfer\(transfer\)/); assert.match(source,/audit\('upload\.authorized',null,new URL\(details\.url\)\.hostname,undefined,requestFacts\)/); assert.match(source,/audit\('upload\.block',transfer\.ruleId,new URL\(details\.url\)\.hostname,undefined,requestFacts\)/); - assert.match(source,/core\.inspect\(requestBytes,policy\)/); + // Supplementary wiring check; actual receiver fixtures verify that ordinary + // secrets and confidential residual fields still cannot leave the browser. + assert.match(source,/scanBytes=claim\?claim\.residual:requestBytes/); + assert.match(source,/core\.inspect\(scanBytes,policy\)/); assert.equal(source.includes('sha256:transfer.sha256'),false); }); diff --git a/tests/security.test.mjs b/tests/security.test.mjs index 30b161d..4bf97e3 100644 --- a/tests/security.test.mjs +++ b/tests/security.test.mjs @@ -16,7 +16,7 @@ test('security defaults deny agent access and DNS/content policies enforce centr p.security.dns.blockedIPs=['not-an-address'];assert.equal(PolicySchema.safeParse(p).success,false); }); test('password manager defaults and idle bounds reject insecure or unknown settings',()=>{ - const p=defaultPolicy();assert.deepEqual(p.passwordManager,{enabled:true,allowSaving:true,allowAutofill:true,idleLockSeconds:300});p.passwordManager.idleLockSeconds=59;assert.equal(PolicySchema.safeParse(p).success,false);p.passwordManager.idleLockSeconds=901;assert.equal(PolicySchema.safeParse(p).success,false);p.passwordManager.idleLockSeconds=60;p.passwordManager.password='secret';assert.equal(PolicySchema.safeParse(p).success,false); + const p=defaultPolicy();assert.deepEqual(p.passwordManager,{enabled:true,allowSaving:true,allowAutofill:true,controlledSubmission:{enabled:false,forms:[]},idleLockSeconds:300});p.passwordManager.idleLockSeconds=59;assert.equal(PolicySchema.safeParse(p).success,false);p.passwordManager.idleLockSeconds=901;assert.equal(PolicySchema.safeParse(p).success,false);p.passwordManager.idleLockSeconds=60;p.passwordManager.password='secret';assert.equal(PolicySchema.safeParse(p).success,false); }); test('visual references require explicit valid brands and canonical exact domain origins',()=>{ const p=defaultPolicy();assert.equal(p.security.visualPhishing.enabled,false);p.security.visualPhishing.enabled=true;assert.equal(PolicySchema.safeParse(p).success,false); diff --git a/tests/vault-agent-controls.test.mjs b/tests/vault-agent-controls.test.mjs index 9c21e76..2894f91 100644 --- a/tests/vault-agent-controls.test.mjs +++ b/tests/vault-agent-controls.test.mjs @@ -17,7 +17,7 @@ test('agent vault inventory is scoped, classified and contains opaque references assert.deepEqual(await a.fill({credentialRef:rows[0].credentialRef}),{completed:true});assert.equal(f.scripts,1); await assert.rejects(a.fill({credentialRef:rows[0].credentialRef}));assert.equal(f.scripts,1); assert.doesNotMatch(JSON.stringify(f.events),/private|password|credentialRef/); - assert.deepEqual(Object.keys(a).sort(),['clear','fill','list','status']); + assert.deepEqual(Object.keys(a).sort(),['clear','fill','list','prepare','status']); }); test('locked vault and failed/incomplete classifier cannot export inventory',async()=>{ for(const mode of ['locked','block','incomplete','error','changed']){const f=fixture();if(mode==='locked')f.lock();else f.cb.inspect=async()=>{if(mode==='error')throw Error('private-password');if(mode==='changed')f.rows[0].origin='https://changed.example';return {decision:mode==='block'?'block':'allow',coverage:{complete:mode!=='incomplete'}};};await assert.rejects(f.make().list());} @@ -50,3 +50,8 @@ test('direct vault arguments reject secret-bearing or arbitrary extra fields',as test('allow verdict with unavailable inspection marker cannot export references',async()=>{ for(const ruleId of ['uninspectable','size-limit','inspection-incomplete','unavailable','timeout']){const f=fixture();f.cb.inspect=async()=>({decision:'allow',ruleId});await assert.rejects(f.make().list());} }); +test('prepare submission has a separate default-off scope and regular fill never arms',async()=>{const f=fixture();let arms=0;f.cb.prepareSubmission=async()=>{arms++;return {prepared:true};};const a=f.make(),[row]=await a.list();await assert.rejects(a.prepare({credentialRef:row.credentialRef}));assert.equal(f.scripts,0);assert.deepEqual(await a.fill({credentialRef:row.credentialRef}),{completed:true});assert.equal(arms,0);}); +test('controlled prepare arms only once after native fill and returns sanitized status',async()=>{const f=fixture();f.c.policy.agents.scopes.push('vault:prepare-submit');f.c.policy.passwordManager.controlledSubmission={enabled:true,forms:[{id:'login',actionUrl:'https://work.example/session',usernameField:'user',passwordField:'pass'}]};let armed=0,credential,after=0;f.c.webContents.executeJavaScriptInIsolatedWorld=async()=>({completed:true,formId:'login'});f.cb.prepareSubmission=async value=>{armed++;credential=value.credential;assert.equal(value.formId,'login');assert.equal(value.credential.password,'private-password');return {prepared:true,password:'must-not-export',formId:'private'};};f.cb.afterFill=()=>after++;const a=f.make(),[row]=await a.list();assert.deepEqual(await a.prepare({credentialRef:row.credentialRef}),{completed:true,prepared:true});assert.equal(armed,1);assert.equal(after,1);assert.equal(credential.password,'');assert.equal(credential.username,'');await assert.rejects(a.prepare({credentialRef:row.credentialRef}));assert.equal(armed,1);assert.doesNotMatch(JSON.stringify(f.events),/private-password|credentialRef/);}); +test('revocation or failed trusted handoff stops native credential write and consumes reference',async()=>{for(const mode of ['revoked','error','aborted']){const f=fixture(),controller=new AbortController();f.cb.beforeFill=async()=>{if(mode==='revoked')f.invalidate();if(mode==='error')throw Error('private handoff error');if(mode==='aborted')controller.abort();};const a=f.make(),[row]=await a.list();await assert.rejects(a.fill({credentialRef:row.credentialRef},{signal:controller.signal}),{message:'Managed vault operation refused'});assert.equal(f.scripts,0);await assert.rejects(a.fill({credentialRef:row.credentialRef}));}}); +test('controlled policy disabled and native renderer form mismatch never arm broker',async()=>{for(const mode of ['disabled','native-fail']){const f=fixture();f.c.policy.agents.scopes.push('vault:prepare-submit');f.c.policy.passwordManager.controlledSubmission={enabled:mode!=='disabled',forms:[{id:'login',actionUrl:'https://work.example/session',usernameField:'user',passwordField:'pass'}]};let armed=0;f.cb.prepareSubmission=async()=>{armed++;return {prepared:true};};f.c.webContents.executeJavaScriptInIsolatedWorld=async()=>{throw Error('Form changed');};const a=f.make(),[row]=await a.list();await assert.rejects(a.prepare({credentialRef:row.credentialRef}));assert.equal(armed,0);}}); +test('post-arm revocation and handoff completion failure cancel only the returned private lease',async()=>{for(const mode of ['revoked','after-fill-error']){const f=fixture();f.c.policy.agents.scopes.push('vault:prepare-submit');f.c.policy.passwordManager.controlledSubmission={enabled:true,forms:[{id:'login',actionUrl:'https://work.example/session',usernameField:'user',passwordField:'pass'}]};let cancelled=0,armed=0;f.c.webContents.executeJavaScriptInIsolatedWorld=async()=>({completed:true,formId:'login'});f.cb.prepareSubmission=async()=>{armed++;if(mode==='revoked')f.invalidate();return {prepared:true,cancel:()=>cancelled++};};if(mode==='after-fill-error')f.cb.afterFill=()=>{throw Error('Private UI error');};const a=f.make(),[row]=await a.list();await assert.rejects(a.prepare({credentialRef:row.credentialRef}),{message:'Managed vault operation refused'});assert.equal(armed,1);assert.equal(cancelled,1);await assert.rejects(a.prepare({credentialRef:row.credentialRef}));assert.equal(armed,1);}});