Add policy-bound credential submission and native DLP audit proofs

This commit is contained in:
Karl committed 2026-10-13 21:00:41 +02:00
1 parent b991ee9da6
commit c8b6ff9cdd
45 files changed
+777 -98

No files matched your search

+49 -3
View File
@@ -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).
+4 -2
View File
@@ -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.
+1 -1
View File
@@ -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}={})=>{
+5 -4
View File
@@ -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;};
+35
View File
@@ -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;index<values.length;index++){let value=values[index];if(typeof value!=='string')fail();const seen=new Set();for(let round=0;round<3;round++){if(seen.has(value))break;seen.add(value);chars+=value.length;if(chars>131072)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};
+2 -1
View File
@@ -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');
+1 -1
View File
@@ -464,7 +464,7 @@
</form>
</details>
<small>
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.
</small>
</div>
</aside>
+68 -19
View File
@@ -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:['<all_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:['<all_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&&currentTabContext(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.time<Date.now()-120000||recentRequests.size>2000)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}`);}
+20 -5
View File
@@ -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;
+2
View File
@@ -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);}
});
+2 -1
View File
@@ -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);
+13 -9
View File
@@ -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};
+1 -1
View File
@@ -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'});
+3 -3
View File
@@ -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};
+1 -1
View File
@@ -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);}
+2 -2
View File
@@ -12,8 +12,8 @@
<section id="user-profiles" class="card"><p class="eyebrow">IDENTITY AND FEATURE ACCESS</p><h2>User classes and website access</h2><p>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.</p><button id="profiles-refresh" class="secondary">Refresh profiles</button><p id="profiles-status" role="status" aria-live="polite"></p><div class="table-wrap"><table><thead><tr><th>Profile</th><th>Assignments</th><th>Developer tools</th><th>Action</th></tr></thead><tbody id="profiles-rows"></tbody></table></div><div class="row"><button id="profile-new" class="secondary">Create your own profile</button><label>Starting class<select id="profile-template"><option value="employee">Employee</option><option value="developer">Developer</option><option value="high-protection">High protection</option></select></label><button id="profile-use-template" class="secondary">Use class in draft</button></div><h3 id="profile-editor-title">Create profile</h3><div class="grid"><label>Identifier<input id="profile-id" maxlength="80"></label><label>Name<input id="profile-name" maxlength="100"></label><label>Verified enterprise subject identifiers<textarea id="profile-subjects" placeholder="Exact issuer-verified subject; one per line"></textarea></label><fieldset><legend>Explicit device assignments</legend><div id="profile-devices" class="device-checklist"></div></fieldset><label>Allowed website hosts<textarea id="profile-allowed" placeholder="Empty adds no allowlist restriction"></textarea></label><label>Denied website hosts<textarea id="profile-blocked" placeholder="*.unapproved.example"></textarea></label><fieldset><legend>Denied domain categories</legend><div id="profile-categories" class="device-checklist"></div></fieldset><label>Developer tools / browser console<select id="profile-devtools"><option value="inherit">Inherit baseline</option><option value="deny">Deny</option><option value="allow">Allow within delegated baseline</option></select></label><label>Downloads<select id="profile-downloads"><option value="inherit">Inherit</option><option value="deny">Deny</option></select></label><label>AI workspace<select id="profile-ai"><option value="inherit">Inherit</option><option value="deny">Deny</option></select></label><label>Local agents<select id="profile-agents"><option value="inherit">Inherit</option><option value="deny">Deny</option></select></label><label class="checks"><input id="profile-scan" type="checkbox"> Require document analysis</label></div><p class="muted">Developer grants require central delegation; any applicable deny wins. No profile can override mandatory scanning or unlock centrally hidden settings.</p><div class="row"><button id="profile-save">Publish profile</button><button id="profile-delete" class="danger" disabled>Delete profile</button></div><h3>Preview effective device policy</h3><div class="row"><select id="profile-preview-device" aria-label="Preview device"></select><button id="profile-preview" class="secondary">Preview current draft</button></div><p id="profile-preview-summary" aria-live="polite"></p><div id="profile-preview-diff"></div><details><summary>Central categories and developer delegation</summary><p>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.</p><label class="checks"><input id="profile-delegation" type="checkbox"> Permit profiles to grant developer tools</label><h4>Website categories</h4><p>Use exact hosts or wildcard subdomains such as *.social.example. Changes stay in this local draft until explicit publication or independent approval.</p><button id="profile-category-add" class="secondary">Add website category</button><button id="profile-category-reset" class="secondary">Discard category draft</button><p id="profile-category-status" role="status" aria-live="polite"></p><div id="profile-category-editor"></div><button id="profile-config-save">Publish categories and delegation</button></details><h3>Assignment visibility</h3><div class="table-wrap"><table><thead><tr><th>Device</th><th>Verified subject</th><th>Effective profiles</th></tr></thead><tbody id="profile-device-rows"></tbody></table></div></section><section class="card" id="policy-groups"><p class="eyebrow">CONTROLLED DEPLOYMENT</p><h2>Groups and pilot rollouts</h2><p>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.</p><button id="groups-refresh" classLine truncated
<section class="card" id="bookmarks"><p class="eyebrow">COMPANY LIBRARY</p><h2>Managed bookmarks</h2><p>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.</p><div class="checks"><label><input type="checkbox" id="bookmarks-add"> Allow personal additions</label><label><input type="checkbox" id="bookmarks-edit"> Allow personal edits</label><label><input type="checkbox" id="bookmarks-delete"> Allow personal deletion</label><label><input type="checkbox" id="bookmarks-admin-read"> Allow administrators to read and edit personal bookmarks</label></div><fieldset><legend>AI library organization</legend><div class="checks"><label><input id="library-ai-enabled" type="checkbox"> Enable approved AI organization suggestions</label><label><input id="library-ai-autoapply" type="checkbox"> Permit agent application without an additional user review</label></div><p class="muted">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.</p></fieldset><div class="table-wrap"><table><thead><tr><th>Title</th><th>HTTPS / HTTP URL (no query or fragment)</th><th>Folder</th><th>Actions</th></tr></thead><tbody id="managed-bookmark-rows"></tbody></table></div><button class="secondary" id="managed-bookmark-add">Add company bookmark</button><p class="muted">Company changes are drafts until you publish the signed policy. Personal permissions also respect the centrally locked or hidden Library section.</p><h3>Device personal bookmarks</h3><p>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.</p><div class="row"><select id="bookmark-device" aria-label="Device to review"></select><button id="bookmark-device-load" class="secondary">Read device bookmarks</button></div><p id="bookmark-device-status" role="status" aria-live="polite"></p><div id="bookmark-device-editor" hidden><div class="table-wrap"><table><thead><tr><th>Title</th><th>HTTPS / HTTP URL (no query or fragment)</th><th>Folder</th><th>Actions</th></tr></thead><tbody id="personal-bookmark-rows"></tbody></table></div><div class="row"><button id="personal-bookmark-add" class="secondary">Add personal bookmark</button><button id="bookmark-device-save">Save remote changes</button><button id="bookmark-device-refresh" class="secondary">Discard edits and refresh</button></div><p class="muted">Saving compares the loaded revision so another user's or administrator's updates cannot be overwritten silently.</p></div></section>
<section id="teaching" class="card"><p class="eyebrow">TEACH YOUR BROWSER</p><h2>Define what must stay inside</h2><p>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.</p><div class="row"><input id="rule-label" placeholder="Rule name"><input id="rule-value" placeholder="Confidential phrase"><button id="add-rule">Add phrase</button></div><label class="file">Register a confidential file fingerprint<input id="fingerprint" type="file"></label><div id="rules"></div><div class="grid"><label class="wide">Try a policy example<textarea id="test-text" placeholder="Test content is sent to your management server, never to an AI provider."></textarea></label></div><button id="test" class="secondary">Test draft policy</button><output id="test-result"></output><h2>Local semantic reference protection</h2><p>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.</p><div class="grid"><label>Category name<input id="semantic-label" placeholder="Unreleased product designs"></label><label>Similarity threshold<input id="semantic-threshold" type="number" min="0.000001" max="1" step="0.01" value="0.8"></label><label class="wide">Confidential category description<textarea id="semantic-description" placeholder="Describe the protected topic with representative examples."></textarea></label></div><button id="semantic-add" class="secondary">Create semantic reference</button><label>Internal semantic reference document (up to 1 MiB)<input id="semantic-document" type="file"></label><button id="semantic-document-add" class="secondary">Derive local document reference</button><p>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.</p><label class="checks"><input id="semantic-enabled" type="checkbox"> Enforce semantic categories</label><div id="semantic-rules"></div><p class="muted">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.</p></section>
<section class="card" id="security-controls"><h2>Enterprise security and local agents</h2><p>Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.</p><div class="grid"><label class="checks"><input id="security-dns-enabled" type="checkbox"> Enforce DNS blocklists</label><label>Blocked DNS hosts<textarea id="security-dns-hosts" placeholder="*.unapproved.example"></textarea></label><label>Blocked IP addresses<textarea id="security-dns-ips" placeholder="203.0.113.10"></textarea></label><label>Blocked content phrases<textarea id="security-content-phrases" placeholder="One phrase per line"></textarea></label><label class="checks"><input id="security-yara-enabled" type="checkbox"> Require local YARA scanning</label><label>YARA maximum scanned bytes<input id="security-yara-max" type="number" min="1024" max="20971520"></label><label class="checks"><input id="agent-enabled" type="checkbox"> Enable paired local agents</label><label>Agent destination allowlist<textarea id="agent-hosts" placeholder="*.company.example"></textarea></label><label class="checks"><input id="agent-sensitive" type="checkbox"> Permit sensitive page reads within approved scopes</label><fieldset class="wide"><legend>Agent capabilities</legend><div class="checks"><label><input id="agent-scope-sessions" type="checkbox"> Sessions</label><label><input id="agent-scope-navigate" type="checkbox"> Navigate</label><label><input id="agent-scope-back" type="checkbox"> History back</label><label><input id="agent-scope-forward" type="checkbox"> History forward</label><label><input id="agent-scope-reload" type="checkbox"> Reload page</label><label><input id="agent-scope-read" type="checkbox"> Read</label><label><input id="agent-scope-inspect" type="checkbox"> Inspect page controls and values</label><label><input id="agent-scope-scroll" type="checkbox"> Scroll pages</label><label><input id="agent-scope-history:list" type="checkbox"> Read managed browsing history</label><label><input id="agent-scope-history:go" type="checkbox"> Navigate to a history entry</label><label><input id="agent-scope-window:state" type="checkbox"> Read browser window state</label><label><input id="agent-scope-window:control" type="checkbox"> Control browser window</label><label><input id="agent-scope-sessions:save" type="checkbox"> Save managed tab sessions</label><label><input id="agent-scope-sessions:list" type="checkbox"> List saved session references</label><label><input id="agent-scope-sessions:restore" type="checkbox"> Restore an approved saved session</label><label><input id="agent-scope-vault:status" type="checkbox"> Read password vault status</label><label><input id="agent-scope-vault:list" type="checkbox"> List masked references for approved origins</label><label><input id="agent-scope-vault:fill" type="checkbox"> Fill a selected credential on its exact origin</label><label><input id="agent-scope-click" type="checkbox"> Click</label><label><input id="agent-scope-type" type="checkbox"> Type</label><label><input id="agent-scope-select" type="checkbox"> Select approved form option</label><label><input id="agent-scope-check" type="checkbox"> Set checkbox state</label><label><input id="agent-scope-screenshot" type="checkbox"> Screenshot</label><label><input id="agent-scope-ai" type="checkbox"> AI gateway</label><label><input id="agent-scope-tabs:close" type="checkbox"> Close tabs</label><label><input id="agent-scope-bookmarks:list" type="checkbox"> Read bookmarks</label><label><input id="agent-scope-tabs:group" type="checkbox"> Group tabs</label><label><input id="agent-scope-tabs:activate" type="checkbox"> Activate tabs</label><label><input id="agent-scope-bookmarks:mutate" type="checkbox"> Organize user bookmarks</label><label><input id="agent-scope-application:state" type="checkbox"> Application state</label><label><input id="agent-scope-tabs:create" type="checkbox"> Create native tabs</label><label><input id="agent-scope-settings:get" type="checkbox"> Read managed settings</label><label><input id="agent-scope-settings:update" type="checkbox"> Change permitted appearance</label><label><input id="agent-scope-library:organize-plan" type="checkbox"> Propose AI library organization</label><label><input id="agent-scope-library:organize-apply" type="checkbox"> Apply approved library organization</label></div></fieldset><p class="wide" id="agent-navigation-window-help">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.</p><p class="wide" id="agent-sessions-help">Saving, listing and restoring sessions require separate capabilities. Saved sessions contain approved URLs and tab group metadata Line truncated
<section class="card" id="document-teaching"><h2>Governed document teaching</h2><p>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.</p><div class="row"><input id="teach-label" placeholder="Confidential reference label"><input id="teach-file" type="file"><button id="teach-submit">Create reviewed draft</button></div><div id="teach-drafts"></div></section><section class="card" id="password-vault"><h2>Local password vault</h2><p>Credentials stay on the browser device. Management stores permissions and lifecycle audit events only; passwords, usernames and credential identifiers are not uploaded.</p><div class="checks"><label><input id="vault-enabled" type="checkbox"> Enable password vault</label><label><input id="vault-saving" type="checkbox"> Allow saving credentials</label><label><input id="vault-autofill" type="checkbox"> Allow autofill</label></div><label>Idle lock in seconds (60–900)<input id="vault-idle" type="number" min="60" max="900"></label><p>Publish signed policy to apply these controls.</p></section><section class="card" id="visual-protection"><h2>Visual impersonation protection</h2><label class="checks"><input id="visual-enabled" type="checkbox"> Block protected login designs on unauthorized origins</label><p>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.</p><p>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.</p><div class="grid"><label>Brand identifier<input id="visual-id" maxlength="80" placeholder="corporate-login"></label><label>Brand label<input id="visual-label" maxlength="100" placeholder="Corporate login"></label><label class="wide">Exact allowed HTTPS origins<textarea id="visual-origins" placeholder="https://login.company.example"></textarea></label><label>Reference scope<select id="visual-scope"><option value="viewport">Whole viewport</option><option value="login-form">Cropped login form</option></select></label><label>Trusted PNG reference<input id="visual-image" type="file" accept="image/png"></label></div><button id="visual-add">Derive reference into draft</button><p>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.</p></section><section class="card" id="clipboard-controls"><h2>Managed clipboard and ClickFix protection</h2><p>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.</p><div class="checks"><label><input id="clipboard-enabled" type="checkbox"> Enable clipboard controls</label><label><input id="clipboard-copy" type="checkbox"> Allow copy</label><label><input id="clipboard-cut" type="checkbox"> Allow cut</label><label><input id="clipboard-paste" type="checkbox"> Allow paste</label><label><input id="clipboard-command" type="checkbox"> Block suspicious execution commands</label></div><label>Maximum clipboard text bytes<input id="clipboard-max" type="number" min="1024" max="1048576"></label><p>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.</p></section><section id="devices" class="card"><div class="section-head"><div><p class="eyebrow">DEVICE FLEET</p><h2>Enrollment and access</h2><p>Enable or disable supplemental manual document analysis per browser. Mandatory upload DLP and download YARA continue regardless of this setting.</p></div></div><div class="row"><input id="enroll-user" placeholder="Employee identity / asset owner"><button id="enroll">Create one-use enrollment code</button></div><output id="enrollment"></output><div class="row filter-row"><label>Find a device<input id="device-filter" type="search" placeholder="Name, identity or platform"></label><label>Access<select id="device-access-filter"><option value="all">All devices</option><option value="active">Active</option><option value="revoked">Revoked</Line truncated
<section class="card" id="security-controls"><h2>Enterprise security and local agents</h2><p>Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.</p><div class="grid"><label class="checks"><input id="security-dns-enabled" type="checkbox"> Enforce DNS blocklists</label><label>Blocked DNS hosts<textarea id="security-dns-hosts" placeholder="*.unapproved.example"></textarea></label><label>Blocked IP addresses<textarea id="security-dns-ips" placeholder="203.0.113.10"></textarea></label><label>Blocked content phrases<textarea id="security-content-phrases" placeholder="One phrase per line"></textarea></label><label class="checks"><input id="security-yara-enabled" type="checkbox"> Require local YARA scanning</label><label>YARA maximum scanned bytes<input id="security-yara-max" type="number" min="1024" max="20971520"></label><label class="checks"><input id="agent-enabled" type="checkbox"> Enable paired local agents</label><label>Agent destination allowlist<textarea id="agent-hosts" placeholder="*.company.example"></textarea></label><label class="checks"><input id="agent-sensitive" type="checkbox"> Permit sensitive page reads within approved scopes</label><fieldset class="wide"><legend>Agent capabilities</legend><div class="checks"><label><input id="agent-scope-sessions" type="checkbox"> Sessions</label><label><input id="agent-scope-navigate" type="checkbox"> Navigate</label><label><input id="agent-scope-back" type="checkbox"> History back</label><label><input id="agent-scope-forward" type="checkbox"> History forward</label><label><input id="agent-scope-reload" type="checkbox"> Reload page</label><label><input id="agent-scope-read" type="checkbox"> Read</label><label><input id="agent-scope-inspect" type="checkbox"> Inspect page controls and values</label><label><input id="agent-scope-scroll" type="checkbox"> Scroll pages</label><label><input id="agent-scope-history:list" type="checkbox"> Read managed browsing history</label><label><input id="agent-scope-history:go" type="checkbox"> Navigate to a history entry</label><label><input id="agent-scope-window:state" type="checkbox"> Read browser window state</label><label><input id="agent-scope-window:control" type="checkbox"> Control browser window</label><label><input id="agent-scope-sessions:save" type="checkbox"> Save managed tab sessions</label><label><input id="agent-scope-sessions:list" type="checkbox"> List saved session references</label><label><input id="agent-scope-sessions:restore" type="checkbox"> Restore an approved saved session</label><label><input id="agent-scope-vault:status" type="checkbox"> Read password vault status</label><label><input id="agent-scope-vault:list" type="checkbox"> List masked references for approved origins</label><label><input id="agent-scope-vault:fill" type="checkbox"> Fill a selected credential on its exact origin</label><label><input id="agent-scope-vault:prepare-submit" type="checkbox"> Fill and authorize one approved login POST (human-unlocked vault)</label><label><input id="agent-scope-click" type="checkbox"> Click</label><label><input id="agent-scope-type" type="checkbox"> Type</label><label><input id="agent-scope-select" type="checkbox"> Select approved form option</label><label><input id="agent-scope-check" type="checkbox"> Set checkbox state</label><label><input id="agent-scope-screenshot" type="checkbox"> Screenshot</label><label><input id="agent-scope-ai" type="checkbox"> AI gateway</label><label><input id="agent-scope-tabs:close" type="checkbox"> Close tabs</label><label><input id="agent-scope-bookmarks:list" type="checkbox"> Read bookmarks</label><label><input id="agent-scope-tabs:group" type="checkbox"> Group tabs</label><label><input id="agent-scope-tabs:activate" type="checkbox"> Activate tabs</label><label><input id="agent-scope-bookmarks:mutate" type="checkbox"> Organize user bookmarks</label><label><input id="agent-scope-application:state" type="checkbox"> Application state</label><label><input id="agent-scope-tabs:create" type="checkbox"> Create native tabs</label><label><input id="agent-scope-settings:get" type="checkbox"> Read managed settings</label><label><input id="agent-scope-settings:update" type="checkbox"> Change permitted appearance</label><label><input id="agent-scope-library:organize-plan" type="checkbox"> Propose AI library organization</label><label><input id="agent-scope-library:organize-apply" type="checkbox"> Apply approved library organization</label></div></fieldset><p class="wide" id="agent-navigation-window-help">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.</p><p class="wide" id="agent-sesLine truncated
<section class="card" id="document-teaching"><h2>Governed document teaching</h2><p>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.</p><div class="row"><input id="teach-label" placeholder="Confidential reference label"><input id="teach-file" type="file"><button id="teach-submit">Create reviewed draft</button></div><div id="teach-drafts"></div></section><section class="card" id="password-vault"><h2>Local password vault</h2><p>Credentials stay on the browser device. Management stores permissions and lifecycle audit events only; passwords, usernames and credential identifiers are not uploaded.</p><div class="checks"><label><input id="vault-enabled" type="checkbox"> Enable password vault</label><label><input id="vault-saving" type="checkbox"> Allow saving credentials</label><label><input id="vault-autofill" type="checkbox"> Allow autofill</label></div><fieldset><legend>Controlled credential submission</legend><label class="checks"><input id="vault-controlled-submit" type="checkbox"> Allow one approved vault login POST</label><p id="vault-controlled-submit-help">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.</p><label>Approved login POST rules (metadata only, maximum 32)<textarea id="vault-submit-forms" rows="9" maxlength="150000" spellcheck="false" placeholder='[{"id":"company-login","actionUrl":"https://login.company.example/signin","usernameField":"username","passwordField":"password"}]'></textarea></label><p>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.</p></fieldset><label>Idle lock in seconds (60–900)<input id="vault-idle" type="number" min="60" max="900"></label><p>Publish signed policy to apply these controls.</p></section><section class="card" id="visual-protection"><h2>Visual impersonation protection</h2><label class="checks"><input id="visual-enabled" type="checkbox"> Block protected login designs on unauthorized origins</label><p>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.</p><p>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.</p><div class="grid"><label>Brand identifier<input id="visual-id" maxlength="80" placeholder="corporate-login"></label><label>Brand label<input id="visual-label" maxlength="100" placeholder="Corporate login"></label><label class="wide">Exact allowed HTTPS origins<textarea id="visual-origins" placeholder="https://login.company.example"></textarea></label><label>Reference scope<select id="visual-scope"><option value="viewport">Whole viewport</option><option value="login-form">Cropped login form</option></select></label><label>Trusted PNG reference<input id="visual-image" type="file" accept="image/png"></label></div><button id="visual-add">Derive reference into draft</button><p>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.</p></section><section class="card" id="clipboard-controls"><h2>Managed clipboard and ClickFix protection</h2><p>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.</p><div class="checks"><label><input id="clipboard-enabled" type="checkbox"> Enable clipboard controls</label><label><input id="clipboard-copy" type="checkbox"> Allow copy</lLine truncated
<section id="access-sessions" class="card"><p class="eyebrow">ADMINISTRATOR ACCESS</p><h2>Active management sessions</h2><p>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.</p><button id="sessions-load" class="secondary">Refresh active sessions</button><p id="sessions-status" role="status" aria-live="polite">Session inventory requires the administrator role.</p><div class="table-wrap"><table><thead><tr><th>Identity</th><th>Role</th><th>Expires</th><th>Action</th></tr></thead><tbody id="session-rows"></tbody></table></div></section><section id="incidents" class="card"><p class="eyebrow">SECURITY OPERATIONS</p><h2>Incident triage</h2><p>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.</p><div class="row filter-row"><label>Status<select id="incident-status-filter"><option value="">All statuses</option><option>open</option><option>investigating</option><option>resolved</option><option>dismissed</option></select></label><label>Severity<select id="incident-severity-filter"><option value="">All severities</option><option>low</option><option>medium</option><option>high</option><option>critical</option></select></label><button id="incidents-refresh" class="secondary">Refresh incidents</button></div><p id="incidents-status" role="status" aria-live="polite"></p><div class="table-wrap"><table><thead><tr><th>Created</th><th>Security event</th><th>Status</th><th>Severity</th><th>Assignee</th><th>Review</th></tr></thead><tbody id="incident-rows"></tbody></table></div><button id="incidents-more" class="secondary" hidden>Load older incidents</button><div id="incident-detail" hidden><div class="section-head"><h3 id="incident-title">Incident</h3><button id="incident-detail-refresh" class="secondary">Discard edits and refresh</button></div><p id="incident-revision" class="muted"></p><pre id="incident-event"></pre><section id="incident-evidence" class="evidence-viewer" hidden aria-labelledby="evidence-title"><div class="section-head"><h3 id="evidence-title">Device-reported protected evidence</h3><button id="evidence-close" class="secondary">Close and clear</button></div><label>Access purpose<select id="evidence-reason"><option value="">Choose a purpose</option><option value="incident-response">Incident response</option><option value="investigation">Investigation</option><option value="compliance">Compliance</option></select></label><div id="evidence-rows"></div><p id="evidence-status" role="status" aria-live="polite"></p><pre id="evidence-plaintext" hidden></pre></section><div class="grid"><label>Investigation status<select id="incident-state"><option>open</option><option>investigating</option><option>resolved</option><option>dismissed</option></select></label><label>Severity<select id="incident-severity"><option>low</option><option>medium</option><option>high</option><option>critical</option></select></label><label>Assignee<input id="incident-assignee" maxlength="160" placeholder="Team or analyst identity"></label><label class="wide">Investigation note<textarea id="incident-comment" maxlength="1000" placeholder="Metadata and investigation outcome only; do not paste secrets."></textarea></label></div><button id="incident-save">Save investigation update</button><p id="incident-detail-status" role="status" aria-live="polite"></p><h3>Investigation history</h3><div id="incident-history"></div></div></section><section class="card" id="audit-retention"><p class="eyebrow">AUDIT LIFECYCLE</p><h2>Database event retention</h2><p id="audit-retention-status" role="status">Loading deployment configuration.</p><p class="muted">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.</p></section><section id="events" class="card"><p class="eyebrow">SECURITY MONITORING</p><h2>Recent audit events</h2><div class="row"><button id="export-ecs" class="secondary">Export ECS JSON</button><button id="export-cef" class="secondary">Export CEF</button></div><div class="row filter-row"><label>Find an event<input id="event-filter" type="search" placeholder="Event type, device or metadata"></label><label>Decision<select id="event-decision-filter"><option value="all">All events</option><option value="block">Blocks</option><option value="allow">Allows</option></select></label><output id="event-filter-count" aria-live="polite"></output></div><div class="table-wrap"><table><thead><tr><th>Time</th><th>Event</th><th>Device</th><th>Metadata</th></tr></thead><tbody id="event-rows"></tbody></table></div><p class="muted">This view shows the latest 100 eventsLine truncated
<section class="card" id="company-branding"><p class="eyebrow">COMPANY EXPERIENCE</p><h2>Company logo and start page</h2><p>Roll out your company identity through signed policy. Name and accent above apply alongside this logo.</p><div class="grid"><label>Company logo (PNG, up to 128 KiB)<input id="brand-logo-file" type="file" accept="image/png"></label><div><img id="brand-logo-preview" alt="Company logo preview" width="160" height="80" hidden><button id="brand-logo-remove" type="button" class="secondary">Remove logo</button><output id="brand-logo-status" aria-live="polite"></output></div><label>Start-page heading<input id="startpage-title" maxlength="100" placeholder="Welcome"></label><label>Start-page message<input id="startpage-subtitle" maxlength="240" placeholder="Your company workspace"></label><label><input id="startpage-news-enabled" type="checkbox"> Show company news</label><label>Company RSS / Atom feed (HTTPS)<input id="startpage-feed-url" type="url" placeholder="https://intranet.company.example/news.xml"></label></div><p>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.</p></section><section class="card" id="download-scan-controls"><h2>Download inspection</h2><div class="checks"><label><input id="download-document-dlp" type="checkbox"> Require complete document DLP for incoming downloads</label><label><input id="yara-office-macros" type="checkbox"> Scan extracted Office VBA with enabled central YARA rules</label></div><p>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.</p></section><section id="audit-integrity" class="card" hidden><p class="eyebrow">AUDIT INTEGRITY</p><div class="section-head"><h2>Verify security audit records</h2><button id="audit-integrity-verify" type="button" class="secondary">Verify stored records</button></div><p>Run an explicit consistency and signature check. This check is separate from OpenTelemetry traces and SIEM delivery.</p><p id="audit-integrity-status" class="muted" aria-live="polite">Not verified in this session.</p><div id="audit-integrity-details" style="overflow-wrap:anywhere"></div></section><section id="evidence-policy-controls" class="card"><p class="eyebrow">PROTECTED SECURITY EVIDENCE</p><h2>Protected evidence collection</h2><p>Opt in through centrally signed policy. All collection controls are off by default. Ordinary security audit records and OpenTelemetry traces remain separate.</p><div class="checks"><label><input id="evidence-policy-enabled" type="checkbox"> Enable protected evidence recording</label><label><input id="evidence-policy-downloads" type="checkbox"> Automatically capture inspected download metadata</label><label><input id="evidence-policy-interactions" type="checkbox"> Capture clipboard, agent-click and observed page-gesture metadata</label></div><p>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.</p><p>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.</p></section><section id="diagnostics-controls" class="card"><p class="eyebrow">OBSERVABILITY</p><h2>Browser OpenTelemetry</h2><p>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.</p><div class="grid"><label><input id="diagnostics-enabled" type="checkbox"> Enable browser diaLine truncated
+8 -6
View File
@@ -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.
+135
View File
@@ -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.
+54
View File
@@ -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.
+142 -17
View File
@@ -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.
+25
View File
@@ -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.
+1 -1
View File
@@ -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'});
+15
View File
@@ -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({});
+2 -1
View File
@@ -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) })
+1 -1
View File
@@ -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();
+2 -2
View File
@@ -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,
+4 -4
View File
@@ -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');
+3 -2
View File
@@ -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});}
+27
View File
@@ -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);}});
+25
View File
@@ -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('<!doctype html><title>Login accepted</title>Done'),17000);return;}res.end('<!doctype html><title>Login accepted</title>Done');});return;}if(req.url==='/final'){finalGets++;res.setHeader('content-type','text/html');res.end('<!doctype html><title>Login accepted</title>Done');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('<!doctype html><title>Approved login fixture</title><form method="post" action="/signin"><input name="username" autocomplete="username"><input name="password" type="password" autocomplete="current-password"><button id="submit">Sign in</button></form>');}).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);
+10
View File
@@ -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);}});
+22
View File
@@ -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('<!doctype html><title>Public response</title>Done');return;}res.setHeader('content-type','text/html');res.end('<!doctype html><title>Public login fixture</title><form method="post" action="/submit"><input name="username" autocomplete="username"><input name="password" type="password" autocomplete="current-password"><button>Sign in</button></form>');}).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);
+1 -1
View File
@@ -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}`);
+8
View File
@@ -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(`<input[^>]*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(`<input[^>]*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(`<input[^>]*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'"));
});
+16
View File
@@ -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'));}});
+5
View File
@@ -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:[email protected]/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);});
@@ -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&note=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);});
+30
View File
@@ -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&note=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&note=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&note=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));}});
+2 -1
View File
@@ -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}})};
+10 -3
View File
@@ -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);}
});
+2 -2
View File
@@ -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=>{
+2 -1
View File
@@ -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()};
}
+4 -1
View File
@@ -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);
});
+1 -1
View File
@@ -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);
+6 -1
View File
@@ -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);}});