ngx-security-audit 1.0.0 → 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ngx-security-audit",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Enterprise-grade Angular security auditing tool. Static analysis scanner that detects XSS, CSRF, injection, sensitive data exposure, misconfigurations and 40+ security vulnerabilities in Angular projects. Generates detailed reports with severity levels for CI/CD pipeline integration.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -2,190 +2,633 @@
2
2
 
3
3
  const { SEVERITY, SEVERITY_LABELS } = require('../utils/severity');
4
4
 
5
- /**
6
- * HTML Reporter - Beautiful HTML report for stakeholders
7
- */
8
- function htmlReport(result) {
9
- const severityColors = {
10
- [SEVERITY.CRITICAL]: '#dc2626',
11
- [SEVERITY.HIGH]: '#ea580c',
12
- [SEVERITY.MEDIUM]: '#d97706',
13
- [SEVERITY.LOW]: '#2563eb',
14
- [SEVERITY.INFO]: '#6b7280',
5
+ const SEV_COLORS = {
6
+ [SEVERITY.CRITICAL]: '#ef4444',
7
+ [SEVERITY.HIGH]: '#f97316',
8
+ [SEVERITY.MEDIUM]: '#eab308',
9
+ [SEVERITY.LOW]: '#3b82f6',
10
+ [SEVERITY.INFO]: '#6b7280',
11
+ };
12
+
13
+ const OWASP_LABELS = {
14
+ A01: 'Broken Access Control',
15
+ A02: 'Cryptographic Failures',
16
+ A03: 'Injection',
17
+ A04: 'Insecure Design',
18
+ A05: 'Security Misconfiguration',
19
+ A06: 'Vulnerable Components',
20
+ A07: 'Auth Failures',
21
+ A08: 'Software Integrity',
22
+ A09: 'Logging Failures',
23
+ A10: 'SSRF',
24
+ };
25
+
26
+ function escapeHtml(str) {
27
+ if (!str) return '';
28
+ return String(str)
29
+ .replace(/&/g, '&')
30
+ .replace(/</g, '&lt;')
31
+ .replace(/>/g, '&gt;')
32
+ .replace(/"/g, '&quot;')
33
+ .replace(/'/g, '&#039;');
34
+ }
35
+
36
+ /* ── SVG Helpers ────────────────────────────────────── */
37
+
38
+ function buildDonutChart(summary) {
39
+ const data = [
40
+ { label: 'Critical', value: summary.critical, color: SEV_COLORS.critical },
41
+ { label: 'High', value: summary.high, color: SEV_COLORS.high },
42
+ { label: 'Medium', value: summary.medium, color: SEV_COLORS.medium },
43
+ { label: 'Low', value: summary.low, color: SEV_COLORS.low },
44
+ { label: 'Info', value: summary.info, color: SEV_COLORS.info },
45
+ ].filter((d) => d.value > 0);
46
+
47
+ if (data.length === 0) {
48
+ return `<svg viewBox="0 0 200 200" width="200" height="200">
49
+ <circle cx="100" cy="100" r="80" fill="none" stroke="#22c55e" stroke-width="30"/>
50
+ <text x="100" y="105" text-anchor="middle" fill="#22c55e" font-size="18" font-weight="800">CLEAN</text>
51
+ </svg>`;
52
+ }
53
+
54
+ const total = data.reduce((s, d) => s + d.value, 0);
55
+ const R = 80, CX = 100, CY = 100;
56
+ const circumference = 2 * Math.PI * R;
57
+ let offset = 0;
58
+ const arcs = data.map((d) => {
59
+ const pct = d.value / total;
60
+ const dash = pct * circumference;
61
+ const gap = circumference - dash;
62
+ const arc = `<circle cx="${CX}" cy="${CY}" r="${R}" fill="none" stroke="${d.color}" stroke-width="30" stroke-dasharray="${dash.toFixed(2)} ${gap.toFixed(2)}" stroke-dashoffset="${(-offset).toFixed(2)}" transform="rotate(-90 ${CX} ${CY})"/>`;
63
+ offset += dash;
64
+ return arc;
65
+ });
66
+
67
+ return `<svg viewBox="0 0 200 200" width="200" height="200">
68
+ ${arcs.join('\n ')}
69
+ <circle cx="${CX}" cy="${CY}" r="60" fill="#0f172a"/>
70
+ <text x="${CX}" y="${CY - 5}" text-anchor="middle" fill="#f1f5f9" font-size="28" font-weight="800">${total}</text>
71
+ <text x="${CX}" y="${CY + 16}" text-anchor="middle" fill="#94a3b8" font-size="11">issues</text>
72
+ </svg>`;
73
+ }
74
+
75
+ function buildBarChart(summary) {
76
+ const bars = [
77
+ { label: 'Critical', value: summary.critical, color: SEV_COLORS.critical },
78
+ { label: 'High', value: summary.high, color: SEV_COLORS.high },
79
+ { label: 'Medium', value: summary.medium, color: SEV_COLORS.medium },
80
+ { label: 'Low', value: summary.low, color: SEV_COLORS.low },
81
+ { label: 'Info', value: summary.info, color: SEV_COLORS.info },
82
+ ];
83
+
84
+ const maxVal = Math.max(...bars.map((b) => b.value), 1);
85
+ const barW = 48, gap = 16, chartH = 160, padTop = 20, padBottom = 30;
86
+ const totalW = bars.length * barW + (bars.length - 1) * gap + 40;
87
+
88
+ const rects = bars.map((b, i) => {
89
+ const x = 20 + i * (barW + gap);
90
+ const h = (b.value / maxVal) * (chartH - padTop);
91
+ const y = chartH - h;
92
+ return `
93
+ <rect x="${x}" y="${y}" width="${barW}" height="${h}" rx="6" fill="${b.color}" opacity="0.85"/>
94
+ <text x="${x + barW / 2}" y="${y - 6}" text-anchor="middle" fill="#f1f5f9" font-size="13" font-weight="700">${b.value}</text>
95
+ <text x="${x + barW / 2}" y="${chartH + 16}" text-anchor="middle" fill="#94a3b8" font-size="10">${b.label}</text>`;
96
+ });
97
+
98
+ return `<svg viewBox="0 0 ${totalW} ${chartH + padBottom}" width="${totalW}" height="${chartH + padBottom}">
99
+ <line x1="15" y1="${chartH}" x2="${totalW - 15}" y2="${chartH}" stroke="#334155" stroke-width="1"/>
100
+ ${rects.join('')}
101
+ </svg>`;
102
+ }
103
+
104
+ function buildScoreGauge(score) {
105
+ const R = 70, CX = 90, CY = 90;
106
+ const arc = Math.PI * 1.5; // 270° arc
107
+ const circumference = arc * R;
108
+ const filled = (score / 100) * circumference;
109
+ const gap = circumference - filled;
110
+ const color = score >= 80 ? '#22c55e' : score >= 60 ? '#eab308' : '#ef4444';
111
+
112
+ return `<svg viewBox="0 0 180 180" width="180" height="180">
113
+ <circle cx="${CX}" cy="${CY}" r="${R}" fill="none" stroke="#1e293b" stroke-width="14"
114
+ stroke-dasharray="${circumference.toFixed(2)} 9999" stroke-dashoffset="0"
115
+ transform="rotate(135 ${CX} ${CY})" stroke-linecap="round"/>
116
+ <circle cx="${CX}" cy="${CY}" r="${R}" fill="none" stroke="${color}" stroke-width="14"
117
+ stroke-dasharray="${filled.toFixed(2)} ${gap.toFixed(2)}" stroke-dashoffset="0"
118
+ transform="rotate(135 ${CX} ${CY})" stroke-linecap="round"/>
119
+ <text x="${CX}" y="${CY - 2}" text-anchor="middle" fill="${color}" font-size="36" font-weight="900">${score}</text>
120
+ <text x="${CX}" y="${CY + 18}" text-anchor="middle" fill="#94a3b8" font-size="12">/ 100</text>
121
+ </svg>`;
122
+ }
123
+
124
+ function buildCategoryChart(findings) {
125
+ const cats = {};
126
+ for (const f of findings) {
127
+ cats[f.category] = (cats[f.category] || 0) + 1;
128
+ }
129
+ const entries = Object.entries(cats).sort((a, b) => b[1] - a[1]);
130
+ if (entries.length === 0) return '';
131
+
132
+ const maxVal = Math.max(...entries.map((e) => e[1]), 1);
133
+ const barH = 28, gap = 8, padLeft = 150, padRight = 50;
134
+ const totalH = entries.length * (barH + gap) + 10;
135
+ const chartW = 500;
136
+ const barArea = chartW - padLeft - padRight;
137
+ const colors = ['#38bdf8', '#818cf8', '#c084fc', '#f472b6', '#fb923c', '#34d399', '#fbbf24'];
138
+
139
+ const rows = entries.map(([cat, count], i) => {
140
+ const y = i * (barH + gap) + 5;
141
+ const w = (count / maxVal) * barArea;
142
+ const c = colors[i % colors.length];
143
+ return `
144
+ <text x="${padLeft - 10}" y="${y + barH / 2 + 4}" text-anchor="end" fill="#cbd5e1" font-size="12">${escapeHtml(cat)}</text>
145
+ <rect x="${padLeft}" y="${y}" width="${w}" height="${barH}" rx="6" fill="${c}" opacity="0.8"/>
146
+ <text x="${padLeft + w + 8}" y="${y + barH / 2 + 4}" fill="#f1f5f9" font-size="12" font-weight="700">${count}</text>`;
147
+ });
148
+
149
+ return `<svg viewBox="0 0 ${chartW} ${totalH}" width="${chartW}" height="${totalH}">
150
+ ${rows.join('')}
151
+ </svg>`;
152
+ }
153
+
154
+ function buildOwaspRadar(findings) {
155
+ const mappings = {
156
+ 'XSS': ['A03'],
157
+ 'Injection': ['A03'],
158
+ 'Authentication': ['A01', 'A07'],
159
+ 'Sensitive Data': ['A02', 'A09'],
160
+ 'HTTP Security': ['A02', 'A05'],
161
+ 'Angular Config': ['A04', 'A05', 'A06', 'A10'],
162
+ 'Best Practices': ['A01', 'A03', 'A04', 'A06'],
15
163
  };
16
164
 
17
- const scoreColor = result.score >= 80 ? '#16a34a' : result.score >= 60 ? '#d97706' : '#dc2626';
165
+ const owaspCounts = {};
166
+ const owaspKeys = ['A01', 'A02', 'A03', 'A04', 'A05', 'A06', 'A07', 'A09', 'A10'];
167
+ owaspKeys.forEach((k) => (owaspCounts[k] = 0));
18
168
 
19
- const findingsHtml = result.findings
169
+ for (const f of findings) {
170
+ const tags = mappings[f.category] || [];
171
+ for (const t of tags) {
172
+ if (owaspCounts[t] !== undefined) owaspCounts[t]++;
173
+ }
174
+ }
175
+
176
+ const n = owaspKeys.length;
177
+ const CX = 160, CY = 160, R = 120;
178
+ const maxVal = Math.max(...Object.values(owaspCounts), 1);
179
+
180
+ // Grid circles
181
+ const gridCircles = [0.25, 0.5, 0.75, 1].map((p) =>
182
+ `<circle cx="${CX}" cy="${CY}" r="${R * p}" fill="none" stroke="#334155" stroke-width="0.5"/>`
183
+ ).join('\n ');
184
+
185
+ // Axis lines and labels
186
+ const axes = owaspKeys.map((key, i) => {
187
+ const angle = (2 * Math.PI * i) / n - Math.PI / 2;
188
+ const x2 = CX + R * Math.cos(angle);
189
+ const y2 = CY + R * Math.sin(angle);
190
+ const lx = CX + (R + 22) * Math.cos(angle);
191
+ const ly = CY + (R + 22) * Math.sin(angle);
192
+ return `
193
+ <line x1="${CX}" y1="${CY}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="#334155" stroke-width="0.5"/>
194
+ <text x="${lx.toFixed(1)}" y="${(ly + 4).toFixed(1)}" text-anchor="middle" fill="#94a3b8" font-size="9">${key}</text>`;
195
+ });
196
+
197
+ // Data polygon
198
+ const points = owaspKeys.map((key, i) => {
199
+ const angle = (2 * Math.PI * i) / n - Math.PI / 2;
200
+ const r = (owaspCounts[key] / maxVal) * R;
201
+ return `${(CX + r * Math.cos(angle)).toFixed(1)},${(CY + r * Math.sin(angle)).toFixed(1)}`;
202
+ }).join(' ');
203
+
204
+ // Data dots
205
+ const dots = owaspKeys.map((key, i) => {
206
+ const angle = (2 * Math.PI * i) / n - Math.PI / 2;
207
+ const r = (owaspCounts[key] / maxVal) * R;
208
+ const x = CX + r * Math.cos(angle);
209
+ const y = CY + r * Math.sin(angle);
210
+ return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" fill="#38bdf8"/>`;
211
+ });
212
+
213
+ return `<svg viewBox="0 0 320 320" width="320" height="320">
214
+ ${gridCircles}
215
+ ${axes.join('')}
216
+ <polygon points="${points}" fill="rgba(56,189,248,0.15)" stroke="#38bdf8" stroke-width="2"/>
217
+ ${dots.join('\n ')}
218
+ </svg>`;
219
+ }
220
+
221
+ function buildFileHeatmap(findings) {
222
+ const fileCounts = {};
223
+ for (const f of findings) {
224
+ const short = f.file ? f.file.replace(/\\/g, '/').split('/').slice(-2).join('/') : 'unknown';
225
+ fileCounts[short] = (fileCounts[short] || 0) + 1;
226
+ }
227
+ const entries = Object.entries(fileCounts).sort((a, b) => b[1] - a[1]).slice(0, 10);
228
+ if (entries.length === 0) return '';
229
+
230
+ const maxVal = Math.max(...entries.map((e) => e[1]), 1);
231
+ const barH = 26, gap = 6, padLeft = 200, padRight = 50;
232
+ const totalH = entries.length * (barH + gap) + 10;
233
+ const chartW = 560;
234
+ const barArea = chartW - padLeft - padRight;
235
+
236
+ const rows = entries.map(([file, count], i) => {
237
+ const y = i * (barH + gap) + 5;
238
+ const w = Math.max(4, (count / maxVal) * barArea);
239
+ const heat = count / maxVal;
240
+ const color = heat > 0.66 ? '#ef4444' : heat > 0.33 ? '#f97316' : '#eab308';
241
+ return `
242
+ <text x="${padLeft - 10}" y="${y + barH / 2 + 4}" text-anchor="end" fill="#cbd5e1" font-size="11" font-family="monospace">${escapeHtml(file)}</text>
243
+ <rect x="${padLeft}" y="${y}" width="${w}" height="${barH}" rx="5" fill="${color}" opacity="0.75"/>
244
+ <text x="${padLeft + w + 8}" y="${y + barH / 2 + 4}" fill="#f1f5f9" font-size="11" font-weight="700">${count}</text>`;
245
+ });
246
+
247
+ return `<svg viewBox="0 0 ${chartW} ${totalH}" width="100%" height="${totalH}" preserveAspectRatio="xMinYMin meet">
248
+ ${rows.join('')}
249
+ </svg>`;
250
+ }
251
+
252
+ /* ── Executive Summary ──────────────────────────────── */
253
+
254
+ function buildExecutiveSummary(result) {
255
+ const { critical, high, medium, low, info, total } = result.summary;
256
+ const urgent = critical + high;
257
+ const riskLevel = critical > 0 ? 'CRITICAL' : high > 0 ? 'HIGH' : medium > 0 ? 'MODERATE' : 'LOW';
258
+ const riskColor = critical > 0 ? '#ef4444' : high > 0 ? '#f97316' : medium > 0 ? '#eab308' : '#22c55e';
259
+
260
+ const bullets = [];
261
+ if (critical > 0) bullets.push(`<li><strong style="color:#ef4444">${critical} critical</strong> vulnerability${critical > 1 ? 'ies' : 'y'} requiring immediate remediation.</li>`);
262
+ if (high > 0) bullets.push(`<li><strong style="color:#f97316">${high} high-severity</strong> issue${high > 1 ? 's' : ''} that should be addressed before production.</li>`);
263
+ if (medium > 0) bullets.push(`<li><strong style="color:#eab308">${medium} medium</strong> finding${medium > 1 ? 's' : ''} recommended for next sprint.</li>`);
264
+ if (low + info > 0) bullets.push(`<li><strong style="color:#3b82f6">${low + info} low/informational</strong> item${(low + info) > 1 ? 's' : ''} for continuous improvement.</li>`);
265
+ if (total === 0) bullets.push('<li style="color:#22c55e"><strong>No security issues detected.</strong> The application passed all checks.</li>');
266
+
267
+ return `
268
+ <div class="exec-summary">
269
+ <h2>📋 Executive Summary</h2>
270
+ <div class="exec-grid">
271
+ <div class="exec-card">
272
+ <div class="exec-label">Overall Risk</div>
273
+ <div class="exec-value" style="color:${riskColor}">${riskLevel}</div>
274
+ </div>
275
+ <div class="exec-card">
276
+ <div class="exec-label">Urgent Actions</div>
277
+ <div class="exec-value" style="color:${urgent > 0 ? '#ef4444' : '#22c55e'}">${urgent}</div>
278
+ </div>
279
+ <div class="exec-card">
280
+ <div class="exec-label">Total Findings</div>
281
+ <div class="exec-value">${total}</div>
282
+ </div>
283
+ <div class="exec-card">
284
+ <div class="exec-label">Attack Surface</div>
285
+ <div class="exec-value">${result.filesScanned} files</div>
286
+ </div>
287
+ </div>
288
+ <ul class="exec-bullets">${bullets.join('\n ')}</ul>
289
+ </div>`;
290
+ }
291
+
292
+ /* ── Top Risks + Remediation ────────────────────────── */
293
+
294
+ function buildTopRisks(findings) {
295
+ const top = findings
20
296
  .sort((a, b) => {
21
297
  const order = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
22
298
  return (order[a.severity] || 5) - (order[b.severity] || 5);
23
299
  })
24
- .map((f) => `
25
- <div class="finding finding-${f.severity}">
26
- <div class="finding-header">
27
- <span class="badge" style="background:${severityColors[f.severity]}">${SEVERITY_LABELS[f.severity]}</span>
28
- <span class="rule-id">${escapeHtml(f.ruleId)}</span>
29
- <span class="category">${escapeHtml(f.category)}</span>
30
- </div>
31
- <p class="finding-message">${escapeHtml(f.message)}</p>
32
- <div class="finding-meta">
33
- <span>📁 ${escapeHtml(f.file)}${f.line ? `:${f.line}` : ''}</span>
34
- </div>
35
- ${f.code ? `<pre class="code-block"><code>${escapeHtml(f.code)}</code></pre>` : ''}
36
- ${f.recommendation ? `<div class="recommendation">✅ <strong>Recommendation:</strong> ${escapeHtml(f.recommendation)}</div>` : ''}
300
+ .slice(0, 5);
301
+
302
+ if (top.length === 0) return '';
303
+
304
+ const rows = top.map((f, i) => `
305
+ <tr>
306
+ <td class="rank">#${i + 1}</td>
307
+ <td><span class="badge-sm" style="background:${SEV_COLORS[f.severity]}">${SEVERITY_LABELS[f.severity]}</span></td>
308
+ <td class="rule-id-cell">${escapeHtml(f.ruleId)}</td>
309
+ <td>${escapeHtml(f.message).substring(0, 100)}</td>
310
+ <td class="rec-cell">${escapeHtml(f.recommendation || 'See documentation')}</td>
311
+ </tr>`).join('');
312
+
313
+ return `
314
+ <div class="section-block">
315
+ <h2>🔥 Top Risks &amp; Remediation</h2>
316
+ <div class="table-wrap">
317
+ <table class="data-table">
318
+ <thead><tr><th>#</th><th>Severity</th><th>Rule</th><th>Issue</th><th>Recommended Fix</th></tr></thead>
319
+ <tbody>${rows}</tbody>
320
+ </table>
37
321
  </div>
38
- `)
39
- .join('\n');
322
+ </div>`;
323
+ }
324
+
325
+ /* ── OWASP Compliance Table ─────────────────────────── */
326
+
327
+ function buildOwaspTable(findings) {
328
+ const mappings = {
329
+ 'XSS': ['A03'],
330
+ 'Injection': ['A03'],
331
+ 'Authentication': ['A01', 'A07'],
332
+ 'Sensitive Data': ['A02', 'A09'],
333
+ 'HTTP Security': ['A02', 'A05'],
334
+ 'Angular Config': ['A04', 'A05', 'A06', 'A10'],
335
+ 'Best Practices': ['A01', 'A03', 'A04', 'A06'],
336
+ };
337
+
338
+ const owaspCounts = {};
339
+ const owaspKeys = ['A01', 'A02', 'A03', 'A04', 'A05', 'A06', 'A07', 'A08', 'A09', 'A10'];
340
+ owaspKeys.forEach((k) => (owaspCounts[k] = 0));
341
+
342
+ for (const f of findings) {
343
+ const tags = mappings[f.category] || [];
344
+ for (const t of tags) {
345
+ if (owaspCounts[t] !== undefined) owaspCounts[t]++;
346
+ }
347
+ }
348
+
349
+ const rows = owaspKeys.map((k) => {
350
+ const count = owaspCounts[k];
351
+ const status = count === 0 ? '<span style="color:#22c55e">✅ Pass</span>' : `<span style="color:#ef4444">⚠️ ${count} issue${count > 1 ? 's' : ''}</span>`;
352
+ return `<tr><td><strong>${k}</strong></td><td>${OWASP_LABELS[k] || k}</td><td>${status}</td></tr>`;
353
+ }).join('');
354
+
355
+ return `
356
+ <div class="section-block">
357
+ <h2>🌐 OWASP Top 10 Compliance</h2>
358
+ <div class="table-wrap">
359
+ <table class="data-table"><thead><tr><th>ID</th><th>Category</th><th>Status</th></tr></thead><tbody>${rows}</tbody></table>
360
+ </div>
361
+ </div>`;
362
+ }
363
+
364
+ /* ── Main Report ────────────────────────────────────── */
365
+
366
+ function htmlReport(result) {
367
+ const scoreColor = result.score >= 80 ? '#22c55e' : result.score >= 60 ? '#eab308' : '#ef4444';
368
+
369
+ const sortedFindings = [...result.findings].sort((a, b) => {
370
+ const order = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
371
+ return (order[a.severity] || 5) - (order[b.severity] || 5);
372
+ });
373
+
374
+ const findingsHtml = sortedFindings.map((f) => `
375
+ <div class="finding finding-${f.severity}">
376
+ <div class="finding-header">
377
+ <span class="badge" style="background:${SEV_COLORS[f.severity]}">${SEVERITY_LABELS[f.severity]}</span>
378
+ <span class="rule-id">${escapeHtml(f.ruleId)}</span>
379
+ <span class="cat-tag">${escapeHtml(f.category)}</span>
380
+ </div>
381
+ <p class="finding-message">${escapeHtml(f.message)}</p>
382
+ <div class="finding-meta">📁 ${escapeHtml(f.file)}${f.line ? `:${f.line}` : ''}</div>
383
+ ${f.code ? `<pre class="code-block"><code>${escapeHtml(f.code)}</code></pre>` : ''}
384
+ ${f.recommendation ? `<div class="recommendation">💡 <strong>Fix:</strong> ${escapeHtml(f.recommendation)}</div>` : ''}
385
+ </div>`).join('\n');
40
386
 
41
387
  return `<!DOCTYPE html>
42
388
  <html lang="en">
43
389
  <head>
44
390
  <meta charset="UTF-8">
45
391
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
46
- <title>NGX Security Audit Report - ${escapeHtml(result.projectName || 'Angular Project')}</title>
392
+ <title>Security Report ${escapeHtml(result.projectName || 'Angular Project')}</title>
47
393
  <style>
48
- :root {
49
- --bg: #0f172a;
50
- --surface: #1e293b;
51
- --surface2: #334155;
52
- --text: #f1f5f9;
53
- --text-dim: #94a3b8;
54
- --accent: #38bdf8;
55
- --success: #22c55e;
56
- --danger: #ef4444;
57
- --border: #475569;
58
- }
59
- * { margin: 0; padding: 0; box-sizing: border-box; }
60
- body { font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; }
61
- .container { max-width: 1200px; margin: 0 auto; padding: 2rem; }
62
- header { text-align: center; padding: 3rem 0; border-bottom: 1px solid var(--border); margin-bottom: 2rem; }
63
- header h1 { font-size: 2.5rem; background: linear-gradient(135deg, #38bdf8, #818cf8); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin-bottom: 0.5rem; }
64
- header .subtitle { color: var(--text-dim); font-size: 1.1rem; }
65
- .info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
66
- .info-card { background: var(--surface); border-radius: 12px; padding: 1.5rem; border: 1px solid var(--border); }
67
- .info-card label { color: var(--text-dim); font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; }
68
- .info-card .value { font-size: 1.3rem; font-weight: 600; margin-top: 0.25rem; }
69
- .score-section { text-align: center; padding: 3rem; background: var(--surface); border-radius: 16px; margin-bottom: 2rem; border: 1px solid var(--border); }
70
- .score-circle { display: inline-flex; align-items: center; justify-content: center; width: 160px; height: 160px; border-radius: 50%; border: 8px solid ${scoreColor}; margin-bottom: 1rem; }
71
- .score-number { font-size: 3rem; font-weight: 800; color: ${scoreColor}; }
72
- .score-grade { font-size: 1.5rem; color: ${scoreColor}; font-weight: 700; }
73
- .status { display: inline-block; padding: 0.5rem 2rem; border-radius: 50px; font-weight: 700; font-size: 1.1rem; margin-top: 1rem; }
74
- .status-pass { background: rgba(34, 197, 94, 0.15); color: #22c55e; border: 2px solid #22c55e; }
75
- .status-fail { background: rgba(239, 68, 68, 0.15); color: #ef4444; border: 2px solid #ef4444; }
76
- .summary-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 1rem; margin-bottom: 2rem; }
77
- .summary-card { background: var(--surface); border-radius: 12px; padding: 1.2rem; text-align: center; border: 1px solid var(--border); }
78
- .summary-card .count { font-size: 2rem; font-weight: 800; }
79
- .summary-card .label { color: var(--text-dim); font-size: 0.85rem; text-transform: uppercase; }
80
- .findings-section h2 { font-size: 1.5rem; margin-bottom: 1rem; padding-bottom: 0.5rem; border-bottom: 2px solid var(--accent); }
81
- .finding { background: var(--surface); border-radius: 12px; padding: 1.5rem; margin-bottom: 1rem; border-left: 4px solid var(--border); border: 1px solid var(--border); }
82
- .finding-critical { border-left-color: #dc2626; }
83
- .finding-high { border-left-color: #ea580c; }
84
- .finding-medium { border-left-color: #d97706; }
85
- .finding-low { border-left-color: #2563eb; }
86
- .finding-info { border-left-color: #6b7280; }
87
- .finding-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; flex-wrap: wrap; }
88
- .badge { padding: 0.2rem 0.75rem; border-radius: 50px; color: white; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; }
89
- .rule-id { font-family: monospace; color: var(--accent); font-size: 0.9rem; }
90
- .category { color: var(--text-dim); font-size: 0.85rem; background: var(--surface2); padding: 0.15rem 0.5rem; border-radius: 4px; }
91
- .finding-message { margin-bottom: 0.75rem; }
92
- .finding-meta { color: var(--text-dim); font-size: 0.85rem; margin-bottom: 0.75rem; }
93
- .code-block { background: #0d1117; border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 0.75rem 0; border: 1px solid #30363d; }
94
- .code-block code { font-family: 'Cascadia Code', 'Fira Code', monospace; font-size: 0.85rem; color: #c9d1d9; white-space: pre; }
95
- .recommendation { background: rgba(34, 197, 94, 0.08); border: 1px solid rgba(34, 197, 94, 0.2); border-radius: 8px; padding: 0.75rem 1rem; font-size: 0.9rem; color: #86efac; }
96
- footer { text-align: center; padding: 2rem 0; color: var(--text-dim); border-top: 1px solid var(--border); margin-top: 2rem; font-size: 0.85rem; }
97
- @media (max-width: 768px) {
98
- .summary-grid { grid-template-columns: repeat(2, 1fr); }
99
- .info-grid { grid-template-columns: 1fr; }
100
- header h1 { font-size: 1.8rem; }
394
+ :root{--bg:#0f172a;--surface:#1e293b;--surface2:#334155;--text:#f1f5f9;--text-dim:#94a3b8;--accent:#38bdf8;--accent2:#818cf8;--success:#22c55e;--danger:#ef4444;--border:#475569;--gradient:linear-gradient(135deg,#38bdf8,#818cf8,#c084fc)}
395
+ *{margin:0;padding:0;box-sizing:border-box}
396
+ body{font-family:'Segoe UI',system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);line-height:1.7}
397
+ a{color:var(--accent)}
398
+ code,pre{font-family:'Cascadia Code','Fira Code','JetBrains Mono',monospace}
399
+ .container{max-width:1280px;margin:0 auto;padding:2rem}
400
+
401
+ /* Print */
402
+ @media print{body{background:#fff;color:#111}.container{max-width:100%}nav,.no-print{display:none!important}.surface,.section-block,.finding,.exec-card,.info-card,.score-section,.chart-panel{background:#f8fafc!important;border-color:#d1d5db!important;color:#111!important}h1,h2,h3,.score-number,.exec-value,.count{color:#111!important;-webkit-text-fill-color:#111!important}}
403
+
404
+ /* Nav */
405
+ nav{position:sticky;top:0;z-index:100;background:rgba(15,23,42,0.92);backdrop-filter:blur(16px);border-bottom:1px solid var(--border);padding:0.75rem 0}
406
+ nav .container{display:flex;align-items:center;justify-content:space-between;padding-top:0;padding-bottom:0}
407
+ nav .logo{font-weight:800;font-size:1.1rem;background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
408
+ .nav-links{display:flex;gap:1.5rem}
409
+ .nav-links a{color:var(--text-dim);font-size:0.85rem;font-weight:500;text-decoration:none}
410
+ .nav-links a:hover{color:var(--text)}
411
+ .print-btn{background:var(--surface2);color:var(--text);border:1px solid var(--border);padding:0.4rem 1rem;border-radius:8px;font-size:0.8rem;cursor:pointer;font-weight:600}
412
+ .print-btn:hover{background:var(--accent);color:var(--bg);border-color:var(--accent)}
413
+
414
+ /* Header */
415
+ .report-header{text-align:center;padding:3rem 0 2rem;border-bottom:1px solid var(--border);margin-bottom:2rem}
416
+ .report-header h1{font-size:2.8rem;font-weight:900;background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:0.5rem}
417
+ .report-header .subtitle{color:var(--text-dim);font-size:1.1rem}
418
+ .report-header .ts{color:var(--text-dim);font-size:0.85rem;margin-top:0.5rem}
419
+
420
+ /* Info strip */
421
+ .info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem;margin-bottom:2rem}
422
+ .info-card{background:var(--surface);border-radius:12px;padding:1.2rem 1.5rem;border:1px solid var(--border)}
423
+ .info-card label{color:var(--text-dim);font-size:0.78rem;text-transform:uppercase;letter-spacing:0.06em}
424
+ .info-card .value{font-size:1.2rem;font-weight:700;margin-top:0.2rem}
425
+
426
+ /* Score section */
427
+ .score-section{text-align:center;padding:2.5rem;background:var(--surface);border-radius:16px;margin-bottom:2rem;border:1px solid var(--border)}
428
+ .score-grade{font-size:1.5rem;color:${scoreColor};font-weight:800;margin-top:0.5rem}
429
+ .status{display:inline-block;padding:0.5rem 2rem;border-radius:50px;font-weight:700;font-size:1rem;margin-top:1rem}
430
+ .status-pass{background:rgba(34,197,94,0.12);color:#22c55e;border:2px solid #22c55e}
431
+ .status-fail{background:rgba(239,68,68,0.12);color:#ef4444;border:2px solid #ef4444}
432
+
433
+ /* Charts */
434
+ .charts-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:1.5rem;margin-bottom:2rem}
435
+ .chart-panel{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:1.5rem;display:flex;flex-direction:column;align-items:center}
436
+ .chart-panel h3{font-size:1rem;margin-bottom:1rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.04em;font-weight:600}
437
+
438
+ /* Executive summary */
439
+ .exec-summary{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:2rem;margin-bottom:2rem}
440
+ .exec-summary h2{font-size:1.4rem;margin-bottom:1rem}
441
+ .exec-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:1rem;margin-bottom:1.5rem}
442
+ .exec-card{background:var(--bg);border-radius:12px;padding:1.2rem;text-align:center;border:1px solid var(--border)}
443
+ .exec-label{color:var(--text-dim);font-size:0.8rem;text-transform:uppercase;letter-spacing:0.04em}
444
+ .exec-value{font-size:1.6rem;font-weight:800;margin-top:0.3rem}
445
+ .exec-bullets{list-style:none;padding:0}
446
+ .exec-bullets li{padding:0.4rem 0;padding-left:1.2rem;position:relative;font-size:0.95rem}
447
+ .exec-bullets li::before{content:'›';position:absolute;left:0;color:var(--accent);font-weight:800}
448
+
449
+ /* Summary cards */
450
+ .summary-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:1rem;margin-bottom:2rem}
451
+ .summary-card{background:var(--surface);border-radius:12px;padding:1.2rem;text-align:center;border:1px solid var(--border);transition:transform 0.2s}
452
+ .summary-card:hover{transform:translateY(-2px)}
453
+ .summary-card .count{font-size:2.2rem;font-weight:800}
454
+ .summary-card .label{color:var(--text-dim);font-size:0.8rem;text-transform:uppercase;margin-top:0.2rem}
455
+
456
+ /* Section blocks */
457
+ .section-block{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:2rem;margin-bottom:2rem}
458
+ .section-block h2{font-size:1.4rem;margin-bottom:1.2rem}
459
+
460
+ /* Tables */
461
+ .table-wrap{overflow-x:auto;border-radius:10px;border:1px solid var(--border);background:var(--bg)}
462
+ .data-table{width:100%;border-collapse:collapse}
463
+ .data-table th{text-align:left;padding:0.6rem 1rem;color:var(--text-dim);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;border-bottom:2px solid var(--border)}
464
+ .data-table td{padding:0.6rem 1rem;border-bottom:1px solid var(--border);font-size:0.88rem}
465
+ .data-table tr:hover{background:var(--surface)}
466
+ .rank{font-weight:800;color:var(--accent);font-size:1rem}
467
+ .rule-id-cell{font-family:monospace;color:var(--accent);font-size:0.85rem}
468
+ .rec-cell{font-size:0.82rem;color:#86efac;max-width:250px}
469
+ .badge-sm{padding:0.15rem 0.6rem;border-radius:50px;color:white;font-size:0.7rem;font-weight:700;text-transform:uppercase}
470
+
471
+ /* Findings */
472
+ .findings-section{margin-bottom:2rem}
473
+ .findings-section > h2{font-size:1.5rem;margin-bottom:1rem;padding-bottom:0.5rem;border-bottom:2px solid var(--accent)}
474
+ .finding{background:var(--surface);border-radius:12px;padding:1.5rem;margin-bottom:1rem;border:1px solid var(--border);border-left:4px solid var(--border)}
475
+ .finding-critical{border-left-color:#ef4444}
476
+ .finding-high{border-left-color:#f97316}
477
+ .finding-medium{border-left-color:#eab308}
478
+ .finding-low{border-left-color:#3b82f6}
479
+ .finding-info{border-left-color:#6b7280}
480
+ .finding-header{display:flex;align-items:center;gap:0.75rem;margin-bottom:0.75rem;flex-wrap:wrap}
481
+ .badge{padding:0.2rem 0.75rem;border-radius:50px;color:white;font-size:0.72rem;font-weight:700;text-transform:uppercase}
482
+ .rule-id{font-family:monospace;color:var(--accent);font-size:0.88rem}
483
+ .cat-tag{color:var(--text-dim);font-size:0.82rem;background:var(--surface2);padding:0.12rem 0.5rem;border-radius:4px}
484
+ .finding-message{margin-bottom:0.75rem}
485
+ .finding-meta{color:var(--text-dim);font-size:0.82rem;margin-bottom:0.75rem;font-family:monospace}
486
+ .code-block{background:#0d1117;border-radius:8px;padding:1rem;overflow-x:auto;margin:0.75rem 0;border:1px solid #30363d}
487
+ .code-block code{font-size:0.82rem;color:#c9d1d9;white-space:pre}
488
+ .recommendation{background:rgba(34,197,94,0.08);border:1px solid rgba(34,197,94,0.2);border-radius:8px;padding:0.75rem 1rem;font-size:0.88rem;color:#86efac}
489
+
490
+ /* Filter */
491
+ .filter-bar{display:flex;gap:0.5rem;margin-bottom:1.5rem;flex-wrap:wrap}
492
+ .filter-btn{padding:0.4rem 1rem;border-radius:50px;font-size:0.82rem;font-weight:600;cursor:pointer;background:var(--surface2);color:var(--text-dim);border:1px solid var(--border);transition:all 0.2s}
493
+ .filter-btn:hover,.filter-btn.active{background:var(--accent);color:var(--bg);border-color:var(--accent)}
494
+
495
+ /* Footer */
496
+ footer{text-align:center;padding:2rem 0;color:var(--text-dim);border-top:1px solid var(--border);margin-top:2rem;font-size:0.82rem}
497
+ footer strong{color:var(--accent)}
498
+
499
+ @media(max-width:768px){
500
+ .summary-grid{grid-template-columns:repeat(2,1fr)}
501
+ .info-grid{grid-template-columns:1fr 1fr}
502
+ .report-header h1{font-size:1.8rem}
503
+ .charts-row{grid-template-columns:1fr}
101
504
  }
102
505
  </style>
103
506
  </head>
104
507
  <body>
508
+
509
+ <nav class="no-print">
510
+ <div class="container">
511
+ <span class="logo">🛡️ ngx-security-audit</span>
512
+ <div class="nav-links">
513
+ <a href="#summary">Summary</a>
514
+ <a href="#charts">Charts</a>
515
+ <a href="#owasp">OWASP</a>
516
+ <a href="#findings">Findings</a>
517
+ <button class="print-btn" onclick="window.print()">🖨️ Print / PDF</button>
518
+ </div>
519
+ </div>
520
+ </nav>
521
+
105
522
  <div class="container">
106
- <header>
107
- <h1>🛡️ NGX Security Audit Report</h1>
108
- <p class="subtitle">Angular Application Security Analysis powered by ngx-security-audit</p>
109
- </header>
110
523
 
524
+ <!-- Header -->
525
+ <div class="report-header">
526
+ <h1>🛡️ Security Audit Report</h1>
527
+ <p class="subtitle">${escapeHtml(result.projectName || 'Angular Project')} — Comprehensive Security Analysis</p>
528
+ <p class="ts">Generated ${new Date(result.scanDate).toLocaleString()} by <strong>ngx-security-audit v1.1.0</strong></p>
529
+ </div>
530
+
531
+ <!-- Project Info -->
111
532
  <div class="info-grid">
112
- <div class="info-card">
113
- <label>Project</label>
114
- <div class="value">${escapeHtml(result.projectName || 'Angular Project')}</div>
115
- </div>
116
- <div class="info-card">
117
- <label>Angular Version</label>
118
- <div class="value">${escapeHtml(result.angularVersion || 'Unknown')}</div>
119
- </div>
120
- <div class="info-card">
121
- <label>Scan Date</label>
122
- <div class="value">${new Date(result.scanDate).toLocaleDateString()}</div>
123
- </div>
124
- <div class="info-card">
125
- <label>Files Scanned</label>
126
- <div class="value">${result.filesScanned}</div>
127
- </div>
128
- <div class="info-card">
129
- <label>Rules Executed</label>
130
- <div class="value">${result.rulesExecuted}</div>
131
- </div>
533
+ <div class="info-card"><label>Project</label><div class="value">${escapeHtml(result.projectName || 'Angular Project')}</div></div>
534
+ <div class="info-card"><label>Angular</label><div class="value">${escapeHtml(result.angularVersion || 'N/A')}</div></div>
535
+ <div class="info-card"><label>Scan Date</label><div class="value">${new Date(result.scanDate).toLocaleDateString()}</div></div>
536
+ <div class="info-card"><label>Files Scanned</label><div class="value">${result.filesScanned}</div></div>
537
+ <div class="info-card"><label>Rules Executed</label><div class="value">${result.rulesExecuted}</div></div>
538
+ <div class="info-card"><label>Threshold</label><div class="value" style="text-transform:uppercase">${escapeHtml(result.threshold)}</div></div>
132
539
  </div>
133
540
 
134
- <div class="score-section">
135
- <div class="score-circle">
136
- <span class="score-number">${result.score}</span>
137
- </div>
541
+ <!-- Score -->
542
+ <div class="score-section" id="summary">
543
+ ${buildScoreGauge(result.score)}
138
544
  <div class="score-grade">Grade: ${result.grade}</div>
139
545
  <div class="status ${result.passed ? 'status-pass' : 'status-fail'}">
140
- ${result.passed ? '✅ PASSED' : '❌ FAILED'} (threshold: ${escapeHtml(result.threshold)})
546
+ ${result.passed ? '✅ AUDIT PASSED' : '❌ AUDIT FAILED'}
141
547
  </div>
142
548
  </div>
143
549
 
550
+ <!-- Executive Summary -->
551
+ ${buildExecutiveSummary(result)}
552
+
553
+ <!-- Severity Cards -->
144
554
  <div class="summary-grid">
145
- <div class="summary-card">
146
- <div class="count" style="color:#dc2626">${result.summary.critical}</div>
147
- <div class="label">Critical</div>
555
+ <div class="summary-card"><div class="count" style="color:#ef4444">${result.summary.critical}</div><div class="label">Critical</div></div>
556
+ <div class="summary-card"><div class="count" style="color:#f97316">${result.summary.high}</div><div class="label">High</div></div>
557
+ <div class="summary-card"><div class="count" style="color:#eab308">${result.summary.medium}</div><div class="label">Medium</div></div>
558
+ <div class="summary-card"><div class="count" style="color:#3b82f6">${result.summary.low}</div><div class="label">Low</div></div>
559
+ <div class="summary-card"><div class="count" style="color:#6b7280">${result.summary.info}</div><div class="label">Info</div></div>
560
+ </div>
561
+
562
+ <!-- Charts -->
563
+ <div class="charts-row" id="charts">
564
+ <div class="chart-panel">
565
+ <h3>Severity Distribution</h3>
566
+ ${buildDonutChart(result.summary)}
148
567
  </div>
149
- <div class="summary-card">
150
- <div class="count" style="color:#ea580c">${result.summary.high}</div>
151
- <div class="label">High</div>
568
+ <div class="chart-panel">
569
+ <h3>Severity Breakdown</h3>
570
+ ${buildBarChart(result.summary)}
152
571
  </div>
153
- <div class="summary-card">
154
- <div class="count" style="color:#d97706">${result.summary.medium}</div>
155
- <div class="label">Medium</div>
572
+ <div class="chart-panel">
573
+ <h3>OWASP Radar</h3>
574
+ ${buildOwaspRadar(result.findings)}
156
575
  </div>
157
- <div class="summary-card">
158
- <div class="count" style="color:#2563eb">${result.summary.low}</div>
159
- <div class="label">Low</div>
576
+ </div>
577
+
578
+ <div class="charts-row">
579
+ <div class="chart-panel">
580
+ <h3>Findings by Category</h3>
581
+ ${buildCategoryChart(result.findings) || '<p style="color:var(--text-dim)">No findings</p>'}
160
582
  </div>
161
- <div class="summary-card">
162
- <div class="count" style="color:#6b7280">${result.summary.info}</div>
163
- <div class="label">Info</div>
583
+ <div class="chart-panel">
584
+ <h3>Hotspot Files</h3>
585
+ ${buildFileHeatmap(result.findings) || '<p style="color:var(--text-dim)">No findings</p>'}
164
586
  </div>
165
587
  </div>
166
588
 
167
- <div class="findings-section">
168
- <h2>Detailed Findings (${result.summary.total})</h2>
169
- ${result.findings.length === 0 ? '<p style="color: #22c55e; text-align: center; padding: 2rem;">🎉 No security issues found! Your Angular application passed all checks.</p>' : findingsHtml}
589
+ <!-- Top Risks -->
590
+ ${buildTopRisks(sortedFindings)}
591
+
592
+ <!-- OWASP Table -->
593
+ <div id="owasp"></div>
594
+ ${buildOwaspTable(result.findings)}
595
+
596
+ <!-- Findings -->
597
+ <div class="findings-section" id="findings">
598
+ <h2>📝 Detailed Findings (${result.summary.total})</h2>
599
+
600
+ <div class="filter-bar no-print">
601
+ <button class="filter-btn active" onclick="filterFindings('all',this)">All (${result.summary.total})</button>
602
+ <button class="filter-btn" onclick="filterFindings('critical',this)">Critical (${result.summary.critical})</button>
603
+ <button class="filter-btn" onclick="filterFindings('high',this)">High (${result.summary.high})</button>
604
+ <button class="filter-btn" onclick="filterFindings('medium',this)">Medium (${result.summary.medium})</button>
605
+ <button class="filter-btn" onclick="filterFindings('low',this)">Low (${result.summary.low})</button>
606
+ <button class="filter-btn" onclick="filterFindings('info',this)">Info (${result.summary.info})</button>
607
+ </div>
608
+
609
+ ${result.findings.length === 0 ? '<p style="color:#22c55e;text-align:center;padding:2rem;">🎉 No security issues found! Your Angular application passed all checks.</p>' : findingsHtml}
170
610
  </div>
171
611
 
172
612
  <footer>
173
- <p>Generated by <strong>ngx-security-audit v1.0.0</strong> | ${new Date(result.scanDate).toISOString()}</p>
174
- <p>OWASP Top 10 Aligned | ${result.rulesExecuted} Security Rules | Angular-Specific Analysis</p>
613
+ <p>Generated by <strong>ngx-security-audit v1.1.0</strong> ${new Date(result.scanDate).toISOString()}</p>
614
+ <p>OWASP Top 10 2021 Aligned · ${result.rulesExecuted} Security Rules · CWE Mapped · Angular-Specific Analysis</p>
615
+ <p style="margin-top:0.5rem">© ${new Date().getFullYear()} ngx-security-audit — MIT License</p>
175
616
  </footer>
617
+
176
618
  </div>
619
+
620
+ <script>
621
+ function filterFindings(sev, btn) {
622
+ document.querySelectorAll('.filter-btn').forEach(function(b){b.classList.remove('active')});
623
+ btn.classList.add('active');
624
+ document.querySelectorAll('.finding').forEach(function(el) {
625
+ if (sev === 'all') { el.style.display = ''; return; }
626
+ el.style.display = el.classList.contains('finding-' + sev) ? '' : 'none';
627
+ });
628
+ }
629
+ </script>
177
630
  </body>
178
631
  </html>`;
179
632
  }
180
633
 
181
- function escapeHtml(str) {
182
- if (!str) return '';
183
- return String(str)
184
- .replace(/&/g, '&amp;')
185
- .replace(/</g, '&lt;')
186
- .replace(/>/g, '&gt;')
187
- .replace(/"/g, '&quot;')
188
- .replace(/'/g, '&#039;');
189
- }
190
-
191
634
  module.exports = htmlReport;