@slicedearth/whoisleuth-cli 1.34.0 → 1.36.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.
- package/README.md +2 -0
- package/cli/arguments.mjs +92 -0
- package/cli/artifact-verify.mjs +4 -0
- package/cli/bulk.mjs +1 -2
- package/cli/completion.mjs +19 -0
- package/cli/export-evidence.mjs +9 -2
- package/cli/formatters/terminal.mjs +7 -1
- package/cli/investigation-plan.mjs +112 -0
- package/cli/mail-review.mjs +47 -2
- package/cli/offline-evidence-review.mjs +141 -0
- package/cli/registry-support.mjs +1 -4
- package/cli/runner.mjs +109 -1
- package/docs/cli.md +102 -9
- package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
- package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
- package/frontend/src/lib/analysis/http-summary.js +2 -1
- package/frontend/src/lib/analysis/utils.js +7 -40
- package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
- package/lib/availability.mjs +30 -10
- package/lib/bulk-limits.mjs +13 -0
- package/lib/ct-response-bounds.mjs +9 -0
- package/lib/ct-search.mjs +66 -12
- package/lib/dns-delegation-health.mjs +56 -5
- package/lib/dnssec-evidence-validation.mjs +235 -0
- package/lib/domain-control-manifest.mjs +339 -0
- package/lib/domain-posture.mjs +2 -1
- package/lib/encrypted-dns-contract.mjs +81 -0
- package/lib/evidence-export.mjs +17 -3
- package/lib/evidence-report.mjs +3 -0
- package/lib/favicon.mjs +3 -8
- package/lib/html-signals.mjs +4 -0
- package/lib/http-evidence-bounds.mjs +4 -0
- package/lib/http-intelligence.mjs +2 -1
- package/lib/ip-prefix.mjs +54 -0
- package/lib/local-geoip-evidence.mjs +89 -0
- package/lib/outbound-identity.mjs +9 -0
- package/lib/outbound-request-bounds.mjs +9 -0
- package/lib/perceptual-hash-comparison.mjs +34 -0
- package/lib/perceptual-hash.mjs +3 -31
- package/lib/project-metadata.mjs +8 -0
- package/lib/provider-policy-admission.mjs +49 -0
- package/lib/rdap-search-workbench.mjs +157 -0
- package/lib/rdap.mjs +7 -2
- package/lib/registry-capabilities.mjs +31 -1
- package/lib/registry-capability-catalogue.mjs +11 -10
- package/lib/response-policy.mjs +81 -6
- package/lib/rpki-evidence.mjs +105 -0
- package/lib/safe-fetch.mjs +2 -1
- package/lib/semantic-version.mjs +54 -0
- package/lib/static-html-analysis.mjs +27 -0
- package/lib/threatfox-intelligence.mjs +10 -5
- package/lib/tlsa-evidence.mjs +194 -0
- package/lib/urlhaus-intelligence.mjs +10 -5
- package/lib/urlscan-intelligence.mjs +10 -5
- package/lib/website-security-posture.mjs +11 -4
- package/lib/whois-chain.mjs +14 -152
- package/lib/whois.mjs +2 -2
- package/package.json +2 -2
|
@@ -90,6 +90,36 @@ function registryAccessDiagnosticFor(value) {
|
|
|
90
90
|
authority: 'context_only',
|
|
91
91
|
};
|
|
92
92
|
}
|
|
93
|
+
function registryServiceAdmissionFor(value, service) {
|
|
94
|
+
const capability = registryCapabilityFor(value);
|
|
95
|
+
if (!capability || (service !== 'rdap' && service !== 'whois'))
|
|
96
|
+
return null;
|
|
97
|
+
if (service === 'rdap') {
|
|
98
|
+
const allowed = capability.rdapAccessProfile === 'iana-bootstrap';
|
|
99
|
+
return {
|
|
100
|
+
service,
|
|
101
|
+
state: allowed ? 'allowed' : 'unsupported',
|
|
102
|
+
allowed,
|
|
103
|
+
limitation: capability.limitation,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (capability.whoisAccessProfile === 'iana-referral') {
|
|
107
|
+
return {
|
|
108
|
+
service,
|
|
109
|
+
state: 'allowed',
|
|
110
|
+
allowed: true,
|
|
111
|
+
limitation: capability.limitation,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const permissionRequired = capability.whoisAccessProfile === 'registry-policy-restricted'
|
|
115
|
+
|| capability.whoisAccessProfile === 'source-ip-authorization-required';
|
|
116
|
+
return {
|
|
117
|
+
service,
|
|
118
|
+
state: permissionRequired ? 'permission_required' : 'unsupported',
|
|
119
|
+
allowed: false,
|
|
120
|
+
limitation: capability.limitation,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
93
123
|
function listRegistryCapabilities() {
|
|
94
124
|
return EXPLICIT_CAPABILITIES.map((capability) => cloneCapability(capability));
|
|
95
125
|
}
|
|
@@ -114,4 +144,4 @@ function registryCompatibilityMatrix() {
|
|
|
114
144
|
})))
|
|
115
145
|
.sort((left, right) => (left.suffixes[0] ?? '').localeCompare(right.suffixes[0] ?? ''));
|
|
116
146
|
}
|
|
117
|
-
export { REGISTRY_CAPABILITIES_VERSION, VERSION_26_NO_RDAP_SUFFIXES, VERSION_27_RDAP_ONLY_GENERIC_SUFFIXES, registryCapabilityFor, registryCompatibilityMatrix, listRegistryCapabilities, registryAccessDiagnosticFor, registryStandardsCoverageSnapshot, };
|
|
147
|
+
export { REGISTRY_CAPABILITIES_VERSION, VERSION_26_NO_RDAP_SUFFIXES, VERSION_27_RDAP_ONLY_GENERIC_SUFFIXES, registryCapabilityFor, registryCompatibilityMatrix, listRegistryCapabilities, registryAccessDiagnosticFor, registryServiceAdmissionFor, registryStandardsCoverageSnapshot, };
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
// Registry compatibility metadata. IANA bootstrap and referral discovery
|
|
2
2
|
// remain authoritative. Explicit query profiles may alter only the first
|
|
3
3
|
// referred registry query after a fixture-backed adapter is integrated.
|
|
4
|
-
const REGISTRY_CAPABILITIES_VERSION =
|
|
4
|
+
const REGISTRY_CAPABILITIES_VERSION = 28;
|
|
5
5
|
const MAX_CAPABILITY_INPUT_LENGTH = 253;
|
|
6
6
|
const REGISTRY_STANDARDS_COVERAGE_SNAPSHOT = Object.freeze({
|
|
7
7
|
schema: 'whoisleuth.registry-standards-coverage',
|
|
8
8
|
version: 1,
|
|
9
|
-
verifiedAt: '2026-08-
|
|
9
|
+
verifiedAt: '2026-08-03',
|
|
10
10
|
sources: Object.freeze({
|
|
11
|
-
rootZoneVersion: '
|
|
12
|
-
rootZoneLastUpdatedAt: '2026-
|
|
11
|
+
rootZoneVersion: '2026080200',
|
|
12
|
+
rootZoneLastUpdatedAt: '2026-08-02T07:07:01.000Z',
|
|
13
|
+
rootZoneTldSetSha256: 'aa0a75a9860b2cba07d7fe8172f4546d981be3674bf6764fb0d5f39a45940d25',
|
|
13
14
|
rdapBootstrapPublication: '2026-07-23T02:00:03.000Z',
|
|
14
15
|
rdapBootstrapVersion: '1.0',
|
|
15
16
|
urls: Object.freeze([
|
|
@@ -158,7 +159,6 @@ const DEFAULT_CAPABILITY = freezeCapability({
|
|
|
158
159
|
whoisQueryScope: 'first-referral',
|
|
159
160
|
whoisEncodingProfile: 'utf-8',
|
|
160
161
|
whoisParserProfile: 'generic-colon',
|
|
161
|
-
fallbackProfile: null,
|
|
162
162
|
whoisAccessProfile: 'iana-referral',
|
|
163
163
|
rdapAccessProfile: 'iana-bootstrap',
|
|
164
164
|
coverageState: 'discovery_only',
|
|
@@ -661,10 +661,12 @@ const EXPLICIT_CAPABILITY_SEEDS = [
|
|
|
661
661
|
],
|
|
662
662
|
},
|
|
663
663
|
{
|
|
664
|
-
id: 'gt-
|
|
665
|
-
whoisParserProfile: 'generic-colon',
|
|
666
|
-
|
|
667
|
-
|
|
664
|
+
id: 'gt-no-iana-service', suffixes: ['gt'], registryClass: 'country-code',
|
|
665
|
+
whoisParserProfile: 'generic-colon', fixtureScenarios: [],
|
|
666
|
+
coverageState: 'access_documented', whoisAccessProfile: 'no-iana-service',
|
|
667
|
+
rdapAccessProfile: 'no-iana-service', verificationFiles: [],
|
|
668
|
+
documentationUrls: ['https://www.iana.org/domains/root/db/gt.html'],
|
|
669
|
+
limitation: 'IANA publishes no RDAP service or domain WHOIS service for this suffix. WHOISleuth does not automate the registry website.',
|
|
668
670
|
},
|
|
669
671
|
{
|
|
670
672
|
id: 'carnet-icann-colon', suffixes: ['hr'], registryClass: 'country-code',
|
|
@@ -1179,7 +1181,6 @@ const EXPLICIT_CAPABILITIES = EXPLICIT_CAPABILITY_SEEDS.map((entry) => {
|
|
|
1179
1181
|
const rdapAccessProfile = resolvedSeedRdapAccessProfile(entry);
|
|
1180
1182
|
return freezeCapability({
|
|
1181
1183
|
...entry,
|
|
1182
|
-
fallbackProfile: entry.fallbackProfile || null,
|
|
1183
1184
|
rdapDiscovery: 'iana-bootstrap',
|
|
1184
1185
|
whoisDiscovery: 'iana-referral',
|
|
1185
1186
|
whoisQueryProfile: entry.whoisQueryProfile || 'plain-domain',
|
package/lib/response-policy.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Privacy-minimized interpretation of selected response policies. Header and
|
|
2
2
|
// cookie values are read transiently from the already-selected homepage
|
|
3
3
|
// response and reduced to fixed signal identifiers plus bounded counts.
|
|
4
|
-
export const RESPONSE_POLICY_VERSION =
|
|
4
|
+
export const RESPONSE_POLICY_VERSION = 2;
|
|
5
5
|
export const MAX_RESPONSE_POLICY_HEADER_BYTES = 8 * 1024;
|
|
6
|
+
export const MAX_CSP_META_POLICIES = 4;
|
|
6
7
|
export const MAX_RESPONSE_POLICY_DIRECTIVES = 64;
|
|
7
8
|
export const MAX_RESPONSE_POLICY_TOKENS = 128;
|
|
8
9
|
export const MAX_RESPONSE_COOKIES = 32;
|
|
@@ -56,7 +57,7 @@ function unavailableHeaderState(header) {
|
|
|
56
57
|
}
|
|
57
58
|
function analyzeCsp(header, signals) {
|
|
58
59
|
if (header.state !== 'present' || !header.value) {
|
|
59
|
-
return unavailableHeaderState(header);
|
|
60
|
+
return { state: unavailableHeaderState(header), inlineControl: 'unknown' };
|
|
60
61
|
}
|
|
61
62
|
const directives = new Map();
|
|
62
63
|
let tokenCount = 0;
|
|
@@ -68,7 +69,7 @@ function analyzeCsp(header, signals) {
|
|
|
68
69
|
const [rawName = '', ...rawTokens] = normalized.split(/\s+/u);
|
|
69
70
|
const name = rawName.toLowerCase();
|
|
70
71
|
if (!DIRECTIVE_NAME_RE.test(name))
|
|
71
|
-
return 'malformed';
|
|
72
|
+
return { state: 'malformed', inlineControl: 'unknown' };
|
|
72
73
|
if (directives.has(name))
|
|
73
74
|
continue;
|
|
74
75
|
if (directives.size >= MAX_RESPONSE_POLICY_DIRECTIVES) {
|
|
@@ -83,7 +84,7 @@ function analyzeCsp(header, signals) {
|
|
|
83
84
|
directives.set(name, tokens);
|
|
84
85
|
}
|
|
85
86
|
if (!directives.size)
|
|
86
|
-
return 'malformed';
|
|
87
|
+
return { state: 'malformed', inlineControl: 'unknown' };
|
|
87
88
|
const defaultSources = directives.get('default-src');
|
|
88
89
|
const scriptSources = directives.get('script-src') || defaultSources;
|
|
89
90
|
if (!defaultSources)
|
|
@@ -92,6 +93,7 @@ function analyzeCsp(header, signals) {
|
|
|
92
93
|
addSignal(signals, 'csp_base_uri_missing');
|
|
93
94
|
if (!directives.has('object-src') && !defaultSources)
|
|
94
95
|
addSignal(signals, 'csp_object_source_unbounded');
|
|
96
|
+
let inlineControl = scriptSources ? 'restricted' : 'uncontrolled';
|
|
95
97
|
if (scriptSources) {
|
|
96
98
|
if (scriptSources.some((token) => ['*', 'http:', 'https:', 'data:', 'blob:'].includes(token))) {
|
|
97
99
|
addSignal(signals, 'csp_permissive_script_source');
|
|
@@ -101,9 +103,79 @@ function analyzeCsp(header, signals) {
|
|
|
101
103
|
if (scriptSources.includes("'unsafe-inline'")
|
|
102
104
|
&& !scriptSources.some((token) => CSP_NONCE_OR_HASH_RE.test(token))) {
|
|
103
105
|
addSignal(signals, 'csp_unsafe_inline');
|
|
106
|
+
inlineControl = 'unqualified';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
state: partial ? 'partial' : 'parsed',
|
|
111
|
+
inlineControl: partial ? 'unknown' : inlineControl,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
export function analyzeCspMetaPolicies(value, sourceTruncated = false) {
|
|
115
|
+
const candidates = Array.isArray(value) ? value : [];
|
|
116
|
+
const policies = candidates.slice(0, MAX_CSP_META_POLICIES);
|
|
117
|
+
let policiesParsed = 0;
|
|
118
|
+
let inlineScriptConstrained = false;
|
|
119
|
+
let truncated = sourceTruncated || candidates.length > MAX_CSP_META_POLICIES;
|
|
120
|
+
for (const rawPolicy of policies) {
|
|
121
|
+
const policy = rawPolicy && typeof rawPolicy === 'object' && !Array.isArray(rawPolicy)
|
|
122
|
+
? rawPolicy
|
|
123
|
+
: null;
|
|
124
|
+
if (!policy || typeof policy.content !== 'string') {
|
|
125
|
+
truncated = true;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const policyContent = policy.content;
|
|
129
|
+
const content = readHeader({ get: () => policyContent }, 'content-security-policy');
|
|
130
|
+
const analysis = analyzeCsp(content, []);
|
|
131
|
+
if (analysis.state !== 'parsed') {
|
|
132
|
+
truncated = true;
|
|
133
|
+
continue;
|
|
104
134
|
}
|
|
135
|
+
policiesParsed += 1;
|
|
136
|
+
if (policy.beforeScript === true && analysis.inlineControl === 'restricted') {
|
|
137
|
+
inlineScriptConstrained = true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
cspMetaPolicyVersion: 1,
|
|
142
|
+
policiesObserved: boundedCount(candidates.length, MAX_CSP_META_POLICIES),
|
|
143
|
+
policiesParsed,
|
|
144
|
+
inlineScriptConstrained,
|
|
145
|
+
truncated,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
export function qualifyResponsePolicyWithCspMeta(value, meta) {
|
|
149
|
+
if (!value || !meta || meta.cspMetaPolicyVersion !== 1)
|
|
150
|
+
return value;
|
|
151
|
+
const constrained = meta.inlineScriptConstrained
|
|
152
|
+
&& value.signals.some((signal) => signal.id === 'csp_unsafe_inline');
|
|
153
|
+
const signals = constrained
|
|
154
|
+
? [
|
|
155
|
+
...value.signals.filter((signal) => signal.id !== 'csp_unsafe_inline'),
|
|
156
|
+
{ id: 'csp_inline_constrained_by_meta' },
|
|
157
|
+
]
|
|
158
|
+
: [...value.signals];
|
|
159
|
+
const limitations = [...value.limitations];
|
|
160
|
+
if (meta.policiesObserved > 0) {
|
|
161
|
+
limitations.push(constrained
|
|
162
|
+
? 'A bounded CSP meta policy observed before page scripts further constrained inline script allowed by the response header.'
|
|
163
|
+
: 'CSP meta policy observations did not qualify the selected response-header finding; policies after scripts, malformed policies, and capped policies remain non-authoritative for this comparison.');
|
|
105
164
|
}
|
|
106
|
-
|
|
165
|
+
if (meta.truncated)
|
|
166
|
+
limitations.push('CSP meta policy analysis reached a configured count, position, or byte boundary.');
|
|
167
|
+
return {
|
|
168
|
+
...value,
|
|
169
|
+
signals: signals.slice(0, 16),
|
|
170
|
+
diagnostics: {
|
|
171
|
+
...value.diagnostics,
|
|
172
|
+
signalCount: Math.min(signals.length, 16),
|
|
173
|
+
cspMetaPoliciesObserved: boundedCount(meta.policiesObserved, MAX_CSP_META_POLICIES),
|
|
174
|
+
cspMetaPoliciesParsed: boundedCount(meta.policiesParsed, MAX_CSP_META_POLICIES),
|
|
175
|
+
cspMetaPoliciesTruncated: meta.truncated,
|
|
176
|
+
},
|
|
177
|
+
limitations,
|
|
178
|
+
};
|
|
107
179
|
}
|
|
108
180
|
function analyzeHsts(header, signals) {
|
|
109
181
|
if (header.state !== 'present' || !header.value) {
|
|
@@ -221,7 +293,7 @@ function analyzeCookies(headers, signals) {
|
|
|
221
293
|
}
|
|
222
294
|
export function analyzeResponsePolicyHeaders(headers) {
|
|
223
295
|
const signals = [];
|
|
224
|
-
const contentSecurityPolicy = analyzeCsp(readHeader(headers, 'content-security-policy'), signals);
|
|
296
|
+
const contentSecurityPolicy = analyzeCsp(readHeader(headers, 'content-security-policy'), signals).state;
|
|
225
297
|
const strictTransportSecurity = analyzeHsts(readHeader(headers, 'strict-transport-security'), signals);
|
|
226
298
|
const referrerPolicy = analyzeReferrerPolicy(readHeader(headers, 'referrer-policy'), signals);
|
|
227
299
|
const cookies = analyzeCookies(headers, signals);
|
|
@@ -250,6 +322,9 @@ export function analyzeResponsePolicyHeaders(headers) {
|
|
|
250
322
|
signalCount: Math.min(signals.length, 16),
|
|
251
323
|
cookieCount: boundedCount(cookies.count, MAX_RESPONSE_COOKIES),
|
|
252
324
|
cookiesTruncated: cookies.truncated,
|
|
325
|
+
cspMetaPoliciesObserved: 0,
|
|
326
|
+
cspMetaPoliciesParsed: 0,
|
|
327
|
+
cspMetaPoliciesTruncated: false,
|
|
253
328
|
},
|
|
254
329
|
limitations,
|
|
255
330
|
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { parseIpPrefix, prefixContains } from "./ip-prefix.mjs";
|
|
2
|
+
const RPKI_EVIDENCE_SCHEMA = 'whoisleuth.rpki-route-review';
|
|
3
|
+
const RPKI_EVIDENCE_VERSION = 1;
|
|
4
|
+
const MAX_RPKI_RECORDS = 100_000;
|
|
5
|
+
const MAX_RPKI_MATCHES = 50;
|
|
6
|
+
function record(value) {
|
|
7
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
8
|
+
? value
|
|
9
|
+
: null;
|
|
10
|
+
}
|
|
11
|
+
function normalizeAsn(value) {
|
|
12
|
+
const text = typeof value === 'string' ? value.trim().replace(/^AS/iu, '') : value;
|
|
13
|
+
const number = Number(text);
|
|
14
|
+
return Number.isInteger(number) && number >= 0 && number <= 4_294_967_295 ? number : null;
|
|
15
|
+
}
|
|
16
|
+
function normalizeAuthorization(value) {
|
|
17
|
+
const source = record(value);
|
|
18
|
+
if (!source)
|
|
19
|
+
return null;
|
|
20
|
+
const prefix = typeof source.prefix === 'string' ? source.prefix : typeof source.route === 'string' ? source.route : '';
|
|
21
|
+
const parsedPrefix = parseIpPrefix(prefix);
|
|
22
|
+
const asn = normalizeAsn(source.asn ?? source.asID ?? source.origin);
|
|
23
|
+
const maxLengthValue = source.maxLength ?? source.max_length ?? parsedPrefix?.length;
|
|
24
|
+
const maxLength = Number(maxLengthValue);
|
|
25
|
+
const maximum = parsedPrefix?.family === 4 ? 32 : 128;
|
|
26
|
+
if (!parsedPrefix || asn === null || !Number.isInteger(maxLength)
|
|
27
|
+
|| maxLength < parsedPrefix.length || maxLength > maximum)
|
|
28
|
+
return null;
|
|
29
|
+
return Object.freeze({ prefix: `${parsedPrefix.address}/${parsedPrefix.length}`, parsedPrefix, maxLength, asn });
|
|
30
|
+
}
|
|
31
|
+
function authorizationRows(value) {
|
|
32
|
+
if (Array.isArray(value))
|
|
33
|
+
return value;
|
|
34
|
+
const source = record(value);
|
|
35
|
+
if (!source)
|
|
36
|
+
return null;
|
|
37
|
+
if (Array.isArray(source.roas))
|
|
38
|
+
return source.roas;
|
|
39
|
+
if (Array.isArray(source.vrps))
|
|
40
|
+
return source.vrps;
|
|
41
|
+
if (Array.isArray(source.validated_route_origins))
|
|
42
|
+
return source.validated_route_origins;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
function reviewRpkiRoute(input) {
|
|
46
|
+
const route = parseIpPrefix(input.routePrefix);
|
|
47
|
+
const originAsn = normalizeAsn(input.originAsn);
|
|
48
|
+
const rows = authorizationRows(input.authorizations);
|
|
49
|
+
const baseLimitations = Object.freeze([
|
|
50
|
+
'This offline review evaluates an explicitly supplied route prefix and origin ASN against an analyst-supplied VRP snapshot.',
|
|
51
|
+
'It does not collect BGP announcements, establish route ownership, or prove that the snapshot was current or complete.',
|
|
52
|
+
]);
|
|
53
|
+
if (!route || originAsn === null || !rows) {
|
|
54
|
+
return Object.freeze({
|
|
55
|
+
schema: RPKI_EVIDENCE_SCHEMA,
|
|
56
|
+
version: RPKI_EVIDENCE_VERSION,
|
|
57
|
+
state: 'invalid_input',
|
|
58
|
+
routePrefix: null,
|
|
59
|
+
originAsn,
|
|
60
|
+
coveringAuthorizationCount: 0,
|
|
61
|
+
matchingAuthorizationCount: 0,
|
|
62
|
+
rejectedCount: 0,
|
|
63
|
+
truncated: false,
|
|
64
|
+
matches: Object.freeze([]),
|
|
65
|
+
limitations: baseLimitations,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const truncated = rows.length > MAX_RPKI_RECORDS;
|
|
69
|
+
const normalized = rows.slice(0, MAX_RPKI_RECORDS).map(normalizeAuthorization);
|
|
70
|
+
const rejectedCount = normalized.filter((item) => item === null).length + Math.max(0, rows.length - MAX_RPKI_RECORDS);
|
|
71
|
+
const authorizations = normalized.filter((item) => item !== null);
|
|
72
|
+
const covering = authorizations.filter((item) => prefixContains(item.parsedPrefix, route));
|
|
73
|
+
const matches = covering
|
|
74
|
+
.sort((left, right) => right.parsedPrefix.length - left.parsedPrefix.length || left.asn - right.asn)
|
|
75
|
+
.slice(0, MAX_RPKI_MATCHES)
|
|
76
|
+
.map((item) => Object.freeze({
|
|
77
|
+
prefix: item.prefix,
|
|
78
|
+
maxLength: item.maxLength,
|
|
79
|
+
asn: item.asn,
|
|
80
|
+
state: item.asn !== originAsn ? 'asn_mismatch'
|
|
81
|
+
: route.length > item.maxLength ? 'max_length_exceeded'
|
|
82
|
+
: 'valid',
|
|
83
|
+
}));
|
|
84
|
+
const matchingAuthorizationCount = matches.filter((item) => item.state === 'valid').length;
|
|
85
|
+
const state = covering.length === 0
|
|
86
|
+
? 'not_found'
|
|
87
|
+
: matchingAuthorizationCount > 0
|
|
88
|
+
? truncated || rejectedCount > 0 || covering.length > MAX_RPKI_MATCHES ? 'partial'
|
|
89
|
+
: 'valid'
|
|
90
|
+
: 'invalid';
|
|
91
|
+
return Object.freeze({
|
|
92
|
+
schema: RPKI_EVIDENCE_SCHEMA,
|
|
93
|
+
version: RPKI_EVIDENCE_VERSION,
|
|
94
|
+
state,
|
|
95
|
+
routePrefix: `${route.address}/${route.length}`,
|
|
96
|
+
originAsn,
|
|
97
|
+
coveringAuthorizationCount: covering.length,
|
|
98
|
+
matchingAuthorizationCount,
|
|
99
|
+
rejectedCount,
|
|
100
|
+
truncated,
|
|
101
|
+
matches: Object.freeze(matches),
|
|
102
|
+
limitations: baseLimitations,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
export { MAX_RPKI_RECORDS, RPKI_EVIDENCE_SCHEMA, RPKI_EVIDENCE_VERSION, reviewRpkiRoute, };
|
package/lib/safe-fetch.mjs
CHANGED
|
@@ -22,7 +22,8 @@ import { promises as dns } from 'node:dns';
|
|
|
22
22
|
import * as net from 'node:net';
|
|
23
23
|
import * as crypto from 'node:crypto';
|
|
24
24
|
import { Agent, fetch as undiciFetch } from 'undici';
|
|
25
|
-
|
|
25
|
+
import { MAX_OUTBOUND_REDIRECTS } from "./outbound-request-bounds.mjs";
|
|
26
|
+
const MAX_REDIRECTS = MAX_OUTBOUND_REDIRECTS;
|
|
26
27
|
const MAX_SAFE_FETCH_URL_LENGTH = 4096;
|
|
27
28
|
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
28
29
|
async function defaultSafeFetchRequest(url, options) {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Dependency-free semantic-version validation shared by release tooling,
|
|
2
|
+
// runtime identity, and the frontend build. Callers provide a bounded label so
|
|
3
|
+
// errors retain their local context without reimplementing SemVer parsing.
|
|
4
|
+
export const MAX_SEMANTIC_VERSION_LENGTH = 128;
|
|
5
|
+
function fail(label, detail) {
|
|
6
|
+
throw new TypeError(`${label} ${detail}`);
|
|
7
|
+
}
|
|
8
|
+
function validateIdentifierList(value, label, kind, forbidNumericLeadingZero) {
|
|
9
|
+
const identifiers = value.split('.');
|
|
10
|
+
if (identifiers.some((identifier) => identifier.length === 0)) {
|
|
11
|
+
fail(label, `${kind} contains an empty identifier.`);
|
|
12
|
+
}
|
|
13
|
+
for (const identifier of identifiers) {
|
|
14
|
+
if (!/^[0-9A-Za-z-]+$/u.test(identifier)) {
|
|
15
|
+
fail(label, `${kind} contains an invalid identifier.`);
|
|
16
|
+
}
|
|
17
|
+
if (forbidNumericLeadingZero && /^[0-9]+$/u.test(identifier) && identifier.length > 1 && identifier.startsWith('0')) {
|
|
18
|
+
fail(label, `${kind} numeric identifiers must not contain leading zeroes.`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function normalizeBoundedSemanticVersion(value, label = 'Semantic') {
|
|
23
|
+
if (typeof value !== 'string'
|
|
24
|
+
|| value.length === 0
|
|
25
|
+
|| value.length > MAX_SEMANTIC_VERSION_LENGTH
|
|
26
|
+
|| value.trim() !== value) {
|
|
27
|
+
fail(label, 'version must be a bounded semantic-version string.');
|
|
28
|
+
}
|
|
29
|
+
const buildParts = value.split('+');
|
|
30
|
+
if (buildParts.length > 2)
|
|
31
|
+
fail(label, 'version contains more than one build-metadata separator.');
|
|
32
|
+
const [precedence, buildMetadata] = buildParts;
|
|
33
|
+
if (!precedence)
|
|
34
|
+
fail(label, 'version must include numeric precedence.');
|
|
35
|
+
const prereleaseSeparator = precedence.indexOf('-');
|
|
36
|
+
const core = prereleaseSeparator === -1 ? precedence : precedence.slice(0, prereleaseSeparator);
|
|
37
|
+
const prerelease = prereleaseSeparator === -1 ? undefined : precedence.slice(prereleaseSeparator + 1);
|
|
38
|
+
const coreParts = core.split('.');
|
|
39
|
+
if (coreParts.length !== 3 || coreParts.some((part) => !/^(?:0|[1-9][0-9]*)$/u.test(part))) {
|
|
40
|
+
fail(label, 'version must contain major, minor, and patch numbers without leading zeroes.');
|
|
41
|
+
}
|
|
42
|
+
if (prerelease !== undefined)
|
|
43
|
+
validateIdentifierList(prerelease, label, 'prerelease', true);
|
|
44
|
+
if (buildMetadata !== undefined)
|
|
45
|
+
validateIdentifierList(buildMetadata, label, 'build metadata', false);
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
export function normalizeBoundedStableSemanticVersion(value, label = 'Stable semantic') {
|
|
49
|
+
const version = normalizeBoundedSemanticVersion(value, label);
|
|
50
|
+
if (version.includes('-') || version.includes('+')) {
|
|
51
|
+
fail(label, 'version must not include prerelease or build identifiers.');
|
|
52
|
+
}
|
|
53
|
+
return version;
|
|
54
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// so deeply nested hostile markup cannot create an unbounded element tree.
|
|
4
4
|
// Only normalized start-tag markup and capped script indicators are retained.
|
|
5
5
|
import { Tokenizer, TokenizerMode } from 'parse5';
|
|
6
|
+
import { MAX_CSP_META_POLICIES, MAX_RESPONSE_POLICY_HEADER_BYTES, } from "./response-policy.mjs";
|
|
6
7
|
const MAX_STATIC_HTML_CHARS = 300_000;
|
|
7
8
|
const MAX_STATIC_HTML_TAGS = 8_192;
|
|
8
9
|
const MAX_STATIC_STRUCTURE_TOKENS = 4_096;
|
|
@@ -192,6 +193,7 @@ function analyzeStaticHtml(value, options = {}) {
|
|
|
192
193
|
const markup = [];
|
|
193
194
|
const structureTokens = [];
|
|
194
195
|
const scripts = [];
|
|
196
|
+
const cspMetaPolicies = [];
|
|
195
197
|
const forms = {
|
|
196
198
|
formsObserved: 0,
|
|
197
199
|
inputsObserved: 0,
|
|
@@ -209,6 +211,9 @@ function analyzeStaticHtml(value, options = {}) {
|
|
|
209
211
|
let structureLimitReached = false;
|
|
210
212
|
let scriptLimitReached = false;
|
|
211
213
|
let inlineLimitReached = false;
|
|
214
|
+
let cspMetaLimitReached = false;
|
|
215
|
+
let insideExplicitHead = false;
|
|
216
|
+
let scriptElementSeen = false;
|
|
212
217
|
function appendInlineScript(chars) {
|
|
213
218
|
if (!activeInlineScript || !chars)
|
|
214
219
|
return;
|
|
@@ -242,6 +247,23 @@ function analyzeStaticHtml(value, options = {}) {
|
|
|
242
247
|
}
|
|
243
248
|
tagsExamined += 1;
|
|
244
249
|
appendStructureToken(token.selfClosing || VOID_TAGS.has(tagName) ? `${tagName}/` : tagName);
|
|
250
|
+
if (tagName === 'head')
|
|
251
|
+
insideExplicitHead = true;
|
|
252
|
+
if (tagName === 'meta' && insideExplicitHead) {
|
|
253
|
+
const httpEquiv = attributeValue(token.attrs, 'http-equiv', 64);
|
|
254
|
+
if (httpEquiv.value === 'content-security-policy') {
|
|
255
|
+
const content = attributeValue(token.attrs, 'content', MAX_RESPONSE_POLICY_HEADER_BYTES);
|
|
256
|
+
if (content.value === null || !content.value) {
|
|
257
|
+
cspMetaLimitReached = cspMetaLimitReached || content.present || content.truncated;
|
|
258
|
+
}
|
|
259
|
+
else if (cspMetaPolicies.length >= MAX_CSP_META_POLICIES) {
|
|
260
|
+
cspMetaLimitReached = true;
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
cspMetaPolicies.push({ content: content.value, beforeScript: !scriptElementSeen });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
245
267
|
if (markup.length < MAX_TECHNOLOGY_TAGS) {
|
|
246
268
|
const serialized = serializedStartTag(tagName, token.attrs);
|
|
247
269
|
if (serialized.limitReached)
|
|
@@ -287,6 +309,7 @@ function analyzeStaticHtml(value, options = {}) {
|
|
|
287
309
|
activeInlineScript = null;
|
|
288
310
|
return;
|
|
289
311
|
}
|
|
312
|
+
scriptElementSeen = true;
|
|
290
313
|
if (scripts.length >= MAX_SCRIPT_ELEMENTS) {
|
|
291
314
|
scriptLimitReached = true;
|
|
292
315
|
activeInlineScript = null;
|
|
@@ -305,6 +328,8 @@ function analyzeStaticHtml(value, options = {}) {
|
|
|
305
328
|
const tagName = token.tagName.toLowerCase();
|
|
306
329
|
if (tagName === 'script')
|
|
307
330
|
activeInlineScript = null;
|
|
331
|
+
if (tagName === 'head')
|
|
332
|
+
insideExplicitHead = false;
|
|
308
333
|
if (!VOID_TAGS.has(tagName))
|
|
309
334
|
appendStructureToken(`/${tagName}`);
|
|
310
335
|
},
|
|
@@ -327,6 +352,8 @@ function analyzeStaticHtml(value, options = {}) {
|
|
|
327
352
|
markup: markup.join('\n'),
|
|
328
353
|
structureTokens,
|
|
329
354
|
scripts,
|
|
355
|
+
cspMetaPolicies,
|
|
356
|
+
cspMetaLimitReached,
|
|
330
357
|
forms,
|
|
331
358
|
inputLimitReached,
|
|
332
359
|
tagLimitReached,
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// sample. The adapter is disabled unless explicitly enabled and credentialed.
|
|
4
4
|
import { abusechAuthKey } from "./abusech-auth.mjs";
|
|
5
5
|
import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
|
|
6
|
+
import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
|
|
7
|
+
import { providerPolicyAdmission } from "./provider-policy-admission.mjs";
|
|
6
8
|
import { createThreatIntelligenceResult, defineThreatIntelligenceProvider, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-contract.mjs";
|
|
7
9
|
const THREATFOX_SEARCH_ENDPOINT = 'https://threatfox-api.abuse.ch/api/v1/';
|
|
8
10
|
const THREATFOX_MAX_RESULTS = 20;
|
|
@@ -46,15 +48,18 @@ function threatfoxConfiguration(env = process.env) {
|
|
|
46
48
|
const source = env && typeof env === 'object' ? env : {};
|
|
47
49
|
const enabled = enabledValue(source.WHOISLEUTH_ENABLE_THREATFOX);
|
|
48
50
|
const authKey = abusechAuthKey(source);
|
|
51
|
+
const policy = providerPolicyAdmission(THREATFOX_PROVIDER.terms, source);
|
|
49
52
|
return {
|
|
50
53
|
enabled,
|
|
51
|
-
configured: enabled && authKey !== null,
|
|
54
|
+
configured: enabled && policy.allowed && authKey !== null,
|
|
52
55
|
authKey: enabled ? authKey : null,
|
|
53
56
|
reason: !enabled
|
|
54
57
|
? 'Malware-IOC intelligence is not enabled for this deployment.'
|
|
55
|
-
:
|
|
56
|
-
?
|
|
57
|
-
:
|
|
58
|
+
: !policy.allowed
|
|
59
|
+
? policy.reason
|
|
60
|
+
: authKey
|
|
61
|
+
? null
|
|
62
|
+
: 'Malware-IOC intelligence is enabled but its API credential is unavailable or malformed.',
|
|
58
63
|
};
|
|
59
64
|
}
|
|
60
65
|
function boundedText(value, maxLength) {
|
|
@@ -192,7 +197,7 @@ function createThreatfoxIntelligenceAdapter(dependencies = {}) {
|
|
|
192
197
|
accept: 'application/json',
|
|
193
198
|
'auth-key': configuration.authKey,
|
|
194
199
|
'content-type': 'application/json',
|
|
195
|
-
|
|
200
|
+
...whoisleuthRequestHeaders(),
|
|
196
201
|
},
|
|
197
202
|
body,
|
|
198
203
|
signal: AbortSignal.timeout(THREATFOX_PROVIDER.limits.timeoutMs),
|