@slicedearth/whoisleuth-cli 1.32.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/LICENSE +661 -0
- package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
- package/NOTICE +76 -0
- package/README.md +84 -0
- package/TRADEMARKS.md +41 -0
- package/bin/whoisleuth.mjs +35 -0
- package/cli/archive-inspect.mjs +198 -0
- package/cli/arguments.mjs +1022 -0
- package/cli/artifact-verify.mjs +210 -0
- package/cli/bulk-checkpoint.mjs +218 -0
- package/cli/bulk-command-runner.mjs +109 -0
- package/cli/bulk-output.mjs +112 -0
- package/cli/bulk.mjs +133 -0
- package/cli/compare.mjs +238 -0
- package/cli/completion.mjs +175 -0
- package/cli/csv.mjs +1 -0
- package/cli/discover.mjs +50 -0
- package/cli/discovery-command-runner.mjs +36 -0
- package/cli/discovery-observation-snapshot.mjs +315 -0
- package/cli/discovery-scan-command-runner.mjs +141 -0
- package/cli/discovery-scan.mjs +325 -0
- package/cli/discovery-snapshot.mjs +133 -0
- package/cli/discovery-workflow.mjs +73 -0
- package/cli/doctor.mjs +137 -0
- package/cli/errors.mjs +21 -0
- package/cli/evidence-command-arguments.mjs +145 -0
- package/cli/evidence-command-runner.mjs +106 -0
- package/cli/evidence-signing.mjs +215 -0
- package/cli/exit-codes.mjs +9 -0
- package/cli/export-evidence.mjs +28 -0
- package/cli/formatters/evidence-report.mjs +3 -0
- package/cli/formatters/html.mjs +69 -0
- package/cli/formatters/json.mjs +135 -0
- package/cli/formatters/markdown.mjs +3 -0
- package/cli/formatters/terminal.mjs +658 -0
- package/cli/http.mjs +27 -0
- package/cli/lookup-command-runner.mjs +87 -0
- package/cli/lookup-diff.mjs +171 -0
- package/cli/mail-review.mjs +204 -0
- package/cli/manual.mjs +34 -0
- package/cli/network-command-runner.mjs +88 -0
- package/cli/output-file.mjs +97 -0
- package/cli/page-compare.mjs +123 -0
- package/cli/posture.mjs +20 -0
- package/cli/progress-events.mjs +53 -0
- package/cli/progress.mjs +56 -0
- package/cli/registry-access.mjs +3 -0
- package/cli/registry-support.mjs +120 -0
- package/cli/risk-calibration.mjs +294 -0
- package/cli/runner-types.mjs +1 -0
- package/cli/runner.mjs +726 -0
- package/cli/saved-lookup.mjs +87 -0
- package/cli/source-reliability.mjs +727 -0
- package/cli/strict-exit.mjs +55 -0
- package/cli/terminal-presentation.mjs +147 -0
- package/docs/cli.md +827 -0
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
- package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
- package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
- package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
- package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
- package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
- package/frontend/src/lib/analysis/bulk-sort.js +49 -0
- package/frontend/src/lib/analysis/campaign-model.js +265 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
- package/frontend/src/lib/analysis/case-migration-model.js +248 -0
- package/frontend/src/lib/analysis/case-model.js +5 -0
- package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
- package/frontend/src/lib/analysis/case-record-core.js +232 -0
- package/frontend/src/lib/analysis/case-record-model.js +7 -0
- package/frontend/src/lib/analysis/case-record-operations.js +224 -0
- package/frontend/src/lib/analysis/case-response-model.js +640 -0
- package/frontend/src/lib/analysis/case-response-packet.js +573 -0
- package/frontend/src/lib/analysis/case-storage-model.js +93 -0
- package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
- package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
- package/frontend/src/lib/analysis/http-summary.js +165 -0
- package/frontend/src/lib/analysis/investigation-guide.js +500 -0
- package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
- package/frontend/src/lib/analysis/page-baseline.js +272 -0
- package/frontend/src/lib/analysis/page-similarity.js +179 -0
- package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
- package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
- package/frontend/src/lib/analysis/scoring.js +135 -0
- package/frontend/src/lib/analysis/shortlist-model.js +209 -0
- package/frontend/src/lib/analysis/utils.js +265 -0
- package/frontend/src/lib/analysis/watchlist-history.js +643 -0
- package/frontend/src/lib/analysis/watchlist-store.js +127 -0
- package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
- package/frontend/src/lib/analysis/workspace-archive.js +472 -0
- package/lib/abort.mjs +26 -0
- package/lib/abusech-auth.mjs +19 -0
- package/lib/availability.mjs +716 -0
- package/lib/bounded-contract-normalizers.mjs +61 -0
- package/lib/browser-library-profile.mjs +310 -0
- package/lib/bulk-comparison-evidence.mjs +83 -0
- package/lib/classify.mjs +93 -0
- package/lib/client-behavior-profile.mjs +83 -0
- package/lib/credential-surface-profile.mjs +66 -0
- package/lib/csv.mjs +23 -0
- package/lib/ct-query.mjs +27 -0
- package/lib/ct-search.mjs +393 -0
- package/lib/distributed-operation-budget.mjs +461 -0
- package/lib/dns-delegation-health.mjs +344 -0
- package/lib/dns-intelligence.mjs +739 -0
- package/lib/dns-mx.mjs +24 -0
- package/lib/dns-resolver-selection.mjs +33 -0
- package/lib/domain-posture-analysis.mjs +254 -0
- package/lib/domain-posture-parsers.mjs +358 -0
- package/lib/domain-posture.mjs +745 -0
- package/lib/error-detail.mjs +10 -0
- package/lib/evidence-export.mjs +431 -0
- package/lib/evidence-report-markdown.mjs +71 -0
- package/lib/evidence-report.mjs +391 -0
- package/lib/external-intelligence-risk.mjs +108 -0
- package/lib/favicon.mjs +146 -0
- package/lib/feature-policy.mjs +70 -0
- package/lib/generated/retire-browser-catalog.mjs +8724 -0
- package/lib/generated/unicode-confusables-17.mjs +93 -0
- package/lib/hostname.mjs +38 -0
- package/lib/html-signals.mjs +680 -0
- package/lib/http-intelligence.mjs +232 -0
- package/lib/idn-confusable-policy.mjs +86 -0
- package/lib/idn-confusables.mjs +465 -0
- package/lib/lookup-cache.mjs +130 -0
- package/lib/lookup-progress.mjs +291 -0
- package/lib/lookup-source-progress.mjs +109 -0
- package/lib/lookup-threat-provider-inventory.mjs +13 -0
- package/lib/lookup.mjs +592 -0
- package/lib/observation.mjs +114 -0
- package/lib/observed-network-context.mjs +355 -0
- package/lib/operation-budget.mjs +489 -0
- package/lib/page-fingerprints.mjs +481 -0
- package/lib/page-role-profile.mjs +117 -0
- package/lib/perceptual-hash.mjs +451 -0
- package/lib/rdap-attempts.mjs +34 -0
- package/lib/rdap-bootstrap.mjs +173 -0
- package/lib/rdap-capabilities.mjs +292 -0
- package/lib/rdap-client.mjs +125 -0
- package/lib/rdap-normalization.mjs +691 -0
- package/lib/rdap-registrar.mjs +196 -0
- package/lib/rdap-transport.mjs +39 -0
- package/lib/rdap-types.mjs +1 -0
- package/lib/rdap-validation.mjs +94 -0
- package/lib/rdap.mjs +16 -0
- package/lib/registry-access.mjs +32 -0
- package/lib/registry-capabilities.mjs +117 -0
- package/lib/registry-capability-catalogue.mjs +1206 -0
- package/lib/registry-comparison.mjs +318 -0
- package/lib/registry-dates.mjs +190 -0
- package/lib/registry-insights.mjs +325 -0
- package/lib/response-policy.mjs +256 -0
- package/lib/risk-scoring.mjs +164 -0
- package/lib/safe-fetch.mjs +446 -0
- package/lib/security-txt.mjs +359 -0
- package/lib/service-binding-dns.mjs +608 -0
- package/lib/sslbl-certificates.generated.mjs +143 -0
- package/lib/sslbl-intelligence.mjs +241 -0
- package/lib/static-html-analysis.mjs +340 -0
- package/lib/structured-data-identity.mjs +323 -0
- package/lib/threat-intelligence-contract.mjs +5 -0
- package/lib/threat-intelligence-definition-registry.mjs +34 -0
- package/lib/threat-intelligence-fixtures.mjs +82 -0
- package/lib/threat-intelligence-manifests.mjs +512 -0
- package/lib/threat-intelligence-runtime.mjs +662 -0
- package/lib/threat-intelligence-targets.mjs +168 -0
- package/lib/threat-intelligence-types.mjs +3 -0
- package/lib/threatfox-intelligence.mjs +307 -0
- package/lib/tls-intelligence.mjs +814 -0
- package/lib/typosquat-generator.mjs +884 -0
- package/lib/urlhaus-intelligence.mjs +361 -0
- package/lib/urlscan-intelligence.mjs +349 -0
- package/lib/website-security-posture.mjs +325 -0
- package/lib/website-technology.mjs +437 -0
- package/lib/whois-authority.mjs +98 -0
- package/lib/whois-chain.mjs +261 -0
- package/lib/whois-contacts.mjs +163 -0
- package/lib/whois-contracts.mjs +3 -0
- package/lib/whois-normalization.mjs +27 -0
- package/lib/whois-transport.mjs +100 -0
- package/lib/whois-values.mjs +92 -0
- package/lib/whois.mjs +1187 -0
- package/package.json +55 -0
- package/third-party-notices.txt +5571 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Shared strict primitives for internal evidence contracts. These helpers do
|
|
2
|
+
// not infer missing values and keep every string, object, enum, and counter
|
|
3
|
+
// bounded before a provider or connector result reaches its public model.
|
|
4
|
+
export function isRecord(value) {
|
|
5
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
export function boundedString(value, maxLength) {
|
|
8
|
+
if (typeof value !== 'string' || /[\u0000-\u001f\u007f]/u.test(value))
|
|
9
|
+
return null;
|
|
10
|
+
const normalized = value.trim().replace(/\s+/gu, ' ');
|
|
11
|
+
return normalized ? normalized.slice(0, maxLength) : null;
|
|
12
|
+
}
|
|
13
|
+
export function strictBoundedString(value, maxLength) {
|
|
14
|
+
if (typeof value !== 'string'
|
|
15
|
+
|| value.length > maxLength
|
|
16
|
+
|| /[\u0000-\u001f\u007f]/u.test(value))
|
|
17
|
+
return null;
|
|
18
|
+
const normalized = value.trim().replace(/\s+/gu, ' ');
|
|
19
|
+
return normalized && normalized.length <= maxLength ? normalized : null;
|
|
20
|
+
}
|
|
21
|
+
export function isoTimestamp(value) {
|
|
22
|
+
if (typeof value !== 'string' || value.length > 64 || /[\u0000-\u001f\u007f]/u.test(value))
|
|
23
|
+
return null;
|
|
24
|
+
const timestamp = Date.parse(value);
|
|
25
|
+
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
|
|
26
|
+
}
|
|
27
|
+
export function boundedHttpsUrl(value, maxLength) {
|
|
28
|
+
const raw = strictBoundedString(value, maxLength);
|
|
29
|
+
if (!raw)
|
|
30
|
+
return null;
|
|
31
|
+
try {
|
|
32
|
+
const parsed = new URL(raw);
|
|
33
|
+
if (parsed.protocol !== 'https:' || parsed.username || parsed.password)
|
|
34
|
+
return null;
|
|
35
|
+
const normalized = parsed.toString();
|
|
36
|
+
return normalized.length <= maxLength ? normalized : null;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function exactKeys(value, allowed, label) {
|
|
43
|
+
if (!isRecord(value))
|
|
44
|
+
throw new TypeError(`${label} must be an object`);
|
|
45
|
+
const unknown = Object.keys(value).find((key) => !allowed.has(key));
|
|
46
|
+
if (unknown)
|
|
47
|
+
throw new TypeError(`${label} contains an unknown field: ${unknown}`);
|
|
48
|
+
}
|
|
49
|
+
export function enumValue(value, allowed, label) {
|
|
50
|
+
if (typeof value !== 'string' || !allowed.has(value)) {
|
|
51
|
+
throw new TypeError(`${label} is invalid`);
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
export function boundedInteger(value, minimum, maximum, label) {
|
|
56
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value)
|
|
57
|
+
|| value < minimum || value > maximum) {
|
|
58
|
+
throw new TypeError(`${label} must be an integer between ${minimum} and ${maximum}`);
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// Passive browser-library identification over the capped HTML body already
|
|
2
|
+
// collected by an eligible deep lookup. The matcher uses a pinned, reviewed
|
|
3
|
+
// projection of Retire.js's browser catalogue and never fetches referenced
|
|
4
|
+
// scripts. Upstream URLs, filenames, inline bodies, and catalogue expressions
|
|
5
|
+
// are treated as untrusted inputs and are not retained in the result.
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { RETIRE_BROWSER_CATALOG } from "./generated/retire-browser-catalog.mjs";
|
|
8
|
+
import { createObservation } from "./observation.mjs";
|
|
9
|
+
import { MAX_INLINE_SCRIPT_CHARS, MAX_INLINE_SCRIPT_TOTAL_CHARS, MAX_SCRIPT_ELEMENTS, MAX_STATIC_HTML_CHARS, analyzeStaticHtml, } from "./static-html-analysis.mjs";
|
|
10
|
+
const BROWSER_LIBRARY_PROFILE_VERSION = 1;
|
|
11
|
+
const MAX_LIBRARY_HTML_CHARS = MAX_STATIC_HTML_CHARS;
|
|
12
|
+
const MAX_LIBRARY_FINDINGS = 16;
|
|
13
|
+
const MAX_ADVISORY_IDENTIFIERS = 16;
|
|
14
|
+
const MAX_WEAKNESS_CLASSES = 12;
|
|
15
|
+
const MAX_MATCHES_PER_PATTERN = 4;
|
|
16
|
+
const MAX_VERSION_LENGTH = 64;
|
|
17
|
+
const COMPONENT_RE = /^[a-z0-9._-]{1,80}$/i;
|
|
18
|
+
const VERSION_RE = /^[0-9][0-9.a-z_-]{0,63}$/i;
|
|
19
|
+
const CVE_RE = /^CVE-[0-9X-]+$/;
|
|
20
|
+
const GHSA_RE = /^GHSA-[A-Z0-9-]+$/;
|
|
21
|
+
const CWE_RE = /^CWE-[0-9]+$/;
|
|
22
|
+
const SEVERITY_WEIGHT = Object.freeze({
|
|
23
|
+
none: 0,
|
|
24
|
+
low: 1,
|
|
25
|
+
medium: 2,
|
|
26
|
+
high: 3,
|
|
27
|
+
critical: 4,
|
|
28
|
+
});
|
|
29
|
+
const CATALOG_COMPONENTS = RETIRE_BROWSER_CATALOG.components;
|
|
30
|
+
function record(value) {
|
|
31
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
32
|
+
? value
|
|
33
|
+
: {};
|
|
34
|
+
}
|
|
35
|
+
function boundedStringArray(value, limit, pattern) {
|
|
36
|
+
const output = [];
|
|
37
|
+
for (const item of (Array.isArray(value) ? value : []).slice(0, limit)) {
|
|
38
|
+
if (typeof item !== 'string' || item.length === 0 || item.length > 80 || !pattern.test(item))
|
|
39
|
+
continue;
|
|
40
|
+
output.push(item);
|
|
41
|
+
}
|
|
42
|
+
return output;
|
|
43
|
+
}
|
|
44
|
+
function comparableVersionPart(value) {
|
|
45
|
+
if (value === undefined)
|
|
46
|
+
return 0;
|
|
47
|
+
return /^[0-9]+$/.test(value) ? Number.parseInt(value, 10) : value;
|
|
48
|
+
}
|
|
49
|
+
// Retire.js uses a deliberately small dotted/dashed version comparator rather
|
|
50
|
+
// than a package-manager-specific semantic-version parser. Keeping the same
|
|
51
|
+
// comparison contract makes the pinned catalogue thresholds deterministic.
|
|
52
|
+
function isAtOrAbove(left, right) {
|
|
53
|
+
const leftParts = left.split(/[.-]/g);
|
|
54
|
+
const rightParts = right.split(/[.-]/g);
|
|
55
|
+
const length = Math.max(leftParts.length, rightParts.length);
|
|
56
|
+
for (let index = 0; index < length; index += 1) {
|
|
57
|
+
const leftPart = comparableVersionPart(leftParts[index]);
|
|
58
|
+
const rightPart = comparableVersionPart(rightParts[index]);
|
|
59
|
+
if (typeof leftPart !== typeof rightPart)
|
|
60
|
+
return typeof leftPart === 'number';
|
|
61
|
+
if (leftPart > rightPart)
|
|
62
|
+
return true;
|
|
63
|
+
if (leftPart < rightPart)
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
function matchingVulnerabilities(component, version) {
|
|
69
|
+
const vulnerabilities = Array.isArray(component.vulnerabilities) ? component.vulnerabilities : [];
|
|
70
|
+
const matched = [];
|
|
71
|
+
for (const rawVulnerability of vulnerabilities) {
|
|
72
|
+
const vulnerability = record(rawVulnerability);
|
|
73
|
+
if (typeof vulnerability.below !== 'string' || !VERSION_RE.test(vulnerability.below))
|
|
74
|
+
continue;
|
|
75
|
+
if (isAtOrAbove(version, vulnerability.below))
|
|
76
|
+
continue;
|
|
77
|
+
if (typeof vulnerability.atOrAbove === 'string'
|
|
78
|
+
&& VERSION_RE.test(vulnerability.atOrAbove)
|
|
79
|
+
&& !isAtOrAbove(version, vulnerability.atOrAbove))
|
|
80
|
+
continue;
|
|
81
|
+
if (Array.isArray(vulnerability.excludes) && vulnerability.excludes.includes(version))
|
|
82
|
+
continue;
|
|
83
|
+
matched.push(vulnerability);
|
|
84
|
+
}
|
|
85
|
+
return matched;
|
|
86
|
+
}
|
|
87
|
+
function matchPattern(pattern, value) {
|
|
88
|
+
if (typeof pattern !== 'string' || pattern.length === 0 || pattern.length > 2_048)
|
|
89
|
+
return [];
|
|
90
|
+
let expression;
|
|
91
|
+
try {
|
|
92
|
+
expression = new RegExp(pattern, 'g');
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
const matches = [];
|
|
98
|
+
let match;
|
|
99
|
+
while ((match = expression.exec(value)) && matches.length < MAX_MATCHES_PER_PATTERN) {
|
|
100
|
+
if (typeof match[1] === 'string' && VERSION_RE.test(match[1]))
|
|
101
|
+
matches.push(match[1]);
|
|
102
|
+
if (match[0] === '')
|
|
103
|
+
expression.lastIndex += 1;
|
|
104
|
+
}
|
|
105
|
+
return matches;
|
|
106
|
+
}
|
|
107
|
+
function matchReplacementPattern(pattern, value) {
|
|
108
|
+
if (typeof pattern !== 'string' || pattern.length === 0 || pattern.length > 2_048)
|
|
109
|
+
return [];
|
|
110
|
+
const descriptor = /^\/(.*[^\\])\/([^/]+)\/$/.exec(pattern);
|
|
111
|
+
const expressionSource = descriptor?.[1];
|
|
112
|
+
const replacement = descriptor?.[2];
|
|
113
|
+
if (!expressionSource || replacement === undefined)
|
|
114
|
+
return [];
|
|
115
|
+
let expression;
|
|
116
|
+
try {
|
|
117
|
+
expression = new RegExp(expressionSource, 'g');
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
const matches = [];
|
|
123
|
+
let match;
|
|
124
|
+
while ((match = expression.exec(value)) && matches.length < MAX_MATCHES_PER_PATTERN) {
|
|
125
|
+
const replaced = match[0].replace(new RegExp(expressionSource), replacement).replace(/(?:\.|-)?min$/i, '');
|
|
126
|
+
if (VERSION_RE.test(replaced))
|
|
127
|
+
matches.push(replaced);
|
|
128
|
+
if (match[0] === '')
|
|
129
|
+
expression.lastIndex += 1;
|
|
130
|
+
}
|
|
131
|
+
return matches;
|
|
132
|
+
}
|
|
133
|
+
function addDetected(detected, component, version, method) {
|
|
134
|
+
const normalizedVersion = version.replace(/(?:\.|-)?min$/i, '').slice(0, MAX_VERSION_LENGTH);
|
|
135
|
+
if (!COMPONENT_RE.test(component) || !VERSION_RE.test(normalizedVersion))
|
|
136
|
+
return;
|
|
137
|
+
const key = `${component}\u0000${normalizedVersion}`;
|
|
138
|
+
const existing = detected.get(key);
|
|
139
|
+
if (existing) {
|
|
140
|
+
existing.detections.add(method);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (detected.size >= MAX_LIBRARY_FINDINGS * 4)
|
|
144
|
+
return;
|
|
145
|
+
detected.set(key, { component, version: normalizedVersion, detections: new Set([method]) });
|
|
146
|
+
}
|
|
147
|
+
function scanExtractor(detected, extractorName, value, method) {
|
|
148
|
+
for (const [componentName, rawComponent] of Object.entries(CATALOG_COMPONENTS)) {
|
|
149
|
+
const component = record(rawComponent);
|
|
150
|
+
const extractors = record(component.extractors);
|
|
151
|
+
const patterns = Array.isArray(extractors[extractorName]) ? extractors[extractorName] : [];
|
|
152
|
+
for (const pattern of patterns.slice(0, 64)) {
|
|
153
|
+
for (const version of matchPattern(pattern, value))
|
|
154
|
+
addDetected(detected, componentName, version, method);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function scanFilename(detected, reference) {
|
|
159
|
+
const filename = (reference.replace(/\\/g, '/').split('/').pop() || '').split(/[?#]/, 1)[0];
|
|
160
|
+
if (!filename)
|
|
161
|
+
return;
|
|
162
|
+
for (const [componentName, rawComponent] of Object.entries(CATALOG_COMPONENTS)) {
|
|
163
|
+
const component = record(rawComponent);
|
|
164
|
+
const extractors = record(component.extractors);
|
|
165
|
+
const patterns = Array.isArray(extractors.filename) ? extractors.filename : [];
|
|
166
|
+
for (const pattern of patterns.slice(0, 64)) {
|
|
167
|
+
if (typeof pattern !== 'string' || pattern.length > 2_048)
|
|
168
|
+
continue;
|
|
169
|
+
for (const version of matchPattern(`^(?:${pattern})$`, filename)) {
|
|
170
|
+
addDetected(detected, componentName, version, 'script filename');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function scanInlineContent(detected, content) {
|
|
176
|
+
const normalized = content.replace(/\r\n?|\n/g, '\n');
|
|
177
|
+
const digest = createHash('sha1').update(normalized).digest('hex');
|
|
178
|
+
scanExtractor(detected, 'filecontent', normalized, 'inline signature');
|
|
179
|
+
for (const [componentName, rawComponent] of Object.entries(CATALOG_COMPONENTS)) {
|
|
180
|
+
const component = record(rawComponent);
|
|
181
|
+
const extractors = record(component.extractors);
|
|
182
|
+
const replacementPatterns = Array.isArray(extractors.filecontentreplace) ? extractors.filecontentreplace : [];
|
|
183
|
+
for (const pattern of replacementPatterns.slice(0, 64)) {
|
|
184
|
+
for (const version of matchReplacementPattern(pattern, normalized)) {
|
|
185
|
+
addDetected(detected, componentName, version, 'inline signature');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const hashes = record(extractors.hashes);
|
|
189
|
+
const version = hashes[digest];
|
|
190
|
+
if (typeof version === 'string')
|
|
191
|
+
addDetected(detected, componentName, version, 'inline hash');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function severity(value) {
|
|
195
|
+
return typeof value === 'string' && Object.hasOwn(SEVERITY_WEIGHT, value)
|
|
196
|
+
? value
|
|
197
|
+
: null;
|
|
198
|
+
}
|
|
199
|
+
function findingFromDetection(detected) {
|
|
200
|
+
const component = record(CATALOG_COMPONENTS[detected.component]);
|
|
201
|
+
const vulnerabilities = matchingVulnerabilities(component, detected.version);
|
|
202
|
+
let highestSeverity = null;
|
|
203
|
+
const identifiers = new Set();
|
|
204
|
+
const weaknesses = new Set();
|
|
205
|
+
for (const vulnerability of vulnerabilities) {
|
|
206
|
+
const candidateSeverity = severity(vulnerability.severity);
|
|
207
|
+
if (candidateSeverity
|
|
208
|
+
&& (!highestSeverity || SEVERITY_WEIGHT[candidateSeverity] > SEVERITY_WEIGHT[highestSeverity]))
|
|
209
|
+
highestSeverity = candidateSeverity;
|
|
210
|
+
const vulnerabilityIdentifiers = record(vulnerability.identifiers);
|
|
211
|
+
for (const cve of boundedStringArray(vulnerabilityIdentifiers.CVE, 16, CVE_RE)) {
|
|
212
|
+
if (identifiers.size < MAX_ADVISORY_IDENTIFIERS)
|
|
213
|
+
identifiers.add(cve);
|
|
214
|
+
}
|
|
215
|
+
if (typeof vulnerabilityIdentifiers.githubID === 'string'
|
|
216
|
+
&& GHSA_RE.test(vulnerabilityIdentifiers.githubID)
|
|
217
|
+
&& identifiers.size < MAX_ADVISORY_IDENTIFIERS)
|
|
218
|
+
identifiers.add(vulnerabilityIdentifiers.githubID.toUpperCase());
|
|
219
|
+
for (const cwe of boundedStringArray(vulnerability.cwe, 16, CWE_RE)) {
|
|
220
|
+
if (weaknesses.size < MAX_WEAKNESS_CLASSES)
|
|
221
|
+
weaknesses.add(cwe);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
id: detected.component,
|
|
226
|
+
name: detected.component,
|
|
227
|
+
apparentVersion: detected.version,
|
|
228
|
+
detectionMethods: [...detected.detections].sort(),
|
|
229
|
+
advisoryCount: vulnerabilities.length,
|
|
230
|
+
highestSeverity,
|
|
231
|
+
advisoryIdentifiers: [...identifiers].sort(),
|
|
232
|
+
weaknessClasses: [...weaknesses].sort(),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
function analyzeBrowserLibraries(input = {}) {
|
|
236
|
+
const htmlAnalysis = input.htmlAnalysis ?? analyzeStaticHtml(input.html);
|
|
237
|
+
const detected = new Map();
|
|
238
|
+
let referencesExamined = 0;
|
|
239
|
+
let inlineScriptsExamined = 0;
|
|
240
|
+
for (const script of htmlAnalysis.scripts) {
|
|
241
|
+
if (script.reference) {
|
|
242
|
+
referencesExamined += 1;
|
|
243
|
+
scanExtractor(detected, 'uri', script.reference, 'script URL');
|
|
244
|
+
scanFilename(detected, script.reference);
|
|
245
|
+
}
|
|
246
|
+
else if (script.inlineContent && script.mediaType !== 'application/ld+json') {
|
|
247
|
+
inlineScriptsExamined += 1;
|
|
248
|
+
scanInlineContent(detected, script.inlineContent);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const allFindings = [...detected.values()]
|
|
252
|
+
.map(findingFromDetection)
|
|
253
|
+
.sort((left, right) => ((right.highestSeverity ? SEVERITY_WEIGHT[right.highestSeverity] : -1)
|
|
254
|
+
- (left.highestSeverity ? SEVERITY_WEIGHT[left.highestSeverity] : -1)
|
|
255
|
+
|| right.advisoryCount - left.advisoryCount
|
|
256
|
+
|| left.name.localeCompare(right.name)
|
|
257
|
+
|| left.apparentVersion.localeCompare(right.apparentVersion)));
|
|
258
|
+
const findingLimitReached = allFindings.length > MAX_LIBRARY_FINDINGS;
|
|
259
|
+
const truncated = input.sourceTruncated === true
|
|
260
|
+
|| htmlAnalysis.inputLimitReached
|
|
261
|
+
|| htmlAnalysis.tagLimitReached
|
|
262
|
+
|| htmlAnalysis.scriptLimitReached
|
|
263
|
+
|| htmlAnalysis.inlineLimitReached
|
|
264
|
+
|| findingLimitReached;
|
|
265
|
+
const limitations = [
|
|
266
|
+
'Library versions are inferred from passive static signatures and may be absent, transformed, or misleading.',
|
|
267
|
+
'A catalogue advisory match identifies a component version associated with a published advisory; it does not establish reachability or exploitability.',
|
|
268
|
+
'Referenced scripts are not fetched, executed, or retained, and unmatched scripts are not evidence that no library is present.',
|
|
269
|
+
];
|
|
270
|
+
if (input.sourceTruncated === true)
|
|
271
|
+
limitations.push('The captured homepage body was truncated, so script evidence may be incomplete.');
|
|
272
|
+
if (htmlAnalysis.inputLimitReached)
|
|
273
|
+
limitations.push(`Only the first ${MAX_LIBRARY_HTML_CHARS} HTML characters were evaluated.`);
|
|
274
|
+
if (htmlAnalysis.tagLimitReached)
|
|
275
|
+
limitations.push('Static HTML tokenization reached its tag or attribute boundary.');
|
|
276
|
+
if (htmlAnalysis.scriptLimitReached)
|
|
277
|
+
limitations.push(`Only the first ${MAX_SCRIPT_ELEMENTS} script elements were evaluated.`);
|
|
278
|
+
if (htmlAnalysis.inlineLimitReached)
|
|
279
|
+
limitations.push(`Inline script evaluation reached its ${MAX_INLINE_SCRIPT_TOTAL_CHARS}-character cumulative boundary.`);
|
|
280
|
+
if (findingLimitReached)
|
|
281
|
+
limitations.push(`Only the first ${MAX_LIBRARY_FINDINGS} library findings were retained.`);
|
|
282
|
+
return {
|
|
283
|
+
profileVersion: BROWSER_LIBRARY_PROFILE_VERSION,
|
|
284
|
+
catalog: {
|
|
285
|
+
name: 'Retire.js',
|
|
286
|
+
version: RETIRE_BROWSER_CATALOG.catalogVersion,
|
|
287
|
+
sourceRevision: RETIRE_BROWSER_CATALOG.sourceRevision,
|
|
288
|
+
},
|
|
289
|
+
...createObservation({
|
|
290
|
+
status: truncated ? 'partial' : 'success',
|
|
291
|
+
observedAt: input.observedAt,
|
|
292
|
+
scanMode: 'deep',
|
|
293
|
+
source: 'derived',
|
|
294
|
+
complete: !truncated,
|
|
295
|
+
truncated,
|
|
296
|
+
limitations,
|
|
297
|
+
diagnostics: {
|
|
298
|
+
scriptsExamined: htmlAnalysis.scripts.length,
|
|
299
|
+
referencesExamined,
|
|
300
|
+
inlineScriptsExamined,
|
|
301
|
+
inlineCharactersExamined: htmlAnalysis.inlineCharactersExamined,
|
|
302
|
+
catalogComponents: Object.keys(CATALOG_COMPONENTS).length,
|
|
303
|
+
findings: allFindings.length,
|
|
304
|
+
advisoryMatches: allFindings.reduce((sum, finding) => sum + finding.advisoryCount, 0),
|
|
305
|
+
},
|
|
306
|
+
}),
|
|
307
|
+
findings: allFindings.slice(0, MAX_LIBRARY_FINDINGS),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
export { BROWSER_LIBRARY_PROFILE_VERSION, MAX_INLINE_SCRIPT_CHARS, MAX_INLINE_SCRIPT_TOTAL_CHARS, MAX_LIBRARY_FINDINGS, MAX_LIBRARY_HTML_CHARS, MAX_SCRIPT_ELEMENTS, analyzeBrowserLibraries, };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Bounded comparison-only evidence for compact Deep Bulk responses. The
|
|
2
|
+
// complete technology and TLS observations remain transient and separately
|
|
3
|
+
// attributed; this projection retains only stable identifiers and source
|
|
4
|
+
// health needed for analyst-controlled comparisons.
|
|
5
|
+
export const BULK_COMPARISON_EVIDENCE_VERSION = 1;
|
|
6
|
+
export const MAX_BULK_TECHNOLOGY_IDS = 12;
|
|
7
|
+
export const MAX_BULK_TLS_ISSUER_LENGTH = 240;
|
|
8
|
+
const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
|
|
9
|
+
const TECHNOLOGY_ID_RE = /^[a-z0-9][a-z0-9_-]{0,79}$/u;
|
|
10
|
+
const SHA256_RE = /^[a-f0-9]{64}$/iu;
|
|
11
|
+
const STATES = new Set(['error', 'not_found', 'partial', 'success', 'unavailable']);
|
|
12
|
+
function record(value) {
|
|
13
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
14
|
+
? value
|
|
15
|
+
: {};
|
|
16
|
+
}
|
|
17
|
+
function boundedText(value, maximum) {
|
|
18
|
+
return typeof value === 'string' && value.length <= maximum && !CONTROL_RE.test(value)
|
|
19
|
+
? value.replace(/\s+/gu, ' ').trim()
|
|
20
|
+
: '';
|
|
21
|
+
}
|
|
22
|
+
function state(value) {
|
|
23
|
+
const normalized = boundedText(value, 40).toLowerCase();
|
|
24
|
+
return STATES.has(normalized)
|
|
25
|
+
? normalized
|
|
26
|
+
: 'unavailable';
|
|
27
|
+
}
|
|
28
|
+
function technologyIds(value) {
|
|
29
|
+
if (!Array.isArray(value))
|
|
30
|
+
return { ids: [], truncated: false };
|
|
31
|
+
const output = new Set();
|
|
32
|
+
let truncated = value.length > MAX_BULK_TECHNOLOGY_IDS;
|
|
33
|
+
for (const candidate of value.slice(0, MAX_BULK_TECHNOLOGY_IDS * 4)) {
|
|
34
|
+
const id = boundedText(record(candidate).id, 80).toLowerCase();
|
|
35
|
+
if (!TECHNOLOGY_ID_RE.test(id) || output.has(id))
|
|
36
|
+
continue;
|
|
37
|
+
if (output.size >= MAX_BULK_TECHNOLOGY_IDS)
|
|
38
|
+
truncated = true;
|
|
39
|
+
else
|
|
40
|
+
output.add(id);
|
|
41
|
+
}
|
|
42
|
+
return { ids: [...output].sort(), truncated };
|
|
43
|
+
}
|
|
44
|
+
function issuerLabel(value) {
|
|
45
|
+
const issuer = record(value);
|
|
46
|
+
const values = [
|
|
47
|
+
...(Array.isArray(issuer.commonNames) ? issuer.commonNames : []),
|
|
48
|
+
...(Array.isArray(issuer.organizations) ? issuer.organizations : []),
|
|
49
|
+
]
|
|
50
|
+
.slice(0, 8)
|
|
51
|
+
.map((candidate) => boundedText(candidate, 160))
|
|
52
|
+
.filter(Boolean);
|
|
53
|
+
const label = [...new Set(values)].join(' · ');
|
|
54
|
+
return label ? label.slice(0, MAX_BULK_TLS_ISSUER_LENGTH) : null;
|
|
55
|
+
}
|
|
56
|
+
export function buildBulkComparisonEvidence(availabilityValue) {
|
|
57
|
+
const availability = record(availabilityValue);
|
|
58
|
+
const technology = record(availability.technologyProfile);
|
|
59
|
+
const technologyState = state(technology.status);
|
|
60
|
+
const technologyUsable = ['success', 'partial'].includes(technologyState);
|
|
61
|
+
const technologyFindings = technologyUsable
|
|
62
|
+
? technologyIds(technology.findings)
|
|
63
|
+
: { ids: [], truncated: false };
|
|
64
|
+
const tls = record(availability.tls);
|
|
65
|
+
const tlsState = state(tls.status);
|
|
66
|
+
const tlsUsable = ['success', 'partial'].includes(tlsState);
|
|
67
|
+
const certificate = record(tls.certificate);
|
|
68
|
+
const publicKey = record(certificate.publicKey);
|
|
69
|
+
const spki = boundedText(publicKey.fingerprintSha256, 64);
|
|
70
|
+
return {
|
|
71
|
+
version: BULK_COMPARISON_EVIDENCE_VERSION,
|
|
72
|
+
technology: {
|
|
73
|
+
state: technologyState,
|
|
74
|
+
ids: technologyFindings.ids,
|
|
75
|
+
truncated: technologyUsable && (technology.truncated === true || technologyFindings.truncated),
|
|
76
|
+
},
|
|
77
|
+
tls: {
|
|
78
|
+
state: tlsState,
|
|
79
|
+
issuerLabel: tlsUsable ? issuerLabel(certificate.issuer) : null,
|
|
80
|
+
spkiSha256: tlsUsable && SHA256_RE.test(spki) ? spki.toLowerCase() : null,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
package/lib/classify.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Query classification - shared by both the Express server and the Netlify
|
|
2
|
+
// Functions so a domain/IP/ASN query is always parsed identically wherever
|
|
3
|
+
// this code runs. Every classified value eventually reaches a raw protocol
|
|
4
|
+
// call - a WHOIS TCP socket write (lib/whois.mts), a DNS query, an RDAP/
|
|
5
|
+
// MTA-STS fetch URL - and none of those downstream call sites re-validate
|
|
6
|
+
// syntax, so this is the one place responsible for rejecting anything that
|
|
7
|
+
// isn't actually safe to hand to them.
|
|
8
|
+
//
|
|
9
|
+
// Domains are classified to their *registrable* domain via the Public Suffix
|
|
10
|
+
// List (tldts). Registries only publish RDAP/WHOIS records at the registrable-
|
|
11
|
+
// domain level, so a lookup for `login.example.com` must query `example.com` -
|
|
12
|
+
// otherwise an arbitrary subdomain's RDAP 404 would be misread as "available
|
|
13
|
+
// to register" when the underlying registration plainly exists. Both the
|
|
14
|
+
// original input hostname and the resolved registrable domain are returned so
|
|
15
|
+
// callers can look up the latter while still showing the user what they typed.
|
|
16
|
+
import { isIP } from 'node:net';
|
|
17
|
+
import { parse } from 'tldts';
|
|
18
|
+
// A CR/LF here would let one query become multiple lines once it reaches
|
|
19
|
+
// lib/whois.mts's raw `socket.write(query + '\r\n')` - a normal URL-encoded
|
|
20
|
+
// query string (?q=example.com%0D%0AHELP) decodes back into literal control
|
|
21
|
+
// characters before this function ever sees them, so this has to be
|
|
22
|
+
// checked explicitly, before any other parsing, rather than assumed away.
|
|
23
|
+
const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/;
|
|
24
|
+
// 32-bit ASN ceiling (RFC 6793 four-octet ASNs). AS0 and AS4294967295 are
|
|
25
|
+
// reserved but syntactically valid; anything above is not an ASN at all.
|
|
26
|
+
const MAX_ASN = 4294967295;
|
|
27
|
+
// A registrable-domain label: LDH (letter/digit/hyphen), 1-63 characters, no
|
|
28
|
+
// leading or trailing hyphen. tldts already rejects empty labels, over-long
|
|
29
|
+
// labels, leading/trailing hyphens, and public-suffix-only inputs (all yield
|
|
30
|
+
// a null registrable domain) - but it does NOT reject underscores, so this is
|
|
31
|
+
// the explicit backstop that catches `foo_bar.com` and makes the full rule
|
|
32
|
+
// checkable in one place.
|
|
33
|
+
function isValidRegistrableLabel(label) {
|
|
34
|
+
return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label);
|
|
35
|
+
}
|
|
36
|
+
function classifyQuery(raw) {
|
|
37
|
+
const trimmed = raw.trim();
|
|
38
|
+
if (CONTROL_CHAR_RE.test(trimmed)) {
|
|
39
|
+
throw new Error('Query contains control characters, which are not valid in a domain, IP, or ASN.');
|
|
40
|
+
}
|
|
41
|
+
// Strip a scheme and any path/query/fragment. A single terminal root dot
|
|
42
|
+
// (`example.com.`, the fully-qualified form) is normalized away, but more
|
|
43
|
+
// than one is malformed - a valid name has at most one root label.
|
|
44
|
+
const withoutScheme = trimmed.replace(/^[a-z]+:\/\//i, '').split(/[/?#]/)[0] ?? '';
|
|
45
|
+
if (withoutScheme.endsWith('..')) {
|
|
46
|
+
throw new Error(`"${trimmed}" has more than one terminal dot.`);
|
|
47
|
+
}
|
|
48
|
+
const q = withoutScheme.replace(/\.$/, '');
|
|
49
|
+
// net.isIP is Node's own IP-address parser - stricter and more correct
|
|
50
|
+
// than a hand-rolled regex (rejects out-of-range IPv4 octets, malformed
|
|
51
|
+
// IPv6 groupings, etc.) and handles both families in one call.
|
|
52
|
+
const ipVersion = isIP(q);
|
|
53
|
+
if (ipVersion === 4)
|
|
54
|
+
return { type: 'ipv4', value: q };
|
|
55
|
+
if (ipVersion === 6)
|
|
56
|
+
return { type: 'ipv6', value: q };
|
|
57
|
+
const asnMatch = q.match(/^AS(\d+)$/i) || q.match(/^(\d+)$/);
|
|
58
|
+
if (asnMatch) {
|
|
59
|
+
const num = Number(asnMatch[1]);
|
|
60
|
+
if (!Number.isInteger(num) || num < 0 || num > MAX_ASN) {
|
|
61
|
+
throw new Error(`"${trimmed}" is not a valid ASN (must be an integer 0-${MAX_ASN}).`);
|
|
62
|
+
}
|
|
63
|
+
return { type: 'asn', value: `AS${num}` };
|
|
64
|
+
}
|
|
65
|
+
// Domain: IDNA-normalize via the WHATWG URL parser (punycode-encodes an
|
|
66
|
+
// internationalized name, strips a stray port), then resolve the registrable
|
|
67
|
+
// domain via the Public Suffix List.
|
|
68
|
+
let inputHostname;
|
|
69
|
+
try {
|
|
70
|
+
inputHostname = new URL(`https://${q}`).hostname.toLowerCase();
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
inputHostname = '';
|
|
74
|
+
}
|
|
75
|
+
if (!inputHostname || !inputHostname.includes('.') || inputHostname.length > 253) {
|
|
76
|
+
throw new Error(`"${trimmed}" is not a valid domain, IP, or ASN.`);
|
|
77
|
+
}
|
|
78
|
+
const registrableDomain = parse(inputHostname).domain;
|
|
79
|
+
if (!registrableDomain) {
|
|
80
|
+
throw new Error(`"${trimmed}" is not a registrable domain (no public-suffix match).`);
|
|
81
|
+
}
|
|
82
|
+
if (!registrableDomain.split('.').every(isValidRegistrableLabel)) {
|
|
83
|
+
throw new Error(`"${trimmed}" contains an invalid domain label (underscores, empty, or malformed labels are not registrable).`);
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
type: 'domain',
|
|
87
|
+
value: registrableDomain,
|
|
88
|
+
inputHostname,
|
|
89
|
+
registrableDomain,
|
|
90
|
+
isSubdomain: inputHostname !== registrableDomain,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
export { classifyQuery, MAX_ASN };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Static client-side behaviour indicators derived from the existing bounded
|
|
2
|
+
// HTML tokenizer pass. Scripts are never fetched or executed. Only fixed
|
|
3
|
+
// indicator identifiers, descriptions, evidence classes, and bounded counts
|
|
4
|
+
// leave this module; script references and contents are discarded.
|
|
5
|
+
import { createObservation } from "./observation.mjs";
|
|
6
|
+
import { analyzeStaticHtml, } from "./static-html-analysis.mjs";
|
|
7
|
+
const CLIENT_BEHAVIOR_PROFILE_VERSION = 1;
|
|
8
|
+
const MAX_CLIENT_BEHAVIOR_INDICATORS = 12;
|
|
9
|
+
const MAX_INDICATOR_OCCURRENCES = 999;
|
|
10
|
+
function boundedOccurrences(value) {
|
|
11
|
+
return Math.max(1, Math.min(MAX_INDICATOR_OCCURRENCES, value));
|
|
12
|
+
}
|
|
13
|
+
function patternCount(value, pattern) {
|
|
14
|
+
let count = 0;
|
|
15
|
+
for (const _match of value.matchAll(pattern)) {
|
|
16
|
+
count += 1;
|
|
17
|
+
if (count >= MAX_INDICATOR_OCCURRENCES)
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
return count;
|
|
21
|
+
}
|
|
22
|
+
function analyzeClientBehavior(input = {}) {
|
|
23
|
+
const htmlAnalysis = input.htmlAnalysis ?? analyzeStaticHtml(input.html);
|
|
24
|
+
const markup = htmlAnalysis.markup;
|
|
25
|
+
const inlineScripts = htmlAnalysis.scripts
|
|
26
|
+
.filter((script) => script.reference === null)
|
|
27
|
+
.map((script) => script.inlineContent)
|
|
28
|
+
.join('\n');
|
|
29
|
+
const indicators = [];
|
|
30
|
+
function add(id, label, evidenceClass, occurrences, explanation) {
|
|
31
|
+
if (occurrences <= 0 || indicators.length >= MAX_CLIENT_BEHAVIOR_INDICATORS)
|
|
32
|
+
return;
|
|
33
|
+
indicators.push({ id, label, evidenceClass, occurrences: boundedOccurrences(occurrences), explanation });
|
|
34
|
+
}
|
|
35
|
+
add('inline_event_handlers', 'Inline event handlers', 'static_markup', patternCount(markup, /\son[a-z]{2,24}="/gu), 'Inline HTML event-handler attributes were observed.');
|
|
36
|
+
add('client_navigation', 'Client-side navigation', 'inline_script', patternCount(inlineScripts, /\b(?:history\.(?:pushState|replaceState)|location\.(?:assign|replace)|window\.location)\b/gu), 'Inline script contains a client-side navigation API.');
|
|
37
|
+
add('form_interception', 'Form interception', 'inline_script', patternCount(inlineScripts, /(?:addEventListener\s*\(\s*['"]submit['"]|\.onsubmit\s*=|preventDefault\s*\()/gu), 'Inline script contains a form-submission or default-action interception marker.');
|
|
38
|
+
add('service_worker', 'Service worker registration', 'inline_script', patternCount(inlineScripts, /\bnavigator\.serviceWorker\.register\s*\(/gu), 'Inline script contains a service-worker registration call.');
|
|
39
|
+
add('browser_storage', 'Browser storage access', 'inline_script', patternCount(inlineScripts, /\b(?:localStorage|sessionStorage|indexedDB)\b/gu), 'Inline script references a browser-local storage API.');
|
|
40
|
+
add('clipboard_access', 'Clipboard access', 'inline_script', patternCount(inlineScripts, /\bnavigator\.clipboard\b/gu), 'Inline script references the browser clipboard API.');
|
|
41
|
+
add('dynamic_code_evaluation', 'Dynamic code evaluation', 'inline_script', patternCount(inlineScripts, /(?:\beval\s*\(|\bnew\s+Function\s*\()/gu), 'Inline script contains a dynamic code-evaluation API.');
|
|
42
|
+
add('browser_fingerprinting', 'Browser fingerprinting surfaces', 'inline_script', patternCount(inlineScripts, /(?:toDataURL\s*\(|getImageData\s*\(|AudioContext\b|WEBGL_debug_renderer_info\b)/gu), 'Inline script references an API commonly used in browser-fingerprint calculations.');
|
|
43
|
+
add('persistent_connection', 'Persistent client connection', 'inline_script', patternCount(inlineScripts, /\b(?:WebSocket|EventSource)\s*\(/gu), 'Inline script contains a persistent browser-connection constructor.');
|
|
44
|
+
const sourceTruncated = input.sourceTruncated === true;
|
|
45
|
+
const truncated = sourceTruncated
|
|
46
|
+
|| htmlAnalysis.inputLimitReached
|
|
47
|
+
|| htmlAnalysis.tagLimitReached
|
|
48
|
+
|| htmlAnalysis.scriptLimitReached
|
|
49
|
+
|| htmlAnalysis.inlineLimitReached;
|
|
50
|
+
const limitations = [
|
|
51
|
+
'Indicators come only from bounded static markup and retained inline-script prefixes; referenced scripts are not fetched and no code is executed.',
|
|
52
|
+
'An observed API or handler can be normal application behaviour and does not establish vulnerability, tracking, credential theft, maliciousness, reachability, or execution.',
|
|
53
|
+
'No script contents, script references, paths, queries, hashes, arbitrary attributes, or matched source fragments are retained.',
|
|
54
|
+
'No indicator means only that the bounded retained static evidence did not match this fixed catalogue.',
|
|
55
|
+
];
|
|
56
|
+
if (truncated)
|
|
57
|
+
limitations.push('The captured page or static script/token boundaries were reached, so behaviour indicators may be incomplete.');
|
|
58
|
+
return {
|
|
59
|
+
clientBehaviorProfileVersion: CLIENT_BEHAVIOR_PROFILE_VERSION,
|
|
60
|
+
...createObservation({
|
|
61
|
+
status: truncated ? 'partial' : 'success',
|
|
62
|
+
observedAt: input.observedAt,
|
|
63
|
+
scanMode: 'deep',
|
|
64
|
+
source: 'derived',
|
|
65
|
+
complete: !truncated,
|
|
66
|
+
truncated,
|
|
67
|
+
limitations,
|
|
68
|
+
diagnostics: {
|
|
69
|
+
indicatorsObserved: indicators.length,
|
|
70
|
+
scriptElementsExamined: htmlAnalysis.scripts.length,
|
|
71
|
+
inlineCharactersExamined: htmlAnalysis.inlineCharactersExamined,
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
scriptSummary: {
|
|
75
|
+
elementsObserved: htmlAnalysis.scripts.length,
|
|
76
|
+
referencedScripts: htmlAnalysis.scripts.filter((script) => script.reference !== null).length,
|
|
77
|
+
inlineScripts: htmlAnalysis.scripts.filter((script) => script.reference === null).length,
|
|
78
|
+
moduleScripts: htmlAnalysis.scripts.filter((script) => script.mediaType === 'module').length,
|
|
79
|
+
},
|
|
80
|
+
indicators,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export { CLIENT_BEHAVIOR_PROFILE_VERSION, MAX_CLIENT_BEHAVIOR_INDICATORS, analyzeClientBehavior, };
|