Zion Boggan
repos/TreeTrace/src/security-report.js
zionboggan.com ↗
208 lines · javascript
History for this file →
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
}