securl 1.6.0 → 1.8.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/CHANGELOG.md +2 -0
- package/README.md +22 -0
- package/dist/actionPlan.d.ts +2 -0
- package/dist/actionPlan.js +315 -0
- package/dist/cli.js +0 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +22 -3
- package/dist/types.d.ts +70 -0
- package/dist/vendorExposure.d.ts +2 -0
- package/dist/vendorExposure.js +151 -0
- package/package.json +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,8 @@ The format is based on Keep a Changelog and this package follows Semantic Versio
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
9
|
### Added
|
|
10
|
+
- Added `buildVendorExposureBrief()` for compact vendor and supply-chain exposure summaries covering visible third-party providers, data-flow categories, SRI gaps, priority vendors, and next actions.
|
|
11
|
+
- Added `vendorExposure` to analysis results and the `securl/vendor-exposure` package export for SDK consumers.
|
|
10
12
|
- Added `buildExposureBrief()` for compact outside-observer action briefs covering public entry points, sensitive exposures, trust gaps, abuse indicators, third-party risk, AI surface signals, and next actions.
|
|
11
13
|
- Added `exposureBrief` to analysis results and the `securl/exposure-brief` package export for SDK consumers.
|
|
12
14
|
|
package/README.md
CHANGED
|
@@ -174,6 +174,26 @@ 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
|
+
|
|
177
197
|
### 6. Evidence-backed remediation plans
|
|
178
198
|
|
|
179
199
|
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 +303,7 @@ Primary exports:
|
|
|
283
303
|
- `buildHistoryDiffFromSnapshots(current, previous)` - build a structured diff between scans.
|
|
284
304
|
- `buildPostureRiskEventsFromSnapshots(current, previous, diff)` - classify scan changes into alert-friendly risk events.
|
|
285
305
|
- `buildPostureDigest(result)` - reduce a full scan result to a compact API/mobile-friendly digest.
|
|
306
|
+
- `buildActionPlan(result)` - turn remediation, score drivers, exposure, and vendor context into prioritized fix actions.
|
|
286
307
|
- `buildPostureDriftReportFromSnapshots(current, previous)` - produce a complete scan-to-scan drift report for monitoring, alerting, and history views.
|
|
287
308
|
- `buildPostureRemediationPlan(result)` - generate prioritized, owner-aware remediation actions from findings and score drivers.
|
|
288
309
|
- `attachIssueEvidence(result)` - add structured evidence references to findings without changing their existing fields.
|
|
@@ -292,6 +313,7 @@ Package subpath exports:
|
|
|
292
313
|
|
|
293
314
|
- `securl/history-diff`
|
|
294
315
|
- `securl/posture-digest`
|
|
316
|
+
- `securl/action-plan`
|
|
295
317
|
- `securl/posture-drift`
|
|
296
318
|
- `securl/remediation-plan`
|
|
297
319
|
- `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/cli.js
CHANGED
|
File without changes
|
package/dist/index.d.ts
CHANGED
|
@@ -11,7 +11,9 @@ 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";
|
|
14
15
|
export { buildExposureBrief } from "./exposureBrief.js";
|
|
16
|
+
export { buildVendorExposureBrief } from "./vendorExposure.js";
|
|
15
17
|
export { analyzeInfrastructure } from "./infrastructure.js";
|
|
16
18
|
export { buildHistoryDiff, buildHistoryDiffFromSnapshots, snapshotFromAnalysis } from "./historyDiff.js";
|
|
17
19
|
export { assertPublicRequestTarget, isLocalHostname, isPrivateAddress, } from "./network-validation.js";
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
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";
|
|
6
|
+
import { buildVendorExposureBrief } from "./vendorExposure.js";
|
|
5
7
|
import { parseSetCookie } from "./cookie-analysis.js";
|
|
6
8
|
import { analyzeCookieHeaders } from "./cookieAnalysis.js";
|
|
7
9
|
import { fetchCtDiscovery } from "./ctDiscovery.js";
|
|
@@ -507,9 +509,14 @@ async function buildLimitedResult(input, normalizedInput, failure, scanTiming) {
|
|
|
507
509
|
remediationPlan,
|
|
508
510
|
evidenceSummary: buildPostureEvidenceSummary({ ...evidenceResult, remediationPlan }),
|
|
509
511
|
};
|
|
510
|
-
|
|
512
|
+
const resultWithBriefs = {
|
|
511
513
|
...resultWithRemediation,
|
|
512
514
|
exposureBrief: buildExposureBrief(resultWithRemediation),
|
|
515
|
+
vendorExposure: buildVendorExposureBrief(resultWithRemediation),
|
|
516
|
+
};
|
|
517
|
+
return {
|
|
518
|
+
...resultWithBriefs,
|
|
519
|
+
actionPlan: buildActionPlan(resultWithBriefs),
|
|
513
520
|
};
|
|
514
521
|
}
|
|
515
522
|
async function enrichCoreResult(result, profile) {
|
|
@@ -941,9 +948,14 @@ function buildTimedOutEnrichmentResult(result, pageAnalysisEnabled, timeoutMs, c
|
|
|
941
948
|
remediationPlan,
|
|
942
949
|
evidenceSummary: buildPostureEvidenceSummary({ ...evidenceResult, remediationPlan }),
|
|
943
950
|
};
|
|
944
|
-
|
|
951
|
+
const resultWithBriefs = {
|
|
945
952
|
...resultWithRemediation,
|
|
946
953
|
exposureBrief: buildExposureBrief(resultWithRemediation),
|
|
954
|
+
vendorExposure: buildVendorExposureBrief(resultWithRemediation),
|
|
955
|
+
};
|
|
956
|
+
return {
|
|
957
|
+
...resultWithBriefs,
|
|
958
|
+
actionPlan: buildActionPlan(resultWithBriefs),
|
|
947
959
|
};
|
|
948
960
|
}
|
|
949
961
|
export async function analyzeUrl(input, options = {}) {
|
|
@@ -1014,15 +1026,22 @@ export async function analyzeUrl(input, options = {}) {
|
|
|
1014
1026
|
remediationPlan,
|
|
1015
1027
|
evidenceSummary: buildPostureEvidenceSummary({ ...resultWithEvidence, remediationPlan }),
|
|
1016
1028
|
};
|
|
1017
|
-
|
|
1029
|
+
const resultWithBriefs = {
|
|
1018
1030
|
...resultWithRemediation,
|
|
1019
1031
|
exposureBrief: buildExposureBrief(resultWithRemediation),
|
|
1032
|
+
vendorExposure: buildVendorExposureBrief(resultWithRemediation),
|
|
1033
|
+
};
|
|
1034
|
+
return {
|
|
1035
|
+
...resultWithBriefs,
|
|
1036
|
+
actionPlan: buildActionPlan(resultWithBriefs),
|
|
1020
1037
|
};
|
|
1021
1038
|
}
|
|
1022
1039
|
export const analyzeTarget = analyzeUrl;
|
|
1023
1040
|
export { formatErrorMessage };
|
|
1024
1041
|
export { buildCompromiseSignals, emptyCompromiseSignals } from "./compromiseSignals.js";
|
|
1042
|
+
export { buildActionPlan } from "./actionPlan.js";
|
|
1025
1043
|
export { buildExposureBrief } from "./exposureBrief.js";
|
|
1044
|
+
export { buildVendorExposureBrief } from "./vendorExposure.js";
|
|
1026
1045
|
export { analyzeInfrastructure } from "./infrastructure.js";
|
|
1027
1046
|
export { buildHistoryDiff, buildHistoryDiffFromSnapshots, snapshotFromAnalysis } from "./historyDiff.js";
|
|
1028
1047
|
export { assertPublicRequestTarget, isLocalHostname, isPrivateAddress, } from "./network-validation.js";
|
package/dist/types.d.ts
CHANGED
|
@@ -190,6 +190,74 @@ export interface ExposureBrief {
|
|
|
190
190
|
collectionBoundary: string;
|
|
191
191
|
limitation: AssessmentLimitation | null;
|
|
192
192
|
}
|
|
193
|
+
export type VendorExposureRisk = "low" | "medium" | "high";
|
|
194
|
+
export interface VendorExposureProvider {
|
|
195
|
+
name: string;
|
|
196
|
+
domain: string;
|
|
197
|
+
category: ThirdPartyProvider["category"];
|
|
198
|
+
risk: ThirdPartyProvider["risk"];
|
|
199
|
+
evidence: string;
|
|
200
|
+
reviewPriority: "routine" | "review" | "urgent";
|
|
201
|
+
dataFlow: "content_delivery" | "telemetry" | "user_interaction" | "payment" | "security" | "ai" | "unknown";
|
|
202
|
+
action: string;
|
|
203
|
+
}
|
|
204
|
+
export interface VendorExposureBrief {
|
|
205
|
+
generatedAt: string;
|
|
206
|
+
risk: VendorExposureRisk;
|
|
207
|
+
summary: string;
|
|
208
|
+
counts: {
|
|
209
|
+
totalProviders: number;
|
|
210
|
+
highRiskProviders: number;
|
|
211
|
+
mediumRiskProviders: number;
|
|
212
|
+
sessionReplayProviders: number;
|
|
213
|
+
analyticsProviders: number;
|
|
214
|
+
aiProviders: number;
|
|
215
|
+
paymentProviders: number;
|
|
216
|
+
supportProviders: number;
|
|
217
|
+
missingSriScripts: number;
|
|
218
|
+
};
|
|
219
|
+
providers: VendorExposureProvider[];
|
|
220
|
+
highPriorityProviders: VendorExposureProvider[];
|
|
221
|
+
issues: string[];
|
|
222
|
+
strengths: string[];
|
|
223
|
+
nextActions: string[];
|
|
224
|
+
collectionBoundary: string;
|
|
225
|
+
limitation: AssessmentLimitation | null;
|
|
226
|
+
}
|
|
227
|
+
export type ActionPlanTheme = "browser_hardening" | "transport" | "domain_trust" | "public_exposure" | "vendor_risk" | "identity" | "availability" | "monitoring";
|
|
228
|
+
export interface ActionPlanItem {
|
|
229
|
+
id: string;
|
|
230
|
+
priority: number;
|
|
231
|
+
title: string;
|
|
232
|
+
whyNow: string;
|
|
233
|
+
action: string;
|
|
234
|
+
verify: string;
|
|
235
|
+
owner: RemediationOwner;
|
|
236
|
+
effort: RemediationEffort;
|
|
237
|
+
impact: RemediationImpact;
|
|
238
|
+
scoreImpact: number | null;
|
|
239
|
+
confidence: IssueConfidence;
|
|
240
|
+
theme: ActionPlanTheme;
|
|
241
|
+
evidence: ScanEvidenceReference[];
|
|
242
|
+
relatedFindings: string[];
|
|
243
|
+
source: "remediation" | "score_driver" | "exposure_brief" | "vendor_exposure";
|
|
244
|
+
}
|
|
245
|
+
export interface ActionPlan {
|
|
246
|
+
generatedAt: string;
|
|
247
|
+
summary: string;
|
|
248
|
+
posture: {
|
|
249
|
+
score: number;
|
|
250
|
+
grade: string;
|
|
251
|
+
limited: boolean;
|
|
252
|
+
mainRisk: string | null;
|
|
253
|
+
};
|
|
254
|
+
totalActions: number;
|
|
255
|
+
highImpactActions: number;
|
|
256
|
+
quickWins: number;
|
|
257
|
+
items: ActionPlanItem[];
|
|
258
|
+
nextReview: string;
|
|
259
|
+
limitation: AssessmentLimitation | null;
|
|
260
|
+
}
|
|
193
261
|
export interface CrawlPageResult {
|
|
194
262
|
label: string;
|
|
195
263
|
path: string;
|
|
@@ -756,6 +824,8 @@ export interface AnalysisResult {
|
|
|
756
824
|
remediationPlan?: RemediationPlan;
|
|
757
825
|
evidenceSummary?: PostureEvidenceSummary;
|
|
758
826
|
exposureBrief?: ExposureBrief;
|
|
827
|
+
vendorExposure?: VendorExposureBrief;
|
|
828
|
+
actionPlan?: ActionPlan;
|
|
759
829
|
crawl: CrawlSummary;
|
|
760
830
|
securityTxt: SecurityTxtInfo;
|
|
761
831
|
domainSecurity: DomainSecurityInfo;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
const normalizeArray = (value) => (Array.isArray(value) ? value : []);
|
|
2
|
+
function dataFlowForCategory(category) {
|
|
3
|
+
if (category === "analytics" || category === "ads" || category === "session_replay") {
|
|
4
|
+
return "telemetry";
|
|
5
|
+
}
|
|
6
|
+
if (category === "support" || category === "social" || category === "consent") {
|
|
7
|
+
return "user_interaction";
|
|
8
|
+
}
|
|
9
|
+
if (category === "payments") {
|
|
10
|
+
return "payment";
|
|
11
|
+
}
|
|
12
|
+
if (category === "security") {
|
|
13
|
+
return "security";
|
|
14
|
+
}
|
|
15
|
+
if (category === "ai") {
|
|
16
|
+
return "ai";
|
|
17
|
+
}
|
|
18
|
+
if (category === "cdn") {
|
|
19
|
+
return "content_delivery";
|
|
20
|
+
}
|
|
21
|
+
return "unknown";
|
|
22
|
+
}
|
|
23
|
+
function reviewPriority(provider) {
|
|
24
|
+
if (provider.risk === "high" || provider.category === "session_replay" || provider.category === "payments") {
|
|
25
|
+
return "urgent";
|
|
26
|
+
}
|
|
27
|
+
if (provider.risk === "medium" || provider.category === "ai" || provider.category === "ads") {
|
|
28
|
+
return "review";
|
|
29
|
+
}
|
|
30
|
+
return "routine";
|
|
31
|
+
}
|
|
32
|
+
function actionForProvider(provider) {
|
|
33
|
+
if (provider.category === "session_replay") {
|
|
34
|
+
return "Confirm session replay masking, consent coverage, retention, and vendor ownership.";
|
|
35
|
+
}
|
|
36
|
+
if (provider.category === "payments") {
|
|
37
|
+
return "Confirm payment provider ownership, PCI scope, and expected public loading paths.";
|
|
38
|
+
}
|
|
39
|
+
if (provider.category === "ai") {
|
|
40
|
+
return "Confirm AI vendor disclosure, data-handling boundaries, and escalation ownership.";
|
|
41
|
+
}
|
|
42
|
+
if (provider.risk === "high") {
|
|
43
|
+
return "Confirm the provider is intentional, documented, and covered by security and privacy review.";
|
|
44
|
+
}
|
|
45
|
+
if (provider.risk === "medium") {
|
|
46
|
+
return "Review whether the provider is still needed and document the data-flow owner.";
|
|
47
|
+
}
|
|
48
|
+
return "Keep the provider in the vendor inventory and monitor for drift.";
|
|
49
|
+
}
|
|
50
|
+
function rankProvider(provider) {
|
|
51
|
+
const priorityWeight = { urgent: 0, review: 1, routine: 2 }[provider.reviewPriority];
|
|
52
|
+
const riskWeight = { high: 0, medium: 1, low: 2 }[provider.risk];
|
|
53
|
+
return priorityWeight * 10 + riskWeight;
|
|
54
|
+
}
|
|
55
|
+
function summarizeRisk(risk, counts) {
|
|
56
|
+
if (counts.totalProviders === 0) {
|
|
57
|
+
return "No obvious third-party script or stylesheet providers were observed on the fetched page.";
|
|
58
|
+
}
|
|
59
|
+
if (risk === "high") {
|
|
60
|
+
return "The fetched page exposes high-priority third-party dependencies that deserve explicit ownership and review.";
|
|
61
|
+
}
|
|
62
|
+
if (risk === "medium") {
|
|
63
|
+
return "The fetched page has a visible vendor footprint with review-worthy data-flow or integrity considerations.";
|
|
64
|
+
}
|
|
65
|
+
return "The fetched page uses third-party providers, but the visible footprint is mostly lower-risk delivery or operational tooling.";
|
|
66
|
+
}
|
|
67
|
+
function deriveRisk(counts, issues) {
|
|
68
|
+
if (counts.highRiskProviders > 0 ||
|
|
69
|
+
counts.sessionReplayProviders > 0 ||
|
|
70
|
+
counts.missingSriScripts >= 3 ||
|
|
71
|
+
issues.some((issue) => /session replay|high-trust|high-observability/i.test(issue))) {
|
|
72
|
+
return "high";
|
|
73
|
+
}
|
|
74
|
+
if (counts.mediumRiskProviders > 0 || counts.aiProviders > 0 || counts.paymentProviders > 0 || counts.missingSriScripts > 0 || issues.length > 0) {
|
|
75
|
+
return "medium";
|
|
76
|
+
}
|
|
77
|
+
return "low";
|
|
78
|
+
}
|
|
79
|
+
function pushUnique(values, value) {
|
|
80
|
+
if (!value) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const trimmed = value.trim();
|
|
84
|
+
if (!trimmed || values.includes(trimmed)) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
values.push(trimmed);
|
|
88
|
+
}
|
|
89
|
+
export function buildVendorExposureBrief(analysis) {
|
|
90
|
+
const sourceProviders = normalizeArray(analysis.thirdPartyTrust?.providers);
|
|
91
|
+
const providers = sourceProviders
|
|
92
|
+
.map((provider) => ({
|
|
93
|
+
name: provider.name,
|
|
94
|
+
domain: provider.domain,
|
|
95
|
+
category: provider.category,
|
|
96
|
+
risk: provider.risk,
|
|
97
|
+
evidence: provider.evidence,
|
|
98
|
+
reviewPriority: reviewPriority(provider),
|
|
99
|
+
dataFlow: dataFlowForCategory(provider.category),
|
|
100
|
+
action: actionForProvider(provider),
|
|
101
|
+
}))
|
|
102
|
+
.sort((left, right) => {
|
|
103
|
+
const rankDelta = rankProvider(left) - rankProvider(right);
|
|
104
|
+
if (rankDelta !== 0) {
|
|
105
|
+
return rankDelta;
|
|
106
|
+
}
|
|
107
|
+
return left.name.localeCompare(right.name);
|
|
108
|
+
});
|
|
109
|
+
const missingSriScripts = normalizeArray(analysis.htmlSecurity?.missingSriScriptUrls).length;
|
|
110
|
+
const issues = normalizeArray(analysis.thirdPartyTrust?.issues);
|
|
111
|
+
const strengths = normalizeArray(analysis.thirdPartyTrust?.strengths);
|
|
112
|
+
const counts = {
|
|
113
|
+
totalProviders: analysis.thirdPartyTrust?.totalProviders ?? providers.length,
|
|
114
|
+
highRiskProviders: analysis.thirdPartyTrust?.highRiskProviders ?? providers.filter((provider) => provider.risk === "high").length,
|
|
115
|
+
mediumRiskProviders: providers.filter((provider) => provider.risk === "medium").length,
|
|
116
|
+
sessionReplayProviders: providers.filter((provider) => provider.category === "session_replay").length,
|
|
117
|
+
analyticsProviders: providers.filter((provider) => provider.category === "analytics" || provider.category === "ads").length,
|
|
118
|
+
aiProviders: providers.filter((provider) => provider.category === "ai").length + normalizeArray(analysis.aiSurface?.vendors).length,
|
|
119
|
+
paymentProviders: providers.filter((provider) => provider.category === "payments").length,
|
|
120
|
+
supportProviders: providers.filter((provider) => provider.category === "support").length,
|
|
121
|
+
missingSriScripts,
|
|
122
|
+
};
|
|
123
|
+
const risk = deriveRisk(counts, issues);
|
|
124
|
+
const highPriorityProviders = providers.filter((provider) => provider.reviewPriority !== "routine").slice(0, 10);
|
|
125
|
+
const nextActions = [];
|
|
126
|
+
for (const provider of highPriorityProviders) {
|
|
127
|
+
pushUnique(nextActions, provider.action);
|
|
128
|
+
}
|
|
129
|
+
if (missingSriScripts > 0) {
|
|
130
|
+
pushUnique(nextActions, "Add Subresource Integrity for third-party scripts that can be pinned safely, or document why they cannot be pinned.");
|
|
131
|
+
}
|
|
132
|
+
if (counts.totalProviders > 0) {
|
|
133
|
+
pushUnique(nextActions, "Keep a lightweight vendor inventory covering owner, purpose, data handled, and removal criteria.");
|
|
134
|
+
}
|
|
135
|
+
if (nextActions.length === 0) {
|
|
136
|
+
pushUnique(nextActions, "Keep monitoring vendor drift after frontend, analytics, support, payment, or AI changes.");
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
generatedAt: new Date().toISOString(),
|
|
140
|
+
risk,
|
|
141
|
+
summary: summarizeRisk(risk, counts),
|
|
142
|
+
counts,
|
|
143
|
+
providers,
|
|
144
|
+
highPriorityProviders,
|
|
145
|
+
issues,
|
|
146
|
+
strengths,
|
|
147
|
+
nextActions: nextActions.slice(0, 6),
|
|
148
|
+
collectionBoundary: "Passive public page evidence only. Vendor signals are inferred from fetched HTML, scripts, stylesheets, and visible AI/provider markers.",
|
|
149
|
+
limitation: analysis.assessmentLimitation?.limited ? analysis.assessmentLimitation : null,
|
|
150
|
+
};
|
|
151
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "securl",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Passive external security posture scanner for public URLs and web services.",
|
|
6
6
|
"author": {
|
|
@@ -69,6 +69,14 @@
|
|
|
69
69
|
"./exposure-brief": {
|
|
70
70
|
"types": "./dist/exposureBrief.d.ts",
|
|
71
71
|
"default": "./dist/exposureBrief.js"
|
|
72
|
+
},
|
|
73
|
+
"./vendor-exposure": {
|
|
74
|
+
"types": "./dist/vendorExposure.d.ts",
|
|
75
|
+
"default": "./dist/vendorExposure.js"
|
|
76
|
+
},
|
|
77
|
+
"./action-plan": {
|
|
78
|
+
"types": "./dist/actionPlan.d.ts",
|
|
79
|
+
"default": "./dist/actionPlan.js"
|
|
72
80
|
}
|
|
73
81
|
},
|
|
74
82
|
"files": [
|