@vulcn/plugin-report 0.2.0 → 0.5.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.
package/dist/index.js CHANGED
@@ -3,6 +3,262 @@ import { z } from "zod";
3
3
  import { writeFile, mkdir } from "fs/promises";
4
4
  import { resolve } from "path";
5
5
 
6
+ // src/report-model.ts
7
+ var CWE_MAP = {
8
+ xss: {
9
+ id: 79,
10
+ name: "Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')"
11
+ },
12
+ sqli: {
13
+ id: 89,
14
+ name: "Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')"
15
+ },
16
+ ssrf: { id: 918, name: "Server-Side Request Forgery (SSRF)" },
17
+ xxe: {
18
+ id: 611,
19
+ name: "Improper Restriction of XML External Entity Reference"
20
+ },
21
+ "command-injection": {
22
+ id: 78,
23
+ name: "Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')"
24
+ },
25
+ "path-traversal": {
26
+ id: 22,
27
+ name: "Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')"
28
+ },
29
+ "open-redirect": {
30
+ id: 601,
31
+ name: "URL Redirection to Untrusted Site ('Open Redirect')"
32
+ },
33
+ reflection: {
34
+ id: 200,
35
+ name: "Exposure of Sensitive Information to an Unauthorized Actor"
36
+ },
37
+ "security-misconfiguration": {
38
+ id: 16,
39
+ name: "Configuration"
40
+ },
41
+ "information-disclosure": {
42
+ id: 200,
43
+ name: "Exposure of Sensitive Information to an Unauthorized Actor"
44
+ },
45
+ custom: { id: 20, name: "Improper Input Validation" }
46
+ };
47
+ var SEVERITY_WEIGHTS = {
48
+ critical: 10,
49
+ high: 7,
50
+ medium: 4,
51
+ low: 1,
52
+ info: 0
53
+ };
54
+ var SECURITY_SEVERITY = {
55
+ critical: "9.0",
56
+ high: "7.0",
57
+ medium: "4.0",
58
+ low: "2.0",
59
+ info: "0.0"
60
+ };
61
+ var PASSIVE_CATEGORIES = [
62
+ {
63
+ id: "security-headers",
64
+ label: "Security Headers",
65
+ icon: "\u{1F512}",
66
+ color: "#42a5f5",
67
+ remedy: "Add the recommended security headers to your server configuration. Most web servers and frameworks support these via middleware.",
68
+ checks: [
69
+ "Strict-Transport-Security (HSTS)",
70
+ "Content-Security-Policy (CSP)",
71
+ "X-Content-Type-Options",
72
+ "X-Frame-Options",
73
+ "Referrer-Policy",
74
+ "Permissions-Policy"
75
+ ]
76
+ },
77
+ {
78
+ id: "cookie-security",
79
+ label: "Cookie Security",
80
+ icon: "\u{1F36A}",
81
+ color: "#ffab40",
82
+ remedy: "Set the Secure, HttpOnly, and SameSite attributes on all session cookies. Configure your framework's session middleware accordingly.",
83
+ checks: ["Secure flag", "HttpOnly flag", "SameSite attribute"]
84
+ },
85
+ {
86
+ id: "information-disclosure",
87
+ label: "Information Disclosure",
88
+ icon: "\u{1F50D}",
89
+ color: "#66bb6a",
90
+ remedy: "Remove or obfuscate server version headers (Server, X-Powered-By). Disable debug mode in production environments.",
91
+ checks: ["Server version", "X-Powered-By", "Debug tokens"]
92
+ },
93
+ {
94
+ id: "cors",
95
+ label: "CORS Configuration",
96
+ icon: "\u{1F310}",
97
+ color: "#ce93d8",
98
+ remedy: "Replace wildcard origins with specific trusted domains. Never combine Access-Control-Allow-Credentials with wildcard origins.",
99
+ checks: ["Wildcard origin", "Credentials with wildcard"]
100
+ },
101
+ {
102
+ id: "mixed-content",
103
+ label: "Mixed Content",
104
+ icon: "\u26A0\uFE0F",
105
+ color: "#ff8a65",
106
+ remedy: "Replace all HTTP resource URLs with HTTPS. Use Content-Security-Policy: upgrade-insecure-requests as a fallback.",
107
+ checks: ["HTTP resources on HTTPS"]
108
+ }
109
+ ];
110
+ function toRuleId(type) {
111
+ return `VULCN-${type.toUpperCase().replace(/[^A-Z0-9]+/g, "-")}`;
112
+ }
113
+ function fingerprint(f) {
114
+ return `${f.type}:${f.stepId}:${f.payload.slice(0, 50)}`;
115
+ }
116
+ function detectMethod(f) {
117
+ return f.metadata?.detectionMethod === "passive" ? "passive" : "active";
118
+ }
119
+ function passiveCat(f) {
120
+ const method = detectMethod(f);
121
+ if (method !== "passive") return void 0;
122
+ return f.metadata?.category || "other";
123
+ }
124
+ function enrichFinding(f) {
125
+ const cwe = CWE_MAP[f.type] || CWE_MAP.custom;
126
+ const sev = f.severity;
127
+ return {
128
+ // Original fields
129
+ type: f.type,
130
+ severity: sev,
131
+ title: f.title,
132
+ description: f.description,
133
+ stepId: f.stepId,
134
+ payload: f.payload,
135
+ url: f.url,
136
+ evidence: f.evidence,
137
+ metadata: f.metadata,
138
+ // Enriched
139
+ ruleId: toRuleId(f.type),
140
+ cwe,
141
+ securitySeverity: SECURITY_SEVERITY[sev] || "4.0",
142
+ fingerprint: fingerprint(f),
143
+ detectionMethod: detectMethod(f),
144
+ passiveCategory: passiveCat(f)
145
+ };
146
+ }
147
+ function buildRules(enriched) {
148
+ const seen = /* @__PURE__ */ new Map();
149
+ for (const f of enriched) {
150
+ if (!seen.has(f.type)) seen.set(f.type, f);
151
+ }
152
+ return Array.from(seen.entries()).map(([type, sample]) => ({
153
+ id: toRuleId(type),
154
+ type,
155
+ cwe: sample.cwe,
156
+ severity: sample.severity,
157
+ securitySeverity: sample.securitySeverity,
158
+ description: `Vulcn detected a potential ${type} vulnerability. ${sample.cwe.name}.`
159
+ }));
160
+ }
161
+ function countSeverities(findings) {
162
+ const counts = {
163
+ critical: 0,
164
+ high: 0,
165
+ medium: 0,
166
+ low: 0,
167
+ info: 0
168
+ };
169
+ for (const f of findings) {
170
+ counts[f.severity] = (counts[f.severity] || 0) + 1;
171
+ }
172
+ return counts;
173
+ }
174
+ function assessRisk(counts, total) {
175
+ const score = counts.critical * SEVERITY_WEIGHTS.critical + counts.high * SEVERITY_WEIGHTS.high + counts.medium * SEVERITY_WEIGHTS.medium + counts.low * SEVERITY_WEIGHTS.low;
176
+ const maxRisk = total * SEVERITY_WEIGHTS.critical || 1;
177
+ const percent = Math.min(100, Math.round(score / maxRisk * 100));
178
+ const label = percent >= 80 ? "Critical" : percent >= 50 ? "High" : percent >= 25 ? "Medium" : percent > 0 ? "Low" : "Clear";
179
+ return { score, percent, label };
180
+ }
181
+ function buildPassiveAnalysis(passiveFindings) {
182
+ const grouped = /* @__PURE__ */ new Map();
183
+ for (const f of passiveFindings) {
184
+ const cat = f.passiveCategory || "other";
185
+ if (!grouped.has(cat)) grouped.set(cat, []);
186
+ grouped.get(cat).push(f);
187
+ }
188
+ const categories = PASSIVE_CATEGORIES.map((def) => {
189
+ const findings = grouped.get(def.id) || [];
190
+ const issueCount = findings.length;
191
+ const totalChecks = def.checks.length;
192
+ const passedChecks = Math.max(0, totalChecks - issueCount);
193
+ const status = issueCount === 0 ? "pass" : issueCount >= 3 ? "fail" : "warn";
194
+ return {
195
+ definition: def,
196
+ findings,
197
+ issueCount,
198
+ passedChecks,
199
+ totalChecks,
200
+ status
201
+ };
202
+ });
203
+ return {
204
+ totalIssues: passiveFindings.length,
205
+ categories
206
+ };
207
+ }
208
+ function buildReport(session, result, generatedAt, engineVersion) {
209
+ const severityOrder = {
210
+ critical: 0,
211
+ high: 1,
212
+ medium: 2,
213
+ low: 3,
214
+ info: 4
215
+ };
216
+ const sortedFindings = [...result.findings].sort(
217
+ (a, b) => (severityOrder[a.severity] ?? 5) - (severityOrder[b.severity] ?? 5)
218
+ );
219
+ const findings = sortedFindings.map(enrichFinding);
220
+ const activeFindings = findings.filter((f) => f.detectionMethod === "active");
221
+ const passiveFindings = findings.filter(
222
+ (f) => f.detectionMethod === "passive"
223
+ );
224
+ const counts = countSeverities(findings);
225
+ const risk = assessRisk(counts, findings.length);
226
+ const rules = buildRules(findings);
227
+ return {
228
+ reportVersion: "2.0",
229
+ engineVersion,
230
+ generatedAt,
231
+ session: {
232
+ name: session.name,
233
+ driver: session.driver,
234
+ driverConfig: session.driverConfig,
235
+ stepsCount: session.steps.length,
236
+ metadata: session.metadata
237
+ },
238
+ stats: {
239
+ stepsExecuted: result.stepsExecuted,
240
+ payloadsTested: result.payloadsTested,
241
+ durationMs: result.duration,
242
+ errors: result.errors
243
+ },
244
+ summary: {
245
+ totalFindings: findings.length,
246
+ severityCounts: counts,
247
+ risk,
248
+ vulnerabilityTypes: [...new Set(findings.map((f) => f.type))],
249
+ affectedUrls: [...new Set(findings.map((f) => f.url))]
250
+ },
251
+ rules,
252
+ findings,
253
+ activeFindings,
254
+ passiveAnalysis: buildPassiveAnalysis(passiveFindings)
255
+ };
256
+ }
257
+ function formatDuration(ms) {
258
+ if (ms < 1e3) return `${ms}ms`;
259
+ return `${(ms / 1e3).toFixed(1)}s`;
260
+ }
261
+
6
262
  // src/html.ts
7
263
  var COLORS = {
8
264
  bg: "#0a0a0f",
@@ -39,30 +295,9 @@ function severityColor(severity) {
39
295
  return COLORS.textMuted;
40
296
  }
41
297
  }
42
- function severityOrder(severity) {
43
- switch (severity) {
44
- case "critical":
45
- return 0;
46
- case "high":
47
- return 1;
48
- case "medium":
49
- return 2;
50
- case "low":
51
- return 3;
52
- case "info":
53
- return 4;
54
- default:
55
- return 5;
56
- }
57
- }
58
298
  function escapeHtml(str) {
59
299
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
60
300
  }
61
- function formatDuration(ms) {
62
- if (ms < 1e3) return `${ms}ms`;
63
- const seconds = (ms / 1e3).toFixed(1);
64
- return `${seconds}s`;
65
- }
66
301
  function formatDate(iso) {
67
302
  const d = new Date(iso);
68
303
  return d.toLocaleDateString("en-US", {
@@ -89,31 +324,95 @@ var VULCN_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20
89
324
  <path fill="#dc2626" d="m 13.0058,9.89 c -0.164,1.484 -0.749,2.568 -1.659,3.353 -0.418,0.36 -0.465,0.992 -0.104,1.41 0.36,0.418 0.992,0.465 1.41,0.104 1.266,-1.092 2.112,-2.583 2.341,-4.647 0.061,-0.548 -0.335,-1.043 -0.884,-1.104 -0.548,-0.061 -1.043,0.335 -1.104,0.884 z"/>
90
325
  <path fill="url(#lg2)" d="m 14.0058,8.89 c -0.164,1.484 -0.749,2.568 -1.659,3.353 -0.418,0.36 -0.465,0.992 -0.104,1.41 0.36,0.418 0.992,0.465 1.41,0.104 1.266,-1.092 2.112,-2.583 2.341,-4.647 0.061,-0.548 -0.335,-1.043 -0.884,-1.104 -0.548,-0.061 -1.043,0.335 -1.104,0.884 z"/>
91
326
  </svg>`;
92
- function generateHtml(data) {
93
- const { session, result, generatedAt, engineVersion } = data;
94
- const findings = [...result.findings].sort(
95
- (a, b) => severityOrder(a.severity) - severityOrder(b.severity)
96
- );
97
- const counts = {
98
- critical: 0,
99
- high: 0,
100
- medium: 0,
101
- low: 0,
102
- info: 0
103
- };
104
- for (const f of findings) {
105
- counts[f.severity] = (counts[f.severity] || 0) + 1;
327
+ function renderPassiveSection(analysis) {
328
+ if (analysis.totalIssues === 0) {
329
+ return `
330
+ <div class="passive-section animate-in-delay-3">
331
+ <div class="section-header">
332
+ <h3>\u{1F6E1}\uFE0F Passive Security Analysis</h3>
333
+ <span class="findings-count">All clear</span>
334
+ </div>
335
+ <div class="passive-clear">
336
+ <div class="icon">\u2705</div>
337
+ <h4>All Passive Checks Passed</h4>
338
+ <p>No security header, cookie, CORS, or information disclosure issues detected.</p>
339
+ </div>
340
+ </div>`;
106
341
  }
107
- const totalFindings = findings.length;
342
+ const categoryCards = analysis.categories.map((cat) => {
343
+ const statusColor = cat.status === "pass" ? COLORS.success : cat.status === "fail" ? COLORS.high : COLORS.medium;
344
+ return `
345
+ <div class="passive-category-card">
346
+ <div class="passive-cat-header">
347
+ <div class="passive-cat-icon">${cat.definition.icon}</div>
348
+ <div class="passive-cat-info">
349
+ <div class="passive-cat-title">${cat.definition.label}</div>
350
+ <div class="passive-cat-count" style="color: ${statusColor}">
351
+ ${cat.issueCount === 0 ? "\u2713 All clear" : `${cat.issueCount} issue${cat.issueCount !== 1 ? "s" : ""}`}
352
+ </div>
353
+ </div>
354
+ <div class="passive-cat-badge" style="background: ${statusColor}20; color: ${statusColor}; border-color: ${statusColor}30">
355
+ ${cat.status.toUpperCase()}
356
+ </div>
357
+ </div>
358
+ ${cat.findings.length > 0 ? `
359
+ <div class="passive-cat-findings">
360
+ ${cat.findings.map(
361
+ (f) => `
362
+ <div class="passive-finding-row">
363
+ <div class="passive-finding-dot" style="background: ${severityColor(f.severity)}"></div>
364
+ <div class="passive-finding-content">
365
+ <div class="passive-finding-title">${escapeHtml(f.title)}</div>
366
+ <div class="passive-finding-desc">${escapeHtml(f.description)}</div>
367
+ ${f.evidence ? `<div class="passive-finding-evidence">${escapeHtml(f.evidence)}</div>` : ""}
368
+ </div>
369
+ <span class="passive-sev-tag" style="color: ${severityColor(f.severity)}">${f.severity.toUpperCase()}</span>
370
+ </div>
371
+ `
372
+ ).join("")}
373
+ </div>
374
+ <div class="passive-remedy">
375
+ <span class="passive-remedy-label">\u{1F4A1} Remediation</span>
376
+ <span>${cat.definition.remedy}</span>
377
+ </div>
378
+ ` : `
379
+ <div class="passive-checks-passed">
380
+ ${cat.definition.checks.map(
381
+ (check) => `
382
+ <div class="passive-check-item">
383
+ <span class="passive-check-icon">\u2713</span>
384
+ <span>${check}</span>
385
+ </div>
386
+ `
387
+ ).join("")}
388
+ </div>
389
+ `}
390
+ </div>`;
391
+ }).join("");
392
+ return `
393
+ <div class="passive-section animate-in-delay-3">
394
+ <div class="section-header">
395
+ <h3>\u{1F6E1}\uFE0F Passive Security Analysis</h3>
396
+ <span class="findings-count">${analysis.totalIssues} issue${analysis.totalIssues !== 1 ? "s" : ""}</span>
397
+ </div>
398
+ <div class="passive-grid">
399
+ ${categoryCards}
400
+ </div>
401
+ </div>`;
402
+ }
403
+ function generateHtml(report) {
404
+ const { summary, stats, session, passiveAnalysis, activeFindings, findings } = report;
405
+ const {
406
+ severityCounts: counts,
407
+ risk,
408
+ totalFindings,
409
+ affectedUrls,
410
+ vulnerabilityTypes: vulnTypes
411
+ } = summary;
412
+ const { stepsExecuted, payloadsTested, durationMs, errors } = stats;
108
413
  const hasFindings = totalFindings > 0;
109
- const riskScore = counts.critical * 10 + counts.high * 7 + counts.medium * 4 + counts.low * 1;
110
- const maxRisk = totalFindings * 10 || 1;
111
- const riskPercent = Math.min(100, Math.round(riskScore / maxRisk * 100));
112
- const riskLabel = riskPercent >= 80 ? "Critical" : riskPercent >= 50 ? "High" : riskPercent >= 25 ? "Medium" : riskPercent > 0 ? "Low" : "Clear";
113
- const riskColor = riskPercent >= 80 ? COLORS.critical : riskPercent >= 50 ? COLORS.high : riskPercent >= 25 ? COLORS.medium : riskPercent > 0 ? COLORS.low : COLORS.success;
414
+ const riskColor = risk.percent >= 80 ? COLORS.critical : risk.percent >= 50 ? COLORS.high : risk.percent >= 25 ? COLORS.medium : risk.percent > 0 ? COLORS.low : COLORS.success;
114
415
  const donutSvg = generateDonut(counts, totalFindings);
115
- const affectedUrls = [...new Set(findings.map((f) => f.url))];
116
- const vulnTypes = [...new Set(findings.map((f) => f.type))];
117
416
  return `<!DOCTYPE html>
118
417
  <html lang="en">
119
418
  <head>
@@ -605,6 +904,180 @@ function generateHtml(data) {
605
904
  .no-findings h3 { font-size: 20px; font-weight: 700; color: ${COLORS.success}; margin-bottom: 8px; }
606
905
  .no-findings p { font-size: 14px; color: var(--text-muted); }
607
906
 
907
+ /* \u2500\u2500 Passive Security Analysis \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
908
+ .passive-section { margin-bottom: 32px; }
909
+
910
+ .section-header {
911
+ display: flex;
912
+ align-items: center;
913
+ justify-content: space-between;
914
+ margin-bottom: 16px;
915
+ }
916
+
917
+ .section-header h3 {
918
+ font-size: 18px;
919
+ font-weight: 700;
920
+ letter-spacing: -0.01em;
921
+ }
922
+
923
+ .passive-grid {
924
+ display: grid;
925
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
926
+ gap: 12px;
927
+ }
928
+
929
+ .passive-category-card {
930
+ background: var(--surface);
931
+ border: 1px solid var(--border);
932
+ border-radius: var(--radius);
933
+ overflow: hidden;
934
+ transition: border-color 0.2s;
935
+ }
936
+
937
+ .passive-category-card:hover { border-color: var(--border-active); }
938
+
939
+ .passive-cat-header {
940
+ display: flex;
941
+ align-items: center;
942
+ gap: 12px;
943
+ padding: 16px 20px;
944
+ border-bottom: 1px solid var(--border);
945
+ }
946
+
947
+ .passive-cat-icon {
948
+ font-size: 20px;
949
+ width: 36px;
950
+ height: 36px;
951
+ display: flex;
952
+ align-items: center;
953
+ justify-content: center;
954
+ background: rgba(255,255,255,0.03);
955
+ border-radius: var(--radius-xs);
956
+ flex-shrink: 0;
957
+ }
958
+
959
+ .passive-cat-info { flex: 1; min-width: 0; }
960
+
961
+ .passive-cat-title {
962
+ font-size: 14px;
963
+ font-weight: 600;
964
+ letter-spacing: -0.01em;
965
+ }
966
+
967
+ .passive-cat-count {
968
+ font-size: 12px;
969
+ font-weight: 500;
970
+ }
971
+
972
+ .passive-cat-badge {
973
+ font-size: 10px;
974
+ font-weight: 700;
975
+ letter-spacing: 0.05em;
976
+ padding: 3px 8px;
977
+ border-radius: 100px;
978
+ border: 1px solid;
979
+ flex-shrink: 0;
980
+ }
981
+
982
+ .passive-cat-findings {
983
+ padding: 12px 20px;
984
+ }
985
+
986
+ .passive-finding-row {
987
+ display: flex;
988
+ align-items: flex-start;
989
+ gap: 10px;
990
+ padding: 8px 0;
991
+ border-bottom: 1px solid rgba(255,255,255,0.03);
992
+ }
993
+
994
+ .passive-finding-row:last-child { border-bottom: none; }
995
+
996
+ .passive-finding-dot {
997
+ width: 6px;
998
+ height: 6px;
999
+ border-radius: 50%;
1000
+ flex-shrink: 0;
1001
+ margin-top: 7px;
1002
+ }
1003
+
1004
+ .passive-finding-content { flex: 1; min-width: 0; }
1005
+
1006
+ .passive-finding-title {
1007
+ font-size: 13px;
1008
+ font-weight: 500;
1009
+ margin-bottom: 2px;
1010
+ }
1011
+
1012
+ .passive-finding-desc {
1013
+ font-size: 12px;
1014
+ color: var(--text-muted);
1015
+ line-height: 1.4;
1016
+ }
1017
+
1018
+ .passive-finding-evidence {
1019
+ font-family: 'JetBrains Mono', monospace;
1020
+ font-size: 11px;
1021
+ color: var(--text-dim);
1022
+ margin-top: 4px;
1023
+ padding: 4px 8px;
1024
+ background: rgba(255,255,255,0.02);
1025
+ border-radius: 4px;
1026
+ }
1027
+
1028
+ .passive-sev-tag {
1029
+ font-size: 10px;
1030
+ font-weight: 700;
1031
+ letter-spacing: 0.04em;
1032
+ flex-shrink: 0;
1033
+ margin-top: 2px;
1034
+ }
1035
+
1036
+ .passive-remedy {
1037
+ padding: 12px 20px;
1038
+ background: rgba(66, 165, 245, 0.04);
1039
+ border-top: 1px solid rgba(66, 165, 245, 0.08);
1040
+ font-size: 12px;
1041
+ color: var(--text-muted);
1042
+ line-height: 1.5;
1043
+ }
1044
+
1045
+ .passive-remedy-label {
1046
+ font-weight: 600;
1047
+ margin-right: 6px;
1048
+ }
1049
+
1050
+ .passive-checks-passed {
1051
+ padding: 12px 20px;
1052
+ }
1053
+
1054
+ .passive-check-item {
1055
+ display: flex;
1056
+ align-items: center;
1057
+ gap: 8px;
1058
+ padding: 4px 0;
1059
+ font-size: 12px;
1060
+ color: var(--text-muted);
1061
+ }
1062
+
1063
+ .passive-check-icon {
1064
+ color: ${COLORS.success};
1065
+ font-weight: 700;
1066
+ font-size: 11px;
1067
+ }
1068
+
1069
+ .passive-clear {
1070
+ text-align: center;
1071
+ padding: 40px 24px;
1072
+ background: var(--surface);
1073
+ border: 1px solid var(--border);
1074
+ border-radius: var(--radius);
1075
+ }
1076
+
1077
+ .passive-clear .icon { font-size: 36px; margin-bottom: 12px; }
1078
+ .passive-clear h4 { font-size: 16px; font-weight: 600; color: ${COLORS.success}; margin-bottom: 6px; }
1079
+ .passive-clear p { font-size: 13px; color: var(--text-muted); }
1080
+
608
1081
  /* Errors section */
609
1082
  .errors-section {
610
1083
  margin-bottom: 32px;
@@ -669,6 +1142,7 @@ function generateHtml(data) {
669
1142
  body::before { display: none; }
670
1143
  .finding-details { display: block !important; padding-top: 12px !important; }
671
1144
  .finding-card { page-break-inside: avoid; }
1145
+ .passive-category-card { page-break-inside: avoid; }
672
1146
  }
673
1147
  </style>
674
1148
  </head>
@@ -684,8 +1158,8 @@ function generateHtml(data) {
684
1158
  </div>
685
1159
  </div>
686
1160
  <div class="header-meta">
687
- <div>${formatDate(generatedAt)}</div>
688
- <div>Engine v${escapeHtml(engineVersion)}</div>
1161
+ <div>${formatDate(report.generatedAt)}</div>
1162
+ <div>Engine v${escapeHtml(report.engineVersion)}</div>
689
1163
  </div>
690
1164
  </div>
691
1165
 
@@ -700,11 +1174,11 @@ function generateHtml(data) {
700
1174
  ${session.driverConfig?.startUrl ? `<div class="meta-item"><span class="meta-label">Target URL</span><span class="meta-value">${escapeHtml(String(session.driverConfig.startUrl))}</span></div>` : ""}
701
1175
  <div class="meta-item">
702
1176
  <span class="meta-label">Duration</span>
703
- <span class="meta-value">${formatDuration(result.duration)}</span>
1177
+ <span class="meta-value">${formatDuration(durationMs)}</span>
704
1178
  </div>
705
1179
  <div class="meta-item">
706
1180
  <span class="meta-label">Generated</span>
707
- <span class="meta-value">${formatDate(generatedAt)}</span>
1181
+ <span class="meta-value">${formatDate(report.generatedAt)}</span>
708
1182
  </div>
709
1183
  </div>
710
1184
  </div>
@@ -717,13 +1191,13 @@ function generateHtml(data) {
717
1191
  <svg viewBox="0 0 160 160" width="160" height="160">
718
1192
  <circle cx="80" cy="80" r="68" fill="none" stroke="rgba(255,255,255,0.04)" stroke-width="10"/>
719
1193
  <circle cx="80" cy="80" r="68" fill="none" stroke="${riskColor}" stroke-width="10"
720
- stroke-dasharray="${riskPercent / 100 * 427} 427"
1194
+ stroke-dasharray="${risk.percent / 100 * 427} 427"
721
1195
  stroke-linecap="round"
722
1196
  style="filter: drop-shadow(0 0 6px ${riskColor});"/>
723
1197
  </svg>
724
1198
  <div class="risk-gauge-label">
725
- <div class="score" style="color: ${riskColor}">${hasFindings ? riskPercent : 0}</div>
726
- <div class="label">${riskLabel}</div>
1199
+ <div class="score" style="color: ${riskColor}">${hasFindings ? risk.percent : 0}</div>
1200
+ <div class="label">${risk.label}</div>
727
1201
  </div>
728
1202
  </div>
729
1203
  </div>
@@ -736,11 +1210,11 @@ function generateHtml(data) {
736
1210
  <div class="stat-label">Findings</div>
737
1211
  </div>
738
1212
  <div class="stat-box">
739
- <div class="stat-number">${result.payloadsTested}</div>
1213
+ <div class="stat-number">${payloadsTested}</div>
740
1214
  <div class="stat-label">Payloads Tested</div>
741
1215
  </div>
742
1216
  <div class="stat-box">
743
- <div class="stat-number">${result.stepsExecuted}</div>
1217
+ <div class="stat-number">${stepsExecuted}</div>
744
1218
  <div class="stat-label">Steps Executed</div>
745
1219
  </div>
746
1220
  <div class="stat-box">
@@ -768,14 +1242,17 @@ function generateHtml(data) {
768
1242
  </div>
769
1243
  </div>
770
1244
 
771
- <!-- Findings -->
1245
+ <!-- Passive Security Analysis -->
1246
+ ${renderPassiveSection(passiveAnalysis)}
1247
+
1248
+ <!-- Active Findings -->
772
1249
  <div class="findings-section animate-in-delay-3">
773
1250
  <div class="findings-header">
774
- <h3>Findings</h3>
775
- <span class="findings-count">${totalFindings} total</span>
1251
+ <h3>\u{1F3AF} Active Scan Findings</h3>
1252
+ <span class="findings-count">${activeFindings.length} finding${activeFindings.length !== 1 ? "s" : ""}</span>
776
1253
  </div>
777
1254
 
778
- ${hasFindings ? findings.map(
1255
+ ${activeFindings.length > 0 ? activeFindings.map(
779
1256
  (f, i) => `
780
1257
  <div class="finding-card" onclick="this.classList.toggle('open')">
781
1258
  <div class="finding-header">
@@ -821,16 +1298,16 @@ function generateHtml(data) {
821
1298
  ).join("") : `
822
1299
  <div class="no-findings">
823
1300
  <div class="icon">\u{1F6E1}\uFE0F</div>
824
- <h3>No Vulnerabilities Detected</h3>
825
- <p>${result.payloadsTested} payloads were tested across ${result.stepsExecuted} steps with no findings.</p>
1301
+ <h3>No Active Vulnerabilities Detected</h3>
1302
+ <p>${payloadsTested} payloads were tested across ${stepsExecuted} steps with no findings.</p>
826
1303
  </div>
827
1304
  `}
828
1305
  </div>
829
1306
 
830
- ${result.errors.length > 0 ? `
1307
+ ${errors.length > 0 ? `
831
1308
  <div class="errors-section">
832
- <h3>\u26A0\uFE0F Errors During Execution (${result.errors.length})</h3>
833
- ${result.errors.map((e) => `<div class="error-item">${escapeHtml(e)}</div>`).join("")}
1309
+ <h3>\u26A0\uFE0F Errors During Execution (${errors.length})</h3>
1310
+ ${errors.map((e) => `<div class="error-item">${escapeHtml(e)}</div>`).join("")}
834
1311
  </div>
835
1312
  ` : ""}
836
1313
 
@@ -865,67 +1342,231 @@ function generateDonut(counts, total) {
865
1342
  }
866
1343
 
867
1344
  // src/json.ts
868
- function formatDuration2(ms) {
869
- if (ms < 1e3) return `${ms}ms`;
870
- return `${(ms / 1e3).toFixed(1)}s`;
871
- }
872
- function generateJson(session, result, generatedAt, engineVersion) {
873
- const counts = {
874
- critical: 0,
875
- high: 0,
876
- medium: 0,
877
- low: 0,
878
- info: 0
879
- };
880
- for (const f of result.findings) {
881
- counts[f.severity] = (counts[f.severity] || 0) + 1;
882
- }
883
- const riskScore = counts.critical * 10 + counts.high * 7 + counts.medium * 4 + counts.low * 1;
1345
+ function generateJson(report) {
884
1346
  return {
885
1347
  vulcn: {
886
- version: engineVersion,
887
- reportVersion: "1.0",
888
- generatedAt
889
- },
890
- session: {
891
- name: session.name,
892
- driver: session.driver,
893
- driverConfig: session.driverConfig,
894
- stepsCount: session.steps.length,
895
- metadata: session.metadata
1348
+ version: report.engineVersion,
1349
+ reportVersion: report.reportVersion,
1350
+ generatedAt: report.generatedAt
896
1351
  },
1352
+ session: report.session,
897
1353
  execution: {
898
- stepsExecuted: result.stepsExecuted,
899
- payloadsTested: result.payloadsTested,
900
- durationMs: result.duration,
901
- durationFormatted: formatDuration2(result.duration),
902
- errors: result.errors
1354
+ stepsExecuted: report.stats.stepsExecuted,
1355
+ payloadsTested: report.stats.payloadsTested,
1356
+ durationMs: report.stats.durationMs,
1357
+ durationFormatted: formatDuration(report.stats.durationMs),
1358
+ errors: report.stats.errors
903
1359
  },
904
1360
  summary: {
905
- totalFindings: result.findings.length,
906
- riskScore,
907
- severityCounts: counts,
908
- vulnerabilityTypes: [...new Set(result.findings.map((f) => f.type))],
909
- affectedUrls: [...new Set(result.findings.map((f) => f.url))]
1361
+ totalFindings: report.summary.totalFindings,
1362
+ riskScore: report.summary.risk.score,
1363
+ riskLabel: report.summary.risk.label,
1364
+ severityCounts: report.summary.severityCounts,
1365
+ vulnerabilityTypes: report.summary.vulnerabilityTypes,
1366
+ affectedUrls: report.summary.affectedUrls
910
1367
  },
911
- findings: result.findings
1368
+ findings: report.findings,
1369
+ passiveAnalysis: {
1370
+ totalIssues: report.passiveAnalysis.totalIssues,
1371
+ categories: report.passiveAnalysis.categories.map((c) => ({
1372
+ id: c.definition.id,
1373
+ label: c.definition.label,
1374
+ status: c.status,
1375
+ issueCount: c.issueCount,
1376
+ passedChecks: c.passedChecks,
1377
+ totalChecks: c.totalChecks,
1378
+ remedy: c.definition.remedy
1379
+ }))
1380
+ },
1381
+ rules: report.rules
912
1382
  };
913
1383
  }
914
1384
 
915
1385
  // src/yaml.ts
916
1386
  import { stringify } from "yaml";
917
- function generateYaml(session, result, generatedAt, engineVersion) {
918
- const report = generateJson(session, result, generatedAt, engineVersion);
1387
+ function generateYaml(report) {
1388
+ const jsonReport = generateJson(report);
919
1389
  const header = [
920
1390
  "# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
921
1391
  "# Vulcn Security Report",
922
- `# Generated: ${generatedAt}`,
923
- `# Session: ${session.name}`,
924
- `# Findings: ${result.findings.length}`,
1392
+ `# Generated: ${report.generatedAt}`,
1393
+ `# Session: ${report.session.name}`,
1394
+ `# Findings: ${report.summary.totalFindings}`,
925
1395
  "# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
926
1396
  ""
927
1397
  ].join("\n");
928
- return header + stringify(report, { indent: 2 });
1398
+ return header + stringify(jsonReport, { indent: 2 });
1399
+ }
1400
+
1401
+ // src/sarif.ts
1402
+ function toSarifLevel(severity) {
1403
+ switch (severity) {
1404
+ case "critical":
1405
+ case "high":
1406
+ return "error";
1407
+ case "medium":
1408
+ return "warning";
1409
+ case "low":
1410
+ case "info":
1411
+ return "note";
1412
+ default:
1413
+ return "warning";
1414
+ }
1415
+ }
1416
+ function toPrecision(severity) {
1417
+ switch (severity) {
1418
+ case "critical":
1419
+ return "very-high";
1420
+ case "high":
1421
+ return "high";
1422
+ case "medium":
1423
+ return "medium";
1424
+ case "low":
1425
+ case "info":
1426
+ return "low";
1427
+ default:
1428
+ return "medium";
1429
+ }
1430
+ }
1431
+ function toSarifRule(rule) {
1432
+ return {
1433
+ id: rule.id,
1434
+ name: rule.type,
1435
+ shortDescription: {
1436
+ text: `${rule.cwe.name} (CWE-${rule.cwe.id})`
1437
+ },
1438
+ fullDescription: {
1439
+ text: rule.description
1440
+ },
1441
+ helpUri: `https://cwe.mitre.org/data/definitions/${rule.cwe.id}.html`,
1442
+ help: {
1443
+ text: `## ${rule.cwe.name}
1444
+
1445
+ CWE-${rule.cwe.id}: ${rule.cwe.name}
1446
+
1447
+ This rule detects ${rule.type} vulnerabilities by injecting security payloads into form inputs and analyzing the application's response for signs of exploitation.
1448
+
1449
+ ### Remediation
1450
+
1451
+ See https://cwe.mitre.org/data/definitions/${rule.cwe.id}.html for detailed remediation guidance.`,
1452
+ markdown: `## ${rule.cwe.name}
1453
+
1454
+ **CWE-${rule.cwe.id}**: ${rule.cwe.name}
1455
+
1456
+ This rule detects \`${rule.type}\` vulnerabilities by injecting security payloads into form inputs and analyzing the application's response for signs of exploitation.
1457
+
1458
+ ### Remediation
1459
+
1460
+ See [CWE-${rule.cwe.id}](https://cwe.mitre.org/data/definitions/${rule.cwe.id}.html) for detailed remediation guidance.`
1461
+ },
1462
+ properties: {
1463
+ tags: [
1464
+ "security",
1465
+ `CWE-${rule.cwe.id}`,
1466
+ `external/cwe/cwe-${rule.cwe.id}`
1467
+ ],
1468
+ precision: toPrecision(rule.severity),
1469
+ "security-severity": rule.securitySeverity
1470
+ },
1471
+ defaultConfiguration: {
1472
+ level: toSarifLevel(rule.severity)
1473
+ }
1474
+ };
1475
+ }
1476
+ function toSarifResult(finding, sarifRules) {
1477
+ const ruleIndex = sarifRules.findIndex((r) => r.id === finding.ruleId);
1478
+ let messageText = `${finding.title}
1479
+
1480
+ ${finding.description}`;
1481
+ if (finding.evidence) {
1482
+ messageText += `
1483
+
1484
+ Evidence: ${finding.evidence}`;
1485
+ }
1486
+ messageText += `
1487
+
1488
+ Payload: ${finding.payload}`;
1489
+ return {
1490
+ ruleId: finding.ruleId,
1491
+ ruleIndex: Math.max(ruleIndex, 0),
1492
+ level: toSarifLevel(finding.severity),
1493
+ message: { text: messageText },
1494
+ locations: [
1495
+ {
1496
+ physicalLocation: {
1497
+ artifactLocation: {
1498
+ uri: finding.url || "unknown"
1499
+ },
1500
+ region: {
1501
+ startLine: 1
1502
+ }
1503
+ },
1504
+ logicalLocations: [
1505
+ {
1506
+ name: finding.stepId,
1507
+ kind: "test-step"
1508
+ }
1509
+ ]
1510
+ }
1511
+ ],
1512
+ fingerprints: {
1513
+ vulcnFindingV1: finding.fingerprint
1514
+ },
1515
+ partialFingerprints: {
1516
+ vulcnType: finding.type,
1517
+ vulcnStepId: finding.stepId
1518
+ },
1519
+ properties: {
1520
+ severity: finding.severity,
1521
+ payload: finding.payload,
1522
+ stepId: finding.stepId,
1523
+ detectionMethod: finding.detectionMethod,
1524
+ ...finding.evidence ? { evidence: finding.evidence } : {},
1525
+ ...finding.passiveCategory ? { passiveCategory: finding.passiveCategory } : {}
1526
+ }
1527
+ };
1528
+ }
1529
+ function generateSarif(report) {
1530
+ const sarifRules = report.rules.map(toSarifRule);
1531
+ const results = report.findings.map((f) => toSarifResult(f, sarifRules));
1532
+ const artifacts = report.summary.affectedUrls.map((url) => ({
1533
+ location: { uri: url }
1534
+ }));
1535
+ const startDate = new Date(report.generatedAt);
1536
+ const endDate = new Date(startDate.getTime() + report.stats.durationMs);
1537
+ return {
1538
+ $schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
1539
+ version: "2.1.0",
1540
+ runs: [
1541
+ {
1542
+ tool: {
1543
+ driver: {
1544
+ name: "Vulcn",
1545
+ version: report.engineVersion,
1546
+ semanticVersion: report.engineVersion,
1547
+ informationUri: "https://vulcn.dev",
1548
+ rules: sarifRules
1549
+ }
1550
+ },
1551
+ results,
1552
+ invocations: [
1553
+ {
1554
+ executionSuccessful: report.stats.errors.length === 0,
1555
+ startTimeUtc: report.generatedAt,
1556
+ endTimeUtc: endDate.toISOString(),
1557
+ properties: {
1558
+ sessionName: report.session.name,
1559
+ stepsExecuted: report.stats.stepsExecuted,
1560
+ payloadsTested: report.stats.payloadsTested,
1561
+ durationMs: report.stats.durationMs,
1562
+ ...report.stats.errors.length > 0 ? { errors: report.stats.errors } : {}
1563
+ }
1564
+ }
1565
+ ],
1566
+ ...artifacts.length > 0 ? { artifacts } : {}
1567
+ }
1568
+ ]
1569
+ };
929
1570
  }
930
1571
 
931
1572
  // src/index.ts
@@ -935,10 +1576,11 @@ var configSchema = z.object({
935
1576
  * - "html": Beautiful dark-themed HTML report
936
1577
  * - "json": Machine-readable structured JSON
937
1578
  * - "yaml": Human-readable YAML
938
- * - "all": Generate all three formats
1579
+ * - "sarif": SARIF v2.1.0 for GitHub Code Scanning
1580
+ * - "all": Generate all formats
939
1581
  * @default "html"
940
1582
  */
941
- format: z.enum(["html", "json", "yaml", "all"]).default("html"),
1583
+ format: z.enum(["html", "json", "yaml", "sarif", "all"]).default("html"),
942
1584
  /**
943
1585
  * Output directory for report files
944
1586
  * @default "."
@@ -956,14 +1598,14 @@ var configSchema = z.object({
956
1598
  open: z.boolean().default(false)
957
1599
  });
958
1600
  function getFormats(format) {
959
- if (format === "all") return ["html", "json", "yaml"];
1601
+ if (format === "all") return ["html", "json", "yaml", "sarif"];
960
1602
  return [format];
961
1603
  }
962
1604
  var plugin = {
963
1605
  name: "@vulcn/plugin-report",
964
1606
  version: "0.1.0",
965
1607
  apiVersion: 1,
966
- description: "Report generation plugin \u2014 generates beautiful HTML, JSON, and YAML security reports",
1608
+ description: "Report generation plugin \u2014 generates HTML, JSON, YAML, and SARIF security reports",
967
1609
  configSchema,
968
1610
  hooks: {
969
1611
  onInit: async (ctx) => {
@@ -973,13 +1615,20 @@ var plugin = {
973
1615
  );
974
1616
  },
975
1617
  /**
976
- * Generate report(s) after run completes
1618
+ * Generate report(s) after run completes.
1619
+ *
1620
+ * Architecture: RunResult + Session → buildReport() → VulcnReport
1621
+ * Each output format is a pure projection of the canonical model.
977
1622
  */
978
1623
  onRunEnd: async (result, ctx) => {
979
1624
  const config = configSchema.parse(ctx.config);
980
1625
  const formats = getFormats(config.format);
981
- const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
982
- const engineVersion = ctx.engine.version;
1626
+ const report = buildReport(
1627
+ ctx.session,
1628
+ result,
1629
+ (/* @__PURE__ */ new Date()).toISOString(),
1630
+ ctx.engine.version
1631
+ );
983
1632
  const outDir = resolve(config.outputDir);
984
1633
  await mkdir(outDir, { recursive: true });
985
1634
  const basePath = resolve(outDir, config.filename);
@@ -988,13 +1637,7 @@ var plugin = {
988
1637
  try {
989
1638
  switch (fmt) {
990
1639
  case "html": {
991
- const htmlData = {
992
- session: ctx.session,
993
- result,
994
- generatedAt,
995
- engineVersion
996
- };
997
- const html = generateHtml(htmlData);
1640
+ const html = generateHtml(report);
998
1641
  const htmlPath = `${basePath}.html`;
999
1642
  await writeFile(htmlPath, html, "utf-8");
1000
1643
  writtenFiles.push(htmlPath);
@@ -1002,12 +1645,7 @@ var plugin = {
1002
1645
  break;
1003
1646
  }
1004
1647
  case "json": {
1005
- const jsonReport = generateJson(
1006
- ctx.session,
1007
- result,
1008
- generatedAt,
1009
- engineVersion
1010
- );
1648
+ const jsonReport = generateJson(report);
1011
1649
  const jsonPath = `${basePath}.json`;
1012
1650
  await writeFile(
1013
1651
  jsonPath,
@@ -1019,18 +1657,25 @@ var plugin = {
1019
1657
  break;
1020
1658
  }
1021
1659
  case "yaml": {
1022
- const yamlContent = generateYaml(
1023
- ctx.session,
1024
- result,
1025
- generatedAt,
1026
- engineVersion
1027
- );
1660
+ const yamlContent = generateYaml(report);
1028
1661
  const yamlPath = `${basePath}.yml`;
1029
1662
  await writeFile(yamlPath, yamlContent, "utf-8");
1030
1663
  writtenFiles.push(yamlPath);
1031
1664
  ctx.logger.info(`\u{1F4C4} YAML report: ${yamlPath}`);
1032
1665
  break;
1033
1666
  }
1667
+ case "sarif": {
1668
+ const sarifReport = generateSarif(report);
1669
+ const sarifPath = `${basePath}.sarif`;
1670
+ await writeFile(
1671
+ sarifPath,
1672
+ JSON.stringify(sarifReport, null, 2),
1673
+ "utf-8"
1674
+ );
1675
+ writtenFiles.push(sarifPath);
1676
+ ctx.logger.info(`\u{1F4C4} SARIF report: ${sarifPath}`);
1677
+ break;
1678
+ }
1034
1679
  }
1035
1680
  } catch (err) {
1036
1681
  ctx.logger.error(
@@ -1053,10 +1698,12 @@ var plugin = {
1053
1698
  };
1054
1699
  var index_default = plugin;
1055
1700
  export {
1701
+ buildReport,
1056
1702
  configSchema,
1057
1703
  index_default as default,
1058
1704
  generateHtml,
1059
1705
  generateJson,
1706
+ generateSarif,
1060
1707
  generateYaml
1061
1708
  };
1062
1709
  //# sourceMappingURL=index.js.map