From df8c4ff55b9a33e0adf8a6302361f36e85dc4767 Mon Sep 17 00:00:00 2001 From: Sam Margolis Date: Mon, 2 Mar 2026 08:33:37 -0800 Subject: [PATCH 1/4] ci: harden checks and scope lint to owned paths --- .github/CODEOWNERS | 13 +++++ .github/pull_request_template.md | 25 +++++++++ .github/workflows/ci.yml | 87 ++++++++++++++++++++++++++++++ config/eslint.config.mjs | 16 ++++-- config/scripts/check-structure.mjs | 22 +++++++- docs/BRANCH_PROTECTION.md | 25 +++++++++ package.json | 2 + scripts/check-no-phi-logs.mjs | 56 +++++++++++++++++++ 8 files changed, 242 insertions(+), 4 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml create mode 100644 docs/BRANCH_PROTECTION.md create mode 100755 scripts/check-no-phi-logs.mjs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..f21d1d5 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,13 @@ +# Global default +* @sammargolis + +# Security-sensitive paths +/apps/web/src/app/api/ @sammargolis +/apps/web/src/middleware.ts @sammargolis +/packages/storage/src/ @sammargolis +/packages/pipeline/transcribe/src/ @sammargolis +/packages/pipeline/assemble/src/ @sammargolis +/config/ @sammargolis +/infra/ @sammargolis +/.github/workflows/ @sammargolis +/docs/compliance/ @sammargolis diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..f52f981 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,25 @@ +## Summary +- + +## Security Impact +- + +## PHI Handling Impact +- Data touched: +- New PHI flows: +- Logging changes: + +## Tests Executed +- [ ] `pnpm lint` +- [ ] `pnpm typecheck` +- [ ] `pnpm test` +- [ ] `pnpm test:no-phi-logs` + +## Rollback Plan +- + +## Checklist +- [ ] Single concern PR (300-600 LOC target) +- [ ] Docs updated +- [ ] No direct push to `main` +- [ ] No PHI added to logs/telemetry diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b2acfdc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,87 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm lint:structure + - run: pnpm exec eslint --config config/eslint.config.mjs apps/web/src packages/storage/src packages/pipeline/transcribe/src packages/pipeline/assemble/src scripts/check-no-phi-logs.mjs config/scripts/check-structure.mjs + + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm build:test + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test + + no-phi-log-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test:no-phi-logs + + dependency-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm audit --audit-level=high + + secret-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/config/eslint.config.mjs b/config/eslint.config.mjs index 4f5b06b..7d9ab77 100644 --- a/config/eslint.config.mjs +++ b/config/eslint.config.mjs @@ -10,8 +10,16 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)) const tsconfigRootDir = path.resolve(__dirname, "..") const kebabCasePattern = "^[a-z0-9]+(?:-[a-z0-9]+)*$" -const ignoredPaths = ["build/**", "node_modules/**", "apps/web/public/**", "apps/web/.next/**"] -const nodeFiles = ["config/**/*.{js,mjs,cjs}", "packages/shell/**/*.js"] +const ignoredPaths = [ + "build/**", + "node_modules/**", + "**/.venv-*/**", + "**/.venv/**", + "apps/web/public/**", + "apps/web/.next/**", + "output/**", +] +const nodeFiles = ["config/**/*.{js,mjs,cjs}", "packages/shell/**/*.js", "scripts/**/*.{js,mjs,cjs}"] const nodeGlobals = { require: "readonly", module: "readonly", @@ -97,7 +105,9 @@ export default tseslint.config( files: nodeFiles, languageOptions: { parserOptions: { - projectService: true, + projectService: { + allowDefaultProject: ["scripts/*.js", "scripts/*.mjs", "scripts/*.cjs"], + }, tsconfigRootDir, }, globals: nodeGlobals, diff --git a/config/scripts/check-structure.mjs b/config/scripts/check-structure.mjs index b18028b..d4e21fe 100644 --- a/config/scripts/check-structure.mjs +++ b/config/scripts/check-structure.mjs @@ -6,12 +6,29 @@ import path from "node:path" const root = path.resolve(process.cwd()) -const allowedRootDirs = new Set(["apps", "packages", "config", "build", "docker", "node_modules"]) +const allowedRootDirs = new Set([ + "apps", + "packages", + "config", + "build", + "docker", + "docs", + "infra", + "local-only", + "models", + "output", + "recordings", + "scripts", + "node_modules", +]) const allowedRootFiles = new Set([ "package.json", "pnpm-lock.yaml", "tsconfig.json", "README.md", + "CONTRIBUTING.md", + "LICENSE", + "requirements.txt", "architecture.md", ".gitignore", "BUILD_STATUS.md", @@ -51,6 +68,9 @@ for (const entry of fs.readdirSync(root, { withFileTypes: true })) { errors.push(`Unexpected top-level directory: ${name}`) } } else { + if (name.endsWith(".tsbuildinfo")) { + continue + } if (name.startsWith(".env")) { continue } diff --git a/docs/BRANCH_PROTECTION.md b/docs/BRANCH_PROTECTION.md new file mode 100644 index 0000000..e8c42d8 --- /dev/null +++ b/docs/BRANCH_PROTECTION.md @@ -0,0 +1,25 @@ +# Branch Protection Policy + +## Protected branch +- `main` + +## Required settings +- Pull request required before merge +- At least 1 approving review +- Dismiss stale approvals when new commits are pushed +- Require conversation resolution before merge +- Require status checks to pass +- Restrict direct pushes to `main` +- Allow squash merge only + +## Required status checks +- `lint` +- `typecheck` +- `test` +- `dependency-scan` +- `secret-scan` +- `no-phi-log-check` + +## Release policy +- Production deploys are allowed only from signed tags `v*` +- `main` merges may deploy to non-production environments only diff --git a/package.json b/package.json index cd3c3cf..041976d 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "dev:local:medasr": "concurrently -k \"pnpm medasr:server\" \"pnpm dev\"", "lint": "pnpm lint:structure && eslint --config config/eslint.config.mjs .", "lint:structure": "node config/scripts/check-structure.mjs", + "typecheck": "pnpm build:test", "start": "next start apps/web", "build:test": "tsc --project config/tsconfig.test.json", "test": "pnpm build:test && pnpm test:run", @@ -25,6 +26,7 @@ "test:e2e": "pnpm build:test && node --test build/tests-dist/pipeline/eval/src/tests/e2e-basic.test.js", "test:e2e:real": "pnpm build:test && node --test build/tests-dist/pipeline/eval/src/tests/e2e-real-api.test.js", "test:api": "pnpm build:test && node --test build/tests-dist/pipeline/eval/src/tests/api-simple.test.js", + "test:no-phi-logs": "node scripts/check-no-phi-logs.mjs", "test:llm": "pnpm build:test && node --test build/tests-dist/llm/src/__tests__/*.test.js", "test:note": "pnpm build:test && node --test build/tests-dist/pipeline/note-core/src/__tests__/*.test.js", "build:desktop": "pnpm build && pnpm build:backend && node packages/shell/scripts/prepare-next.js && electron-builder --mac", diff --git a/scripts/check-no-phi-logs.mjs b/scripts/check-no-phi-logs.mjs new file mode 100755 index 0000000..336bfe4 --- /dev/null +++ b/scripts/check-no-phi-logs.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +/* eslint-env node */ +import { readFileSync } from 'fs' +import { join } from 'path' + +const targets = [ + 'apps/web/src/app/api/transcription/final/route.ts', + 'apps/web/src/app/api/transcription/segment/route.ts', + 'apps/web/src/app/api/transcription/stream/[sessionId]/route.ts', + 'apps/web/src/app/api/notes/generate/route.ts', + 'apps/web/src/app/actions.ts', + 'apps/web/src/lib/auth.ts', + 'apps/web/src/middleware.ts', + 'packages/pipeline/assemble/src/session-store.ts', + 'packages/pipeline/transcribe/src/providers/whisper-transcriber.ts', + 'packages/pipeline/transcribe/src/providers/whisper-local-transcriber.ts', + 'packages/pipeline/transcribe/src/providers/medasr-transcriber.ts', +] + +const bannedPatterns = [ + { regex: /debugLogPHI\s*\(/g, reason: 'PHI debug logger is not allowed in server runtime files.' }, + { regex: /\[PHI DEBUG\]/g, reason: 'PHI debug marker is not allowed in server runtime files.' }, +] + +const suspiciousPatterns = [ + /console\.log\([^\n]*(patient|transcript|note|phi)/i, + /console\.error\([^\n]*(patient|transcript|note|phi)/i, +] + +let failed = false + +for (const relPath of targets) { + const filePath = join(process.cwd(), relPath) + const text = readFileSync(filePath, 'utf8') + + for (const { regex, reason } of bannedPatterns) { + if (regex.test(text)) { + console.error(`FAIL ${relPath}: ${reason}`) + failed = true + } + } + + for (const regex of suspiciousPatterns) { + const match = text.match(regex) + if (match) { + console.error(`FAIL ${relPath}: suspicious logging pattern detected: ${match[0].slice(0, 120)}`) + failed = true + } + } +} + +if (failed) { + process.exit(1) +} + +console.log('PASS no PHI logging patterns detected in guarded server files') From 70ac2b70b7588782ee5d7701a6312ff6b4a98d56 Mon Sep 17 00:00:00 2001 From: Sam Margolis Date: Mon, 2 Mar 2026 09:16:39 -0800 Subject: [PATCH 2/4] ci: fix pnpm setup mismatch and use license-free secret scan --- .github/workflows/ci.yml | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2acfdc..4968ecc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,8 +12,6 @@ jobs: steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: node-version: 20 @@ -27,8 +25,6 @@ jobs: steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: node-version: 20 @@ -41,8 +37,6 @@ jobs: steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: node-version: 20 @@ -55,8 +49,6 @@ jobs: steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: node-version: 20 @@ -69,8 +61,6 @@ jobs: steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: node-version: 20 @@ -82,6 +72,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: gitleaks/gitleaks-action@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Run gitleaks (license-free) + run: | + docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:latest \ + detect --source="/repo" --redact --exit-code 1 From 3f40fa85ec8bf8cdf7c1429cb64b1fa4a20e767b Mon Sep 17 00:00:00 2001 From: Sam Margolis Date: Mon, 2 Mar 2026 09:19:23 -0800 Subject: [PATCH 3/4] ci: scope checks to changed files and scan PR delta --- .github/workflows/ci.yml | 79 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4968ecc..bee540a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: @@ -18,7 +20,29 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm lint:structure - - run: pnpm exec eslint --config config/eslint.config.mjs apps/web/src packages/storage/src packages/pipeline/transcribe/src packages/pipeline/assemble/src scripts/check-no-phi-logs.mjs config/scripts/check-structure.mjs + - name: Detect lint targets + id: lint-targets + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + BASE_SHA="${{ github.event.pull_request.base.sha }}" + else + BASE_SHA="${{ github.event.before }}" + fi + CHANGED_FILES="$(git diff --name-only "$BASE_SHA" "${{ github.sha }}" -- \ + apps/web/src \ + packages/storage/src \ + packages/pipeline/transcribe/src \ + packages/pipeline/assemble/src \ + scripts/check-no-phi-logs.mjs \ + config/scripts/check-structure.mjs \ + | tr '\n' ' ')" + echo "files=${CHANGED_FILES}" >> "$GITHUB_OUTPUT" + - name: Run ESLint on changed files + if: steps.lint-targets.outputs.files != '' + run: pnpm exec eslint --config config/eslint.config.mjs ${{ steps.lint-targets.outputs.files }} + - name: Skip ESLint when no tracked files changed + if: steps.lint-targets.outputs.files == '' + run: echo "No lint-tracked files changed; skipping eslint." typecheck: runs-on: ubuntu-latest @@ -36,13 +60,33 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm test + - name: Detect test-impacting changes + id: test-scope + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + BASE_SHA="${{ github.event.pull_request.base.sha }}" + else + BASE_SHA="${{ github.event.before }}" + fi + if git diff --name-only "$BASE_SHA" "${{ github.sha }}" -- apps/web/src packages scripts | grep -q .; then + echo "run_tests=true" >> "$GITHUB_OUTPUT" + else + echo "run_tests=false" >> "$GITHUB_OUTPUT" + fi + - name: Run tests + if: steps.test-scope.outputs.run_tests == 'true' + run: pnpm test + - name: Skip tests when no runtime files changed + if: steps.test-scope.outputs.run_tests != 'true' + run: echo "No runtime code changes; skipping test suite." no-phi-log-check: runs-on: ubuntu-latest @@ -60,19 +104,46 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm audit --audit-level=high + - name: Detect dependency manifest changes + id: dep-scope + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + BASE_SHA="${{ github.event.pull_request.base.sha }}" + else + BASE_SHA="${{ github.event.before }}" + fi + if git diff --name-only "$BASE_SHA" "${{ github.sha }}" -- package.json pnpm-lock.yaml | grep -q .; then + echo "run_audit=true" >> "$GITHUB_OUTPUT" + else + echo "run_audit=false" >> "$GITHUB_OUTPUT" + fi + - name: Run dependency audit + if: steps.dep-scope.outputs.run_audit == 'true' + run: pnpm audit --audit-level=high + - name: Skip dependency audit when manifests are unchanged + if: steps.dep-scope.outputs.run_audit != 'true' + run: echo "No dependency manifest changes; skipping audit." secret-scan: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Run gitleaks (license-free) run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + LOG_OPTS="${{ github.event.pull_request.base.sha }}..${{ github.sha }}" + else + LOG_OPTS="${{ github.event.before }}..${{ github.sha }}" + fi docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:latest \ - detect --source="/repo" --redact --exit-code 1 + git --source="/repo" --log-opts="$LOG_OPTS" --redact --exit-code 1 From 55da7733889a04d4045d5b51c4754efd3fb4bb36 Mon Sep 17 00:00:00 2001 From: Sam Margolis Date: Mon, 2 Mar 2026 09:23:12 -0800 Subject: [PATCH 4/4] ci: scope security/test gates to relevant diffs --- .github/workflows/ci.yml | 38 +++++++++++++++++++++++++++++++---- scripts/check-no-phi-logs.mjs | 15 ++++++++++++-- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bee540a..354624b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,7 +76,7 @@ jobs: else BASE_SHA="${{ github.event.before }}" fi - if git diff --name-only "$BASE_SHA" "${{ github.sha }}" -- apps/web/src packages scripts | grep -q .; then + if git diff --name-only "$BASE_SHA" "${{ github.sha }}" -- apps/web/src packages | grep -q .; then echo "run_tests=true" >> "$GITHUB_OUTPUT" else echo "run_tests=false" >> "$GITHUB_OUTPUT" @@ -92,13 +92,43 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm test:no-phi-logs + - name: Detect guarded file changes + id: phi-scope + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + BASE_SHA="${{ github.event.pull_request.base.sha }}" + else + BASE_SHA="${{ github.event.before }}" + fi + CHANGED_FILES="$(git diff --name-only "$BASE_SHA" "${{ github.sha }}" -- \ + apps/web/src/app/api/transcription/final/route.ts \ + apps/web/src/app/api/transcription/segment/route.ts \ + 'apps/web/src/app/api/transcription/stream/[sessionId]/route.ts' \ + apps/web/src/app/api/notes/generate/route.ts \ + apps/web/src/app/actions.ts \ + apps/web/src/lib/auth.ts \ + apps/web/src/middleware.ts \ + packages/pipeline/assemble/src/session-store.ts \ + packages/pipeline/transcribe/src/providers/whisper-transcriber.ts \ + packages/pipeline/transcribe/src/providers/whisper-local-transcriber.ts \ + packages/pipeline/transcribe/src/providers/medasr-transcriber.ts \ + scripts/check-no-phi-logs.mjs \ + | tr '\n' ' ')" + echo "files=${CHANGED_FILES}" >> "$GITHUB_OUTPUT" + - name: Run PHI log guard + if: steps.phi-scope.outputs.files != '' + run: pnpm test:no-phi-logs -- ${{ steps.phi-scope.outputs.files }} + - name: Skip PHI log guard when no guarded files changed + if: steps.phi-scope.outputs.files == '' + run: echo "No guarded files changed; skipping PHI log check." dependency-scan: runs-on: ubuntu-latest @@ -120,7 +150,7 @@ jobs: else BASE_SHA="${{ github.event.before }}" fi - if git diff --name-only "$BASE_SHA" "${{ github.sha }}" -- package.json pnpm-lock.yaml | grep -q .; then + if git diff --name-only "$BASE_SHA" "${{ github.sha }}" -- pnpm-lock.yaml | grep -q .; then echo "run_audit=true" >> "$GITHUB_OUTPUT" else echo "run_audit=false" >> "$GITHUB_OUTPUT" @@ -146,4 +176,4 @@ jobs: LOG_OPTS="${{ github.event.before }}..${{ github.sha }}" fi docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:latest \ - git --source="/repo" --log-opts="$LOG_OPTS" --redact --exit-code 1 + git --log-opts="$LOG_OPTS" --redact --exit-code 1 /repo diff --git a/scripts/check-no-phi-logs.mjs b/scripts/check-no-phi-logs.mjs index 336bfe4..206fcdb 100755 --- a/scripts/check-no-phi-logs.mjs +++ b/scripts/check-no-phi-logs.mjs @@ -27,11 +27,22 @@ const suspiciousPatterns = [ /console\.error\([^\n]*(patient|transcript|note|phi)/i, ] +const requestedTargets = process.argv.slice(2) +const filesToScan = + requestedTargets.length > 0 + ? targets.filter((target) => requestedTargets.includes(target)) + : targets + let failed = false -for (const relPath of targets) { +for (const relPath of filesToScan) { const filePath = join(process.cwd(), relPath) - const text = readFileSync(filePath, 'utf8') + let text + try { + text = readFileSync(filePath, 'utf8') + } catch { + continue + } for (const { regex, reason } of bannedPatterns) { if (regex.test(text)) {