| 1 | import { truncate, escapeMd } from './util.js'; |
| 2 | import { analyzeTree, classifySecuritySurface, isRiskyCommand, mentionsTestSkip } from './analyze.js'; |
| 3 | import { detectHallucinations } from './hallucinate.js'; |
| 4 | import { REPO_URL } from './config.js'; |
| 5 | |
| 6 | const SURFACE_LABELS = { |
| 7 | auth: 'auth', |
| 8 | secrets: 'secrets', |
| 9 | 'access-control': 'access control', |
| 10 | crypto: 'crypto', |
| 11 | 'dependency-config': 'dependency config', |
| 12 | ci: 'CI', |
| 13 | deployment: 'deployment', |
| 14 | tests: 'tests', |
| 15 | }; |
| 16 | const SURFACE_ORDER = ['auth', 'secrets', 'access-control', 'crypto', 'dependency-config', 'ci', 'deployment', 'tests']; |
| 17 | const EVIDENCE_CAP = 200; |
| 18 | const tierRank = { verified: 4, high: 3, confirmed: 2, inferred: 1 }; |
| 19 | |
| 20 | function collectSurfaceTouches(tree) { |
| 21 | const bySurface = new Map(); |
| 22 | for (const node of tree.nodes) { |
| 23 | if (node.status === 'abandoned') continue; |
| 24 | for (const a of node.actions || []) { |
| 25 | const surface = classifySecuritySurface(a.file); |
| 26 | if (!surface) continue; |
| 27 | if (!bySurface.has(surface)) bySurface.set(surface, []); |
| 28 | bySurface.get(surface).push({ file: a.file, nodeId: node.id, model: a.model || node.model || null }); |
| 29 | } |
| 30 | } |
| 31 | return bySurface; |
| 32 | } |
| 33 | |
| 34 | function collectTestSkips(tree) { |
| 35 | const out = []; |
| 36 | for (const node of tree.nodes) { |
| 37 | if (node.status === 'abandoned') continue; |
| 38 | if (mentionsTestSkip(node.text)) { |
| 39 | out.push({ nodeId: node.id, evidence: truncate(node.text, EVIDENCE_CAP) }); |
| 40 | continue; |
| 41 | } |
| 42 | for (const a of node.actions || []) { |
| 43 | const body = a.input || a.command || ''; |
| 44 | if (mentionsTestSkip(body)) { |
| 45 | out.push({ nodeId: node.id, evidence: truncate(body, EVIDENCE_CAP) }); |
| 46 | break; |
| 47 | } |
| 48 | } |
| 49 | } |
| 50 | return out; |
| 51 | } |
| 52 | |
| 53 | function collectRiskyCommands(tree) { |
| 54 | const out = []; |
| 55 | for (const node of tree.nodes) { |
| 56 | if (node.status === 'abandoned') continue; |
| 57 | for (const a of node.actions || []) { |
| 58 | if (isRiskyCommand(a.command)) { |
| 59 | out.push({ nodeId: node.id, command: truncate(a.command, EVIDENCE_CAP), model: a.model || node.model || null }); |
| 60 | } |
| 61 | } |
| 62 | } |
| 63 | return out; |
| 64 | } |
| 65 | |
| 66 | function collectCorrections(tree) { |
| 67 | return tree.nodes.filter((n) => n.status !== 'abandoned' && n.kind === 'correction'); |
| 68 | } |
| 69 | |
| 70 | export function buildSecurityFindings(tree, projectDir, opts = {}) { |
| 71 | const analysis = analyzeTree(tree); |
| 72 | const surfaces = collectSurfaceTouches(tree); |
| 73 | const testSkips = collectTestSkips(tree); |
| 74 | const riskyCommands = collectRiskyCommands(tree); |
| 75 | const hallucinationResult = projectDir |
| 76 | ? detectHallucinations(tree, projectDir, opts) |
| 77 | : { hallucinations: [], verifiedAgainstWorkingTree: false }; |
| 78 | const securitySignals = analysis.failures |
| 79 | .filter((f) => f.type === 'security_or_privacy_risk') |
| 80 | .sort((a, b) => (tierRank[b.tier] || 0) - (tierRank[a.tier] || 0)); |
| 81 | const corrections = collectCorrections(tree); |
| 82 | |
| 83 | return { analysis, surfaces, testSkips, riskyCommands, hallucinationResult, securitySignals, corrections }; |
| 84 | } |
| 85 | |
| 86 | export function hasSecuritySignal(tree, projectDir, opts = {}) { |
| 87 | const f = buildSecurityFindings(tree, projectDir, opts); |
| 88 | return ( |
| 89 | f.surfaces.size > 0 || |
| 90 | f.testSkips.length > 0 || |
| 91 | f.riskyCommands.length > 0 || |
| 92 | f.securitySignals.length > 0 || |
| 93 | f.hallucinationResult.hallucinations.length > 0 |
| 94 | ); |
| 95 | } |
| 96 | |
| 97 | export function renderSecurityReport(tree, projectDir, opts = {}) { |
| 98 | const projectName = opts.projectName || 'project'; |
| 99 | const generatedAt = opts.generatedAt || new Date().toISOString(); |
| 100 | const f = buildSecurityFindings(tree, projectDir, opts); |
| 101 | const lines = []; |
| 102 | |
| 103 | lines.push(`# TreeTrace Security Report - ${escapeMd(projectName)}`); |
| 104 | lines.push(''); |
| 105 | lines.push(`Generated: ${generatedAt}`); |
| 106 | lines.push(''); |
| 107 | |
| 108 | const anySignal = |
| 109 | f.surfaces.size || f.testSkips.length || f.riskyCommands.length || f.securitySignals.length || f.hallucinationResult.hallucinations.length; |
| 110 | if (!anySignal) { |
| 111 | lines.push('None detected.'); |
| 112 | lines.push(''); |
| 113 | footer(lines, opts); |
| 114 | return lines.join('\n'); |
| 115 | } |
| 116 | |
| 117 | lines.push('## Surfaces touched'); |
| 118 | lines.push(''); |
| 119 | if (f.surfaces.size) { |
| 120 | for (const surface of SURFACE_ORDER) { |
| 121 | const touches = f.surfaces.get(surface); |
| 122 | if (!touches || !touches.length) continue; |
| 123 | const files = [...new Set(touches.map((t) => t.file))].slice(0, 8); |
| 124 | const nodeIds = [...new Set(touches.map((t) => t.nodeId).filter(Boolean))].slice(0, 8); |
| 125 | const ids = nodeIds.length ? ` [${nodeIds.join(', ')}]` : ''; |
| 126 | lines.push(`- ${SURFACE_LABELS[surface]}: ${files.map((x) => `\`${escapeMd(truncate(x, 100))}\``).join(', ')}${ids}`); |
| 127 | } |
| 128 | } else { |
| 129 | lines.push('None detected.'); |
| 130 | } |
| 131 | if (f.securitySignals.length) { |
| 132 | lines.push(''); |
| 133 | lines.push('## Security signals (highest tier first)'); |
| 134 | lines.push(''); |
| 135 | for (const s of f.securitySignals.slice(0, 12)) { |
| 136 | const tag = s.tier === 'inferred' ? 'stated intent' : s.tier; |
| 137 | const nodeId = s.firstSeenNodeId ? ` [${s.firstSeenNodeId}]` : ''; |
| 138 | lines.push(`- (${tag})${nodeId} ${escapeMd(truncate(s.evidence, EVIDENCE_CAP))}${s.model ? ` (${s.model})` : ''}`); |
| 139 | } |
| 140 | } |
| 141 | lines.push(''); |
| 142 | |
| 143 | lines.push('## Test skips'); |
| 144 | lines.push(''); |
| 145 | if (f.testSkips.length) { |
| 146 | for (const t of f.testSkips.slice(0, 8)) lines.push(`- (${t.nodeId}) ${escapeMd(t.evidence)}`); |
| 147 | } else { |
| 148 | lines.push('None detected.'); |
| 149 | } |
| 150 | lines.push(''); |
| 151 | |
| 152 | lines.push('## Risky shell commands'); |
| 153 | lines.push(''); |
| 154 | if (f.riskyCommands.length) { |
| 155 | for (const r of f.riskyCommands.slice(0, 8)) lines.push(`- (${r.nodeId}) \`${escapeMd(r.command)}\`${r.model ? ` (${r.model})` : ''}`); |
| 156 | } else { |
| 157 | lines.push('None detected.'); |
| 158 | } |
| 159 | lines.push(''); |
| 160 | |
| 161 | lines.push('## Hallucinated references'); |
| 162 | lines.push(''); |
| 163 | if (!f.hallucinationResult.verifiedAgainstWorkingTree) { |
| 164 | lines.push('Working tree not available for verification.'); |
| 165 | } else if (f.hallucinationResult.hallucinations.length) { |
| 166 | for (const h of f.hallucinationResult.hallucinations.slice(0, 12)) { |
| 167 | const nodeId = h.nodeId ? ` [${h.nodeId}]` : ''; |
| 168 | lines.push(`- (${h.category})${nodeId} ${escapeMd(truncate(h.evidence, EVIDENCE_CAP))}`); |
| 169 | } |
| 170 | } else { |
| 171 | lines.push('None detected.'); |
| 172 | } |
| 173 | lines.push(''); |
| 174 | |
| 175 | lines.push('## Corrections to promote'); |
| 176 | lines.push(''); |
| 177 | const securityChains = f.analysis.correctionChains.filter((c) => c.failureType === 'security_or_privacy_risk'); |
| 178 | if (securityChains.length || f.corrections.length) { |
| 179 | const seen = new Set(); |
| 180 | for (const chain of securityChains.slice(0, 6)) { |
| 181 | const corr = tree.nodes.find((n) => n.id === chain.correctionNodeId); |
| 182 | if (corr && !seen.has(corr.id)) { |
| 183 | seen.add(corr.id); |
| 184 | lines.push(`- (${corr.id}) ${escapeMd(truncate(corr.text.replace(/\s+/g, ' '), 300))}`); |
| 185 | } |
| 186 | } |
| 187 | for (const corr of f.corrections.slice(-6)) { |
| 188 | if (seen.has(corr.id)) continue; |
| 189 | seen.add(corr.id); |
| 190 | lines.push(`- (${corr.id}) ${escapeMd(truncate(corr.text.replace(/\s+/g, ' '), 300))}`); |
| 191 | } |
| 192 | lines.push(''); |
| 193 | lines.push('→ Eval candidates: .treetrace/evals.jsonl · .treetrace/hallucinations.json'); |
| 194 | } else { |
| 195 | lines.push('None. If a security touch above was intentional, log the rationale.'); |
| 196 | } |
| 197 | lines.push(''); |
| 198 | |
| 199 | footer(lines, opts); |
| 200 | return lines.join('\n'); |
| 201 | } |
| 202 | |
| 203 | function footer(lines, opts) { |
| 204 | lines.push('---'); |
| 205 | lines.push(''); |
| 206 | lines.push(`Generated by [treetrace](${REPO_URL})${opts.version ? ` v${opts.version}` : ''}.`); |
| 207 | lines.push(''); |
| 208 | } |