securl 1.5.1 → 1.7.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 CHANGED
@@ -6,6 +6,12 @@ The format is based on Keep a Changelog and this package follows Semantic Versio
6
6
 
7
7
  ## [Unreleased]
8
8
 
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.
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.
13
+ - Added `exposureBrief` to analysis results and the `securl/exposure-brief` package export for SDK consumers.
14
+
9
15
  ## [1.5.1] - 2026-06-15
10
16
 
11
17
  ### Changed
@@ -0,0 +1,2 @@
1
+ import type { AnalysisResult, ExposureBrief } from "./types.js";
2
+ export declare function buildExposureBrief(analysis: AnalysisResult): ExposureBrief;
@@ -0,0 +1,278 @@
1
+ const SEVERITY_ORDER = {
2
+ critical: 0,
3
+ warning: 1,
4
+ watch: 2,
5
+ info: 3,
6
+ };
7
+ const normalizeArray = (value) => (Array.isArray(value) ? value : []);
8
+ function cleanEvidence(value) {
9
+ return normalizeArray(value)
10
+ .filter((item) => typeof item === "string" && item.trim().length > 0)
11
+ .map((item) => item.trim())
12
+ .slice(0, 4);
13
+ }
14
+ function uniqueByTitle(items) {
15
+ const seen = new Set();
16
+ const unique = [];
17
+ for (const item of items) {
18
+ const key = `${item.category}:${item.title.toLowerCase()}:${item.detail.toLowerCase()}`;
19
+ if (seen.has(key)) {
20
+ continue;
21
+ }
22
+ seen.add(key);
23
+ unique.push(item);
24
+ }
25
+ return unique;
26
+ }
27
+ function sortItems(items) {
28
+ return [...items].sort((left, right) => {
29
+ const severityDelta = SEVERITY_ORDER[left.severity] - SEVERITY_ORDER[right.severity];
30
+ if (severityDelta !== 0) {
31
+ return severityDelta;
32
+ }
33
+ return left.title.localeCompare(right.title);
34
+ });
35
+ }
36
+ function item({ title, detail, severity, category, confidence = "medium", source, evidence = [], action, }) {
37
+ return {
38
+ title,
39
+ detail,
40
+ severity,
41
+ category,
42
+ confidence,
43
+ source,
44
+ evidence: cleanEvidence(evidence),
45
+ action,
46
+ };
47
+ }
48
+ function mapCompromiseCategory(indicator) {
49
+ if (indicator.category === "credential_collection" || indicator.category === "script_anomaly") {
50
+ return "abuse_signal";
51
+ }
52
+ if (indicator.category === "supply_chain") {
53
+ return "third_party";
54
+ }
55
+ if (indicator.category === "infrastructure") {
56
+ return "infrastructure";
57
+ }
58
+ if (indicator.category === "exposure") {
59
+ return "sensitive_exposure";
60
+ }
61
+ return "abuse_signal";
62
+ }
63
+ function mapCompromiseSource(indicator) {
64
+ if (indicator.source === "asset") {
65
+ return "html";
66
+ }
67
+ if (indicator.source === "reputation") {
68
+ return "public_record";
69
+ }
70
+ return indicator.source;
71
+ }
72
+ function buildSummary(level, counts, topRisks) {
73
+ if (level === "unknown") {
74
+ return "Exposure could not be summarized confidently because the passive assessment was limited.";
75
+ }
76
+ if (level === "critical") {
77
+ return "Critical public exposure or abuse indicators need immediate review before treating this target as healthy.";
78
+ }
79
+ if (level === "high") {
80
+ return "Publicly observable exposure is elevated, with multiple items that deserve near-term review.";
81
+ }
82
+ if (level === "medium") {
83
+ return "The target has review-worthy public exposure, but no critical public signal was observed.";
84
+ }
85
+ if (counts.publicEntryPoints > 0) {
86
+ return "Public entry points were observed, with no major exposure signal in the passive checks.";
87
+ }
88
+ if (topRisks.length === 0) {
89
+ return "No notable public exposure signal was observed in the passive checks.";
90
+ }
91
+ return "The passive checks found low-risk public exposure context.";
92
+ }
93
+ function deriveLevel(items, counts, limited) {
94
+ if (items.some((risk) => risk.severity === "critical")) {
95
+ return "critical";
96
+ }
97
+ const warningCount = items.filter((risk) => risk.severity === "warning").length;
98
+ if (warningCount >= 3 || counts.sensitiveExposures > 0 || counts.highRiskThirdParties > 0) {
99
+ return "high";
100
+ }
101
+ if (warningCount > 0 || items.some((risk) => risk.severity === "watch")) {
102
+ return "medium";
103
+ }
104
+ if (limited && items.length === 0) {
105
+ return "unknown";
106
+ }
107
+ return "low";
108
+ }
109
+ function pushUnique(actions, value) {
110
+ if (!value) {
111
+ return;
112
+ }
113
+ const trimmed = value.trim();
114
+ if (!trimmed || actions.includes(trimmed)) {
115
+ return;
116
+ }
117
+ actions.push(trimmed);
118
+ }
119
+ export function buildExposureBrief(analysis) {
120
+ const items = [];
121
+ for (const indicator of normalizeArray(analysis.compromiseSignals?.indicators)) {
122
+ items.push(item({
123
+ title: indicator.title,
124
+ detail: indicator.detail,
125
+ severity: indicator.severity,
126
+ category: mapCompromiseCategory(indicator),
127
+ confidence: indicator.confidence,
128
+ source: mapCompromiseSource(indicator),
129
+ evidence: indicator.evidence,
130
+ action: indicator.action,
131
+ }));
132
+ }
133
+ for (const probe of normalizeArray(analysis.exposure?.probes)) {
134
+ if (probe.finding !== "exposed" && probe.finding !== "interesting") {
135
+ continue;
136
+ }
137
+ items.push(item({
138
+ title: probe.finding === "exposed" ? `${probe.label} appears exposed` : `${probe.label} needs review`,
139
+ detail: probe.detail,
140
+ severity: probe.finding === "exposed" ? "warning" : "watch",
141
+ category: "sensitive_exposure",
142
+ confidence: "medium",
143
+ source: "exposure",
144
+ evidence: [`${probe.statusCode} ${probe.finalUrl}`],
145
+ action: "Review whether this path should be publicly reachable and restrict it if it exposes operational detail.",
146
+ }));
147
+ }
148
+ for (const probe of normalizeArray(analysis.apiSurface?.probes)) {
149
+ if (probe.classification !== "public" && probe.classification !== "interesting") {
150
+ continue;
151
+ }
152
+ items.push(item({
153
+ title: probe.classification === "public" ? `${probe.label} is publicly reachable` : `${probe.label} looks like an API surface`,
154
+ detail: probe.detail,
155
+ severity: probe.classification === "public" ? "watch" : "info",
156
+ category: "entry_point",
157
+ confidence: "medium",
158
+ source: "api",
159
+ evidence: [`${probe.statusCode} ${probe.finalUrl}`, probe.contentType ? `Content-Type: ${probe.contentType}` : ""],
160
+ action: "Confirm the endpoint is intentional, authenticated where needed, and covered by monitoring.",
161
+ }));
162
+ }
163
+ for (const host of normalizeArray(analysis.ctDiscovery?.prioritizedHosts).slice(0, 8)) {
164
+ const priority = "priority" in host ? String(host.priority) : "review";
165
+ items.push(item({
166
+ title: `${host.host} is visible in certificate transparency`,
167
+ detail: "Certificate transparency logs expose this related host as part of the public attack surface.",
168
+ severity: priority === "high" ? "watch" : "info",
169
+ category: "entry_point",
170
+ confidence: "high",
171
+ source: "ct",
172
+ evidence: [analysis.ctDiscovery?.sourceUrl || "", priority],
173
+ action: "Confirm this hostname is still owned, intentionally exposed, and included in monitoring.",
174
+ }));
175
+ }
176
+ for (const issue of normalizeArray(analysis.domainSecurity?.issues)) {
177
+ items.push(item({
178
+ title: "Domain trust gap",
179
+ detail: issue,
180
+ severity: "watch",
181
+ category: "trust_gap",
182
+ confidence: "high",
183
+ source: "dns",
184
+ evidence: [analysis.host],
185
+ action: "Review DNS, mail authentication, and domain policy records for the target domain.",
186
+ }));
187
+ }
188
+ for (const issue of normalizeArray(analysis.securityTxt?.issues)) {
189
+ items.push(item({
190
+ title: "Security contact signal gap",
191
+ detail: issue,
192
+ severity: "info",
193
+ category: "trust_gap",
194
+ confidence: "medium",
195
+ source: "public_record",
196
+ evidence: [analysis.securityTxt?.url || analysis.finalUrl],
197
+ action: "Publish or correct security.txt so researchers and vendors know where to report issues.",
198
+ }));
199
+ }
200
+ for (const issue of normalizeArray(analysis.publicSignals?.issues)) {
201
+ items.push(item({
202
+ title: "Public trust signal gap",
203
+ detail: issue,
204
+ severity: "watch",
205
+ category: "trust_gap",
206
+ confidence: "medium",
207
+ source: "public_record",
208
+ evidence: [analysis.publicSignals?.hstsPreload?.sourceUrl || analysis.host],
209
+ action: "Review public trust signals such as HSTS preload eligibility and domain policy posture.",
210
+ }));
211
+ }
212
+ for (const provider of normalizeArray(analysis.thirdPartyTrust?.providers)) {
213
+ if (provider.risk !== "high" && provider.risk !== "medium") {
214
+ continue;
215
+ }
216
+ items.push(item({
217
+ title: `${provider.name} third-party dependency`,
218
+ detail: `${provider.domain} was observed as a ${provider.category} provider with ${provider.risk} passive trust risk.`,
219
+ severity: provider.risk === "high" ? "warning" : "watch",
220
+ category: "third_party",
221
+ confidence: "medium",
222
+ source: "third_party",
223
+ evidence: [provider.evidence],
224
+ action: "Confirm the provider is intentional, documented, and covered by privacy/security review.",
225
+ }));
226
+ }
227
+ for (const vendor of normalizeArray(analysis.aiSurface?.vendors)) {
228
+ items.push(item({
229
+ title: `${vendor.name} AI surface signal`,
230
+ detail: vendor.evidence,
231
+ severity: "info",
232
+ category: "ai",
233
+ confidence: vendor.confidence,
234
+ source: "ai",
235
+ evidence: [vendor.category],
236
+ action: "Confirm AI-assisted surfaces have suitable disclosure, data-handling, and support escalation controls.",
237
+ }));
238
+ }
239
+ const uniqueItems = sortItems(uniqueByTitle(items));
240
+ const publicEntryPoints = uniqueItems.filter((risk) => risk.category === "entry_point").slice(0, 8);
241
+ const trustGaps = uniqueItems.filter((risk) => risk.category === "trust_gap").slice(0, 8);
242
+ const topRisks = uniqueItems.slice(0, 8);
243
+ const counts = {
244
+ publicEntryPoints: publicEntryPoints.length,
245
+ sensitiveExposures: uniqueItems.filter((risk) => risk.category === "sensitive_exposure").length,
246
+ trustGaps: uniqueItems.filter((risk) => risk.category === "trust_gap").length,
247
+ abuseIndicators: uniqueItems.filter((risk) => risk.category === "abuse_signal").length,
248
+ thirdPartyProviders: analysis.thirdPartyTrust?.totalProviders ?? normalizeArray(analysis.thirdPartyTrust?.providers).length,
249
+ highRiskThirdParties: analysis.thirdPartyTrust?.highRiskProviders ?? 0,
250
+ aiVendors: normalizeArray(analysis.aiSurface?.vendors).length,
251
+ ctPriorityHosts: normalizeArray(analysis.ctDiscovery?.prioritizedHosts).length,
252
+ };
253
+ const exposureLevel = deriveLevel(uniqueItems, counts, Boolean(analysis.assessmentLimitation?.limited));
254
+ const nextActions = [];
255
+ for (const risk of topRisks) {
256
+ pushUnique(nextActions, risk.action);
257
+ }
258
+ for (const action of normalizeArray(analysis.remediationPlan?.items).map((planItem) => planItem.action)) {
259
+ pushUnique(nextActions, action);
260
+ }
261
+ if (nextActions.length === 0) {
262
+ nextActions.push("Keep the target in monitoring and rescan after meaningful deployment, DNS, or vendor changes.");
263
+ }
264
+ return {
265
+ generatedAt: new Date().toISOString(),
266
+ exposureLevel,
267
+ summary: buildSummary(exposureLevel, counts, topRisks),
268
+ counts,
269
+ topRisks,
270
+ publicEntryPoints,
271
+ trustGaps,
272
+ nextActions: nextActions.slice(0, 6),
273
+ collectionBoundary: analysis.compromiseSignals?.collectionBoundary
274
+ || analysis.passiveIntelligence?.collectionBoundary
275
+ || "Passive public evidence only. No credentials, exploitation, intrusive probing, or authenticated access was used.",
276
+ limitation: analysis.assessmentLimitation?.limited ? analysis.assessmentLimitation : null,
277
+ };
278
+ }
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 { buildExposureBrief } from "./exposureBrief.js";
15
+ export { buildVendorExposureBrief } from "./vendorExposure.js";
14
16
  export { analyzeInfrastructure } from "./infrastructure.js";
15
17
  export { buildHistoryDiff, buildHistoryDiffFromSnapshots, snapshotFromAnalysis } from "./historyDiff.js";
16
18
  export { assertPublicRequestTarget, isLocalHostname, isPrivateAddress, } from "./network-validation.js";
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { URL } from "node:url";
2
2
  import { scanTls } from "./certificate.js";
3
3
  import { buildCompromiseSignals, emptyCompromiseSignals } from "./compromiseSignals.js";
4
+ import { buildExposureBrief } from "./exposureBrief.js";
5
+ import { buildVendorExposureBrief } from "./vendorExposure.js";
4
6
  import { parseSetCookie } from "./cookie-analysis.js";
5
7
  import { analyzeCookieHeaders } from "./cookieAnalysis.js";
6
8
  import { fetchCtDiscovery } from "./ctDiscovery.js";
@@ -501,11 +503,16 @@ async function buildLimitedResult(input, normalizedInput, failure, scanTiming) {
501
503
  };
502
504
  const evidenceResult = attachIssueEvidence(limitedResult);
503
505
  const remediationPlan = buildPostureRemediationPlan(evidenceResult);
504
- return {
506
+ const resultWithRemediation = {
505
507
  ...evidenceResult,
506
508
  remediationPlan,
507
509
  evidenceSummary: buildPostureEvidenceSummary({ ...evidenceResult, remediationPlan }),
508
510
  };
511
+ return {
512
+ ...resultWithRemediation,
513
+ exposureBrief: buildExposureBrief(resultWithRemediation),
514
+ vendorExposure: buildVendorExposureBrief(resultWithRemediation),
515
+ };
509
516
  }
510
517
  async function enrichCoreResult(result, profile) {
511
518
  const finalUrl = new URL(result.finalUrl);
@@ -931,11 +938,16 @@ function buildTimedOutEnrichmentResult(result, pageAnalysisEnabled, timeoutMs, c
931
938
  };
932
939
  const evidenceResult = attachIssueEvidence(timedOutResultWithSummary);
933
940
  const remediationPlan = buildPostureRemediationPlan(evidenceResult);
934
- return {
941
+ const resultWithRemediation = {
935
942
  ...evidenceResult,
936
943
  remediationPlan,
937
944
  evidenceSummary: buildPostureEvidenceSummary({ ...evidenceResult, remediationPlan }),
938
945
  };
946
+ return {
947
+ ...resultWithRemediation,
948
+ exposureBrief: buildExposureBrief(resultWithRemediation),
949
+ vendorExposure: buildVendorExposureBrief(resultWithRemediation),
950
+ };
939
951
  }
940
952
  export async function analyzeUrl(input, options = {}) {
941
953
  const scanStartedAt = Date.now();
@@ -1000,15 +1012,22 @@ export async function analyzeUrl(input, options = {}) {
1000
1012
  };
1001
1013
  const resultWithEvidence = attachIssueEvidence(resultWithSummary);
1002
1014
  const remediationPlan = buildPostureRemediationPlan(resultWithEvidence);
1003
- return {
1015
+ const resultWithRemediation = {
1004
1016
  ...resultWithEvidence,
1005
1017
  remediationPlan,
1006
1018
  evidenceSummary: buildPostureEvidenceSummary({ ...resultWithEvidence, remediationPlan }),
1007
1019
  };
1020
+ return {
1021
+ ...resultWithRemediation,
1022
+ exposureBrief: buildExposureBrief(resultWithRemediation),
1023
+ vendorExposure: buildVendorExposureBrief(resultWithRemediation),
1024
+ };
1008
1025
  }
1009
1026
  export const analyzeTarget = analyzeUrl;
1010
1027
  export { formatErrorMessage };
1011
1028
  export { buildCompromiseSignals, emptyCompromiseSignals } from "./compromiseSignals.js";
1029
+ export { buildExposureBrief } from "./exposureBrief.js";
1030
+ export { buildVendorExposureBrief } from "./vendorExposure.js";
1012
1031
  export { analyzeInfrastructure } from "./infrastructure.js";
1013
1032
  export { buildHistoryDiff, buildHistoryDiffFromSnapshots, snapshotFromAnalysis } from "./historyDiff.js";
1014
1033
  export { assertPublicRequestTarget, isLocalHostname, isPrivateAddress, } from "./network-validation.js";
package/dist/types.d.ts CHANGED
@@ -156,6 +156,74 @@ export interface PostureEvidenceSummary {
156
156
  findingEvidence: PostureEvidenceSummaryReference[];
157
157
  limitation: AssessmentLimitation | null;
158
158
  }
159
+ export type ExposureBriefLevel = "low" | "medium" | "high" | "critical" | "unknown";
160
+ export type ExposureBriefCategory = "entry_point" | "trust_gap" | "abuse_signal" | "sensitive_exposure" | "third_party" | "identity" | "ai" | "infrastructure";
161
+ export type ExposureBriefSource = "headers" | "tls" | "cookies" | "dns" | "html" | "public_record" | "third_party" | "ai" | "ct" | "api" | "exposure" | "derived";
162
+ export interface ExposureBriefItem {
163
+ title: string;
164
+ detail: string;
165
+ severity: "info" | "watch" | "warning" | "critical";
166
+ category: ExposureBriefCategory;
167
+ confidence: IssueConfidence;
168
+ source: ExposureBriefSource;
169
+ evidence: string[];
170
+ action: string | null;
171
+ }
172
+ export interface ExposureBrief {
173
+ generatedAt: string;
174
+ exposureLevel: ExposureBriefLevel;
175
+ summary: string;
176
+ counts: {
177
+ publicEntryPoints: number;
178
+ sensitiveExposures: number;
179
+ trustGaps: number;
180
+ abuseIndicators: number;
181
+ thirdPartyProviders: number;
182
+ highRiskThirdParties: number;
183
+ aiVendors: number;
184
+ ctPriorityHosts: number;
185
+ };
186
+ topRisks: ExposureBriefItem[];
187
+ publicEntryPoints: ExposureBriefItem[];
188
+ trustGaps: ExposureBriefItem[];
189
+ nextActions: string[];
190
+ collectionBoundary: string;
191
+ limitation: AssessmentLimitation | null;
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
+ }
159
227
  export interface CrawlPageResult {
160
228
  label: string;
161
229
  path: string;
@@ -721,6 +789,8 @@ export interface AnalysisResult {
721
789
  remediation: RemediationSnippet[];
722
790
  remediationPlan?: RemediationPlan;
723
791
  evidenceSummary?: PostureEvidenceSummary;
792
+ exposureBrief?: ExposureBrief;
793
+ vendorExposure?: VendorExposureBrief;
724
794
  crawl: CrawlSummary;
725
795
  securityTxt: SecurityTxtInfo;
726
796
  domainSecurity: DomainSecurityInfo;
@@ -0,0 +1,2 @@
1
+ import type { AnalysisResult, VendorExposureBrief } from "./types.js";
2
+ export declare function buildVendorExposureBrief(analysis: AnalysisResult): VendorExposureBrief;
@@ -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.5.1",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "description": "Passive external security posture scanner for public URLs and web services.",
6
6
  "author": {
@@ -65,6 +65,14 @@
65
65
  "./evidence-summary": {
66
66
  "types": "./dist/postureRemediation.d.ts",
67
67
  "default": "./dist/postureRemediation.js"
68
+ },
69
+ "./exposure-brief": {
70
+ "types": "./dist/exposureBrief.d.ts",
71
+ "default": "./dist/exposureBrief.js"
72
+ },
73
+ "./vendor-exposure": {
74
+ "types": "./dist/vendorExposure.d.ts",
75
+ "default": "./dist/vendorExposure.js"
68
76
  }
69
77
  },
70
78
  "files": [