@slicedearth/whoisleuth-cli 1.47.4 → 2.0.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 +74 -177
- package/SECURITY.md +6 -3
- package/bin/whoisleuth.mjs +2 -1
- package/cli/archive-inspect.mjs +28 -10
- package/cli/arguments.mjs +204 -174
- package/cli/artifact-structure.mjs +837 -163
- package/cli/artifact-verify.mjs +90 -61
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +33 -20
- package/cli/bulk.mjs +9 -2
- package/cli/case-pack.mjs +180 -576
- package/cli/ci-report.mjs +6 -1
- package/cli/collection-preflight.mjs +2 -2
- package/cli/command-catalogue.mjs +12 -3
- package/cli/command-reference.mjs +910 -92
- package/cli/completion.mjs +504 -226
- package/cli/config-profile.mjs +13 -3
- package/cli/discover.mjs +7 -1
- package/cli/discovery-observation-snapshot.mjs +24 -28
- package/cli/discovery-scan-command-runner.mjs +9 -9
- package/cli/discovery-scan.mjs +17 -3
- package/cli/discovery-snapshot.mjs +15 -7
- package/cli/domain-control-monitor.mjs +203 -30
- package/cli/domain-control-observations.mjs +15 -21
- package/cli/errors.mjs +8 -3
- package/cli/evidence-signing.mjs +10 -20
- package/cli/external-observation-mapping.mjs +3 -3
- package/cli/formatters/json.mjs +20 -32
- package/cli/formatters/terminal.mjs +583 -22
- package/cli/input.mjs +7 -2
- package/cli/interactive-launcher.mjs +226 -0
- package/cli/interchange-report.mjs +19 -19
- package/cli/investigation-manifest.mjs +1 -1
- package/cli/investigation-plan.mjs +167 -16
- package/cli/investigation-run.mjs +9 -1
- package/cli/lookalike-calibration.mjs +3 -1
- package/cli/lookup-brief.mjs +13 -8
- package/cli/lookup-browser.mjs +844 -0
- package/cli/lookup-command-runner.mjs +94 -31
- package/cli/lookup-diff.mjs +2 -2
- package/cli/lookup-plan.mjs +1 -0
- package/cli/mail-review.mjs +206 -94
- package/cli/manual.mjs +5 -5
- package/cli/offline-evidence-review.mjs +18 -7
- package/cli/open-asset-model-bridge.mjs +1 -1
- package/cli/output-file.mjs +70 -14
- package/cli/page-compare.mjs +11 -6
- package/cli/progress-events.mjs +2 -1
- package/cli/progress.mjs +3 -3
- package/cli/registry-cohort.mjs +9 -51
- package/cli/registry-doctor.mjs +67 -28
- package/cli/retained-artifact-diff.mjs +7 -8
- package/cli/risk-calibration.mjs +85 -38
- package/cli/runner.mjs +189 -75
- package/cli/saved-lookup.mjs +424 -57
- package/cli/sharing-review.mjs +6 -1
- package/cli/source-reliability.mjs +75 -36
- package/cli/terminal-presentation.mjs +43 -18
- package/docs/cli-reference.md +125 -1708
- package/docs/cli.md +86 -254
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -145
- package/frontend/src/lib/analysis/domain-control-records.js +3 -88
- package/lib/availability.mjs +58 -40
- package/lib/bounded-contract-normalizers.mjs +2 -4
- package/lib/bounded-file.mjs +78 -4
- package/lib/bounded-json.mjs +51 -13
- package/lib/browser-library-profile.mjs +1 -1
- package/lib/classify.mjs +56 -5
- package/lib/client-behavior-profile.mjs +1 -1
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/cryptographic-assurance.mjs +35 -17
- package/lib/ct-query.mjs +3 -3
- package/lib/ct-search.mjs +37 -21
- package/lib/distributed-operation-budget.mjs +4 -2
- package/lib/dns-delegation-health.mjs +9 -6
- package/lib/dns-intelligence.mjs +1 -1
- package/lib/dnssec-chain-validation.mjs +2 -3
- package/lib/dnssec-evidence-validation.mjs +13 -7
- package/lib/domain-assurance.mjs +12 -12
- package/lib/domain-change-packet.mjs +1 -1
- package/lib/domain-control-flight-recorder.mjs +442 -78
- package/lib/domain-control-manifest.mjs +314 -59
- package/lib/domain-posture-analysis.mjs +5 -2
- package/lib/encrypted-dns-contract.mjs +2 -3
- package/lib/evidence-export.mjs +518 -36
- package/lib/external-intelligence-risk.mjs +26 -10
- package/lib/feature-policy.mjs +1 -1
- package/lib/homepage-metadata-contract.mjs +290 -0
- package/lib/hostname.mjs +1 -1
- package/lib/html-signals.mjs +89 -5
- package/lib/http-intelligence.mjs +331 -2
- package/lib/interchange-fidelity-registry.mjs +28 -26
- package/lib/lookup-cache.mjs +6 -4
- package/lib/lookup.mjs +25 -2
- package/lib/observed-network-context.mjs +1 -1
- package/lib/page-fingerprints.mjs +2 -2
- package/lib/page-role-profile.mjs +1 -1
- package/lib/portable-generator.mjs +8 -0
- package/lib/project-metadata.mjs +1 -1
- package/lib/provider-policy-admission.mjs +3 -1
- package/lib/rdap-bootstrap.mjs +76 -16
- package/lib/rdap-client.mjs +20 -12
- package/lib/rdap-normalization.mjs +306 -75
- package/lib/rdap-transport.mjs +2 -2
- package/lib/rdap-validation.mjs +65 -14
- package/lib/rdap.mjs +2 -2
- package/lib/registrable-domain.mjs +36 -0
- package/lib/registry-insights.mjs +7 -0
- package/lib/response-policy.mjs +27 -4
- package/lib/risk-calibration-summary.mjs +35 -33
- package/lib/risk-scoring.mjs +4 -3
- package/lib/safe-fetch.mjs +62 -40
- package/lib/security-txt.mjs +1 -1
- package/lib/smtp-transport-review.mjs +8 -5
- package/lib/sslbl-intelligence.mjs +6 -8
- package/lib/static-html-analysis.mjs +301 -4
- package/lib/structured-data-identity.mjs +51 -19
- package/lib/threat-intelligence-fixtures.mjs +2 -4
- package/lib/threat-intelligence-manifests.mjs +2 -7
- package/lib/threat-intelligence-runtime.mjs +3 -5
- package/lib/threat-intelligence-types.mjs +3 -0
- package/lib/threatfox-intelligence.mjs +7 -3
- package/lib/tls-intelligence.mjs +4 -4
- package/lib/urlhaus-intelligence.mjs +4 -2
- package/lib/urlscan-intelligence.mjs +6 -5
- package/lib/website-security-posture.mjs +1 -1
- package/lib/website-technology.mjs +1 -1
- package/lib/whois-authority.mjs +43 -8
- package/lib/whois-chain.mjs +25 -3
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-transport.mjs +12 -3
- package/lib/zone-intent-review.mjs +1 -1
- package/package.json +13 -5
- package/{frontend/src/lib/analysis/case-brand-profile-references.js → packages/cases/case-brand-profile-references.mjs} +2 -2
- package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +35 -14
- package/{frontend/src/lib/analysis/case-investigation-branch-model.js → packages/cases/case-investigation-branch-model.mjs} +10 -12
- package/packages/cases/case-migration-model.mjs +476 -0
- package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
- package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -21
- package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
- package/packages/cases/case-record-model.mjs +9 -0
- package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +72 -29
- package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +108 -14
- package/packages/cases/case-response-model.mjs +1604 -0
- package/packages/cases/case-response-packet.mjs +1312 -0
- package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
- package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
- package/{frontend/src/lib/analysis/comparison-ledger-bulk.js → packages/comparison/comparison-ledger-bulk.mjs} +2 -2
- package/{frontend/src/lib/analysis/comparison-ledger-contract.js → packages/comparison/comparison-ledger-contract.mjs} +4 -4
- package/{frontend/src/lib/analysis/comparison-ledger-json.js → packages/comparison/comparison-ledger-json.mjs} +1 -0
- package/{frontend/src/lib/analysis/comparison-ledger-serialization.js → packages/comparison/comparison-ledger-serialization.mjs} +1 -0
- package/packages/comparison/favicon-similarity.mjs +78 -0
- package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
- package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
- package/packages/contracts/analyst-review-state-contract.mjs +61 -0
- package/packages/contracts/analyst-review-state.mjs +50 -0
- package/packages/contracts/case-portability.mjs +1310 -0
- package/packages/contracts/cli-command-catalogue.mjs +2 -0
- package/packages/contracts/cli-lookup.mjs +394 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
- package/packages/contracts/domain-control-manifest.mjs +1027 -0
- package/packages/contracts/domain-control-monitor.mjs +310 -0
- package/packages/contracts/domain-control-review.mjs +828 -0
- package/packages/contracts/domain-name.mjs +4 -0
- package/packages/contracts/external-observation-interchange.mjs +224 -0
- package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
- package/packages/contracts/investigation-portability.mjs +331 -0
- package/packages/contracts/offline-comparison.mjs +160 -0
- package/packages/contracts/risk-calibration.mjs +1288 -0
- package/packages/contracts/schema-compatibility.mjs +71 -0
- package/packages/contracts/schema-lifecycle.mjs +1702 -0
- package/packages/contracts/sslbl-snapshot.mjs +17 -0
- package/packages/contracts/workspace-portability.mjs +1347 -0
- package/{frontend/src/lib/analysis/artifact-integrity.js → packages/evidence/artifact-integrity.mjs} +13 -11
- package/packages/evidence/decision-fact.mjs +735 -0
- package/packages/evidence/domain-control-runtime.mjs +547 -0
- package/packages/evidence/domain-name.mjs +41 -0
- package/packages/evidence/observation.mjs +384 -0
- package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +24 -22
- package/packages/interchange/retained-text.mjs +8 -0
- package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +9 -6
- package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +5 -6
- package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +4 -6
- package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +5 -5
- package/packages/investigation/bulk-review-policy.mjs +1 -0
- package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +30 -32
- package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
- package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
- package/{frontend/src/lib/analysis/lookup-claim-passport.js → packages/investigation/lookup-claim-passport.mjs} +6 -10
- package/{frontend/src/lib/analysis/lookup-claim-readiness.js → packages/investigation/lookup-claim-readiness.mjs} +9 -2
- package/packages/investigation/lookup-investigation-brief.mjs +172 -0
- package/packages/monitoring/analyst-review-state.mjs +680 -0
- package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +71 -48
- package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +13 -14
- package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +33 -59
- package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +8 -5
- package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
- package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
- package/packages/workspace/hostile-input.mjs +172 -0
- package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +8 -45
- package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +18 -21
- package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
- package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
- package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
- package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
- package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
- package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +13 -13
- package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
- package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +110 -89
- package/third-party-notices.txt +14 -14
- package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
- package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
- package/frontend/src/lib/analysis/case-migration-model.js +0 -305
- package/frontend/src/lib/analysis/case-record-model.js +0 -9
- package/frontend/src/lib/analysis/case-response-model.js +0 -728
- package/frontend/src/lib/analysis/case-response-packet.js +0 -583
- package/frontend/src/lib/analysis/lookup-decision-support.js +0 -542
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
- package/frontend/src/lib/analysis/lookup-presentation.js +0 -120
- package/frontend/src/lib/analysis/lookup-response.js +0 -2
- package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
- package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
- package/frontend/src/lib/analysis/scoring.js +0 -55
- package/frontend/src/lib/analysis/utils.js +0 -255
- package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
- package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
- package/frontend/src/lib/analysis/visualization-matrix.js +0 -131
- package/frontend/src/lib/analysis/visualization-models.js +0 -338
- package/frontend/src/lib/analysis/visualization-time-series.js +0 -185
- package/lib/bounded-json-response.mjs +0 -170
- package/lib/lookup-response-contract.mjs +0 -1034
- package/lib/observation.mjs +0 -115
- /package/{frontend/src/lib/analysis/opaque-reference-id.js → packages/cases/opaque-reference-id.mjs} +0 -0
- /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
- /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
- /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
package/lib/rdap-transport.mjs
CHANGED
|
@@ -8,7 +8,7 @@ async function fetchRdapWithTimeout(url, options, timeoutMs, dependencies = {})
|
|
|
8
8
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
9
9
|
try {
|
|
10
10
|
const response = await (dependencies.fetch ?? safeFetch)(url, { ...options, signal: controller.signal });
|
|
11
|
-
const { text, truncated } = await (dependencies.readText ?? readTextCapped)(response, MAX_RDAP_BYTES);
|
|
11
|
+
const { text, truncated } = await (dependencies.readText ?? readTextCapped)(response, MAX_RDAP_BYTES, { fatalUtf8: true });
|
|
12
12
|
if (truncated)
|
|
13
13
|
throw new Error(`Response from ${url} exceeded ${MAX_RDAP_BYTES} bytes`);
|
|
14
14
|
return { status: response.status, ok: response.ok, text };
|
|
@@ -22,7 +22,7 @@ async function fetchRdapDetailedWithTimeout(url, options, timeoutMs, dependencie
|
|
|
22
22
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
23
23
|
try {
|
|
24
24
|
const result = await (dependencies.fetchDetailed ?? safeFetchDetailed)(url, { ...options, signal: controller.signal });
|
|
25
|
-
const { text, truncated } = await (dependencies.readText ?? readTextCapped)(result.response, MAX_RDAP_BYTES);
|
|
25
|
+
const { text, truncated } = await (dependencies.readText ?? readTextCapped)(result.response, MAX_RDAP_BYTES, { fatalUtf8: true });
|
|
26
26
|
if (truncated)
|
|
27
27
|
throw new Error(`Response from ${url} exceeded ${MAX_RDAP_BYTES} bytes`);
|
|
28
28
|
return {
|
package/lib/rdap-validation.mjs
CHANGED
|
@@ -1,24 +1,75 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
2
|
import { domainToASCII } from 'node:url';
|
|
3
|
-
import { ipv4ToLong, ipv6ToBigInt } from "./rdap-bootstrap.mjs";
|
|
3
|
+
import { admitRdapEndpoint, ipv4ToLong, ipv6ToBigInt } from "./rdap-bootstrap.mjs";
|
|
4
4
|
export function canonicalRdapDomain(value) {
|
|
5
5
|
if (typeof value !== 'string')
|
|
6
6
|
return null;
|
|
7
7
|
const ascii = domainToASCII(value.trim().replace(/\.+$/, ''));
|
|
8
8
|
return ascii ? ascii.toLowerCase() : null;
|
|
9
9
|
}
|
|
10
|
-
function
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const embedded = ip.slice(lastColon + 1);
|
|
15
|
-
if (net.isIP(embedded) !== 4)
|
|
10
|
+
function decodedTerminalPathParts(value) {
|
|
11
|
+
try {
|
|
12
|
+
const parts = new URL(value).pathname.split('/').filter(Boolean);
|
|
13
|
+
if (parts.length < 2)
|
|
16
14
|
return null;
|
|
17
|
-
const
|
|
18
|
-
|
|
15
|
+
const objectType = decodeURIComponent(parts.at(-2) ?? '');
|
|
16
|
+
const objectValue = decodeURIComponent(parts.at(-1) ?? '');
|
|
17
|
+
return objectType && objectValue ? [objectType, objectValue] : null;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Admit final RDAP provenance only when its terminal path still identifies the
|
|
25
|
+
* requested object. Safe redirect transport proves where bytes came from; this
|
|
26
|
+
* binding separately prevents a redirected response for another object from
|
|
27
|
+
* deciding a lookup, especially when a bodyless 404 is otherwise authoritative.
|
|
28
|
+
*/
|
|
29
|
+
export function admitRdapObjectEndpoint(type, requestedValue, value) {
|
|
30
|
+
const admitted = admitRdapEndpoint(value);
|
|
31
|
+
if (!admitted)
|
|
32
|
+
return null;
|
|
33
|
+
const terminal = decodedTerminalPathParts(admitted);
|
|
34
|
+
if (!terminal)
|
|
35
|
+
return null;
|
|
36
|
+
const [objectType, objectValue] = terminal;
|
|
37
|
+
if (type === 'domain') {
|
|
38
|
+
const requested = canonicalRdapDomain(requestedValue);
|
|
39
|
+
const selected = canonicalRdapDomain(objectValue);
|
|
40
|
+
return objectType === 'domain' && requested && selected === requested ? admitted : null;
|
|
19
41
|
}
|
|
42
|
+
if (type === 'ipv4' || type === 'ipv6') {
|
|
43
|
+
const version = type === 'ipv4' ? 4 : 6;
|
|
44
|
+
if (objectType !== 'ip' || isIP(requestedValue) !== version || isIP(objectValue) !== version)
|
|
45
|
+
return null;
|
|
46
|
+
try {
|
|
47
|
+
const convert = version === 4 ? ipv4ToLong : ipv6ToBigInt;
|
|
48
|
+
return convert(requestedValue) === convert(objectValue) ? admitted : null;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (type === 'asn') {
|
|
55
|
+
const requestedMatch = /^(?:AS)?([0-9]{1,10})$/iu.exec(requestedValue);
|
|
56
|
+
if (objectType !== 'autnum' || !requestedMatch || !/^[0-9]{1,10}$/u.test(objectValue))
|
|
57
|
+
return null;
|
|
58
|
+
const requested = Number(requestedMatch[1]);
|
|
59
|
+
const selected = Number(objectValue);
|
|
60
|
+
return Number.isSafeInteger(requested)
|
|
61
|
+
&& requested >= 0
|
|
62
|
+
&& requested <= 4_294_967_295
|
|
63
|
+
&& selected === requested
|
|
64
|
+
&& objectValue === String(selected)
|
|
65
|
+
? admitted
|
|
66
|
+
: null;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
function ipv6ToComparableBigInt(ip) {
|
|
20
71
|
try {
|
|
21
|
-
return ipv6ToBigInt(
|
|
72
|
+
return ipv6ToBigInt(ip);
|
|
22
73
|
}
|
|
23
74
|
catch {
|
|
24
75
|
return null;
|
|
@@ -71,11 +122,11 @@ export function validateRdapResponse(type, requestedValue, parsed) {
|
|
|
71
122
|
const version = type === 'ipv4' ? 4 : 6;
|
|
72
123
|
const startAddress = parsed.startAddress;
|
|
73
124
|
const endAddress = parsed.endAddress;
|
|
74
|
-
if (
|
|
125
|
+
if (isIP(requestedValue) !== version ||
|
|
75
126
|
typeof startAddress !== 'string' ||
|
|
76
|
-
|
|
127
|
+
isIP(startAddress) !== version ||
|
|
77
128
|
typeof endAddress !== 'string' ||
|
|
78
|
-
|
|
129
|
+
isIP(endAddress) !== version) {
|
|
79
130
|
return { valid: false, detail: 'The response did not contain a compatible IP range.' };
|
|
80
131
|
}
|
|
81
132
|
const convert = version === 4 ? ipv4ToLong : ipv6ToComparableBigInt;
|
package/lib/rdap.mjs
CHANGED
|
@@ -3,9 +3,9 @@ import { BOOTSTRAP_STALE_TTL_MS, BOOTSTRAP_TTL_MS, clearRdapBootstrapCache, fetc
|
|
|
3
3
|
import { fetchRdapFromBasesWithParser, fetchRdapRecordWithParser, } from "./rdap-client.mjs";
|
|
4
4
|
import { normalizeRdapEvents, parseRdap, summarizeLifecycle, } from "./rdap-normalization.mjs";
|
|
5
5
|
import { fetchRegistrarRdapRecordWithParser, selectRegistrarRdapLink, } from "./rdap-registrar.mjs";
|
|
6
|
-
import {
|
|
6
|
+
import { fetchRdapDetailedWithTimeout, } from "./rdap-transport.mjs";
|
|
7
7
|
import { registryServiceAdmissionFor } from "./registry-capabilities.mjs";
|
|
8
|
-
async function fetchRdapFromBases(type, value, bases, fetchUpstream =
|
|
8
|
+
async function fetchRdapFromBases(type, value, bases, fetchUpstream = fetchRdapDetailedWithTimeout) {
|
|
9
9
|
return fetchRdapFromBasesWithParser(type, value, bases, parseRdap, fetchUpstream);
|
|
10
10
|
}
|
|
11
11
|
async function fetchRdapRecord(type, value, options = {}) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { getDomain } from 'tldts';
|
|
2
|
+
import { isValidAsciiDomainName } from "./hostname.mjs";
|
|
3
|
+
// Returns one canonical registrable-domain identity for an already public DNS
|
|
4
|
+
// hostname. Callers retain the original hostname separately when subdomain
|
|
5
|
+
// context matters; authority, evidence binding and scoring use this value.
|
|
6
|
+
export function canonicalRegistrableDomain(value) {
|
|
7
|
+
if (typeof value !== 'string')
|
|
8
|
+
return null;
|
|
9
|
+
const trimmed = value.trim();
|
|
10
|
+
if (!trimmed || /[\u0000-\u001f\u007f]/u.test(trimmed))
|
|
11
|
+
return null;
|
|
12
|
+
const withoutScheme = trimmed.replace(/^[a-z]+:\/\//iu, '').split(/[/?#]/u)[0] ?? '';
|
|
13
|
+
if (withoutScheme.endsWith('..'))
|
|
14
|
+
return null;
|
|
15
|
+
const candidate = withoutScheme.replace(/\.$/u, '');
|
|
16
|
+
if (!candidate)
|
|
17
|
+
return null;
|
|
18
|
+
let hostname;
|
|
19
|
+
try {
|
|
20
|
+
hostname = new URL(`https://${candidate}/`).hostname.toLowerCase().replace(/\.$/u, '');
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
if (!isValidAsciiDomainName(hostname, { requireDot: true, requireLowercase: true }))
|
|
26
|
+
return null;
|
|
27
|
+
// Keep this boundary identical to classifyQuery() and the collection
|
|
28
|
+
// runtime: registrable identity follows the ICANN Public Suffix List, while
|
|
29
|
+
// privately operated suffixes remain ordinary host context.
|
|
30
|
+
const domain = getDomain(hostname);
|
|
31
|
+
return domain
|
|
32
|
+
&& isValidAsciiDomainName(domain, { requireDot: true, requireLowercase: true })
|
|
33
|
+
&& (hostname === domain || hostname.endsWith(`.${domain}`))
|
|
34
|
+
? domain
|
|
35
|
+
: null;
|
|
36
|
+
}
|
|
@@ -87,6 +87,13 @@ function disclosure(source, parsedValue, status) {
|
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
89
|
const parsed = record(parsedValue);
|
|
90
|
+
if (parsed.contactsExcluded === true) {
|
|
91
|
+
return {
|
|
92
|
+
source,
|
|
93
|
+
state: 'unavailable',
|
|
94
|
+
detail: 'Contact fields are deliberately excluded from this portable projection, so publication disclosure remains unavailable.',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
90
97
|
const partial = source === 'whois'
|
|
91
98
|
? parsed.chainStatus === 'partial' || strings(parsed.fieldsTruncated, 20).length > 0
|
|
92
99
|
: parsed.serverTruncated === true
|
package/lib/response-policy.mjs
CHANGED
|
@@ -184,9 +184,23 @@ function analyzeHsts(header, signals) {
|
|
|
184
184
|
const directives = header.value.split(';').map((part) => part.trim()).filter(Boolean);
|
|
185
185
|
if (directives.length > MAX_RESPONSE_POLICY_DIRECTIVES)
|
|
186
186
|
return 'partial';
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
187
|
+
const seen = new Set();
|
|
188
|
+
let maxAge = null;
|
|
189
|
+
for (const directive of directives) {
|
|
190
|
+
const match = /^([a-z][a-z0-9-]{0,63})(?:\s*=\s*(.*))?$/iu.exec(directive);
|
|
191
|
+
if (!match)
|
|
192
|
+
return 'malformed';
|
|
193
|
+
const name = match[1]?.toLowerCase() ?? '';
|
|
194
|
+
if (seen.has(name))
|
|
195
|
+
return 'malformed';
|
|
196
|
+
seen.add(name);
|
|
197
|
+
if (name === 'max-age') {
|
|
198
|
+
const value = match[2];
|
|
199
|
+
if (!value || !/^\d+$/u.test(value))
|
|
200
|
+
return 'malformed';
|
|
201
|
+
maxAge = value;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
190
204
|
if (!maxAge || maxAge.length > 12)
|
|
191
205
|
return 'malformed';
|
|
192
206
|
const seconds = Number(maxAge);
|
|
@@ -256,10 +270,15 @@ function analyzeCookies(headers, signals) {
|
|
|
256
270
|
let missingHttpOnly = 0;
|
|
257
271
|
let missingSameSite = 0;
|
|
258
272
|
let sameSiteNoneWithoutSecure = 0;
|
|
273
|
+
let attributesTruncated = false;
|
|
259
274
|
for (const value of result.values) {
|
|
260
275
|
const segments = value.split(';');
|
|
261
276
|
if (!segments[0]?.includes('='))
|
|
262
277
|
return { state: 'malformed', count: result.values.length, truncated: result.truncated };
|
|
278
|
+
if (segments.length - 1 > MAX_RESPONSE_POLICY_TOKENS) {
|
|
279
|
+
attributesTruncated = true;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
263
282
|
const attributes = new Map();
|
|
264
283
|
for (const rawAttribute of segments.slice(1, MAX_RESPONSE_POLICY_TOKENS + 1)) {
|
|
265
284
|
const [rawName = '', ...rawValue] = rawAttribute.trim().split('=');
|
|
@@ -289,7 +308,11 @@ function analyzeCookies(headers, signals) {
|
|
|
289
308
|
if (sameSiteNoneWithoutSecure) {
|
|
290
309
|
addSignal(signals, 'cookies_same_site_none_without_secure', sameSiteNoneWithoutSecure);
|
|
291
310
|
}
|
|
292
|
-
return {
|
|
311
|
+
return {
|
|
312
|
+
state: result.state === 'partial' || attributesTruncated ? 'partial' : result.state,
|
|
313
|
+
count: result.values.length,
|
|
314
|
+
truncated: result.truncated || attributesTruncated,
|
|
315
|
+
};
|
|
293
316
|
}
|
|
294
317
|
export function analyzeResponsePolicyHeaders(headers) {
|
|
295
318
|
const signals = [];
|
|
@@ -1,35 +1,25 @@
|
|
|
1
1
|
import { ANALYST_REVIEW_REASON_VALUES } from "./analyst-taxonomy.mjs";
|
|
2
2
|
import { scanBoundedJson } from "./bounded-json.mjs";
|
|
3
3
|
import { RISK_REVIEW_THRESHOLD } from "./risk-scoring.mjs";
|
|
4
|
-
|
|
5
|
-
export const
|
|
6
|
-
export const
|
|
7
|
-
export const
|
|
4
|
+
import { MAX_RISK_CALIBRATION_RECORDS, MAX_RISK_CALIBRATION_JSON_CONTAINER_ITEMS, MAX_RISK_CALIBRATION_JSON_DEPTH, MAX_RISK_CALIBRATION_JSON_KEYS, MAX_RISK_CALIBRATION_JSON_VALUES, MAX_RISK_CALIBRATION_MODEL_VERSION, MAX_RISK_CALIBRATION_REVIEW_REASON_LENGTH, MAX_RISK_CALIBRATION_SUMMARY_BYTES, MAX_RISK_CALIBRATION_SUMMARY_STRATA, MAX_RISK_CALIBRATION_TIMESTAMP_LENGTH, RISK_CALIBRATION_DATASET_SCHEMA, RISK_CALIBRATION_REPORT_CONFIDENCE_KEYS, RISK_CALIBRATION_REPORT_DATASET_KEYS, RISK_CALIBRATION_REPORT_INTERPRETATION_KEYS, RISK_CALIBRATION_REPORT_INTERVAL_KEYS, RISK_CALIBRATION_REPORT_METRIC_KEYS, RISK_CALIBRATION_REPORT_MODEL_COMPARISON_KEYS, RISK_CALIBRATION_REPORT_PRIVACY_KEYS, RISK_CALIBRATION_REPORT_SCHEMA, RISK_CALIBRATION_REPORT_SCORE_BAND_KEYS, RISK_CALIBRATION_REPORT_STRATUM_KEYS, RISK_CALIBRATION_REPORT_SUMMARY_KEYS, RISK_CALIBRATION_REPORT_VERSION, RISK_CALIBRATION_REPORT_V3_SUMMARY_ROOT_KEYS, SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS, } from "../packages/contracts/risk-calibration.mjs";
|
|
5
|
+
export const RISK_CALIBRATION_SUMMARY_SCHEMA = RISK_CALIBRATION_REPORT_SCHEMA;
|
|
6
|
+
export const RISK_CALIBRATION_SUMMARY_VERSION = RISK_CALIBRATION_REPORT_VERSION;
|
|
7
|
+
export const RISK_CALIBRATION_SUMMARY_MAX_BYTES = MAX_RISK_CALIBRATION_SUMMARY_BYTES;
|
|
8
|
+
export const RISK_CALIBRATION_SUMMARY_MAX_RECORDS = MAX_RISK_CALIBRATION_RECORDS;
|
|
8
9
|
export const RISK_CALIBRATION_SUMMARY_MIN_STRATUM_SAMPLE = 20;
|
|
9
10
|
export const RISK_CALIBRATION_SUMMARY_THRESHOLDS = Object.freeze([...new Set([40, 50, 60, RISK_REVIEW_THRESHOLD, 80, 90])].sort((left, right) => left - right));
|
|
10
11
|
export const RISK_CALIBRATION_SUMMARY_INTERPRETATION = 'This offline replay compares heuristic Risk scores with analyst dispositions. It does not prove maliciousness or safety.';
|
|
11
|
-
const ROOT_KEYS = new Set(
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
'balancedAccuracy', 'confidence95',
|
|
23
|
-
]);
|
|
24
|
-
const CONFIDENCE_KEYS = new Set(['precision', 'recall', 'specificity']);
|
|
25
|
-
const INTERVAL_KEYS = new Set(['lower', 'upper']);
|
|
26
|
-
const STRATUM_KEYS = new Set(['dimension', 'value', 'sampleCount', 'insufficientSample', 'metrics']);
|
|
27
|
-
const MODEL_COMPARISON_KEYS = new Set([
|
|
28
|
-
'available', 'previousModelVersion', 'currentModelVersion', 'scoresChanged',
|
|
29
|
-
'bandsChanged', 'thresholdClassificationsChanged',
|
|
30
|
-
]);
|
|
31
|
-
const PRIVACY_KEYS = new Set(['targetsRetained', 'identifiersRetained', 'rawEvidenceRetained']);
|
|
32
|
-
const INTERPRETATION_KEYS = new Set(['authority', 'statement', 'automaticTuning', 'networkRequests', 'persisted']);
|
|
12
|
+
const ROOT_KEYS = new Set(RISK_CALIBRATION_REPORT_V3_SUMMARY_ROOT_KEYS);
|
|
13
|
+
const DATASET_KEYS = new Set(RISK_CALIBRATION_REPORT_DATASET_KEYS);
|
|
14
|
+
const SUMMARY_KEYS = new Set(RISK_CALIBRATION_REPORT_SUMMARY_KEYS);
|
|
15
|
+
const SCORE_BAND_KEYS = new Set(RISK_CALIBRATION_REPORT_SCORE_BAND_KEYS);
|
|
16
|
+
const METRIC_KEYS = new Set(RISK_CALIBRATION_REPORT_METRIC_KEYS);
|
|
17
|
+
const CONFIDENCE_KEYS = new Set(RISK_CALIBRATION_REPORT_CONFIDENCE_KEYS);
|
|
18
|
+
const INTERVAL_KEYS = new Set(RISK_CALIBRATION_REPORT_INTERVAL_KEYS);
|
|
19
|
+
const STRATUM_KEYS = new Set(RISK_CALIBRATION_REPORT_STRATUM_KEYS);
|
|
20
|
+
const MODEL_COMPARISON_KEYS = new Set(RISK_CALIBRATION_REPORT_MODEL_COMPARISON_KEYS);
|
|
21
|
+
const PRIVACY_KEYS = new Set(RISK_CALIBRATION_REPORT_PRIVACY_KEYS);
|
|
22
|
+
const INTERPRETATION_KEYS = new Set(RISK_CALIBRATION_REPORT_INTERPRETATION_KEYS);
|
|
33
23
|
const REVIEW_REASON_VALUES = new Set([...ANALYST_REVIEW_REASON_VALUES, 'not_recorded']);
|
|
34
24
|
const SCAN_DEPTH_VALUES = new Set(['deep', 'fast', 'unknown']);
|
|
35
25
|
const CONTROL_RE = /[\x00-\x1f\x7f]/u;
|
|
@@ -51,13 +41,15 @@ function boundedCount(value, label, maximum = RISK_CALIBRATION_SUMMARY_MAX_RECOR
|
|
|
51
41
|
return Number(value);
|
|
52
42
|
}
|
|
53
43
|
function modelVersion(value, label) {
|
|
54
|
-
if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) >
|
|
44
|
+
if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > MAX_RISK_CALIBRATION_MODEL_VERSION) {
|
|
55
45
|
throw new Error(`${label} must be a supported positive model version.`);
|
|
56
46
|
}
|
|
57
47
|
return Number(value);
|
|
58
48
|
}
|
|
59
49
|
function canonicalTimestamp(value, label) {
|
|
60
|
-
if (typeof value !== 'string'
|
|
50
|
+
if (typeof value !== 'string'
|
|
51
|
+
|| value.length > MAX_RISK_CALIBRATION_TIMESTAMP_LENGTH
|
|
52
|
+
|| !Number.isFinite(Date.parse(value))) {
|
|
61
53
|
throw new Error(`${label} must be a valid date and time.`);
|
|
62
54
|
}
|
|
63
55
|
const canonical = new Date(value).toISOString();
|
|
@@ -154,7 +146,12 @@ export function parseRiskCalibrationSummaryReport(raw) {
|
|
|
154
146
|
let parsed;
|
|
155
147
|
try {
|
|
156
148
|
const withoutBom = raw.replace(/^\uFEFF/u, '');
|
|
157
|
-
scanBoundedJson(withoutBom
|
|
149
|
+
scanBoundedJson(withoutBom, {
|
|
150
|
+
maximumDepth: MAX_RISK_CALIBRATION_JSON_DEPTH,
|
|
151
|
+
maximumKeys: MAX_RISK_CALIBRATION_JSON_KEYS,
|
|
152
|
+
maximumValues: MAX_RISK_CALIBRATION_JSON_VALUES,
|
|
153
|
+
maximumContainerItems: MAX_RISK_CALIBRATION_JSON_CONTAINER_ITEMS,
|
|
154
|
+
});
|
|
158
155
|
parsed = JSON.parse(withoutBom);
|
|
159
156
|
}
|
|
160
157
|
catch (reason) {
|
|
@@ -172,7 +169,9 @@ export function parseRiskCalibrationSummaryReport(raw) {
|
|
|
172
169
|
}
|
|
173
170
|
const dataset = object(root.dataset, 'Risk calibration dataset metadata');
|
|
174
171
|
exactKeys(dataset, DATASET_KEYS, 'Risk calibration dataset metadata');
|
|
175
|
-
if (dataset.schema !==
|
|
172
|
+
if (dataset.schema !== RISK_CALIBRATION_DATASET_SCHEMA
|
|
173
|
+
|| typeof dataset.version !== 'number'
|
|
174
|
+
|| !SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS.includes(dataset.version)) {
|
|
176
175
|
throw new Error('Risk calibration dataset metadata has an unsupported schema or version.');
|
|
177
176
|
}
|
|
178
177
|
const recordCount = boundedCount(dataset.recordCount, 'Risk calibration dataset record count');
|
|
@@ -210,7 +209,7 @@ export function parseRiskCalibrationSummaryReport(raw) {
|
|
|
210
209
|
throw new Error('Risk calibration summary must contain the fixed threshold set exactly once.');
|
|
211
210
|
}
|
|
212
211
|
const thresholds = Object.freeze(root.thresholds.map((value, index) => thresholdMetrics(value, `Threshold ${index + 1}`, RISK_CALIBRATION_SUMMARY_THRESHOLDS[index], positive + negative, positive, negative)));
|
|
213
|
-
if (!Array.isArray(root.strata) || root.strata.length >
|
|
212
|
+
if (!Array.isArray(root.strata) || root.strata.length > MAX_RISK_CALIBRATION_SUMMARY_STRATA) {
|
|
214
213
|
throw new Error('Risk calibration strata must be a bounded array.');
|
|
215
214
|
}
|
|
216
215
|
const seenStrata = new Set();
|
|
@@ -221,7 +220,10 @@ export function parseRiskCalibrationSummaryReport(raw) {
|
|
|
221
220
|
if (stratum.dimension !== 'review_reason' && stratum.dimension !== 'scan_depth') {
|
|
222
221
|
throw new Error(`${label} has an unsupported dimension.`);
|
|
223
222
|
}
|
|
224
|
-
if (typeof stratum.value !== 'string'
|
|
223
|
+
if (typeof stratum.value !== 'string'
|
|
224
|
+
|| !stratum.value
|
|
225
|
+
|| stratum.value.length > MAX_RISK_CALIBRATION_REVIEW_REASON_LENGTH
|
|
226
|
+
|| CONTROL_RE.test(stratum.value)) {
|
|
225
227
|
throw new Error(`${label} has an invalid value.`);
|
|
226
228
|
}
|
|
227
229
|
const allowedValues = stratum.dimension === 'review_reason' ? REVIEW_REASON_VALUES : SCAN_DEPTH_VALUES;
|
|
@@ -290,7 +292,7 @@ export function parseRiskCalibrationSummaryReport(raw) {
|
|
|
290
292
|
mode: 'summary',
|
|
291
293
|
generatedAt: canonicalTimestamp(root.generatedAt, 'Risk calibration generation time'),
|
|
292
294
|
dataset: Object.freeze({
|
|
293
|
-
schema:
|
|
295
|
+
schema: RISK_CALIBRATION_DATASET_SCHEMA,
|
|
294
296
|
version: dataset.version,
|
|
295
297
|
recordCount,
|
|
296
298
|
}),
|
package/lib/risk-scoring.mjs
CHANGED
|
@@ -97,8 +97,9 @@ function scoreQuality(input) {
|
|
|
97
97
|
if ([input.hasPasswordField, input.hasExternalFormAction].some(hasBoolean) || typeof input.phishingLanguageMatch === 'string') {
|
|
98
98
|
observedFamilies.push('credential-lure');
|
|
99
99
|
}
|
|
100
|
-
if (input.threatIntelligence
|
|
100
|
+
if (calibrateExternalIntelligenceRisk(input.threatIntelligence, input.domain).eligibleProviderCount > 0) {
|
|
101
101
|
observedFamilies.push('external-intelligence');
|
|
102
|
+
}
|
|
102
103
|
if ([input.hasMx, input.hasSpf, input.hasDmarc].some(hasBoolean)
|
|
103
104
|
|| typeof input.activityStatus === 'string'
|
|
104
105
|
|| typeof input.domainAgeDays === 'number')
|
|
@@ -156,7 +157,7 @@ function explainRiskScoreInternal(input, excludedFamily) {
|
|
|
156
157
|
else if (input.hasExternalFormAction === true) {
|
|
157
158
|
add('credential-lure', 'External form destination observed without a password field', 0);
|
|
158
159
|
}
|
|
159
|
-
const external = calibrateExternalIntelligenceRisk(input.threatIntelligence);
|
|
160
|
+
const external = calibrateExternalIntelligenceRisk(input.threatIntelligence, input.domain);
|
|
160
161
|
if (external.factor)
|
|
161
162
|
add('external-intelligence', external.factor.label, external.contribution);
|
|
162
163
|
const primaryFamilies = ['domain-resemblance', 'brand-presentation', 'credential-lure', 'external-intelligence']
|
|
@@ -279,7 +280,7 @@ export function explainRiskScoreV6(input) {
|
|
|
279
280
|
}
|
|
280
281
|
if (contextual.size >= 2)
|
|
281
282
|
add('corroboration', `Corroborating context across ${contextual.size} distinct evidence families`, contextual.size >= 3 ? 20 : 10);
|
|
282
|
-
const external = calibrateExternalIntelligenceRisk(input.threatIntelligence);
|
|
283
|
+
const external = calibrateExternalIntelligenceRisk(input.threatIntelligence, input.domain);
|
|
283
284
|
if (external.factor)
|
|
284
285
|
add('external-intelligence', external.factor.label, external.contribution);
|
|
285
286
|
if (input.activityStatus === 'active')
|
package/lib/safe-fetch.mjs
CHANGED
|
@@ -434,34 +434,43 @@ async function readTextCapped(res, maxBytes, options = {}) {
|
|
|
434
434
|
throw new Error('Response body does not expose a bounded readable stream');
|
|
435
435
|
}
|
|
436
436
|
const reader = res.body.getReader();
|
|
437
|
-
const decoder = new TextDecoder();
|
|
437
|
+
const decoder = new TextDecoder('utf-8', { fatal: options.fatalUtf8 === true });
|
|
438
438
|
let text = '';
|
|
439
439
|
let received = 0;
|
|
440
440
|
let truncated = false;
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
441
|
+
try {
|
|
442
|
+
while (received < maxBytes) {
|
|
443
|
+
const { done, value } = await reader.read();
|
|
444
|
+
if (done)
|
|
445
|
+
break;
|
|
446
|
+
const remaining = maxBytes - received;
|
|
447
|
+
const captured = value.subarray(0, remaining);
|
|
448
|
+
hasher?.update(captured);
|
|
449
|
+
received += captured.byteLength;
|
|
450
|
+
text += decoder.decode(captured, { stream: true });
|
|
451
|
+
if (captured.byteLength < value.byteLength) {
|
|
452
|
+
truncated = true;
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
// If the cap was hit, one more read() tells us whether there was actually
|
|
457
|
+
// more data left (truncated) or the body just happened to end right there.
|
|
458
|
+
if (!truncated && received >= maxBytes) {
|
|
459
|
+
const { done } = await reader.read();
|
|
460
|
+
if (!done)
|
|
461
|
+
truncated = true;
|
|
453
462
|
}
|
|
463
|
+
text += decoder.decode();
|
|
464
|
+
return result(text, truncated, received);
|
|
454
465
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
466
|
+
finally {
|
|
467
|
+
try {
|
|
468
|
+
reader.cancel().catch(() => { });
|
|
469
|
+
}
|
|
470
|
+
finally {
|
|
471
|
+
reader.releaseLock();
|
|
472
|
+
}
|
|
461
473
|
}
|
|
462
|
-
reader.cancel().catch(() => { });
|
|
463
|
-
text += decoder.decode();
|
|
464
|
-
return result(text, truncated, received);
|
|
465
474
|
}
|
|
466
475
|
// Binary-safe counterpart to readTextCapped, for a response body that isn't
|
|
467
476
|
// text (a favicon image) - collects raw bytes into a Buffer instead of
|
|
@@ -476,26 +485,39 @@ async function readBytesCapped(res, maxBytes) {
|
|
|
476
485
|
const chunks = [];
|
|
477
486
|
let received = 0;
|
|
478
487
|
let truncated = false;
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
488
|
+
// Cancel unread body data and release the exclusive reader lock on every
|
|
489
|
+
// exit path, exactly as readTextCapped does. A mid-stream read failure (an
|
|
490
|
+
// upstream reset while fetching an untrusted favicon) must not leave the
|
|
491
|
+
// response locked to this reader.
|
|
492
|
+
try {
|
|
493
|
+
while (received < maxBytes) {
|
|
494
|
+
const { done, value } = await reader.read();
|
|
495
|
+
if (done)
|
|
496
|
+
break;
|
|
497
|
+
const remaining = maxBytes - received;
|
|
498
|
+
const captured = value.subarray(0, remaining);
|
|
499
|
+
received += captured.byteLength;
|
|
500
|
+
chunks.push(Buffer.from(captured));
|
|
501
|
+
if (captured.byteLength < value.byteLength) {
|
|
502
|
+
truncated = true;
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
// Same "one more read() to check for leftover data" trick as readTextCapped.
|
|
507
|
+
if (!truncated && received >= maxBytes) {
|
|
508
|
+
const { done } = await reader.read();
|
|
509
|
+
if (!done)
|
|
510
|
+
truncated = true;
|
|
490
511
|
}
|
|
512
|
+
return { bytes: Buffer.concat(chunks), truncated, bytesRead: received };
|
|
491
513
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
514
|
+
finally {
|
|
515
|
+
try {
|
|
516
|
+
reader.cancel().catch(() => { });
|
|
517
|
+
}
|
|
518
|
+
finally {
|
|
519
|
+
reader.releaseLock();
|
|
520
|
+
}
|
|
497
521
|
}
|
|
498
|
-
reader.cancel().catch(() => { });
|
|
499
|
-
return { bytes: Buffer.concat(chunks), truncated, bytesRead: received };
|
|
500
522
|
}
|
|
501
523
|
export { MAX_REDIRECTS, MAX_SAFE_FETCH_URL_LENGTH, MAX_SAFE_FETCH_ADDRESS_CANDIDATES, safeFetch, safeFetchDetailed, readTextCapped, readBytesCapped, isPrivateAddress, resolvePublicAddresses, };
|
package/lib/security-txt.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Risk. Published contact and policy values are normalized into a small,
|
|
5
5
|
// bounded record; the response body itself is discarded after parsing.
|
|
6
6
|
import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
|
|
7
|
-
import { createObservation } from "
|
|
7
|
+
import { createObservation } from "../packages/evidence/observation.mjs";
|
|
8
8
|
const SECURITY_TXT_VERSION = 1;
|
|
9
9
|
const SECURITY_TXT_PATH = '/.well-known/security.txt';
|
|
10
10
|
const SECURITY_TXT_TIMEOUT_MS = 6_000;
|
|
@@ -11,6 +11,7 @@ import * as tls from 'node:tls';
|
|
|
11
11
|
import { DNS_TYPE_A, DNS_TYPE_AAAA, DNS_TYPE_CNAME, DNS_TYPE_TLSA, DnssecQuerySession, collectValidatedRrset, normalizeDnsName, normalizeResolverEndpoint, parseDnssecTrustAnchor, validateDnssecChainWithContext, } from "./dnssec-chain-validation.mjs";
|
|
12
12
|
import { isPrivateAddress } from "./safe-fetch.mjs";
|
|
13
13
|
import { analyzeTlsaEvidence } from "./tlsa-evidence.mjs";
|
|
14
|
+
import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
14
15
|
const MAIL_TRANSPORT_INPUT_SCHEMA = 'whoisleuth.mail-transport.input';
|
|
15
16
|
const MAIL_TRANSPORT_INPUT_VERSION = 1;
|
|
16
17
|
const MAIL_TRANSPORT_REVIEW_SCHEMA = 'whoisleuth.cli.mail-transport-review';
|
|
@@ -45,10 +46,12 @@ function exactRecord(value, keys, label) {
|
|
|
45
46
|
throw new TypeError(`${label} contains unsupported field "${unexpected}".`);
|
|
46
47
|
return record;
|
|
47
48
|
}
|
|
48
|
-
function timestamp(value, label) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
function timestamp(value, label, legacy = false) {
|
|
50
|
+
const normalized = normalizeExplicitIsoTimestamp(value)
|
|
51
|
+
?? (legacy ? normalizeLegacyIsoTimestamp(value) : null);
|
|
52
|
+
if (!normalized)
|
|
53
|
+
throw new TypeError(`${label} must be one bounded ISO timestamp${legacy ? '' : ' with an explicit timezone'}.`);
|
|
54
|
+
return normalized;
|
|
52
55
|
}
|
|
53
56
|
function text(value, label, maximum = 160) {
|
|
54
57
|
if (typeof value !== 'string' || value.length > maximum * 4)
|
|
@@ -70,7 +73,7 @@ function policyEvidence(value, label) {
|
|
|
70
73
|
if (!allowedStates.includes(state) || !['complete', 'partial', 'unavailable'].includes(completeness))
|
|
71
74
|
throw new TypeError(`policyContext.${label} has an unsupported state or completeness.`);
|
|
72
75
|
const source = record.source === null ? null : text(record.source, `policyContext.${label}.source`, 160);
|
|
73
|
-
const observedAt = record.observedAt === null ? null : timestamp(record.observedAt, `policyContext.${label}.observedAt
|
|
76
|
+
const observedAt = record.observedAt === null ? null : timestamp(record.observedAt, `policyContext.${label}.observedAt`, true);
|
|
74
77
|
const unavailable = completeness === 'unavailable';
|
|
75
78
|
if ((unavailable && (source !== null || observedAt !== null)) || (!unavailable && (source === null || observedAt === null))) {
|
|
76
79
|
throw new TypeError(`policyContext.${label} unavailable evidence must omit source and time; retained evidence requires both.`);
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// positive match, but a stale miss remains inconclusive.
|
|
4
4
|
import { SSLBL_CERTIFICATE_SNAPSHOT } from "./sslbl-certificates.generated.mjs";
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
|
+
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
7
|
+
import { SSLBL_SNAPSHOT_SCHEMA, SSLBL_SNAPSHOT_VERSION, } from "../packages/contracts/sslbl-snapshot.mjs";
|
|
6
8
|
export const SSLBL_INTELLIGENCE_VERSION = 1;
|
|
7
9
|
export const SSLBL_SNAPSHOT_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
|
|
8
10
|
export const SSLBL_SNAPSHOT_EXPIRED_AGE_MS = 45 * 24 * 60 * 60 * 1000;
|
|
@@ -11,7 +13,6 @@ export const MAX_SSLBL_FINGERPRINT_CHUNKS = 1_000;
|
|
|
11
13
|
export const SSLBL_SOURCE_PAGE = 'https://sslbl.abuse.ch/blacklist/';
|
|
12
14
|
const SHA1_RE = /^[a-f0-9]{40}$/u;
|
|
13
15
|
const SHA256_RE = /^[a-f0-9]{64}$/u;
|
|
14
|
-
const SNAPSHOT_SCHEMA = 'whoisleuth.sslbl-certificate-snapshot';
|
|
15
16
|
const SNAPSHOT_SOURCE = 'https://sslbl.abuse.ch/blacklist/sslblacklist.csv';
|
|
16
17
|
function record(value) {
|
|
17
18
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
@@ -19,10 +20,7 @@ function record(value) {
|
|
|
19
20
|
: {};
|
|
20
21
|
}
|
|
21
22
|
function timestamp(value) {
|
|
22
|
-
|
|
23
|
-
return null;
|
|
24
|
-
const parsed = Date.parse(value);
|
|
25
|
-
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
23
|
+
return normalizeExplicitIsoTimestamp(value);
|
|
26
24
|
}
|
|
27
25
|
function normalizedFingerprint(value) {
|
|
28
26
|
if (typeof value !== 'string')
|
|
@@ -39,8 +37,8 @@ function validateSnapshot(value) {
|
|
|
39
37
|
const candidate = record(value);
|
|
40
38
|
const sourceUpdatedAt = timestamp(candidate.sourceUpdatedAt);
|
|
41
39
|
const generatedAt = timestamp(candidate.generatedAt);
|
|
42
|
-
if (candidate.schema !==
|
|
43
|
-
|| candidate.version !==
|
|
40
|
+
if (candidate.schema !== SSLBL_SNAPSHOT_SCHEMA
|
|
41
|
+
|| candidate.version !== SSLBL_SNAPSHOT_VERSION
|
|
44
42
|
|| candidate.source !== SNAPSHOT_SOURCE
|
|
45
43
|
|| !sourceUpdatedAt
|
|
46
44
|
|| !generatedAt
|
|
@@ -95,7 +93,7 @@ function effectiveNow(value) {
|
|
|
95
93
|
: typeof value === 'number'
|
|
96
94
|
? value
|
|
97
95
|
: typeof value === 'string'
|
|
98
|
-
? Date.parse(value)
|
|
96
|
+
? Date.parse(normalizeExplicitIsoTimestamp(value) ?? '')
|
|
99
97
|
: Date.now();
|
|
100
98
|
return Number.isFinite(candidate) ? candidate : Date.now();
|
|
101
99
|
}
|