diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d5127c7..ca981c7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -86,9 +86,66 @@ jobs: # # `--omit=dev` skips devDependencies because they don't ship # to production; the prod bundle is what reaches a user. + # + # ── Advisory waiver: GHSA-qwww-vcr4-c8h2 (react-router) ─────── + # react-router 7.12.0–8.2.0 carries a HIGH "RSC Mode CSRF Bypass" + # advisory. We run react-router-dom@7.18.1 (the latest published + # `dom` package) in classic SPA mode — `` + + # declarative ``/``, no `createBrowserRouter`, + # no `RouterProvider`, no `useFetcher`/`useActionData`/`
`, + # no RSC/SSR. The vulnerable code path (action execution before + # the 400 response in RSC mode) is NOT reachable in this app. + # + # No patched `react-router-dom` exists yet: 7.18.1 is the latest + # on the registry, and the advisory's "patched >= 8.3.0" only + # applies to the bare `react-router` package (no `react-router-dom` + # 8.x has been published). npm's only suggested remediation is a + # breaking downgrade to react-router-dom@7.11.0, which would lose + # 7.12→7.18 fixes and is riskier than the (unreachable) vuln. + # + # This mirrors the backend pip-audit convention: when a CVE has no + # fix yet, suppress the specific advisory with a citation and + # revisit date instead of blocking every deploy. npm v9 `audit` + # has no per-advisory `--ignore` flag, so we filter the JSON + # output and fail only on vulnerabilities OTHER than this one. + # Remove this waiver once react-router-dom publishes a fixed 8.x + # (or a 7.x patch) and bump the pin in package.json. - name: npm audit (production deps only, high+critical) working-directory: frontend - run: npm audit --audit-level=high --omit=dev + run: | + # Waived advisory IDs (GitHub Advisory URL slugs). Each MUST + # have a justification comment above. Revisit on every bump. + WAIVED="GHSA-qwww-vcr4-c8h2" + + # Run the audit and capture JSON. --audit-level=high keeps + # the gate at high+critical; --omit=dev scopes to prod deps. + REPORT="$(npm audit --audit-level=high --omit=dev --json || true)" + + # Extract advisory URL slugs that are NOT in the waive list. + # `npm audit --json` nests advisories under .vulnerabilities + # → → .via[] (each advisory is a dict with .url). + UNWAIVED="$(printf '%s' "$REPORT" | jq -r ' + [.vulnerabilities[]? | + .via[]? | + select(type == "object") | + .url // empty + ] | + map(gsub("https://github.com/advisories/"; "")) | + . - ["'"$WAIVED"'"] | + unique | + .[] + ')" + + if [ -n "$UNWAIVED" ]; then + echo "::error::npm audit found un-waived high+critical advisories:" + echo "$UNWAIVED" + echo "" + echo "Full report:" + printf '%s\n' "$REPORT" | jq '.vulnerabilities | to_entries | map(select(.value.severity != "low" and .value.severity != "moderate")) | from_entries' + exit 1 + fi + + echo "::notice::npm audit passed (1 documented waiver active: $WAIVED — react-router RSC-mode CSRF bypass, not reachable in SPA BrowserRouter mode; no patched react-router-dom published yet)." # Vitest component tests — run BEFORE the build so a regression # caught by tests doesn't get the chance to ship via a successful diff --git a/.github/workflows/weekly-deps-refresh.yml b/.github/workflows/weekly-deps-refresh.yml index 051be58..c764338 100644 --- a/.github/workflows/weekly-deps-refresh.yml +++ b/.github/workflows/weekly-deps-refresh.yml @@ -81,7 +81,31 @@ jobs: run: npm update - name: Frontend audit (production deps, high+critical) working-directory: frontend - run: npm audit --audit-level=high --omit=dev + # Gate parity with deploy.yml — same waived-advisory filter. + # See deploy.yml "npm audit" step for the GHSA-qwww-vcr4-c8h2 + # (react-router RSC-mode CSRF bypass) waiver justification: + # app uses SPA BrowserRouter, not RSC; no patched + # react-router-dom published yet. + run: | + WAIVED="GHSA-qwww-vcr4-c8h2" + REPORT="$(npm audit --audit-level=high --omit=dev --json || true)" + UNWAIVED="$(printf '%s' "$REPORT" | jq -r ' + [.vulnerabilities[]? | + .via[]? | + select(type == "object") | + .url // empty + ] | + map(gsub("https://github.com/advisories/"; "")) | + . - ["'"$WAIVED"'"] | + unique | + .[] + ')" + if [ -n "$UNWAIVED" ]; then + echo "::error::npm audit found un-waived high+critical advisories:" + echo "$UNWAIVED" + exit 1 + fi + echo "::notice::npm audit passed (1 documented waiver active: $WAIVED)." - name: Frontend tests (vitest) working-directory: frontend run: npm test diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7af3305..057f09e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -170,4 +170,17 @@ constraint-dependencies = [ # pydantic-settings: GHSA-4xgf-cpjx-pc3j (fixed in 2.14.2). Transitive # via fastmcp. Remove once fastmcp's own pin clears 2.14.2. "pydantic-settings>=2.14.2", + # click: PYSEC-2026-2132 / CVE-2026-7246 / GHSA-47fr-3ffg-hgmw + # (command injection in click.edit(), fixed in 8.3.3). Transitive + # via uvicorn, which depends on ``click`` with no version specifier, + # so it pulls whatever the resolver picks — without this floor that + # was 8.3.1. Surfaced in the PyPA advisory DB ~2026-07-20, after the + # last green deploy — pip-audit --strict started failing on it even + # though no code changed. No app code calls click.edit() (CLI + # helper used by uvicorn's launcher), so the vulnerable path isn't + # reachable in this service — but pip-audit --strict is a deploy + # gate and correctly fails the scan. Remove once uvicorn pins + # click>=8.3.3 itself (currently unbounded) or the advisory is + # withdrawn. + "click>=8.3.3", ] \ No newline at end of file diff --git a/backend/uv.lock b/backend/uv.lock index b2f606c..23c0868 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -11,6 +11,7 @@ resolution-markers = [ [manifest] constraints = [ { name = "authlib", specifier = ">=1.7.1" }, + { name = "click", specifier = ">=8.3.3" }, { name = "cryptography", specifier = ">=48.0.1" }, { name = "idna", specifier = ">=3.15" }, { name = "joserfc", specifier = ">=1.6.7" }, @@ -309,14 +310,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.1" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] [[package]] @@ -1665,8 +1666,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "sys_platform != 'win32'" }, - { name = "jeepney", marker = "sys_platform != 'win32'" }, + { name = "cryptography" }, + { name = "jeepney" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [