securl 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/LICENSE +21 -0
  3. package/README.md +427 -0
  4. package/RELEASING.md +37 -0
  5. package/SECURITY.md +27 -0
  6. package/dist/certificate.d.ts +5 -0
  7. package/dist/certificate.js +92 -0
  8. package/dist/cli.d.ts +1 -0
  9. package/dist/cli.js +674 -0
  10. package/dist/compromiseSignals.d.ts +10 -0
  11. package/dist/compromiseSignals.js +183 -0
  12. package/dist/cookie-analysis.d.ts +2 -0
  13. package/dist/cookie-analysis.js +41 -0
  14. package/dist/cookieAnalysis.d.ts +2 -0
  15. package/dist/cookieAnalysis.js +82 -0
  16. package/dist/ctDiscovery.d.ts +19 -0
  17. package/dist/ctDiscovery.js +357 -0
  18. package/dist/domain-security.d.ts +10 -0
  19. package/dist/domain-security.js +416 -0
  20. package/dist/header-analysis.d.ts +14 -0
  21. package/dist/header-analysis.js +165 -0
  22. package/dist/historyDiff.d.ts +4 -0
  23. package/dist/historyDiff.js +117 -0
  24. package/dist/html-extraction.d.ts +12 -0
  25. package/dist/html-extraction.js +279 -0
  26. package/dist/html-page-analysis.d.ts +38 -0
  27. package/dist/html-page-analysis.js +459 -0
  28. package/dist/htmlInsights.d.ts +23 -0
  29. package/dist/htmlInsights.js +460 -0
  30. package/dist/identityProvider.d.ts +14 -0
  31. package/dist/identityProvider.js +259 -0
  32. package/dist/index.d.ts +17 -0
  33. package/dist/index.js +1008 -0
  34. package/dist/infrastructure.d.ts +9 -0
  35. package/dist/infrastructure.js +149 -0
  36. package/dist/libraryRisk.d.ts +3 -0
  37. package/dist/libraryRisk.js +164 -0
  38. package/dist/network-validation.d.ts +30 -0
  39. package/dist/network-validation.js +161 -0
  40. package/dist/network.d.ts +34 -0
  41. package/dist/network.js +139 -0
  42. package/dist/passive-intelligence.d.ts +21 -0
  43. package/dist/passive-intelligence.js +247 -0
  44. package/dist/path-discovery.d.ts +4 -0
  45. package/dist/path-discovery.js +50 -0
  46. package/dist/postureDigest.d.ts +142 -0
  47. package/dist/postureDigest.js +159 -0
  48. package/dist/postureDrift.d.ts +4 -0
  49. package/dist/postureDrift.js +118 -0
  50. package/dist/postureRemediation.d.ts +6 -0
  51. package/dist/postureRemediation.js +286 -0
  52. package/dist/redirectChain.d.ts +2 -0
  53. package/dist/redirectChain.js +39 -0
  54. package/dist/riskEvents.d.ts +3 -0
  55. package/dist/riskEvents.js +187 -0
  56. package/dist/scannerConfig.d.ts +49 -0
  57. package/dist/scannerConfig.js +79 -0
  58. package/dist/scoring.d.ts +32 -0
  59. package/dist/scoring.js +367 -0
  60. package/dist/security-txt.d.ts +4 -0
  61. package/dist/security-txt.js +123 -0
  62. package/dist/surfaceEnrichment.d.ts +44 -0
  63. package/dist/surfaceEnrichment.js +377 -0
  64. package/dist/technology-detection.d.ts +4 -0
  65. package/dist/technology-detection.js +93 -0
  66. package/dist/types.d.ts +730 -0
  67. package/dist/types.js +1 -0
  68. package/dist/utils.d.ts +7 -0
  69. package/dist/utils.js +66 -0
  70. package/dist/wafFingerprint.d.ts +5 -0
  71. package/dist/wafFingerprint.js +156 -0
  72. package/examples/risk-events.mjs +27 -0
  73. package/examples/scan-url.mjs +17 -0
  74. package/package.json +102 -0
@@ -0,0 +1,183 @@
1
+ import { unique } from "./utils.js";
2
+ const COLLECTION_BOUNDARY = "Public indicators only: visible HTML, public script and form destinations, public CT/DNS-derived observations, exposed public paths, and advisory metadata from detected client libraries. Reputation lookups are opt-in and are not run unless a provider is configured.";
3
+ const envValue = (key) => {
4
+ if (typeof process === "undefined" || !process.env) {
5
+ return undefined;
6
+ }
7
+ return process.env[key];
8
+ };
9
+ const configuredReputationChecks = () => [
10
+ {
11
+ provider: "google_safe_browsing",
12
+ status: envValue("GOOGLE_SAFE_BROWSING_API_KEY") ? "not_checked" : "not_configured",
13
+ summary: envValue("GOOGLE_SAFE_BROWSING_API_KEY")
14
+ ? "Google Safe Browsing key is configured, but live reputation lookup is not enabled in this passive engine build."
15
+ : "Google Safe Browsing/Web Risk lookup is not configured.",
16
+ },
17
+ {
18
+ provider: "urlhaus",
19
+ status: envValue("URLHAUS_API_KEY") ? "not_checked" : "not_configured",
20
+ summary: envValue("URLHAUS_API_KEY")
21
+ ? "URLhaus key is configured, but live reputation lookup is not enabled in this passive engine build."
22
+ : "URLhaus lookup is not configured.",
23
+ },
24
+ {
25
+ provider: "virustotal",
26
+ status: envValue("VIRUSTOTAL_API_KEY") ? "not_checked" : "not_configured",
27
+ summary: envValue("VIRUSTOTAL_API_KEY")
28
+ ? "VirusTotal key is configured, but live reputation lookup is not enabled in this passive engine build."
29
+ : "VirusTotal lookup is not configured.",
30
+ },
31
+ ];
32
+ const addIndicator = (indicators, indicator) => {
33
+ const key = `${indicator.category}:${indicator.title}:${indicator.evidence.join("|")}`;
34
+ if (!indicators.some((existing) => `${existing.category}:${existing.title}:${existing.evidence.join("|")}` === key)) {
35
+ indicators.push(indicator);
36
+ }
37
+ };
38
+ const severityRank = {
39
+ info: 0,
40
+ watch: 1,
41
+ warning: 2,
42
+ critical: 3,
43
+ };
44
+ const indicatorSort = (a, b) => severityRank[b.severity] - severityRank[a.severity] || a.title.localeCompare(b.title);
45
+ const toSummary = (indicators) => {
46
+ if (!indicators.length) {
47
+ return "No public indicators of compromise or abuse were inferred from the passive evidence collected.";
48
+ }
49
+ const critical = indicators.filter((item) => item.severity === "critical").length;
50
+ const warning = indicators.filter((item) => item.severity === "warning").length;
51
+ if (critical) {
52
+ return `${critical} critical public abuse indicator${critical === 1 ? "" : "s"} should be reviewed immediately.`;
53
+ }
54
+ if (warning) {
55
+ return `${warning} suspicious public indicator${warning === 1 ? "" : "s"} should be reviewed before treating the target as clean.`;
56
+ }
57
+ return `${indicators.length} low-confidence public signal${indicators.length === 1 ? "" : "s"} may be useful for triage.`;
58
+ };
59
+ export const emptyCompromiseSignals = (summary = "Compromise and abuse indicators were not assessed.") => ({
60
+ posture: "not_assessed",
61
+ summary,
62
+ indicators: [],
63
+ reputationChecks: configuredReputationChecks(),
64
+ issues: [],
65
+ strengths: [],
66
+ collectionBoundary: COLLECTION_BOUNDARY,
67
+ });
68
+ export function buildCompromiseSignals({ finalUrl, htmlSecurity, ctDiscovery, exposure, }) {
69
+ const indicators = [];
70
+ for (const form of htmlSecurity.forms) {
71
+ if (form.hasPasswordField && form.offOriginSubmission) {
72
+ addIndicator(indicators, {
73
+ category: "credential_collection",
74
+ severity: "critical",
75
+ title: "Password form posts off-origin",
76
+ detail: "A visible password form appears to submit to a different origin. This can be legitimate for hosted identity flows, but it is also a high-value compromise/phishing signal.",
77
+ confidence: "medium",
78
+ source: "html",
79
+ evidence: [form.resolvedAction],
80
+ action: "Confirm the destination is an expected identity provider or owned service before trusting the page.",
81
+ });
82
+ }
83
+ else if (form.offOriginSubmission && form.method !== "GET") {
84
+ addIndicator(indicators, {
85
+ category: "credential_collection",
86
+ severity: "watch",
87
+ title: "Form posts off-origin",
88
+ detail: "A visible form submits data to a different origin. Review whether this is expected for payment, CRM, newsletter, or identity workflows.",
89
+ confidence: "medium",
90
+ source: "html",
91
+ evidence: [form.resolvedAction],
92
+ action: "Validate the form destination and data handling path.",
93
+ });
94
+ }
95
+ }
96
+ for (const signal of htmlSecurity.suspiciousScriptSignals) {
97
+ addIndicator(indicators, {
98
+ category: "script_anomaly",
99
+ severity: signal.severity === "warning" ? "warning" : "info",
100
+ title: signal.title,
101
+ detail: signal.detail,
102
+ confidence: signal.severity === "warning" ? "medium" : "low",
103
+ source: "html",
104
+ evidence: signal.evidence,
105
+ action: "Review whether the script pattern is expected, especially if CSP is weak or the host is unfamiliar.",
106
+ });
107
+ }
108
+ for (const signal of htmlSecurity.passiveLeakSignals) {
109
+ if (signal.category === "source_map" || signal.category === "public_token") {
110
+ addIndicator(indicators, {
111
+ category: "exposure",
112
+ severity: signal.severity === "warning" ? "warning" : "watch",
113
+ title: signal.title,
114
+ detail: signal.detail,
115
+ confidence: "medium",
116
+ source: "html",
117
+ evidence: signal.evidence,
118
+ action: "Review whether the exposed artifact contains internal paths, secrets, or debugging details.",
119
+ });
120
+ }
121
+ }
122
+ for (const library of htmlSecurity.libraryRiskSignals) {
123
+ const aliases = unique(library.vulnerabilities.flatMap((item) => item.aliases)).slice(0, 4);
124
+ const hasHigh = library.vulnerabilities.some((item) => item.severity === "critical" || item.severity === "high");
125
+ addIndicator(indicators, {
126
+ category: "supply_chain",
127
+ severity: hasHigh ? "warning" : "watch",
128
+ title: "Known vulnerable client library visible",
129
+ detail: `${library.packageName} ${library.version} matched ${library.vulnerabilities.length} OSV advisory match${library.vulnerabilities.length === 1 ? "" : "es"}.`,
130
+ confidence: library.confidence,
131
+ source: "asset",
132
+ evidence: [library.sourceUrl, ...aliases].slice(0, 6),
133
+ action: "Confirm whether the visible library is actually executed and update or remove it where possible.",
134
+ });
135
+ }
136
+ for (const host of ctDiscovery.sampledHosts.filter((entry) => entry.suspectedTakeover)) {
137
+ addIndicator(indicators, {
138
+ category: "infrastructure",
139
+ severity: "critical",
140
+ title: "Possible subdomain takeover signal",
141
+ detail: host.suspectedTakeover?.evidence || "A sampled CT host matched an unclaimed service pattern.",
142
+ confidence: host.suspectedTakeover?.confidence || "medium",
143
+ source: "ct",
144
+ evidence: [host.host, ...(host.cnameTargets || [])].slice(0, 6),
145
+ action: "Verify DNS ownership and remove or claim dangling service targets.",
146
+ });
147
+ }
148
+ for (const probe of exposure.probes.filter((item) => item.finding === "exposed")) {
149
+ addIndicator(indicators, {
150
+ category: "exposure",
151
+ severity: "warning",
152
+ title: "Sensitive public path appears exposed",
153
+ detail: probe.detail,
154
+ confidence: "medium",
155
+ source: "public_record",
156
+ evidence: [new URL(probe.path, finalUrl.origin).toString(), String(probe.statusCode)],
157
+ action: "Confirm whether this path should be public and restrict access if it exposes operational detail.",
158
+ });
159
+ }
160
+ const sortedIndicators = indicators.sort(indicatorSort);
161
+ const reputationChecks = configuredReputationChecks();
162
+ const reputationFlagged = reputationChecks.some((check) => check.status === "flagged");
163
+ const posture = reputationFlagged
164
+ ? "reputation_flagged"
165
+ : sortedIndicators.some((item) => item.severity === "critical")
166
+ ? "suspicious"
167
+ : sortedIndicators.length
168
+ ? "review_recommended"
169
+ : "no_public_ioc";
170
+ return {
171
+ posture,
172
+ summary: toSummary(sortedIndicators),
173
+ indicators: sortedIndicators,
174
+ reputationChecks,
175
+ issues: sortedIndicators
176
+ .filter((item) => item.severity === "critical" || item.severity === "warning")
177
+ .map((item) => item.title),
178
+ strengths: sortedIndicators.length
179
+ ? []
180
+ : ["No passive public IOC-style indicators were inferred from the collected evidence."],
181
+ collectionBoundary: COLLECTION_BOUNDARY,
182
+ };
183
+ }
@@ -0,0 +1,2 @@
1
+ import type { CookieResult } from "./types.js";
2
+ export declare const parseSetCookie: (setCookieHeaders: string[] | undefined) => CookieResult[];
@@ -0,0 +1,41 @@
1
+ export const parseSetCookie = (setCookieHeaders) => (setCookieHeaders || []).map((cookieLine) => {
2
+ const parts = cookieLine.split(";").map((item) => item.trim());
3
+ const [nameValue, ...attributes] = parts;
4
+ const [rawName, ...rawValue] = nameValue.split("=");
5
+ const attributeMap = Object.fromEntries(attributes.map((attribute) => {
6
+ const [key, ...value] = attribute.split("=");
7
+ return [key.toLowerCase(), value.join("=") || true];
8
+ }));
9
+ const sameSiteValue = typeof attributeMap.samesite === "string"
10
+ ? attributeMap.samesite
11
+ : null;
12
+ const sameSite = sameSiteValue
13
+ ? sameSiteValue.charAt(0).toUpperCase() + sameSiteValue.slice(1).toLowerCase()
14
+ : null;
15
+ const issues = [];
16
+ if (!attributeMap.secure) {
17
+ issues.push("Missing Secure flag");
18
+ }
19
+ if (!attributeMap.httponly) {
20
+ issues.push("Missing HttpOnly flag");
21
+ }
22
+ if (!sameSite) {
23
+ issues.push("Missing SameSite attribute");
24
+ }
25
+ else if (sameSite === "None" && !attributeMap.secure) {
26
+ issues.push("SameSite=None should be paired with Secure");
27
+ }
28
+ return {
29
+ name: rawName,
30
+ valuePreview: rawValue.join("="),
31
+ secure: Boolean(attributeMap.secure),
32
+ httpOnly: Boolean(attributeMap.httponly),
33
+ sameSite,
34
+ domain: typeof attributeMap.domain === "string" ? attributeMap.domain : null,
35
+ path: typeof attributeMap.path === "string" ? attributeMap.path : null,
36
+ expires: typeof attributeMap.expires === "string" ? attributeMap.expires : null,
37
+ maxAge: typeof attributeMap["max-age"] === "string" ? attributeMap["max-age"] : null,
38
+ issues,
39
+ risk: issues.length >= 2 ? "high" : issues.length === 1 ? "medium" : "low",
40
+ };
41
+ });
@@ -0,0 +1,2 @@
1
+ import type { CookieAnalysisInfo } from "./types.js";
2
+ export declare function analyzeCookieHeaders(setCookieHeaders: string[] | undefined): CookieAnalysisInfo | null;
@@ -0,0 +1,82 @@
1
+ import { parseSetCookie } from "./cookie-analysis.js";
2
+ const normalizeSameSite = (value) => {
3
+ if (!value)
4
+ return "missing";
5
+ const normalized = value.toLowerCase();
6
+ if (normalized === "strict")
7
+ return "Strict";
8
+ if (normalized === "lax")
9
+ return "Lax";
10
+ if (normalized === "none")
11
+ return "None";
12
+ return "missing";
13
+ };
14
+ export function analyzeCookieHeaders(setCookieHeaders) {
15
+ const parsedCookies = parseSetCookie(setCookieHeaders);
16
+ if (!parsedCookies.length) {
17
+ return null;
18
+ }
19
+ const cookies = parsedCookies.map((cookie) => {
20
+ const hasHostPrefix = cookie.name.startsWith("__Host-");
21
+ const hasSecurePrefix = cookie.name.startsWith("__Secure-");
22
+ return {
23
+ name: cookie.name,
24
+ hasSecure: cookie.secure,
25
+ hasHttpOnly: cookie.httpOnly,
26
+ sameSite: normalizeSameSite(cookie.sameSite),
27
+ hasHostPrefix,
28
+ hasSecurePrefix,
29
+ isSessionCookie: !cookie.expires && !cookie.maxAge,
30
+ domain: cookie.domain,
31
+ path: cookie.path,
32
+ };
33
+ });
34
+ const publicCookies = cookies.map(({ domain: _domain, path: _path, ...cookie }) => cookie);
35
+ const cookiesWithoutSecure = publicCookies.filter((cookie) => !cookie.hasSecure).length;
36
+ const cookiesWithoutHttpOnly = publicCookies.filter((cookie) => !cookie.hasHttpOnly).length;
37
+ const cookiesWithSameSiteNone = publicCookies.filter((cookie) => cookie.sameSite === "None").length;
38
+ const cookiesWithoutSameSite = publicCookies.filter((cookie) => cookie.sameSite === "missing").length;
39
+ const issues = [];
40
+ const strengths = [];
41
+ if (cookiesWithoutSecure) {
42
+ issues.push(`${cookiesWithoutSecure} cookie${cookiesWithoutSecure === 1 ? "" : "s"} missing Secure.`);
43
+ }
44
+ if (cookiesWithoutHttpOnly) {
45
+ issues.push(`${cookiesWithoutHttpOnly} cookie${cookiesWithoutHttpOnly === 1 ? "" : "s"} missing HttpOnly.`);
46
+ }
47
+ if (cookiesWithSameSiteNone) {
48
+ const noneWithoutSecure = publicCookies.filter((cookie) => cookie.sameSite === "None" && !cookie.hasSecure).length;
49
+ issues.push(noneWithoutSecure
50
+ ? `${noneWithoutSecure} SameSite=None cookie${noneWithoutSecure === 1 ? "" : "s"} missing Secure.`
51
+ : `${cookiesWithSameSiteNone} cookie${cookiesWithSameSiteNone === 1 ? "" : "s"} allow cross-site use with SameSite=None.`);
52
+ }
53
+ if (cookiesWithoutSameSite) {
54
+ issues.push(`${cookiesWithoutSameSite} cookie${cookiesWithoutSameSite === 1 ? "" : "s"} missing SameSite.`);
55
+ }
56
+ for (const cookie of cookies) {
57
+ if (cookie.hasHostPrefix && (!cookie.hasSecure || cookie.domain || cookie.path !== "/")) {
58
+ issues.push(`__Host- cookie ${cookie.name} does not meet Secure, Path=/, and no Domain requirements.`);
59
+ }
60
+ if (cookie.hasSecurePrefix && !cookie.hasSecure) {
61
+ issues.push(`__Secure- cookie ${cookie.name} does not include Secure.`);
62
+ }
63
+ }
64
+ if (!issues.length) {
65
+ strengths.push("Cookies include the expected Secure, HttpOnly, and SameSite protections.");
66
+ }
67
+ if (publicCookies.some((cookie) => cookie.hasHostPrefix)) {
68
+ strengths.push("__Host- cookie prefix is in use.");
69
+ }
70
+ if (publicCookies.some((cookie) => cookie.hasSecurePrefix)) {
71
+ strengths.push("__Secure- cookie prefix is in use.");
72
+ }
73
+ return {
74
+ cookies: publicCookies,
75
+ cookiesWithoutSecure,
76
+ cookiesWithoutHttpOnly,
77
+ cookiesWithSameSiteNone,
78
+ cookiesWithoutSameSite,
79
+ issues,
80
+ strengths,
81
+ };
82
+ }
@@ -0,0 +1,19 @@
1
+ import type { CtDiscoveryInfo } from "./types.js";
2
+ interface JsonResponse<T = unknown> {
3
+ statusCode: number;
4
+ json: T | null;
5
+ }
6
+ interface TextResponse {
7
+ statusCode: number;
8
+ headers: Record<string, string | string[] | undefined>;
9
+ body: string;
10
+ }
11
+ type RequestJsonFn = (targetUrl: URL, extraHeaders?: Record<string, string>) => Promise<JsonResponse>;
12
+ type RequestTextFn = (targetUrl: URL, extraHeaders?: Record<string, string>) => Promise<TextResponse>;
13
+ export declare const fetchCtDiscovery: (host: string, requestJson: RequestJsonFn, requestText: RequestTextFn, options?: {
14
+ sampleHosts?: boolean;
15
+ subdomainLimit?: number;
16
+ wildcardLimit?: number;
17
+ sampleLimit?: number;
18
+ }) => Promise<CtDiscoveryInfo>;
19
+ export {};