#!/usr/bin/env node /** * check-route-auth.js — scans all Express route files for :id-bearing * routes that lack an auth-guard middleware. * * HOW TO USE: * npm run lint:routes * * MARKING A ROUTE AS INTENTIONALLY PUBLIC: * Add a comment on the line immediately before the route definition: * // @public-by-design: * router.get('/:id', handler); * * ADDING A GUARD (preferred): * router.get('/:id', requireOwnership({ table: 'tests', ownerField: 'created_by' }), handler); * router.get('/:id', requireRole('admin'), handler); * router.get('/:id', requirePermission('questions.manage'), handler); * router.get('/:id', parentAuth, handler); * router.get('/:id', authMiddleware, handler); // generic auth check * * AVAILABLE GUARDS (backend/src/middleware/): * requireOwnership — verifies resource belongs to req.user.id * requireRole — requires specific role(s) * requirePermission — checks granular permission key * parentAuth — JWT auth for parent-link tokens * authMiddleware — basic auth (no ownership check) * * BASELINE: * The script allows up to BASELINE unprotected routes (set from first run). * Any new unprotected :id route causes exit(1). Reduce BASELINE over time * as old routes are migrated. */ 'use strict'; const fs = require('fs'); const path = require('path'); const registry = require('../src/permissions/registry'); const ROUTES_DIR = path.join(__dirname, '../src/routes'); // Auth-guard identifiers that count as protection const GUARDS = [ 'requireOwnership', 'requireRole', 'requirePermission', 'perm', // ergonomic alias from auth.js that validates key at registration time 'parentAuth', 'authMiddleware', 'requireAuth', 'ownsTest', // alias used in tests.js ]; // Baseline: number of unprotected :id-routes. // 2026-06-11: линтер научился видеть router-level guards (router.use()), // что убрало ложные срабатывания (admin/permissions/flashcards/… защищены на // уровне роутера). Оставшиеся 8 публичных маршрутов (guest-доска по токену, // справочные данные red-book, список тем) помечены @public-by-design. Долг закрыт. // ONLY decrease this over time — never increase it. const BASELINE = 0; function scanFile(filePath) { const content = fs.readFileSync(filePath, 'utf8'); const lines = content.split('\n'); const issues = []; // Router-level guard: `router.use()` without a leading path string // protects every route declared after it (same guards accepted inline). // Find the earliest such line so those routes aren't false-flagged. let globalGuardLine = Infinity; for (let i = 0; i < lines.length; i++) { const t = lines[i].trim(); if (!t.startsWith('router.use(')) continue; if (/^router\.use\(\s*['"`]/.test(t)) continue; // path-scoped — not global if (GUARDS.some(g => t.includes(g))) { globalGuardLine = i; break; } } for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); // Match: router.METHOD('.../:id...', ...) // Allow multi-segment params like /:id/strokes, /:classId/members/:userId if (!/^router\.(get|post|put|patch|delete)\s*\(/.test(line)) continue; // Does path contain a :param? const pathMatch = line.match(/router\.\w+\s*\(\s*['"`]([^'"`]+)['"`]/); if (!pathMatch) continue; if (!pathMatch[1].includes(':')) continue; // Protected by a router-level guard declared earlier in this file if (i > globalGuardLine) continue; // Collect the full route call (may span multiple lines) let callText = line; let j = i + 1; while (j < lines.length && !callText.includes(');') && j < i + 10) { callText += ' ' + lines[j].trim(); j++; } // Check if any guard is present in the call arguments. // Also treat spread middleware arrays (...auth, ...teacher) as guards. const hasGuard = GUARDS.some(g => callText.includes(g)) || /\.\.\.[a-zA-Z_]\w*/.test(callText); // spread like ...auth, ...teacher if (hasGuard) continue; // Check for @public-by-design comment on the preceding non-empty line let prev = i - 1; while (prev >= 0 && lines[prev].trim() === '') prev--; const prevLine = prev >= 0 ? lines[prev].trim() : ''; if (prevLine.startsWith('// @public-by-design:')) continue; issues.push({ file: path.basename(filePath), line: i + 1, route: line.slice(0, 100), }); } return issues; } function main() { const files = fs.readdirSync(ROUTES_DIR) .filter(f => f.endsWith('.js')) .map(f => path.join(ROUTES_DIR, f)); let total = 0; let unprotected = 0; const allIssues = []; for (const file of files) { const issues = scanFile(file); const routeMatches = fs.readFileSync(file, 'utf8') .split('\n') .filter(l => /^router\.(get|post|put|patch|delete)\s*\(/.test(l.trim()) && l.includes(':')); total += routeMatches.length; unprotected += issues.length; allIssues.push(...issues); } if (allIssues.length > 0) { console.log('\nUnprotected :id-routes (no auth-guard, no @public-by-design comment):'); for (const { file, line, route } of allIssues) { console.log(` ${file}:${line} ${route}`); } console.log(); } console.log(`Route auth check: ${total} :id-routes total, ${unprotected} unprotected (baseline: ${BASELINE})`); /* ── Permission key validation ───────────────────────────────────────── */ // Scan all route files for requirePermission('X') and perm('X') calls. // Verify each key exists in the central registry. const permKeyRe = /(?:requirePermission|perm)\(\s*['"`]([^'"`]+)['"`]/g; const unknownPermKeys = []; for (const file of files) { const src = fs.readFileSync(file, 'utf8'); let m; permKeyRe.lastIndex = 0; while ((m = permKeyRe.exec(src)) !== null) { const key = m[1]; if (!registry.isKnown(key)) { unknownPermKeys.push({ file: path.basename(file), key }); } } } if (unknownPermKeys.length > 0) { console.error('\nFAIL: requirePermission/perm calls with keys not in registry:'); for (const { file, key } of unknownPermKeys) { console.error(` ${file}: "${key}"`); } console.error('Add missing keys to backend/src/permissions/registry.js'); process.exit(1); } else { console.log('Permission key check: all requirePermission/perm keys are registered.'); } if (unprotected > BASELINE) { console.error(`\nFAIL: ${unprotected - BASELINE} new unprotected route(s) added above baseline.`); console.error('Add requireOwnership/requireRole/requirePermission, or mark intentional:'); console.error(' // @public-by-design: '); process.exit(1); } if (unprotected < BASELINE) { console.log(`\nBaseline can be lowered from ${BASELINE} to ${unprotected} — update BASELINE in check-route-auth.js`); } process.exit(0); } main();