securl 1.5.1 → 1.6.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,10 @@ 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 `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
+ - Added `exposureBrief` to analysis results and the `securl/exposure-brief` package export for SDK consumers.
12
+
9
13
  ## [1.5.1] - 2026-06-15
10
14
 
11
15
  ### Changed
package/dist/cli.js CHANGED
File without changes
@@ -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,7 @@ 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";
14
15
  export { analyzeInfrastructure } from "./infrastructure.js";
15
16
  export { buildHistoryDiff, buildHistoryDiffFromSnapshots, snapshotFromAnalysis } from "./historyDiff.js";
16
17
  export { assertPublicRequestTarget, isLocalHostname, isPrivateAddress, } from "./network-validation.js";
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
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";
4
5
  import { parseSetCookie } from "./cookie-analysis.js";
5
6
  import { analyzeCookieHeaders } from "./cookieAnalysis.js";
6
7
  import { fetchCtDiscovery } from "./ctDiscovery.js";
@@ -501,11 +502,15 @@ async function buildLimitedResult(input, normalizedInput, failure, scanTiming) {
501
502
  };
502
503
  const evidenceResult = attachIssueEvidence(limitedResult);
503
504
  const remediationPlan = buildPostureRemediationPlan(evidenceResult);
504
- return {
505
+ const resultWithRemediation = {
505
506
  ...evidenceResult,
506
507
  remediationPlan,
507
508
  evidenceSummary: buildPostureEvidenceSummary({ ...evidenceResult, remediationPlan }),
508
509
  };
510
+ return {
511
+ ...resultWithRemediation,
512
+ exposureBrief: buildExposureBrief(resultWithRemediation),
513
+ };
509
514
  }
510
515
  async function enrichCoreResult(result, profile) {
511
516
  const finalUrl = new URL(result.finalUrl);
@@ -931,11 +936,15 @@ function buildTimedOutEnrichmentResult(result, pageAnalysisEnabled, timeoutMs, c
931
936
  };
932
937
  const evidenceResult = attachIssueEvidence(timedOutResultWithSummary);
933
938
  const remediationPlan = buildPostureRemediationPlan(evidenceResult);
934
- return {
939
+ const resultWithRemediation = {
935
940
  ...evidenceResult,
936
941
  remediationPlan,
937
942
  evidenceSummary: buildPostureEvidenceSummary({ ...evidenceResult, remediationPlan }),
938
943
  };
944
+ return {
945
+ ...resultWithRemediation,
946
+ exposureBrief: buildExposureBrief(resultWithRemediation),
947
+ };
939
948
  }
940
949
  export async function analyzeUrl(input, options = {}) {
941
950
  const scanStartedAt = Date.now();
@@ -1000,15 +1009,20 @@ export async function analyzeUrl(input, options = {}) {
1000
1009
  };
1001
1010
  const resultWithEvidence = attachIssueEvidence(resultWithSummary);
1002
1011
  const remediationPlan = buildPostureRemediationPlan(resultWithEvidence);
1003
- return {
1012
+ const resultWithRemediation = {
1004
1013
  ...resultWithEvidence,
1005
1014
  remediationPlan,
1006
1015
  evidenceSummary: buildPostureEvidenceSummary({ ...resultWithEvidence, remediationPlan }),
1007
1016
  };
1017
+ return {
1018
+ ...resultWithRemediation,
1019
+ exposureBrief: buildExposureBrief(resultWithRemediation),
1020
+ };
1008
1021
  }
1009
1022
  export const analyzeTarget = analyzeUrl;
1010
1023
  export { formatErrorMessage };
1011
1024
  export { buildCompromiseSignals, emptyCompromiseSignals } from "./compromiseSignals.js";
1025
+ export { buildExposureBrief } from "./exposureBrief.js";
1012
1026
  export { analyzeInfrastructure } from "./infrastructure.js";
1013
1027
  export { buildHistoryDiff, buildHistoryDiffFromSnapshots, snapshotFromAnalysis } from "./historyDiff.js";
1014
1028
  export { assertPublicRequestTarget, isLocalHostname, isPrivateAddress, } from "./network-validation.js";
package/dist/types.d.ts CHANGED
@@ -156,6 +156,40 @@ 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
+ }
159
193
  export interface CrawlPageResult {
160
194
  label: string;
161
195
  path: string;
@@ -721,6 +755,7 @@ export interface AnalysisResult {
721
755
  remediation: RemediationSnippet[];
722
756
  remediationPlan?: RemediationPlan;
723
757
  evidenceSummary?: PostureEvidenceSummary;
758
+ exposureBrief?: ExposureBrief;
724
759
  crawl: CrawlSummary;
725
760
  securityTxt: SecurityTxtInfo;
726
761
  domainSecurity: DomainSecurityInfo;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securl",
3
- "version": "1.5.1",
3
+ "version": "1.6.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,10 @@
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"
68
72
  }
69
73
  },
70
74
  "files": [