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..354624b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,179 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + lint: + 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 lint:structure + - 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 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - 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 + 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 + - 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 | 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 + 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 + - 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 + 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 + - 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 }}" -- 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 \ + git --log-opts="$LOG_OPTS" --redact --exit-code 1 /repo 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..206fcdb --- /dev/null +++ b/scripts/check-no-phi-logs.mjs @@ -0,0 +1,67 @@ +#!/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, +] + +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 filesToScan) { + const filePath = join(process.cwd(), relPath) + let text + try { + text = readFileSync(filePath, 'utf8') + } catch { + continue + } + + 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')