diff --git a/modules/code-scanner/README.md b/modules/code-scanner/README.md index f8df60b..875756b 100644 --- a/modules/code-scanner/README.md +++ b/modules/code-scanner/README.md @@ -9,7 +9,7 @@ same directories, so the module is a thin host and each one is a **subsystem**: | `deps/` | Dependency advisories, malicious install scripts, lockfile drift | **implemented** ([PRD 0002](../../prd/0002-detect-vulnerable-and-malicious-dependencies-on-running-servers.md)) | | `secrets/` | Hardcoded credentials, redacted by construction | **implemented** ([PRD 0003](../../prd/0003-detect-hardcoded-secrets-before-they-are-committed-or-served.md)) | | `sast/` | Dangerous source patterns, with stated confidence | **implemented** ([PRD 0004](../../prd/0004-find-dangerous-code-patterns-without-pretending-to-be-a-compiler.md)) | -| `config/` | Misconfiguration checks | not yet built | +| `config/` | Misconfigurations that get servers breached | **implemented** ([PRD 0005](../../prd/0005-catch-the-misconfigurations-that-actually-get-servers-breached.md)) | ```bash threatcrush modules install code-scanner diff --git a/modules/code-scanner/mod.toml b/modules/code-scanner/mod.toml index 0071e11..2619ed4 100644 --- a/modules/code-scanner/mod.toml +++ b/modules/code-scanner/mod.toml @@ -42,6 +42,24 @@ max_alerts = 25 # truth is "nothing was read". An unexamined project is not a clean one. fail_on_unparseable = true +# --- config subsystem: misconfiguration detection (PRD 0005) -------------- +config_enabled = true + +# Directories the webserver serves. Empty means: parse nginx/Apache config, +# then fall back to conventional locations. If none can be determined the scan +# reports the exposure checks as NOT RUN rather than as clean — silence there +# would mean "did not look", not "nothing exposed". +config_web_roots = [] + +config_check_permissions = true +config_check_containers = true + +# Minimum reachability to report: +# exposed reachable over the network — sets the deadline +# local needs host access first +# hardening defence in depth +config_min_reachability = "local" + # --- sast subsystem: dangerous source patterns (PRD 0004) ----------------- sast_enabled = true diff --git a/modules/code-scanner/src/__tests__/config.test.ts b/modules/code-scanner/src/__tests__/config.test.ts new file mode 100644 index 0000000..e08cb45 --- /dev/null +++ b/modules/code-scanner/src/__tests__/config.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest'; +import { + checkComposeFile, + checkDebugSettings, + checkMode, + checkWebserverConfig, + parseWebRoots, + rankFindings, + sensitiveInWebroot, +} from '../config/index.js'; + +describe('sensitive files in a web root', () => { + it('recognises what must never be served, and says what it is', () => { + // The finding says "environment file", not a rule id — an operator should + // not have to look anything up. + expect(sensitiveInWebroot('.env')).toBe('environment file'); + expect(sensitiveInWebroot('.env.production')).toBe('environment file'); + expect(sensitiveInWebroot('.git')).toBe('git directory'); + expect(sensitiveInWebroot('dump.sql')).toBe('database dump'); + expect(sensitiveInWebroot('config.php.bak')).toBe('backup file'); + expect(sensitiveInWebroot('id_rsa')).toBe('private key'); + expect(sensitiveInWebroot('docker-compose.yml')).toBe('compose file'); + }); + + it('leaves ordinary web content alone', () => { + for (const name of ['index.html', 'app.js', 'logo.png', 'styles.css']) { + expect(sensitiveInWebroot(name), name).toBeNull(); + } + }); +}); + +describe('web root inference', () => { + it('reads nginx root directives', () => { + const conf = ['server {', ' listen 80;', ' root /var/www/html;', '}'].join('\n'); + expect(parseWebRoots(conf)).toEqual(['/var/www/html']); + }); + + it('reads Apache DocumentRoot', () => { + expect(parseWebRoots('DocumentRoot /srv/www/app')).toEqual(['/srv/www/app']); + }); + + it('ignores commented directives', () => { + // Getting this wrong invents a web root nobody serves, and then reports + // confident findings about files that are not reachable. + expect(parseWebRoots('# root /old/path;\nroot /new/path;')).toEqual(['/new/path']); + }); + + it('strips quotes', () => { + expect(parseWebRoots('DocumentRoot "/var/www/html"')).toEqual(['/var/www/html']); + }); + + it('returns nothing rather than guessing when there is nothing to read', () => { + // The caller must then report `unknown`, not "nothing exposed". + expect(parseWebRoots('server { listen 80; }')).toEqual([]); + }); +}); + +describe('permissions', () => { + it('flags a world-writable directory as higher risk than a file', () => { + const dir = checkMode({ path: '/srv/app', mode: 0o777, isDirectory: true }); + const file = checkMode({ path: '/srv/app/x.js', mode: 0o666, isDirectory: false }); + expect(dir?.severity).toBe('high'); + expect(file?.severity).toBe('medium'); + }); + + it('flags world-readable credentials and says to rotate them', () => { + const finding = checkMode({ path: '/srv/app/.env', mode: 0o644, isDirectory: false }); + expect(finding?.checkId).toBe('credentials-world-readable'); + // Changing the mode is only half of remediation. + expect(finding?.remediation).toMatch(/rotate/i); + }); + + it('stays quiet on sane modes', () => { + expect(checkMode({ path: '/srv/app/.env', mode: 0o600, isDirectory: false })).toBeNull(); + expect(checkMode({ path: '/srv/app', mode: 0o755, isDirectory: true })).toBeNull(); + }); +}); + +describe('debug settings', () => { + it('detects framework debug flags', () => { + expect(checkDebugSettings('/srv/.env', 'APP_DEBUG=true')[0]?.checkId).toBe('debug-enabled'); + expect(checkDebugSettings('/srv/.env', 'NODE_ENV=development')).toHaveLength(1); + expect(checkDebugSettings('/srv/.env', 'DEBUG=1')).toHaveLength(1); + }); + + it('stays quiet on production values', () => { + expect(checkDebugSettings('/srv/.env', 'NODE_ENV=production\nAPP_DEBUG=false')).toHaveLength(0); + }); +}); + +describe('container configuration', () => { + it('flags privileged containers as critical', () => { + const found = checkComposeFile('/srv/docker-compose.yml', 'services:\n a:\n privileged: true'); + expect(found[0]?.checkId).toBe('container-privileged'); + expect(found[0]?.severity).toBe('critical'); + }); + + it('flags a mounted Docker socket as critical', () => { + const found = checkComposeFile( + '/srv/docker-compose.yml', + 'volumes:\n - /var/run/docker.sock:/var/run/docker.sock', + ); + expect(found.map((f) => f.checkId)).toContain('docker-socket-mounted'); + // Socket access is root on the host; the text should not undersell it. + expect(found[0]?.consequence).toMatch(/root/i); + }); + + it('flags host networking and SYS_ADMIN', () => { + expect( + checkComposeFile('/c.yml', 'network_mode: host').map((f) => f.checkId), + ).toContain('container-host-network'); + expect( + checkComposeFile('/c.yml', 'cap_add:\n - SYS_ADMIN').map((f) => f.checkId), + ).toContain('container-sys-admin'); + }); + + it('ignores commented-out settings', () => { + expect(checkComposeFile('/c.yml', '# privileged: true')).toHaveLength(0); + }); + + it('stays quiet on an ordinary compose file', () => { + const ordinary = ['services:', ' web:', ' image: nginx', ' ports:', ' - "80:80"'].join( + '\n', + ); + expect(checkComposeFile('/c.yml', ordinary)).toHaveLength(0); + }); +}); + +describe('webserver configuration', () => { + it('flags directory listing', () => { + expect(checkWebserverConfig('/etc/nginx/nginx.conf', 'autoindex on;')[0]?.checkId).toBe( + 'directory-listing', + ); + }); + + it('only flags CORS when the wildcard is combined with credentials', () => { + // Each is defensible alone; together any site can make authenticated + // requests with a visitor's session. + const wildcardOnly = checkWebserverConfig('/c', "add_header Access-Control-Allow-Origin *;"); + expect(wildcardOnly).toHaveLength(0); + + const both = checkWebserverConfig( + '/c', + "add_header Access-Control-Allow-Origin *;\nadd_header Access-Control-Allow-Credentials true;", + ); + expect(both.map((f) => f.checkId)).toContain('cors-wildcard-with-credentials'); + }); + + it('stays quiet on a normal config', () => { + expect(checkWebserverConfig('/c', 'server { listen 80; autoindex off; }')).toHaveLength(0); + }); +}); + +describe('ranking', () => { + it('puts exposed findings above local ones regardless of severity', () => { + // Reachability sets the deadline: an exposed medium is more urgent than a + // local critical that needs shell access first. + const ranked = rankFindings([ + { + checkId: 'a', + title: 'local critical', + subject: '/a', + reachability: 'local', + severity: 'critical', + consequence: '', + remediation: '', + }, + { + checkId: 'b', + title: 'exposed medium', + subject: '/b', + reachability: 'exposed', + severity: 'medium', + consequence: '', + remediation: '', + }, + ]); + expect(ranked[0]?.checkId).toBe('b'); + }); +}); + +describe('every finding is actionable', () => { + it('carries a remediation and a consequence', () => { + // A rule added without these is the beginning of the compliance-checklist + // failure mode this subsystem exists to avoid. + const all = [ + ...checkComposeFile('/c.yml', 'privileged: true\nnetwork_mode: host'), + ...checkDebugSettings('/srv/.env', 'APP_DEBUG=true'), + ...checkWebserverConfig('/c', 'autoindex on;'), + checkMode({ path: '/srv/app', mode: 0o777, isDirectory: true })!, + ]; + + for (const finding of all) { + expect(finding.remediation.length, finding.checkId).toBeGreaterThan(10); + expect(finding.consequence.length, finding.checkId).toBeGreaterThan(20); + } + }); +}); diff --git a/modules/code-scanner/src/config/checks.ts b/modules/code-scanner/src/config/checks.ts new file mode 100644 index 0000000..d841644 --- /dev/null +++ b/modules/code-scanner/src/config/checks.ts @@ -0,0 +1,306 @@ +/** + * Misconfiguration checks — the `config` subsystem of `code-scanner`. + * + * Implements PRD 0005. Two ideas shape this file: + * + * 1. **Rank by reachability, not by benchmark severity.** An exposed `.env` in + * a served directory is a tonight problem; the same file outside any web + * root is a tidiness issue. A tool that scores both identically forces the + * operator to do the triage the tool should have done. + * + * 2. **Every finding carries a fix, and the fix includes the follow-through.** + * Moving an exposed `.env` is not remediation — the credentials in it must + * be rotated, because it should be assumed read. Operators routinely do the + * first half and stop, so the text says both. + * + * The standing risk, named in the PRD, is drift toward a 200-item compliance + * checklist that nobody reads. Every check here should be traceable to a way + * servers actually get breached. + */ + +export type Reachability = 'exposed' | 'local' | 'hardening'; +export type ConfigSeverity = 'low' | 'medium' | 'high' | 'critical'; + +export interface ConfigFinding { + checkId: string; + title: string; + /** Where the problem is: a path, or a setting. */ + subject: string; + reachability: Reachability; + severity: ConfigSeverity; + /** What an attacker gets. */ + consequence: string; + /** What to do, including anything that must follow. */ + remediation: string; +} + +/** Files that must never sit inside a directory a webserver serves. */ +export const SENSITIVE_IN_WEBROOT: readonly { pattern: RegExp; what: string }[] = [ + { pattern: /^\.env(\..+)?$/i, what: 'environment file' }, + { pattern: /^\.git$/i, what: 'git directory' }, + { pattern: /^\.htpasswd$/i, what: 'htpasswd file' }, + { pattern: /^id_(?:rsa|dsa|ecdsa|ed25519)$/i, what: 'private key' }, + { pattern: /\.(?:sql|dump)$/i, what: 'database dump' }, + { pattern: /\.(?:bak|old|orig|save|swp)$/i, what: 'backup file' }, + { pattern: /^docker-compose\.ya?ml$/i, what: 'compose file' }, + { pattern: /^\.npmrc$/i, what: 'npm credentials file' }, + { pattern: /^\.aws$/i, what: 'AWS credentials directory' }, +]; + +/** + * Is this filename dangerous to serve? + * + * Returns what it is, so the finding can say "environment file" rather than + * quoting a rule id at the operator. + */ +export function sensitiveInWebroot(name: string): string | null { + for (const entry of SENSITIVE_IN_WEBROOT) { + if (entry.pattern.test(name)) return entry.what; + } + return null; +} + +/** + * Web roots declared by an nginx or Apache configuration. + * + * A targeted directive scrape rather than a config parser: the full grammars + * are large, and PRD 0005 explicitly scopes a parser out. When this finds + * nothing the caller must report `unknown` rather than assuming nothing is + * served — an unknown root that reads as "nothing exposed" is the failure this + * subsystem is supposed to prevent. + */ +export function parseWebRoots(configText: string): string[] { + const roots = new Set(); + + // nginx: `root /var/www/html;` — skip commented lines. + for (const line of configText.split('\n')) { + const trimmed = line.trim(); + if (trimmed.startsWith('#')) continue; + + const nginx = /^root\s+([^;]+);/.exec(trimmed); + if (nginx?.[1]) roots.add(nginx[1].trim().replace(/^["']|["']$/g, '')); + + const apache = /^DocumentRoot\s+(.+)$/i.exec(trimmed); + if (apache?.[1]) roots.add(apache[1].trim().replace(/^["']|["']$/g, '')); + } + + return [...roots]; +} + +/** Conventional locations, used only when config parsing yields nothing. */ +export const CONVENTIONAL_WEB_ROOTS = [ + '/var/www/html', + '/var/www', + '/usr/share/nginx/html', + '/srv/www', + '/srv/http', +]; + +export interface ModeCheck { + path: string; + /** Unix mode bits, e.g. 0o777. */ + mode: number; + isDirectory: boolean; +} + +/** + * Permission problems that matter. + * + * Restricted to cases with a real consequence: anyone on the box can rewrite + * the code that runs, or read the credentials it runs with. General + * "permissions are broader than ideal" reporting is how this subsystem becomes + * a checklist. + */ +export function checkMode(entry: ModeCheck): ConfigFinding | null { + const worldWritable = (entry.mode & 0o002) !== 0; + const worldReadable = (entry.mode & 0o004) !== 0; + const name = entry.path.split('/').pop() ?? entry.path; + + if (worldWritable) { + return { + checkId: 'world-writable', + title: `world-writable ${entry.isDirectory ? 'directory' : 'file'}`, + subject: entry.path, + reachability: 'local', + severity: entry.isDirectory ? 'high' : 'medium', + consequence: entry.isDirectory + ? 'Any local user can add or replace files here, including code that will be executed.' + : 'Any local user can rewrite this file.', + remediation: `chmod o-w ${entry.path}`, + }; + } + + if (worldReadable && /^\.env|^id_(?:rsa|dsa|ecdsa|ed25519)$|\.pem$|\.key$/i.test(name)) { + return { + checkId: 'credentials-world-readable', + title: 'credential file readable by every local user', + subject: entry.path, + reachability: 'local', + severity: 'high', + consequence: 'Any account on this host can read these credentials.', + remediation: `chmod 600 ${entry.path} — then rotate the credentials, since anyone with shell access could already have read them.`, + }; + } + + return null; +} + +/** Production-marker detection for debug settings. */ +const DEBUG_PATTERNS: readonly { pattern: RegExp; setting: string }[] = [ + { pattern: /^\s*APP_DEBUG\s*=\s*(?:true|1)\s*$/im, setting: 'APP_DEBUG' }, + { pattern: /^\s*DEBUG\s*=\s*(?:true|1|True)\s*$/im, setting: 'DEBUG' }, + { pattern: /^\s*DEBUG\s*=\s*True\s*$/m, setting: 'Django DEBUG' }, + { pattern: /consider_all_requests_local\s*=\s*true/i, setting: 'Rails consider_all_requests_local' }, + { pattern: /^\s*NODE_ENV\s*=\s*(?:development|dev)\s*$/im, setting: 'NODE_ENV' }, +]; + +export function checkDebugSettings(path: string, text: string): ConfigFinding[] { + const findings: ConfigFinding[] = []; + + for (const { pattern, setting } of DEBUG_PATTERNS) { + if (!pattern.test(text)) continue; + findings.push({ + checkId: 'debug-enabled', + title: `${setting} enabled`, + subject: path, + reachability: 'exposed', + severity: 'high', + consequence: + 'Error pages disclose stack traces, file paths, environment variables and sometimes credentials to anyone who can trigger an error.', + remediation: `Set ${setting} to its production value and restart the service.`, + }); + } + + return findings; +} + +/** + * Container settings that hand over the host. + * + * Reading compose files from disk only. Querying the Docker daemon is a + * privilege boundary of its own and is deliberately not done here. + */ +export function checkComposeFile(path: string, text: string): ConfigFinding[] { + const findings: ConfigFinding[] = []; + const lines = text.split('\n').filter((line) => !line.trim().startsWith('#')); + const body = lines.join('\n'); + + if (/privileged\s*:\s*true/i.test(body)) { + findings.push({ + checkId: 'container-privileged', + title: 'container runs privileged', + subject: path, + reachability: 'local', + severity: 'critical', + consequence: + 'A privileged container can access host devices and escape to the host trivially; the container boundary is decorative.', + remediation: + 'Remove `privileged: true` and grant only the specific capabilities the workload needs.', + }); + } + + if (/\/var\/run\/docker\.sock/.test(body)) { + findings.push({ + checkId: 'docker-socket-mounted', + title: 'Docker socket mounted into a container', + subject: path, + reachability: 'local', + severity: 'critical', + consequence: + 'Access to the socket is equivalent to root on the host — a process in the container can start a new privileged container mounting the host filesystem.', + remediation: + 'Remove the socket mount. If the container genuinely needs to orchestrate, use a scoped proxy rather than the raw socket.', + }); + } + + if (/network_mode\s*:\s*["']?host/i.test(body)) { + findings.push({ + checkId: 'container-host-network', + title: 'container shares the host network namespace', + subject: path, + reachability: 'local', + severity: 'high', + consequence: + 'The container can reach every service bound to loopback on the host, including databases that believe they are unreachable.', + remediation: 'Use a bridge network and publish only the ports the service needs.', + }); + } + + if (/cap_add\s*:[\s\S]{0,80}SYS_ADMIN/i.test(body)) { + findings.push({ + checkId: 'container-sys-admin', + title: 'container granted CAP_SYS_ADMIN', + subject: path, + reachability: 'local', + severity: 'high', + consequence: 'CAP_SYS_ADMIN is broad enough to be a well-known route to container escape.', + remediation: 'Drop SYS_ADMIN and add only the narrower capability actually required.', + }); + } + + return findings; +} + +/** Webserver directives that expose more than intended. */ +export function checkWebserverConfig(path: string, text: string): ConfigFinding[] { + const findings: ConfigFinding[] = []; + const body = text + .split('\n') + .filter((line) => !line.trim().startsWith('#')) + .join('\n'); + + if (/autoindex\s+on|Options\s+[^\n]*\+Indexes/i.test(body)) { + findings.push({ + checkId: 'directory-listing', + title: 'directory listing enabled', + subject: path, + reachability: 'exposed', + severity: 'medium', + consequence: + 'Visitors can enumerate every file in the directory, including ones that were never meant to be discoverable.', + remediation: 'Set `autoindex off` (nginx) or `Options -Indexes` (Apache).', + }); + } + + // Individually defensible, together a vulnerability: a wildcard origin with + // credentials allows any site to make authenticated requests as the user. + const wildcardOrigin = /Access-Control-Allow-Origin[^\n]*\*/i.test(body); + const allowCredentials = /Access-Control-Allow-Credentials[^\n]*true/i.test(body); + if (wildcardOrigin && allowCredentials) { + findings.push({ + checkId: 'cors-wildcard-with-credentials', + title: 'CORS allows any origin with credentials', + subject: path, + reachability: 'exposed', + severity: 'high', + consequence: + 'Any website can make authenticated requests to this application using a visitor’s session.', + remediation: + 'Replace the wildcard with an explicit origin allowlist, or stop sending credentials cross-origin.', + }); + } + + return findings; +} + +export const REACHABILITY_RANK: Record = { + hardening: 0, + local: 1, + exposed: 2, +}; + +export const CONFIG_SEVERITY_RANK: Record = { + low: 0, + medium: 1, + high: 2, + critical: 3, +}; + +/** Exposure first, then severity — reachability is what sets the deadline. */ +export function rankFindings(findings: readonly ConfigFinding[]): ConfigFinding[] { + return [...findings].sort( + (a, b) => + REACHABILITY_RANK[b.reachability] - REACHABILITY_RANK[a.reachability] || + CONFIG_SEVERITY_RANK[b.severity] - CONFIG_SEVERITY_RANK[a.severity], + ); +} diff --git a/modules/code-scanner/src/config/index.ts b/modules/code-scanner/src/config/index.ts new file mode 100644 index 0000000..baab5a1 --- /dev/null +++ b/modules/code-scanner/src/config/index.ts @@ -0,0 +1,278 @@ +/** + * Misconfiguration scanning — the `config` subsystem of `code-scanner`. + * + * Implements PRD 0005. This is the subsystem with the least overlap with CI + * tooling and, for small-team servers, probably the highest yield: a + * `docker-compose.yml` in a repository says what someone *intended*, while the + * file on the box — plus what is actually served, plus the permissions on the + * directory — says what is *true*. Only an agent on the host sees the second. + * + * The web-root inference in here is the weakest link and is treated as such: + * when it cannot determine what is served it reports `unknown` rather than + * assuming nothing is exposed, because a confident "nothing found" derived from + * a failed inference is the exact failure this whole module was built to avoid. + */ + +export * from './checks.js'; + +import { open, readdir, stat } from 'node:fs/promises'; +import type { FileHandle } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { + CONVENTIONAL_WEB_ROOTS, + REACHABILITY_RANK, + checkComposeFile, + checkDebugSettings, + checkMode, + checkWebserverConfig, + parseWebRoots, + rankFindings, + sensitiveInWebroot, + type ConfigFinding, + type Reachability, +} from './checks.js'; + +export interface ConfigOptions { + paths: readonly string[]; + maxDepth: number; + /** Explicit web roots. Empty means infer. */ + webRoots: readonly string[]; + checkPermissions: boolean; + checkContainers: boolean; + minReachability: Reachability; +} + +export interface ConfigResult { + findings: ConfigFinding[]; + /** Web roots used, and how they were determined — inference can be wrong. */ + webRoots: { path: string; source: 'configured' | 'parsed' | 'conventional' }[]; + /** True when no web root could be established; exposure checks are then blind. */ + webRootUnknown: boolean; + filesInspected: number; +} + +export const CONFIG_DEFAULTS: ConfigOptions = { + paths: ['/srv', '/var/www', '/opt', '/etc'], + maxDepth: 6, + webRoots: [], + checkPermissions: true, + checkContainers: true, + minReachability: 'local', +}; + +const WEBSERVER_CONFIGS = [ + '/etc/nginx/nginx.conf', + '/etc/nginx/sites-enabled', + '/etc/nginx/conf.d', + '/etc/apache2/apache2.conf', + '/etc/apache2/sites-enabled', + '/etc/httpd/conf/httpd.conf', +]; + +async function readTextFile(path: string, maxBytes = 2_000_000): Promise { + // Single handle for stat and read, as in the other subsystems: a separate + // stat leaves a window in which the path can be swapped. + let handle: FileHandle | undefined; + try { + handle = await open(path, 'r'); + if ((await handle.stat()).size > maxBytes) return null; + return await handle.readFile('utf8'); + } catch { + return null; + } finally { + await handle?.close().catch(() => { + /* a failed close must not abort the scan */ + }); + } +} + +async function listFiles(path: string): Promise { + try { + const entries = await readdir(path, { withFileTypes: true }); + return entries.filter((e) => e.isFile()).map((e) => join(path, e.name)); + } catch { + return []; + } +} + +/** + * Determine what the webserver actually serves. + * + * Configured roots win; then directives scraped from nginx/Apache config; then + * conventional locations that exist on disk. The source is reported alongside + * because a conventional guess deserves less confidence than a parsed + * directive, and the operator should be able to see which they got. + */ +export async function resolveWebRoots( + configured: readonly string[], +): Promise { + if (configured.length > 0) { + return configured.map((path) => ({ path, source: 'configured' as const })); + } + + const parsed = new Set(); + for (const location of WEBSERVER_CONFIGS) { + let files: string[] = []; + try { + files = (await stat(location)).isDirectory() ? await listFiles(location) : [location]; + } catch { + continue; + } + + for (const file of files) { + const text = await readTextFile(file); + if (!text) continue; + for (const root of parseWebRoots(text)) parsed.add(root); + } + } + + if (parsed.size > 0) { + return [...parsed].map((path) => ({ path, source: 'parsed' as const })); + } + + const conventional: ConfigResult['webRoots'] = []; + for (const path of CONVENTIONAL_WEB_ROOTS) { + try { + if ((await stat(path)).isDirectory()) { + conventional.push({ path, source: 'conventional' }); + } + } catch { + /* not present */ + } + } + return conventional; +} + +async function walkEntries( + dir: string, + depth: number, + maxDepth: number, + out: { path: string; isDirectory: boolean }[], +): Promise { + if (depth > maxDepth) return; + + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const path = join(dir, entry.name); + // `.git` is itself a finding when served, so it is recorded rather than + // skipped the way the other subsystems skip it. + out.push({ path, isDirectory: entry.isDirectory() }); + if (entry.isDirectory() && entry.name !== 'node_modules' && entry.name !== '.git') { + await walkEntries(path, depth + 1, maxDepth, out); + } + } +} + +/** Run the misconfiguration checks. Options in, result out — no ModuleContext. */ +export async function scanConfig(options: ConfigOptions): Promise { + const findings: ConfigFinding[] = []; + const webRoots = await resolveWebRoots(options.webRoots); + let filesInspected = 0; + + // ---- exposure: sensitive files inside something that is served ---------- + for (const root of webRoots) { + const entries: { path: string; isDirectory: boolean }[] = []; + await walkEntries(root.path, 0, options.maxDepth, entries); + + for (const entry of entries) { + filesInspected += 1; + const name = entry.path.split('/').pop() ?? ''; + const what = sensitiveInWebroot(name); + if (!what) continue; + + findings.push({ + checkId: 'sensitive-file-in-webroot', + title: `${what} inside a served directory`, + subject: entry.path, + reachability: 'exposed', + severity: 'critical', + consequence: `Anyone who requests this path over HTTP receives the ${what}, including anything it contains.`, + remediation: + `Move it above the web root, or deny it in the server config. Then rotate every credential it held — ` + + `assume it was read. (web root determined by: ${root.source})`, + }); + } + } + + // ---- application and container configuration ---------------------------- + for (const base of options.paths) { + const entries: { path: string; isDirectory: boolean }[] = []; + await walkEntries(base, 0, Math.min(options.maxDepth, 4), entries); + + for (const entry of entries) { + if (entry.isDirectory) { + if (options.checkPermissions) { + try { + const info = await stat(entry.path); + const finding = checkMode({ + path: entry.path, + mode: info.mode & 0o777, + isDirectory: true, + }); + if (finding) findings.push(finding); + } catch { + /* unreadable */ + } + } + continue; + } + + const name = entry.path.split('/').pop() ?? ''; + + if (/^\.env(\..+)?$/i.test(name)) { + const text = await readTextFile(entry.path); + if (text) { + filesInspected += 1; + findings.push(...checkDebugSettings(entry.path, text)); + } + } + + if (options.checkContainers && /^docker-compose\.ya?ml$/i.test(name)) { + const text = await readTextFile(entry.path); + if (text) { + filesInspected += 1; + findings.push(...checkComposeFile(entry.path, text)); + } + } + + if (/^(?:nginx\.conf|httpd\.conf|apache2\.conf)$/i.test(name) || /sites-enabled/.test(entry.path)) { + const text = await readTextFile(entry.path); + if (text) { + filesInspected += 1; + findings.push(...checkWebserverConfig(entry.path, text)); + } + } + + if (options.checkPermissions && /^\.env|^id_(?:rsa|dsa|ecdsa|ed25519)$|\.pem$|\.key$/i.test(name)) { + try { + const info = await stat(entry.path); + const finding = checkMode({ + path: entry.path, + mode: info.mode & 0o777, + isDirectory: false, + }); + if (finding) findings.push(finding); + } catch { + /* unreadable */ + } + } + } + } + + const floor = REACHABILITY_RANK[options.minReachability]; + const visible = findings.filter((f) => REACHABILITY_RANK[f.reachability] >= floor); + + return { + findings: rankFindings(visible), + webRoots, + webRootUnknown: webRoots.length === 0, + filesInspected, + }; +} diff --git a/modules/code-scanner/src/index.ts b/modules/code-scanner/src/index.ts index d20b3d0..bf874fc 100644 --- a/modules/code-scanner/src/index.ts +++ b/modules/code-scanner/src/index.ts @@ -9,7 +9,7 @@ * deps/ dependency and supply-chain scanning — PRD 0002, implemented * secrets/ hardcoded credential detection — PRD 0003, implemented * sast/ source-level vulnerability analysis — PRD 0004, implemented - * config/ misconfiguration checks — not yet built + * config/ misconfiguration checks — PRD 0005, implemented * * PRD 0002 originally proposed dependency scanning as a standalone * `dep-scanner` module and left the boundary as an open question. It is @@ -38,6 +38,12 @@ import { type DepsResult, type Finding, } from './deps/index.js'; +import { + CONFIG_DEFAULTS, + scanConfig, + type ConfigOptions, + type ConfigResult, +} from './config/index.js'; import { SAST_DEFAULTS, scanSast, @@ -63,6 +69,7 @@ export interface ScanResult { deps: DepsResult | null; secrets: SecretsResult | null; sast: SastResult | null; + config: ConfigResult | null; /** True when any subsystem could not complete — never report "clean". */ incomplete: boolean; } @@ -130,7 +137,11 @@ export default class CodeScannerModule implements ThreatCrushModule { const deps = this.depsEnabled() ? await scanDependencies(this.depsOptions()) : null; const secrets = this.secretsEnabled() ? await scanSecrets(this.secretsOptions()) : null; const sast = this.sastEnabled() ? await scanSast(this.sastOptions()) : null; - return { deps, secrets, sast, incomplete: Boolean(deps?.incomplete) }; + const config = this.configEnabled() ? await scanConfig(this.configOptions()) : null; + // A config scan that could not find the web root is blind to every exposure + // check, which must not present as a clean result. + const incomplete = Boolean(deps?.incomplete) || Boolean(config?.webRootUnknown); + return { deps, secrets, sast, config, incomplete }; } private depsEnabled(): boolean { @@ -141,6 +152,24 @@ export default class CodeScannerModule implements ThreatCrushModule { return this.ctx.config.secrets_enabled !== false; } + private configEnabled(): boolean { + return this.ctx.config.config_enabled !== false; + } + + private configOptions(): ConfigOptions { + const cfg = this.ctx.config; + return { + paths: this.paths(), + maxDepth: (cfg.max_depth as number | undefined) ?? CONFIG_DEFAULTS.maxDepth, + webRoots: Array.isArray(cfg.config_web_roots) ? (cfg.config_web_roots as string[]) : [], + checkPermissions: cfg.config_check_permissions !== false, + checkContainers: cfg.config_check_containers !== false, + minReachability: + (cfg.config_min_reachability as ConfigOptions['minReachability'] | undefined) ?? + CONFIG_DEFAULTS.minReachability, + }; + } + private sastEnabled(): boolean { return this.ctx.config.sast_enabled !== false; } @@ -200,6 +229,7 @@ export default class CodeScannerModule implements ThreatCrushModule { if (result.secrets) this.reportSecrets(result.secrets, floor, maxAlerts, reported); if (result.sast) this.reportSast(result.sast, floor, maxAlerts, reported); + if (result.config) this.reportConfig(result.config, maxAlerts, reported); const deps = result.deps; if (!deps) { @@ -409,6 +439,55 @@ export default class CodeScannerModule implements ThreatCrushModule { ); } + /** + * Alert on misconfiguration. + * + * Reachability leads, because it is what sets the deadline: an exposed .env + * is a tonight problem and the same file outside a web root is not. + */ + private reportConfig(config: ConfigResult, maxAlerts: number, reported: Set): void { + for (const finding of config.findings + .filter((f) => !reported.has(`config:${f.checkId}:${f.subject}`)) + .slice(0, maxAlerts)) { + const headline = `code-scanner: ${finding.title} — ${finding.subject}`; + this.ctx.emit( + this.event('scan', finding.severity, headline, { + check: finding.checkId, + subject: finding.subject, + reachability: finding.reachability, + }), + ); + this.ctx.alert({ + title: headline, + severity: finding.severity, + body: [ + `${finding.subject} reachability: ${finding.reachability}`, + '', + finding.consequence, + '', + `Fix: ${finding.remediation}`, + ].join('\n'), + } satisfies Alert); + reported.add(`config:${finding.checkId}:${finding.subject}`); + } + + if (config.webRootUnknown) { + // Every exposure check depends on knowing what is served. Silence here + // means "did not look", not "nothing exposed". + this.ctx.logger.warn( + '[%s] config: no web root could be determined — exposure checks did not run', + this.name, + ); + } + + this.ctx.logger.info( + '[%s] config: %d finding(s) across %d path(s) inspected', + this.name, + config.findings.length, + config.filesInspected, + ); + } + private paths(): string[] { const configured = this.ctx.config.paths; if (Array.isArray(configured) && configured.length > 0) return configured as string[]; @@ -464,5 +543,6 @@ export function summarize(result: ScanResult): string { } export * from './deps/index.js'; +export * from './config/index.js'; export * from './sast/index.js'; export * from './secrets/index.js'; diff --git a/prd/0005-catch-the-misconfigurations-that-actually-get-servers-breached.md b/prd/0005-catch-the-misconfigurations-that-actually-get-servers-breached.md new file mode 100644 index 0000000..bc0c511 --- /dev/null +++ b/prd/0005-catch-the-misconfigurations-that-actually-get-servers-breached.md @@ -0,0 +1,215 @@ +--- +openprd: "0.2" +id: "0005" +title: "Catch the misconfigurations that actually get servers breached" +status: Draft +authors: + - anthony@profullstack.com +created: 2026-07-28 +updated: 2026-07-28 +repo: profullstack/threatcrush +discussion: +implementation: modules/code-scanner (config subsystem) +tags: code-scanner, config, misconfiguration, hardening, exposure, modules +supersedes: +superseded-by: +--- + +## Problem + +`PRD.md` lists "misconfigs" in `code-scanner`'s remit. It is the last of the +four subsystems and, on the evidence, the one that matters most for the users +this product targets. + +**Small-team servers are far more often breached by configuration than by code.** +The recurring causes are unglamorous and completely mechanical: a `.env` served +by a web root, a `.git` directory reachable over HTTP, a database bound to +`0.0.0.0` with a default password, debug mode left on in production, an +`APP_DEBUG` stack trace disclosing paths and credentials, a Docker socket +mounted into a container, `chmod 777` on a deploy directory, a `docker-compose` +file with `privileged: true`. None of these require an exploit. Each is a +default that was never changed or a temporary fix that became permanent. + +**This is the one subsystem where the daemon is unambiguously the right place +to look.** The other three have a CI counterpart doing overlapping work: +Dependabot for `deps`, gitleaks for `secrets`, CodeQL for `sast`. Configuration +is different — a `docker-compose.yml` in a repository tells you what someone +*intended*; the file on the box, plus what is actually listening, plus the +permissions on the directory, tell you what is *true*. Only an agent on the +host can see the second. + +**The failure mode to avoid is the compliance checklist.** Tools in this space +tend toward hundreds of CIS benchmark items, most irrelevant to a four-person +team on a single VPS, delivered as an undifferentiated report. That output does +not get read. This subsystem should check a small number of things that have +actually caused breaches, and rank them by whether they are *reachable* — an +exposed `.env` in a served directory is an emergency; the same file outside any +web root is a tidiness issue. + +## Goals + +- The handful of misconfigurations that most often lead to compromise are + detected on a running server, with the specific file or setting named. +- **Findings are ranked by reachability**, not by benchmark severity. What is + exposed to the network outranks what is merely untidy. +- Each finding states the **fix**, not just the rule — a config finding without + a remediation line is an interrupt. +- Low enough volume that the output is read. A first run should produce a short + list, not a compliance report. +- **Honest scope.** The subsystem states plainly that it is not a CIS benchmark + tool, so nobody mistakes a clean result for compliance certification. + +## Non-Goals + +- **Not a CIS/STIG benchmark implementation.** Hundreds of controls scored for + an audit is a different product with a different buyer. +- **Not compliance certification.** SOC2/HIPAA/PCI reporting is listed in + `PRD.md` as a separate marketplace module (`compliance-reporter`). +- **Not automatic remediation.** Changing a webserver config or a file mode on a + running production host, unattended, is how a monitoring agent causes the + outage it was bought to prevent. Report and explain. +- **Not secret detection or dependency scanning** — those are `secrets/` and + `deps/`. A `.env` file's *exposure* is this subsystem's finding; its + *contents* are `secrets/`'s. +- **Not full webserver config parsing.** nginx and Apache configuration + languages are large; targeted checks for known-dangerous directives are in + scope, a general parser is not. + +## Users + +- **Solo founders and small teams** deploying to a VPS by hand, who have never + run a hardening checklist and would not read a 300-item one. Primary persona. +- **Platform/ops engineers** verifying that a fleet still matches its intended + posture after months of manual changes. +- **Incident responders** enumerating what was reachable at the time of a + compromise. + +## Requirements + +### Detect — exposure + +- R1 [P0] **Sensitive files inside a web root.** `.env*`, `.git/`, `*.sql`, + `*.bak`, `*.log`, `.htpasswd`, `id_rsa`, `docker-compose.yml`, + `package-lock.json` under a directory that a running webserver serves. + Highest-value check in the subsystem: it is how a large share of small-site + breaches begin. +- R2 [P0] **Web root inference.** Read nginx/Apache config for `root`/ + `DocumentRoot`, and fall back to conventional locations (`/var/www`, + `/usr/share/nginx/html`, `/srv/www`). When the root cannot be determined, + say so — an unknown root must not read as "nothing exposed". +- R3 [P1] **Directory listing enabled** (`autoindex on`, `Options +Indexes`). + +### Detect — services and permissions + +- R4 [P0] **Dangerous file modes** on sensitive paths: world-writable + directories in a deploy path, `.env` or key files readable by others, + `authorized_keys` group-writable. +- R5 [P1] **Services bound to all interfaces** where a loopback binding is + almost certainly intended — databases, caches, admin ports. +- R6 [P1] **Default or empty credentials** in config files for common services + (`postgres/postgres`, `root` with no password, `admin/admin`). + +### Detect — application and container + +- R7 [P0] **Debug mode in production**: `NODE_ENV` not `production` alongside a + production marker, `DEBUG=true`, `APP_DEBUG=true`, Django `DEBUG = True`, + Rails `config.consider_all_requests_local = true`. +- R8 [P0] **Container escapes waiting to happen**: `privileged: true`, the + Docker socket bind-mounted into a container, `network_mode: host`, + `--cap-add=SYS_ADMIN`, a container running as root with a writable host mount. +- R9 [P1] **Permissive CORS**: `Access-Control-Allow-Origin: *` combined with + `Allow-Credentials: true` — individually defensible, together a + vulnerability. +- R10 [P1] **Missing security headers** on a served application (HSTS, CSP, + `X-Content-Type-Options`). Low severity by design; these are hardening rather + than holes. + +### Report + +- R11 [P0] **Every finding carries a remediation line.** "Move `.env` outside + `/var/www/html`, then rotate anything it contained" — not "CIS 3.4.1". +- R12 [P0] **Reachability ranking**: `exposed` (network-reachable) outranks + `local` (needs host access) outranks `hardening` (defence in depth). +- R13 [P1] Dedupe per (check, path) so a long-standing misconfiguration alerts + on discovery and on change, not every scan. +- R14 [P1] `threatcrush code-scanner config audit` one-shot, exit non-zero above + a threshold. + +## UX Notes + +Ships as the `config` subsystem of `code-scanner`, configured under `config_*`. + +```bash +threatcrush code-scanner config audit +threatcrush code-scanner config audit --json +threatcrush code-scanner config checks # every check, with what it looks at +``` + +```toml +[code-scanner.config] +enabled = true +web_roots = [] # empty: infer from webserver config, then conventions +check_permissions = true +check_containers = true +min_reachability = "local" # exposed | local | hardening +``` + +``` +[CRITICAL] code-scanner · config · environment file inside a served directory + + /var/www/html/.env reachability: exposed + nginx serves /var/www/html (site: app.example.com) + + Anyone who requests /.env receives this file, including whatever + credentials it holds. + + Fix: move it above the web root, or add a location block denying dotfiles. + Then rotate every credential it contained — assume it was read. +``` + +Design constraints: + +- **Lead with reachability**, because that is what determines whether this is a + tonight problem or a Friday problem. +- **State the fix and its follow-through.** Moving an exposed `.env` is not + sufficient; the credentials must be rotated, and operators routinely miss the + second half. +- **Keep the check list short and defensible.** Every check should be traceable + to a way servers actually get breached. + +## Success Metrics + +- **Zero findings** on a correctly configured host: app outside the web root, + no debug flags, sane permissions. The acceptance test for staying enabled. +- **100% detection** of a fixture host carrying one instance of every P0 check. +- Every finding in the fixture set has a remediation string; asserted by test, + because a rule added without one is the beginning of the checklist failure + mode. +- **Web-root inference correctness** ≥95% on a corpus of real nginx/Apache + configs; when inference fails the subsystem reports `unknown` rather than + assuming nothing is exposed. +- Median ≤3 findings per host on a maintained server. + +## Risks & Open Questions + +- **Web-root inference is the weakest link.** Every check in R1 depends on + knowing what is served, and real nginx configs use includes, variables, + regex locations and per-vhost roots. Getting this wrong in the safe direction + (reporting `unknown`) costs a missed finding; getting it wrong in the unsafe + direction (assuming a root that is not served) produces confident false + alarms about files nobody can reach. **Open:** should the subsystem verify by + actually requesting the path from localhost, rather than inferring? That is + far more accurate and means the security agent making HTTP requests to its own + host — which needs thought before it is a default. +- **Permission checks are noisy on shared hosts** where group-writable + directories are intentional. Ranking, not suppression, plus a path allowlist. +- **Container checks need Docker access**, which is itself a privilege boundary. + Reading `docker-compose.yml` from disk is safe; querying the daemon is not + equivalent and should stay opt-in. +- **Scope creep toward the checklist is the standing risk.** Every new check is + individually defensible, and 200 of them make the output unreadable — the + outcome this PRD exists to avoid. **Open:** should the subsystem cap itself, + requiring a check to be retired when one is added? +- **A clean result must not read as compliance.** Operators will screenshot this + for customers. The output should state that it is a targeted check set, not an + audit. diff --git a/prd/README.md b/prd/README.md index 438870a..c110add 100644 --- a/prd/README.md +++ b/prd/README.md @@ -17,3 +17,4 @@ these numbered PRDs cover individual changes to it. | [0002](./0002-detect-vulnerable-and-malicious-dependencies-on-running-servers.md) | Detect vulnerable and malicious dependencies on running servers | Draft | code-scanner, deps, supply-chain, sbom, cve, osv, install-scripts, drift, modules | | [0003](./0003-detect-hardcoded-secrets-before-they-are-committed-or-served.md) | Detect hardcoded secrets before they are committed or served | Draft | code-scanner, secrets, credentials, entropy, redaction, modules | | [0004](./0004-find-dangerous-code-patterns-without-pretending-to-be-a-compiler.md) | Find dangerous code patterns without pretending to be a compiler | Draft | code-scanner, sast, static-analysis, injection, taint, modules | +| [0005](./0005-catch-the-misconfigurations-that-actually-get-servers-breached.md) | Catch the misconfigurations that actually get servers breached | Draft | code-scanner, config, misconfiguration, hardening, exposure, modules |