From 184083222ea72caaad7a7243954632b0749ddada Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:45:05 +0200 Subject: [PATCH 01/11] fix(docs): stop citing closed PR #356 as active desktop-encryption remediation docs/SECURITY-THREAT-MODEL.md and docs/IDB-ENCRYPTION.md both asserted PR #356 was the active/pending remediation for desktop plaintext storage; it was closed as superseded on 2026-08-18. Rewrote both to anchor on the living Ledger-row-9/R-15 state instead of a PR number, and added a check-doc-metrics.mjs rule (scanSecurityDocPrStatus) that rejects an unqualified live/pending-remediation claim tied to a bare PR number in these two files, so this can't silently go stale again. --- README.md | 8 ++--- docs/IDB-ENCRYPTION.md | 2 +- docs/SECURITY-THREAT-MODEL.md | 4 +-- scripts/check-doc-metrics.d.mts | 1 + scripts/check-doc-metrics.mjs | 35 ++++++++++++++++++++++ tests/unit/checkDocMetrics.test.ts | 48 ++++++++++++++++++++++++++++++ 6 files changed, 91 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 409663fe7..a7098ca3f 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 + 7573+ 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 (7573+ 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 (7573+ 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 +- **7573+ 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/IDB-ENCRYPTION.md b/docs/IDB-ENCRYPTION.md index ebc4232b4..e5e01d578 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 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 R-15's project-data encryption work (`docs/native/CORE-MIGRATION-LEDGER.md` row 10, gated behind row 9's `S5_IMPLEMENTATION_READY=NO`). 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 3eb882042..b09bff08f 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, gated behind Ledger row 9's `S5_IMPLEMENTATION_READY=NO`; 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/scripts/check-doc-metrics.d.mts b/scripts/check-doc-metrics.d.mts index 00eb384b7..d446ca8c5 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 4f83985c5..ca2f3c071 100644 --- a/scripts/check-doc-metrics.mjs +++ b/scripts/check-doc-metrics.mjs @@ -499,6 +499,30 @@ export function scanForDrift(content, filePath, { localeCount, keyCount, latestV return findings; } +// QNBS-v3: (audit F-1) reject a live/pending-remediation claim tied to a bare PR number in the two +// security-status docs unless the same line also states that PR's actual closed/merged state — 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']; +const LIVE_STATUS_CLAIM = + /is the active remediation|\bpending\s+\[?PR\s*#\d+|\bin progress on\s+\[?PR\s*#\d+/i; +const STATUS_QUALIFIER = /\b(?:closed|merged|superseded)\b/i; + +export function scanSecurityDocPrStatus(content, filePath) { + const findings = []; + const lines = content.split('\n'); + lines.forEach((line, i) => { + if (LIVE_STATUS_CLAIM.test(line) && !STATUS_QUALIFIER.test(line)) { + findings.push( + `${filePath}:${i + 1} — asserts a live/pending remediation status tied to a PR number without stating that PR's actual closed/merged state: "${line.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 +579,17 @@ 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 { + continue; + } + allFindings.push(...scanSecurityDocPrStatus(content, relPath)); + } + for (const relPath of BUNDLE_BUDGET_DOCS) { const abs = join(root, relPath); let content; diff --git a/tests/unit/checkDocMetrics.test.ts b/tests/unit/checkDocMetrics.test.ts index 3fcee4d61..da9a0196e 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,50 @@ 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); + }); +}); From 2e4467d580c7aeb4aaf44c2b7a0745b1a1043dcd Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:25:47 +0200 Subject: [PATCH 02/11] fix(docs): address review findings on PR #1's guard and doc correction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/IDB-ENCRYPTION.md: the API-key-reset notification description was backwards — it fires when the initial removal attempt fails but the catch block's own follow-up removal then succeeds, not "on removal failure"; and is suppressed (not fired) when that follow-up also fails. Corrected to match services/fs/settingsFsStore.ts#getApiKey. - scripts/check-doc-metrics.mjs: scanSecurityDocPrStatus now groups physical lines into Markdown paragraphs before matching, so a status claim soft-wrapped across two lines can no longer evade detection. The qualifier check is now scoped to the same sentence and the same captured PR number, so an unrelated PR's closed/merged/superseded status can no longer suppress a different, still-unqualified PR's live-status claim. - 2 new regression tests proving both fixes; README test-count resynced. --- README.md | 8 +-- docs/IDB-ENCRYPTION.md | 2 +- scripts/check-doc-metrics.mjs | 86 ++++++++++++++++++++++++------ tests/unit/checkDocMetrics.test.ts | 23 ++++++++ 4 files changed, 99 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index a7098ca3f..0136d0264 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2942 keys - 7573+ tests / 603 files + 7575+ 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 (7573+ tests / 603 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7575+ 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 (7573+ tests, 603 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7575+ 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):** -- **7573+ unit tests** across **603 test files** — CI is authoritative for pass/fail +- **7575+ 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/IDB-ENCRYPTION.md b/docs/IDB-ENCRYPTION.md index e5e01d578..8a8bc1f31 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 R-15's project-data encryption work (`docs/native/CORE-MIGRATION-LEDGER.md` row 10, gated behind row 9's `S5_IMPLEMENTATION_READY=NO`). 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 the initial removal attempt throws (a permissions/IO error) **and** the catch block's own follow-up removal then succeeds; if that follow-up removal also fails, the notification is 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, gated behind row 9's `S5_IMPLEMENTATION_READY=NO`). 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/scripts/check-doc-metrics.mjs b/scripts/check-doc-metrics.mjs index ca2f3c071..19f409fcc 100644 --- a/scripts/check-doc-metrics.mjs +++ b/scripts/check-doc-metrics.mjs @@ -500,26 +500,82 @@ export function scanForDrift(content, filePath, { localeCount, keyCount, latestV } // QNBS-v3: (audit F-1) reject a live/pending-remediation claim tied to a bare PR number in the two -// security-status docs unless the same line also states that PR's actual closed/merged state — 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. +// 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']; -const LIVE_STATUS_CLAIM = - /is the active remediation|\bpending\s+\[?PR\s*#\d+|\bin progress on\s+\[?PR\s*#\d+/i; -const STATUS_QUALIFIER = /\b(?:closed|merged|superseded)\b/i; +// QNBS-v3 (CodeAnt): the PR number is captured INSIDE each alternative so the qualifier check +// below can require proximity to that specific PR, not "any qualifier anywhere in the text" — +// otherwise a closed/merged mention of a DIFFERENT PR would wrongly suppress this one's finding. +const LIVE_STATUS_CLAIM_WITH_PR = new RegExp( + [ + // QNBS-v3: the gap allows any character (a markdown link's URL contains periods, e.g. + // "github.com") — matching already runs per-sentence, so a real sentence boundary was + // already cut before this regex ever sees the text. + String.raw`\[?PR\s*#(?\d+)\]?[\s\S]{0,120}?\bis the active remediation`, + String.raw`\bpending\s+\[?PR\s*#(?\d+)\]?`, + String.raw`\bin progress on\s+\[?PR\s*#(?\d+)\]?`, + ].join('|'), + 'gi', +); +const STATUS_QUALIFIER_WORD = 'closed|merged|superseded'; + +// 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, since neither half alone matched the full pattern. +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) => { + if (line.trim() === '') { + flush(); + } else { + if (buffer.length === 0) startLine = i; + buffer.push(line.trim()); + } + }); + flush(); + return paragraphs; +} + +// QNBS-v3: crude but sufficient sentence split for this narrow, two-file gate — doesn't need to +// handle abbreviations/decimals correctly, only to stop a qualifier for one PR bleeding across an +// unrelated sentence into another PR's claim. +function splitIntoSentences(paragraph) { + return paragraph.split(/(?<=[.;])\s+/); +} export function scanSecurityDocPrStatus(content, filePath) { const findings = []; - const lines = content.split('\n'); - lines.forEach((line, i) => { - if (LIVE_STATUS_CLAIM.test(line) && !STATUS_QUALIFIER.test(line)) { - findings.push( - `${filePath}:${i + 1} — asserts a live/pending remediation status tied to a PR number without stating that PR's actual closed/merged state: "${line.trim()}"`, - ); + for (const { text: paragraph, startLine } of splitIntoParagraphs(content)) { + for (const sentence of splitIntoSentences(paragraph)) { + for (const match of sentence.matchAll(LIVE_STATUS_CLAIM_WITH_PR)) { + const prNumber = match.groups?.prA ?? match.groups?.prB ?? match.groups?.prC; + // QNBS-v3 (CodeAnt): the qualifier must sit near THIS PR's own number in the same + // sentence, not merely appear somewhere in it — otherwise a different, already-qualified + // PR mentioned nearby would incorrectly suppress this one's live-status finding. + const qualifiedForThisPr = new RegExp( + `#${prNumber}\\b[\\s\\S]{0,60}\\b(?:${STATUS_QUALIFIER_WORD})\\b|` + + `\\b(?:${STATUS_QUALIFIER_WORD})\\b[\\s\\S]{0,60}#${prNumber}\\b`, + 'i', + ).test(sentence); + if (!qualifiedForThisPr) { + findings.push( + `${filePath}:${startLine} — asserts a live/pending remediation status tied to PR #${prNumber} without stating that PR's actual closed/merged state: "${sentence.trim()}"`, + ); + } + } } - }); + } return findings; } diff --git a/tests/unit/checkDocMetrics.test.ts b/tests/unit/checkDocMetrics.test.ts index da9a0196e..168593d92 100644 --- a/tests/unit/checkDocMetrics.test.ts +++ b/tests/unit/checkDocMetrics.test.ts @@ -601,4 +601,27 @@ describe('scanSecurityDocPrStatus', () => { 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'); + }); }); From 3b7c036124e9d007c1ee6c5f5e14786a19990d9b Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:32:27 +0200 Subject: [PATCH 03/11] fix(docs): attribute S5_IMPLEMENTATION_READY to ledger row 10, not row 9 Both rewritten security-doc references said the readiness flag lived on Ledger row 9 (the project state-shape compatibility adapter). It's actually recorded in row 10's own cell (R-15 itself); row 9 is a separate prerequisite that additionally gates row 10's implementation. Corrected both docs to attribute the flag to its actual row while still noting row 9's gating relationship. --- docs/IDB-ENCRYPTION.md | 2 +- docs/SECURITY-THREAT-MODEL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/IDB-ENCRYPTION.md b/docs/IDB-ENCRYPTION.md index 8a8bc1f31..a19b227eb 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 when the initial removal attempt throws (a permissions/IO error) **and** the catch block's own follow-up removal then succeeds; if that follow-up removal also fails, the notification is 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, gated behind row 9's `S5_IMPLEMENTATION_READY=NO`). 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 the initial removal attempt throws (a permissions/IO error) **and** the catch block's own follow-up removal then succeeds; if that follow-up removal also fails, the notification is 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, which itself records `S5_IMPLEMENTATION_READY=NO` and is 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 b09bff08f..2572e8b97 100644 --- a/docs/SECURITY-THREAT-MODEL.md +++ b/docs/SECURITY-THREAT-MODEL.md @@ -41,7 +41,7 @@ This document provides a formal STRIDE threat analysis for WorldScript Studio, m |--------|------------|-------------| | 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), 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, gated behind Ledger row 9's `S5_IMPLEMENTATION_READY=NO`; do not broaden this claim until implementation actually starts. | `services/fs/*Store.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 — row 10 itself records `S5_IMPLEMENTATION_READY=NO`, and implementation additionally stays gated behind row 9 (the project state-shape compatibility adapter) converging first; 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` | From 53f621c751447ba48f3d5c1bd86cfc20feafdda4 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:51:07 +0200 Subject: [PATCH 04/11] fix(deps): resolve new lockfile security findings from OSV scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real dependency-path and exposure analysis per finding, not a blanket ignore: - joi 18.2.3 -> 18.2.5 (GHSA-6w3j-5fw6-r9vr, GHSA-gg4h-3hg2-grpc): both fixed upstream, floor bumped via pnpm.overrides. Transitive dev-only (wait-on/@storybook/test-runner). - js-yaml 4.3.1 -> 4.3.2 (GHSA-2883-xcg3-v3hh): fixed upstream, floor bumped. Transitive dev-only (jest/babel-istanbul/@lhci tooling). - sharp 0.35.3 -> 0.35.4 (GHSA-rgj7-g3m4-5g8c): fixed upstream, floor bumped so the @huggingface/transformers optionalDependency chain now dedupes to the same patched version already used by the wrangler/miniflare chain. - adm-zip 0.6.0 (GHSA-vwc7-r8mq-g2x9) and a second, distinct extract-zip 2.0.1 advisory (GHSA-7pqw-9j4j-h8q3): neither has a published fix. Verified real exposure for both — adm-zip is invoked only by onnxruntime-node's own postinstall to unpack its own npm-sourced binary into its own node_modules directory at install time (no shared/predictable extraction path, no attacker-supplied archive); extract-zip is the same already-documented transitive Playwright/@puppeteer/browsers devDependency extracting only Playwright's own CDN-hosted binaries. Added two new documented ignore entries to src-tauri/osv-scanner.toml matching the existing, previously-accepted pattern for this exact situation. --- pnpm-lock.yaml | 333 ++----------------------------------- pnpm-workspace.yaml | 6 +- src-tauri/osv-scanner.toml | 49 +++++- 3 files changed, 58 insertions(+), 330 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e14e25ca8..34c30f96f 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' 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 fb070dbba..57ff0feff 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" # GHSA-6w3j-5fw6-r9vr + GHSA-gg4h-3hg2-grpc (wait-on transitive, dev-only) 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/src-tauri/osv-scanner.toml b/src-tauri/osv-scanner.toml index 5ed060f02..908590e63 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,13 +117,14 @@ 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. @@ -138,3 +139,35 @@ reason = "unic-ucd-ident 0.9.0: unmaintained Unicode data crate; final version; 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" + +# 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: transitive @puppeteer/browsers devDependency, only ever +# extracts Playwright/Chromium's own CDN-hosted archives, 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 Playwright/@puppeteer/browsers devDependency, 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 here is used only by onnxruntime-node's own +# postinstall to unpack its bundled native-binary release into its own +# node_modules directory at `pnpm install` time — a location this project's +# local/CI install process controls exclusively, extracting content sourced +# from the npm registry/ONNX Runtime project's own release, never a +# user-supplied or externally-influenced archive. No shared/predictable +# extraction directory exists for an attacker to pre-place a symlink in, and +# the affected code path never runs as part of the shipped web or desktop app. + +[[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; used only by onnxruntime-node's own postinstall to unpack its own trusted npm-sourced binary at install time, never a user-supplied archive, no runtime footprint in the shipped app" From 39a19bbde1890d09298be2d32e18d5e183758eb6 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:39:41 +0200 Subject: [PATCH 05/11] fix(docs): address codex-connector's second review wave on PR #1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scanSecurityDocPrStatus rewritten to be order-independent (trigger phrase near a PR reference, not three fixed word-order alternatives) so "the active remediation is PR #N", "PR #N remains the active remediation", and "pending on PR #N" are all caught. - Markdown table rows are now isolated as their own unit before matching (they have no blank line between them, so joining them let a live claim in one row absorb an unrelated row's qualifier). - Markdown link URLs are stripped before measuring qualifier proximity so a long github.com/.../pull/NNN URL can't make a genuinely, explicitly qualified claim look unqualified. - Both security docs no longer restate the ledger's literal S5_IMPLEMENTATION_READY value — they point at the ledger instead, so they can't independently go stale when that flag changes. - IDB-ENCRYPTION.md's API-key-reset notification description now covers the full try-block scope (existence check and removal, not just "the initial removal attempt") per settings FsStore#getApiKey. - A missing/unreadable security-status doc is now itself a finding, matching the existing BUNDLE_BUDGET_DOCS pattern, instead of being silently skipped. - joi's pnpm.overrides floor gained an explicit <19 ceiling so a future major can't force-install past wait-on's declared range. - 5 new regression tests; README test-count resynced. --- README.md | 8 +-- docs/IDB-ENCRYPTION.md | 2 +- docs/SECURITY-THREAT-MODEL.md | 2 +- pnpm-lock.yaml | 2 +- pnpm-workspace.yaml | 2 +- scripts/check-doc-metrics.mjs | 81 +++++++++++++++++++----------- tests/unit/checkDocMetrics.test.ts | 30 +++++++++++ 7 files changed, 89 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 0136d0264..add715aee 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2942 keys - 7575+ tests / 603 files + 7577+ 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 (7575+ tests / 603 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7577+ 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 (7575+ tests, 603 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7577+ 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):** -- **7575+ unit tests** across **603 test files** — CI is authoritative for pass/fail +- **7577+ 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/IDB-ENCRYPTION.md b/docs/IDB-ENCRYPTION.md index a19b227eb..bb0e8ce1d 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 when the initial removal attempt throws (a permissions/IO error) **and** the catch block's own follow-up removal then succeeds; if that follow-up removal also fails, the notification is 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, which itself records `S5_IMPLEMENTATION_READY=NO` and is 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`. +**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 2572e8b97..778527371 100644 --- a/docs/SECURITY-THREAT-MODEL.md +++ b/docs/SECURITY-THREAT-MODEL.md @@ -41,7 +41,7 @@ This document provides a formal STRIDE threat analysis for WorldScript Studio, m |--------|------------|-------------| | 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), 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 — row 10 itself records `S5_IMPLEMENTATION_READY=NO`, and implementation additionally stays gated behind row 9 (the project state-shape compatibility adapter) converging first; do not broaden this claim until implementation actually starts. | `services/fs/*Store.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 34c30f96f..226c4f915 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.5' + joi: '>=18.2.5 <19' typescript: 6.0.3 serialize-javascript: '>=7.0.3' chrome-launcher: ^1.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 57ff0feff..3ddbe044e 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.5" # GHSA-6w3j-5fw6-r9vr + GHSA-gg4h-3hg2-grpc (wait-on transitive, dev-only) + 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" diff --git a/scripts/check-doc-metrics.mjs b/scripts/check-doc-metrics.mjs index 19f409fcc..c22247bda 100644 --- a/scripts/check-doc-metrics.mjs +++ b/scripts/check-doc-metrics.mjs @@ -499,32 +499,28 @@ export function scanForDrift(content, filePath, { localeCount, keyCount, latestV return findings; } -// QNBS-v3: (audit F-1) reject a live/pending-remediation claim tied to a bare PR number in the two +// 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 (CodeAnt): the PR number is captured INSIDE each alternative so the qualifier check -// below can require proximity to that specific PR, not "any qualifier anywhere in the text" — -// otherwise a closed/merged mention of a DIFFERENT PR would wrongly suppress this one's finding. -const LIVE_STATUS_CLAIM_WITH_PR = new RegExp( - [ - // QNBS-v3: the gap allows any character (a markdown link's URL contains periods, e.g. - // "github.com") — matching already runs per-sentence, so a real sentence boundary was - // already cut before this regex ever sees the text. - String.raw`\[?PR\s*#(?\d+)\]?[\s\S]{0,120}?\bis the active remediation`, - String.raw`\bpending\s+\[?PR\s*#(?\d+)\]?`, - String.raw`\bin progress on\s+\[?PR\s*#(?\d+)\]?`, - ].join('|'), - 'gi', -); +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/i; const STATUS_QUALIFIER_WORD = 'closed|merged|superseded'; +const STATUS_QUALIFIER_RE = new RegExp(`\\b(?:${STATUS_QUALIFIER_WORD})\\b`, 'i'); +const PROXIMITY_WINDOW = 60; // 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, since neither half alone matched the full pattern. +// detection entirely. QNBS-v3 (codex): a Markdown table row is its own logical unit even though +// consecutive rows have no blank line between them — joining them let a live-status claim in one +// row absorb an unrelated row's qualifier (or vice versa). function splitIntoParagraphs(content) { const paragraphs = []; let buffer = []; @@ -536,11 +532,15 @@ function splitIntoParagraphs(content) { } }; content.split('\n').forEach((line, i) => { - if (line.trim() === '') { + const trimmed = line.trim(); + if (trimmed === '') { + flush(); + } else if (trimmed.startsWith('|')) { flush(); + paragraphs.push({ text: trimmed, startLine: i + 1 }); } else { if (buffer.length === 0) startLine = i; - buffer.push(line.trim()); + buffer.push(trimmed); } }); flush(); @@ -554,23 +554,40 @@ function splitIntoSentences(paragraph) { return paragraph.split(/(?<=[.;])\s+/); } +// 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 text.replace(/\]\([^)]*\)/g, ']'); +} + export function scanSecurityDocPrStatus(content, filePath) { const findings = []; for (const { text: paragraph, startLine } of splitIntoParagraphs(content)) { - for (const sentence of splitIntoSentences(paragraph)) { - for (const match of sentence.matchAll(LIVE_STATUS_CLAIM_WITH_PR)) { - const prNumber = match.groups?.prA ?? match.groups?.prB ?? match.groups?.prC; - // QNBS-v3 (CodeAnt): the qualifier must sit near THIS PR's own number in the same - // sentence, not merely appear somewhere in it — otherwise a different, already-qualified - // PR mentioned nearby would incorrectly suppress this one's live-status finding. - const qualifiedForThisPr = new RegExp( - `#${prNumber}\\b[\\s\\S]{0,60}\\b(?:${STATUS_QUALIFIER_WORD})\\b|` + - `\\b(?:${STATUS_QUALIFIER_WORD})\\b[\\s\\S]{0,60}#${prNumber}\\b`, - 'i', - ).test(sentence); + const compact = stripLinkUrls(paragraph); + for (const sentence of splitIntoSentences(compact)) { + for (const match of sentence.matchAll(PR_REFERENCE)) { + const prNumber = match[1]; + const nearby = sentence.slice( + Math.max(0, match.index - PROXIMITY_WINDOW), + match.index + match[0].length + PROXIMITY_WINDOW, + ); + if (!LIVE_STATUS_TRIGGER.test(nearby)) continue; + // QNBS-v3 (CodeAnt): the qualifier must sit near THIS PR's own number, not merely appear + // somewhere in the sentence — otherwise a different, already-qualified PR mentioned + // nearby would incorrectly suppress this one's live-status finding. Checked two ways: in + // the immediate window (same clause) or anywhere else THIS PR number repeats with a + // qualifier nearby (e.g. "PR #N is the active remediation … PR #N was later closed"). + const qualifiedForThisPr = + STATUS_QUALIFIER_RE.test(nearby) || + new RegExp( + `#${prNumber}\\b[\\s\\S]{0,${PROXIMITY_WINDOW}}\\b(?:${STATUS_QUALIFIER_WORD})\\b|` + + `\\b(?:${STATUS_QUALIFIER_WORD})\\b[\\s\\S]{0,${PROXIMITY_WINDOW}}#${prNumber}\\b`, + 'i', + ).test(sentence); if (!qualifiedForThisPr) { findings.push( - `${filePath}:${startLine} — asserts a live/pending remediation status tied to PR #${prNumber} without stating that PR's actual closed/merged state: "${sentence.trim()}"`, + `${filePath}:${startLine} — asserts a live/pending remediation status near PR #${prNumber} without stating that PR's actual closed/merged state: "${sentence.trim()}"`, ); } } @@ -641,6 +658,10 @@ function main() { 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)); diff --git a/tests/unit/checkDocMetrics.test.ts b/tests/unit/checkDocMetrics.test.ts index 168593d92..54319f722 100644 --- a/tests/unit/checkDocMetrics.test.ts +++ b/tests/unit/checkDocMetrics.test.ts @@ -624,4 +624,34 @@ describe('scanSecurityDocPrStatus', () => { 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); + }); }); From 5252c05a40dd5dcb9ff6eb9ad79b8194df53dd0a Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:01:35 +0200 Subject: [PATCH 06/11] fix(docs): address codex-connector's third review wave on PR #1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Qualifier/trigger association is now nearest-PR-by-distance instead of "any qualifier/trigger within a fixed window" — a short, unrelated PR's qualifier could otherwise suppress a different PR's genuinely unqualified claim when both sat close together ("[PR #999] is the active remediation, unlike [PR #111], closed." was previously missed). - Markdown list items are now isolated the same way table rows already were: consecutive items have no blank line between them, so joining them let one item's wording attach to a different item's PR reference. - Sentence splitting no longer treats a semicolon as a sentence boundary — it doesn't end a sentence, and splitting on it separated a claim from its own qualifying clause ("PR #N is the active remediation; it was later closed."). - docs/DEPENDABOT-TRIAGE.md's OSV ignore-expiry inventory updated to note the 19->21 entry count and the two new npm clusters, and now points at src-tauri/osv-scanner.toml itself as the dynamic source of truth rather than restating a count that will drift again. - 4 new regression tests covering all three logic fixes; README test-count resynced. --- README.md | 8 +-- docs/DEPENDABOT-TRIAGE.md | 15 ++++-- scripts/check-doc-metrics.mjs | 84 ++++++++++++++++++------------ tests/unit/checkDocMetrics.test.ts | 28 ++++++++++ 4 files changed, 93 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index add715aee..9b1f8e334 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2942 keys - 7577+ tests / 603 files + 7580+ 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 (7577+ tests / 603 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7580+ 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 (7577+ tests, 603 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7580+ 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):** -- **7577+ unit tests** across **603 test files** — CI is authoritative for pass/fail +- **7580+ 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 096ed53d2..25cbb3f15 100644 --- a/docs/DEPENDABOT-TRIAGE.md +++ b/docs/DEPENDABOT-TRIAGE.md @@ -81,11 +81,16 @@ 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. +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 Playwright browser download dependency with no patched release and no production-runtime footprint; it must still be diff --git a/scripts/check-doc-metrics.mjs b/scripts/check-doc-metrics.mjs index c22247bda..e1713233a 100644 --- a/scripts/check-doc-metrics.mjs +++ b/scripts/check-doc-metrics.mjs @@ -511,16 +511,19 @@ const PR_REFERENCE = /\[?PR\s*#(\d+)\]?/gi; // 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/i; +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`, 'i'); +const STATUS_QUALIFIER_RE = new RegExp(`\\b(?:${STATUS_QUALIFIER_WORD})\\b`, 'gi'); 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 is its own logical unit even though -// consecutive rows have no blank line between them — joining them let a live-status claim in one -// row absorb an unrelated row's qualifier (or vice versa). +// 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 = []; @@ -535,7 +538,7 @@ function splitIntoParagraphs(content) { const trimmed = line.trim(); if (trimmed === '') { flush(); - } else if (trimmed.startsWith('|')) { + } else if (trimmed.startsWith('|') || LIST_ITEM_MARKER.test(trimmed)) { flush(); paragraphs.push({ text: trimmed, startLine: i + 1 }); } else { @@ -547,11 +550,12 @@ function splitIntoParagraphs(content) { return paragraphs; } -// QNBS-v3: crude but sufficient sentence split for this narrow, two-file gate — doesn't need to -// handle abbreviations/decimals correctly, only to stop a qualifier for one PR bleeding across an -// unrelated sentence into another PR's claim. +// 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+/); + return paragraph.split(/(?<=\.)\s+/); } // QNBS-v3 (codex): a markdown link's URL (github.com/.../pull/NNN) adds length between a PR @@ -561,35 +565,49 @@ function stripLinkUrls(text) { return 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; +} + export function scanSecurityDocPrStatus(content, filePath) { const findings = []; for (const { text: paragraph, startLine } of splitIntoParagraphs(content)) { const compact = stripLinkUrls(paragraph); for (const sentence of splitIntoSentences(compact)) { - for (const match of sentence.matchAll(PR_REFERENCE)) { - const prNumber = match[1]; - const nearby = sentence.slice( - Math.max(0, match.index - PROXIMITY_WINDOW), - match.index + match[0].length + PROXIMITY_WINDOW, + const prRefs = [...sentence.matchAll(PR_REFERENCE)].map((m) => ({ + index: m.index, + end: m.index + m[0].length, + prNumber: m[1], + })); + if (prRefs.length === 0) continue; + + const qualifiedPrs = new Set(); + for (const m of sentence.matchAll(STATUS_QUALIFIER_RE)) { + const nearest = nearestPrNumber(m.index, prRefs); + if (nearest !== null) qualifiedPrs.add(nearest); + } + + const alreadyFlagged = new Set(); + for (const m of sentence.matchAll(LIVE_STATUS_TRIGGER)) { + const nearest = nearestPrNumber(m.index, prRefs); + if (nearest === null || qualifiedPrs.has(nearest) || alreadyFlagged.has(nearest)) continue; + alreadyFlagged.add(nearest); + findings.push( + `${filePath}:${startLine} — asserts a live/pending remediation status near PR #${nearest} without stating that PR's actual closed/merged state: "${sentence.trim()}"`, ); - if (!LIVE_STATUS_TRIGGER.test(nearby)) continue; - // QNBS-v3 (CodeAnt): the qualifier must sit near THIS PR's own number, not merely appear - // somewhere in the sentence — otherwise a different, already-qualified PR mentioned - // nearby would incorrectly suppress this one's live-status finding. Checked two ways: in - // the immediate window (same clause) or anywhere else THIS PR number repeats with a - // qualifier nearby (e.g. "PR #N is the active remediation … PR #N was later closed"). - const qualifiedForThisPr = - STATUS_QUALIFIER_RE.test(nearby) || - new RegExp( - `#${prNumber}\\b[\\s\\S]{0,${PROXIMITY_WINDOW}}\\b(?:${STATUS_QUALIFIER_WORD})\\b|` + - `\\b(?:${STATUS_QUALIFIER_WORD})\\b[\\s\\S]{0,${PROXIMITY_WINDOW}}#${prNumber}\\b`, - 'i', - ).test(sentence); - if (!qualifiedForThisPr) { - findings.push( - `${filePath}:${startLine} — asserts a live/pending remediation status near PR #${prNumber} without stating that PR's actual closed/merged state: "${sentence.trim()}"`, - ); - } } } } diff --git a/tests/unit/checkDocMetrics.test.ts b/tests/unit/checkDocMetrics.test.ts index 54319f722..425669c6d 100644 --- a/tests/unit/checkDocMetrics.test.ts +++ b/tests/unit/checkDocMetrics.test.ts @@ -654,4 +654,32 @@ describe('scanSecurityDocPrStatus', () => { 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); + }); }); From e4ac09036ceef0041b783441ab99fd4c2387a958 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:09:09 +0200 Subject: [PATCH 07/11] refactor(docs): flatten scanSecurityDocPrStatus nesting (CodeScene) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeScene flagged 3 stacked nested-conditional blocks in one function. Extracted findPrReferences/collectQualifiedPrs/collectUnqualifiedClaims/ findUnqualifiedClaimsInSentence — each owns exactly one loop+conditional, scanSecurityDocPrStatus itself is now a flat paragraph/sentence loop calling one helper. No behavior change: all 70 existing tests pass unchanged. --- scripts/check-doc-metrics.mjs | 56 +++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/scripts/check-doc-metrics.mjs b/scripts/check-doc-metrics.mjs index e1713233a..2116ef233 100644 --- a/scripts/check-doc-metrics.mjs +++ b/scripts/check-doc-metrics.mjs @@ -582,31 +582,49 @@ function nearestPrNumber(position, prRefs) { 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 (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) 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)) 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)]; +} + export function scanSecurityDocPrStatus(content, filePath) { const findings = []; for (const { text: paragraph, startLine } of splitIntoParagraphs(content)) { const compact = stripLinkUrls(paragraph); for (const sentence of splitIntoSentences(compact)) { - const prRefs = [...sentence.matchAll(PR_REFERENCE)].map((m) => ({ - index: m.index, - end: m.index + m[0].length, - prNumber: m[1], - })); - if (prRefs.length === 0) continue; - - const qualifiedPrs = new Set(); - for (const m of sentence.matchAll(STATUS_QUALIFIER_RE)) { - const nearest = nearestPrNumber(m.index, prRefs); - if (nearest !== null) qualifiedPrs.add(nearest); - } - - const alreadyFlagged = new Set(); - for (const m of sentence.matchAll(LIVE_STATUS_TRIGGER)) { - const nearest = nearestPrNumber(m.index, prRefs); - if (nearest === null || qualifiedPrs.has(nearest) || alreadyFlagged.has(nearest)) continue; - alreadyFlagged.add(nearest); + for (const prNumber of findUnqualifiedClaimsInSentence(sentence)) { findings.push( - `${filePath}:${startLine} — asserts a live/pending remediation status near PR #${nearest} without stating that PR's actual closed/merged state: "${sentence.trim()}"`, + `${filePath}:${startLine} — asserts a live/pending remediation status near PR #${prNumber} without stating that PR's actual closed/merged state: "${sentence.trim()}"`, ); } } From 323428e0b13d0eea6464ab03643c83f1f3870992 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:38:52 +0200 Subject: [PATCH 08/11] fix(docs): address 4th codex/coderabbit review wave on PR #1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PR_REFERENCE now tolerates an inline-code span around the digits ("PR `#356`"), not just a markdown-link bracket. - A new list item's own text may soft-wrap across the following physical line(s) — it's now buffered like prose (not pushed immediately) and flushed by the next marker/table-row/blank line, so a claim split across a wrapped list item is no longer missed. - A qualifier word (closed/merged/superseded) is now rejected if it's negated ("is not closed") or prospective ("will be merged") in the ~30 characters immediately before it — neither actually asserts a completed status, so neither should suppress a live-status finding. - 6 new regression tests covering all three; README test-count resynced. --- README.md | 8 +++---- scripts/check-doc-metrics.mjs | 30 +++++++++++++++++++++--- tests/unit/checkDocMetrics.test.ts | 37 ++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9b1f8e334..781eeccdc 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2942 keys - 7580+ tests / 603 files + 7585+ 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 (7580+ tests / 603 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7585+ 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 (7580+ tests, 603 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7585+ 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):** -- **7580+ unit tests** across **603 test files** — CI is authoritative for pass/fail +- **7585+ 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/scripts/check-doc-metrics.mjs b/scripts/check-doc-metrics.mjs index 2116ef233..15b95773e 100644 --- a/scripts/check-doc-metrics.mjs +++ b/scripts/check-doc-metrics.mjs @@ -506,7 +506,9 @@ export function scanForDrift(content, filePath, { localeCount, keyCount, latestV // 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']; -const PR_REFERENCE = /\[?PR\s*#(\d+)\]?/gi; +// 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 @@ -514,6 +516,12 @@ const PR_REFERENCE = /\[?PR\s*#(\d+)\]?/gi; 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") or prospective ("will be merged") — checked against the text immediately +// preceding the match. +const QUALIFIER_NEGATION_OR_FUTURE = + /\b(?:not|never|isn't|won't|will|would|should|going to be)\s+(?:be\s+)?$/i; +const NEGATION_LOOKBACK = 30; const PROXIMITY_WINDOW = 60; // QNBS-v3 (codex): an unordered/ordered Markdown list marker — same isolation reasoning as table // rows below. @@ -538,9 +546,18 @@ function splitIntoParagraphs(content) { const trimmed = line.trim(); if (trimmed === '') { flush(); - } else if (trimmed.startsWith('|') || LIST_ITEM_MARKER.test(trimmed)) { + } 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); @@ -590,13 +607,20 @@ function findPrReferences(sentence) { })); } +// 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 isNegatedOrProspective(sentence, matchIndex) { + const before = sentence.slice(Math.max(0, matchIndex - NEGATION_LOOKBACK), matchIndex); + return QUALIFIER_NEGATION_OR_FUTURE.test(before); +} + // 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) qualifiedPrs.add(nearest); + if (nearest !== null && !isNegatedOrProspective(sentence, m.index)) qualifiedPrs.add(nearest); } return qualifiedPrs; } diff --git a/tests/unit/checkDocMetrics.test.ts b/tests/unit/checkDocMetrics.test.ts index 425669c6d..0a68fa089 100644 --- a/tests/unit/checkDocMetrics.test.ts +++ b/tests/unit/checkDocMetrics.test.ts @@ -682,4 +682,41 @@ describe('scanSecurityDocPrStatus', () => { 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); + }); }); From e10be5fe372d0486eed2d07cf61a99ebda889531 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Wed, 9 Sep 2026 02:04:54 +0200 Subject: [PATCH 09/11] fix(docs): address 5th codex/coderabbit review wave on PR #1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A negated or "no longer" live-status trigger ("is not the active remediation", "no longer the active remediation", "not in progress on") doesn't assert live status at all — it's now excluded. - Qualifier negation/prospective detection is now presence-based within the lookback window rather than requiring exact adjacency, so compound forms ("has not been closed", "is not yet closed", "may be merged") are caught without attempting a full negation-scope parser — a bounded best-effort heuristic, documented as such. - HTML comments and fenced code blocks are stripped (blanked, keeping line numbers stable) before scanning, since a literal example inside either isn't a rendered prose assertion about a real PR. A single- backtick inline-code span is deliberately still not stripped and documented as a known, accepted limitation — needed for the "PR `#356`" citation style supported in the prior commit. - 9 new regression tests; README test-count resynced. --- README.md | 8 ++--- scripts/check-doc-metrics.mjs | 53 +++++++++++++++++++++++------- tests/unit/checkDocMetrics.test.ts | 45 +++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 781eeccdc..6eec5465c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2942 keys - 7585+ tests / 603 files + 7588+ 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 (7585+ tests / 603 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7588+ 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 (7585+ tests, 603 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7588+ 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):** -- **7585+ unit tests** across **603 test files** — CI is authoritative for pass/fail +- **7588+ 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/scripts/check-doc-metrics.mjs b/scripts/check-doc-metrics.mjs index 15b95773e..75d62ef46 100644 --- a/scripts/check-doc-metrics.mjs +++ b/scripts/check-doc-metrics.mjs @@ -517,11 +517,17 @@ const LIVE_STATUS_TRIGGER = /\bactive remediation\b|\bpending\b|\bin progress\b/ 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") or prospective ("will be merged") — checked against the text immediately -// preceding the match. -const QUALIFIER_NEGATION_OR_FUTURE = - /\b(?:not|never|isn't|won't|will|would|should|going to be)\s+(?:be\s+)?$/i; -const NEGATION_LOOKBACK = 30; +// ("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. @@ -607,11 +613,20 @@ function findPrReferences(sentence) { })); } +function lookback(sentence, matchIndex) { + return sentence.slice(Math.max(0, matchIndex - NEGATION_LOOKBACK), matchIndex); +} + // 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 isNegatedOrProspective(sentence, matchIndex) { - const before = sentence.slice(Math.max(0, matchIndex - NEGATION_LOOKBACK), matchIndex); - return QUALIFIER_NEGATION_OR_FUTURE.test(before); +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 — @@ -620,7 +635,9 @@ 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 && !isNegatedOrProspective(sentence, m.index)) qualifiedPrs.add(nearest); + if (nearest !== null && !isNegatedOrProspectiveQualifier(sentence, m.index)) { + qualifiedPrs.add(nearest); + } } return qualifiedPrs; } @@ -629,7 +646,9 @@ 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)) claims.add(nearest); + if (nearest !== null && !qualifiedPrs.has(nearest) && !isNegatedTrigger(sentence, m.index)) { + claims.add(nearest); + } } return claims; } @@ -641,9 +660,21 @@ function findUnqualifiedClaimsInSentence(sentence) { 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 = []; - for (const { text: paragraph, startLine } of splitIntoParagraphs(content)) { + 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)) { diff --git a/tests/unit/checkDocMetrics.test.ts b/tests/unit/checkDocMetrics.test.ts index 0a68fa089..fdaf983d1 100644 --- a/tests/unit/checkDocMetrics.test.ts +++ b/tests/unit/checkDocMetrics.test.ts @@ -719,4 +719,49 @@ describe('scanSecurityDocPrStatus', () => { 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); + }); }); From cdf8ceeb9b16df931e0b7cc17b712414edda5b56 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Wed, 9 Sep 2026 02:31:56 +0200 Subject: [PATCH 10/11] fix(docs): correct cross-clause negation bleed and adm-zip rationale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lookback() now truncates at the last comma/semicolon/colon before the match, so a negation word belonging to an EARLIER clause can no longer suppress a genuinely live, unqualified claim in a later clause of the same sentence ("... is not encrypted, so PR #N is the active remediation" was previously, incorrectly, treated as negated). This was a real false-negative letting a stale claim escape the gate entirely, not just a false-positive nuisance. - src-tauri/osv-scanner.toml's adm-zip ignore rationale was factually wrong: verified directly against onnxruntime-node@1.24.3's own install-utils.js that its postinstall extracts into a SHARED system temp directory (os.tmpdir()), not project-local node_modules as previously claimed. The real, verified mitigation is pnpm-workspace.yaml's allowBuilds: onnxruntime-node=false denying the lifecycle script outright — rewritten to anchor on that instead, with an explicit note to re-evaluate if that policy ever changes. - 2 new regression tests for the negation-scope fix; README test-count resynced. --- README.md | 8 ++++---- scripts/check-doc-metrics.mjs | 12 +++++++++++- src-tauri/osv-scanner.toml | 21 ++++++++++++--------- tests/unit/checkDocMetrics.test.ts | 15 +++++++++++++++ 4 files changed, 42 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6eec5465c..ef872a4f3 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2942 keys - 7588+ tests / 603 files + 7590+ 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 (7588+ tests / 603 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7590+ 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 (7588+ tests, 603 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7590+ 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):** -- **7588+ unit tests** across **603 test files** — CI is authoritative for pass/fail +- **7590+ 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/scripts/check-doc-metrics.mjs b/scripts/check-doc-metrics.mjs index 75d62ef46..ad1f16847 100644 --- a/scripts/check-doc-metrics.mjs +++ b/scripts/check-doc-metrics.mjs @@ -613,8 +613,18 @@ function findPrReferences(sentence) { })); } +// 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) { - return sentence.slice(Math.max(0, matchIndex - NEGATION_LOOKBACK), 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 diff --git a/src-tauri/osv-scanner.toml b/src-tauri/osv-scanner.toml index 908590e63..2555979d4 100644 --- a/src-tauri/osv-scanner.toml +++ b/src-tauri/osv-scanner.toml @@ -158,16 +158,19 @@ reason = "extract-zip 2.0.1: second, distinct symlink path-traversal advisory (t # 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 here is used only by onnxruntime-node's own -# postinstall to unpack its bundled native-binary release into its own -# node_modules directory at `pnpm install` time — a location this project's -# local/CI install process controls exclusively, extracting content sourced -# from the npm registry/ONNX Runtime project's own release, never a -# user-supplied or externally-influenced archive. No shared/predictable -# extraction directory exists for an attacker to pre-place a symlink in, and -# the affected code path never runs as part of the shipped web or desktop app. +# 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; used only by onnxruntime-node's own postinstall to unpack its own trusted npm-sourced binary at install time, never a user-supplied archive, no runtime footprint in the shipped app" +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 fdaf983d1..69d9627db 100644 --- a/tests/unit/checkDocMetrics.test.ts +++ b/tests/unit/checkDocMetrics.test.ts @@ -764,4 +764,19 @@ describe('scanSecurityDocPrStatus', () => { 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); + }); }); From 048ae9835b0a51852066da10f71d6b91157cb532 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Wed, 9 Sep 2026 02:58:53 +0200 Subject: [PATCH 11/11] fix(docs): recognize PR-link shorthand, correct Lighthouse mislabeling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The bare "[#N](.../pull/N)" shorthand — the same convention already used for issue links like "[#358](.../issues/358)" in these exact two docs — is now normalized to include the literal "PR" prefix before URL-stripping, so a stale claim can't evade the gate merely by using this link style. A backreference ties the bracket's number to the URL's /pull/ number so a mismatched pair isn't misattributed; an /issues/ link is correctly left untouched. - src-tauri/osv-scanner.toml's extract-zip ignore rationale (both entries, including the pre-existing one this PR's second entry copied wording from) incorrectly called this a "Playwright" browser downloader. Verified via `pnpm audit`: the actual lock-graph path is @lhci/cli -> lighthouse -> puppeteer-core -> @puppeteer/browsers -> extract-zip — Lighthouse CI's own Puppeteer stack, unrelated to this repo's separate playwright/playwright-core devDependency despite the similarly-named package. Corrected both entries and the matching claim in docs/DEPENDABOT-TRIAGE.md. - 3 new regression tests for the shorthand-link fix; README test-count resynced. --- README.md | 8 ++++---- docs/DEPENDABOT-TRIAGE.md | 6 ++++-- scripts/check-doc-metrics.mjs | 12 +++++++++++- src-tauri/osv-scanner.toml | 22 ++++++++++++++-------- tests/unit/checkDocMetrics.test.ts | 24 ++++++++++++++++++++++++ 5 files changed, 57 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index ef872a4f3..381806a19 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2942 keys - 7590+ 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 (7590+ 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 (7590+ 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):** -- **7590+ 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 25cbb3f15..2d0c8d4c7 100644 --- a/docs/DEPENDABOT-TRIAGE.md +++ b/docs/DEPENDABOT-TRIAGE.md @@ -92,8 +92,10 @@ substitute for reading the file. The entries remain grouped by their real reason 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 Playwright browser -download dependency with no patched release and no production-runtime footprint; it must still be +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/scripts/check-doc-metrics.mjs b/scripts/check-doc-metrics.mjs index ad1f16847..7bd124321 100644 --- a/scripts/check-doc-metrics.mjs +++ b/scripts/check-doc-metrics.mjs @@ -581,11 +581,21 @@ 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 text.replace(/\]\([^)]*\)/g, ']'); + return normalizePullShorthand(text).replace(/\]\([^)]*\)/g, ']'); } // QNBS-v3 (codex): associate a word occurrence with its NEAREST PR reference by character diff --git a/src-tauri/osv-scanner.toml b/src-tauri/osv-scanner.toml index 2555979d4..ca344f5f5 100644 --- a/src-tauri/osv-scanner.toml +++ b/src-tauri/osv-scanner.toml @@ -130,28 +130,34 @@ reason = "unic-ucd-ident 0.9.0: unmaintained Unicode data crate; final version; # 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: transitive @puppeteer/browsers devDependency, only ever -# extracts Playwright/Chromium's own CDN-hosted archives, never ships in the app. +# 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 Playwright/@puppeteer/browsers devDependency, never user-supplied zips, no runtime footprint in the shipped app" +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 diff --git a/tests/unit/checkDocMetrics.test.ts b/tests/unit/checkDocMetrics.test.ts index 69d9627db..0984a7fe7 100644 --- a/tests/unit/checkDocMetrics.test.ts +++ b/tests/unit/checkDocMetrics.test.ts @@ -779,4 +779,28 @@ describe('scanSecurityDocPrStatus', () => { 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); + }); });