diff --git a/README.md b/README.md index 409663fe..381806a1 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2942 keys - 7567+ tests / 603 files + 7593+ tests / 603 files Codecov Coverage License MIT CI Status @@ -511,7 +511,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | | **i18n** | Custom React Context (`I18nContext.tsx`) | 2942 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence | -| **Testing** | Vitest 4.x (7567+ tests / 603 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7593+ tests / 603 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | | **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy | | **Visualization** | Force-directed graph | Interactive character relationship network | | **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` | @@ -549,7 +549,7 @@ WorldScript-Studio/ │ ├── sw.js # PWA Service Worker │ └── manifest.json # PWA Web App Manifest v3 ├── tests/ -│ ├── unit/ # Vitest unit tests (7567+ tests, 603 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7593+ tests, 603 files) — count spans tests/, components/, packages/*/tests/, not just this folder │ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths │ │ └── settings/ # WebLlmPanel, AiSections │ └── e2e/ # Playwright specs + helpers.ts @@ -714,7 +714,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt Raw bundle-budget ceilings (KB per uncompressed asset): entry **2500 KB**, vendor **6200 KB**, other JavaScript **2500 KB**, and WASM **30000 KB**. **Current test metrics (2026-09-07, source-synchronized; CI remains authoritative for pass/fail):** -- **7567+ unit tests** across **603 test files** — CI is authoritative for pass/fail +- **7593+ unit tests** across **603 test files** — CI is authoritative for pass/fail - Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics) - i18n: **2942 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta) diff --git a/docs/DEPENDABOT-TRIAGE.md b/docs/DEPENDABOT-TRIAGE.md index 096ed53d..2d0c8d4c 100644 --- a/docs/DEPENDABOT-TRIAGE.md +++ b/docs/DEPENDABOT-TRIAGE.md @@ -81,14 +81,21 @@ rule; it applies to dependency PRs exactly as it does to feature PRs. ## OSV ignore-expiry review -The 2026-08-20 consolidation review found **19** entries in `src-tauri/osv-scanner.toml`, all -currently sharing the `2026-11-30T00:00:00Z` review deadline. This is a synchronized review cliff, -not evidence that the risks were extended or resolved. The entries remain grouped by their real -reason: legacy GTK3/WebKit bindings, build-time `proc-macro-error`/`paste`, archived Unicode data -crates, and the transitive `extract-zip` advisory. - -No ignore deadline was extended in this pass. `extract-zip` remains a transitive Playwright browser -download dependency with no patched release and no production-runtime footprint; it must still be +The 2026-08-20 consolidation review found 19 entries in `src-tauri/osv-scanner.toml`, all +currently sharing the `2026-11-30T00:00:00Z` review deadline. **2026-09-08 update: two more npm +entries were added under the same deadline** — a second, distinct `extract-zip` 2.0.1 advisory +(`GHSA-7pqw-9j4j-h8q3`, not an alias of the original) and `adm-zip` 0.6.0 +(`GHSA-vwc7-r8mq-g2x9`) — bringing the total to **21**. This is a synchronized review cliff, not +evidence that the risks were extended or resolved; **`src-tauri/osv-scanner.toml` is the dynamic +source of truth for the exact count and entries** — treat this list as a cluster summary, not a +substitute for reading the file. The entries remain grouped by their real reason: legacy GTK3/WebKit +bindings, build-time `proc-macro-error`/`paste`, archived Unicode data crates, both `extract-zip` +advisories, and `adm-zip`. + +No ignore deadline was extended in this pass. `extract-zip` remains a transitive dependency of +`@lhci/cli`'s own Puppeteer stack (`lighthouse` -> `puppeteer-core` -> `@puppeteer/browsers` -> +`extract-zip`, verified via `pnpm audit` — not this repo's separate Playwright devDependency) +with no patched release and no production-runtime footprint; it must still be rechecked before expiry and removed as soon as an upstream fix or dependency-path change makes that possible. Review each cluster against current upstream status before changing any deadline. diff --git a/docs/IDB-ENCRYPTION.md b/docs/IDB-ENCRYPTION.md index ebc4232b..bb0e8ce1 100644 --- a/docs/IDB-ENCRYPTION.md +++ b/docs/IDB-ENCRYPTION.md @@ -165,7 +165,7 @@ Every protected store writer runs inside `withProtectedWriteAdmission()` (shared On the Tauri desktop build, primary project, settings, snapshot, image, Codex, RAG, and binder-asset data is persisted by the filesystem-backed store (`services/fs/*Store.ts`), not IndexedDB. That store writes plaintext (LZ-string compressed only, no encryption) regardless of `enableIdbAtRestEncryption`. Enabling the setting on desktop still shows `IdbUnlockModal`/`PassphraseModal` (the passphrase sentinel lives in the WebView's own IndexedDB, which persists on desktop too), but that unlock flow gates nothing on the filesystem side today — only the UI, not the actual manuscript files under `$APPDATA`, is shared with the web build. Character and world image reads use `storageService`, so they now follow the same selected backend as image uploads; this removes the prior desktop filesystem/IndexedDB split-persistence availability bug. See `README.md`'s "Encryption — which mechanism protects what" table for the authoritative per-mechanism breakdown. Extending real at-rest protection to the desktop filesystem store is a tracked, open gap — not yet implemented. -**API keys (resolved 2026-08-14):** all provider API keys, including Gemini, now route through `storageService` directly to the IndexedDB key store (`services/storage/idbKeyStore.ts`, random non-extractable AES-GCM key) on every platform, desktop included. The Tauri filesystem adapter's `saveApiKey`/`getApiKey` (`services/fs/settingsFsStore.ts`) is now a defense-in-depth backstop rather than the active path: `saveApiKey` throws if ever called, and `getApiKey` silently removes any pre-existing legacy key file — whether from the pre-2026-07-29 unsalted-SHA-256 scheme or the since-hardened but still filesystem-reconstructible PBKDF2 scheme — and returns `null`. A user-facing "API Key Reset Required" notification fires only if that removal itself fails (a permissions/IO error surfacing through the catch path); the common case (file found and removed cleanly) is silent, since a re-prompt for a never-populated key is indistinguishable from normal first-use. `encryptText`/`decryptText` in `fsCore.ts` are no longer called anywhere in the codebase for API keys and remain only as shared crypto plumbing pending [PR #356](https://github.com/qnbs/WorldScript-Studio/pull/356)'s project-data encryption work. This also resolves the earlier Gemini split-persistence bug tracked in [#358](https://github.com/qnbs/WorldScript-Studio/issues/358): `components/ApiKeySection.tsx` and `services/geminiService.ts` now both route through `storageService`. +**API keys (resolved 2026-08-14):** all provider API keys, including Gemini, now route through `storageService` directly to the IndexedDB key store (`services/storage/idbKeyStore.ts`, random non-extractable AES-GCM key) on every platform, desktop included. The Tauri filesystem adapter's `saveApiKey`/`getApiKey` (`services/fs/settingsFsStore.ts`) is now a defense-in-depth backstop rather than the active path: `saveApiKey` throws if ever called, and `getApiKey` silently removes any pre-existing legacy key file — whether from the pre-2026-07-29 unsalted-SHA-256 scheme or the since-hardened but still filesystem-reconstructible PBKDF2 scheme — and returns `null`. A user-facing "API Key Reset Required" notification fires only when a failure occurs after the key-file path is resolved — during the existence check or the removal attempt itself, e.g. a transient permissions/IO error — **and** the catch block's own follow-up removal then succeeds; earlier failures (acquiring the platform APIs or resolving the app-data path) never reach the cleanup/notification logic at all, and if the follow-up removal also fails, the notification is likewise suppressed and only a warning is logged. The common case — no legacy file exists, or the initial removal succeeds outright — returns silently, since a re-prompt for a never-populated key is indistinguishable from normal first-use. `encryptText`/`decryptText` in `fsCore.ts` are no longer called anywhere in the codebase for API keys and remain only as shared crypto plumbing pending R-15's project-data encryption work (`docs/native/CORE-MIGRATION-LEDGER.md` row 10 — check the ledger directly for its current readiness marker rather than trusting a copied status here — additionally gated behind row 9, the project state-shape compatibility adapter, converging first). This also resolves the earlier Gemini split-persistence bug tracked in [#358](https://github.com/qnbs/WorldScript-Studio/issues/358): `components/ApiKeySection.tsx` and `services/geminiService.ts` now both route through `storageService`. The repository does **not** currently use `tauri-plugin-stronghold`, an OS keychain, or a transparent desktop-only passphrase store. diff --git a/docs/SECURITY-THREAT-MODEL.md b/docs/SECURITY-THREAT-MODEL.md index 3eb88204..77852737 100644 --- a/docs/SECURITY-THREAT-MODEL.md +++ b/docs/SECURITY-THREAT-MODEL.md @@ -40,8 +40,8 @@ This document provides a formal STRIDE threat analysis for WorldScript Studio, m | Threat | Mitigation | Code Location | |--------|------------|-------------| | API key leakage via logs | StructuredLogger sanitization; never log keys | `services/logger.ts:sanitizeLogContext()` | -| Desktop API key exposure via local filesystem read | **Resolved 2026-08-14 (was "not resolved" through 2026-08-13 — see history).** Filesystem API-key persistence is disabled: `storageService`'s key methods route directly to the IndexedDB key store (random non-extractable AES-GCM key) on every platform, desktop included — the filesystem adapter's own `saveApiKey` is a defense-in-depth backstop that throws if ever called directly. The prior PBKDF2-from-reconstructible-material derivation (`deriveFileSystemCryptoKey()`) is no longer used for API keys; it remains in `fsCore.ts` as shared crypto plumbing for other filesystem-encrypted data (see [PR #356](https://github.com/qnbs/WorldScript-Studio/pull/356)). Legacy filesystem key files are removed on a best-effort basis (each failure is logged, not retried indefinitely) and re-entry is required if cleanup or decryption fails. **Gemini split-persistence bug also resolved:** `components/ApiKeySection.tsx` and `services/geminiService.ts` now both route through `storageService`, closing [#358](https://github.com/qnbs/WorldScript-Studio/issues/358) (previously ApiKeySection read/wrote the Gemini key via `dbService` directly while geminiService read it via `storageService`, so a key saved on desktop was invisible to the code that used it). | `services/storage/idbKeyStore.ts`, `services/storageService.ts`, `services/fs/settingsFsStore.ts` | -| Desktop project/settings/snapshot/Codex/RAG/image/binder-asset data disclosure via local file-read access | **Not resolved on current `main`.** Tauri filesystem records, including binder `.bin` payloads, are plaintext (some text records are compressed only), so an attacker who can read the app-data directory can disclose them. The separate tampering threat requires local file-write access and is modeled under Tampering above. Enabling the current browser/IndexedDB setting does not protect these files. [PR #356](https://github.com/qnbs/WorldScript-Studio/pull/356) is the active remediation; do not broaden this claim until its combined-state runtime proof completes. | `services/fs/*Store.ts` | +| Desktop API key exposure via local filesystem read | **Resolved 2026-08-14 (was "not resolved" through 2026-08-13 — see history).** Filesystem API-key persistence is disabled: `storageService`'s key methods route directly to the IndexedDB key store (random non-extractable AES-GCM key) on every platform, desktop included — the filesystem adapter's own `saveApiKey` is a defense-in-depth backstop that throws if ever called directly. The prior PBKDF2-from-reconstructible-material derivation (`deriveFileSystemCryptoKey()`) is no longer used for API keys; it remains in `fsCore.ts` as shared crypto plumbing for other filesystem-encrypted data (see [PR #356](https://github.com/qnbs/WorldScript-Studio/pull/356), closed 2026-08-18 as superseded). Legacy filesystem key files are removed on a best-effort basis (each failure is logged, not retried indefinitely) and re-entry is required if cleanup or decryption fails. **Gemini split-persistence bug also resolved:** `components/ApiKeySection.tsx` and `services/geminiService.ts` now both route through `storageService`, closing [#358](https://github.com/qnbs/WorldScript-Studio/issues/358) (previously ApiKeySection read/wrote the Gemini key via `dbService` directly while geminiService read it via `storageService`, so a key saved on desktop was invisible to the code that used it). | `services/storage/idbKeyStore.ts`, `services/storageService.ts`, `services/fs/settingsFsStore.ts` | +| Desktop project/settings/snapshot/Codex/RAG/image/binder-asset data disclosure via local file-read access | **Not resolved on current `main`.** Tauri filesystem records, including binder `.bin` payloads, are plaintext (some text records are compressed only), so an attacker who can read the app-data directory can disclose them. The separate tampering threat requires local file-write access and is modeled under Tampering above. Enabling the current browser/IndexedDB setting does not protect these files. R-15 (`docs/native/CORE-MIGRATION-LEDGER.md` row 10) is the design-complete remediation — implementation readiness is tracked live by row 10's own readiness marker, additionally gated behind row 9 (the project state-shape compatibility adapter) converging first; check the ledger directly rather than trusting a copied status here, and do not broaden this claim until implementation actually starts. | `services/fs/*Store.ts` | | Manuscript data in IndexedDB | AES-256-GCM at-rest encryption | `services/storage/storageEncryptionService.ts` | | Voice audio to cloud | Web Speech API consent gate | `components/voice/VoicePrivacyConsentModal.tsx` | | DuckDB analytics unencrypted (SEC-6) | **Bounded by design, with one prose column now encrypted:** most persisted fields are local metadata only (titles, loglines, character names, word counts, embeddings) and **nothing leaves the device**. The one column that genuinely holds literal manuscript prose, `codex_mentions.excerpt`, is now cell-level encrypted (AES-256-GCM via `services/duckdb/duckdbEncryption.ts`, reusing the IDB at-rest encryption key) whenever `enableIdbAtRestEncryption` is active: `duckdbCodexWrite()` writes ciphertext into `excerpt_enc BLOB` and nulls the plaintext `excerpt` column; `services/duckdb/codexExcerptEncryptionMigration.ts` backfills any pre-existing plaintext rows once encryption is unlocked. Gated by `enableDuckDbAnalytics` **and** the Settings → Privacy "Analytics" opt-out (`isAnalyticsPersistenceAllowed` in `app/listenerMiddleware.ts`); turning the toggle off stops all DuckDB writes + inference telemetry. Full OPFS file-level encryption remains **infeasible** — DuckDB-WASM owns the OPFS file handle directly, so there is no app-level interception point; the other metadata columns stay intentionally plaintext (bounded-exposure design). | `app/listenerMiddleware.ts:isAnalyticsPersistenceAllowed`, `services/duckdb/duckdbAnalytics.ts:duckdbCodexWrite()`, `services/duckdb/duckdbEncryption.ts`, `services/duckdb/codexExcerptEncryptionMigration.ts` | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e14e25ca..226c4f91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,7 +8,7 @@ overrides: playwright: 1.61.0 playwright-core: 1.61.0 esbuild: '>=0.28.1' - joi: ^18.2.1 + joi: '>=18.2.5 <19' typescript: 6.0.3 serialize-javascript: '>=7.0.3' chrome-launcher: ^1.2.1 @@ -28,12 +28,12 @@ overrides: dompurify: '>=3.4.13' '@babel/core': '>=7.29.6 <8' undici: '>=7.29.0 <8' - js-yaml: '>=4.3.1 <5' + js-yaml: '>=4.3.2 <5' nanoid@^3: 3.3.18 nanoid@^5: 5.1.16 adm-zip: '>=0.6.0' postcss: '>=8.5.18' - sharp: '>=0.35.0' + sharp: '>=0.35.4' body-parser: '>=1.20.6 <2' patchedDependencies: @@ -1615,163 +1615,81 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.3': - resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} - engines: {node: '>=20.9.0'} - cpu: [arm64] - os: [darwin] - '@img/sharp-darwin-arm64@0.35.4': resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.3': - resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} - engines: {node: '>=20.9.0'} - cpu: [x64] - os: [darwin] - '@img/sharp-darwin-x64@0.35.4': resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.3': - resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} - engines: {node: '>=20.9.0'} - os: [freebsd] - '@img/sharp-freebsd-wasm32@0.35.4': resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.2': - resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} - cpu: [arm64] - os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.3.3': resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.2': - resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} - cpu: [x64] - os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.3': resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.2': - resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} - cpu: [arm64] - os: [linux] - libc: [glibc] - '@img/sharp-libvips-linux-arm64@1.3.3': resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.2': - resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} - cpu: [arm] - os: [linux] - libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.3': resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.2': - resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.3': resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.2': - resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.3': resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.2': - resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} - cpu: [s390x] - os: [linux] - libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.3': resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.2': - resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} - cpu: [x64] - os: [linux] - libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.3': resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.3.2': - resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} - cpu: [arm64] - os: [linux] - libc: [musl] - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.2': - resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} - cpu: [x64] - os: [linux] - libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.3': resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.35.3': - resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} - engines: {node: '>=20.9.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - '@img/sharp-linux-arm64@0.35.4': resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} engines: {node: '>=20.9.0'} @@ -1779,13 +1697,6 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.35.3': - resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} - engines: {node: '>=20.9.0'} - cpu: [arm] - os: [linux] - libc: [glibc] - '@img/sharp-linux-arm@0.35.4': resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} engines: {node: '>=20.9.0'} @@ -1793,13 +1704,6 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.35.3': - resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} - engines: {node: '>=20.9.0'} - cpu: [ppc64] - os: [linux] - libc: [glibc] - '@img/sharp-linux-ppc64@0.35.4': resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} engines: {node: '>=20.9.0'} @@ -1807,13 +1711,6 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.35.3': - resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} - engines: {node: '>=20.9.0'} - cpu: [riscv64] - os: [linux] - libc: [glibc] - '@img/sharp-linux-riscv64@0.35.4': resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} engines: {node: '>=20.9.0'} @@ -1821,13 +1718,6 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.35.3': - resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} - engines: {node: '>=20.9.0'} - cpu: [s390x] - os: [linux] - libc: [glibc] - '@img/sharp-linux-s390x@0.35.4': resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} engines: {node: '>=20.9.0'} @@ -1835,13 +1725,6 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.35.3': - resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} - engines: {node: '>=20.9.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - '@img/sharp-linux-x64@0.35.4': resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} engines: {node: '>=20.9.0'} @@ -1849,13 +1732,6 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.35.3': - resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} - engines: {node: '>=20.9.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - '@img/sharp-linuxmusl-arm64@0.35.4': resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} engines: {node: '>=20.9.0'} @@ -1863,13 +1739,6 @@ packages: os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.3': - resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} - engines: {node: '>=20.9.0'} - cpu: [x64] - os: [linux] - libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.4': resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} engines: {node: '>=20.9.0'} @@ -1877,54 +1746,27 @@ packages: os: [linux] libc: [musl] - '@img/sharp-wasm32@0.35.3': - resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} - engines: {node: '>=20.9.0'} - '@img/sharp-wasm32@0.35.4': resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.3': - resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} - engines: {node: '>=20.9.0'} - cpu: [wasm32] - '@img/sharp-webcontainers-wasm32@0.35.4': resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.3': - resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} - engines: {node: '>=20.9.0'} - cpu: [arm64] - os: [win32] - '@img/sharp-win32-arm64@0.35.4': resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.3': - resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} - engines: {node: ^20.9.0} - cpu: [ia32] - os: [win32] - '@img/sharp-win32-ia32@0.35.4': resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.3': - resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} - engines: {node: '>=20.9.0'} - cpu: [x64] - os: [win32] - '@img/sharp-win32-x64@0.35.4': resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} engines: {node: '>=20.9.0'} @@ -5734,8 +5576,8 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - joi@18.2.3: - resolution: {integrity: sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==} + joi@18.2.5: + resolution: {integrity: sha512-+gEA7rLfaNWx9JzawWPrPetSZwT16NUqHtECDgjyAJreXcs4TM7tx2Pa+VVJJK0YHM83ybrVdaT6UekHH50FJQ==} engines: {node: '>= 20'} jpeg-js@0.4.4: @@ -5754,8 +5596,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.3.1: - resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true jsdom@29.1.1: @@ -6996,15 +6838,6 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - sharp@0.35.3: - resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} - engines: {node: '>=20.9.0'} - peerDependencies: - '@types/node': '*' - peerDependenciesMeta: - '@types/node': - optional: true - sharp@0.35.4: resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} engines: {node: '>=20.9.0'} @@ -9391,218 +9224,114 @@ snapshots: '@huggingface/tokenizers': 0.1.3 onnxruntime-node: 1.24.3 onnxruntime-web: 1.26.0-dev.20260416-b7804b056c - sharp: 0.35.3(@types/node@25.9.2) + sharp: 0.35.4(@types/node@25.9.2) transitivePeerDependencies: - '@types/node' optional: true '@img/colour@1.1.0': {} - '@img/sharp-darwin-arm64@0.35.3': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.2 - optional: true - '@img/sharp-darwin-arm64@0.35.4': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.3.3 optional: true - '@img/sharp-darwin-x64@0.35.3': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.2 - optional: true - '@img/sharp-darwin-x64@0.35.4': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.3.3 optional: true - '@img/sharp-freebsd-wasm32@0.35.3': - dependencies: - '@img/sharp-wasm32': 0.35.3 - optional: true - '@img/sharp-freebsd-wasm32@0.35.4': dependencies: '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.2': - optional: true - '@img/sharp-libvips-darwin-arm64@1.3.3': optional: true - '@img/sharp-libvips-darwin-x64@1.3.2': - optional: true - '@img/sharp-libvips-darwin-x64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm64@1.3.2': - optional: true - '@img/sharp-libvips-linux-arm64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm@1.3.2': - optional: true - '@img/sharp-libvips-linux-arm@1.3.3': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.2': - optional: true - '@img/sharp-libvips-linux-ppc64@1.3.3': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.2': - optional: true - '@img/sharp-libvips-linux-riscv64@1.3.3': optional: true - '@img/sharp-libvips-linux-s390x@1.3.2': - optional: true - '@img/sharp-libvips-linux-s390x@1.3.3': optional: true - '@img/sharp-libvips-linux-x64@1.3.2': - optional: true - '@img/sharp-libvips-linux-x64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.2': - optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.2': - optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.3': optional: true - '@img/sharp-linux-arm64@0.35.3': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.2 - optional: true - '@img/sharp-linux-arm64@0.35.4': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.3.3 optional: true - '@img/sharp-linux-arm@0.35.3': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.2 - optional: true - '@img/sharp-linux-arm@0.35.4': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.3.3 optional: true - '@img/sharp-linux-ppc64@0.35.3': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.2 - optional: true - '@img/sharp-linux-ppc64@0.35.4': optionalDependencies: '@img/sharp-libvips-linux-ppc64': 1.3.3 optional: true - '@img/sharp-linux-riscv64@0.35.3': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.2 - optional: true - '@img/sharp-linux-riscv64@0.35.4': optionalDependencies: '@img/sharp-libvips-linux-riscv64': 1.3.3 optional: true - '@img/sharp-linux-s390x@0.35.3': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.2 - optional: true - '@img/sharp-linux-s390x@0.35.4': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.3.3 optional: true - '@img/sharp-linux-x64@0.35.3': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.2 - optional: true - '@img/sharp-linux-x64@0.35.4': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.3.3 optional: true - '@img/sharp-linuxmusl-arm64@0.35.3': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 - optional: true - '@img/sharp-linuxmusl-arm64@0.35.4': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 optional: true - '@img/sharp-linuxmusl-x64@0.35.3': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.2 - optional: true - '@img/sharp-linuxmusl-x64@0.35.4': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.3.3 optional: true - '@img/sharp-wasm32@0.35.3': - dependencies: - '@emnapi/runtime': 1.11.3 - optional: true - '@img/sharp-wasm32@0.35.4': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.3': - dependencies: - '@img/sharp-wasm32': 0.35.3 - optional: true - '@img/sharp-webcontainers-wasm32@0.35.4': dependencies: '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-win32-arm64@0.35.3': - optional: true - '@img/sharp-win32-arm64@0.35.4': optional: true - '@img/sharp-win32-ia32@0.35.3': - optional: true - '@img/sharp-win32-ia32@0.35.4': optional: true - '@img/sharp-win32-x64@0.35.3': - optional: true - '@img/sharp-win32-x64@0.35.4': optional: true @@ -9741,7 +9470,7 @@ snapshots: camelcase: 5.3.1 find-up: 4.1.0 get-package-type: 0.1.0 - js-yaml: 4.3.1 + js-yaml: 4.3.2 resolve-from: 5.0.0 '@istanbuljs/schema@0.1.6': {} @@ -9995,7 +9724,7 @@ snapshots: dependencies: debug: 4.4.3(supports-color@10.2.2) isomorphic-fetch: 3.0.0 - js-yaml: 4.3.1 + js-yaml: 4.3.2 lighthouse: 12.6.1(supports-color@10.2.2) tree-kill: 1.2.2 transitivePeerDependencies: @@ -13675,7 +13404,7 @@ snapshots: jiti@2.7.0: {} - joi@18.2.3: + joi@18.2.5: dependencies: '@hapi/address': 5.1.1 '@hapi/formula': 3.0.2 @@ -13695,7 +13424,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.3.1: + js-yaml@4.3.2: dependencies: argparse: 2.0.1 @@ -15066,40 +14795,6 @@ snapshots: setprototypeof@1.2.0: {} - sharp@0.35.3(@types/node@25.9.2): - dependencies: - '@img/colour': 1.1.0 - detect-libc: 2.1.2 - semver: 7.8.5 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.3 - '@img/sharp-darwin-x64': 0.35.3 - '@img/sharp-freebsd-wasm32': 0.35.3 - '@img/sharp-libvips-darwin-arm64': 1.3.2 - '@img/sharp-libvips-darwin-x64': 1.3.2 - '@img/sharp-libvips-linux-arm': 1.3.2 - '@img/sharp-libvips-linux-arm64': 1.3.2 - '@img/sharp-libvips-linux-ppc64': 1.3.2 - '@img/sharp-libvips-linux-riscv64': 1.3.2 - '@img/sharp-libvips-linux-s390x': 1.3.2 - '@img/sharp-libvips-linux-x64': 1.3.2 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 - '@img/sharp-libvips-linuxmusl-x64': 1.3.2 - '@img/sharp-linux-arm': 0.35.3 - '@img/sharp-linux-arm64': 0.35.3 - '@img/sharp-linux-ppc64': 0.35.3 - '@img/sharp-linux-riscv64': 0.35.3 - '@img/sharp-linux-s390x': 0.35.3 - '@img/sharp-linux-x64': 0.35.3 - '@img/sharp-linuxmusl-arm64': 0.35.3 - '@img/sharp-linuxmusl-x64': 0.35.3 - '@img/sharp-webcontainers-wasm32': 0.35.3 - '@img/sharp-win32-arm64': 0.35.3 - '@img/sharp-win32-ia32': 0.35.3 - '@img/sharp-win32-x64': 0.35.3 - '@types/node': 25.9.2 - optional: true - sharp@0.35.4(@types/node@25.9.2): dependencies: '@img/colour': 1.1.0 @@ -15903,7 +15598,7 @@ snapshots: wait-on@7.2.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2): dependencies: axios: 1.19.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) - joi: 18.2.3 + joi: 18.2.5 lodash: 4.18.1 minimist: 1.2.8 rxjs: 7.8.2 @@ -15914,7 +15609,7 @@ snapshots: wait-on@9.1.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2): dependencies: axios: 1.19.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) - joi: 18.2.3 + joi: 18.2.5 lodash: 4.18.1 minimist: 1.2.8 rxjs: 7.8.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fb070dbb..3ddbe044 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -66,7 +66,7 @@ overrides: playwright: "1.61.0" playwright-core: "1.61.0" esbuild: ">=0.28.1" - joi: "^18.2.1" + joi: ">=18.2.5 <19" # GHSA-6w3j-5fw6-r9vr + GHSA-gg4h-3hg2-grpc; capped at major 18 (wait-on's own declared range) so a future major can't force-install typescript: "6.0.3" serialize-javascript: ">=7.0.3" chrome-launcher: "^1.2.1" @@ -87,13 +87,13 @@ overrides: dompurify: ">=3.4.13" "@babel/core": ">=7.29.6 <8" undici: ">=7.29.0 <8" - js-yaml: ">=4.3.1 <5" + js-yaml: ">=4.3.2 <5" # GHSA-2883-xcg3-v3hh (jest/babel-istanbul transitive, dev-only) nanoid@^3: "3.3.18" nanoid@^5: "5.1.16" # QNBS-v3 (2026-07-26): GHSA batch blocking the CI security gate — transitive-only dev paths. adm-zip: ">=0.6.0" # GHSA-xcpc-8h2w-3j85 — 4GB memory allocation via crafted ZIP (onnxruntime-node) postcss: ">=8.5.18" # GHSA — path traversal in previous source map (vite/storybook chain) - sharp: ">=0.35.0" # inherited libvips vulnerabilities (@huggingface/transformers, miniflare) + sharp: ">=0.35.4" # GHSA-rgj7-g3m4-5g8c + inherited libvips vulnerabilities (@huggingface/transformers, miniflare) body-parser: ">=1.20.6 <2" # GHSA-v422-hmwv-36x6 — DoS via empty urlencoded body (express via @lhci/cli, dev-only) peerDependencyRules: diff --git a/scripts/check-doc-metrics.d.mts b/scripts/check-doc-metrics.d.mts index 00eb384b..d446ca8c 100644 --- a/scripts/check-doc-metrics.d.mts +++ b/scripts/check-doc-metrics.d.mts @@ -36,3 +36,4 @@ export function scanLocalizedBundleBudgetTruth( export function getCanonicalProductionUrl(): string; export function scanForUrlDrift(content: string, filePath: string, canonicalUrl: string): string[]; export const VERCEL_URL_PATTERN: RegExp; +export function scanSecurityDocPrStatus(content: string, filePath: string): string[]; diff --git a/scripts/check-doc-metrics.mjs b/scripts/check-doc-metrics.mjs index 4f83985c..7bd12432 100644 --- a/scripts/check-doc-metrics.mjs +++ b/scripts/check-doc-metrics.mjs @@ -499,6 +499,214 @@ export function scanForDrift(content, filePath, { localeCount, keyCount, latestV return findings; } +// QNBS-v3: (audit F-1) reject a live/pending-remediation claim near a bare PR number in the two +// security-status docs unless that same PR number also carries its actual closed/merged state +// nearby — a stale "PR #356 is the active remediation" survived weeks after #356 closed because +// nothing checked it. Deliberately scoped to these two files, not repo-wide: a blanket rule would +// also reject the legitimate historical CHANGELOG entry, ADR narrative, and already-qualified +// ROADMAP citations of the same PR elsewhere in the repo. +const SECURITY_STATUS_DOCS = ['docs/SECURITY-THREAT-MODEL.md', 'docs/IDB-ENCRYPTION.md']; +// QNBS-v3 (coderabbit): tolerate an inline-code span around the digits ("PR `#356`"), not just a +// markdown-link bracket — both are real Markdown ways to format a PR reference. +const PR_REFERENCE = /\[?PR\s*`?#(\d+)`?\]?/gi; +// QNBS-v3 (codex): order-independent — catches "PR #N is the active remediation", "the active +// remediation is PR #N", "PR #N remains the active remediation", "pending PR #N", "pending on +// PR #N", "in progress on PR #N", etc. Proximity to a PR reference (not fixed word order) is what +// makes a phrase a live-status CLAIM rather than incidental prose. +const LIVE_STATUS_TRIGGER = /\bactive remediation\b|\bpending\b|\bin progress\b/gi; +const STATUS_QUALIFIER_WORD = 'closed|merged|superseded'; +const STATUS_QUALIFIER_RE = new RegExp(`\\b(?:${STATUS_QUALIFIER_WORD})\\b`, 'gi'); +// QNBS-v3 (codex): a qualifier word only proves a completed status when it isn't negated +// ("is not closed", "has not been closed", "is not yet closed") or prospective ("will be merged", +// "may be merged") — checked for PRESENCE anywhere in the short lookback immediately before the +// match, not requiring exact adjacency, so common compound/auxiliary forms are covered without +// attempting a full negation-scope parser (a known, bounded best-effort heuristic, matching this +// file's existing "crude but sufficient" sentence-split rationale). +const QUALIFIER_NEGATION_OR_FUTURE_WORDS = + /\b(?:not|never|isn't|won't|will|would|should|may|might|could|going to)\b/i; +// QNBS-v3 (codex): a live-status TRIGGER phrase negated or "no longer" true isn't a live claim at +// all ("is not the active remediation", "no longer the active remediation"). +const TRIGGER_NEGATION_WORDS = /\b(?:not|never|no longer|isn't)\b/i; +const NEGATION_LOOKBACK = 40; +const PROXIMITY_WINDOW = 60; +// QNBS-v3 (codex): an unordered/ordered Markdown list marker — same isolation reasoning as table +// rows below. +const LIST_ITEM_MARKER = /^(?:[-*+]|\d+\.)\s+/; + +// QNBS-v3 (CodeAnt): group physical lines into Markdown paragraphs (blank-line-delimited) before +// matching — a naive per-line split let a status claim split across a soft-wrapped line evade +// detection entirely. QNBS-v3 (codex): a Markdown table row or list item is its own logical unit +// even though consecutive rows/items have no blank line between them — joining them let a +// live-status claim in one row/item absorb an unrelated row/item's qualifier (or vice versa). +function splitIntoParagraphs(content) { + const paragraphs = []; + let buffer = []; + let startLine = 0; + const flush = () => { + if (buffer.length > 0) { + paragraphs.push({ text: buffer.join(' '), startLine: startLine + 1 }); + buffer = []; + } + }; + content.split('\n').forEach((line, i) => { + const trimmed = line.trim(); + if (trimmed === '') { + flush(); + } else if (trimmed.startsWith('|')) { + // QNBS-v3 (codex): a table row is always a single physical line in standard Markdown — it + // never wraps — so push it immediately as its own unit, unlike a list item below. + flush(); + paragraphs.push({ text: trimmed, startLine: i + 1 }); + } else if (LIST_ITEM_MARKER.test(trimmed)) { + // QNBS-v3 (coderabbit): a new list item starts a new unit, but its own text may still + // soft-wrap across the following physical line(s) — buffer it like prose (don't push + // immediately) and let the next marker/table-row/blank line flush it. + flush(); + startLine = i; + buffer.push(trimmed); + } else { + if (buffer.length === 0) startLine = i; + buffer.push(trimmed); + } + }); + flush(); + return paragraphs; +} + +// QNBS-v3 (codex): a semicolon does not end a sentence — splitting on it separated a claim from +// its own qualifying clause (e.g. "PR #N is the active remediation; it was later closed."). Only +// a period genuinely ends a sentence here; the character-proximity window below is what actually +// bounds how far a qualifier/trigger may be from a PR reference, not this split. +function splitIntoSentences(paragraph) { + return paragraph.split(/(?<=\.)\s+/); +} + +// QNBS-v3 (codex): a bracketed "#NNN" whose link target is a /pull/NNN URL (the same bare +// shorthand style already used for issue links like "[#358](.../issues/358)" in these exact two +// docs) is a real PR reference with no literal "PR" text — normalize it to include "PR" BEFORE +// the URL is stripped below, since PR_REFERENCE needs the URL gone but the "PR" word present. The +// \2 backreference ties the link text's number to the URL's /pull/ number so a mismatched pair +// (accidentally or adversarially) isn't misattributed. +function normalizePullShorthand(text) { + return text.replace(/\[(#(\d+))\]\((?:[^)]*\/pull\/\2)\)/gi, '[PR $1]'); +} + +// QNBS-v3 (codex): a markdown link's URL (github.com/.../pull/NNN) adds length between a PR +// reference and its surrounding wording without adding meaning — strip it before measuring +// proximity, so a long URL can't push a genuinely adjacent trigger/qualifier word out of window. +function stripLinkUrls(text) { + return normalizePullShorthand(text).replace(/\]\([^)]*\)/g, ']'); +} + +// QNBS-v3 (codex): associate a word occurrence with its NEAREST PR reference by character +// distance, not "any PR reference within a fixed window" — a raw-window check let a short, +// unrelated PR's qualifier suppress a different PR's live claim when both sat close together +// (e.g. "[PR #999] is the active remediation, unlike [PR #111], closed."). +function nearestPrNumber(position, prRefs) { + let best = null; + let bestDistance = Infinity; + for (const ref of prRefs) { + const distance = position < ref.index ? ref.index - position : Math.max(0, position - ref.end); + if (distance < bestDistance) { + bestDistance = distance; + best = ref.prNumber; + } + } + return bestDistance <= PROXIMITY_WINDOW ? best : null; +} + +function findPrReferences(sentence) { + return [...sentence.matchAll(PR_REFERENCE)].map((m) => ({ + index: m.index, + end: m.index + m[0].length, + prNumber: m[1], + })); +} + +// QNBS-v3 (coderabbit/codex): a negation word belonging to an EARLIER clause must not scope over +// this match ("... is not encrypted, so PR #N is the active remediation" — the "not" modifies +// "encrypted", not "active remediation"). Truncate the raw window at the last clause-separating +// comma/semicolon/colon before the match. +function lookback(sentence, matchIndex) { + const window = sentence.slice(Math.max(0, matchIndex - NEGATION_LOOKBACK), matchIndex); + const boundary = Math.max( + window.lastIndexOf(','), + window.lastIndexOf(';'), + window.lastIndexOf(':'), + ); + return boundary === -1 ? window : window.slice(boundary + 1); +} + +// QNBS-v3 (codex): "is not closed" / "will be merged" don't assert a completed status — only a +// qualifier that isn't negated or prospective actually proves the PR is done. +function isNegatedOrProspectiveQualifier(sentence, matchIndex) { + return QUALIFIER_NEGATION_OR_FUTURE_WORDS.test(lookback(sentence, matchIndex)); +} + +// QNBS-v3 (codex): "is not the active remediation" / "no longer the active remediation" don't +// assert live status at all — the trigger phrase itself is negated away. +function isNegatedTrigger(sentence, matchIndex) { + return TRIGGER_NEGATION_WORDS.test(lookback(sentence, matchIndex)); +} + +// QNBS-v3 (CodeScene): extracted so scanSecurityDocPrStatus itself stays a flat, shallow loop — +// each of these small helpers owns exactly one nested loop+conditional, not three stacked in one. +function collectQualifiedPrs(sentence, prRefs) { + const qualifiedPrs = new Set(); + for (const m of sentence.matchAll(STATUS_QUALIFIER_RE)) { + const nearest = nearestPrNumber(m.index, prRefs); + if (nearest !== null && !isNegatedOrProspectiveQualifier(sentence, m.index)) { + qualifiedPrs.add(nearest); + } + } + return qualifiedPrs; +} + +function collectUnqualifiedClaims(sentence, prRefs, qualifiedPrs) { + const claims = new Set(); + for (const m of sentence.matchAll(LIVE_STATUS_TRIGGER)) { + const nearest = nearestPrNumber(m.index, prRefs); + if (nearest !== null && !qualifiedPrs.has(nearest) && !isNegatedTrigger(sentence, m.index)) { + claims.add(nearest); + } + } + return claims; +} + +function findUnqualifiedClaimsInSentence(sentence) { + const prRefs = findPrReferences(sentence); + if (prRefs.length === 0) return []; + const qualifiedPrs = collectQualifiedPrs(sentence, prRefs); + return [...collectUnqualifiedClaims(sentence, prRefs, qualifiedPrs)]; +} + +// QNBS-v3 (codex): an HTML comment or fenced code block is never rendered prose — a literal +// example inside one isn't a live assertion about a real PR. Blank out matched spans (keep +// newlines) rather than remove lines, so line numbers stay stable for the findings below. +// Known, accepted limitation: a single-backtick inline-code SPAN is deliberately not stripped — +// PR_REFERENCE needs backtick tolerance for a real "PR `#356`" citation, and distinguishing that +// from a whole illustrative phrase wrapped in one backtick pair isn't attempted here. +function stripNonProseMarkdown(content) { + const blank = (match) => match.replace(/[^\n]/g, ' '); + return content.replace(//g, blank).replace(/```[\s\S]*?```/g, blank); +} + +export function scanSecurityDocPrStatus(content, filePath) { + const findings = []; + const prose = stripNonProseMarkdown(content); + for (const { text: paragraph, startLine } of splitIntoParagraphs(prose)) { + const compact = stripLinkUrls(paragraph); + for (const sentence of splitIntoSentences(compact)) { + for (const prNumber of findUnqualifiedClaimsInSentence(sentence)) { + findings.push( + `${filePath}:${startLine} — asserts a live/pending remediation status near PR #${prNumber} without stating that PR's actual closed/merged state: "${sentence.trim()}"`, + ); + } + } + } + return findings; +} + // QNBS-v3: exits 1 on any finding — unlike check-coverage-ratchet.mjs this gate is blocking, since a doc claiming a wrong locale/key/release count is actively misleading, not just an opportunity. // QNBS-v3 (F-10, CodeRabbit follow-up): locales/it/help.json IS included — it's exactly where the F-10 stale-URL drift happened; the in-app link reads the constant directly so it can't drift and isn't listed here. const URL_CHECK_FILES = ['README.md', 'CLAUDE.md', 'locales/it/help.json']; @@ -555,6 +763,21 @@ function main() { allFindings.push(...scanForUrlDrift(content, relPath, canonicalUrl)); } + for (const relPath of SECURITY_STATUS_DOCS) { + const abs = join(root, relPath); + let content; + try { + content = readFileSync(abs, 'utf8'); + } catch { + // QNBS-v3 (codex): these two files are this gate's required subjects — silently skipping a + // missing/unreadable one would make the live-status enforcement disappear exactly when its + // input is unavailable, the same failure mode as BUNDLE_BUDGET_DOCS below. + allFindings.push(`${relPath} — required security-status document is missing or unreadable`); + continue; + } + allFindings.push(...scanSecurityDocPrStatus(content, relPath)); + } + for (const relPath of BUNDLE_BUDGET_DOCS) { const abs = join(root, relPath); let content; diff --git a/src-tauri/osv-scanner.toml b/src-tauri/osv-scanner.toml index 5ed060f0..ca344f5f 100644 --- a/src-tauri/osv-scanner.toml +++ b/src-tauri/osv-scanner.toml @@ -1,6 +1,6 @@ # osv-scanner ignore list for src-tauri/Cargo.lock + pnpm-lock.yaml # All entries have been evaluated and accepted by the maintainer. -# Reviewed: 2026-05-17 (Rust) / 2026-06-16 (npm) +# Reviewed: 2026-05-17 (Rust) / 2026-09-08 (npm) # ─── GTK3 Rust bindings (Linux WebKit stack) ──────────────────────────────── # webkit2gtk 2.0.2 (required by wry 0.55.x / Tauri 2.x for Linux) depends on @@ -117,24 +117,66 @@ ignoreUntil = "2026-11-30T00:00:00Z" reason = "unic-ucd-ident 0.9.0: unmaintained Unicode data crate; final version; no exploitable attack surface" # ─── npm advisories (pnpm-lock.yaml) ───────────────────────────────────────── -# Reviewed 2026-06-16 / 2026-07-26 / 2026-08-12. Most currently-flagged npm -# advisories are remediated via pnpm-workspace.yaml overrides (tmp, ws, -# form-data, protobufjs, @babel/core, dompurify→3.4.12, js-yaml→4.x, -# body-parser→1.20.6, …). (The former dompurify GHSA-x4vx-rjvf-j5p4 and -# js-yaml-3.x GHSA-h67p-54hq-rp68 ignores were dropped: both advisories no -# longer match the lockfile after the overrides.) One documented ignore -# remains below — no fixed version exists to override to. +# Reviewed 2026-06-16 / 2026-07-26 / 2026-08-12 / 2026-09-08. Most +# currently-flagged npm advisories are remediated via pnpm-workspace.yaml +# overrides (tmp, ws, form-data, protobufjs, @babel/core, dompurify→3.4.13, +# js-yaml→4.3.2, body-parser→1.20.6, joi→18.2.5, sharp→0.35.4, …). (The former +# dompurify GHSA-x4vx-rjvf-j5p4 and js-yaml-3.x GHSA-h67p-54hq-rp68 ignores +# were dropped: both advisories no longer match the lockfile after the +# overrides.) Three documented ignores remain below — no fixed version exists +# to override to for any of them. # extract-zip@2.0.1 (CVE-2026-56876 / GHSA-jmr9-qjv8-65gv, CVSS 8.6): unvalidated # symlink path traversal when extracting an attacker-controlled zip archive. # extract-zip@2.0.1 is the latest published release — no patched version exists # (first_patched_version: null on the advisory) — so pnpm.overrides cannot fix -# this. It's a transitive devDependency of @puppeteer/browsers (Playwright's -# browser-binary downloader): only ever extracts Playwright/Chromium's own -# CDN-hosted zip releases, never a user- or attacker-supplied archive, and never -# ships in the production app bundle. No exploitable attack surface here. +# this. QNBS-v3 (codex, corrected): the actual lock-graph path (verified via +# `pnpm audit`) is `@lhci/cli` -> `lighthouse` -> `puppeteer-core` -> +# `@puppeteer/browsers` -> `extract-zip` — Lighthouse CI's own headless-Chrome +# automation stack (which itself depends on Puppeteer internally), NOT this +# repo's separate `playwright`/`playwright-core` devDependency despite the +# similarly-named `@puppeteer/browsers` package. It only extracts +# Puppeteer/Lighthouse's own CDN-hosted Chrome binary releases, never a user- +# or attacker-supplied archive, and never ships in the production app bundle. +# No exploitable attack surface here. [[IgnoredVulns]] id = "GHSA-jmr9-qjv8-65gv" ignoreUntil = "2026-11-30T00:00:00Z" -reason = "extract-zip 2.0.1: unvalidated symlink path traversal; latest release, no fixed version exists; transitive Playwright/@puppeteer/browsers devDependency only extracting Playwright's own CDN-hosted browser binaries, never user-supplied zips; no runtime footprint in the shipped app" +reason = "extract-zip 2.0.1: unvalidated symlink path traversal; latest release, no fixed version exists; transitive @lhci/cli->lighthouse->puppeteer-core->@puppeteer/browsers devDependency (Lighthouse CI's own Puppeteer stack, not Playwright) only extracting its own CDN-hosted Chrome binaries, never user-supplied zips; no runtime footprint in the shipped app" + +# extract-zip@2.0.1 (GHSA-7pqw-9j4j-h8q3, CVSS 8.1): a second, distinct advisory +# for the same package/version (CWE-22 path traversal via two archive entries +# with identical names — a symlink followed by a regular file — bypassing the +# containment check). Not an alias of GHSA-jmr9-qjv8-65gv above; OSV reports it +# separately. Same "no patched version exists" and same non-exposure reasoning +# applies unchanged: the same @lhci/cli->lighthouse->puppeteer-core-> +# @puppeteer/browsers devDependency chain above, only ever extracting +# Puppeteer/Lighthouse's own CDN-hosted Chrome binaries, never ships in the app. + +[[IgnoredVulns]] +id = "GHSA-7pqw-9j4j-h8q3" +ignoreUntil = "2026-11-30T00:00:00Z" +reason = "extract-zip 2.0.1: second, distinct symlink path-traversal advisory (two-entry name-collision technique); latest release, no fixed version exists; same transitive @lhci/cli->lighthouse->puppeteer-core->@puppeteer/browsers devDependency (Lighthouse CI's own Puppeteer stack, not Playwright), never user-supplied zips, no runtime footprint in the shipped app" + +# adm-zip@0.6.0 (CVE-2026-76845 / GHSA-vwc7-r8mq-g2x9, CVSS 6.8): symlink-following +# path traversal on extraction with overwrite enabled, when an attacker can +# pre-place a symlink at the extraction destination. 0.6.0 is the latest +# published release with no patched version yet — pnpm.overrides already pins +# the newest available release (see pnpm-workspace.yaml) but cannot fix an +# unpatched vulnerability. adm-zip is a dependency of onnxruntime-node's own +# postinstall/build script (which extracts its prebuilt native binary into +# `path.join(os.tmpdir(), 'onnxruntime-node-pkgs_')` — a shared, +# timestamp-named system temp directory, NOT a project-local one — verified +# directly against onnxruntime-node@1.24.3's own install-utils.js). The actual +# mitigation is that `pnpm-workspace.yaml`'s `allowBuilds: { onnxruntime-node: +# false }` denies this package's lifecycle scripts outright, so this code path +# never executes during a normal `pnpm install` in this repo, in CI, or in the +# shipped app. If that `allowBuilds` entry is ever relaxed or removed, this +# exposure model must be re-evaluated — the temp-directory extraction target +# is not inherently safe on its own. + +[[IgnoredVulns]] +id = "GHSA-vwc7-r8mq-g2x9" +ignoreUntil = "2026-11-30T00:00:00Z" +reason = "adm-zip 0.6.0: symlink-following path traversal on overwrite-enabled extraction; latest release, no fixed version exists; mitigated by pnpm-workspace.yaml's allowBuilds: onnxruntime-node=false denying its postinstall/build script outright (not by the extraction path itself, which uses a shared system temp directory) — re-evaluate if that allowBuilds entry ever changes" diff --git a/tests/unit/checkDocMetrics.test.ts b/tests/unit/checkDocMetrics.test.ts index 3fcee4d6..0984a7fe 100644 --- a/tests/unit/checkDocMetrics.test.ts +++ b/tests/unit/checkDocMetrics.test.ts @@ -16,6 +16,7 @@ import { scanLocalizedBundleBudgetTruth, scanReadmeTestMetrics, scanReleaseTruth, + scanSecurityDocPrStatus, stripHistoricalSections, VERCEL_URL_PATTERN, } from '../../scripts/check-doc-metrics.mjs'; @@ -554,3 +555,252 @@ describe('scanForUrlDrift', () => { }); }); }); + +// QNBS-v3 (audit F-1): a security doc asserting a PR is "the active remediation" or work is +// "pending"/"in progress on" that PR must say so truthfully — this gate exists because +// docs/SECURITY-THREAT-MODEL.md said exactly that about PR #356 for weeks after it closed. +describe('scanSecurityDocPrStatus', () => { + it('flags an unqualified "is the active remediation" claim', () => { + const content = + '| Threat | [PR #356](https://github.com/qnbs/WorldScript-Studio/pull/356) is the active remediation | Loc |'; + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(1); + expect(findings[0]).toContain('docs/SECURITY-THREAT-MODEL.md:1'); + }); + + it('flags an unqualified "pending [PR #NNN]" claim', () => { + const content = + "remain only as shared crypto plumbing pending [PR #356](https://github.com/qnbs/WorldScript-Studio/pull/356)'s project-data encryption work."; + const findings = scanSecurityDocPrStatus(content, 'docs/IDB-ENCRYPTION.md'); + expect(findings).toHaveLength(1); + }); + + it('flags an unqualified "in progress on PR #NNN" claim', () => { + const content = 'Encryption work is in progress on PR #356.'; + const findings = scanSecurityDocPrStatus(content, 'docs/IDB-ENCRYPTION.md'); + expect(findings).toHaveLength(1); + }); + + it('does not flag the same claim once qualified as closed/superseded on the same line', () => { + const content = + 'PR #356 is the active remediation for the prior (now inaccurate) history — PR #356 was later closed as superseded by R-15.'; + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(0); + }); + + it('does not flag a bare historical PR citation with no live-status verb', () => { + const content = + 'it remains in fsCore.ts as shared crypto plumbing for other filesystem-encrypted data (see [PR #356](https://github.com/qnbs/WorldScript-Studio/pull/356), closed 2026-08-18 as superseded).'; + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(0); + }); + + it('does not flag "pending" prose with no PR-number anchor', () => { + const content = + 'Full at-rest protection for the desktop filesystem store is pending R-15 implementation.'; + const findings = scanSecurityDocPrStatus(content, 'docs/IDB-ENCRYPTION.md'); + expect(findings).toHaveLength(0); + }); + + // QNBS-v3 (CodeAnt): a naive per-physical-line split let a status claim soft-wrapped across two + // Markdown lines within the same paragraph evade detection entirely. + it('flags a claim even when Markdown wraps it across two physical lines of one paragraph', () => { + const content = [ + 'Desktop plaintext persistence remains open. [PR #356](https://github.com/qnbs/pull/356)', + 'is the active remediation for this gap.', + ].join('\n'); + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(1); + expect(findings[0]).toContain('PR #356'); + }); + + // QNBS-v3 (CodeAnt): the qualifier must be tied to the SAME PR number, not merely present + // anywhere in the sentence/line — otherwise a different, already-closed PR mentioned nearby + // would wrongly suppress a live claim about an unrelated, still-unqualified PR. + it('still flags an unqualified claim when a DIFFERENT PR is closed nearby', () => { + const content = + 'PR #999 is the active remediation for this gap, unlike PR #111 which was already closed.'; + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(1); + expect(findings[0]).toContain('PR #999'); + }); + + // QNBS-v3 (codex): the original three-alternative regex required an exact word order and missed + // common natural phrasings — the fix is order-independent (trigger phrase near a PR reference). + it.each([ + 'The active remediation is PR #356 for this gap.', + 'PR #356 remains the active remediation for this gap.', + 'Work is pending on PR #356 for this gap.', + ])('flags the natural-language variant: %s', (content) => { + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(1); + expect(findings[0]).toContain('PR #356'); + }); + + // QNBS-v3 (codex): consecutive Markdown table rows have no blank line between them — joining + // them into one paragraph let a live claim in one row absorb an unrelated row's qualifier. + it("does not let one table row's qualifier suppress a different row's unqualified claim", () => { + const content = ['| PR #356 was closed |', '| PR #999 is the active remediation |'].join('\n'); + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(1); + expect(findings[0]).toContain('PR #999'); + }); + + // QNBS-v3 (codex): a long markdown-link URL between the PR reference and its qualifier must not + // make a genuinely, explicitly qualified claim look unqualified. + it('does not flag a claim qualified via a Markdown link with a long URL', () => { + const content = + '[PR #356](https://github.com/qnbs/WorldScript-Studio/pull/356) is the active remediation, but was closed.'; + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(0); + }); + + // QNBS-v3 (codex): a raw character-window qualifier check let a SHORT, unrelated PR's qualifier + // suppress a different PR's unqualified claim when both PRs sat close together. Nearest-PR + // association (not "any qualifier within the window") is required. + it('still flags an unqualified claim when a nearby DIFFERENT PR is closed right next to it', () => { + const content = '[PR #999] is the active remediation, unlike [PR #111], closed.'; + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(1); + expect(findings[0]).toContain('PR #999'); + }); + + // QNBS-v3 (codex): consecutive Markdown list items have no blank line between them either — + // joining them let an unrelated later item's trigger word attach to an earlier item's PR. + it("does not let one list item's wording attach to a different item's PR reference", () => { + const content = ['- Historical context: PR #356', '- R-15 implementation is pending'].join( + '\n', + ); + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(0); + }); + + // QNBS-v3 (codex): a semicolon does not end a sentence — splitting on it separated a claim from + // its own qualifying clause. + it('does not flag a claim whose qualifier follows a semicolon in the same sentence', () => { + const content = 'PR #356 is the active remediation; it was later closed as superseded.'; + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(0); + }); + + // QNBS-v3 (coderabbit/codex): a list item's own text may soft-wrap across the following + // physical line — an immediate push-per-marker-line split it before the trigger was reached. + it('flags a live-status claim wrapped across a list item', () => { + const content = ['- PR `#356` is', ' the active remediation for this gap'].join('\n'); + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(1); + }); + + it('flags a Markdown-link PR reference wrapped across a list item', () => { + const content = [ + '- [PR #999](https://github.com/qnbs/pull/999)', + 'is the active remediation', + ].join('\n'); + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(1); + }); + + // QNBS-v3 (codex): "is not closed" / "will be merged" don't assert a completed status — only an + // unnegated, non-prospective qualifier actually proves the PR is done. + it('still flags a claim whose only nearby qualifier is negated', () => { + const content = 'PR #999 is the active remediation; it is not closed.'; + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(1); + }); + + it('still flags a claim whose only nearby qualifier is prospective (future tense)', () => { + const content = 'PR #999 is the active remediation and will be merged soon.'; + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(1); + }); + + it('does not flag a claim with a genuinely completed (non-negated) qualifier', () => { + const content = 'PR #999 is the active remediation; it was later closed.'; + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(0); + }); + + // QNBS-v3 (codex): a negated or "no longer" trigger phrase explicitly denies live status — it + // isn't a claim at all. + it.each([ + 'PR #356 is not the active remediation for this gap.', + 'PR #356 is no longer the active remediation for this gap.', + 'Work is not in progress on PR #356 for this gap.', + ])('does not flag a negated trigger: %s', (content) => { + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(0); + }); + + // QNBS-v3 (codex): compound negation/auxiliary forms around a qualifier must not be accepted as + // proof of a completed status. + it.each([ + 'PR #999 is the active remediation; it has not been closed.', + 'PR #999 is the active remediation; it is not yet closed.', + 'PR #999 is the active remediation and may be merged eventually.', + ])('still flags a claim with compound-negated/prospective qualifier: %s', (content) => { + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(1); + }); + + // QNBS-v3 (codex): an HTML comment or fenced code block is never rendered prose — a literal + // example inside one isn't a live assertion about a real PR. + it('does not flag trigger wording inside an HTML comment', () => { + const content = '\nReal prose here.'; + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(0); + }); + + it('does not flag trigger wording inside a fenced code block', () => { + const content = ['```', 'PR #999 is the active remediation', '```'].join('\n'); + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(0); + }); + + it('still flags real prose surrounding a stripped HTML comment', () => { + const content = [ + '', + 'PR #999 is the active remediation for this gap.', + ].join('\n'); + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(1); + }); + + // QNBS-v3 (coderabbit/codex): a negation word in an EARLIER clause must not suppress a + // genuinely live, unqualified claim in a later clause of the same sentence — a real + // false-negative that let a stale claim escape the gate entirely. + it('flags a claim when negation belongs to an earlier, unrelated clause', () => { + const content = 'Desktop project data is not encrypted, so PR #356 is the active remediation.'; + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(1); + }); + + it('flags a claim when negation modifies a different word in an earlier clause', () => { + const content = 'PR #356 is not complete, but remains the active remediation.'; + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(1); + }); + + // QNBS-v3 (codex): the bare "[#NNN](.../pull/NNN)" shorthand is the same convention already + // used for issue links (e.g. "[#358](.../issues/358)") in these exact two docs — a stale claim + // shouldn't evade the gate merely by using this link style instead of writing "PR #NNN". + it('flags a live-status claim using the bare "[#N](.../pull/N)" shorthand', () => { + const content = + '[#356](https://github.com/qnbs/WorldScript-Studio/pull/356) is the active remediation.'; + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(1); + }); + + it('does not treat a bare "[#N](.../issues/N)" shorthand as a PR reference', () => { + const content = + 'Closing [#358](https://github.com/qnbs/WorldScript-Studio/issues/358) is the active remediation.'; + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(0); + }); + + it('does not misattribute a mismatched "[#N](.../pull/M)" shorthand pair', () => { + const content = + '[#356](https://github.com/qnbs/WorldScript-Studio/pull/999) is the active remediation.'; + const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md'); + expect(findings).toHaveLength(0); + }); +});