diff --git a/.github/workflows/env-secret-scan.yml b/.github/workflows/env-secret-scan.yml new file mode 100644 index 0000000..82053a6 --- /dev/null +++ b/.github/workflows/env-secret-scan.yml @@ -0,0 +1,60 @@ +name: Env Secret Scan + +on: + pull_request: + paths: + - '.env*' + push: + branches: [main] + paths: + - '.env*' + +jobs: + secret-scan: + name: Scan tracked .env files for secrets + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Scan .env files for high-entropy secret patterns + run: | + ENV_FILES=$(git ls-files '.env*') + if [[ -z "$ENV_FILES" ]]; then + echo "No tracked .env files found." + exit 0 + fi + + FOUND=0 + # Patterns that indicate real credentials (not placeholders) + SECRET_PATTERNS=( + 'sk_live_[A-Za-z0-9]' + 'sk_test_[A-Za-z0-9]' + 'rk_live_[A-Za-z0-9]' + 'AKIA[0-9A-Z]{16}' + 'ghp_[A-Za-z0-9]{36}' + 'gho_[A-Za-z0-9]{36}' + 'github_pat_[A-Za-z0-9]{22}' + 'xox[baprs]-[A-Za-z0-9]' + '-----BEGIN.*PRIVATE KEY-----' + 'eyJhbGciOi[A-Za-z0-9_-]+\.eyJ' + 'postgres(ql)?://[^:]+:[^@]+@' + 'redis://:[^@]+@' + 'mongodb(\+srv)?://[^:]+:[^@]+@' + ) + + for file in $ENV_FILES; do + for pattern in "${SECRET_PATTERNS[@]}"; do + if grep -qE "$pattern" "$file" 2>/dev/null; then + echo "❌ High-entropy secret pattern found in $file (pattern: $pattern)" + FOUND=1 + fi + done + done + + if [[ "$FOUND" -eq 1 ]]; then + echo "" + echo "Tracked .env files must contain only placeholders — no live credentials." + exit 1 + fi + + echo "✅ No secrets found in tracked .env files." diff --git a/.github/workflows/repo-hygiene.yml b/.github/workflows/repo-hygiene.yml new file mode 100644 index 0000000..bf3d441 --- /dev/null +++ b/.github/workflows/repo-hygiene.yml @@ -0,0 +1,63 @@ +name: Repo Hygiene + +on: + pull_request: + paths: + - '**' + +jobs: + reject-clutter: + name: Reject PR clutter files at repo root + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check for forbidden clutter files + run: | + # Files that must not appear at the repo root + FORBIDDEN_PATTERNS=( + 'PR_*SUMMARY*.md' + 'IMPLEMENTATION_SUMMARY.md' + '*FIX*.md' + '*GUIDE*.md' + 'verify_*.sh' + 'fix-*.sh' + ) + # Directories that must not appear + FORBIDDEN_DIRS=( + 'docs/superpowers/plans' + ) + + VIOLATIONS="" + + # Check files added in this PR + ADDED=$(git diff --name-only --diff-filter=A origin/${{ github.base_ref }}...HEAD) + + for file in $ADDED; do + # Only check repo root (no / in path) + if [[ "$file" != */* ]]; then + for pattern in "${FORBIDDEN_PATTERNS[@]}"; do + if [[ "$file" == $pattern ]]; then + VIOLATIONS="$VIOLATIONS\n FORBIDDEN FILE: $file" + fi + done + fi + # Check forbidden directories + for dir in "${FORBIDDEN_DIRS[@]}"; do + if [[ "$file" == "$dir"/* ]]; then + VIOLATIONS="$VIOLATIONS\n FORBIDDEN PATH: $file" + fi + done + done + + if [[ -n "$VIOLATIONS" ]]; then + echo -e "❌ Repo hygiene check failed. The following files are not allowed:$VIOLATIONS" + echo "" + echo "Durable guidance belongs in docs/ (MIGRATIONS.md, RUNBOOK.md, etc.)." + echo "See CONTRIBUTING.md for the full policy." + exit 1 + fi + + echo "✅ Repo hygiene check passed." diff --git a/.gitignore b/.gitignore index a4b0ea1..1814d3a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ node_modules .env .env.* !.env.example +!.env.development +!.env.production +!.env.test dist logs/ postgres/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cf81640..9181ad5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -185,6 +185,20 @@ Every issue carries exactly one primary type label, plus community and dispositi When a triage pass (a **Backlog Cleanup** issue) touches a batch of issues, list every item and its outcome in the pass issue so the decision trail stays auditable. +## Repo Hygiene + +Durable guidance belongs in `docs/` (MIGRATIONS.md, RUNBOOK.md, etc.). The CI job in `.github/workflows/repo-hygiene.yml` **fails a PR** when it adds any of these at the repo root: + +- `PR_*SUMMARY*.md`, `IMPLEMENTATION_SUMMARY.md`, `*FIX*.md`, `*GUIDE*.md` +- `verify_*.sh`, `fix-*.sh` +- Anything under `docs/superpowers/plans/` + +One-off implementation notes, fix scripts, and rehearsal guides do not belong in the repository root. If you find yourself writing one, put the durable parts into `docs/` and leave the rest in the PR description. + +## Env File Policy + +Tracked env files (`.env.example`, `.env.development`, `.env.production`, `.env.test`) contain **placeholders only** — never real credentials. The `.gitignore` explicitly allows exactly these four files. The CI job in `.github/workflows/env-secret-scan.yml` fails if a high-entropy secret pattern is found in any tracked `.env*` file. + ## Need Help? - Check existing [documentation](docs/) diff --git a/package.json b/package.json index 66bc507..1dfeb3c 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "dev": "nodemon --exec ts-node src/app.ts", "build": "tsc -p tsconfig.build.json", "test": "jest", + "pretest": "npx prisma generate", + "prebuild": "npm run clean && npm run build:spec && npx prisma generate", "test:unit": "jest tests/unit", "test:integration": "jest tests/integration", "prisma:generate": "npx prisma generate", @@ -33,7 +35,6 @@ "docs:rate-limits": "ts-node scripts/rate-limit-doc-tables.ts", "docs:scopes": "ts-node scripts/scope-doc-tables.ts", "build:spec": "mkdir -p dist/docs && cp docs/openapi.yaml dist/docs/openapi.yaml", - "prebuild": "npm run clean && npm run build:spec", "prestart": "npm run build", "release:preflight": "bash scripts/release-preflight.sh" }, diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 5b6be9a..baf6c1f 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -2624,6 +2624,12 @@ router.get( '/erasure/requests', requireAdminScope('read'), async (req: Request, res: Response) => { + const adminAuth = getAdminAuth(res) + if (!adminAuth) { + return res + .status(401) + .json({ success: false, error: 'Admin authentication required' }) + } try { const { status, limit, offset } = req.query const result = await listErasureRequests({ @@ -2661,6 +2667,12 @@ router.post( erasureRateLimiter, requireAdminScope('write'), async (req: Request, res: Response) => { + const adminAuth = getAdminAuth(res) + if (!adminAuth) { + return res + .status(401) + .json({ success: false, error: 'Admin authentication required' }) + } try { const { userId, reason } = req.body const adminAuth = res.locals.adminAuth @@ -2710,9 +2722,14 @@ router.post( erasureRateLimiter, requireAdminScope('super'), async (req: Request, res: Response) => { + const adminAuth = getAdminAuth(res) + if (!adminAuth) { + return res + .status(401) + .json({ success: false, error: 'Admin authentication required' }) + } try { const { id } = req.params - const adminAuth = res.locals.adminAuth const request = await approveErasureRequest(id, adminAuth.name) @@ -2757,6 +2774,12 @@ router.post( erasureRateLimiter, requireAdminScope('super'), async (req: Request, res: Response) => { + const adminAuth = getAdminAuth(res) + if (!adminAuth) { + return res + .status(401) + .json({ success: false, error: 'Admin authentication required' }) + } try { const { id } = req.params const { rejectionReason } = req.body @@ -2819,9 +2842,14 @@ router.post( erasureRateLimiter, requireAdminScope('super'), async (req: Request, res: Response) => { + const adminAuth = getAdminAuth(res) + if (!adminAuth) { + return res + .status(401) + .json({ success: false, error: 'Admin authentication required' }) + } try { const { id } = req.params - const adminAuth = res.locals.adminAuth const confirm = typeof req.body?.confirm === 'string' ? req.body.confirm : undefined @@ -2878,6 +2906,12 @@ router.get( erasureRateLimiter, requireAdminScope('read'), async (req: Request, res: Response) => { + const adminAuth = getAdminAuth(res) + if (!adminAuth) { + return res + .status(401) + .json({ success: false, error: 'Admin authentication required' }) + } try { const { userId } = req.params @@ -2917,6 +2951,12 @@ router.get( '/referrals/review', requireAdminScope('read'), async (req: Request, res: Response) => { + const adminAuth = getAdminAuth(res) + if (!adminAuth) { + return res + .status(401) + .json({ success: false, error: 'Admin authentication required' }) + } try { const conversions = await listConversionsForReview() @@ -2946,9 +2986,14 @@ router.post( '/referrals/review/:id/approve', requireAdminScope('write'), async (req: Request, res: Response) => { + const adminAuth = getAdminAuth(res) + if (!adminAuth) { + return res + .status(401) + .json({ success: false, error: 'Admin authentication required' }) + } try { const { id } = req.params - const adminAuth = res.locals.adminAuth await approveReferralConversion(id, adminAuth.name) @@ -2986,10 +3031,15 @@ router.post( '/referrals/review/:id/reject', requireAdminScope('write'), async (req: Request, res: Response) => { + const adminAuth = getAdminAuth(res) + if (!adminAuth) { + return res + .status(401) + .json({ success: false, error: 'Admin authentication required' }) + } try { const { id } = req.params const { rejectionReason } = req.body - const adminAuth = res.locals.adminAuth if (!rejectionReason) { return res.status(400).json({ @@ -3235,22 +3285,18 @@ router.put( } if (allowlist !== undefined && !Array.isArray(allowlist)) { - res - .status(400) - .json({ - success: false, - error: 'allowlist must be an array of strings', - }) + res.status(400).json({ + success: false, + error: 'allowlist must be an array of strings', + }) return } if (blocklist !== undefined && !Array.isArray(blocklist)) { - res - .status(400) - .json({ - success: false, - error: 'blocklist must be an array of strings', - }) + res.status(400).json({ + success: false, + error: 'blocklist must be an array of strings', + }) return } diff --git a/src/utils/logger.ts b/src/utils/logger.ts index ff488d9..ebd5ea1 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -163,7 +163,7 @@ export { redactSensitiveData, SENSITIVE_PATTERNS } */ export function logBackgroundJob( jobName: string, - status: 'success' | 'failed', + status: 'success' | 'failed' | 'dry_run', durationSeconds: number, correlationId?: string, details?: Record diff --git a/src/utils/metrics.ts b/src/utils/metrics.ts index 49b2472..fa65c95 100644 --- a/src/utils/metrics.ts +++ b/src/utils/metrics.ts @@ -575,7 +575,7 @@ export function recordAnalyticsRequest( */ export function recordBackgroundJob( job: string, - status: 'success' | 'failed', + status: 'success' | 'failed' | 'dry_run', durationSeconds: number ): void { backgroundJobsTotal.inc({ job, status })