feat(health): group security findings by rule instead of one row per hit #5437
1 changed file+109−12
Modifiedsrc/routes/health.tsx+109−12View fileUnifiedSplit
@@ -39,6 +39,8 @@ health.get("/:owner/:repo/health", async (c) => {
3939 detectCIConfig(owner, repo, ref),
4040 ]);
4141
42 const securityGroups = groupSecurityIssues(report.breakdown.security.issues);
43
4244 const gradeColor =
4345 report.grade === "A+" || report.grade === "A"
4446 ? "var(--green)"
@@ -133,25 +135,57 @@ health.get("/:owner/:repo/health", async (c) => {
133135 />
134136 </div>
135137
136 {report.breakdown.security.issues.length > 0 && (
138 {securityGroups.length > 0 && (
137139 <div style="margin-top: 32px">
138 <h3 style="margin-bottom: 12px">Security Issues</h3>
140 <h3 style="margin-bottom: 12px">
141 Security Issues
142 <span style="font-size: 13px; color: var(--text-muted); font-weight: 400; margin-left: 8px">
143 {report.breakdown.security.issues.length} finding
144 {report.breakdown.security.issues.length !== 1 ? "s" : ""} ·{" "}
145 {securityGroups.length} distinct rule
146 {securityGroups.length !== 1 ? "s" : ""}
147 </span>
148 </h3>
139149 <div class="issue-list">
140 {report.breakdown.security.issues.map((issue) => (
141 <div class="issue-item">
142 <div style="display: flex; gap: 8px; align-items: center">
143 <SeverityBadge severity={issue.severity} />
144 <div>
150 {securityGroups.map((g) => (
151 <details class="issue-item" style="display: block">
152 <summary style="display: flex; gap: 8px; align-items: center; cursor: pointer; list-style: none">
153 <SeverityBadge severity={g.severity} />
154 <div style="flex: 1; min-width: 0">
145155 <div style="font-size: 14px; font-weight: 500">
146 {issue.message}
156 {g.message}
157 <span style="color: var(--text-muted); font-weight: 400">
158 {" "}
159 — {g.count} occurrence{g.count !== 1 ? "s" : ""} in{" "}
160 {g.files.length} file{g.files.length !== 1 ? "s" : ""}
161 </span>
147162 </div>
148 <div style="font-size: 12px; color: var(--text-muted); font-family: var(--font-mono)">
149 {issue.file}
150 {issue.line ? `:${issue.line}` : ""} — {issue.rule}
163 <div style="font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis">
164 {g.rule} ·{" "}
165 {g.files
166 .slice(0, 3)
167 .map((f) => `${shortFileName(f.file)} (${f.count})`)
168 .join(", ")}
169 {g.files.length > 3
170 ? `, +${g.files.length - 3} more`
171 : ""}
151172 </div>
152173 </div>
174 <span style="font-size: 12px; color: var(--text-muted); flex-shrink: 0">
175 details
176 </span>
177 </summary>
178 <div style="margin-top: 10px; padding-left: 28px">
179 {g.files.map((f) => (
180 <div style="font-size: 12px; font-family: var(--font-mono); padding: 3px 0; color: var(--text-muted)">
181 <span style="color: var(--text)">{f.file}</span>
182 {" — "}
183 {f.count === 1 ? "line " : "lines "}
184 {f.lines.join(", ")}
185 </div>
186 ))}
153187 </div>
154 </div>
188 </details>
155189 ))}
156190 </div>
157191 </div>
@@ -226,6 +260,69 @@ const HealthNav = ({
226260 </div>
227261);
228262
263interface SecurityGroup {
264 rule: string;
265 message: string;
266 severity: SecurityIssue["severity"];
267 count: number;
268 files: { file: string; count: number; lines: (number | string)[] }[];
269}
270
271const SEVERITY_ORDER: Record<string, number> = {
272 critical: 0,
273 high: 1,
274 medium: 2,
275 low: 3,
276 info: 4,
277};
278
279/**
280 * 66 raw findings render as 66 identical rows — a wall the eye skips
281 * entirely. Grouped by rule with per-file rollups, the same data reads as
282 * "no-inner-html: 66 occurrences across 5 files, worst is attorney.js"
283 * with the full file:line list one click away.
284 */
285function groupSecurityIssues(issues: SecurityIssue[]): SecurityGroup[] {
286 const byRule = new Map<string, SecurityGroup & { fileMap: Map<string, (number | string)[]> }>();
287 for (const issue of issues) {
288 const key = `${issue.rule}|${issue.severity}`;
289 let g = byRule.get(key);
290 if (!g) {
291 g = {
292 rule: issue.rule,
293 message: issue.message,
294 severity: issue.severity,
295 count: 0,
296 files: [],
297 fileMap: new Map(),
298 };
299 byRule.set(key, g);
300 }
301 g.count++;
302 const lines = g.fileMap.get(issue.file) ?? [];
303 lines.push(issue.line ?? "?");
304 g.fileMap.set(issue.file, lines);
305 }
306 return [...byRule.values()]
307 .map(({ fileMap, ...g }) => ({
308 ...g,
309 files: [...fileMap.entries()]
310 .map(([file, lines]) => ({ file, count: lines.length, lines }))
311 .sort((a, b) => b.count - a.count),
312 }))
313 .sort(
314 (a, b) =>
315 (SEVERITY_ORDER[a.severity] ?? 9) - (SEVERITY_ORDER[b.severity] ?? 9) ||
316 b.count - a.count
317 );
318}
319
320/** frontend/js/attorney.js → attorney.js — the path detail lives in the expanded list. */
321function shortFileName(path: string): string {
322 const parts = path.split("/");
323 return parts[parts.length - 1] || path;
324}
325
229326const ScoreCard = ({
230327 title,
231328 score,
232329
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts