dependencyiq 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,642 @@
1
+ /**
2
+ * Static HTML dashboard generator.
3
+ *
4
+ * GitLab's direct equivalent of GitHub Pages is GitLab Pages — a static
5
+ * site published from a `public/` directory by a CI job named `pages`,
6
+ * no extra hosting service needed. Since there's no backend here (by
7
+ * design — see README's honesty boundaries), this renders one
8
+ * self-contained HTML file from the same data the CLI already produces,
9
+ * with inline CSS and zero external JS/fonts/images — nothing to fetch,
10
+ * nothing that can go stale or 404. No live interactivity, no server —
11
+ * exactly what GitLab Pages is for.
12
+ */
13
+
14
+ const { buildExecutiveSummary } = require('./executiveSummary');
15
+
16
+ function escapeHtml(str) {
17
+ return String(str)
18
+ .replace(/&/g, '&')
19
+ .replace(/</g, '&lt;')
20
+ .replace(/>/g, '&gt;')
21
+ .replace(/"/g, '&quot;');
22
+ }
23
+
24
+ function priorityColor(priority) {
25
+ return {
26
+ UNUSED: '#8b5cf6',
27
+ URGENT: '#dc2626',
28
+ HIGH: '#ea580c',
29
+ MEDIUM: '#ca8a04',
30
+ LOW: '#65a30d',
31
+ }[priority] || '#6b7280';
32
+ }
33
+
34
+ /**
35
+ * Build a GitLab "new issue" URL with the title/description prefilled —
36
+ * a real, no-backend way to make a static page actionable: GitLab itself
37
+ * renders the New Issue form from these query params, no API call or
38
+ * server needed on our side.
39
+ */
40
+ function buildIssueUrl(v, meta, totalRiskScore) {
41
+ if (!meta.serverUrl || !meta.projectPath) return null;
42
+ const isRemoval = v.recommendation === 'remove';
43
+ const contributionPercent = totalRiskScore > 0 ? Math.round(((v.riskScore?.score || 0) / totalRiskScore) * 100) : null;
44
+ const contributionLine = contributionPercent !== null ? `\n- Share of total flagged risk: ${contributionPercent}% of ${totalRiskScore} points across all findings` : '';
45
+ const title = isRemoval
46
+ ? `Remove unused dependency: ${v.package}`
47
+ : `Security: upgrade ${v.package} ${v.currentVersion} → ${v.fixedVersion} (${v.id || v.vulnerability || 'CVE'})`;
48
+ const description = isRemoval
49
+ ? `GitLab Orbit found zero files importing **${v.package}** anywhere in this project. Recommend removing it instead of patching ${v.id || 'this advisory'}.\n\nRun: \`node src/agent.js analyze . --fix --create-pr\` (when this is the top-ranked finding) to remove it automatically, or delete the dependency manually.`
50
+ : `**${v.package}** (${v.ecosystem}) ${v.currentVersion} → ${v.fixedVersion}\n- Severity: ${v.severity || '?'} (CVSS ${v.cvss ?? '?'})\n- Risk score: ${v.riskScore?.score ?? '?'}/100 (${v.riskScore?.priority || 'unscored'})\n- Exposure data: ${v.riskScore?.exposureDataSource === 'orbit' ? 'GitLab Orbit blast-radius query' : 'unavailable — Orbit not reachable for this run'}${contributionLine}\n\nRun: \`node src/agent.js analyze . --fix --create-pr\` (when this is the top-ranked finding) to upgrade and open a merge request automatically, or bump the dependency manually to ${v.fixedVersion}.`;
51
+ const params = new URLSearchParams({ 'issue[title]': title, 'issue[description]': description });
52
+ return `${meta.serverUrl}/${meta.projectPath}/-/issues/new?${params.toString()}`;
53
+ }
54
+
55
+ /**
56
+ * Direct-vs-transitive badge, with the real resolved chain when known
57
+ * (src/scanners/dependencyTreeBuilder.js) — never a guessed chain.
58
+ */
59
+ function renderDependencyBadge(v) {
60
+ const info = v.dependencyChain;
61
+ if (!info || !info.available) {
62
+ return `<span class="dep-badge dep-unknown">unknown${info?.reason ? ` (${escapeHtml(info.reason)})` : ''}</span>`;
63
+ }
64
+ if (info.isDirect) {
65
+ return '<span class="dep-badge dep-direct">direct</span>';
66
+ }
67
+ if (info.chain) {
68
+ return `<span class="dep-badge dep-transitive">transitive</span> <code class="dep-chain">${escapeHtml(info.chain.join(' → '))}</code>`;
69
+ }
70
+ return '<span class="dep-badge dep-unknown">transitive (chain unknown)</span>';
71
+ }
72
+
73
+ const CATEGORY_LABEL = { 'public-api': '🌐 Public API', internal: '🔒 Internal', test: '🧪 Test-only' };
74
+ const CATEGORY_ORDER = ['public-api', 'internal', 'test'];
75
+
76
+ /**
77
+ * Group the real evidence files by exposure category (src/blastRadius.js
78
+ * classifies each one individually) instead of one flat list — public-
79
+ * API exposure is the whole reason this finding might outrank a
80
+ * higher-CVSS one with only test-only usage, so the grouping should be
81
+ * visible, not just folded into a single aggregate number.
82
+ */
83
+ function renderEvidenceGroups(files, packageName) {
84
+ if (files.length === 0) return '';
85
+ const groups = { 'public-api': [], internal: [], test: [] };
86
+ for (const f of files) {
87
+ (groups[f.category] || groups.internal).push(f);
88
+ }
89
+
90
+ const sections = CATEGORY_ORDER
91
+ .filter(cat => groups[cat].length > 0)
92
+ .map(cat => {
93
+ const items = groups[cat].slice(0, 6).map(f => `<li>${escapeHtml(f.path || f)}</li>`).join('');
94
+ const more = groups[cat].length > 6 ? `<div class="trail-more">+${groups[cat].length - 6} more</div>` : '';
95
+ return `<div class="evidence-group"><strong>${CATEGORY_LABEL[cat]} (${groups[cat].length})</strong><ul>${items}</ul>${more}</div>`;
96
+ })
97
+ .join('');
98
+
99
+ return `<div class="trail-evidence"><strong>Evidence — files importing ${escapeHtml(packageName)}, grouped by exposure:</strong>${sections}</div>`;
100
+ }
101
+
102
+ /**
103
+ * "Show your work" panel — the same risk-score formula this project
104
+ * documents everywhere (README/AGENTS.md), broken into its actual
105
+ * weighted contributions for this one finding, plus the real evidence
106
+ * (the file list Orbit returned, grouped by exposure) instead of just a
107
+ * final number. Also states this finding's share of the project's total
108
+ * flagged risk, so "fix this one" has a concrete, quantified payoff
109
+ * instead of an abstract score.
110
+ */
111
+ function fmt1(n) {
112
+ // One decimal, but drop a trailing ".0" so whole numbers read cleanly.
113
+ return Number.isInteger(n) ? String(n) : n.toFixed(1);
114
+ }
115
+
116
+ function renderDecisionTrail(v, totalRiskScore) {
117
+ const b = v.riskScore?.breakdown;
118
+ if (!b) return '';
119
+ const files = v.affectedFiles || [];
120
+ // Prefer the exact point contributions the calculator computed; these
121
+ // sum to the final score by construction, so the trail can never claim
122
+ // a breakdown that doesn't add up. (Older breakdowns without
123
+ // `contributions` fall back to deriving from the normalized values.)
124
+ const c = b.contributions || {
125
+ cvss: (b.cvss || 0) * 45,
126
+ exposure: (b.exposure || 0) * 35,
127
+ usage: (b.usage || 0) * 10,
128
+ testCoverageDiscount: (b.testCoverage || 0) * 10,
129
+ };
130
+ const exposureNote = v.riskScore.exposureDataSource === 'orbit'
131
+ ? 'real GitLab Orbit blast-radius query'
132
+ : 'unavailable — Orbit unreachable, contributes 0 rather than a guess';
133
+ const contributionPercent = totalRiskScore > 0 ? Math.round((v.riskScore.score / totalRiskScore) * 100) : null;
134
+
135
+ return `
136
+ <details class="decision-trail">
137
+ <summary>🔍 Decision trail</summary>
138
+ <div class="trail-content">
139
+ <div class="trail-row"><span>CVSS contribution</span><span>+${fmt1(c.cvss)} <em>(45% weight)</em></span></div>
140
+ <div class="trail-row"><span>Exposure contribution</span><span>+${fmt1(c.exposure)} <em>(35% weight · ${escapeHtml(exposureNote)})</em></span></div>
141
+ <div class="trail-row"><span>Usage contribution</span><span>+${fmt1(c.usage)} <em>(10% weight)</em></span></div>
142
+ <div class="trail-row"><span>Test-coverage discount</span><span>−${fmt1(c.testCoverageDiscount)} <em>(10% weight)</em></span></div>
143
+ <div class="trail-row trail-final"><span>Final score</span><span>${v.riskScore.score}/100 (${escapeHtml(v.riskScore.priority)})</span></div>
144
+ ${contributionPercent !== null ? `<div class="trail-row trail-contribution"><span>Share of total flagged risk</span><span>${contributionPercent}% of ${totalRiskScore} points</span></div>` : ''}
145
+ ${renderEvidenceGroups(files, v.package)}
146
+ </div>
147
+ </details>`;
148
+ }
149
+
150
+ /**
151
+ * One finding, rendered as a self-contained card instead of a row in a
152
+ * wide table — reads cleanly top-to-bottom on any screen size, no
153
+ * horizontal scrolling needed regardless of how many data points a
154
+ * finding carries.
155
+ */
156
+ function renderFindingCard(v, index, meta, totalRiskScore) {
157
+ const priority = v.riskScore?.priority || 'UNSCORED';
158
+ const score = v.riskScore?.score ?? '—';
159
+ const exposureSource = v.riskScore?.exposureDataSource === 'orbit' ? 'Orbit (real)' : 'unavailable';
160
+ const fixCommand = 'node src/agent.js analyze . --fix --create-pr';
161
+ const issueUrl = buildIssueUrl(v, meta, totalRiskScore);
162
+ const color = priorityColor(priority);
163
+
164
+ return `
165
+ <article class="finding-card" data-priority="${escapeHtml(priority)}" data-score="${typeof score === 'number' ? score : -1}">
166
+ <div class="finding-top">
167
+ <div class="finding-identity">
168
+ <span class="finding-rank">#${index + 1}</span>
169
+ <code class="finding-pkg">${escapeHtml(v.package)}</code>
170
+ <span class="finding-version">${escapeHtml(v.currentVersion)} → ${escapeHtml(v.fixedVersion || '?')}</span>
171
+ </div>
172
+ <div class="finding-score" style="--score-color:${color}">
173
+ <span class="score-num">${escapeHtml(score)}</span><span class="score-max">/100</span>
174
+ <span class="badge" style="background:${color}">${escapeHtml(priority)}</span>
175
+ </div>
176
+ </div>
177
+
178
+ <div class="finding-meta">
179
+ <span class="meta-chip">${escapeHtml(v.ecosystem)}</span>
180
+ ${renderDependencyBadge(v)}
181
+ <span class="meta-chip">CVSS ${escapeHtml(v.cvss ?? '?')} · ${escapeHtml(v.severity || '?')}</span>
182
+ <span class="meta-chip">Exposure: ${escapeHtml(exposureSource)}</span>
183
+ <span class="meta-chip meta-recommendation">${escapeHtml(v.recommendation || 'upgrade')}</span>
184
+ </div>
185
+
186
+ <div class="finding-actions">
187
+ ${issueUrl ? `<a href="${issueUrl}" target="_blank" class="action-btn action-btn-primary">📋 Create issue</a>` : ''}
188
+ <button class="action-btn copy-btn" data-copy="${escapeHtml(fixCommand)}" onclick="copyFixCommand(this)">📎 Copy fix command</button>
189
+ </div>
190
+
191
+ ${renderDecisionTrail(v, totalRiskScore)}
192
+ </article>`;
193
+ }
194
+
195
+ /**
196
+ * Executive framing banner — the numbers a non-technical decision-maker
197
+ * weighs: remediation work on the table, how much is urgent, and the
198
+ * manual triage the tool did for you. Sourced from executiveSummary.js
199
+ * (all derived from data already computed; the triage-saved figure is a
200
+ * labelled heuristic).
201
+ */
202
+ function renderExecutiveSummary(vulnerabilities) {
203
+ if (vulnerabilities.length === 0) return '';
204
+ const s = buildExecutiveSummary(vulnerabilities);
205
+ return `
206
+ <section class="exec">
207
+ <h2>📊 Executive summary</h2>
208
+ <div class="exec-grid">
209
+ <div class="exec-item"><span class="exec-num">${s.remediationEffortHours}h</span><span class="exec-label">Remediation effort represented</span></div>
210
+ <div class="exec-item"><span class="exec-num">${s.urgentHigh}</span><span class="exec-label">Urgent / high priority</span></div>
211
+ <div class="exec-item"><span class="exec-num">${s.breakingCount}</span><span class="exec-label">Likely-breaking (major) fixes</span></div>
212
+ <div class="exec-item"><span class="exec-num">${s.unusedCount}</span><span class="exec-label">Removable unused deps</span></div>
213
+ <div class="exec-item exec-highlight"><span class="exec-num">~${s.manualTriageHoursSaved}h</span><span class="exec-label">Manual triage avoided</span></div>
214
+ </div>
215
+ <div class="exec-note">"Manual triage avoided" is a heuristic — ~${s.manualTriageMinutesPerFinding}min/finding of grep-and-decide that DependencyIQ did automatically (blast-radius classification + remove/upgrade/override decision). Effort figures are estimates, not measurements.</div>
216
+ </section>`;
217
+ }
218
+
219
+ /**
220
+ * Visualize the agentic decision pipeline for the top finding — the same
221
+ * five stages the Custom Flow runs (discover → assess → score → decide →
222
+ * remediate/verify), each filled in with this finding's REAL data, so a
223
+ * viewer can see how DependencyIQ reasoned, stage by stage, not just the
224
+ * final score. Stages 1-4 are computed here from real analysis data;
225
+ * stage 5 (remediate + verify) is what the Flow executes as a CI job —
226
+ * shown so the full agentic loop is visible end to end.
227
+ */
228
+ function renderDecisionPipeline(top) {
229
+ if (!top || !top.riskScore) return '';
230
+ const rs = top.riskScore;
231
+ const files = top.affectedFiles || [];
232
+
233
+ const dep = top.dependencyChain;
234
+ const depText = dep?.isDirect ? 'direct dependency'
235
+ : dep?.chain ? `transitive via ${escapeHtml(dep.chain.join(' → '))}`
236
+ : 'dependency';
237
+
238
+ const orbitReal = rs.exposureDataSource === 'orbit';
239
+ const assessText = orbitReal
240
+ ? `Orbit: <strong>${files.length}</strong> file(s) import it`
241
+ : 'Orbit unavailable — score is CVSS-only';
242
+
243
+ // Show the final score and the formula label only — NOT rounded
244
+ // intermediate contributions, which can visually fail to sum (the
245
+ // exact, provably-summing breakdown lives in the per-finding decision
246
+ // trail below; the compact pipeline view must not appear to not add up).
247
+ const scoreText = `<strong>${rs.score}</strong>/100`;
248
+
249
+ const decideText = top.recommendation === 'remove'
250
+ ? 'Remove (zero importers)'
251
+ : `Upgrade → <strong>${escapeHtml(top.fixedVersion || '?')}</strong>`;
252
+
253
+ const stages = [
254
+ { n: 1, label: 'Discover', icon: '🔎', body: `OSV-Scanner found <code>${escapeHtml(top.package)}</code> ${escapeHtml(top.currentVersion)}<br><span class="stage-sub">${escapeHtml(top.id || top.cveLink || 'advisory')} · ${depText}</span>` },
255
+ { n: 2, label: 'Assess', icon: '🛰️', body: `${assessText}<br><span class="stage-sub">${orbitReal ? 'real blast-radius exposure' : 'no import-graph weighting'}</span>` },
256
+ { n: 3, label: 'Score', icon: '⚖️', body: `${scoreText}<br><span class="stage-sub">CVSS+Exposure+Usage−Coverage</span>` },
257
+ { n: 4, label: 'Decide', icon: '🎯', body: `${decideText}<br><span class="stage-sub badge-inline" style="color:${priorityColor(rs.priority)}">${escapeHtml(rs.priority)}</span></span>` },
258
+ { n: 5, label: 'Remediate + Verify', icon: '🔧', body: 'Flow commits the fix, opens an MR,<br><span class="stage-sub">runs the test suite, never auto-merges</span>' },
259
+ ];
260
+
261
+ return `
262
+ <section class="pipeline">
263
+ <h2>🤖 Agentic decision flow <span class="pipeline-subject">— top finding: <code>${escapeHtml(top.package)}</code></span></h2>
264
+ <div class="pipeline-stages">
265
+ ${stages.map((s, i) => `
266
+ <div class="stage">
267
+ <div class="stage-head"><span class="stage-icon">${s.icon}</span><span class="stage-n">${s.n}. ${s.label}</span></div>
268
+ <div class="stage-body">${s.body}</div>
269
+ </div>${i < stages.length - 1 ? '<div class="stage-arrow">→</div>' : ''}`).join('')}
270
+ </div>
271
+ <div class="pipeline-note">Stages 1–4 are the analysis you see below; stage 5 is what the <strong>Custom Flow</strong> runs as a CI job when triggered. Same engine throughout.</div>
272
+ </section>`;
273
+ }
274
+
275
+ /**
276
+ * "Why this ranks above that" — compares the top two findings' actual
277
+ * score components instead of leaving the ranking as an unexplained
278
+ * black box.
279
+ */
280
+ function renderRankingExplanation(vulnerabilities) {
281
+ if (vulnerabilities.length < 2) return '';
282
+ const [top, second] = vulnerabilities;
283
+ if (!top.riskScore || !second.riskScore) return '';
284
+
285
+ const exposureDiff = (top.riskScore.exposure ?? 0) - (second.riskScore.exposure ?? 0);
286
+ const severityDiff = (top.riskScore.severity ?? 0) - (second.riskScore.severity ?? 0);
287
+
288
+ let reason;
289
+ if (exposureDiff !== 0 && Math.abs(exposureDiff) >= Math.abs(severityDiff)) {
290
+ reason = `exposure — ${(top.affectedFiles || []).length} file(s) import <strong>${escapeHtml(top.package)}</strong> vs ${(second.affectedFiles || []).length} for <strong>${escapeHtml(second.package)}</strong>`;
291
+ } else if (severityDiff !== 0) {
292
+ reason = `severity — a CVSS-derived score of ${top.riskScore.severity}/100 vs ${second.riskScore.severity}/100`;
293
+ } else {
294
+ reason = 'a near-tie across severity and exposure';
295
+ }
296
+
297
+ return `<div class="ranking-note"><span class="ranking-icon">📌</span> <strong>${escapeHtml(top.package)}</strong> ranks above <strong>${escapeHtml(second.package)}</strong> primarily because of ${reason}.</div>`;
298
+ }
299
+
300
+ function relativeTime(isoDate, now = Date.now()) {
301
+ const diffMs = now - new Date(isoDate).getTime();
302
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
303
+ if (diffHours < 1) return 'just now';
304
+ if (diffHours < 24) return `${diffHours}h ago`;
305
+ return `${Math.floor(diffHours / 24)}d ago`;
306
+ }
307
+
308
+ const ACTIVITY_ICON = { issue: '📋', merge_request: '🔧' };
309
+ const STATE_LABEL = { opened: 'open', merged: 'merged', closed: 'closed' };
310
+
311
+ function renderActivityItem(item) {
312
+ const icon = ACTIVITY_ICON[item.type] || '•';
313
+ const stateLabel = STATE_LABEL[item.state] || item.state;
314
+ const stateClass = item.state === 'merged' ? 'state-merged' : item.state === 'opened' ? 'state-open' : 'state-closed';
315
+ return `
316
+ <li class="activity-item">
317
+ <span class="activity-icon">${icon}</span>
318
+ <a href="${escapeHtml(item.url)}" target="_blank">${escapeHtml(item.title)}</a>
319
+ <span class="activity-state ${stateClass}">${escapeHtml(stateLabel)}</span>
320
+ <span class="activity-time">${escapeHtml(relativeTime(item.createdAt))}</span>
321
+ </li>`;
322
+ }
323
+
324
+ /**
325
+ * Render the "what has the agent actually done" feed — real issues/MRs
326
+ * fetched live from GitLab's API (src/activityFetcher.js), not a
327
+ * separate log this project would have to keep in sync.
328
+ * @param {Object} activity - result of activityFetcher.getRecentActivity
329
+ */
330
+ function renderActivityFeed(activity) {
331
+ if (!activity) return '';
332
+ if (!activity.available) {
333
+ return `<section class="activity"><h2>🤖 Agent Activity</h2><div class="empty">Activity feed unavailable: ${escapeHtml(activity.reason || 'unknown reason')}</div></section>`;
334
+ }
335
+ if (activity.items.length === 0) {
336
+ return '<section class="activity"><h2>🤖 Agent Activity</h2><div class="empty">No issues or merge requests created by the agent yet.</div></section>';
337
+ }
338
+ return `
339
+ <section class="activity">
340
+ <h2>🤖 Agent Activity</h2>
341
+ <ul class="activity-list">
342
+ ${activity.items.map(renderActivityItem).join('')}
343
+ </ul>
344
+ </section>`;
345
+ }
346
+
347
+ const SHARED_STYLES = `
348
+ :root {
349
+ color-scheme: dark;
350
+ --bg: #0a0d12;
351
+ --surface: #11161d;
352
+ --surface-raised: #161c25;
353
+ --border: #232b36;
354
+ --border-soft: #1b212b;
355
+ --text: #e6edf3;
356
+ --text-muted: #8b949e;
357
+ --accent: #2f81f7;
358
+ --accent-soft: rgba(47, 129, 247, 0.14);
359
+ --radius: 10px;
360
+ --radius-sm: 6px;
361
+ --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
362
+ }
363
+ * { box-sizing: border-box; }
364
+ body {
365
+ font-family: var(--font);
366
+ margin: 0;
367
+ background: var(--bg);
368
+ color: var(--text);
369
+ line-height: 1.5;
370
+ }
371
+ a { color: var(--accent); }
372
+ code { background: var(--surface-raised); padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.85em; }
373
+ `;
374
+
375
+ /**
376
+ * @param {Object} result - return value of agent.js's analyzeRepository()
377
+ * @param {Object} meta - { projectName, projectId, generatedAt, projectPath, serverUrl }
378
+ * @param {Object} activity - result of activityFetcher.getRecentActivity, or null to omit the feed
379
+ * @returns {string} a complete, self-contained HTML document
380
+ */
381
+ function generateDashboardHtml(result, meta = {}, activity = null) {
382
+ // Highest risk first — the CLI's sortByRisk already does this, but a
383
+ // dashboard shouldn't trust call-order; sort explicitly for display.
384
+ const vulnerabilities = [...(result.vulnerabilities || [])]
385
+ .sort((a, b) => (b.riskScore?.score ?? -1) - (a.riskScore?.score ?? -1));
386
+ const counts = vulnerabilities.reduce((acc, v) => {
387
+ const p = v.riskScore?.priority || 'UNSCORED';
388
+ acc[p] = (acc[p] || 0) + 1;
389
+ return acc;
390
+ }, {});
391
+ const orbitCount = vulnerabilities.filter(v => v.riskScore?.exposureDataSource === 'orbit').length;
392
+ const totalRiskScore = vulnerabilities.reduce((sum, v) => sum + (v.riskScore?.score || 0), 0);
393
+ const generatedAt = meta.generatedAt || new Date().toISOString();
394
+
395
+ return `<!DOCTYPE html>
396
+ <html lang="en">
397
+ <head>
398
+ <meta charset="UTF-8">
399
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
400
+ <title>DependencyIQ Dashboard${meta.projectName ? ` — ${escapeHtml(meta.projectName)}` : ''}</title>
401
+ <style>
402
+ ${SHARED_STYLES}
403
+ .page { max-width: 1080px; margin: 0 auto; padding: 0 1.5rem 3rem; }
404
+ header.page-header {
405
+ display: flex; align-items: center; gap: 0.9rem;
406
+ padding: 1.75rem 0 1.25rem; border-bottom: 1px solid var(--border);
407
+ margin-bottom: 1.75rem;
408
+ }
409
+ .brand-icon { font-size: 1.9rem; line-height: 1; }
410
+ .brand-title { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.01em; }
411
+ .brand-subtitle { color: var(--text-muted); font-size: 0.85rem; margin-top: 0.15rem; }
412
+
413
+ .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.85rem; margin-bottom: 1.75rem; }
414
+ .stat { background: var(--surface); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: var(--radius); padding: 1rem 1.1rem; }
415
+ .stat .num { font-size: 1.7rem; font-weight: 700; line-height: 1.1; }
416
+ .stat .label { color: var(--text-muted); font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; margin-top: 0.3rem; }
417
+
418
+ .exec { margin-bottom: 1.75rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.1rem 1.25rem; }
419
+ .exec h2 { font-size: 1rem; margin: 0 0 0.85rem; font-weight: 600; }
420
+ .exec-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.75rem; }
421
+ .exec-item { display: flex; flex-direction: column; gap: 0.2rem; padding: 0.6rem 0.75rem; background: var(--surface-raised); border-radius: var(--radius-sm); border: 1px solid var(--border-soft); }
422
+ .exec-num { font-size: 1.5rem; font-weight: 800; line-height: 1; }
423
+ .exec-label { font-size: 0.72rem; color: var(--text-muted); }
424
+ .exec-highlight { border-color: rgba(47, 129, 247, 0.4); }
425
+ .exec-highlight .exec-num { color: var(--accent); }
426
+ .exec-note { color: var(--text-muted); font-size: 0.72rem; margin-top: 0.7rem; }
427
+ .ranking-note {
428
+ background: var(--accent-soft); border: 1px solid rgba(47, 129, 247, 0.35);
429
+ border-radius: var(--radius); padding: 0.85rem 1.1rem; margin-bottom: 1.1rem; font-size: 0.88rem;
430
+ }
431
+ .ranking-icon { display: inline-block; }
432
+
433
+ .pipeline { margin-bottom: 1.75rem; }
434
+ .pipeline h2 { font-size: 1rem; margin: 0 0 0.85rem; font-weight: 600; }
435
+ .pipeline-subject { color: var(--text-muted); font-weight: 400; }
436
+ .pipeline-stages { display: flex; align-items: stretch; gap: 0; flex-wrap: wrap; }
437
+ .stage {
438
+ flex: 1 1 0; min-width: 150px;
439
+ background: var(--surface); border: 1px solid var(--border); border-top: 2px solid var(--accent);
440
+ border-radius: var(--radius); padding: 0.75rem 0.85rem;
441
+ }
442
+ .stage-head { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.4rem; }
443
+ .stage-icon { font-size: 1rem; }
444
+ .stage-n { font-size: 0.78rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.02em; }
445
+ .stage-body { font-size: 0.8rem; line-height: 1.45; }
446
+ .stage-sub { color: var(--text-muted); font-size: 0.72rem; }
447
+ .badge-inline { font-weight: 700; text-transform: uppercase; font-size: 0.7rem; }
448
+ .stage-arrow { display: flex; align-items: center; color: var(--text-muted); padding: 0 0.35rem; font-size: 1.1rem; }
449
+ .pipeline-note { color: var(--text-muted); font-size: 0.76rem; margin-top: 0.6rem; }
450
+ @media (max-width: 760px) {
451
+ .stage-arrow { display: none; }
452
+ .stage { flex-basis: 100%; }
453
+ }
454
+
455
+ .filters { display: flex; gap: 0.5rem; margin-bottom: 1.25rem; flex-wrap: wrap; }
456
+ .filter-btn {
457
+ background: var(--surface); border: 1px solid var(--border); color: var(--text);
458
+ padding: 0.4rem 0.9rem; border-radius: 999px; cursor: pointer; font-size: 0.82rem;
459
+ transition: background 0.15s, border-color 0.15s;
460
+ }
461
+ .filter-btn:hover { border-color: var(--accent); }
462
+ .filter-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
463
+
464
+ .findings-list { display: flex; flex-direction: column; gap: 0.85rem; }
465
+ .finding-card {
466
+ background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
467
+ padding: 1.1rem 1.25rem;
468
+ }
469
+ .finding-top { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
470
+ .finding-identity { display: flex; align-items: baseline; gap: 0.6rem; flex-wrap: wrap; }
471
+ .finding-rank { color: var(--text-muted); font-size: 0.8rem; }
472
+ .finding-pkg { font-size: 1.05rem; font-weight: 600; }
473
+ .finding-version { color: var(--text-muted); font-size: 0.85rem; }
474
+ .finding-score { display: flex; align-items: center; gap: 0.6rem; }
475
+ .score-num { font-size: 1.4rem; font-weight: 800; color: var(--score-color); }
476
+ .score-max { color: var(--text-muted); font-size: 0.85rem; }
477
+
478
+ .finding-meta { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 0.75rem 0 0.85rem; }
479
+ .meta-chip {
480
+ background: var(--surface-raised); border: 1px solid var(--border-soft); color: var(--text-muted);
481
+ padding: 0.2rem 0.55rem; border-radius: var(--radius-sm); font-size: 0.75rem;
482
+ }
483
+ .meta-recommendation { color: var(--text); }
484
+
485
+ .badge { color: white; padding: 0.2rem 0.55rem; border-radius: var(--radius-sm); font-size: 0.72rem; font-weight: 700; text-transform: uppercase; }
486
+
487
+ .finding-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.5rem; }
488
+ .action-btn {
489
+ background: var(--surface-raised); border: 1px solid var(--border); color: var(--text);
490
+ padding: 0.35rem 0.7rem; border-radius: var(--radius-sm); cursor: pointer; font-size: 0.78rem;
491
+ text-decoration: none; display: inline-block; transition: background 0.15s, border-color 0.15s;
492
+ }
493
+ .action-btn:hover { background: var(--border); border-color: var(--accent); }
494
+ .action-btn-primary { border-color: rgba(47, 129, 247, 0.4); color: var(--accent); }
495
+
496
+ .dep-badge { padding: 0.18rem 0.5rem; border-radius: var(--radius-sm); font-size: 0.72rem; text-transform: uppercase; font-weight: 700; }
497
+ .dep-direct { background: rgba(47, 129, 247, 0.16); color: #58a6ff; }
498
+ .dep-transitive { background: rgba(202, 138, 4, 0.18); color: #e3b341; }
499
+ .dep-unknown { background: rgba(107, 114, 128, 0.18); color: var(--text-muted); }
500
+ .dep-chain { font-size: 0.72rem; color: var(--text-muted); }
501
+
502
+ .decision-trail { font-size: 0.82rem; border-top: 1px solid var(--border-soft); padding-top: 0.6rem; margin-top: 0.4rem; }
503
+ .decision-trail summary { cursor: pointer; color: var(--accent); font-weight: 500; }
504
+ .trail-content { margin-top: 0.6rem; }
505
+ .trail-row { display: flex; justify-content: space-between; gap: 1rem; padding: 0.3rem 0; border-bottom: 1px solid var(--border-soft); }
506
+ .trail-row em { color: var(--text-muted); font-style: normal; font-size: 0.72rem; }
507
+ .trail-final { font-weight: 700; border-bottom: none; }
508
+ .trail-contribution { color: #e3b341; }
509
+ .trail-evidence { margin-top: 0.6rem; padding-top: 0.6rem; border-top: 1px solid var(--border-soft); }
510
+ .trail-evidence ul { margin: 0.3rem 0; padding-left: 1.2rem; }
511
+ .trail-more { color: var(--text-muted); font-size: 0.75rem; }
512
+ .evidence-group { margin-top: 0.5rem; }
513
+ .evidence-group strong { font-size: 0.76rem; }
514
+
515
+ .empty { padding: 2.25rem 1rem; text-align: center; color: var(--text-muted); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); }
516
+
517
+ .activity { margin-top: 2.25rem; }
518
+ .activity h2 { font-size: 1.05rem; margin-bottom: 0.85rem; }
519
+ .activity-list { list-style: none; padding: 0; margin: 0; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
520
+ .activity-item { padding: 0.7rem 1.1rem; border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; gap: 0.7rem; font-size: 0.85rem; }
521
+ .activity-item:last-child { border-bottom: none; }
522
+ .activity-item a { text-decoration: none; flex: 1; }
523
+ .activity-item a:hover { text-decoration: underline; }
524
+ .activity-state { padding: 0.12rem 0.55rem; border-radius: var(--radius-sm); font-size: 0.7rem; text-transform: uppercase; }
525
+ .state-open { background: rgba(47, 129, 247, 0.16); color: #58a6ff; }
526
+ .state-merged { background: rgba(137, 87, 229, 0.18); color: #a371f7; }
527
+ .state-closed { background: rgba(107, 114, 128, 0.18); color: var(--text-muted); }
528
+ .activity-time { color: var(--text-muted); font-size: 0.75rem; white-space: nowrap; }
529
+
530
+ footer { margin-top: 2.5rem; padding-top: 1.25rem; border-top: 1px solid var(--border); color: var(--text-muted); font-size: 0.78rem; }
531
+
532
+ @media (max-width: 560px) {
533
+ .finding-top { flex-direction: column; align-items: flex-start; }
534
+ .page { padding: 0 1rem 2.5rem; }
535
+ }
536
+ </style>
537
+ </head>
538
+ <body>
539
+ <div class="page">
540
+ <header class="page-header">
541
+ <span class="brand-icon">🛡️</span>
542
+ <div>
543
+ <div class="brand-title">DependencyIQ Dashboard</div>
544
+ <div class="brand-subtitle">${meta.projectName ? escapeHtml(meta.projectName) + ' · ' : ''}generated ${escapeHtml(generatedAt)}</div>
545
+ </div>
546
+ </header>
547
+
548
+ <div class="stats">
549
+ <div class="stat"><div class="num">${vulnerabilities.length}</div><div class="label">Total findings</div></div>
550
+ <div class="stat" style="border-left-color:${priorityColor('URGENT')}"><div class="num" style="color:${priorityColor('URGENT')}">${counts.URGENT || 0}</div><div class="label">Urgent</div></div>
551
+ <div class="stat" style="border-left-color:${priorityColor('HIGH')}"><div class="num" style="color:${priorityColor('HIGH')}">${counts.HIGH || 0}</div><div class="label">High</div></div>
552
+ <div class="stat" style="border-left-color:${priorityColor('UNUSED')}"><div class="num" style="color:${priorityColor('UNUSED')}">${counts.UNUSED || 0}</div><div class="label">Unused (remove)</div></div>
553
+ <div class="stat"><div class="num">${orbitCount}/${vulnerabilities.length}</div><div class="label">Scored with real Orbit data</div></div>
554
+ </div>
555
+
556
+ ${vulnerabilities.length === 0 ? '<div class="empty">No vulnerabilities found.</div>' : `
557
+ ${renderExecutiveSummary(vulnerabilities)}
558
+ ${renderDecisionPipeline(vulnerabilities[0])}
559
+ ${renderRankingExplanation(vulnerabilities)}
560
+ <div class="filters">
561
+ <button class="filter-btn active" data-filter="ALL" onclick="filterRows(this)">All</button>
562
+ <button class="filter-btn" data-filter="UNUSED" onclick="filterRows(this)">Unused</button>
563
+ <button class="filter-btn" data-filter="URGENT" onclick="filterRows(this)">Urgent</button>
564
+ <button class="filter-btn" data-filter="HIGH" onclick="filterRows(this)">High</button>
565
+ <button class="filter-btn" data-filter="MEDIUM" onclick="filterRows(this)">Medium</button>
566
+ <button class="filter-btn" data-filter="LOW" onclick="filterRows(this)">Low</button>
567
+ </div>
568
+ <div class="findings-list" id="findings-list">
569
+ ${vulnerabilities.map((v, i) => renderFindingCard(v, i, meta, totalRiskScore)).join('')}
570
+ </div>`}
571
+
572
+ ${renderActivityFeed(activity)}
573
+
574
+ <footer>
575
+ Generated by <a href="https://github.com/google/osv-scanner">OSV-Scanner</a> +
576
+ GitLab Orbit blast-radius scoring. "Exposure: unavailable" means Orbit was unreachable for this
577
+ run — the score is still real CVSS, just without the import-graph weighting.
578
+ Static page published via GitLab Pages — no backend, no live data after generation. Client-side
579
+ filtering is plain JS on data already in this page — still no server round-trip.
580
+ </footer>
581
+ </div>
582
+
583
+ <script>
584
+ function filterRows(btn) {
585
+ document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
586
+ btn.classList.add('active');
587
+ const filter = btn.dataset.filter;
588
+ document.querySelectorAll('#findings-list .finding-card').forEach(card => {
589
+ card.style.display = (filter === 'ALL' || card.dataset.priority === filter) ? '' : 'none';
590
+ });
591
+ }
592
+ function copyFixCommand(btn) {
593
+ const text = btn.dataset.copy;
594
+ if (navigator.clipboard) {
595
+ navigator.clipboard.writeText(text).then(() => {
596
+ const original = btn.textContent;
597
+ btn.textContent = '✅ Copied';
598
+ setTimeout(() => { btn.textContent = original; }, 1500);
599
+ });
600
+ }
601
+ }
602
+ </script>
603
+ </body>
604
+ </html>`;
605
+ }
606
+
607
+ /**
608
+ * Render an honest "scan failed" page instead of either crashing the
609
+ * pipeline with no artifact at all, or — the mistake this project
610
+ * actually made once — silently treating a failed scan as a clean one.
611
+ * @param {string} errorMessage
612
+ * @param {Object} meta - { projectName, generatedAt }
613
+ */
614
+ function generateScanFailedHtml(errorMessage, meta = {}) {
615
+ const generatedAt = meta.generatedAt || new Date().toISOString();
616
+ return `<!DOCTYPE html>
617
+ <html lang="en">
618
+ <head>
619
+ <meta charset="UTF-8">
620
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
621
+ <title>DependencyIQ Dashboard — Scan Failed</title>
622
+ <style>
623
+ ${SHARED_STYLES}
624
+ .page { max-width: 720px; margin: 0 auto; padding: 2.5rem 1.5rem; }
625
+ h1 { color: #f85149; font-size: 1.4rem; display: flex; align-items: center; gap: 0.5rem; }
626
+ pre { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; white-space: pre-wrap; word-break: break-word; }
627
+ .subtitle { color: var(--text-muted); margin-bottom: 1.75rem; font-size: 0.88rem; }
628
+ </style>
629
+ </head>
630
+ <body>
631
+ <div class="page">
632
+ <h1>⚠️ Scan failed — this is NOT a clean result</h1>
633
+ <div class="subtitle">${meta.projectName ? escapeHtml(meta.projectName) + ' — ' : ''}attempted ${escapeHtml(generatedAt)}</div>
634
+ <p>The vulnerability scan did not complete successfully, so no risk data is shown here. This page intentionally does not claim "no vulnerabilities found" — that would be indistinguishable from a real clean scan, which would be misleading.</p>
635
+ <pre>${escapeHtml(errorMessage)}</pre>
636
+ <p>Check the pipeline job log for the full error, and re-run once resolved.</p>
637
+ </div>
638
+ </body>
639
+ </html>`;
640
+ }
641
+
642
+ module.exports = { generateDashboardHtml, generateScanFailedHtml, SHARED_STYLES, escapeHtml, priorityColor };