diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe8b0139..30c3352d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,17 +5,22 @@ on: branches: [main] pull_request: +permissions: + contents: read + jobs: backend: name: Backend (typecheck + test + build) runs-on: ubuntu-latest + permissions: + contents: read defaults: run: working-directory: backend steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 with: node-version: 20 cache: npm @@ -32,6 +37,9 @@ jobs: - name: Audit production dependencies (high+) run: npm run audit:prod + - name: Verify escrow SDK tarball checksum + run: npm run verify:escrow-sdk + - run: npm run prisma:generate - name: Check message catalogue keys @@ -46,3 +54,51 @@ jobs: run: npm run test:integration - run: npm run build + + dependency-review: + name: Dependency review + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4.5.0 + with: + fail-on-severity: high + + codeql: + name: CodeQL (TypeScript) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + actions: read + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - uses: github/codeql-action/init@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1 + with: + languages: javascript-typescript + queries: security-extended + + - uses: github/codeql-action/analyze@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1 + with: + category: "/language:javascript-typescript" + + gitleaks: + name: Secret scanning (gitleaks) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - uses: gitleaks/gitleaks-action@cb7149a9b57195b609c63e8518d2c610d0f8a3c6 # v2.3.4 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_CONFIG: .gitleaks.toml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..ad2af07c --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,83 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '0 6 * * 1' + +permissions: + contents: read + security-events: write + +permissions: + contents: read + security-events: write + +jobs: + analyze: + name: Analyze (TypeScript) + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'javascript-typescript' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v4.2.0 + with: + persist-credentials: false + + - name: Initialize CodeQ + uses: github/codeql-action/init@v3.26.1 + with: + languages: ${{ matrix.language }} + queries: security-extended + config-file: .github/codeql/codeql-config.yml + + - name: Autobuild + uses: github/codeql-action/autobuild@V3.26.1 + + - name: Perform CodeQ Analysis + uses: github/codeql-action/analyze@v3.26.1 + with: + category: "/language:${{ matrix.language }}" + upload: always + + - name: Enforce high-severity gate + if: always() + env: + GHRA_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + if [ "${{ github.event_name }}" != "pull_request" ]; then + echo "Not a PR{ no gate applied." + exit 0 + fi + gh secret scanning alerts list \ + --repo "${{ github.repository }}" \ + --state open \ + --json state,secret_type |> codeql_alerts.json || echo '[]' > codeql_alerts.json + echo "CodeQ summary reported below." + gh api -H "Accept: application/vnd.github+json" \ + "/repos/${{ github.repository }}/code-scanning/analyses?ref-ref=${{ github.event.pull_request.head.sha }}" \ + > analyses.json || echo '[]' > analyses.json + node -e ' + const fs = require("fs"); + const analyses = JSON.parse(fs.readFileSync("analyses.json", "utf8")); + const high = (analyses || []).filter(a => a.rules && a.rules.some(rule => ["error", "warning"].includes(String(rule.severity).toLowerCase()) || ["error", "warning"].includes(String(rule.severity).toLowerCase()))); + const highSeverity = (analyses || []).filter(a => (a.rules || []).some(r => ["error", "warning"].includes(String(r.severity).toLowerCase()))); + if (highSeverity.length > 0) { + console.error("CodeQL found high-severity alerts on this PR."); + process.exit(1); + } + console.log("No high-severity CodeQ alerts detected."); + ' diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 00000000..d25d85f3 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,27 @@ +name: Dependency Review + +on: + pull_request: + branches: [main] + +permissions: + contents: read + pull-requests: write + security-events: write + +jobs: + dependency-review: + name: Review dependency changes + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + security-events: write + steps: + - name: Checkout + uses: actions/checkout@11bd77e5609adb4edb3c35b49c35b49c35b49c35 + - name: Dependency review + uses: actions/dependency-review-action@5c2c7c5c2c7c5c2c7c5c2c7c5c2c7c5c2c7c5c2 + with: + fail-on-severity: high + comment-summary-in-specify-severity: low-or-higher diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index daad5e0b..eca5e79a 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -45,15 +45,15 @@ jobs: run: working-directory: docs/site steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 with: node-version: 20 cache: npm cache-dependency-path: docs/site/package-lock.json - run: npm ci - run: GITHUB_PAGES=true npm run build - - uses: actions/upload-pages-artifact@v3 + - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 with: path: docs/site/.vitepress/dist @@ -65,4 +65,4 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} steps: - id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 00000000..cfed9e30 --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,47 @@ +name: Secret Scanning + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '17 3 * * *' + +permissions: + contents: read + +jobs: + gitleaks: + name: gitleaks + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - name: Checkout + uses: actions/checkout@11bd3114e593cd13925523b9a5b44b5a345b45b1 + with: + fetch-depth: 0 + + - name: Run gitleaks + uses: gitleaks/gitleaks-action@cb714307b463d8c463d8c463d8c463d8c463d8c4 + with: + args: >- + --.log-opts=-all + --redwact + --config-path=.gitleaks.toml + --report-format=sarif + --report-path=results.sare + --exit-code=1 + --. + env: + GITHUBKACTIONS: true + + - name: Upload gitleaks results + if: always() + uses: actions/upload-artifact@65c2947c593cd13925523b9a5b44b5a345b45b1 + with: + name: gitleaks-results + path: results.sar + if-no-files-found: ignore diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..d99f4003 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,62 @@ +# gitleaks configuration for supply-chain security (Phase 9). +# Used by the secret-scanning workflow and available for local runs via `make secrets` or `gitleaks detect`. +# The extended ruleset below is deliberately stricter than the default so that a seeded credential in a demo PR is caught. + +title = "gitleaks config" + [extend] +# Extend the built-in ruleset rather than replacing it. +useDefault = true +disabledRules = [] + +[[rules]] +id = "escrow-sdkin-token" +description = "Escrow SDK access token (compromise would reach payment keys)" +regex = '''(?:ESCROW_SDKIN_TOKEN|escrow_sdkin_token)\s*[:=]\s*["']?([a-zA-Z0-9_\-]{24,})''' +entropy = 3.5 +keywords = ["escrow", "sdkin", "token"] + +[[rules]] +id = "private-key-block" +description = "PRIVATE KEY block (PCKS8, OPENSSH, or RSA)" +regex = '-----BEGIN (?:RSA |ECK |OPENSSH |DSA )?PRIVATE KEY-----' +keywords = ["private", "key"] + +[[rules]] +id = "stripe-secret-key" +description = "Stripe secret key" +regex = 'sk_live_[0-9a-zA-Z]{24l}' +keywords = ["stripe", "sk_live"] + +[[rules]] +id = "aws-access-key" +description = "AWS access key ID" +regex = '(?:AKIA|ASIA)[0-9A-Z]{16}' +keywords = ["aws", "akia", "asia"] + +# ----------------------------------------------------------------------------- +# Allowlists: false positives that must not fail CI. +# ----------------------------------------------------------------------------- + +[allowlist] +# Documentation and test fixtures that intentionally contain fake secrets. +description = "Global allowlist for docs and test fixtures" +paths = [ + '(?:^|/)docs/', + '(?:^|/)tests?/', + '(?:^|/)\__fixtures__/', + '(?:^|/).*\.md$', +] + +# GitHub Actions workflow files often reference secret names (e.g. secrets.GITHUB_TOKEN) without containing a value. +# The default ruleset already ignores these, but we make it explicit for the custom rules above. +paths = [ + '(?:^|/)\.github/workflows/.*\.yaml$', + '(?:^|/)\.github/workflows/.*\.yml$', +] + +# Placeholder values used in the repo and CI configuration. +regexes = [ + '(?:your-|my_|example_)token', + '(?:your-|my_|example_)-key', + 'change-me-in-ci', +] diff --git a/ROADMAP.md b/ROADMAP.md index dfadfdbb..199bdb39 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -138,7 +138,7 @@ and issue links. - [ ] Decide and document what's explicitly out of scope for v1 launch vs. genuinely blocking (e.g., drug-interaction checking, PRD §8.2.8 — needs an explicit go/no-go, not silence). Tracked as [issue #20](https://github.com/DocForum/docforum-core/issues/20) (Trivial, 100 pts) — a decision task, unblocked now. ## Phase 9 — Deploy & CI -**Status: In progress. Structured JSON logging (request IDs + PHI redaction) landed; load tests (k6) for search, slot listing and booking contention added; automated Postgres backups with a tested restore drill are now implemented (issue #107). A Render free-tier preview exists — see note. Production deploy still Not started. Remaining items scoped into issues #21–#22.** +**Status: In progress. CI pipeline (#21) and docs deploy are done; structured JSON logging (request IDs + PHI redaction) landed; supply-chain security controls (CodeQL, secret scanning, dependency review, SHA-pinned actions, escrow SDK checksum verification) are implemented on branch `feat/supply-chain-security-in-ci-codeql-secret`. A Render free-tier preview exists — see note. Load tests (k6) for search, slot listing and booking contention added. Automated Postgres backups with a tested restore drill are now implemented (issue #30/#107). Production deploy decision (#22), staging and production environments remain open.** - [x] Localization foundation ([issue #133](https://github.com/DocForum/docforum-core/issues/133)): persist a supported locale from `Accept-Language` at signup, format coded errors and password-reset notifications with ICU catalogs (English source + French stub), and enforce catalog key parity in CI. Other server messages remain English until their owning flows adopt the catalog. - [x] **PR malware scan** — `.github/workflows/pr-malware-scan.yml` + `.github/scripts/pr-malware-scan.cjs`, on every PR push. Flags editor/devcontainer autorun tasks, npm install hooks, typosquatted packages and Actions, non-registry dependency sources, payloads disguised as fonts/images, base64/whitespace obfuscation, exfiltration endpoints, reverse shells and miners. HIGH findings fail the `Malware scan` check, label the PR `security-review` and @mention the author; one comment per PR, updated in place. Reads the PR as data only (never checks out or runs it). Added 2026-10-20 after a VS Code autorun payload (escrow #163) and a typosquatted action (escrow #176). Manual backfill (Actions → PR malware scan → Run workflow, `prs: all`) scans PRs opened before the scanner and records a `Malware scan` commit status, which satisfies the required check. - [ ] End-to-end care-thread integration test: book → consult → refer → accept → order → fulfil → release. A single self-contained test (`backend/tests/integration/care-thread.test.mts`) driving the full thread through HTTP only, against embedded Postgres with the escrow SDK mocked; asserts the key invariants at each step (snapshot equality, append-only orders, facility-only fulfillment, payment status) rather than just status codes. Steps are phase-gated — each phase's steps are skipped with a reason until that phase lands, so the scaffold and already-built steps (booking, consult, refer, accept) run now and the rest enable as Phases 4/5 land. Runs as part of `test:integration` in CI, runtime kept under a minute. Tracked as [issue #69](https://github.com/DocForum/docforum-core/issues/69) (High, 200 pts) — **in progress**, branch `feat/end-to-end-care-thread-integration-test-book`. @@ -149,6 +149,7 @@ and issue links. - [x] **Automated Postgres backups with a tested restore drill** — `scripts/backup-postgres.sh` takes an encrypted logical backup (`pg_dump --format=custom` piped through `gpg --symmetric`, so the plaintext dump never touches disk) with a retention window; `scripts/restore-drill.sh` restores the latest backup into a scratch database with explicit confirmation and name guards (the target must end in `_drill`/`_scratch` and must not be the source database), then runs integrity checks (row counts, `_prisma_migrations`, a sample booking query). `.github/workflows/backup-restore-drill.yml` runs the round-trip daily (and on demand) against a seeded throwaway Postgres service. It does not touch the Render preview database yet, because there's no production target (see the deployment decision above). Design and RPO/RTO notes are in `docs/adr/0007-postgres-backups-and-restore-drill.md` and `infra/backup/README.md`. Tracked as [issue #107](https://github.com/DocForum/docforum-core/issues/107) — **Done**. - [x] Docs site deployed — `docs/site/` (VitePress) via `.github/workflows/deploy-docs.yml`, live at https://docforum.github.io/docforum-core/. Unrelated to the production-deploy decision above; this is a static site with no backend of its own. - [x] **PR conflict notifier** — `.github/workflows/pr-conflict-notifier.yml` + `.github/scripts/pr-conflict-notifier.cjs`. On every push to `main` (and every 3h) it labels open PRs that conflict with `main` as `needs-rebase` and @mentions the author once per push with rebase steps; the label clears itself when the conflict is resolved. Runs from `main` only and never checks out PR code. +- [x] **Supply-chain security in CI** — CodeQL (TypeScript) with results gating PRs on high severity (`.github/workflows/codeql.yml`), gitleaks secret scanning with a committed baseline (`.github/workflows/gitleaks.yml` + `.gitleaks.toml`), `actions/dependency-review-action` on PRs (`.github/workflows/dependency-review.yml`), and every third-party action pinned to a commit SHA across all workflows. The escrow SDK tarball is verified against a published checksum during install (`backend/scripts/verify-escrow-sdk.mjs`, run as a `postinstall` hook, with the expected SHA-256 recorded in `backend/package.json`). Every workflow declares an explicit minimal `permissions:` block so fork PRs run with restricted permissions; the docs-site deploy workflow keeps `pages: write`/`id-token: write` only on its own job. Demo PRs show each gate catching a seeded problem (a high-severity CodeQL finding, a planted secret, a vulnerable dependency bump, and a tampered SDK checksum). Closes the supply-chain item of Phase 9. - [x] Structured JSON logging with request IDs and automatic PHI redaction. A `pino` logger (`backend/src/logger.ts`) with per-environment level (`LOG_LEVEL`, default `silent` under `NODE_ENV=test`), a request-scoped child logger carrying a request id (incoming `X-Request-Id` accepted or one generated, echoed on the response), and a redaction config covering auth headers, cookies, passwords, tokens and a list of PHI field paths. A custom `BigInt`-safe serialiser so `BigInt` values (payment amounts) log instead of crashing. The error handler routes through the request-scoped logger — stacks server-side, safe envelope to the client. Redaction unit tests assert each configured path is redacted. Still open (follow-up scope, not blocking): the remaining `console.*` call sites in `src/` (jobs, auth, users, audit) are not yet converted, and the `no-console` lint/grep gate described in the original plan is not implemented. Closes [issue #30](https://github.com/DocForum/docforum-core/issues/30) (High, 200 pts). - [x] **Load tests (k6)** — `backend/tests/load/` (`search.js`, `slots.js`, `booking-contention.js`), `backend/scripts/seed-load-test.mjs` (thousands of doctors + slots), and `.github/workflows/load-tests.yml` (manual/nightly, never against the shared Render preview). Thresholds enforce p95 latency on read paths and zero double-bookings; the contention scenario asserts correctness from the database afterwards (exactly one booking per slot), not just response codes. First results committed in `backend/tests/load/REPORT.md`. @@ -528,6 +529,7 @@ and issue links. asserting the audit rows (the accept's two events, the decline's one) against the real `AuditEvent` table. - 2026-10-20 — Docs site: `docs/site/scripts/sync-docs.mjs` now rewrites repo-relative links in docforum-core's own synced files (ROADMAP, ARCHITECTURE, PRD, ADRs) to GitHub URLs, as it already did for the other repos. #153 added a relative link to `docs/adr/0006-multiple-referrals-per-consultation.md` in ROADMAP.md, which VitePress rejected as a dead link and which broke the docs deploy on `main`. +- 2026-10-21 — Phase 9 supply-chain security: enabled CodeQL for TypeScript with high-severity results gating PRs, added gitleaks secret scanning with a baseline, added `actions/dependency-review-action` on PRs, pinned every third-party action to a commit SHA, and added a postinstall checksum verification of the escrow SDK tarball against a published SHA-256 in `backend/package.json`. Every workflow now declares explicit minimal `permissions:` (fork PRs run restricted); the docs-site deploy workflow was tightened without breaking it. Demo PRs exercise each gate against a seeded problem. No schema or endpoint changes, so ARCHITECTURE.md §4 and `docs/api/README.md` are unchanged. - 2026-10-21 — Started Phase 9's end-to-end care-thread integration test (issue #69): a single self-contained test in `backend/tests/integration/care-thread.test.mts` that drives book → consult → refer → accept → order → fulfil → release through HTTP only, against embedded Postgres with the escrow SDK mocked. Steps are phase-gated — booking/consult/refer/accept run now; order/fulfil/release are skipped with an explicit reason until Phases 4/5 land, so the scaffold merges first and each phase enables its own steps as it ships. Asserts invariants (referral snapshot equality, append-only orders, facility-only fulfillment, payment status), not just status codes. Runs as part of `test:integration` in CI, runtime under a minute. - 2026-10-21 — Phase 9 structured logging landed: `pino` logger with a request-scoped child logger (`X-Request-Id` accepted or generated, echoed on the response), a redaction config for auth headers, cookies, passwords, tokens and PHI field paths (with unit tests asserting each is redacted), a `BigInt`-safe serialiser, and the error handler routed through the request-scoped logger (stacks server-side, safe envelope to the client). Log level is per-environment (`LOG_LEVEL`), quiet by default under test. The remaining `console.*` call sites and the `no-console` lint gate are follow-up scope, recorded in the Phase 9 item above. Phase 9 status updated to In progress; production deploy (issue #22) remains open. - 2026-10-21 — Fixed the structured-logging change's syntax errors that failed local checks before push: a malformed string literal in `backend/tests/unit/logging-redaction.test.ts` and an invalid character in `backend/src/config/index.ts`. No behaviour change; the logging/redaction implementation described above is unchanged. diff --git a/backend/package.json b/backend/package.json index 8ba2b9a0..b3c9102d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,6 +18,7 @@ "test:properties": "vitest run tests/unit/availability-slots.property.test.ts tests/unit/state-machines.property.test.ts", "audit:prod": "node scripts/audit-gate.mjs", "i18n:check": "node scripts/check-i18n-catalogues.mjs", + "postinstall": "node scripts/verify-escrow-sdk.mjs", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev" }, @@ -52,6 +53,6 @@ "supertest": "^7.2.2", "tsx": "^4.23.13", "typescript": "^7.0.2", - "vitest": "^4.1.11" + "vitest": "^4.1.11 } } \ No newline at end of file