@slicedearth/whoisleuth-cli 1.34.0 → 1.39.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 +19 -2
- package/bin/whoisleuth.mjs +41 -24
- package/cli/arguments.mjs +326 -3
- package/cli/artifact-verify.mjs +60 -14
- package/cli/bulk.mjs +1 -2
- package/cli/compare.mjs +1 -1
- package/cli/completion.mjs +36 -2
- package/cli/doctor.mjs +11 -5
- package/cli/evidence-command-arguments.mjs +1 -1
- package/cli/evidence-command-runner.mjs +2 -2
- package/cli/evidence-signing.mjs +4 -4
- package/cli/export-evidence.mjs +9 -2
- package/cli/formatters/html.mjs +1 -1
- package/cli/formatters/json.mjs +13 -1
- package/cli/formatters/terminal.mjs +14 -4
- package/cli/investigation-plan.mjs +112 -0
- package/cli/local-mmdb-review.mjs +76 -0
- package/cli/lookalike-calibration.mjs +121 -0
- package/cli/lookup-command-runner.mjs +4 -1
- package/cli/lookup-plan.mjs +3 -3
- package/cli/lookup-reconcile.mjs +141 -0
- package/cli/lookup-timeline.mjs +1 -1
- package/cli/mail-review.mjs +48 -3
- package/cli/offline-evidence-review.mjs +159 -0
- package/cli/registry-doctor.mjs +125 -0
- package/cli/registry-support.mjs +1 -4
- package/cli/risk-calibration.mjs +117 -9
- package/cli/runner.mjs +342 -15
- package/cli/saved-lookup.mjs +2 -2
- package/cli/sharing-review.mjs +202 -0
- package/cli/source-reliability.mjs +1 -1
- package/docs/cli-reference.md +1110 -0
- package/docs/cli.md +224 -847
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +6 -6
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
- package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
- package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
- package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
- package/frontend/src/lib/analysis/case-migration-model.js +6 -1
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-core.js +5 -1
- package/frontend/src/lib/analysis/case-record-operations.js +6 -1
- package/frontend/src/lib/analysis/case-response-packet.js +7 -7
- package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
- package/frontend/src/lib/analysis/http-summary.js +2 -1
- package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
- package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +480 -0
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
- package/frontend/src/lib/analysis/lookup-presentation.js +74 -0
- package/frontend/src/lib/analysis/lookup-response.js +2 -0
- package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
- package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
- package/frontend/src/lib/analysis/page-similarity.js +1 -1
- package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
- package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
- package/frontend/src/lib/analysis/scoring.js +8 -88
- package/frontend/src/lib/analysis/shortlist-model.js +8 -4
- package/frontend/src/lib/analysis/utils.js +7 -40
- package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
- package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
- package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
- package/frontend/src/lib/analysis/visualization-models.js +370 -0
- package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
- package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
- package/frontend/src/lib/analysis/workspace-archive.js +2 -2
- package/lib/analyst-taxonomy.mjs +40 -0
- package/lib/availability.mjs +30 -10
- package/lib/browser-library-profile.mjs +15 -2
- package/lib/bulk-limits.mjs +13 -0
- package/lib/certificate-extension-profile.mjs +71 -0
- package/lib/credential-surface-profile.mjs +1 -1
- 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-assurance.mjs +368 -0
- package/lib/domain-control-manifest.mjs +382 -0
- package/lib/domain-posture.mjs +11 -10
- package/lib/encrypted-dns-contract.mjs +81 -0
- package/lib/evidence-export.mjs +17 -3
- package/lib/evidence-report-markdown.mjs +1 -1
- package/lib/evidence-report.mjs +4 -1
- package/lib/favicon.mjs +3 -8
- package/lib/generated/cisa-kev-catalog.mjs +1668 -0
- 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 +87 -0
- package/lib/local-geoip-evidence.mjs +89 -0
- package/lib/lookup-response-contract.mjs +346 -0
- package/lib/observed-network-context.mjs +1 -1
- package/lib/opportunity-scoring.mjs +107 -0
- package/lib/outbound-identity.mjs +9 -0
- package/lib/outbound-request-bounds.mjs +9 -0
- package/lib/page-fingerprints.mjs +1 -1
- 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-capabilities.mjs +6 -6
- package/lib/rdap-search-workbench.mjs +247 -0
- package/lib/rdap.mjs +7 -2
- package/lib/registry-access.mjs +1 -1
- package/lib/registry-capabilities.mjs +31 -1
- package/lib/registry-capability-catalogue.mjs +15 -14
- package/lib/registry-insights.mjs +6 -6
- package/lib/response-policy.mjs +81 -6
- package/lib/risk-scoring.mjs +206 -83
- package/lib/rpki-evidence.mjs +105 -0
- package/lib/safe-fetch.mjs +2 -1
- package/lib/scoring-evidence-quality.mjs +107 -0
- package/lib/security-txt.mjs +3 -3
- package/lib/semantic-version.mjs +54 -0
- package/lib/static-html-analysis.mjs +27 -0
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threatfox-intelligence.mjs +11 -6
- package/lib/tls-intelligence.mjs +13 -3
- package/lib/tlsa-evidence.mjs +194 -0
- package/lib/urlhaus-intelligence.mjs +11 -6
- package/lib/urlscan-intelligence.mjs +10 -5
- package/lib/website-security-posture.mjs +16 -9
- package/lib/website-technology.mjs +1 -1
- package/lib/whois-chain.mjs +14 -152
- package/lib/whois.mjs +2 -2
- package/package.json +6 -2
- package/third-party-notices.txt +911 -19
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export const SCORE_EVIDENCE_QUALITY_VERSION = 1;
|
|
2
|
+
const MAX_SOURCES = 24;
|
|
3
|
+
const MAX_FAMILIES = 12;
|
|
4
|
+
const SOURCE_ID_RE = /^[a-z][a-z0-9_-]{0,63}$/u;
|
|
5
|
+
const COMPLETE_STATES = new Set(['complete', 'not_found', 'success']);
|
|
6
|
+
const LIMITED_STATES = new Set(['partial', 'unknown']);
|
|
7
|
+
const UNAVAILABLE_STATES = new Set(['error', 'rate_limited', 'unavailable']);
|
|
8
|
+
const SKIPPED_STATES = new Set(['skipped', 'unsupported']);
|
|
9
|
+
const ACCEPTED_SOURCE_STATES = new Set([
|
|
10
|
+
...COMPLETE_STATES,
|
|
11
|
+
...LIMITED_STATES,
|
|
12
|
+
...UNAVAILABLE_STATES,
|
|
13
|
+
...SKIPPED_STATES,
|
|
14
|
+
]);
|
|
15
|
+
function boundedFamilyList(value) {
|
|
16
|
+
const output = new Set();
|
|
17
|
+
for (const item of value.slice(0, MAX_FAMILIES * 2)) {
|
|
18
|
+
const normalized = String(item).trim().toLowerCase().replace(/[^a-z0-9_-]+/gu, '-').slice(0, 64);
|
|
19
|
+
if (SOURCE_ID_RE.test(normalized))
|
|
20
|
+
output.add(normalized);
|
|
21
|
+
if (output.size >= MAX_FAMILIES)
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
return [...output].sort();
|
|
25
|
+
}
|
|
26
|
+
function sourceCoverage(value) {
|
|
27
|
+
if (!Array.isArray(value))
|
|
28
|
+
return [];
|
|
29
|
+
const output = [];
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
for (const candidate of value.slice(0, MAX_SOURCES * 4)) {
|
|
32
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate))
|
|
33
|
+
continue;
|
|
34
|
+
const item = candidate;
|
|
35
|
+
const source = typeof item.source === 'string' ? item.source.trim().toLowerCase() : '';
|
|
36
|
+
const state = typeof item.state === 'string' ? item.state.trim().toLowerCase() : '';
|
|
37
|
+
if (!SOURCE_ID_RE.test(source) || seen.has(source))
|
|
38
|
+
continue;
|
|
39
|
+
if (!ACCEPTED_SOURCE_STATES.has(state))
|
|
40
|
+
continue;
|
|
41
|
+
seen.add(source);
|
|
42
|
+
output.push({ source, state });
|
|
43
|
+
if (output.length >= MAX_SOURCES)
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
return output;
|
|
47
|
+
}
|
|
48
|
+
function scanDepth(value) {
|
|
49
|
+
return value === 'deep' || value === 'fast' ? value : 'unknown';
|
|
50
|
+
}
|
|
51
|
+
function observedFreshness(value) {
|
|
52
|
+
if (typeof value !== 'string' || value.length > 64)
|
|
53
|
+
return 'unknown';
|
|
54
|
+
return Number.isFinite(Date.parse(value)) ? 'observed' : 'unknown';
|
|
55
|
+
}
|
|
56
|
+
export function buildScoreEvidenceQuality(input, options) {
|
|
57
|
+
const depth = scanDepth(input.scanDepth);
|
|
58
|
+
const coverage = sourceCoverage(input.sourceCoverage);
|
|
59
|
+
const completeSources = coverage.filter((item) => COMPLETE_STATES.has(item.state)).length;
|
|
60
|
+
const limitedSources = coverage.filter((item) => LIMITED_STATES.has(item.state)).length;
|
|
61
|
+
const unavailableSources = coverage.filter((item) => UNAVAILABLE_STATES.has(item.state)).length;
|
|
62
|
+
const skippedSources = coverage.filter((item) => SKIPPED_STATES.has(item.state)).length;
|
|
63
|
+
const observedFamilies = boundedFamilyList(options.observedFamilies ?? []);
|
|
64
|
+
const expectedFamilies = boundedFamilyList(options.expectedFamilies ?? []);
|
|
65
|
+
const observedSet = new Set(observedFamilies);
|
|
66
|
+
const missingFamilies = expectedFamilies.filter((family) => !observedSet.has(family));
|
|
67
|
+
const limitations = [];
|
|
68
|
+
let state = 'complete';
|
|
69
|
+
if (!coverage.length || depth === 'unknown')
|
|
70
|
+
state = 'unknown';
|
|
71
|
+
else if (unavailableSources || limitedSources)
|
|
72
|
+
state = 'partial';
|
|
73
|
+
else if (options.expectedDepth === 'deep' && depth !== 'deep')
|
|
74
|
+
state = 'limited';
|
|
75
|
+
if (missingFamilies.length && state === 'complete')
|
|
76
|
+
state = 'limited';
|
|
77
|
+
if (options.expectedDepth === 'deep' && depth !== 'deep') {
|
|
78
|
+
limitations.push('A fast or unknown-depth observation does not evaluate every website and mail scoring family.');
|
|
79
|
+
}
|
|
80
|
+
if (limitedSources || unavailableSources) {
|
|
81
|
+
limitations.push('One or more score-relevant sources were partial, unavailable, or indeterminate.');
|
|
82
|
+
}
|
|
83
|
+
if (skippedSources) {
|
|
84
|
+
limitations.push('One or more reported sources were skipped or unsupported and did not contribute to the score.');
|
|
85
|
+
}
|
|
86
|
+
if (!coverage.length)
|
|
87
|
+
limitations.push('Source-level coverage was not supplied with this score.');
|
|
88
|
+
if (missingFamilies.length)
|
|
89
|
+
limitations.push(`No usable observation was supplied for: ${missingFamilies.join(', ')}.`);
|
|
90
|
+
const boundedLimitations = [
|
|
91
|
+
...limitations.slice(0, 5),
|
|
92
|
+
'Evidence coverage qualifies the score; it never adds points or turns missing evidence into a negative finding.',
|
|
93
|
+
];
|
|
94
|
+
return Object.freeze({
|
|
95
|
+
version: SCORE_EVIDENCE_QUALITY_VERSION,
|
|
96
|
+
state,
|
|
97
|
+
scanDepth: depth,
|
|
98
|
+
freshness: observedFreshness(input.observedAt),
|
|
99
|
+
completeSources,
|
|
100
|
+
limitedSources,
|
|
101
|
+
unavailableSources,
|
|
102
|
+
skippedSources,
|
|
103
|
+
observedFamilies: Object.freeze(observedFamilies),
|
|
104
|
+
missingFamilies: Object.freeze(missingFamilies),
|
|
105
|
+
limitations: Object.freeze(boundedLimitations),
|
|
106
|
+
});
|
|
107
|
+
}
|
package/lib/security-txt.mjs
CHANGED
|
@@ -202,7 +202,7 @@ function parseSecurityTxt(text, options = {}) {
|
|
|
202
202
|
const limitations = [
|
|
203
203
|
...(signedPayload.signed ? ['A cleartext signature was present but was not cryptographically verified.'] : []),
|
|
204
204
|
...(canonicalMatches === false ? ['The fetched URL was not listed by the file as a Canonical location.'] : []),
|
|
205
|
-
...(valuesTruncated ? ['The response or
|
|
205
|
+
...(valuesTruncated ? ['The response or normalised field set exceeded a collection bound.'] : []),
|
|
206
206
|
];
|
|
207
207
|
const detail = state === 'malformed'
|
|
208
208
|
? 'The file did not contain at least one valid Contact field and exactly one valid Expires field.'
|
|
@@ -314,7 +314,7 @@ async function collectSecurityTxt(hostname, dependencies = {}) {
|
|
|
314
314
|
}
|
|
315
315
|
if (response.status === 404 || response.status === 410) {
|
|
316
316
|
await response.body?.cancel().catch(() => { });
|
|
317
|
-
return emptyResult('absent', 'No security.txt file was published at the
|
|
317
|
+
return emptyResult('absent', 'No security.txt file was published at the standardised location for this hostname.', {
|
|
318
318
|
observedAt, requestedUrl, finalUrl: result.finalUrl, httpStatus: response.status, durationMs: now() - startedAt,
|
|
319
319
|
});
|
|
320
320
|
}
|
|
@@ -328,7 +328,7 @@ async function collectSecurityTxt(hostname, dependencies = {}) {
|
|
|
328
328
|
const charset = contentType.match(/;\s*charset\s*=\s*"?([^;"\s]+)/iu)?.[1]?.toLowerCase() || null;
|
|
329
329
|
if (!/^text\/plain(?:\s*;|$)/iu.test(contentType) || (charset !== null && charset !== 'utf-8')) {
|
|
330
330
|
await response.body?.cancel().catch(() => { });
|
|
331
|
-
return emptyResult('unsupported', 'The
|
|
331
|
+
return emptyResult('unsupported', 'The standardised endpoint did not return a UTF-8 text/plain security.txt document.', {
|
|
332
332
|
observedAt, requestedUrl, finalUrl: result.finalUrl, httpStatus: response.status, durationMs: now() - startedAt,
|
|
333
333
|
});
|
|
334
334
|
}
|
|
@@ -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,
|
|
@@ -352,7 +352,7 @@ function normalizeConnectorOutputs(value) {
|
|
|
352
352
|
throw new TypeError('Connector outputs must be unique');
|
|
353
353
|
}
|
|
354
354
|
if (!entities.length) {
|
|
355
|
-
throw new TypeError('Connector outputs require at least one
|
|
355
|
+
throw new TypeError('Connector outputs require at least one normalised entity type');
|
|
356
356
|
}
|
|
357
357
|
const entityTypes = new Set(entities);
|
|
358
358
|
for (const relationship of relationships) {
|
|
@@ -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),
|
|
@@ -277,7 +282,7 @@ function createThreatfoxIntelligenceAdapter(dependencies = {}) {
|
|
|
277
282
|
truncated,
|
|
278
283
|
detail: findings.length
|
|
279
284
|
? `Found ${findings.length} bounded malware-IOC record${findings.length === 1 ? '' : 's'} for this domain${truncated ? '; additional provider records may exist' : ''}.`
|
|
280
|
-
: 'The provider reported a match but no returned record could be safely
|
|
285
|
+
: 'The provider reported a match but no returned record could be safely normalised.',
|
|
281
286
|
upstreamStatus,
|
|
282
287
|
findings,
|
|
283
288
|
limitations: [
|
package/lib/tls-intelligence.mjs
CHANGED
|
@@ -12,7 +12,8 @@ import * as tls from 'node:tls';
|
|
|
12
12
|
import { domainToASCII } from 'node:url';
|
|
13
13
|
import { createObservation } from "./observation.mjs";
|
|
14
14
|
import { isPrivateAddress, resolvePublicAddresses } from "./safe-fetch.mjs";
|
|
15
|
-
|
|
15
|
+
import { parseCertificateExtensionProfile, } from "./certificate-extension-profile.mjs";
|
|
16
|
+
const TLS_PROFILE_VERSION = 3;
|
|
16
17
|
const TLS_PORT = 443;
|
|
17
18
|
const TLS_TIMEOUT_MS = 5000;
|
|
18
19
|
const MAX_RESOLVED_ADDRESSES = 64;
|
|
@@ -486,6 +487,15 @@ function normalizeCertificate(value, state, options = {}) {
|
|
|
486
487
|
};
|
|
487
488
|
certificate.extendedKeyUsage = certificatePurposes(parsedCertificate?.keyUsage || record.ext_key_usage, state);
|
|
488
489
|
certificate.authorityInformationAccess = authorityInformationAccess(parsedCertificate?.infoAccess || record.infoAccess, state);
|
|
490
|
+
const extensionProfile = parseCertificateExtensionProfile(record.raw);
|
|
491
|
+
if (extensionProfile.parsed || extensionProfile.partial) {
|
|
492
|
+
certificate.extensionProfile = extensionProfile;
|
|
493
|
+
if (extensionProfile.partial)
|
|
494
|
+
state.discarded += 1;
|
|
495
|
+
if (extensionProfile.certificatePolicies.truncated || extensionProfile.crlDistributionPoints.truncated) {
|
|
496
|
+
state.truncated = true;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
489
499
|
}
|
|
490
500
|
return certificate;
|
|
491
501
|
}
|
|
@@ -562,7 +572,7 @@ function tlsFindings(profile) {
|
|
|
562
572
|
if (profile.validity.status === 'not_yet_valid')
|
|
563
573
|
findings.push({ id: 'certificate_not_yet_valid', tone: 'warning', label: 'Certificate not yet valid', detail: 'The leaf certificate validity period had not started at observation time.' });
|
|
564
574
|
if (profile.authorization.authorized === false)
|
|
565
|
-
findings.push({ id: 'certificate_unauthorized', tone: 'warning', label: 'Certificate not
|
|
575
|
+
findings.push({ id: 'certificate_unauthorized', tone: 'warning', label: 'Certificate not authorised', detail: 'The runtime trust store did not authorise the observed certificate chain. This can reflect an incomplete chain, private CA, self-signed certificate, expiry, or another validation failure.' });
|
|
566
576
|
if (profile.hostname.matches === false)
|
|
567
577
|
findings.push({ id: 'hostname_mismatch', tone: 'warning', label: 'Hostname mismatch', detail: 'The observed leaf certificate did not match the SNI hostname.' });
|
|
568
578
|
if (profile.certificate?.subjectAltNames?.dnsNames.some((name) => name.startsWith('*.')))
|
|
@@ -631,7 +641,7 @@ function buildTlsObservation(handshake = {}, options = {}) {
|
|
|
631
641
|
truncated: state.truncated,
|
|
632
642
|
limitations: [
|
|
633
643
|
'This is a point-in-time TLS handshake to one validated public address; other addresses or edge locations may present different results.',
|
|
634
|
-
'
|
|
644
|
+
'Authorisation reflects the certificate authorities and verification behaviour available to this runtime.',
|
|
635
645
|
'The profile records the negotiated connection only and does not enumerate supported TLS versions or cipher suites.',
|
|
636
646
|
...(state.truncated ? ['One or more certificate fields or chain entries reached a retention limit.'] : []),
|
|
637
647
|
],
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { domainToASCII } from 'node:url';
|
|
4
|
+
const TLSA_EVIDENCE_SCHEMA = 'whoisleuth.tlsa-evidence';
|
|
5
|
+
const TLSA_EVIDENCE_VERSION = 1;
|
|
6
|
+
const MAX_TLSA_RECORDS = 50;
|
|
7
|
+
const MAX_CERTIFICATE_BYTES = 256 * 1024;
|
|
8
|
+
const MAX_AUTHORITY_MATERIALS = 10;
|
|
9
|
+
function record(value) {
|
|
10
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
11
|
+
? value
|
|
12
|
+
: null;
|
|
13
|
+
}
|
|
14
|
+
function integer(value, minimum, maximum) {
|
|
15
|
+
return Number.isInteger(value) && Number(value) >= minimum && Number(value) <= maximum
|
|
16
|
+
? Number(value)
|
|
17
|
+
: null;
|
|
18
|
+
}
|
|
19
|
+
function hex(value) {
|
|
20
|
+
if (typeof value !== 'string')
|
|
21
|
+
return null;
|
|
22
|
+
const normalized = value.replace(/\s+/gu, '').toLowerCase();
|
|
23
|
+
return normalized && normalized.length % 2 === 0 && /^[0-9a-f]+$/u.test(normalized) ? normalized : null;
|
|
24
|
+
}
|
|
25
|
+
function normalizeTlsaRecord(value) {
|
|
26
|
+
const source = record(value);
|
|
27
|
+
if (!source)
|
|
28
|
+
return null;
|
|
29
|
+
const usage = integer(source.usage, 0, 3);
|
|
30
|
+
const selector = integer(source.selector, 0, 1);
|
|
31
|
+
const matchingType = integer(source.matchingType, 0, 2);
|
|
32
|
+
const associationData = hex(source.associationData);
|
|
33
|
+
if (usage === null || selector === null || matchingType === null || !associationData)
|
|
34
|
+
return null;
|
|
35
|
+
if (matchingType === 1 && associationData.length !== 64)
|
|
36
|
+
return null;
|
|
37
|
+
if (matchingType === 2 && associationData.length !== 128)
|
|
38
|
+
return null;
|
|
39
|
+
if (matchingType === 0 && associationData.length > MAX_CERTIFICATE_BYTES * 2)
|
|
40
|
+
return null;
|
|
41
|
+
return Object.freeze({ usage, selector, matchingType, associationData });
|
|
42
|
+
}
|
|
43
|
+
function decodeBoundedBase64(value) {
|
|
44
|
+
if (typeof value !== 'string' || value.length > MAX_CERTIFICATE_BYTES * 2 || !/^[A-Za-z0-9+/]+={0,2}$/u.test(value))
|
|
45
|
+
return null;
|
|
46
|
+
const decoded = Buffer.from(value, 'base64');
|
|
47
|
+
return decoded.length > 0 && decoded.length <= MAX_CERTIFICATE_BYTES && decoded.toString('base64') === value ? decoded : null;
|
|
48
|
+
}
|
|
49
|
+
function normalizeTlsaServiceName(value) {
|
|
50
|
+
if (typeof value !== 'string' || value.length > 270)
|
|
51
|
+
return null;
|
|
52
|
+
const match = /^_(\d{1,5})\._(tcp|udp)\.(.+)$/iu.exec(value.trim().replace(/\.$/u, ''));
|
|
53
|
+
if (!match)
|
|
54
|
+
return null;
|
|
55
|
+
const port = Number(match[1]);
|
|
56
|
+
const transport = match[2]?.toLowerCase();
|
|
57
|
+
const hostname = domainToASCII(String(match[3] ?? '').toLowerCase());
|
|
58
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535 || (transport !== 'tcp' && transport !== 'udp'))
|
|
59
|
+
return null;
|
|
60
|
+
if (!hostname || hostname.length > 253 || hostname.split('.').some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label)))
|
|
61
|
+
return null;
|
|
62
|
+
return Object.freeze({ ownerName: `_${port}._${transport}.${hostname}`, port, transport, hostname });
|
|
63
|
+
}
|
|
64
|
+
function normalizeCertificateMaterial(value) {
|
|
65
|
+
const source = record(value);
|
|
66
|
+
if (!source)
|
|
67
|
+
return null;
|
|
68
|
+
const certificateDer = decodeBoundedBase64(source.certificateDerBase64);
|
|
69
|
+
const spkiDer = decodeBoundedBase64(source.spkiDerBase64);
|
|
70
|
+
return certificateDer || spkiDer ? Object.freeze({ certificateDer, spkiDer }) : null;
|
|
71
|
+
}
|
|
72
|
+
function selectedValues(recordValue, materials) {
|
|
73
|
+
return materials.flatMap((material) => {
|
|
74
|
+
const selected = recordValue.selector === 0 ? material.certificateDer : material.spkiDer;
|
|
75
|
+
if (!selected)
|
|
76
|
+
return [];
|
|
77
|
+
if (recordValue.matchingType === 0)
|
|
78
|
+
return [selected.toString('hex')];
|
|
79
|
+
if (recordValue.matchingType === 1)
|
|
80
|
+
return [createHash('sha256').update(selected).digest('hex')];
|
|
81
|
+
return [createHash('sha512').update(selected).digest('hex')];
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
function normalizeDnssecState(value) {
|
|
85
|
+
return value === 'validated' || value === 'insecure' || value === 'bogus' ? value : 'unavailable';
|
|
86
|
+
}
|
|
87
|
+
function normalizePkixValidationState(value) {
|
|
88
|
+
return value === 'validated' || value === 'failed' ? value : 'unavailable';
|
|
89
|
+
}
|
|
90
|
+
function analyzeTlsaEvidence(input) {
|
|
91
|
+
const service = normalizeTlsaServiceName(input.serviceName);
|
|
92
|
+
const dnssecState = normalizeDnssecState(input.dnssecState);
|
|
93
|
+
const pkixValidationState = normalizePkixValidationState(input.pkixValidationState);
|
|
94
|
+
const leafMaterial = normalizeCertificateMaterial({
|
|
95
|
+
certificateDerBase64: input.certificateDerBase64,
|
|
96
|
+
spkiDerBase64: input.spkiDerBase64,
|
|
97
|
+
});
|
|
98
|
+
const rawAuthorityMaterials = Array.isArray(input.authorityMaterials) ? input.authorityMaterials : [];
|
|
99
|
+
const normalizedAuthorityMaterials = rawAuthorityMaterials
|
|
100
|
+
.slice(0, MAX_AUTHORITY_MATERIALS)
|
|
101
|
+
.map(normalizeCertificateMaterial);
|
|
102
|
+
const authorityMaterials = normalizedAuthorityMaterials
|
|
103
|
+
.filter((item) => item !== null);
|
|
104
|
+
const authorityMaterialRejectedCount = normalizedAuthorityMaterials.length - authorityMaterials.length;
|
|
105
|
+
const authorityMaterialTruncated = rawAuthorityMaterials.length > MAX_AUTHORITY_MATERIALS;
|
|
106
|
+
const raw = Array.isArray(input.records) ? input.records : null;
|
|
107
|
+
if (!raw || !service) {
|
|
108
|
+
return Object.freeze({
|
|
109
|
+
schema: TLSA_EVIDENCE_SCHEMA,
|
|
110
|
+
version: TLSA_EVIDENCE_VERSION,
|
|
111
|
+
state: 'invalid',
|
|
112
|
+
dnssecState,
|
|
113
|
+
pkixValidationState,
|
|
114
|
+
service,
|
|
115
|
+
authorityMaterialCount: authorityMaterials.length,
|
|
116
|
+
authorityMaterialRejectedCount,
|
|
117
|
+
authorityMaterialTruncated,
|
|
118
|
+
records: Object.freeze([]),
|
|
119
|
+
rejectedCount: 0,
|
|
120
|
+
truncated: false,
|
|
121
|
+
limitations: Object.freeze([
|
|
122
|
+
...(!raw ? ['TLSA evidence must be supplied as a bounded record array.'] : []),
|
|
123
|
+
...(!service ? ['TLSA evidence must be bound to a valid _port._transport.hostname service name.'] : []),
|
|
124
|
+
]),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
const truncated = raw.length > MAX_TLSA_RECORDS;
|
|
128
|
+
const normalized = raw.slice(0, MAX_TLSA_RECORDS).map(normalizeTlsaRecord);
|
|
129
|
+
const rejectedCount = normalized.filter((item) => item === null).length + Math.max(0, raw.length - MAX_TLSA_RECORDS);
|
|
130
|
+
const records = normalized.filter((item) => item !== null);
|
|
131
|
+
const results = records.map((item) => {
|
|
132
|
+
const materials = item.usage === 0 || item.usage === 2
|
|
133
|
+
? authorityMaterials
|
|
134
|
+
: leafMaterial ? [leafMaterial] : [];
|
|
135
|
+
const selected = selectedValues(item, materials);
|
|
136
|
+
if (!selected.length) {
|
|
137
|
+
const role = item.usage === 0 || item.usage === 2 ? 'authority' : 'leaf';
|
|
138
|
+
return Object.freeze({ ...item, state: 'unavailable', reason: `No bounded ${role} ${item.selector === 0 ? 'certificate' : 'SPKI'} bytes were supplied for comparison.` });
|
|
139
|
+
}
|
|
140
|
+
const matched = selected.includes(item.associationData);
|
|
141
|
+
return Object.freeze({
|
|
142
|
+
...item,
|
|
143
|
+
state: matched ? 'matched' : 'different',
|
|
144
|
+
reason: matched ? 'The supplied certificate material matched this TLSA association.' : 'The supplied certificate material differed from this TLSA association.',
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
const hasMatch = results.some((item) => item.state === 'matched');
|
|
148
|
+
const hasDifferent = results.some((item) => item.state === 'different');
|
|
149
|
+
const hasUnavailable = results.some((item) => item.state === 'unavailable');
|
|
150
|
+
const limitations = [
|
|
151
|
+
'This offline comparison does not connect to the target, retrieve DNS, negotiate SMTP STARTTLS, or validate a DNSSEC chain.',
|
|
152
|
+
'A TLSA match is usable as DANE evidence only when the DNSSEC state was independently validated for the same observation.',
|
|
153
|
+
'PKIX-TA and PKIX-EE usages require an independently validated PKIX path; association matching alone does not establish that prerequisite.',
|
|
154
|
+
];
|
|
155
|
+
if (dnssecState !== 'validated' && hasMatch) {
|
|
156
|
+
limitations.push('Certificate material matched, but DNSSEC was not validated, so the result remains partial.');
|
|
157
|
+
}
|
|
158
|
+
const matchedPkixDependent = results.some((item) => item.state === 'matched' && (item.usage === 0 || item.usage === 1));
|
|
159
|
+
const matchedDaneOnly = results.some((item) => item.state === 'matched' && (item.usage === 2 || item.usage === 3));
|
|
160
|
+
if (matchedPkixDependent && pkixValidationState !== 'validated') {
|
|
161
|
+
limitations.push('A PKIX-dependent association matched, but a validated PKIX path was not supplied, so the result is not complete.');
|
|
162
|
+
}
|
|
163
|
+
if (authorityMaterialRejectedCount > 0 || authorityMaterialTruncated) {
|
|
164
|
+
limitations.push('Authority certificate material was malformed or exceeded the review bound, so an unmatched trust-anchor association remains partial.');
|
|
165
|
+
}
|
|
166
|
+
const trustedMatch = dnssecState === 'validated'
|
|
167
|
+
&& (matchedDaneOnly || matchedPkixDependent && pkixValidationState === 'validated');
|
|
168
|
+
const state = results.length === 0
|
|
169
|
+
? 'unavailable'
|
|
170
|
+
: hasDifferent && !hasMatch && rejectedCount === 0 && !truncated
|
|
171
|
+
&& authorityMaterialRejectedCount === 0 && !authorityMaterialTruncated
|
|
172
|
+
? 'different'
|
|
173
|
+
: hasMatch && (dnssecState === 'bogus' || matchedPkixDependent && pkixValidationState === 'failed' && !matchedDaneOnly)
|
|
174
|
+
? 'untrusted'
|
|
175
|
+
: trustedMatch && !hasUnavailable && rejectedCount === 0 && !truncated
|
|
176
|
+
? 'matched'
|
|
177
|
+
: 'partial';
|
|
178
|
+
return Object.freeze({
|
|
179
|
+
schema: TLSA_EVIDENCE_SCHEMA,
|
|
180
|
+
version: TLSA_EVIDENCE_VERSION,
|
|
181
|
+
state,
|
|
182
|
+
dnssecState,
|
|
183
|
+
pkixValidationState,
|
|
184
|
+
service,
|
|
185
|
+
authorityMaterialCount: authorityMaterials.length,
|
|
186
|
+
authorityMaterialRejectedCount,
|
|
187
|
+
authorityMaterialTruncated,
|
|
188
|
+
records: Object.freeze(results),
|
|
189
|
+
rejectedCount,
|
|
190
|
+
truncated,
|
|
191
|
+
limitations: Object.freeze(limitations),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
export { MAX_TLSA_RECORDS, MAX_AUTHORITY_MATERIALS, TLSA_EVIDENCE_SCHEMA, TLSA_EVIDENCE_VERSION, analyzeTlsaEvidence, normalizeTlsaRecord, normalizeTlsaServiceName, };
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import { classifyQuery } from "./classify.mjs";
|
|
7
7
|
import { abusechAuthKey } from "./abusech-auth.mjs";
|
|
8
8
|
import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
|
|
9
|
+
import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
|
|
10
|
+
import { providerPolicyAdmission } from "./provider-policy-admission.mjs";
|
|
9
11
|
import { createThreatIntelligenceResult, defineThreatIntelligenceProvider, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-contract.mjs";
|
|
10
12
|
const URLHAUS_HOST_ENDPOINT = 'https://urlhaus-api.abuse.ch/v1/host/';
|
|
11
13
|
const URLHAUS_MAX_RESULTS = 20;
|
|
@@ -53,15 +55,18 @@ function urlhausConfiguration(env = process.env) {
|
|
|
53
55
|
const source = env && typeof env === 'object' ? env : {};
|
|
54
56
|
const enabled = enabledValue(source.WHOISLEUTH_ENABLE_URLHAUS);
|
|
55
57
|
const authKey = abusechAuthKey(source);
|
|
58
|
+
const policy = providerPolicyAdmission(URLHAUS_PROVIDER.terms, source);
|
|
56
59
|
return {
|
|
57
60
|
enabled,
|
|
58
|
-
configured: enabled && authKey !== null,
|
|
61
|
+
configured: enabled && policy.allowed && authKey !== null,
|
|
59
62
|
authKey: enabled ? authKey : null,
|
|
60
63
|
reason: !enabled
|
|
61
64
|
? 'Malware-host intelligence is not enabled for this deployment.'
|
|
62
|
-
:
|
|
63
|
-
?
|
|
64
|
-
:
|
|
65
|
+
: !policy.allowed
|
|
66
|
+
? policy.reason
|
|
67
|
+
: authKey
|
|
68
|
+
? null
|
|
69
|
+
: 'Malware-host intelligence is enabled but its API credential is unavailable or malformed.',
|
|
65
70
|
};
|
|
66
71
|
}
|
|
67
72
|
function boundedText(value, maxLength) {
|
|
@@ -231,7 +236,7 @@ function createUrlhausIntelligenceAdapter(dependencies = {}) {
|
|
|
231
236
|
accept: 'application/json',
|
|
232
237
|
'auth-key': configuration.authKey,
|
|
233
238
|
'content-type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
|
234
|
-
|
|
239
|
+
...whoisleuthRequestHeaders(),
|
|
235
240
|
},
|
|
236
241
|
body,
|
|
237
242
|
signal: AbortSignal.timeout(URLHAUS_PROVIDER.limits.timeoutMs),
|
|
@@ -328,7 +333,7 @@ function createUrlhausIntelligenceAdapter(dependencies = {}) {
|
|
|
328
333
|
truncated,
|
|
329
334
|
detail: findings.length
|
|
330
335
|
? `Found ${findings.length} bounded malware-distribution record${findings.length === 1 ? '' : 's'} for this host${truncated ? '; additional provider records may exist' : ''}.`
|
|
331
|
-
: 'The provider reported this host but no returned record could be safely
|
|
336
|
+
: 'The provider reported this host but no returned record could be safely normalised.',
|
|
332
337
|
upstreamStatus,
|
|
333
338
|
findings,
|
|
334
339
|
limitations: [
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// key; credentials are sent only in a header to the fixed HTTPS endpoint.
|
|
5
5
|
import { classifyQuery } from "./classify.mjs";
|
|
6
6
|
import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
|
|
7
|
+
import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
|
|
8
|
+
import { providerPolicyAdmission } from "./provider-policy-admission.mjs";
|
|
7
9
|
import { createThreatIntelligenceResult, defineThreatIntelligenceProvider, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-contract.mjs";
|
|
8
10
|
const URLSCAN_SEARCH_ENDPOINT = 'https://urlscan.io/api/v1/search/';
|
|
9
11
|
const URLSCAN_SEARCH_DAYS = 90;
|
|
@@ -57,15 +59,18 @@ function urlscanConfiguration(env = process.env) {
|
|
|
57
59
|
const source = env && typeof env === 'object' ? env : {};
|
|
58
60
|
const enabled = enabledValue(source.WHOISLEUTH_ENABLE_URLSCAN);
|
|
59
61
|
const apiKey = normalizedApiKey(source.URLSCAN_API_KEY);
|
|
62
|
+
const policy = providerPolicyAdmission(URLSCAN_PROVIDER.terms, source);
|
|
60
63
|
return {
|
|
61
64
|
enabled,
|
|
62
|
-
configured: enabled && apiKey !== null,
|
|
65
|
+
configured: enabled && policy.allowed && apiKey !== null,
|
|
63
66
|
apiKey: enabled ? apiKey : null,
|
|
64
67
|
reason: !enabled
|
|
65
68
|
? 'Archived URLscan verdict search is not enabled for this deployment.'
|
|
66
|
-
:
|
|
67
|
-
?
|
|
68
|
-
:
|
|
69
|
+
: !policy.allowed
|
|
70
|
+
? policy.reason
|
|
71
|
+
: apiKey
|
|
72
|
+
? null
|
|
73
|
+
: 'Archived URLscan verdict search is enabled but its API credential is unavailable or malformed.',
|
|
69
74
|
};
|
|
70
75
|
}
|
|
71
76
|
function boundedText(value, maxLength) {
|
|
@@ -230,7 +235,7 @@ function createUrlscanIntelligenceAdapter(dependencies = {}) {
|
|
|
230
235
|
headers: {
|
|
231
236
|
accept: 'application/json',
|
|
232
237
|
'api-key': configuration.apiKey,
|
|
233
|
-
|
|
238
|
+
...whoisleuthRequestHeaders(),
|
|
234
239
|
},
|
|
235
240
|
signal: AbortSignal.timeout(URLSCAN_PROVIDER.limits.timeoutMs),
|
|
236
241
|
}, { maxRedirects: 0 });
|