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.
Files changed (74) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/LICENSE +21 -0
  3. package/README.md +427 -0
  4. package/RELEASING.md +37 -0
  5. package/SECURITY.md +27 -0
  6. package/dist/certificate.d.ts +5 -0
  7. package/dist/certificate.js +92 -0
  8. package/dist/cli.d.ts +1 -0
  9. package/dist/cli.js +674 -0
  10. package/dist/compromiseSignals.d.ts +10 -0
  11. package/dist/compromiseSignals.js +183 -0
  12. package/dist/cookie-analysis.d.ts +2 -0
  13. package/dist/cookie-analysis.js +41 -0
  14. package/dist/cookieAnalysis.d.ts +2 -0
  15. package/dist/cookieAnalysis.js +82 -0
  16. package/dist/ctDiscovery.d.ts +19 -0
  17. package/dist/ctDiscovery.js +357 -0
  18. package/dist/domain-security.d.ts +10 -0
  19. package/dist/domain-security.js +416 -0
  20. package/dist/header-analysis.d.ts +14 -0
  21. package/dist/header-analysis.js +165 -0
  22. package/dist/historyDiff.d.ts +4 -0
  23. package/dist/historyDiff.js +117 -0
  24. package/dist/html-extraction.d.ts +12 -0
  25. package/dist/html-extraction.js +279 -0
  26. package/dist/html-page-analysis.d.ts +38 -0
  27. package/dist/html-page-analysis.js +459 -0
  28. package/dist/htmlInsights.d.ts +23 -0
  29. package/dist/htmlInsights.js +460 -0
  30. package/dist/identityProvider.d.ts +14 -0
  31. package/dist/identityProvider.js +259 -0
  32. package/dist/index.d.ts +17 -0
  33. package/dist/index.js +1008 -0
  34. package/dist/infrastructure.d.ts +9 -0
  35. package/dist/infrastructure.js +149 -0
  36. package/dist/libraryRisk.d.ts +3 -0
  37. package/dist/libraryRisk.js +164 -0
  38. package/dist/network-validation.d.ts +30 -0
  39. package/dist/network-validation.js +161 -0
  40. package/dist/network.d.ts +34 -0
  41. package/dist/network.js +139 -0
  42. package/dist/passive-intelligence.d.ts +21 -0
  43. package/dist/passive-intelligence.js +247 -0
  44. package/dist/path-discovery.d.ts +4 -0
  45. package/dist/path-discovery.js +50 -0
  46. package/dist/postureDigest.d.ts +142 -0
  47. package/dist/postureDigest.js +159 -0
  48. package/dist/postureDrift.d.ts +4 -0
  49. package/dist/postureDrift.js +118 -0
  50. package/dist/postureRemediation.d.ts +6 -0
  51. package/dist/postureRemediation.js +286 -0
  52. package/dist/redirectChain.d.ts +2 -0
  53. package/dist/redirectChain.js +39 -0
  54. package/dist/riskEvents.d.ts +3 -0
  55. package/dist/riskEvents.js +187 -0
  56. package/dist/scannerConfig.d.ts +49 -0
  57. package/dist/scannerConfig.js +79 -0
  58. package/dist/scoring.d.ts +32 -0
  59. package/dist/scoring.js +367 -0
  60. package/dist/security-txt.d.ts +4 -0
  61. package/dist/security-txt.js +123 -0
  62. package/dist/surfaceEnrichment.d.ts +44 -0
  63. package/dist/surfaceEnrichment.js +377 -0
  64. package/dist/technology-detection.d.ts +4 -0
  65. package/dist/technology-detection.js +93 -0
  66. package/dist/types.d.ts +730 -0
  67. package/dist/types.js +1 -0
  68. package/dist/utils.d.ts +7 -0
  69. package/dist/utils.js +66 -0
  70. package/dist/wafFingerprint.d.ts +5 -0
  71. package/dist/wafFingerprint.js +156 -0
  72. package/examples/risk-events.mjs +27 -0
  73. package/examples/scan-url.mjs +17 -0
  74. 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 {};