har-o-scope 0.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/LICENSE +661 -0
- package/README.md +179 -0
- package/completions/har-o-scope.bash +64 -0
- package/completions/har-o-scope.fish +43 -0
- package/completions/har-o-scope.zsh +63 -0
- package/dist/cli/colors.d.ts +17 -0
- package/dist/cli/colors.d.ts.map +1 -0
- package/dist/cli/colors.js +54 -0
- package/dist/cli/demo.d.ts +7 -0
- package/dist/cli/demo.d.ts.map +1 -0
- package/dist/cli/demo.js +62 -0
- package/dist/cli/formatters.d.ts +12 -0
- package/dist/cli/formatters.d.ts.map +1 -0
- package/dist/cli/formatters.js +249 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +260 -0
- package/dist/cli/rules.d.ts +3 -0
- package/dist/cli/rules.d.ts.map +1 -0
- package/dist/cli/rules.js +36 -0
- package/dist/cli/sarif.d.ts +9 -0
- package/dist/cli/sarif.d.ts.map +1 -0
- package/dist/cli/sarif.js +104 -0
- package/dist/lib/analyze.d.ts +10 -0
- package/dist/lib/analyze.d.ts.map +1 -0
- package/dist/lib/analyze.js +83 -0
- package/dist/lib/classifier.d.ts +8 -0
- package/dist/lib/classifier.d.ts.map +1 -0
- package/dist/lib/classifier.js +74 -0
- package/dist/lib/diff.d.ts +15 -0
- package/dist/lib/diff.d.ts.map +1 -0
- package/dist/lib/diff.js +130 -0
- package/dist/lib/errors.d.ts +56 -0
- package/dist/lib/errors.d.ts.map +1 -0
- package/dist/lib/errors.js +65 -0
- package/dist/lib/evaluate.d.ts +19 -0
- package/dist/lib/evaluate.d.ts.map +1 -0
- package/dist/lib/evaluate.js +189 -0
- package/dist/lib/health-score.d.ts +18 -0
- package/dist/lib/health-score.d.ts.map +1 -0
- package/dist/lib/health-score.js +74 -0
- package/dist/lib/html-report.d.ts +15 -0
- package/dist/lib/html-report.d.ts.map +1 -0
- package/dist/lib/html-report.js +299 -0
- package/dist/lib/index.d.ts +26 -0
- package/dist/lib/index.d.ts.map +1 -0
- package/dist/lib/index.js +24 -0
- package/dist/lib/normalizer.d.ts +18 -0
- package/dist/lib/normalizer.d.ts.map +1 -0
- package/dist/lib/normalizer.js +201 -0
- package/dist/lib/rule-engine.d.ts +12 -0
- package/dist/lib/rule-engine.d.ts.map +1 -0
- package/dist/lib/rule-engine.js +122 -0
- package/dist/lib/sanitizer.d.ts +10 -0
- package/dist/lib/sanitizer.d.ts.map +1 -0
- package/dist/lib/sanitizer.js +129 -0
- package/dist/lib/schema.d.ts +85 -0
- package/dist/lib/schema.d.ts.map +1 -0
- package/dist/lib/schema.js +1 -0
- package/dist/lib/trace-sanitizer.d.ts +30 -0
- package/dist/lib/trace-sanitizer.d.ts.map +1 -0
- package/dist/lib/trace-sanitizer.js +85 -0
- package/dist/lib/types.d.ts +161 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/lib/types.js +1 -0
- package/dist/lib/unbatched-detect.d.ts +7 -0
- package/dist/lib/unbatched-detect.d.ts.map +1 -0
- package/dist/lib/unbatched-detect.js +59 -0
- package/dist/lib/validator.d.ts +4 -0
- package/dist/lib/validator.d.ts.map +1 -0
- package/dist/lib/validator.js +409 -0
- package/package.json +98 -0
- package/rules/generic/issue-rules.yaml +292 -0
- package/rules/generic/shared/base-conditions.yaml +28 -0
- package/rules/generic/shared/filters.yaml +12 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { bold, dim, red, green, yellow, cyan, severityColor, severityIcon, healthScoreColor, scoreBar, } from './colors.js';
|
|
2
|
+
// ── Shared helpers ──────────────────────────────────────────────
|
|
3
|
+
function ms(n) {
|
|
4
|
+
if (n >= 10_000)
|
|
5
|
+
return `${(n / 1000).toFixed(1)}s`;
|
|
6
|
+
return `${Math.round(n)}ms`;
|
|
7
|
+
}
|
|
8
|
+
function rootCauseLabel(rootCause) {
|
|
9
|
+
const entries = Object.entries(rootCause);
|
|
10
|
+
entries.sort((a, b) => b[1] - a[1]);
|
|
11
|
+
const top = entries[0];
|
|
12
|
+
if (top[1] === 0)
|
|
13
|
+
return 'none (no findings)';
|
|
14
|
+
const pct = Math.round(top[1] * 100);
|
|
15
|
+
return `${top[0]} (confidence: ${pct}%)`;
|
|
16
|
+
}
|
|
17
|
+
function findingsByServerity(findings) {
|
|
18
|
+
const critical = findings.filter(f => f.severity === 'critical');
|
|
19
|
+
const warning = findings.filter(f => f.severity === 'warning');
|
|
20
|
+
const info = findings.filter(f => f.severity === 'info');
|
|
21
|
+
return { critical, warning, info };
|
|
22
|
+
}
|
|
23
|
+
// ── Text formatter ──────────────────────────────────────────────
|
|
24
|
+
export function formatText(result, score, verbose) {
|
|
25
|
+
const lines = [];
|
|
26
|
+
// Health score
|
|
27
|
+
const scoreStr = `${score.score}/100`;
|
|
28
|
+
const bar = scoreBar(score.score);
|
|
29
|
+
const colorFn = healthScoreColor(score.score);
|
|
30
|
+
lines.push('');
|
|
31
|
+
lines.push(` ${bold('Health Score:')} ${colorFn(scoreStr)} ${colorFn(bar)}`);
|
|
32
|
+
// Root cause + stats
|
|
33
|
+
lines.push(` ${bold('Root Cause:')} ${rootCauseLabel(result.rootCause)}`);
|
|
34
|
+
lines.push(` ${dim(`Requests: ${result.metadata.totalRequests} | Time: ${ms(result.metadata.totalTimeMs)} | Analysis: ${ms(result.metadata.analysisTimeMs)}`)}`);
|
|
35
|
+
lines.push('');
|
|
36
|
+
// Findings
|
|
37
|
+
const { findings } = result;
|
|
38
|
+
if (findings.length === 0) {
|
|
39
|
+
lines.push(` ${green('No issues found.')}`);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
const { critical, warning, info } = findingsByServerity(findings);
|
|
43
|
+
const counts = [];
|
|
44
|
+
if (critical.length)
|
|
45
|
+
counts.push(red(`${critical.length} critical`));
|
|
46
|
+
if (warning.length)
|
|
47
|
+
counts.push(yellow(`${warning.length} warning`));
|
|
48
|
+
if (info.length)
|
|
49
|
+
counts.push(dim(`${info.length} info`));
|
|
50
|
+
lines.push(` ${bold('Findings')} (${counts.join(', ')})`);
|
|
51
|
+
lines.push('');
|
|
52
|
+
const sorted = [...critical, ...warning, ...info];
|
|
53
|
+
for (const finding of sorted) {
|
|
54
|
+
const icon = severityIcon(finding.severity);
|
|
55
|
+
const color = severityColor(finding.severity);
|
|
56
|
+
lines.push(` ${color(icon)} ${color(`[${finding.severity}]`)} ${finding.title}`);
|
|
57
|
+
lines.push(` ${dim(finding.description)}`);
|
|
58
|
+
lines.push(` ${cyan('\u2192')} ${finding.recommendation}`);
|
|
59
|
+
lines.push(` ${dim(`Affected: ${finding.affectedEntries.length} entries`)}`);
|
|
60
|
+
lines.push('');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Warnings
|
|
64
|
+
if (result.warnings.length > 0) {
|
|
65
|
+
lines.push(` ${bold('Warnings')}`);
|
|
66
|
+
lines.push('');
|
|
67
|
+
for (const w of result.warnings) {
|
|
68
|
+
lines.push(` ${yellow('\u26A0')} ${dim(`[${w.code}]`)} ${w.message}`);
|
|
69
|
+
lines.push(` ${dim(`Help: ${w.help}`)}`);
|
|
70
|
+
}
|
|
71
|
+
lines.push('');
|
|
72
|
+
}
|
|
73
|
+
// Verbose: per-entry timing
|
|
74
|
+
if (verbose) {
|
|
75
|
+
lines.push(` ${bold('Per-Entry Timing')}`);
|
|
76
|
+
lines.push('');
|
|
77
|
+
lines.push(` ${'URL'.padEnd(60)} ${'Wait'.padStart(8)} ${'Total'.padStart(8)} ${'Status'.padStart(6)}`);
|
|
78
|
+
lines.push(` ${'\u2500'.repeat(60)} ${'\u2500'.repeat(8)} ${'\u2500'.repeat(8)} ${'\u2500'.repeat(6)}`);
|
|
79
|
+
const sorted = [...result.entries]
|
|
80
|
+
.filter(e => !e.isWebSocket)
|
|
81
|
+
.sort((a, b) => b.timings.wait - a.timings.wait);
|
|
82
|
+
for (const entry of sorted) {
|
|
83
|
+
const url = entry.entry.request?.url ?? 'unknown';
|
|
84
|
+
const truncUrl = url.length > 58 ? url.slice(0, 55) + '...' : url;
|
|
85
|
+
const status = entry.entry.response?.status ?? 0;
|
|
86
|
+
const statusStr = status >= 400 ? red(String(status)) : String(status);
|
|
87
|
+
lines.push(` ${truncUrl.padEnd(60)} ${ms(entry.timings.wait).padStart(8)} ${ms(entry.totalDuration).padStart(8)} ${statusStr.padStart(6)}`);
|
|
88
|
+
}
|
|
89
|
+
lines.push('');
|
|
90
|
+
}
|
|
91
|
+
return lines.join('\n');
|
|
92
|
+
}
|
|
93
|
+
// ── JSON formatter ──────────────────────────────────────────────
|
|
94
|
+
export function formatJson(result, score) {
|
|
95
|
+
const output = {
|
|
96
|
+
healthScore: score.score,
|
|
97
|
+
scoreBreakdown: score.breakdown,
|
|
98
|
+
rootCause: result.rootCause,
|
|
99
|
+
findings: result.findings.map(f => ({
|
|
100
|
+
ruleId: f.ruleId,
|
|
101
|
+
severity: f.severity,
|
|
102
|
+
category: f.category,
|
|
103
|
+
title: f.title,
|
|
104
|
+
description: f.description,
|
|
105
|
+
recommendation: f.recommendation,
|
|
106
|
+
affectedEntries: f.affectedEntries.length,
|
|
107
|
+
impact: f.impact,
|
|
108
|
+
})),
|
|
109
|
+
metadata: result.metadata,
|
|
110
|
+
warnings: result.warnings,
|
|
111
|
+
};
|
|
112
|
+
return JSON.stringify(output, null, 2);
|
|
113
|
+
}
|
|
114
|
+
// ── Markdown formatter ──────────────────────────────────────────
|
|
115
|
+
export function formatMarkdown(result, score) {
|
|
116
|
+
const lines = [];
|
|
117
|
+
lines.push(`# HAR Analysis Report`);
|
|
118
|
+
lines.push('');
|
|
119
|
+
lines.push(`**Health Score:** ${score.score}/100`);
|
|
120
|
+
lines.push(`**Root Cause:** ${rootCauseLabel(result.rootCause)}`);
|
|
121
|
+
lines.push(`**Requests:** ${result.metadata.totalRequests} | **Total Time:** ${ms(result.metadata.totalTimeMs)}`);
|
|
122
|
+
lines.push('');
|
|
123
|
+
if (result.findings.length === 0) {
|
|
124
|
+
lines.push('No issues found.');
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
lines.push('## Findings');
|
|
128
|
+
lines.push('');
|
|
129
|
+
lines.push('| Severity | Rule | Title | Affected |');
|
|
130
|
+
lines.push('|----------|------|-------|----------|');
|
|
131
|
+
for (const f of result.findings) {
|
|
132
|
+
const sev = f.severity === 'critical' ? '\u274C' : f.severity === 'warning' ? '\u26A0\uFE0F' : '\u2139\uFE0F';
|
|
133
|
+
lines.push(`| ${sev} ${f.severity} | ${f.ruleId} | ${f.title} | ${f.affectedEntries.length} |`);
|
|
134
|
+
}
|
|
135
|
+
lines.push('');
|
|
136
|
+
for (const f of result.findings) {
|
|
137
|
+
lines.push(`### ${f.title}`);
|
|
138
|
+
lines.push('');
|
|
139
|
+
lines.push(f.description);
|
|
140
|
+
lines.push('');
|
|
141
|
+
lines.push(`**Recommendation:** ${f.recommendation}`);
|
|
142
|
+
lines.push('');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (result.warnings.length > 0) {
|
|
146
|
+
lines.push('## Warnings');
|
|
147
|
+
lines.push('');
|
|
148
|
+
for (const w of result.warnings) {
|
|
149
|
+
lines.push(`- **${w.code}:** ${w.message}`);
|
|
150
|
+
}
|
|
151
|
+
lines.push('');
|
|
152
|
+
}
|
|
153
|
+
return lines.join('\n');
|
|
154
|
+
}
|
|
155
|
+
// ── CI annotations ──────────────────────────────────────────────
|
|
156
|
+
export function formatCi(result, score, threshold) {
|
|
157
|
+
const lines = [];
|
|
158
|
+
for (const finding of result.findings) {
|
|
159
|
+
// CI annotations: critical → error, warning → warning, info → skip
|
|
160
|
+
if (finding.severity === 'info')
|
|
161
|
+
continue;
|
|
162
|
+
const level = finding.severity === 'critical' ? 'error' : 'warning';
|
|
163
|
+
const msg = `[${finding.ruleId}] ${finding.title}`.replace(/\n/g, '%0A');
|
|
164
|
+
lines.push(`::${level}::${msg}`);
|
|
165
|
+
}
|
|
166
|
+
if (score.score < threshold) {
|
|
167
|
+
lines.push(`::error::Health score ${score.score} is below threshold ${threshold}`);
|
|
168
|
+
}
|
|
169
|
+
return lines.join('\n');
|
|
170
|
+
}
|
|
171
|
+
// ── Diff formatters ─────────────────────────────────────────────
|
|
172
|
+
export function formatDiffText(diff) {
|
|
173
|
+
const lines = [];
|
|
174
|
+
lines.push('');
|
|
175
|
+
const deltaColor = diff.scoreDelta >= 0 ? green : red;
|
|
176
|
+
const deltaSign = diff.scoreDelta >= 0 ? '+' : '';
|
|
177
|
+
lines.push(` ${bold('Score Delta:')} ${deltaColor(`${deltaSign}${diff.scoreDelta}`)}`);
|
|
178
|
+
lines.push(` ${bold('Request Delta:')} ${diff.requestCountDelta >= 0 ? '+' : ''}${diff.requestCountDelta}`);
|
|
179
|
+
lines.push(` ${bold('Time Delta:')} ${diff.totalTimeDelta >= 0 ? '+' : ''}${ms(diff.totalTimeDelta)}`);
|
|
180
|
+
lines.push('');
|
|
181
|
+
if (diff.newFindings.length > 0) {
|
|
182
|
+
lines.push(` ${red(bold('New Issues'))} (${diff.newFindings.length})`);
|
|
183
|
+
for (const f of diff.newFindings) {
|
|
184
|
+
lines.push(` ${red('+')} [${f.severity}] ${f.title}`);
|
|
185
|
+
}
|
|
186
|
+
lines.push('');
|
|
187
|
+
}
|
|
188
|
+
if (diff.resolvedFindings.length > 0) {
|
|
189
|
+
lines.push(` ${green(bold('Resolved'))} (${diff.resolvedFindings.length})`);
|
|
190
|
+
for (const f of diff.resolvedFindings) {
|
|
191
|
+
lines.push(` ${green('-')} [${f.severity}] ${f.title}`);
|
|
192
|
+
}
|
|
193
|
+
lines.push('');
|
|
194
|
+
}
|
|
195
|
+
if (diff.persistedFindings.length > 0) {
|
|
196
|
+
lines.push(` ${yellow(bold('Persisted'))} (${diff.persistedFindings.length})`);
|
|
197
|
+
for (const f of diff.persistedFindings) {
|
|
198
|
+
const delta = f.afterCount - f.beforeCount;
|
|
199
|
+
const deltaStr = delta === 0 ? '=' : delta > 0 ? `+${delta}` : `${delta}`;
|
|
200
|
+
lines.push(` ${dim('\u2022')} ${f.ruleId}: ${f.beforeSeverity} \u2192 ${f.afterSeverity} (${deltaStr} entries)`);
|
|
201
|
+
}
|
|
202
|
+
lines.push('');
|
|
203
|
+
}
|
|
204
|
+
if (diff.timingDeltas.length > 0) {
|
|
205
|
+
lines.push(` ${bold('Top Timing Changes')}`);
|
|
206
|
+
const top = diff.timingDeltas.slice(0, 10);
|
|
207
|
+
for (const td of top) {
|
|
208
|
+
const color = td.deltaMs > 0 ? red : green;
|
|
209
|
+
const sign = td.deltaMs > 0 ? '+' : '';
|
|
210
|
+
lines.push(` ${td.urlPattern.slice(0, 50).padEnd(50)} ${color(`${sign}${ms(td.deltaMs)}`)} (${sign}${td.deltaPercent}%)`);
|
|
211
|
+
}
|
|
212
|
+
lines.push('');
|
|
213
|
+
}
|
|
214
|
+
return lines.join('\n');
|
|
215
|
+
}
|
|
216
|
+
export function formatDiffJson(diff) {
|
|
217
|
+
return JSON.stringify(diff, null, 2);
|
|
218
|
+
}
|
|
219
|
+
export function formatDiffMarkdown(diff) {
|
|
220
|
+
const lines = [];
|
|
221
|
+
lines.push('# HAR Diff Report');
|
|
222
|
+
lines.push('');
|
|
223
|
+
lines.push(`**Score Delta:** ${diff.scoreDelta >= 0 ? '+' : ''}${diff.scoreDelta}`);
|
|
224
|
+
lines.push(`**Request Delta:** ${diff.requestCountDelta >= 0 ? '+' : ''}${diff.requestCountDelta}`);
|
|
225
|
+
lines.push(`**Time Delta:** ${diff.totalTimeDelta >= 0 ? '+' : ''}${ms(diff.totalTimeDelta)}`);
|
|
226
|
+
lines.push('');
|
|
227
|
+
if (diff.newFindings.length > 0) {
|
|
228
|
+
lines.push('## New Issues');
|
|
229
|
+
for (const f of diff.newFindings)
|
|
230
|
+
lines.push(`- \u274C **[${f.severity}]** ${f.title}`);
|
|
231
|
+
lines.push('');
|
|
232
|
+
}
|
|
233
|
+
if (diff.resolvedFindings.length > 0) {
|
|
234
|
+
lines.push('## Resolved');
|
|
235
|
+
for (const f of diff.resolvedFindings)
|
|
236
|
+
lines.push(`- \u2705 **[${f.severity}]** ${f.title}`);
|
|
237
|
+
lines.push('');
|
|
238
|
+
}
|
|
239
|
+
if (diff.timingDeltas.length > 0) {
|
|
240
|
+
lines.push('## Timing Changes');
|
|
241
|
+
lines.push('| URL Pattern | Before | After | Delta |');
|
|
242
|
+
lines.push('|-------------|--------|-------|-------|');
|
|
243
|
+
for (const td of diff.timingDeltas.slice(0, 15)) {
|
|
244
|
+
lines.push(`| ${td.urlPattern} | ${ms(td.beforeAvgMs)} | ${ms(td.afterAvgMs)} | ${td.deltaMs >= 0 ? '+' : ''}${ms(td.deltaMs)} |`);
|
|
245
|
+
}
|
|
246
|
+
lines.push('');
|
|
247
|
+
}
|
|
248
|
+
return lines.join('\n');
|
|
249
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* har-o-scope CLI entry point.
|
|
4
|
+
*
|
|
5
|
+
* Subcommands: analyze, diff, sanitize, validate
|
|
6
|
+
* Exit codes: 0 = clean, 1 = warnings, 2 = critical or below threshold
|
|
7
|
+
*/
|
|
8
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { resolve } from 'node:path';
|
|
10
|
+
import { Command } from 'commander';
|
|
11
|
+
import { analyze } from '../lib/analyze.js';
|
|
12
|
+
import { diff } from '../lib/diff.js';
|
|
13
|
+
import { sanitize } from '../lib/sanitizer.js';
|
|
14
|
+
import { validate } from '../lib/validator.js';
|
|
15
|
+
import { computeHealthScore } from '../lib/health-score.js';
|
|
16
|
+
import { parseHar } from '../lib/normalizer.js';
|
|
17
|
+
import { HarError } from '../lib/errors.js';
|
|
18
|
+
import { setColorEnabled } from './colors.js';
|
|
19
|
+
import { loadBuiltinRules, loadCustomRules } from './rules.js';
|
|
20
|
+
import { demoHar } from './demo.js';
|
|
21
|
+
import { formatText, formatJson, formatMarkdown, formatCi, formatDiffText, formatDiffJson, formatDiffMarkdown } from './formatters.js';
|
|
22
|
+
import { formatSarif } from './sarif.js';
|
|
23
|
+
import { generateHtmlReport } from '../lib/html-report.js';
|
|
24
|
+
const VERSION = '0.1.0';
|
|
25
|
+
// ── Exit code logic ─────────────────────────────────────────────
|
|
26
|
+
function computeExitCode(result, score, threshold) {
|
|
27
|
+
const hasCritical = result.findings.some(f => f.severity === 'critical');
|
|
28
|
+
if (hasCritical || score.score < threshold)
|
|
29
|
+
return 2;
|
|
30
|
+
const hasWarning = result.findings.some(f => f.severity === 'warning');
|
|
31
|
+
if (hasWarning)
|
|
32
|
+
return 1;
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
// ── Helpers ─────────────────────────────────────────────────────
|
|
36
|
+
async function readHarFile(path) {
|
|
37
|
+
const resolved = resolve(path);
|
|
38
|
+
try {
|
|
39
|
+
return await readFile(resolved, 'utf-8');
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
const e = err;
|
|
43
|
+
if (e.code === 'ENOENT') {
|
|
44
|
+
process.stderr.write(`Error: File not found: ${resolved}\n`);
|
|
45
|
+
process.exit(2);
|
|
46
|
+
}
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function output(text) {
|
|
51
|
+
process.stdout.write(text + '\n');
|
|
52
|
+
}
|
|
53
|
+
// ── Commands ────────────────────────────────────────────────────
|
|
54
|
+
async function runAnalyze(file, opts) {
|
|
55
|
+
if (!opts.color)
|
|
56
|
+
setColorEnabled(false);
|
|
57
|
+
await loadBuiltinRules();
|
|
58
|
+
// Determine input
|
|
59
|
+
let harData;
|
|
60
|
+
if (opts.demo) {
|
|
61
|
+
harData = JSON.stringify(demoHar);
|
|
62
|
+
}
|
|
63
|
+
else if (file) {
|
|
64
|
+
harData = await readHarFile(file);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
process.stderr.write('Error: Provide a HAR file or use --demo\n');
|
|
68
|
+
process.exit(2);
|
|
69
|
+
return; // unreachable, but satisfies TS definite assignment
|
|
70
|
+
}
|
|
71
|
+
// Load custom rules if specified
|
|
72
|
+
const customRulesData = opts.rules ? await loadCustomRules(opts.rules) : undefined;
|
|
73
|
+
const result = analyze(harData, { customRulesData });
|
|
74
|
+
const score = computeHealthScore(result);
|
|
75
|
+
const threshold = parseInt(opts.threshold, 10) || 50;
|
|
76
|
+
// Baseline comparison mode
|
|
77
|
+
if (opts.baseline) {
|
|
78
|
+
const baselineData = await readHarFile(opts.baseline);
|
|
79
|
+
const baselineResult = analyze(baselineData, { customRulesData });
|
|
80
|
+
const diffResult = diff(baselineResult, result);
|
|
81
|
+
if (opts.format === 'json') {
|
|
82
|
+
output(formatDiffJson(diffResult));
|
|
83
|
+
}
|
|
84
|
+
else if (opts.format === 'markdown') {
|
|
85
|
+
output(formatDiffMarkdown(diffResult));
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
output(formatDiffText(diffResult));
|
|
89
|
+
}
|
|
90
|
+
// Exit 2 if score dropped below threshold
|
|
91
|
+
const exitCode = computeExitCode(result, score, threshold);
|
|
92
|
+
process.exit(exitCode);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// Output format selection
|
|
96
|
+
if (opts.sarif) {
|
|
97
|
+
output(formatSarif(result, score, VERSION));
|
|
98
|
+
}
|
|
99
|
+
else if (opts.ci) {
|
|
100
|
+
const ciOutput = formatCi(result, score, threshold);
|
|
101
|
+
if (ciOutput)
|
|
102
|
+
output(ciOutput);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
switch (opts.format) {
|
|
106
|
+
case 'json':
|
|
107
|
+
output(formatJson(result, score));
|
|
108
|
+
break;
|
|
109
|
+
case 'markdown':
|
|
110
|
+
output(formatMarkdown(result, score));
|
|
111
|
+
break;
|
|
112
|
+
case 'html':
|
|
113
|
+
output(generateHtmlReport(result, score));
|
|
114
|
+
break;
|
|
115
|
+
default:
|
|
116
|
+
output(formatText(result, score, opts.verbose));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
process.exit(computeExitCode(result, score, threshold));
|
|
120
|
+
}
|
|
121
|
+
async function runDiff(before, after, opts) {
|
|
122
|
+
if (!opts.color)
|
|
123
|
+
setColorEnabled(false);
|
|
124
|
+
await loadBuiltinRules();
|
|
125
|
+
const [beforeData, afterData] = await Promise.all([
|
|
126
|
+
readHarFile(before),
|
|
127
|
+
readHarFile(after),
|
|
128
|
+
]);
|
|
129
|
+
const beforeResult = analyze(beforeData);
|
|
130
|
+
const afterResult = analyze(afterData);
|
|
131
|
+
const diffResult = diff(beforeResult, afterResult);
|
|
132
|
+
switch (opts.format) {
|
|
133
|
+
case 'json':
|
|
134
|
+
output(formatDiffJson(diffResult));
|
|
135
|
+
break;
|
|
136
|
+
case 'markdown':
|
|
137
|
+
output(formatDiffMarkdown(diffResult));
|
|
138
|
+
break;
|
|
139
|
+
default:
|
|
140
|
+
output(formatDiffText(diffResult));
|
|
141
|
+
}
|
|
142
|
+
// Exit 1 if new findings, 2 if new critical findings
|
|
143
|
+
const hasCriticalNew = diffResult.newFindings.some(f => f.severity === 'critical');
|
|
144
|
+
if (hasCriticalNew)
|
|
145
|
+
process.exit(2);
|
|
146
|
+
if (diffResult.newFindings.length > 0)
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
async function runSanitize(file, opts) {
|
|
150
|
+
const data = await readHarFile(file);
|
|
151
|
+
const har = parseHar(data);
|
|
152
|
+
const sanitized = sanitize(har, { mode: opts.mode });
|
|
153
|
+
const json = JSON.stringify(sanitized, null, 2);
|
|
154
|
+
if (opts.output) {
|
|
155
|
+
await writeFile(resolve(opts.output), json, 'utf-8');
|
|
156
|
+
process.stderr.write(`Sanitized HAR written to ${opts.output}\n`);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
output(json);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async function runValidate(file, opts) {
|
|
163
|
+
const data = await readHarFile(file);
|
|
164
|
+
// Validate as HAR
|
|
165
|
+
try {
|
|
166
|
+
parseHar(data);
|
|
167
|
+
process.stderr.write('HAR structure: valid\n');
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
if (err instanceof HarError) {
|
|
171
|
+
process.stderr.write(`HAR structure: invalid\n`);
|
|
172
|
+
process.stderr.write(` ${err.code}: ${err.message}\n`);
|
|
173
|
+
process.stderr.write(` Help: ${err.help}\n`);
|
|
174
|
+
process.exit(2);
|
|
175
|
+
}
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
// Validate custom rules if specified
|
|
179
|
+
if (opts.rules) {
|
|
180
|
+
const rulesContent = await readFile(resolve(opts.rules), 'utf-8');
|
|
181
|
+
const result = validate(rulesContent);
|
|
182
|
+
if (result.errors.length > 0) {
|
|
183
|
+
process.stderr.write(`Rules validation: ${result.errors.length} error(s)\n`);
|
|
184
|
+
for (const e of result.errors) {
|
|
185
|
+
process.stderr.write(` ${e.code}: ${e.message}\n`);
|
|
186
|
+
if (e.suggestion)
|
|
187
|
+
process.stderr.write(` Suggestion: ${e.suggestion}\n`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (result.warnings.length > 0) {
|
|
191
|
+
process.stderr.write(`Rules validation: ${result.warnings.length} warning(s)\n`);
|
|
192
|
+
for (const w of result.warnings) {
|
|
193
|
+
process.stderr.write(` ${w.code}: ${w.message}\n`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (result.valid) {
|
|
197
|
+
process.stderr.write('Rules validation: valid\n');
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
process.exit(2);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// ── Program ─────────────────────────────────────────────────────
|
|
205
|
+
const program = new Command()
|
|
206
|
+
.name('har-o-scope')
|
|
207
|
+
.description('Zero-trust intelligent HAR file analyzer')
|
|
208
|
+
.version(VERSION);
|
|
209
|
+
program
|
|
210
|
+
.command('analyze')
|
|
211
|
+
.argument('[file]', 'HAR file to analyze')
|
|
212
|
+
.description('Analyze a HAR file for performance and security issues')
|
|
213
|
+
.option('-f, --format <format>', 'Output format: text, json, markdown, html', 'text')
|
|
214
|
+
.option('--sarif', 'Output SARIF 2.1.0 JSON')
|
|
215
|
+
.option('--ci', 'Output GitHub-compatible annotations')
|
|
216
|
+
.option('--baseline <file>', 'Compare against a baseline HAR file')
|
|
217
|
+
.option('--threshold <score>', 'Minimum health score (default: 50)', '50')
|
|
218
|
+
.option('--verbose', 'Show per-entry timing details', false)
|
|
219
|
+
.option('--rules <path>', 'Path to custom YAML rules file or directory')
|
|
220
|
+
.option('--demo', 'Analyze a built-in demo HAR file', false)
|
|
221
|
+
.option('--no-color', 'Disable colored output')
|
|
222
|
+
.action(runAnalyze);
|
|
223
|
+
program
|
|
224
|
+
.command('diff')
|
|
225
|
+
.arguments('<before> <after>')
|
|
226
|
+
.description('Compare two HAR files for regressions and improvements')
|
|
227
|
+
.option('-f, --format <format>', 'Output format: text, json, markdown, html', 'text')
|
|
228
|
+
.option('--no-color', 'Disable colored output')
|
|
229
|
+
.action(runDiff);
|
|
230
|
+
program
|
|
231
|
+
.command('sanitize')
|
|
232
|
+
.argument('<file>', 'HAR file to sanitize')
|
|
233
|
+
.description('Strip secrets and sensitive data from a HAR file')
|
|
234
|
+
.option('-o, --output <file>', 'Output file (default: stdout)')
|
|
235
|
+
.option('-m, --mode <mode>', 'Sanitization mode: aggressive, selective', 'aggressive')
|
|
236
|
+
.action(runSanitize);
|
|
237
|
+
program
|
|
238
|
+
.command('validate')
|
|
239
|
+
.argument('<file>', 'HAR file to validate')
|
|
240
|
+
.description('Validate HAR file structure and optionally custom rules')
|
|
241
|
+
.option('--rules <path>', 'Also validate a YAML rules file')
|
|
242
|
+
.action(runValidate);
|
|
243
|
+
// Handle errors globally
|
|
244
|
+
program.exitOverride();
|
|
245
|
+
try {
|
|
246
|
+
await program.parseAsync();
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
if (err instanceof HarError) {
|
|
250
|
+
process.stderr.write(`\nError [${err.code}]: ${err.message}\n`);
|
|
251
|
+
process.stderr.write(`Help: ${err.help}\n`);
|
|
252
|
+
process.stderr.write(`Docs: ${err.docsUrl}\n`);
|
|
253
|
+
process.exit(2);
|
|
254
|
+
}
|
|
255
|
+
// Commander exits with code 0 for --help and --version
|
|
256
|
+
if (err && typeof err === 'object' && 'exitCode' in err) {
|
|
257
|
+
process.exit(err.exitCode);
|
|
258
|
+
}
|
|
259
|
+
throw err;
|
|
260
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rules.d.ts","sourceRoot":"","sources":["../../src/cli/rules.ts"],"names":[],"mappings":"AAaA,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAatD;AAED,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,CActE"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YAML rule loading for CLI.
|
|
3
|
+
* Reads rules from the package's rules/ directory at runtime.
|
|
4
|
+
*/
|
|
5
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
6
|
+
import { resolve, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import yaml from 'js-yaml';
|
|
9
|
+
import { setBuiltinRules } from '../lib/analyze.js';
|
|
10
|
+
const __dirname = resolve(fileURLToPath(import.meta.url), '..');
|
|
11
|
+
export async function loadBuiltinRules() {
|
|
12
|
+
const packageRoot = resolve(__dirname, '..', '..');
|
|
13
|
+
const rulesDir = join(packageRoot, 'rules', 'generic');
|
|
14
|
+
const rulesYaml = await readFile(join(rulesDir, 'issue-rules.yaml'), 'utf-8');
|
|
15
|
+
const conditionsYaml = await readFile(join(rulesDir, 'shared', 'base-conditions.yaml'), 'utf-8');
|
|
16
|
+
const filtersYaml = await readFile(join(rulesDir, 'shared', 'filters.yaml'), 'utf-8');
|
|
17
|
+
const rules = yaml.load(rulesYaml);
|
|
18
|
+
const conditions = yaml.load(conditionsYaml);
|
|
19
|
+
const filters = yaml.load(filtersYaml);
|
|
20
|
+
setBuiltinRules(rules, conditions, filters);
|
|
21
|
+
}
|
|
22
|
+
export async function loadCustomRules(path) {
|
|
23
|
+
const info = await stat(path);
|
|
24
|
+
if (info.isDirectory()) {
|
|
25
|
+
const files = await readdir(path);
|
|
26
|
+
const yamlFiles = files.filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
|
|
27
|
+
const results = [];
|
|
28
|
+
for (const file of yamlFiles) {
|
|
29
|
+
const content = await readFile(join(path, file), 'utf-8');
|
|
30
|
+
results.push(yaml.load(content));
|
|
31
|
+
}
|
|
32
|
+
return results;
|
|
33
|
+
}
|
|
34
|
+
const content = await readFile(path, 'utf-8');
|
|
35
|
+
return [yaml.load(content)];
|
|
36
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SARIF 2.1.0 output formatter.
|
|
3
|
+
*
|
|
4
|
+
* Uses logicalLocation (URL patterns, not source files) since
|
|
5
|
+
* HAR findings reference network requests, not code.
|
|
6
|
+
*/
|
|
7
|
+
import type { AnalysisResult, HealthScore } from '../lib/types.js';
|
|
8
|
+
export declare function formatSarif(result: AnalysisResult, score: HealthScore, version: string): string;
|
|
9
|
+
//# sourceMappingURL=sarif.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sarif.d.ts","sourceRoot":"","sources":["../../src/cli/sarif.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAW,MAAM,iBAAiB,CAAA;AAoD3E,wBAAgB,WAAW,CACzB,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,WAAW,EAClB,OAAO,EAAE,MAAM,GACd,MAAM,CA+DR"}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// ── SARIF severity mapping ─────────────────────────────────────
|
|
2
|
+
// har-o-scope: info | warning | critical
|
|
3
|
+
// SARIF: note | warning | error
|
|
4
|
+
function sarifLevel(severity) {
|
|
5
|
+
switch (severity) {
|
|
6
|
+
case 'critical': return 'error';
|
|
7
|
+
case 'warning': return 'warning';
|
|
8
|
+
case 'info': return 'note';
|
|
9
|
+
default: return 'none';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
// ── Extract URL patterns from affected entries ──────────────────
|
|
13
|
+
function extractLogicalLocations(finding, entries) {
|
|
14
|
+
const patterns = new Map();
|
|
15
|
+
for (const idx of finding.affectedEntries) {
|
|
16
|
+
const entry = entries[idx];
|
|
17
|
+
if (!entry)
|
|
18
|
+
continue;
|
|
19
|
+
const url = entry.entry.request?.url ?? '';
|
|
20
|
+
try {
|
|
21
|
+
const parsed = new URL(url);
|
|
22
|
+
// Normalize: strip query params, replace numeric/UUID segments
|
|
23
|
+
const path = parsed.pathname
|
|
24
|
+
.split('/')
|
|
25
|
+
.map(seg => /^\d+$/.test(seg) ? '*' : seg)
|
|
26
|
+
.join('/');
|
|
27
|
+
const pattern = `${parsed.hostname}${path}`;
|
|
28
|
+
if (!patterns.has(pattern)) {
|
|
29
|
+
patterns.set(pattern, path);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Malformed URL, skip
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return [...patterns.entries()].map(([fqn, name]) => ({
|
|
37
|
+
name,
|
|
38
|
+
kind: 'url-pattern',
|
|
39
|
+
fullyQualifiedName: fqn,
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
// ── Main SARIF formatter ────────────────────────────────────────
|
|
43
|
+
export function formatSarif(result, score, version) {
|
|
44
|
+
// Build rule definitions
|
|
45
|
+
const ruleMap = new Map();
|
|
46
|
+
for (const finding of result.findings) {
|
|
47
|
+
if (!ruleMap.has(finding.ruleId)) {
|
|
48
|
+
ruleMap.set(finding.ruleId, {
|
|
49
|
+
id: finding.ruleId,
|
|
50
|
+
shortDescription: finding.title,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const sarif = {
|
|
55
|
+
$schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json',
|
|
56
|
+
version: '2.1.0',
|
|
57
|
+
runs: [
|
|
58
|
+
{
|
|
59
|
+
tool: {
|
|
60
|
+
driver: {
|
|
61
|
+
name: 'har-o-scope',
|
|
62
|
+
version,
|
|
63
|
+
informationUri: 'https://github.com/vegaPDX/har-o-scope',
|
|
64
|
+
rules: [...ruleMap.values()].map(r => ({
|
|
65
|
+
id: r.id,
|
|
66
|
+
shortDescription: { text: r.shortDescription },
|
|
67
|
+
helpUri: `https://github.com/vegaPDX/har-o-scope/blob/main/docs/rules/${r.id}.md`,
|
|
68
|
+
})),
|
|
69
|
+
properties: {
|
|
70
|
+
healthScore: score.score,
|
|
71
|
+
scoreBreakdown: score.breakdown,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
results: result.findings.map(finding => ({
|
|
76
|
+
ruleId: finding.ruleId,
|
|
77
|
+
ruleIndex: [...ruleMap.keys()].indexOf(finding.ruleId),
|
|
78
|
+
level: sarifLevel(finding.severity),
|
|
79
|
+
message: {
|
|
80
|
+
text: `${finding.title}\n\n${finding.description}\n\nRecommendation: ${finding.recommendation}`,
|
|
81
|
+
},
|
|
82
|
+
logicalLocations: extractLogicalLocations(finding, result.entries),
|
|
83
|
+
properties: {
|
|
84
|
+
severity: finding.severity,
|
|
85
|
+
category: finding.category,
|
|
86
|
+
affectedEntries: finding.affectedEntries.length,
|
|
87
|
+
impact: finding.impact,
|
|
88
|
+
},
|
|
89
|
+
})),
|
|
90
|
+
invocations: [
|
|
91
|
+
{
|
|
92
|
+
executionSuccessful: true,
|
|
93
|
+
properties: {
|
|
94
|
+
healthScore: score.score,
|
|
95
|
+
totalRequests: result.metadata.totalRequests,
|
|
96
|
+
analysisTimeMs: result.metadata.analysisTimeMs,
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
return JSON.stringify(sarif, null, 2);
|
|
104
|
+
}
|