securl 1.7.0 → 1.9.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/README.md +41 -0
- package/dist/actionPlan.d.ts +2 -0
- package/dist/actionPlan.js +315 -0
- package/dist/certificate.d.ts +2 -1
- package/dist/certificate.js +132 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +18 -3
- package/dist/types.d.ts +51 -0
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -174,6 +174,43 @@ console.log({
|
|
|
174
174
|
});
|
|
175
175
|
```
|
|
176
176
|
|
|
177
|
+
### 6. Prioritized action plans
|
|
178
|
+
|
|
179
|
+
Version `1.8.0+` includes an action-plan helper for client surfaces that need to show what to fix first without reinterpreting the full scan result.
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
import { analyzeUrl, buildActionPlan } from "securl";
|
|
183
|
+
|
|
184
|
+
const result = await analyzeUrl("https://example.com");
|
|
185
|
+
const actionPlan = buildActionPlan(result);
|
|
186
|
+
|
|
187
|
+
console.log({
|
|
188
|
+
grade: actionPlan.posture.grade,
|
|
189
|
+
mainRisk: actionPlan.posture.mainRisk,
|
|
190
|
+
highImpactActions: actionPlan.highImpactActions,
|
|
191
|
+
firstAction: actionPlan.items[0],
|
|
192
|
+
});
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Action-plan items include owner, effort, impact, confidence, score impact where available, evidence references, and verification guidance.
|
|
196
|
+
|
|
197
|
+
### 7. Live certificate checks
|
|
198
|
+
|
|
199
|
+
Version `1.9.0+` includes a lightweight certificate helper for Cert Watch-style clients that only need the currently served TLS certificate.
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
import { scanLiveCertificate } from "securl/live-certificate";
|
|
203
|
+
|
|
204
|
+
const certificate = await scanLiveCertificate(new URL("https://example.com"));
|
|
205
|
+
|
|
206
|
+
console.log({
|
|
207
|
+
issuer: certificate.issuer,
|
|
208
|
+
daysRemaining: certificate.daysRemaining,
|
|
209
|
+
protocol: certificate.protocol,
|
|
210
|
+
chainLength: certificate.chain.length,
|
|
211
|
+
});
|
|
212
|
+
```
|
|
213
|
+
|
|
177
214
|
### 6. Evidence-backed remediation plans
|
|
178
215
|
|
|
179
216
|
Version `1.4.0+` includes a remediation plan helper that turns score drivers and findings into prioritized, owner-aware fix guidance. Findings can also carry structured evidence references so clients can show why a finding was raised.
|
|
@@ -283,6 +320,8 @@ Primary exports:
|
|
|
283
320
|
- `buildHistoryDiffFromSnapshots(current, previous)` - build a structured diff between scans.
|
|
284
321
|
- `buildPostureRiskEventsFromSnapshots(current, previous, diff)` - classify scan changes into alert-friendly risk events.
|
|
285
322
|
- `buildPostureDigest(result)` - reduce a full scan result to a compact API/mobile-friendly digest.
|
|
323
|
+
- `buildActionPlan(result)` - turn remediation, score drivers, exposure, and vendor context into prioritized fix actions.
|
|
324
|
+
- `scanLiveCertificate(url)` - perform a TLS handshake-only certificate read for lightweight cert monitoring.
|
|
286
325
|
- `buildPostureDriftReportFromSnapshots(current, previous)` - produce a complete scan-to-scan drift report for monitoring, alerting, and history views.
|
|
287
326
|
- `buildPostureRemediationPlan(result)` - generate prioritized, owner-aware remediation actions from findings and score drivers.
|
|
288
327
|
- `attachIssueEvidence(result)` - add structured evidence references to findings without changing their existing fields.
|
|
@@ -292,6 +331,8 @@ Package subpath exports:
|
|
|
292
331
|
|
|
293
332
|
- `securl/history-diff`
|
|
294
333
|
- `securl/posture-digest`
|
|
334
|
+
- `securl/action-plan`
|
|
335
|
+
- `securl/live-certificate`
|
|
295
336
|
- `securl/posture-drift`
|
|
296
337
|
- `securl/remediation-plan`
|
|
297
338
|
- `securl/risk-events`
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
const normalizeArray = (value) => (Array.isArray(value) ? value : []);
|
|
2
|
+
const IMPACT_RANK = {
|
|
3
|
+
high: 0,
|
|
4
|
+
medium: 1,
|
|
5
|
+
low: 2,
|
|
6
|
+
};
|
|
7
|
+
function evidenceFromText(label, observed, kind, source) {
|
|
8
|
+
return {
|
|
9
|
+
kind,
|
|
10
|
+
label,
|
|
11
|
+
observed,
|
|
12
|
+
source: source,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function evidenceKindForExposure(source) {
|
|
16
|
+
if (source === "dns") {
|
|
17
|
+
return "dns";
|
|
18
|
+
}
|
|
19
|
+
if (source === "tls") {
|
|
20
|
+
return "tls";
|
|
21
|
+
}
|
|
22
|
+
if (source === "cookies") {
|
|
23
|
+
return "cookie";
|
|
24
|
+
}
|
|
25
|
+
if (source === "headers") {
|
|
26
|
+
return "header";
|
|
27
|
+
}
|
|
28
|
+
if (source === "html" || source === "third_party" || source === "ai") {
|
|
29
|
+
return "html";
|
|
30
|
+
}
|
|
31
|
+
if (source === "public_record" || source === "ct") {
|
|
32
|
+
return "public_record";
|
|
33
|
+
}
|
|
34
|
+
return "probe";
|
|
35
|
+
}
|
|
36
|
+
function ownerForExposure(category) {
|
|
37
|
+
if (category === "trust_gap") {
|
|
38
|
+
return "dns";
|
|
39
|
+
}
|
|
40
|
+
if (category === "third_party" || category === "ai") {
|
|
41
|
+
return "third_party";
|
|
42
|
+
}
|
|
43
|
+
if (category === "identity") {
|
|
44
|
+
return "identity";
|
|
45
|
+
}
|
|
46
|
+
if (category === "entry_point" || category === "infrastructure") {
|
|
47
|
+
return "edge";
|
|
48
|
+
}
|
|
49
|
+
return "app";
|
|
50
|
+
}
|
|
51
|
+
function themeForOwner(owner, title) {
|
|
52
|
+
const value = title.toLowerCase();
|
|
53
|
+
if (owner === "dns") {
|
|
54
|
+
return "domain_trust";
|
|
55
|
+
}
|
|
56
|
+
if (owner === "third_party") {
|
|
57
|
+
return "vendor_risk";
|
|
58
|
+
}
|
|
59
|
+
if (owner === "identity") {
|
|
60
|
+
return "identity";
|
|
61
|
+
}
|
|
62
|
+
if (value.includes("tls") || value.includes("certificate") || value.includes("https") || value.includes("hsts")) {
|
|
63
|
+
return "transport";
|
|
64
|
+
}
|
|
65
|
+
if (value.includes("monitor") || value.includes("rescan")) {
|
|
66
|
+
return "monitoring";
|
|
67
|
+
}
|
|
68
|
+
if (value.includes("unreachable") || value.includes("timeout") || value.includes("service")) {
|
|
69
|
+
return "availability";
|
|
70
|
+
}
|
|
71
|
+
if (value.includes("header") || value.includes("policy") || value.includes("cookie") || value.includes("csp")) {
|
|
72
|
+
return "browser_hardening";
|
|
73
|
+
}
|
|
74
|
+
return "public_exposure";
|
|
75
|
+
}
|
|
76
|
+
function themeForExposure(item) {
|
|
77
|
+
if (item.category === "trust_gap") {
|
|
78
|
+
return "domain_trust";
|
|
79
|
+
}
|
|
80
|
+
if (item.category === "third_party" || item.category === "ai") {
|
|
81
|
+
return "vendor_risk";
|
|
82
|
+
}
|
|
83
|
+
if (item.category === "identity") {
|
|
84
|
+
return "identity";
|
|
85
|
+
}
|
|
86
|
+
if (item.category === "entry_point" || item.category === "infrastructure") {
|
|
87
|
+
return "public_exposure";
|
|
88
|
+
}
|
|
89
|
+
if (item.source === "tls") {
|
|
90
|
+
return "transport";
|
|
91
|
+
}
|
|
92
|
+
return "public_exposure";
|
|
93
|
+
}
|
|
94
|
+
function impactForExposure(item) {
|
|
95
|
+
if (item.severity === "critical" || item.severity === "warning") {
|
|
96
|
+
return "high";
|
|
97
|
+
}
|
|
98
|
+
if (item.severity === "watch") {
|
|
99
|
+
return "medium";
|
|
100
|
+
}
|
|
101
|
+
return "low";
|
|
102
|
+
}
|
|
103
|
+
function effortForExposure(item) {
|
|
104
|
+
if (item.category === "entry_point" || item.category === "trust_gap") {
|
|
105
|
+
return "low";
|
|
106
|
+
}
|
|
107
|
+
if (item.category === "third_party" || item.category === "ai") {
|
|
108
|
+
return "medium";
|
|
109
|
+
}
|
|
110
|
+
return item.severity === "critical" ? "high" : "medium";
|
|
111
|
+
}
|
|
112
|
+
function scoreDriverAction(driver) {
|
|
113
|
+
if (driver.impact > 0) {
|
|
114
|
+
return `Review and improve ${driver.label} so this score driver no longer reduces the passive posture score.`;
|
|
115
|
+
}
|
|
116
|
+
return `Review ${driver.label} and confirm it is intentionally configured.`;
|
|
117
|
+
}
|
|
118
|
+
function scoreDriverTheme(driver) {
|
|
119
|
+
if (driver.source === "dns" || driver.areaKey === "domain") {
|
|
120
|
+
return "domain_trust";
|
|
121
|
+
}
|
|
122
|
+
if (driver.source === "tls") {
|
|
123
|
+
return "transport";
|
|
124
|
+
}
|
|
125
|
+
if (driver.source === "cookies" || driver.source === "headers") {
|
|
126
|
+
return "browser_hardening";
|
|
127
|
+
}
|
|
128
|
+
if (driver.source === "third_party" || driver.source === "ai") {
|
|
129
|
+
return "vendor_risk";
|
|
130
|
+
}
|
|
131
|
+
return "public_exposure";
|
|
132
|
+
}
|
|
133
|
+
function scoreDriverOwner(driver) {
|
|
134
|
+
if (driver.source === "dns" || driver.areaKey === "domain") {
|
|
135
|
+
return "dns";
|
|
136
|
+
}
|
|
137
|
+
if (driver.source === "headers" || driver.source === "tls") {
|
|
138
|
+
return "edge";
|
|
139
|
+
}
|
|
140
|
+
if (driver.source === "third_party" || driver.source === "ai") {
|
|
141
|
+
return "third_party";
|
|
142
|
+
}
|
|
143
|
+
if (driver.source === "cookies") {
|
|
144
|
+
return "app";
|
|
145
|
+
}
|
|
146
|
+
return "app";
|
|
147
|
+
}
|
|
148
|
+
function scoreDriverEvidence(driver) {
|
|
149
|
+
return [{
|
|
150
|
+
kind: "score_driver",
|
|
151
|
+
label: driver.label,
|
|
152
|
+
observed: driver.detail,
|
|
153
|
+
source: driver.source,
|
|
154
|
+
}];
|
|
155
|
+
}
|
|
156
|
+
function vendorEvidence(provider) {
|
|
157
|
+
return [{
|
|
158
|
+
kind: "html",
|
|
159
|
+
label: provider.domain || provider.name,
|
|
160
|
+
observed: provider.evidence,
|
|
161
|
+
source: "derived",
|
|
162
|
+
}];
|
|
163
|
+
}
|
|
164
|
+
function actionKey(item) {
|
|
165
|
+
return `${item.theme}:${item.title}:${item.action}`.toLowerCase().replace(/\s+/g, " ").trim();
|
|
166
|
+
}
|
|
167
|
+
function dedupeItems(items) {
|
|
168
|
+
const seen = new Set();
|
|
169
|
+
const unique = [];
|
|
170
|
+
for (const item of items) {
|
|
171
|
+
const key = actionKey(item);
|
|
172
|
+
if (seen.has(key)) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
seen.add(key);
|
|
176
|
+
unique.push(item);
|
|
177
|
+
}
|
|
178
|
+
return unique;
|
|
179
|
+
}
|
|
180
|
+
function sortItems(items) {
|
|
181
|
+
return [...items].sort((left, right) => {
|
|
182
|
+
const impactDelta = IMPACT_RANK[left.impact] - IMPACT_RANK[right.impact];
|
|
183
|
+
if (impactDelta !== 0) {
|
|
184
|
+
return impactDelta;
|
|
185
|
+
}
|
|
186
|
+
const priorityDelta = left.priority - right.priority;
|
|
187
|
+
if (priorityDelta !== 0) {
|
|
188
|
+
return priorityDelta;
|
|
189
|
+
}
|
|
190
|
+
return (right.scoreImpact ?? 0) - (left.scoreImpact ?? 0);
|
|
191
|
+
}).map((item, index) => ({
|
|
192
|
+
...item,
|
|
193
|
+
priority: index + 1,
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
function buildSummary(analysis, items) {
|
|
197
|
+
if (analysis.assessmentLimitation?.limited) {
|
|
198
|
+
return "The assessment was limited, so the first priority is restoring reliable scan coverage before treating the grade as complete.";
|
|
199
|
+
}
|
|
200
|
+
if (items.length === 0) {
|
|
201
|
+
return "No immediate action was identified from the passive evidence. Keep monitoring and rescan after deployment, DNS, or vendor changes.";
|
|
202
|
+
}
|
|
203
|
+
const highImpact = items.filter((item) => item.impact === "high").length;
|
|
204
|
+
if (highImpact > 0) {
|
|
205
|
+
return `${highImpact} high-impact action${highImpact === 1 ? "" : "s"} should move first because they affect the visible security posture most.`;
|
|
206
|
+
}
|
|
207
|
+
return "The next actions are mostly cleanup and monitoring work; no high-impact passive issue is currently leading the posture.";
|
|
208
|
+
}
|
|
209
|
+
export function buildActionPlan(analysis) {
|
|
210
|
+
const items = [];
|
|
211
|
+
for (const planItem of normalizeArray(analysis.remediationPlan?.items)) {
|
|
212
|
+
items.push({
|
|
213
|
+
id: `remediation:${planItem.id}`,
|
|
214
|
+
priority: planItem.priority,
|
|
215
|
+
title: planItem.title,
|
|
216
|
+
whyNow: planItem.scoreImpact && planItem.scoreImpact > 0
|
|
217
|
+
? `This is costing about ${planItem.scoreImpact} point${planItem.scoreImpact === 1 ? "" : "s"} in the passive score.`
|
|
218
|
+
: planItem.detail,
|
|
219
|
+
action: planItem.action,
|
|
220
|
+
verify: planItem.verify,
|
|
221
|
+
owner: planItem.owner,
|
|
222
|
+
effort: planItem.effort,
|
|
223
|
+
impact: planItem.impact,
|
|
224
|
+
scoreImpact: planItem.scoreImpact,
|
|
225
|
+
confidence: "high",
|
|
226
|
+
theme: themeForOwner(planItem.owner, planItem.title),
|
|
227
|
+
evidence: normalizeArray(planItem.evidence).slice(0, 5),
|
|
228
|
+
relatedFindings: normalizeArray(planItem.relatedFindings).slice(0, 5),
|
|
229
|
+
source: "remediation",
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
for (const risk of normalizeArray(analysis.exposureBrief?.topRisks)) {
|
|
233
|
+
if (!risk.action) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const owner = ownerForExposure(risk.category);
|
|
237
|
+
items.push({
|
|
238
|
+
id: `exposure:${risk.category}:${risk.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`,
|
|
239
|
+
priority: 50,
|
|
240
|
+
title: risk.title,
|
|
241
|
+
whyNow: risk.detail,
|
|
242
|
+
action: risk.action,
|
|
243
|
+
verify: "Rescan the target and confirm this exposure brief item is no longer reported as a top risk.",
|
|
244
|
+
owner,
|
|
245
|
+
effort: effortForExposure(risk),
|
|
246
|
+
impact: impactForExposure(risk),
|
|
247
|
+
scoreImpact: null,
|
|
248
|
+
confidence: risk.confidence,
|
|
249
|
+
theme: themeForExposure(risk),
|
|
250
|
+
evidence: normalizeArray(risk.evidence).map((evidence) => evidenceFromText(risk.title, evidence, evidenceKindForExposure(risk.source), risk.source)).slice(0, 5),
|
|
251
|
+
relatedFindings: [risk.detail],
|
|
252
|
+
source: "exposure_brief",
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
for (const provider of normalizeArray(analysis.vendorExposure?.highPriorityProviders)) {
|
|
256
|
+
items.push({
|
|
257
|
+
id: `vendor:${provider.domain || provider.name}`.toLowerCase(),
|
|
258
|
+
priority: provider.reviewPriority === "urgent" ? 40 : 60,
|
|
259
|
+
title: `Review ${provider.name} vendor exposure`,
|
|
260
|
+
whyNow: `${provider.domain} is visible as a ${provider.risk}-risk ${provider.category} provider with ${provider.dataFlow.replace(/_/g, " ")} data-flow implications.`,
|
|
261
|
+
action: provider.action,
|
|
262
|
+
verify: "Rescan the target and confirm the provider remains documented, intentionally loaded, or no longer appears.",
|
|
263
|
+
owner: "third_party",
|
|
264
|
+
effort: "medium",
|
|
265
|
+
impact: provider.reviewPriority === "urgent" || provider.risk === "high" ? "high" : "medium",
|
|
266
|
+
scoreImpact: null,
|
|
267
|
+
confidence: "medium",
|
|
268
|
+
theme: "vendor_risk",
|
|
269
|
+
evidence: vendorEvidence(provider),
|
|
270
|
+
relatedFindings: [provider.domain],
|
|
271
|
+
source: "vendor_exposure",
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
if (items.length === 0) {
|
|
275
|
+
for (const driver of normalizeArray(analysis.scoreDrivers).filter((driver) => driver.impact > 0).slice(0, 5)) {
|
|
276
|
+
const owner = scoreDriverOwner(driver);
|
|
277
|
+
items.push({
|
|
278
|
+
id: `score:${driver.areaKey}:${driver.label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`,
|
|
279
|
+
priority: 100 - driver.impact,
|
|
280
|
+
title: driver.label,
|
|
281
|
+
whyNow: driver.detail,
|
|
282
|
+
action: scoreDriverAction(driver),
|
|
283
|
+
verify: "Rescan and confirm this score driver is no longer reported as missing or warning.",
|
|
284
|
+
owner,
|
|
285
|
+
effort: driver.impact >= 8 ? "medium" : "low",
|
|
286
|
+
impact: driver.impact >= 10 ? "high" : driver.impact >= 4 ? "medium" : "low",
|
|
287
|
+
scoreImpact: driver.impact,
|
|
288
|
+
confidence: "medium",
|
|
289
|
+
theme: scoreDriverTheme(driver),
|
|
290
|
+
evidence: scoreDriverEvidence(driver),
|
|
291
|
+
relatedFindings: [driver.detail],
|
|
292
|
+
source: "score_driver",
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const sortedItems = sortItems(dedupeItems(items)).slice(0, 8);
|
|
297
|
+
return {
|
|
298
|
+
generatedAt: new Date().toISOString(),
|
|
299
|
+
summary: buildSummary(analysis, sortedItems),
|
|
300
|
+
posture: {
|
|
301
|
+
score: analysis.score,
|
|
302
|
+
grade: analysis.grade,
|
|
303
|
+
limited: Boolean(analysis.assessmentLimitation?.limited),
|
|
304
|
+
mainRisk: sortedItems[0]?.title ?? null,
|
|
305
|
+
},
|
|
306
|
+
totalActions: sortedItems.length,
|
|
307
|
+
highImpactActions: sortedItems.filter((item) => item.impact === "high").length,
|
|
308
|
+
quickWins: sortedItems.filter((item) => item.effort === "low").length,
|
|
309
|
+
items: sortedItems,
|
|
310
|
+
nextReview: analysis.assessmentLimitation?.limited
|
|
311
|
+
? "Resolve the limited-assessment condition and rerun the scan before relying on the grade."
|
|
312
|
+
: "Rerun after the high-impact actions are complete, and again after deployment, DNS, or vendor changes.",
|
|
313
|
+
limitation: analysis.assessmentLimitation?.limited ? analysis.assessmentLimitation : null,
|
|
314
|
+
};
|
|
315
|
+
}
|
package/dist/certificate.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { CertificateResult } from "./types.js";
|
|
1
|
+
import type { CertificateResult, LiveCertificateResult } from "./types.js";
|
|
2
2
|
export declare const OBSERVATIONAL_TLS_OPTIONS: {
|
|
3
3
|
rejectUnauthorized: boolean;
|
|
4
4
|
};
|
|
5
5
|
export declare const scanTls: (targetUrl: URL) => Promise<CertificateResult>;
|
|
6
|
+
export declare const scanLiveCertificate: (targetUrl: URL) => Promise<LiveCertificateResult>;
|
package/dist/certificate.js
CHANGED
|
@@ -16,6 +16,48 @@ export const OBSERVATIONAL_TLS_OPTIONS = {
|
|
|
16
16
|
// Set EXTERNAL_POSTURE_ALLOW_INSECURE_TLS=1 only for controlled observational runs.
|
|
17
17
|
rejectUnauthorized: !allowInsecureTls,
|
|
18
18
|
};
|
|
19
|
+
function certificateName(certificate, field) {
|
|
20
|
+
return firstStringValue(certificate?.[field]?.O) ?? firstStringValue(certificate?.[field]?.CN);
|
|
21
|
+
}
|
|
22
|
+
function chainFromCertificate(certificate) {
|
|
23
|
+
const chain = [];
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
let current = certificate;
|
|
26
|
+
while (current && Object.keys(current).length > 0) {
|
|
27
|
+
const fingerprint = current.fingerprint256 || current.fingerprint || null;
|
|
28
|
+
const key = fingerprint || `${current.subject?.CN || ""}:${current.issuer?.CN || ""}:${current.valid_to || ""}`;
|
|
29
|
+
if (seen.has(key)) {
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
seen.add(key);
|
|
33
|
+
chain.push({
|
|
34
|
+
subject: certificateName(current, "subject"),
|
|
35
|
+
issuer: certificateName(current, "issuer"),
|
|
36
|
+
validFrom: current.valid_from || null,
|
|
37
|
+
validTo: current.valid_to || null,
|
|
38
|
+
fingerprint,
|
|
39
|
+
});
|
|
40
|
+
if (!current.issuerCertificate || current.issuerCertificate === current) {
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
current = current.issuerCertificate;
|
|
44
|
+
}
|
|
45
|
+
return chain;
|
|
46
|
+
}
|
|
47
|
+
function keyBitsFromCertificate(certificate) {
|
|
48
|
+
const value = certificate?.bits;
|
|
49
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
50
|
+
}
|
|
51
|
+
function keyTypeFromCertificate(certificate) {
|
|
52
|
+
const value = certificate?.asn1Curve || certificate?.nistCurve;
|
|
53
|
+
if (typeof value === "string" && value.trim()) {
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
if (typeof certificate?.bits === "number") {
|
|
57
|
+
return "rsa";
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
19
61
|
export const scanTls = (targetUrl) => {
|
|
20
62
|
if (targetUrl.protocol !== "https:") {
|
|
21
63
|
return Promise.resolve({
|
|
@@ -71,8 +113,89 @@ export const scanTls = (targetUrl) => {
|
|
|
71
113
|
available: true,
|
|
72
114
|
valid: Boolean(socket.authorized),
|
|
73
115
|
authorized: Boolean(socket.authorized),
|
|
74
|
-
issuer:
|
|
75
|
-
subject:
|
|
116
|
+
issuer: certificateName(certificate, "issuer"),
|
|
117
|
+
subject: certificateName(certificate, "subject"),
|
|
118
|
+
validFrom,
|
|
119
|
+
validTo,
|
|
120
|
+
daysRemaining,
|
|
121
|
+
protocol,
|
|
122
|
+
cipher: cipherInfo?.name || null,
|
|
123
|
+
fingerprint: certificate?.fingerprint256 || null,
|
|
124
|
+
subjectAltName,
|
|
125
|
+
issues,
|
|
126
|
+
});
|
|
127
|
+
socket.end();
|
|
128
|
+
});
|
|
129
|
+
socket.once("timeout", () => {
|
|
130
|
+
socket.destroy(new Error("TLS handshake timed out."));
|
|
131
|
+
});
|
|
132
|
+
socket.once("error", reject);
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
export const scanLiveCertificate = (targetUrl) => {
|
|
136
|
+
if (targetUrl.protocol !== "https:") {
|
|
137
|
+
return Promise.resolve({
|
|
138
|
+
available: false,
|
|
139
|
+
valid: false,
|
|
140
|
+
authorized: false,
|
|
141
|
+
issuer: null,
|
|
142
|
+
subject: null,
|
|
143
|
+
validFrom: null,
|
|
144
|
+
validTo: null,
|
|
145
|
+
daysRemaining: null,
|
|
146
|
+
protocol: null,
|
|
147
|
+
cipher: null,
|
|
148
|
+
fingerprint: null,
|
|
149
|
+
subjectAltName: [],
|
|
150
|
+
issues: ["TLS certificate data is only available for HTTPS targets."],
|
|
151
|
+
host: targetUrl.hostname,
|
|
152
|
+
port: Number(targetUrl.port || 443),
|
|
153
|
+
checkedAt: new Date().toISOString(),
|
|
154
|
+
serialNumber: null,
|
|
155
|
+
keyBits: null,
|
|
156
|
+
keyType: null,
|
|
157
|
+
chain: [],
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return new Promise((resolve, reject) => {
|
|
161
|
+
const socket = tls.connect({
|
|
162
|
+
host: targetUrl.hostname,
|
|
163
|
+
port: Number(targetUrl.port || 443),
|
|
164
|
+
servername: targetUrl.hostname,
|
|
165
|
+
...OBSERVATIONAL_TLS_OPTIONS,
|
|
166
|
+
timeout: TLS_HANDSHAKE_TIMEOUT_MS,
|
|
167
|
+
});
|
|
168
|
+
socket.once("secureConnect", () => {
|
|
169
|
+
const certificate = socket.getPeerCertificate(true);
|
|
170
|
+
const protocol = socket.getProtocol?.() || null;
|
|
171
|
+
const cipherInfo = socket.getCipher?.();
|
|
172
|
+
const validTo = certificate?.valid_to || null;
|
|
173
|
+
const validFrom = certificate?.valid_from || null;
|
|
174
|
+
const daysRemaining = validTo
|
|
175
|
+
? Math.ceil((new Date(validTo).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
|
176
|
+
: null;
|
|
177
|
+
const subjectAltName = typeof certificate?.subjectaltname === "string"
|
|
178
|
+
? certificate.subjectaltname.split(",").map((entry) => entry.trim().replace(/^DNS:/, ""))
|
|
179
|
+
: [];
|
|
180
|
+
const issues = [];
|
|
181
|
+
if (!socket.authorized) {
|
|
182
|
+
issues.push(typeof socket.authorizationError === "string"
|
|
183
|
+
? socket.authorizationError
|
|
184
|
+
: "Certificate is not trusted.");
|
|
185
|
+
}
|
|
186
|
+
if (allowInsecureTls) {
|
|
187
|
+
issues.push("Insecure TLS observation mode is enabled via EXTERNAL_POSTURE_ALLOW_INSECURE_TLS.");
|
|
188
|
+
}
|
|
189
|
+
if (daysRemaining !== null && daysRemaining <= 14)
|
|
190
|
+
issues.push("Certificate expires very soon.");
|
|
191
|
+
if (protocol && /tlsv1(\.0|\.1)?$/i.test(protocol))
|
|
192
|
+
issues.push("TLS protocol is outdated.");
|
|
193
|
+
resolve({
|
|
194
|
+
available: true,
|
|
195
|
+
valid: Boolean(socket.authorized),
|
|
196
|
+
authorized: Boolean(socket.authorized),
|
|
197
|
+
issuer: certificateName(certificate, "issuer"),
|
|
198
|
+
subject: certificateName(certificate, "subject"),
|
|
76
199
|
validFrom,
|
|
77
200
|
validTo,
|
|
78
201
|
daysRemaining,
|
|
@@ -81,6 +204,13 @@ export const scanTls = (targetUrl) => {
|
|
|
81
204
|
fingerprint: certificate?.fingerprint256 || null,
|
|
82
205
|
subjectAltName,
|
|
83
206
|
issues,
|
|
207
|
+
host: targetUrl.hostname,
|
|
208
|
+
port: Number(targetUrl.port || 443),
|
|
209
|
+
checkedAt: new Date().toISOString(),
|
|
210
|
+
serialNumber: certificate?.serialNumber || null,
|
|
211
|
+
keyBits: keyBitsFromCertificate(certificate),
|
|
212
|
+
keyType: keyTypeFromCertificate(certificate),
|
|
213
|
+
chain: chainFromCertificate(certificate),
|
|
84
214
|
});
|
|
85
215
|
socket.end();
|
|
86
216
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export declare function analyzeUrl(input: string, options?: AnalyzeTargetOptions
|
|
|
11
11
|
export declare const analyzeTarget: typeof analyzeUrl;
|
|
12
12
|
export { formatErrorMessage };
|
|
13
13
|
export { buildCompromiseSignals, emptyCompromiseSignals } from "./compromiseSignals.js";
|
|
14
|
+
export { buildActionPlan } from "./actionPlan.js";
|
|
15
|
+
export { scanLiveCertificate } from "./certificate.js";
|
|
14
16
|
export { buildExposureBrief } from "./exposureBrief.js";
|
|
15
17
|
export { buildVendorExposureBrief } from "./vendorExposure.js";
|
|
16
18
|
export { analyzeInfrastructure } from "./infrastructure.js";
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { URL } from "node:url";
|
|
2
2
|
import { scanTls } from "./certificate.js";
|
|
3
|
+
import { buildActionPlan } from "./actionPlan.js";
|
|
3
4
|
import { buildCompromiseSignals, emptyCompromiseSignals } from "./compromiseSignals.js";
|
|
4
5
|
import { buildExposureBrief } from "./exposureBrief.js";
|
|
5
6
|
import { buildVendorExposureBrief } from "./vendorExposure.js";
|
|
@@ -508,11 +509,15 @@ async function buildLimitedResult(input, normalizedInput, failure, scanTiming) {
|
|
|
508
509
|
remediationPlan,
|
|
509
510
|
evidenceSummary: buildPostureEvidenceSummary({ ...evidenceResult, remediationPlan }),
|
|
510
511
|
};
|
|
511
|
-
|
|
512
|
+
const resultWithBriefs = {
|
|
512
513
|
...resultWithRemediation,
|
|
513
514
|
exposureBrief: buildExposureBrief(resultWithRemediation),
|
|
514
515
|
vendorExposure: buildVendorExposureBrief(resultWithRemediation),
|
|
515
516
|
};
|
|
517
|
+
return {
|
|
518
|
+
...resultWithBriefs,
|
|
519
|
+
actionPlan: buildActionPlan(resultWithBriefs),
|
|
520
|
+
};
|
|
516
521
|
}
|
|
517
522
|
async function enrichCoreResult(result, profile) {
|
|
518
523
|
const finalUrl = new URL(result.finalUrl);
|
|
@@ -943,11 +948,15 @@ function buildTimedOutEnrichmentResult(result, pageAnalysisEnabled, timeoutMs, c
|
|
|
943
948
|
remediationPlan,
|
|
944
949
|
evidenceSummary: buildPostureEvidenceSummary({ ...evidenceResult, remediationPlan }),
|
|
945
950
|
};
|
|
946
|
-
|
|
951
|
+
const resultWithBriefs = {
|
|
947
952
|
...resultWithRemediation,
|
|
948
953
|
exposureBrief: buildExposureBrief(resultWithRemediation),
|
|
949
954
|
vendorExposure: buildVendorExposureBrief(resultWithRemediation),
|
|
950
955
|
};
|
|
956
|
+
return {
|
|
957
|
+
...resultWithBriefs,
|
|
958
|
+
actionPlan: buildActionPlan(resultWithBriefs),
|
|
959
|
+
};
|
|
951
960
|
}
|
|
952
961
|
export async function analyzeUrl(input, options = {}) {
|
|
953
962
|
const scanStartedAt = Date.now();
|
|
@@ -1017,15 +1026,21 @@ export async function analyzeUrl(input, options = {}) {
|
|
|
1017
1026
|
remediationPlan,
|
|
1018
1027
|
evidenceSummary: buildPostureEvidenceSummary({ ...resultWithEvidence, remediationPlan }),
|
|
1019
1028
|
};
|
|
1020
|
-
|
|
1029
|
+
const resultWithBriefs = {
|
|
1021
1030
|
...resultWithRemediation,
|
|
1022
1031
|
exposureBrief: buildExposureBrief(resultWithRemediation),
|
|
1023
1032
|
vendorExposure: buildVendorExposureBrief(resultWithRemediation),
|
|
1024
1033
|
};
|
|
1034
|
+
return {
|
|
1035
|
+
...resultWithBriefs,
|
|
1036
|
+
actionPlan: buildActionPlan(resultWithBriefs),
|
|
1037
|
+
};
|
|
1025
1038
|
}
|
|
1026
1039
|
export const analyzeTarget = analyzeUrl;
|
|
1027
1040
|
export { formatErrorMessage };
|
|
1028
1041
|
export { buildCompromiseSignals, emptyCompromiseSignals } from "./compromiseSignals.js";
|
|
1042
|
+
export { buildActionPlan } from "./actionPlan.js";
|
|
1043
|
+
export { scanLiveCertificate } from "./certificate.js";
|
|
1029
1044
|
export { buildExposureBrief } from "./exposureBrief.js";
|
|
1030
1045
|
export { buildVendorExposureBrief } from "./vendorExposure.js";
|
|
1031
1046
|
export { analyzeInfrastructure } from "./infrastructure.js";
|
package/dist/types.d.ts
CHANGED
|
@@ -67,6 +67,22 @@ export interface CertificateResult {
|
|
|
67
67
|
subjectAltName: string[];
|
|
68
68
|
issues: string[];
|
|
69
69
|
}
|
|
70
|
+
export interface LiveCertificateChainEntry {
|
|
71
|
+
subject: string | null;
|
|
72
|
+
issuer: string | null;
|
|
73
|
+
validFrom: string | null;
|
|
74
|
+
validTo: string | null;
|
|
75
|
+
fingerprint: string | null;
|
|
76
|
+
}
|
|
77
|
+
export interface LiveCertificateResult extends CertificateResult {
|
|
78
|
+
host: string;
|
|
79
|
+
port: number;
|
|
80
|
+
checkedAt: string;
|
|
81
|
+
serialNumber: string | null;
|
|
82
|
+
keyBits: number | null;
|
|
83
|
+
keyType: string | null;
|
|
84
|
+
chain: LiveCertificateChainEntry[];
|
|
85
|
+
}
|
|
70
86
|
export interface RedirectHop {
|
|
71
87
|
url: string;
|
|
72
88
|
status: number;
|
|
@@ -224,6 +240,40 @@ export interface VendorExposureBrief {
|
|
|
224
240
|
collectionBoundary: string;
|
|
225
241
|
limitation: AssessmentLimitation | null;
|
|
226
242
|
}
|
|
243
|
+
export type ActionPlanTheme = "browser_hardening" | "transport" | "domain_trust" | "public_exposure" | "vendor_risk" | "identity" | "availability" | "monitoring";
|
|
244
|
+
export interface ActionPlanItem {
|
|
245
|
+
id: string;
|
|
246
|
+
priority: number;
|
|
247
|
+
title: string;
|
|
248
|
+
whyNow: string;
|
|
249
|
+
action: string;
|
|
250
|
+
verify: string;
|
|
251
|
+
owner: RemediationOwner;
|
|
252
|
+
effort: RemediationEffort;
|
|
253
|
+
impact: RemediationImpact;
|
|
254
|
+
scoreImpact: number | null;
|
|
255
|
+
confidence: IssueConfidence;
|
|
256
|
+
theme: ActionPlanTheme;
|
|
257
|
+
evidence: ScanEvidenceReference[];
|
|
258
|
+
relatedFindings: string[];
|
|
259
|
+
source: "remediation" | "score_driver" | "exposure_brief" | "vendor_exposure";
|
|
260
|
+
}
|
|
261
|
+
export interface ActionPlan {
|
|
262
|
+
generatedAt: string;
|
|
263
|
+
summary: string;
|
|
264
|
+
posture: {
|
|
265
|
+
score: number;
|
|
266
|
+
grade: string;
|
|
267
|
+
limited: boolean;
|
|
268
|
+
mainRisk: string | null;
|
|
269
|
+
};
|
|
270
|
+
totalActions: number;
|
|
271
|
+
highImpactActions: number;
|
|
272
|
+
quickWins: number;
|
|
273
|
+
items: ActionPlanItem[];
|
|
274
|
+
nextReview: string;
|
|
275
|
+
limitation: AssessmentLimitation | null;
|
|
276
|
+
}
|
|
227
277
|
export interface CrawlPageResult {
|
|
228
278
|
label: string;
|
|
229
279
|
path: string;
|
|
@@ -791,6 +841,7 @@ export interface AnalysisResult {
|
|
|
791
841
|
evidenceSummary?: PostureEvidenceSummary;
|
|
792
842
|
exposureBrief?: ExposureBrief;
|
|
793
843
|
vendorExposure?: VendorExposureBrief;
|
|
844
|
+
actionPlan?: ActionPlan;
|
|
794
845
|
crawl: CrawlSummary;
|
|
795
846
|
securityTxt: SecurityTxtInfo;
|
|
796
847
|
domainSecurity: DomainSecurityInfo;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "securl",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Passive external security posture scanner for public URLs and web services.",
|
|
6
6
|
"author": {
|
|
@@ -73,6 +73,14 @@
|
|
|
73
73
|
"./vendor-exposure": {
|
|
74
74
|
"types": "./dist/vendorExposure.d.ts",
|
|
75
75
|
"default": "./dist/vendorExposure.js"
|
|
76
|
+
},
|
|
77
|
+
"./action-plan": {
|
|
78
|
+
"types": "./dist/actionPlan.d.ts",
|
|
79
|
+
"default": "./dist/actionPlan.js"
|
|
80
|
+
},
|
|
81
|
+
"./live-certificate": {
|
|
82
|
+
"types": "./dist/certificate.d.ts",
|
|
83
|
+
"default": "./dist/certificate.js"
|
|
76
84
|
}
|
|
77
85
|
},
|
|
78
86
|
"files": [
|