@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.
Files changed (58) hide show
  1. package/README.md +2 -0
  2. package/cli/arguments.mjs +92 -0
  3. package/cli/artifact-verify.mjs +4 -0
  4. package/cli/bulk.mjs +1 -2
  5. package/cli/completion.mjs +19 -0
  6. package/cli/export-evidence.mjs +9 -2
  7. package/cli/formatters/terminal.mjs +7 -1
  8. package/cli/investigation-plan.mjs +112 -0
  9. package/cli/mail-review.mjs +47 -2
  10. package/cli/offline-evidence-review.mjs +141 -0
  11. package/cli/registry-support.mjs +1 -4
  12. package/cli/runner.mjs +109 -1
  13. package/docs/cli.md +102 -9
  14. package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
  15. package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
  16. package/frontend/src/lib/analysis/http-summary.js +2 -1
  17. package/frontend/src/lib/analysis/utils.js +7 -40
  18. package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
  19. package/lib/availability.mjs +30 -10
  20. package/lib/bulk-limits.mjs +13 -0
  21. package/lib/ct-response-bounds.mjs +9 -0
  22. package/lib/ct-search.mjs +66 -12
  23. package/lib/dns-delegation-health.mjs +56 -5
  24. package/lib/dnssec-evidence-validation.mjs +235 -0
  25. package/lib/domain-control-manifest.mjs +339 -0
  26. package/lib/domain-posture.mjs +2 -1
  27. package/lib/encrypted-dns-contract.mjs +81 -0
  28. package/lib/evidence-export.mjs +17 -3
  29. package/lib/evidence-report.mjs +3 -0
  30. package/lib/favicon.mjs +3 -8
  31. package/lib/html-signals.mjs +4 -0
  32. package/lib/http-evidence-bounds.mjs +4 -0
  33. package/lib/http-intelligence.mjs +2 -1
  34. package/lib/ip-prefix.mjs +54 -0
  35. package/lib/local-geoip-evidence.mjs +89 -0
  36. package/lib/outbound-identity.mjs +9 -0
  37. package/lib/outbound-request-bounds.mjs +9 -0
  38. package/lib/perceptual-hash-comparison.mjs +34 -0
  39. package/lib/perceptual-hash.mjs +3 -31
  40. package/lib/project-metadata.mjs +8 -0
  41. package/lib/provider-policy-admission.mjs +49 -0
  42. package/lib/rdap-search-workbench.mjs +157 -0
  43. package/lib/rdap.mjs +7 -2
  44. package/lib/registry-capabilities.mjs +31 -1
  45. package/lib/registry-capability-catalogue.mjs +11 -10
  46. package/lib/response-policy.mjs +81 -6
  47. package/lib/rpki-evidence.mjs +105 -0
  48. package/lib/safe-fetch.mjs +2 -1
  49. package/lib/semantic-version.mjs +54 -0
  50. package/lib/static-html-analysis.mjs +27 -0
  51. package/lib/threatfox-intelligence.mjs +10 -5
  52. package/lib/tlsa-evidence.mjs +194 -0
  53. package/lib/urlhaus-intelligence.mjs +10 -5
  54. package/lib/urlscan-intelligence.mjs +10 -5
  55. package/lib/website-security-posture.mjs +11 -4
  56. package/lib/whois-chain.mjs +14 -152
  57. package/lib/whois.mjs +2 -2
  58. 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 = 27;
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-01',
9
+ verifiedAt: '2026-08-03',
10
10
  sources: Object.freeze({
11
- rootZoneVersion: '2026073100',
12
- rootZoneLastUpdatedAt: '2026-07-31T07:07:01.000Z',
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-registry-web', suffixes: ['gt'], registryClass: 'country-code',
665
- whoisParserProfile: 'generic-colon', fallbackProfile: 'gt-registry-web',
666
- fixtureScenarios: ['registered', 'not_found', 'unavailable'],
667
- verificationFiles: ['test/whois-gt-fallback.test.mts'],
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',
@@ -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 = 1;
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
- return partial ? 'partial' : 'parsed';
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, };
@@ -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
- const MAX_REDIRECTS = 5;
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
- : authKey
56
- ? null
57
- : 'Malware-IOC intelligence is enabled but its API credential is unavailable or malformed.',
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
- 'user-agent': 'WHOISleuth/1.0',
200
+ ...whoisleuthRequestHeaders(),
196
201
  },
197
202
  body,
198
203
  signal: AbortSignal.timeout(THREATFOX_PROVIDER.limits.timeoutMs),