// --------------------------------------------------------------------------- // caduceus — security lens (v0.6.0 T05) // // Static-analysis lens for security-relevant patterns. // // P0 — MUST/SHALL-level CON-NNN in constitution.md lacks CWE field // P1 — secret-like keyword in tasks.md or design.md (per occurrence) // (password / api_key / api-key / apikey / token / secret) // P1 — risky shell pattern in tasks.md (per occurrence) // (curl|sh / wget|sh / sudo) // // Algorithm: design.md §6.3. Pure-TS; no network, no process, no LLM. // // Findings are capped at 20 per REQ-005 with `truncated: true` set on // the LensFindings summary when truncated. // --------------------------------------------------------------------------- import { readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import type { Lens, LensFinding } from "../review-lens-framework.ts"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const PRINCIPLE_HEADER = /^###\s+(CON-\d+):/m; const LEVEL_REGEX = /\*\*Level\*\*:\s*(\S+(?:\s+NOT)?)/; const CWE_FIELD_RE = /\*\*CWE\*\*:/; const SECRET_RE = /\b(?:password|api[_-]?key|token|secret)\b/gi; const RISKY_SHELL_RE = /curl\s*\|\s*sh|wget\s*\|\s*sh|sudo\s/gi; const FILES_FOR_SECRET_SCAN: ReadonlyArray = Object.freeze([ "tasks.md", "design.md", ]); const FILES_FOR_SHELL_SCAN: ReadonlyArray = Object.freeze([ "tasks.md", ]); const FINDING_CAP = 20; /** v0.6.3 CON-015 / REQ-007: detection keywords. */ export const SECURITY_SELF_MATCH_PATTERNS: ReadonlyArray = Object.freeze([ /\bpassword\b/i, /\bapi[_-]?key\b/i, /\btoken\b/i, /\bsecret\b/i, /curl\s*\|\s*sh/i, /wget\s*\|\s*sh/i, /sudo\s+/i, ]); /** v0.6.3 CON-010 / REQ-002: files whose content is excluded from * self-match (defense in depth — used by `isSelfExcludedFile`). */ export const SECURITY_SELF_EXCLUDE_CONTEXT: ReadonlyArray = Object.freeze([ "lib/lens/security.ts", "STATUS.md", "docs/RESEARCH.md", ]); // --------------------------------------------------------------------------- // helpers // --------------------------------------------------------------------------- function readIfExists(path: string): string | null { if (!existsSync(path)) return null; try { return readFileSync(path, "utf8"); } catch { return null; } } type Principle = { id: string; body: string }; function parsePrinciples(constitutionText: string): Principle[] { if (!constitutionText.trim()) return []; const parts = constitutionText.split(PRINCIPLE_HEADER); const principles: Principle[] = []; // parts[0] is front matter; then alternates [id, body, id, body, ...] for (let i = 1; i < parts.length; i += 2) { principles.push({ id: parts[i], body: parts[i + 1] ?? "" }); } return principles; } function isMustOrShall(level: string): boolean { return level.startsWith("MUST") || level.startsWith("SHALL"); } function emitKeywordFindings( text: string, file: string, regex: RegExp, keywordHint: string, findings: LensFinding[], ): void { const lines = text.split("\n"); for (let i = 0; i < lines.length; i++) { const matches = [...lines[i].matchAll(regex)]; for (const m of matches) { // v0.6.3 calibration (CON-010 + CON-015): skip self-matched // files and prose-context matches. if (isSelfExcludedFile(file)) continue; if (isProseContext({ line: i + 1 }, text)) continue; findings.push({ severity: "P1", summary: `Security-sensitive keyword '${m[0]}' in ${file} line ${i + 1} ` + `(category: ${keywordHint})`, location: file, recommendation: "Avoid embedding secrets in MD; use a secrets manager " + "or environment variable loader.", line: i + 1, }); } } } /** v0.6.3 CON-010: file-path self-exclusion. */ function isSelfExcludedFile(file: string): boolean { return SECURITY_SELF_EXCLUDE_CONTEXT.some((p) => file.endsWith(p)); } /** v0.6.3 CON-015 / REQ-007: returns true when the matched line is * inside a documentation context (fenced code block, blockquote, * or table-row echo) rather than an endorsement of a secret. * * Three contexts qualify: * - fenced code block (``` or ~~~) — scanned line-by-line, with * enter/exit toggled on each fence. * - blockquote-prefixed line — line starts with `>`. * - table-row echo — line contains `|`, has multiple cells, and * the matched keyword is repeated in *another* cell of the * same row (so this is a row of documentation labels, not a * row that documents actual secrets). * * Bare-prose matches (the default) continue to fire. */ export function isProseContext( match: { line: number }, fileText: string, ): boolean { const lines = fileText.split("\n"); const idx = match.line - 1; if (idx < 0 || idx >= lines.length) return false; const line = lines[idx]; // 1) Fenced code block: scan up from match.line to find the most // recent unclosed fence. The fence pattern matches the v0.6.0 // `lib/lens/security.ts` test fixture and standard MD fences. let inFence = false; for (let i = 0; i <= idx; i++) { if (/^\s*(```|~~~)/.test(lines[i])) inFence = !inFence; } if (inFence) return true; // 2) Blockquote-prefixed line. if (/^\s*>/.test(line)) return true; // 3) Table-row echo: line is a Markdown table row, and the // matched keyword is either (a) in 2+ cells of the same row, // or (b) in a single cell that itself is an echo header (e.g., // ends with `?`, or is a yes/no/Y/N marker). Bare-config // rows like `| password | hunter2 |` continue to fire. if (/\|/.test(line)) { const cells = line.split("|").map((c) => c.trim()).filter((c) => c.length > 0); if (cells.length >= 2) { const SECRETS = /(password|api[_-]?key|token|secret)/i; const cellHits = cells.filter((c) => SECRETS.test(c)).length; if (cellHits >= 2) return true; // Echo-header: single-hit row is documentation when at least // one cell in the same row is a header marker. const ECHO_MARKERS = /\?$|^no$|^yes$|^maybe$|^y$|^n$/i; const hasEchoHeader = cells.some((c) => ECHO_MARKERS.test(c)); if (cellHits >= 1 && hasEchoHeader) return true; } } // 4) Inline code span: a backtick-wrapped keyword like // `password` or `dirty-secret` is documentation, not // endorsement. Bare-prose matches without backticks continue // to fire. This catches the `dirty-secret` test-fixture // description pattern in v0.6.0 archive's tasks.md/design.md // (without it, those lines are over-eager P1 fires). const INLINE_BACKTICK_RE = /\x60[^\x60]*(?:password|api[_-]?key|token|secret)[^\x60]*\x60/i; if (INLINE_BACKTICK_RE.test(line)) return true; return false; } // --------------------------------------------------------------------------- // Public API: securityLens // --------------------------------------------------------------------------- export const securityLens: Lens = { id: "security", displayName: "Security", description: "CWE mapping for MUST-level principles (P0); secret keywords " + "(password / api_key / token / secret) in tasks or design (P1); " + "risky shell patterns curl|sh / wget|sh / sudo (P1).", async run(changeDir) { const t0 = Date.now(); const findings: LensFinding[] = []; // ----- P0: MUST/SHALL-level CON-NNN lacks CWE field const constitutionText = readIfExists(join(changeDir, "constitution.md")) ?? ""; const principles = parsePrinciples(constitutionText); for (const p of principles) { const levelMatch = p.body.match(LEVEL_REGEX); const level = levelMatch ? levelMatch[1] : ""; if (isMustOrShall(level) && !CWE_FIELD_RE.test(p.body)) { findings.push({ severity: "P0", summary: `${p.id} (Level: '${level || "(empty)"}') lacks CWE field`, location: "constitution.md", recommendation: "MUST/SHALL-level principles MUST provide a CWE-NNN " + "reference or 'CWE: N/A' explicitly.", }); } } // ----- P1: secret keywords in tasks.md and design.md for (const file of FILES_FOR_SECRET_SCAN) { const content = readIfExists(join(changeDir, file)) ?? ""; if (content) { emitKeywordFindings( content, file, SECRET_RE, "secret-like keyword", findings, ); } } // ----- P1: risky shell patterns in tasks.md for (const file of FILES_FOR_SHELL_SCAN) { const content = readIfExists(join(changeDir, file)) ?? ""; if (content) { emitKeywordFindings( content, file, RISKY_SHELL_RE, "risky shell pattern", findings, ); } } // ----- Cap findings at 20 (REQ-005); set truncated flag let truncated: boolean | undefined; if (findings.length > FINDING_CAP) { findings.length = FINDING_CAP; truncated = true; } return { lensId: "security", findings: Object.freeze(findings), durationMs: Date.now() - t0, truncated, }; }, };