securl 1.4.1
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/CHANGELOG.md +241 -0
- package/LICENSE +21 -0
- package/README.md +427 -0
- package/RELEASING.md +37 -0
- package/SECURITY.md +27 -0
- package/dist/certificate.d.ts +5 -0
- package/dist/certificate.js +92 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +674 -0
- package/dist/compromiseSignals.d.ts +10 -0
- package/dist/compromiseSignals.js +183 -0
- package/dist/cookie-analysis.d.ts +2 -0
- package/dist/cookie-analysis.js +41 -0
- package/dist/cookieAnalysis.d.ts +2 -0
- package/dist/cookieAnalysis.js +82 -0
- package/dist/ctDiscovery.d.ts +19 -0
- package/dist/ctDiscovery.js +357 -0
- package/dist/domain-security.d.ts +10 -0
- package/dist/domain-security.js +416 -0
- package/dist/header-analysis.d.ts +14 -0
- package/dist/header-analysis.js +165 -0
- package/dist/historyDiff.d.ts +4 -0
- package/dist/historyDiff.js +117 -0
- package/dist/html-extraction.d.ts +12 -0
- package/dist/html-extraction.js +279 -0
- package/dist/html-page-analysis.d.ts +38 -0
- package/dist/html-page-analysis.js +459 -0
- package/dist/htmlInsights.d.ts +23 -0
- package/dist/htmlInsights.js +460 -0
- package/dist/identityProvider.d.ts +14 -0
- package/dist/identityProvider.js +259 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +1008 -0
- package/dist/infrastructure.d.ts +9 -0
- package/dist/infrastructure.js +149 -0
- package/dist/libraryRisk.d.ts +3 -0
- package/dist/libraryRisk.js +164 -0
- package/dist/network-validation.d.ts +30 -0
- package/dist/network-validation.js +161 -0
- package/dist/network.d.ts +34 -0
- package/dist/network.js +139 -0
- package/dist/passive-intelligence.d.ts +21 -0
- package/dist/passive-intelligence.js +247 -0
- package/dist/path-discovery.d.ts +4 -0
- package/dist/path-discovery.js +50 -0
- package/dist/postureDigest.d.ts +142 -0
- package/dist/postureDigest.js +159 -0
- package/dist/postureDrift.d.ts +4 -0
- package/dist/postureDrift.js +118 -0
- package/dist/postureRemediation.d.ts +6 -0
- package/dist/postureRemediation.js +286 -0
- package/dist/redirectChain.d.ts +2 -0
- package/dist/redirectChain.js +39 -0
- package/dist/riskEvents.d.ts +3 -0
- package/dist/riskEvents.js +187 -0
- package/dist/scannerConfig.d.ts +49 -0
- package/dist/scannerConfig.js +79 -0
- package/dist/scoring.d.ts +32 -0
- package/dist/scoring.js +367 -0
- package/dist/security-txt.d.ts +4 -0
- package/dist/security-txt.js +123 -0
- package/dist/surfaceEnrichment.d.ts +44 -0
- package/dist/surfaceEnrichment.js +377 -0
- package/dist/technology-detection.d.ts +4 -0
- package/dist/technology-detection.js +93 -0
- package/dist/types.d.ts +730 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +7 -0
- package/dist/utils.js +66 -0
- package/dist/wafFingerprint.d.ts +5 -0
- package/dist/wafFingerprint.js +156 -0
- package/examples/risk-events.mjs +27 -0
- package/examples/scan-url.mjs +17 -0
- package/package.json +102 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { analyzeUrl, buildHistoryDiffFromSnapshots, formatErrorMessage, snapshotFromAnalysis } from "./index.js";
|
|
5
|
+
const usage = `SecURL CLI
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
securl scan <target...> [--format json|markdown|summary|sarif|ci-json] [--baseline <report.json>] [--output <file>] [--quiet|--deep-passive] [--fail-on info|warning|critical] [--fail-on-regression] [--fail-if-score-below <0-100>]
|
|
9
|
+
securl compare <current-report.json> <baseline-report.json> [--format json|markdown|summary|sarif|ci-json] [--output <file>] [--fail-on info|warning|critical] [--fail-on-regression] [--fail-if-score-below <0-100>]
|
|
10
|
+
|
|
11
|
+
Examples:
|
|
12
|
+
npx securl scan example.com
|
|
13
|
+
npx securl scan example.com github.com bbc.co.uk
|
|
14
|
+
npx securl scan https://example.com --format markdown
|
|
15
|
+
npx securl scan example.com --format sarif --output findings.sarif
|
|
16
|
+
npx securl scan example.com --format ci-json --output ci.json
|
|
17
|
+
npx securl scan example.com --format json --output report.json
|
|
18
|
+
npx securl scan example.com --quiet
|
|
19
|
+
npx securl scan example.com --deep-passive
|
|
20
|
+
npx securl scan example.com --baseline previous-report.json
|
|
21
|
+
npx securl scan example.com --baseline previous-report.json --fail-on-regression
|
|
22
|
+
npx securl scan example.com github.com --fail-on warning
|
|
23
|
+
npx securl scan example.com github.com --fail-if-score-below 75
|
|
24
|
+
npx securl compare current-report.json baseline-report.json
|
|
25
|
+
npx securl compare current-report.json baseline-report.json --format sarif --fail-on critical
|
|
26
|
+
|
|
27
|
+
Scan modes:
|
|
28
|
+
default scan Fetches the primary response plus bounded passive enrichment: HTML, DNS/mail, CT, OSV, exposure, CORS, API-surface, and public trust signals.
|
|
29
|
+
--quiet Keeps primary response, TLS, headers, cookies, redirects, DNS/mail, CT summary, infrastructure, and public trust checks; skips page-body analysis, related-page crawl, security.txt fetch, identity discovery, exposure probes, CORS probes, API probes, OSV lookups, and CT host sampling.
|
|
30
|
+
--deep-passive Expands passive CT host sampling, related-page crawl, exposure probes, and API-surface probes while keeping strict request limits and scan timeout bounds.
|
|
31
|
+
|
|
32
|
+
CI policy modes:
|
|
33
|
+
--fail-on warning Fail when findings at or above the selected severity are present.
|
|
34
|
+
--fail-on-regression Fail when a baseline comparison finds score, issue, or status regressions.
|
|
35
|
+
--fail-if-score-below 75 Fail when any scanned target falls below the selected score.
|
|
36
|
+
`;
|
|
37
|
+
process.once("SIGINT", () => {
|
|
38
|
+
process.stderr.write("\nScan interrupted. No temporary files were created by the CLI.\n");
|
|
39
|
+
process.exit(130);
|
|
40
|
+
});
|
|
41
|
+
const parseArgs = (argv) => {
|
|
42
|
+
const args = [...argv];
|
|
43
|
+
const command = args.shift();
|
|
44
|
+
if (!command || command === "--help" || command === "-h" || command === "help") {
|
|
45
|
+
return { command: "help" };
|
|
46
|
+
}
|
|
47
|
+
if (!["scan", "compare"].includes(command)) {
|
|
48
|
+
throw new Error(`Unknown command: ${command}`);
|
|
49
|
+
}
|
|
50
|
+
let format = "summary";
|
|
51
|
+
let outputPath = null;
|
|
52
|
+
let baselinePath = null;
|
|
53
|
+
let failOnSeverity = null;
|
|
54
|
+
let failOnRegression = false;
|
|
55
|
+
let failIfScoreBelow = null;
|
|
56
|
+
let scanMode = "standard";
|
|
57
|
+
const positionals = [];
|
|
58
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
59
|
+
const arg = args[index];
|
|
60
|
+
if (arg === "--format") {
|
|
61
|
+
const value = args[index + 1];
|
|
62
|
+
if (!value || !["json", "markdown", "summary", "sarif", "ci-json"].includes(value)) {
|
|
63
|
+
throw new Error("Invalid --format value. Use json, markdown, summary, sarif, or ci-json.");
|
|
64
|
+
}
|
|
65
|
+
format = value;
|
|
66
|
+
index += 1;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (arg === "--output") {
|
|
70
|
+
const value = args[index + 1];
|
|
71
|
+
if (!value) {
|
|
72
|
+
throw new Error("Missing --output value.");
|
|
73
|
+
}
|
|
74
|
+
outputPath = value;
|
|
75
|
+
index += 1;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (arg === "--baseline") {
|
|
79
|
+
const value = args[index + 1];
|
|
80
|
+
if (!value) {
|
|
81
|
+
throw new Error("Missing --baseline value.");
|
|
82
|
+
}
|
|
83
|
+
baselinePath = value;
|
|
84
|
+
index += 1;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (arg === "--help" || arg === "-h") {
|
|
88
|
+
return { command: "help" };
|
|
89
|
+
}
|
|
90
|
+
if (arg === "--fail-on") {
|
|
91
|
+
const value = args[index + 1];
|
|
92
|
+
if (!value || !["info", "warning", "critical"].includes(value)) {
|
|
93
|
+
throw new Error("Invalid --fail-on value. Use info, warning, or critical.");
|
|
94
|
+
}
|
|
95
|
+
failOnSeverity = value;
|
|
96
|
+
index += 1;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (arg === "--fail-on-regression") {
|
|
100
|
+
failOnRegression = true;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (arg === "--fail-if-score-below") {
|
|
104
|
+
const value = args[index + 1];
|
|
105
|
+
const threshold = value ? Number(value) : Number.NaN;
|
|
106
|
+
if (!value || !Number.isFinite(threshold) || threshold < 0 || threshold > 100) {
|
|
107
|
+
throw new Error("Invalid --fail-if-score-below value. Use a number between 0 and 100.");
|
|
108
|
+
}
|
|
109
|
+
failIfScoreBelow = threshold;
|
|
110
|
+
index += 1;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (arg === "--quiet") {
|
|
114
|
+
if (scanMode === "deep-passive") {
|
|
115
|
+
throw new Error("Choose either --quiet or --deep-passive, not both.");
|
|
116
|
+
}
|
|
117
|
+
scanMode = "quiet";
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (arg === "--deep-passive") {
|
|
121
|
+
if (scanMode === "quiet") {
|
|
122
|
+
throw new Error("Choose either --quiet or --deep-passive, not both.");
|
|
123
|
+
}
|
|
124
|
+
scanMode = "deep-passive";
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (arg.startsWith("--")) {
|
|
128
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
129
|
+
}
|
|
130
|
+
positionals.push(arg);
|
|
131
|
+
}
|
|
132
|
+
if (command === "scan") {
|
|
133
|
+
if (!positionals.length) {
|
|
134
|
+
throw new Error("Missing target. Usage: securl scan <target...>");
|
|
135
|
+
}
|
|
136
|
+
if (positionals.length > 1 && baselinePath) {
|
|
137
|
+
throw new Error("Baseline comparison is only supported for a single target scan. Use the compare command for saved reports.");
|
|
138
|
+
}
|
|
139
|
+
if (failOnRegression && !baselinePath) {
|
|
140
|
+
throw new Error("Regression policy mode requires --baseline for scan. Use compare for saved reports.");
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
command: "scan",
|
|
144
|
+
targets: positionals,
|
|
145
|
+
format,
|
|
146
|
+
outputPath,
|
|
147
|
+
baselinePath,
|
|
148
|
+
failOnSeverity,
|
|
149
|
+
failOnRegression,
|
|
150
|
+
failIfScoreBelow,
|
|
151
|
+
scanMode,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const [currentPath, compareBaselinePath] = positionals;
|
|
155
|
+
if (!currentPath || !compareBaselinePath) {
|
|
156
|
+
throw new Error("Missing report paths. Usage: securl compare <current-report.json> <baseline-report.json>");
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
command: "compare",
|
|
160
|
+
currentPath,
|
|
161
|
+
baselinePath: compareBaselinePath,
|
|
162
|
+
format,
|
|
163
|
+
outputPath,
|
|
164
|
+
failOnSeverity,
|
|
165
|
+
failOnRegression,
|
|
166
|
+
failIfScoreBelow,
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
const parseBaselineAnalysis = async (baselinePath) => {
|
|
170
|
+
const raw = await readFile(baselinePath, "utf8");
|
|
171
|
+
let parsed;
|
|
172
|
+
try {
|
|
173
|
+
parsed = JSON.parse(raw);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
throw new Error("Baseline file is not valid JSON.");
|
|
177
|
+
}
|
|
178
|
+
if (parsed && typeof parsed === "object" && "analysis" in parsed && parsed.analysis) {
|
|
179
|
+
return parsed.analysis;
|
|
180
|
+
}
|
|
181
|
+
if (parsed && typeof parsed === "object" && "finalUrl" in parsed && "score" in parsed) {
|
|
182
|
+
return parsed;
|
|
183
|
+
}
|
|
184
|
+
throw new Error("Baseline file must contain a prior analysis JSON report.");
|
|
185
|
+
};
|
|
186
|
+
const formatDiffSummary = (diff) => {
|
|
187
|
+
if (!diff) {
|
|
188
|
+
return "Changes since baseline: No comparable baseline was provided.";
|
|
189
|
+
}
|
|
190
|
+
return [
|
|
191
|
+
"Changes since baseline:",
|
|
192
|
+
...(diff.summary.length
|
|
193
|
+
? diff.summary
|
|
194
|
+
: ["No material posture changes summarized."]).map((item) => `- ${item}`),
|
|
195
|
+
].join("\n");
|
|
196
|
+
};
|
|
197
|
+
const formatComparisonSummary = (current, baseline, diff) => [
|
|
198
|
+
`Current: ${current.finalUrl}`,
|
|
199
|
+
`Baseline: ${baseline.finalUrl}`,
|
|
200
|
+
`Score change: ${baseline.score}/100 (${baseline.grade}) -> ${current.score}/100 (${current.grade})`,
|
|
201
|
+
`Status change: ${baseline.statusCode} -> ${current.statusCode}`,
|
|
202
|
+
"",
|
|
203
|
+
formatDiffSummary(diff),
|
|
204
|
+
].join("\n");
|
|
205
|
+
const formatSummary = (analysis, diff = null) => [
|
|
206
|
+
`Target: ${analysis.inputUrl}`,
|
|
207
|
+
`Final URL: ${analysis.finalUrl}`,
|
|
208
|
+
`Score: ${analysis.score}/100 (${analysis.grade})`,
|
|
209
|
+
`Status: ${analysis.statusCode}`,
|
|
210
|
+
`Summary: ${analysis.summary}`,
|
|
211
|
+
`Top issues: ${analysis.issues.length ? analysis.issues.slice(0, 5).map((issue) => issue.title).join("; ") : "None recorded"}`,
|
|
212
|
+
`Identity: ${analysis.identityProvider.provider ?? "None observed"}${analysis.identityProvider.protocol ? ` (${analysis.identityProvider.protocol.toUpperCase()})` : ""}`,
|
|
213
|
+
`WAF/Edge: ${analysis.wafFingerprint.providers.length ? analysis.wafFingerprint.providers.map((provider) => provider.name).join(", ") : "None conclusively identified"}`,
|
|
214
|
+
`CT coverage: ${analysis.ctDiscovery.coverageSummary}`,
|
|
215
|
+
...(diff ? ["", formatDiffSummary(diff)] : []),
|
|
216
|
+
].join("\n");
|
|
217
|
+
const formatBatchSummary = (analyses) => {
|
|
218
|
+
const averageScore = analyses.length
|
|
219
|
+
? Math.round(analyses.reduce((total, analysis) => total + analysis.score, 0) / analyses.length)
|
|
220
|
+
: 0;
|
|
221
|
+
const gradeCounts = analyses.reduce((counts, analysis) => {
|
|
222
|
+
counts[analysis.grade] = (counts[analysis.grade] ?? 0) + 1;
|
|
223
|
+
return counts;
|
|
224
|
+
}, {});
|
|
225
|
+
const gradeSummary = Object.entries(gradeCounts)
|
|
226
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
227
|
+
.map(([grade, count]) => `${grade}:${count}`)
|
|
228
|
+
.join(", ");
|
|
229
|
+
const weakest = [...analyses].sort((left, right) => left.score - right.score).slice(0, 3);
|
|
230
|
+
return [
|
|
231
|
+
`Batch results (${analyses.length} targets):`,
|
|
232
|
+
`Average score: ${averageScore}/100`,
|
|
233
|
+
`Grade distribution: ${gradeSummary || "None"}`,
|
|
234
|
+
...(weakest.length
|
|
235
|
+
? [
|
|
236
|
+
"Weakest targets:",
|
|
237
|
+
...weakest.map((analysis) => `- ${analysis.host}: ${analysis.score}/100 (${analysis.grade})`),
|
|
238
|
+
]
|
|
239
|
+
: []),
|
|
240
|
+
"",
|
|
241
|
+
"Per-target results:",
|
|
242
|
+
...analyses.map((analysis) => `- ${analysis.host}: ${analysis.score}/100 (${analysis.grade}) | status ${analysis.statusCode} | ${analysis.finalUrl}`),
|
|
243
|
+
].join("\n");
|
|
244
|
+
};
|
|
245
|
+
const formatMarkdown = (analysis, diff = null) => [
|
|
246
|
+
`# SecURL: ${analysis.host}`,
|
|
247
|
+
"",
|
|
248
|
+
`- Final URL: ${analysis.finalUrl}`,
|
|
249
|
+
`- Scanned: ${new Date(analysis.scannedAt).toISOString()}`,
|
|
250
|
+
`- Score: ${analysis.score}/100`,
|
|
251
|
+
`- Grade: ${analysis.grade}`,
|
|
252
|
+
`- HTTP status: ${analysis.statusCode}`,
|
|
253
|
+
"",
|
|
254
|
+
"## Executive Summary",
|
|
255
|
+
"",
|
|
256
|
+
`- Overview: ${analysis.executiveSummary.overview}`,
|
|
257
|
+
`- Main risk: ${analysis.executiveSummary.mainRisk}`,
|
|
258
|
+
...analysis.executiveSummary.takeaways.map((takeaway) => `- ${takeaway}`),
|
|
259
|
+
"",
|
|
260
|
+
"## Key Findings",
|
|
261
|
+
"",
|
|
262
|
+
...(analysis.issues.length
|
|
263
|
+
? analysis.issues.slice(0, 10).map((issue) => `- [${issue.severity}] ${issue.title}: ${issue.detail}`)
|
|
264
|
+
: ["- No core findings recorded."]),
|
|
265
|
+
"",
|
|
266
|
+
"## Identity Provider",
|
|
267
|
+
"",
|
|
268
|
+
`- Provider: ${analysis.identityProvider.provider ?? "Not identified"}`,
|
|
269
|
+
`- Protocol: ${analysis.identityProvider.protocol ?? "Not inferred"}`,
|
|
270
|
+
`- OIDC config: ${analysis.identityProvider.openIdConfigurationUrl ?? "Not observed"}`,
|
|
271
|
+
"",
|
|
272
|
+
"## WAF & Edge Fingerprint",
|
|
273
|
+
"",
|
|
274
|
+
`- Summary: ${analysis.wafFingerprint.summary}`,
|
|
275
|
+
...(analysis.wafFingerprint.providers.length
|
|
276
|
+
? analysis.wafFingerprint.providers.map((provider) => `- ${provider.name} (${provider.confidence} confidence): ${provider.evidence}`)
|
|
277
|
+
: ["- No branded WAF or edge provider was conclusively identified."]),
|
|
278
|
+
"",
|
|
279
|
+
"## Certificate Transparency",
|
|
280
|
+
"",
|
|
281
|
+
`- Coverage summary: ${analysis.ctDiscovery.coverageSummary}`,
|
|
282
|
+
...(analysis.ctDiscovery.prioritizedHosts.length
|
|
283
|
+
? analysis.ctDiscovery.prioritizedHosts.slice(0, 8).map((host) => `- ${host.host} [${host.priority} ${host.category}]`)
|
|
284
|
+
: ["- No prioritized CT hosts recorded."]),
|
|
285
|
+
...(diff
|
|
286
|
+
? [
|
|
287
|
+
"",
|
|
288
|
+
"## Changes Since Baseline",
|
|
289
|
+
"",
|
|
290
|
+
...(diff.summary.length ? diff.summary.map((item) => `- ${item}`) : ["- No material posture changes summarized."]),
|
|
291
|
+
]
|
|
292
|
+
: []),
|
|
293
|
+
].join("\n");
|
|
294
|
+
const formatBatchMarkdown = (analyses) => {
|
|
295
|
+
const averageScore = analyses.length
|
|
296
|
+
? Math.round(analyses.reduce((total, analysis) => total + analysis.score, 0) / analyses.length)
|
|
297
|
+
: 0;
|
|
298
|
+
const strongest = [...analyses].sort((left, right) => right.score - left.score).slice(0, 3);
|
|
299
|
+
const weakest = [...analyses].sort((left, right) => left.score - right.score).slice(0, 3);
|
|
300
|
+
return [
|
|
301
|
+
"# SecURL Batch Scan",
|
|
302
|
+
"",
|
|
303
|
+
`- Targets scanned: ${analyses.length}`,
|
|
304
|
+
`- Average score: ${averageScore}/100`,
|
|
305
|
+
...(strongest.length
|
|
306
|
+
? [`- Strongest: ${strongest.map((analysis) => `${analysis.host} (${analysis.score})`).join(", ")}`]
|
|
307
|
+
: []),
|
|
308
|
+
...(weakest.length
|
|
309
|
+
? [`- Weakest: ${weakest.map((analysis) => `${analysis.host} (${analysis.score})`).join(", ")}`]
|
|
310
|
+
: []),
|
|
311
|
+
"",
|
|
312
|
+
"| Target | Score | Grade | Status | Final URL |",
|
|
313
|
+
"| --- | ---: | :---: | ---: | --- |",
|
|
314
|
+
...analyses.map((analysis) => `| ${analysis.host} | ${analysis.score}/100 | ${analysis.grade} | ${analysis.statusCode} | ${analysis.finalUrl} |`),
|
|
315
|
+
].join("\n");
|
|
316
|
+
};
|
|
317
|
+
const severityRank = {
|
|
318
|
+
info: 1,
|
|
319
|
+
warning: 2,
|
|
320
|
+
critical: 3,
|
|
321
|
+
};
|
|
322
|
+
const hasIssuesAtOrAboveThreshold = (analyses, threshold) => {
|
|
323
|
+
const minimumRank = severityRank[threshold];
|
|
324
|
+
return analyses.some((analysis) => analysis.issues.some((issue) => severityRank[issue.severity] >= minimumRank));
|
|
325
|
+
};
|
|
326
|
+
const statusClass = (statusCode) => {
|
|
327
|
+
if (statusCode >= 500) {
|
|
328
|
+
return 4;
|
|
329
|
+
}
|
|
330
|
+
if (statusCode >= 400) {
|
|
331
|
+
return 3;
|
|
332
|
+
}
|
|
333
|
+
if (statusCode >= 300) {
|
|
334
|
+
return 2;
|
|
335
|
+
}
|
|
336
|
+
if (statusCode >= 200) {
|
|
337
|
+
return 1;
|
|
338
|
+
}
|
|
339
|
+
return 2;
|
|
340
|
+
};
|
|
341
|
+
const isRegression = (diff) => {
|
|
342
|
+
const scoreRegressed = (diff.scoreDelta ?? 0) < 0;
|
|
343
|
+
const newIssuesDetected = diff.newIssues.length > 0;
|
|
344
|
+
const statusWorsened = diff.statusCodeDelta
|
|
345
|
+
? statusClass(diff.statusCodeDelta.to) > statusClass(diff.statusCodeDelta.from)
|
|
346
|
+
: false;
|
|
347
|
+
return scoreRegressed || newIssuesDetected || statusWorsened;
|
|
348
|
+
};
|
|
349
|
+
const formatPolicyFailureMessages = (analyses, options) => {
|
|
350
|
+
const messages = [];
|
|
351
|
+
if (options.failOnSeverity && hasIssuesAtOrAboveThreshold(analyses, options.failOnSeverity)) {
|
|
352
|
+
messages.push(`Policy failed: findings at or above "${options.failOnSeverity}" were detected.`);
|
|
353
|
+
}
|
|
354
|
+
if (options.failIfScoreBelow !== null) {
|
|
355
|
+
const belowThreshold = analyses.filter((analysis) => analysis.score < options.failIfScoreBelow);
|
|
356
|
+
if (belowThreshold.length) {
|
|
357
|
+
messages.push(`Policy failed: score fell below ${options.failIfScoreBelow} for ${belowThreshold.map((analysis) => `${analysis.host} (${analysis.score})`).join(", ")}.`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (options.failOnRegression && options.diff && isRegression(options.diff)) {
|
|
361
|
+
messages.push("Policy failed: baseline comparison detected a regression.");
|
|
362
|
+
}
|
|
363
|
+
return messages;
|
|
364
|
+
};
|
|
365
|
+
const summarizeIssueSeverities = (analysis) => analysis.issues.reduce((counts, issue) => {
|
|
366
|
+
counts[issue.severity] += 1;
|
|
367
|
+
return counts;
|
|
368
|
+
}, { info: 0, warning: 0, critical: 0 });
|
|
369
|
+
const buildPolicySummary = (policyMessages, options) => ({
|
|
370
|
+
passed: policyMessages.length === 0,
|
|
371
|
+
failures: policyMessages,
|
|
372
|
+
failOnSeverity: options.failOnSeverity,
|
|
373
|
+
failOnRegression: options.failOnRegression,
|
|
374
|
+
failIfScoreBelow: options.failIfScoreBelow,
|
|
375
|
+
});
|
|
376
|
+
const toSarifLevel = (severity) => {
|
|
377
|
+
if (severity === "critical") {
|
|
378
|
+
return "error";
|
|
379
|
+
}
|
|
380
|
+
if (severity === "warning") {
|
|
381
|
+
return "warning";
|
|
382
|
+
}
|
|
383
|
+
return "note";
|
|
384
|
+
};
|
|
385
|
+
const toRuleId = (title) => title
|
|
386
|
+
.toLowerCase()
|
|
387
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
388
|
+
.replace(/^-+|-+$/g, "") || "external-posture-finding";
|
|
389
|
+
const buildSarifLog = (analyses, options = {}) => {
|
|
390
|
+
const rules = new Map();
|
|
391
|
+
const results = [];
|
|
392
|
+
for (const analysis of analyses) {
|
|
393
|
+
const baseline = options.baselineByHost?.get(analysis.host) ?? null;
|
|
394
|
+
const newIssueTitles = options.newIssueOnly && baseline
|
|
395
|
+
? new Set(buildHistoryDiffFromSnapshots(snapshotFromAnalysis(analysis), snapshotFromAnalysis(baseline)).newIssues)
|
|
396
|
+
: null;
|
|
397
|
+
for (const issue of analysis.issues) {
|
|
398
|
+
if (newIssueTitles && !newIssueTitles.has(issue.title)) {
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
const ruleId = toRuleId(issue.title);
|
|
402
|
+
if (!rules.has(ruleId)) {
|
|
403
|
+
rules.set(ruleId, {
|
|
404
|
+
id: ruleId,
|
|
405
|
+
name: issue.title,
|
|
406
|
+
shortDescription: { text: issue.title },
|
|
407
|
+
fullDescription: { text: issue.detail },
|
|
408
|
+
help: { text: issue.detail },
|
|
409
|
+
properties: {
|
|
410
|
+
tags: [...issue.owasp, ...issue.mitre, issue.area, issue.source, issue.confidence],
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
const message = baseline && newIssueTitles
|
|
415
|
+
? `${issue.detail} New compared with baseline ${baseline.finalUrl}.`
|
|
416
|
+
: issue.detail;
|
|
417
|
+
results.push({
|
|
418
|
+
ruleId,
|
|
419
|
+
level: toSarifLevel(issue.severity),
|
|
420
|
+
message: { text: message },
|
|
421
|
+
locations: [
|
|
422
|
+
{
|
|
423
|
+
physicalLocation: {
|
|
424
|
+
artifactLocation: { uri: analysis.finalUrl },
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
],
|
|
428
|
+
properties: {
|
|
429
|
+
host: analysis.host,
|
|
430
|
+
scannedAt: analysis.scannedAt,
|
|
431
|
+
score: analysis.score,
|
|
432
|
+
grade: analysis.grade,
|
|
433
|
+
statusCode: analysis.statusCode,
|
|
434
|
+
severity: issue.severity,
|
|
435
|
+
area: issue.area,
|
|
436
|
+
confidence: issue.confidence,
|
|
437
|
+
source: issue.source,
|
|
438
|
+
owasp: issue.owasp,
|
|
439
|
+
mitre: issue.mitre,
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
version: "2.1.0",
|
|
446
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
447
|
+
runs: [
|
|
448
|
+
{
|
|
449
|
+
tool: {
|
|
450
|
+
driver: {
|
|
451
|
+
name: "SecURL",
|
|
452
|
+
informationUri: "https://www.npmjs.com/package/securl",
|
|
453
|
+
rules: [...rules.values()],
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
results,
|
|
457
|
+
},
|
|
458
|
+
],
|
|
459
|
+
};
|
|
460
|
+
};
|
|
461
|
+
const renderSingleOutput = (analysis, format, diff = null) => {
|
|
462
|
+
if (format === "json") {
|
|
463
|
+
return `${JSON.stringify(diff ? { analysis, diff } : analysis, null, 2)}\n`;
|
|
464
|
+
}
|
|
465
|
+
if (format === "sarif") {
|
|
466
|
+
return `${JSON.stringify(buildSarifLog([analysis]), null, 2)}\n`;
|
|
467
|
+
}
|
|
468
|
+
if (format === "markdown") {
|
|
469
|
+
return `${formatMarkdown(analysis, diff)}\n`;
|
|
470
|
+
}
|
|
471
|
+
return `${formatSummary(analysis, diff)}\n`;
|
|
472
|
+
};
|
|
473
|
+
const renderBatchOutput = (analyses, format) => {
|
|
474
|
+
if (format === "json") {
|
|
475
|
+
return `${JSON.stringify({ analyses }, null, 2)}\n`;
|
|
476
|
+
}
|
|
477
|
+
if (format === "sarif") {
|
|
478
|
+
return `${JSON.stringify(buildSarifLog(analyses), null, 2)}\n`;
|
|
479
|
+
}
|
|
480
|
+
if (format === "markdown") {
|
|
481
|
+
return `${formatBatchMarkdown(analyses)}\n`;
|
|
482
|
+
}
|
|
483
|
+
return `${formatBatchSummary(analyses)}\n`;
|
|
484
|
+
};
|
|
485
|
+
const renderComparisonOutput = (current, baseline, diff, format) => {
|
|
486
|
+
if (format === "json") {
|
|
487
|
+
return `${JSON.stringify({ current, baseline, diff }, null, 2)}\n`;
|
|
488
|
+
}
|
|
489
|
+
if (format === "sarif") {
|
|
490
|
+
return `${JSON.stringify(buildSarifLog([current], {
|
|
491
|
+
baselineByHost: new Map([[current.host, baseline]]),
|
|
492
|
+
newIssueOnly: true,
|
|
493
|
+
}), null, 2)}\n`;
|
|
494
|
+
}
|
|
495
|
+
if (format === "markdown") {
|
|
496
|
+
return `${[
|
|
497
|
+
`# SecURL Comparison: ${current.host}`,
|
|
498
|
+
"",
|
|
499
|
+
`- Current: ${current.finalUrl}`,
|
|
500
|
+
`- Baseline: ${baseline.finalUrl}`,
|
|
501
|
+
`- Score change: ${baseline.score}/100 (${baseline.grade}) -> ${current.score}/100 (${current.grade})`,
|
|
502
|
+
`- Status change: ${baseline.statusCode} -> ${current.statusCode}`,
|
|
503
|
+
"",
|
|
504
|
+
"## Changes Since Baseline",
|
|
505
|
+
"",
|
|
506
|
+
...(diff.summary.length ? diff.summary.map((item) => `- ${item}`) : ["- No material posture changes summarized."]),
|
|
507
|
+
].join("\n")}\n`;
|
|
508
|
+
}
|
|
509
|
+
return `${formatComparisonSummary(current, baseline, diff)}\n`;
|
|
510
|
+
};
|
|
511
|
+
const shouldShowProgress = (parsed) => parsed.targets.length > 1
|
|
512
|
+
&& parsed.format === "summary"
|
|
513
|
+
&& !parsed.outputPath
|
|
514
|
+
&& Boolean(process.stderr.isTTY);
|
|
515
|
+
const scanTargets = async (parsed) => {
|
|
516
|
+
const analyses = [];
|
|
517
|
+
const showProgress = shouldShowProgress(parsed);
|
|
518
|
+
for (const [index, target] of parsed.targets.entries()) {
|
|
519
|
+
if (showProgress) {
|
|
520
|
+
process.stderr.write(`Scanning ${index + 1}/${parsed.targets.length}: ${target}\n`);
|
|
521
|
+
}
|
|
522
|
+
analyses.push(await analyzeUrl(target, { scanMode: parsed.scanMode }));
|
|
523
|
+
}
|
|
524
|
+
if (showProgress) {
|
|
525
|
+
process.stderr.write(`Completed ${analyses.length}/${parsed.targets.length} scans.\n`);
|
|
526
|
+
}
|
|
527
|
+
return analyses;
|
|
528
|
+
};
|
|
529
|
+
const main = async () => {
|
|
530
|
+
try {
|
|
531
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
532
|
+
if (parsed.command === "help") {
|
|
533
|
+
process.stdout.write(usage);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
let output;
|
|
537
|
+
let policyMessages = [];
|
|
538
|
+
if (parsed.command === "scan") {
|
|
539
|
+
const analyses = await scanTargets(parsed);
|
|
540
|
+
if (analyses.length === 1) {
|
|
541
|
+
const [analysis] = analyses;
|
|
542
|
+
const baselineAnalysis = parsed.baselinePath ? await parseBaselineAnalysis(parsed.baselinePath) : null;
|
|
543
|
+
const diff = baselineAnalysis
|
|
544
|
+
? buildHistoryDiffFromSnapshots(snapshotFromAnalysis(analysis), snapshotFromAnalysis(baselineAnalysis))
|
|
545
|
+
: null;
|
|
546
|
+
policyMessages = formatPolicyFailureMessages(analyses, {
|
|
547
|
+
failOnSeverity: parsed.failOnSeverity,
|
|
548
|
+
failOnRegression: parsed.failOnRegression,
|
|
549
|
+
failIfScoreBelow: parsed.failIfScoreBelow,
|
|
550
|
+
diff,
|
|
551
|
+
});
|
|
552
|
+
if (parsed.format === "ci-json") {
|
|
553
|
+
output = `${JSON.stringify({
|
|
554
|
+
mode: "scan",
|
|
555
|
+
targetCount: 1,
|
|
556
|
+
analysis: {
|
|
557
|
+
host: analysis.host,
|
|
558
|
+
finalUrl: analysis.finalUrl,
|
|
559
|
+
score: analysis.score,
|
|
560
|
+
grade: analysis.grade,
|
|
561
|
+
statusCode: analysis.statusCode,
|
|
562
|
+
issueCounts: summarizeIssueSeverities(analysis),
|
|
563
|
+
},
|
|
564
|
+
diff,
|
|
565
|
+
policy: buildPolicySummary(policyMessages, {
|
|
566
|
+
failOnSeverity: parsed.failOnSeverity,
|
|
567
|
+
failOnRegression: parsed.failOnRegression,
|
|
568
|
+
failIfScoreBelow: parsed.failIfScoreBelow,
|
|
569
|
+
}),
|
|
570
|
+
}, null, 2)}\n`;
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
output = renderSingleOutput(analysis, parsed.format, diff);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
policyMessages = formatPolicyFailureMessages(analyses, {
|
|
578
|
+
failOnSeverity: parsed.failOnSeverity,
|
|
579
|
+
failOnRegression: false,
|
|
580
|
+
failIfScoreBelow: parsed.failIfScoreBelow,
|
|
581
|
+
diff: null,
|
|
582
|
+
});
|
|
583
|
+
if (parsed.format === "ci-json") {
|
|
584
|
+
output = `${JSON.stringify({
|
|
585
|
+
mode: "scan",
|
|
586
|
+
targetCount: analyses.length,
|
|
587
|
+
analyses: analyses.map((analysis) => ({
|
|
588
|
+
host: analysis.host,
|
|
589
|
+
finalUrl: analysis.finalUrl,
|
|
590
|
+
score: analysis.score,
|
|
591
|
+
grade: analysis.grade,
|
|
592
|
+
statusCode: analysis.statusCode,
|
|
593
|
+
issueCounts: summarizeIssueSeverities(analysis),
|
|
594
|
+
})),
|
|
595
|
+
policy: buildPolicySummary(policyMessages, {
|
|
596
|
+
failOnSeverity: parsed.failOnSeverity,
|
|
597
|
+
failOnRegression: false,
|
|
598
|
+
failIfScoreBelow: parsed.failIfScoreBelow,
|
|
599
|
+
}),
|
|
600
|
+
}, null, 2)}\n`;
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
output = renderBatchOutput(analyses, parsed.format);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (parsed.outputPath) {
|
|
607
|
+
await writeFile(parsed.outputPath, output, "utf8");
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
process.stdout.write(output);
|
|
611
|
+
}
|
|
612
|
+
if (policyMessages.length) {
|
|
613
|
+
process.stderr.write(`${policyMessages.join("\n")}\n`);
|
|
614
|
+
process.exitCode = 1;
|
|
615
|
+
}
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const currentAnalysis = await parseBaselineAnalysis(parsed.currentPath);
|
|
619
|
+
const baselineAnalysis = await parseBaselineAnalysis(parsed.baselinePath);
|
|
620
|
+
const diff = buildHistoryDiffFromSnapshots(snapshotFromAnalysis(currentAnalysis), snapshotFromAnalysis(baselineAnalysis));
|
|
621
|
+
policyMessages = formatPolicyFailureMessages([currentAnalysis], {
|
|
622
|
+
failOnSeverity: parsed.failOnSeverity,
|
|
623
|
+
failOnRegression: parsed.failOnRegression,
|
|
624
|
+
failIfScoreBelow: parsed.failIfScoreBelow,
|
|
625
|
+
diff,
|
|
626
|
+
});
|
|
627
|
+
if (parsed.format === "ci-json") {
|
|
628
|
+
output = `${JSON.stringify({
|
|
629
|
+
mode: "compare",
|
|
630
|
+
current: {
|
|
631
|
+
host: currentAnalysis.host,
|
|
632
|
+
finalUrl: currentAnalysis.finalUrl,
|
|
633
|
+
score: currentAnalysis.score,
|
|
634
|
+
grade: currentAnalysis.grade,
|
|
635
|
+
statusCode: currentAnalysis.statusCode,
|
|
636
|
+
issueCounts: summarizeIssueSeverities(currentAnalysis),
|
|
637
|
+
},
|
|
638
|
+
baseline: {
|
|
639
|
+
host: baselineAnalysis.host,
|
|
640
|
+
finalUrl: baselineAnalysis.finalUrl,
|
|
641
|
+
score: baselineAnalysis.score,
|
|
642
|
+
grade: baselineAnalysis.grade,
|
|
643
|
+
statusCode: baselineAnalysis.statusCode,
|
|
644
|
+
issueCounts: summarizeIssueSeverities(baselineAnalysis),
|
|
645
|
+
},
|
|
646
|
+
diff,
|
|
647
|
+
policy: buildPolicySummary(policyMessages, {
|
|
648
|
+
failOnSeverity: parsed.failOnSeverity,
|
|
649
|
+
failOnRegression: parsed.failOnRegression,
|
|
650
|
+
failIfScoreBelow: parsed.failIfScoreBelow,
|
|
651
|
+
}),
|
|
652
|
+
}, null, 2)}\n`;
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
output = renderComparisonOutput(currentAnalysis, baselineAnalysis, diff, parsed.format);
|
|
656
|
+
}
|
|
657
|
+
if (parsed.outputPath) {
|
|
658
|
+
await writeFile(parsed.outputPath, output, "utf8");
|
|
659
|
+
}
|
|
660
|
+
else {
|
|
661
|
+
process.stdout.write(output);
|
|
662
|
+
}
|
|
663
|
+
if (policyMessages.length) {
|
|
664
|
+
process.stderr.write(`${policyMessages.join("\n")}\n`);
|
|
665
|
+
process.exitCode = 1;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
catch (error) {
|
|
669
|
+
process.stderr.write(`${formatErrorMessage(error)}\n`);
|
|
670
|
+
process.stderr.write("Use --help for CLI usage.\n");
|
|
671
|
+
process.exitCode = 1;
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
void main();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CompromiseSignalsInfo, CtDiscoveryInfo, ExposureSummary, HtmlSecurityInfo } from "./types.js";
|
|
2
|
+
interface CompromiseSignalInput {
|
|
3
|
+
finalUrl: URL;
|
|
4
|
+
htmlSecurity: HtmlSecurityInfo;
|
|
5
|
+
ctDiscovery: CtDiscoveryInfo;
|
|
6
|
+
exposure: ExposureSummary;
|
|
7
|
+
}
|
|
8
|
+
export declare const emptyCompromiseSignals: (summary?: string) => CompromiseSignalsInfo;
|
|
9
|
+
export declare function buildCompromiseSignals({ finalUrl, htmlSecurity, ctDiscovery, exposure, }: CompromiseSignalInput): CompromiseSignalsInfo;
|
|
10
|
+
export {};
|