@slicedearth/whoisleuth-cli 1.45.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DISCLOSURE +7 -3
- package/NOTICE +5 -4
- package/README.md +74 -124
- package/SECURITY.md +6 -3
- package/bin/whoisleuth.mjs +7 -4
- package/cli/archive-inspect.mjs +28 -10
- package/cli/arguments.mjs +450 -173
- package/cli/artifact-structure.mjs +2507 -0
- package/cli/artifact-verify.mjs +286 -64
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +35 -23
- package/cli/bulk-command-runner.mjs +2 -5
- package/cli/bulk.mjs +9 -2
- package/cli/case-pack.mjs +435 -46
- 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 +948 -106
- package/cli/completion.mjs +504 -218
- package/cli/config-profile.mjs +13 -3
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discover.mjs +7 -1
- package/cli/discovery-observation-snapshot.mjs +24 -28
- package/cli/discovery-scan-command-runner.mjs +13 -14
- package/cli/discovery-scan.mjs +17 -3
- package/cli/discovery-snapshot.mjs +15 -7
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +215 -36
- package/cli/domain-control-observations.mjs +34 -17
- package/cli/errors.mjs +8 -3
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +68 -50
- package/cli/export-evidence.mjs +11 -4
- package/cli/external-observation-mapping.mjs +3 -3
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/json.mjs +20 -32
- package/cli/formatters/terminal.mjs +586 -22
- package/cli/input.mjs +62 -0
- package/cli/interactive-launcher.mjs +226 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-plan.mjs +167 -16
- package/cli/investigation-run.mjs +80 -24
- package/cli/local-mmdb-review.mjs +45 -14
- 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 +96 -33
- package/cli/lookup-diff.mjs +11 -2
- package/cli/lookup-plan.mjs +1 -0
- package/cli/mail-review.mjs +206 -94
- package/cli/manual.mjs +5 -5
- package/cli/network-command-runner.mjs +78 -0
- package/cli/offline-evidence-review.mjs +45 -14
- package/cli/open-asset-model-bridge.mjs +1 -1
- package/cli/output-file.mjs +70 -14
- package/cli/page-compare.mjs +18 -10
- package/cli/progress-events.mjs +2 -1
- package/cli/progress.mjs +3 -3
- package/cli/registry-cohort.mjs +343 -61
- package/cli/registry-doctor.mjs +67 -28
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +375 -0
- package/cli/risk-calibration.mjs +89 -40
- package/cli/runner.mjs +322 -246
- package/cli/saved-lookup.mjs +439 -49
- package/cli/sharing-review.mjs +18 -7
- package/cli/source-reliability.mjs +75 -36
- package/cli/terminal-presentation.mjs +43 -18
- package/docs/cli-reference.md +125 -1334
- package/docs/cli.md +87 -236
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -0
- package/frontend/src/lib/analysis/domain-control-records.js +3 -0
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +72 -52
- package/lib/bounded-contract-normalizers.mjs +2 -4
- package/lib/bounded-file.mjs +91 -4
- package/lib/bounded-json.mjs +287 -0
- package/lib/browser-library-profile.mjs +1 -1
- package/lib/certificate-extension-profile.mjs +2 -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 +173 -0
- package/lib/ct-query.mjs +3 -3
- package/lib/ct-search.mjs +120 -64
- package/lib/distributed-operation-budget.mjs +4 -2
- package/lib/dns-delegation-health.mjs +9 -6
- package/lib/dns-intelligence.mjs +4 -3
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1052 -0
- package/lib/dnssec-evidence-validation.mjs +23 -10
- package/lib/domain-assurance.mjs +12 -12
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-flight-recorder.mjs +442 -78
- package/lib/domain-control-manifest.mjs +314 -171
- package/lib/domain-posture-analysis.mjs +5 -2
- package/lib/domain-posture.mjs +7 -4
- package/lib/encrypted-dns-contract.mjs +2 -3
- package/lib/evidence-export.mjs +1243 -88
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/external-intelligence-risk.mjs +26 -10
- package/lib/favicon.mjs +8 -5
- 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-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +332 -6
- package/lib/interchange-fidelity-registry.mjs +124 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-cache.mjs +6 -4
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup.mjs +27 -4
- package/lib/observed-network-context.mjs +1 -1
- package/lib/outbound-identity.mjs +2 -3
- package/lib/page-fingerprints.mjs +2 -2
- package/lib/page-role-profile.mjs +1 -1
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +29 -0
- package/lib/provider-policy-admission.mjs +11 -4
- package/lib/rdap-bootstrap.mjs +76 -16
- package/lib/rdap-client.mjs +20 -12
- package/lib/rdap-normalization.mjs +324 -80
- package/lib/rdap-registrar.mjs +5 -1
- 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-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/registry-insights.mjs +7 -0
- package/lib/response-policy.mjs +27 -4
- package/lib/risk-calibration-summary.mjs +340 -0
- package/lib/risk-scoring.mjs +4 -3
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +121 -50
- package/lib/security-txt.mjs +27 -4
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +787 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- 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 +5 -15
- package/lib/threat-intelligence-types.mjs +21 -1
- package/lib/threatfox-intelligence.mjs +7 -3
- package/lib/tls-intelligence.mjs +15 -9
- package/lib/tlsa-evidence.mjs +37 -7
- 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 +12 -4
- package/packages/cases/case-brand-profile-references.mjs +85 -0
- package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +43 -14
- package/packages/cases/case-investigation-branch-model.mjs +132 -0
- 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 -19
- 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} +106 -33
- package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +143 -16
- 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/packages/cases/opaque-reference-id.mjs +9 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +366 -0
- package/packages/comparison/comparison-ledger-contract.mjs +241 -0
- package/packages/comparison/comparison-ledger-json.mjs +80 -0
- package/packages/comparison/comparison-ledger-serialization.mjs +52 -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/packages/evidence/artifact-integrity.mjs +65 -0
- 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} +86 -20
- package/packages/interchange/retained-text.mjs +8 -0
- package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +11 -8
- package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +28 -9
- package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +67 -18
- package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +10 -9
- package/packages/investigation/bulk-review-policy.mjs +1 -0
- package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +50 -18
- 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/packages/investigation/lookup-claim-passport.mjs +232 -0
- package/packages/investigation/lookup-claim-readiness.mjs +287 -0
- 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} +97 -50
- package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +15 -15
- package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +179 -39
- package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +12 -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} +51 -46
- package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +15 -15
- 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} +18 -12
- 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} +195 -84
- package/third-party-notices.txt +112 -5834
- package/frontend/src/lib/analysis/artifact-integrity.js +0 -21
- 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 -253
- package/frontend/src/lib/analysis/case-record-model.js +0 -7
- package/frontend/src/lib/analysis/case-response-model.js +0 -669
- package/frontend/src/lib/analysis/case-response-packet.js +0 -573
- package/frontend/src/lib/analysis/lookup-decision-support.js +0 -487
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
- package/frontend/src/lib/analysis/lookup-presentation.js +0 -58
- 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 -232
- 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 -88
- package/frontend/src/lib/analysis/visualization-models.js +0 -369
- package/frontend/src/lib/analysis/visualization-time-series.js +0 -144
- package/lib/bounded-json-response.mjs +0 -157
- package/lib/lookup-response-contract.mjs +0 -346
- package/lib/observation.mjs +0 -114
- /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
|
@@ -3,24 +3,14 @@
|
|
|
3
3
|
// network requests and contains no provider credentials. Adapters added later
|
|
4
4
|
// must declare exact input exposure, reviewed terms, and bounded request and
|
|
5
5
|
// output budgets before producing separately attributed normalized evidence.
|
|
6
|
-
import { createObservation } from "
|
|
6
|
+
import { createObservation } from "../packages/evidence/observation.mjs";
|
|
7
|
+
import { THREAT_INTELLIGENCE_CONTRACT_VERSION, THREAT_INTELLIGENCE_CATEGORIES, THREAT_INTELLIGENCE_ENVELOPE_VERSION, THREAT_INTELLIGENCE_RESULT_STATES, THREAT_INTELLIGENCE_SCHEMA, } from "./threat-intelligence-types.mjs";
|
|
7
8
|
import { assertCuratedConnectorDefinition, assertThreatIntelligenceProvider, } from "./threat-intelligence-definition-registry.mjs";
|
|
8
9
|
import { boundedHttpsUrl, boundedString, isRecord, isoTimestamp, strictBoundedString, } from "./bounded-contract-normalizers.mjs";
|
|
9
10
|
import { CURATED_CONNECTOR_ENTITY_TYPES, MAX_THREAT_INTELLIGENCE_URL_LENGTH, normalizeCuratedConnectorTarget, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-targets.mjs";
|
|
10
|
-
const THREAT_INTELLIGENCE_CONTRACT_VERSION = 1;
|
|
11
|
-
const THREAT_INTELLIGENCE_SCHEMA = 'whoisleuth.threat-intelligence-result';
|
|
12
11
|
const CURATED_CONNECTOR_CONTRACT_VERSION = 1;
|
|
13
12
|
const CURATED_CONNECTOR_RESULT_SCHEMA = 'whoisleuth.curated-connector-result';
|
|
14
|
-
const RESULT_STATES = new Set(
|
|
15
|
-
'success',
|
|
16
|
-
'partial',
|
|
17
|
-
'not_found',
|
|
18
|
-
'unsupported',
|
|
19
|
-
'skipped',
|
|
20
|
-
'rate_limited',
|
|
21
|
-
'unavailable',
|
|
22
|
-
'error',
|
|
23
|
-
]);
|
|
13
|
+
const RESULT_STATES = new Set(THREAT_INTELLIGENCE_RESULT_STATES);
|
|
24
14
|
const TERMINAL_STATES_WITHOUT_FINDINGS = new Set([
|
|
25
15
|
'not_found',
|
|
26
16
|
'unsupported',
|
|
@@ -29,7 +19,7 @@ const TERMINAL_STATES_WITHOUT_FINDINGS = new Set([
|
|
|
29
19
|
'unavailable',
|
|
30
20
|
'error',
|
|
31
21
|
]);
|
|
32
|
-
const CATEGORIES = new Set(
|
|
22
|
+
const CATEGORIES = new Set(THREAT_INTELLIGENCE_CATEGORIES);
|
|
33
23
|
const SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'unknown']);
|
|
34
24
|
const CONFIDENCES = new Set(['high', 'medium', 'low', 'unknown']);
|
|
35
25
|
const CONNECTOR_RELATIONSHIP_TYPES = new Set([
|
|
@@ -658,4 +648,4 @@ function createCuratedConnectorResult(connector, target, input = {}, observedAt
|
|
|
658
648
|
}),
|
|
659
649
|
};
|
|
660
650
|
}
|
|
661
|
-
export { THREAT_INTELLIGENCE_CONTRACT_VERSION, THREAT_INTELLIGENCE_SCHEMA, CURATED_CONNECTOR_CONTRACT_VERSION, CURATED_CONNECTOR_RESULT_SCHEMA, MAX_FINDINGS, MAX_INPUT_FINDINGS, MAX_TIMEOUT_MS, MAX_RESPONSE_BYTES, MAX_CACHE_TTL_MS, MAX_CONNECTOR_ENTITIES, MAX_CONNECTOR_RELATIONSHIPS, MAX_CONNECTOR_INPUT_ENTITIES, MAX_CONNECTOR_INPUT_RELATIONSHIPS, normalizeThreatIntelligenceTarget, normalizeCuratedConnectorTarget, createThreatIntelligenceResult, createCuratedConnectorResult, };
|
|
651
|
+
export { THREAT_INTELLIGENCE_CONTRACT_VERSION, THREAT_INTELLIGENCE_ENVELOPE_VERSION, THREAT_INTELLIGENCE_SCHEMA, CURATED_CONNECTOR_CONTRACT_VERSION, CURATED_CONNECTOR_RESULT_SCHEMA, MAX_FINDINGS, MAX_INPUT_FINDINGS, MAX_TIMEOUT_MS, MAX_RESPONSE_BYTES, MAX_CACHE_TTL_MS, MAX_CONNECTOR_ENTITIES, MAX_CONNECTOR_RELATIONSHIPS, MAX_CONNECTOR_INPUT_ENTITIES, MAX_CONNECTOR_INPUT_RELATIONSHIPS, normalizeThreatIntelligenceTarget, normalizeCuratedConnectorTarget, createThreatIntelligenceResult, createCuratedConnectorResult, };
|
|
@@ -1,3 +1,23 @@
|
|
|
1
1
|
// Provider-neutral types shared by threat-intelligence and curated connector
|
|
2
2
|
// manifests, fixture harnesses, normalizers, and result builders.
|
|
3
|
-
export
|
|
3
|
+
export const THREAT_INTELLIGENCE_SCHEMA = 'whoisleuth.threat-intelligence-result';
|
|
4
|
+
export const THREAT_INTELLIGENCE_CONTRACT_VERSION = 1;
|
|
5
|
+
export const THREAT_INTELLIGENCE_ENVELOPE_VERSION = 1;
|
|
6
|
+
export const THREAT_INTELLIGENCE_CATEGORIES = Object.freeze([
|
|
7
|
+
'phishing',
|
|
8
|
+
'malware',
|
|
9
|
+
'spam',
|
|
10
|
+
'suspicious',
|
|
11
|
+
'abuse',
|
|
12
|
+
'unknown',
|
|
13
|
+
]);
|
|
14
|
+
export const THREAT_INTELLIGENCE_RESULT_STATES = Object.freeze([
|
|
15
|
+
'success',
|
|
16
|
+
'partial',
|
|
17
|
+
'not_found',
|
|
18
|
+
'unsupported',
|
|
19
|
+
'skipped',
|
|
20
|
+
'rate_limited',
|
|
21
|
+
'unavailable',
|
|
22
|
+
'error',
|
|
23
|
+
]);
|
|
@@ -6,6 +6,7 @@ import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
|
|
|
6
6
|
import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
|
|
7
7
|
import { providerPolicyAdmission } from "./provider-policy-admission.mjs";
|
|
8
8
|
import { createThreatIntelligenceResult, defineThreatIntelligenceProvider, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-contract.mjs";
|
|
9
|
+
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
9
10
|
const THREATFOX_SEARCH_ENDPOINT = 'https://threatfox-api.abuse.ch/api/v1/';
|
|
10
11
|
const THREATFOX_MAX_RESULTS = 20;
|
|
11
12
|
const THREATFOX_MAX_INPUT_RESULTS = 100;
|
|
@@ -75,8 +76,7 @@ function isoTimestamp(value) {
|
|
|
75
76
|
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?: UTC)?$/u.test(raw)
|
|
76
77
|
? `${raw.slice(0, 10)}T${raw.slice(11, 19)}Z`
|
|
77
78
|
: raw;
|
|
78
|
-
|
|
79
|
-
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
|
|
79
|
+
return normalizeExplicitIsoTimestamp(normalized);
|
|
80
80
|
}
|
|
81
81
|
function normalizedTags(value) {
|
|
82
82
|
if (!Array.isArray(value))
|
|
@@ -112,7 +112,11 @@ function normalizeIocFinding(value, targetDomain) {
|
|
|
112
112
|
if (!id || !/^\d{1,32}$/u.test(id) || ioc !== targetDomain || iocType !== 'domain' || !threatType)
|
|
113
113
|
return null;
|
|
114
114
|
const firstObservedAt = isoTimestamp(value.first_seen);
|
|
115
|
-
const
|
|
115
|
+
const parsedLastObservedAt = isoTimestamp(value.last_seen);
|
|
116
|
+
if ((value.first_seen !== undefined && value.first_seen !== null && !firstObservedAt)
|
|
117
|
+
|| (value.last_seen !== undefined && value.last_seen !== null && !parsedLastObservedAt))
|
|
118
|
+
return null;
|
|
119
|
+
const lastObservedAt = parsedLastObservedAt || firstObservedAt;
|
|
116
120
|
const tags = normalizedTags(value.tags);
|
|
117
121
|
tags.push(`role:${threatType}`);
|
|
118
122
|
if (malware)
|
package/lib/tls-intelligence.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Bounded, one-connection TLS and certificate
|
|
1
|
+
// Bounded, one-connection TLS and certificate evidence for deep scans.
|
|
2
2
|
//
|
|
3
3
|
// The collector resolves the submitted hostname once through the same public-
|
|
4
4
|
// address guard used by safe-fetch, validates every returned address again at
|
|
@@ -10,19 +10,20 @@ import * as crypto from 'node:crypto';
|
|
|
10
10
|
import * as net from 'node:net';
|
|
11
11
|
import * as tls from 'node:tls';
|
|
12
12
|
import { domainToASCII } from 'node:url';
|
|
13
|
-
import { createObservation } from "
|
|
13
|
+
import { createObservation } from "../packages/evidence/observation.mjs";
|
|
14
|
+
import { MAX_LOOKUP_TLS_ALT_NAMES, MAX_LOOKUP_TLS_CHAIN_CERTIFICATES, MAX_LOOKUP_TLS_FINDINGS, MAX_LOOKUP_TLS_NAME_VALUES, } from "./lookup-network-evidence-bounds.mjs";
|
|
14
15
|
import { isPrivateAddress, resolvePublicAddresses } from "./safe-fetch.mjs";
|
|
15
16
|
import { parseCertificateExtensionProfile, } from "./certificate-extension-profile.mjs";
|
|
16
17
|
const TLS_PROFILE_VERSION = 3;
|
|
17
18
|
const TLS_PORT = 443;
|
|
18
19
|
const TLS_TIMEOUT_MS = 5000;
|
|
19
20
|
const MAX_RESOLVED_ADDRESSES = 64;
|
|
20
|
-
const MAX_CHAIN_CERTIFICATES =
|
|
21
|
-
const MAX_ALT_NAMES =
|
|
21
|
+
const MAX_CHAIN_CERTIFICATES = MAX_LOOKUP_TLS_CHAIN_CERTIFICATES;
|
|
22
|
+
const MAX_ALT_NAMES = MAX_LOOKUP_TLS_ALT_NAMES;
|
|
22
23
|
const MAX_SAN_ENTRIES = 100;
|
|
23
24
|
const MAX_EXTENDED_KEY_USAGES = 16;
|
|
24
25
|
const MAX_AIA_LOCATIONS = 32;
|
|
25
|
-
const MAX_NAME_VALUES =
|
|
26
|
+
const MAX_NAME_VALUES = MAX_LOOKUP_TLS_NAME_VALUES;
|
|
26
27
|
const MAX_TEXT_LENGTH = 256;
|
|
27
28
|
const MAX_ERROR_LENGTH = 240;
|
|
28
29
|
const MAX_SERIAL_LENGTH = 128;
|
|
@@ -577,7 +578,7 @@ function tlsFindings(profile) {
|
|
|
577
578
|
findings.push({ id: 'hostname_mismatch', tone: 'warning', label: 'Hostname mismatch', detail: 'The observed leaf certificate did not match the SNI hostname.' });
|
|
578
579
|
if (profile.certificate?.subjectAltNames?.dnsNames.some((name) => name.startsWith('*.')))
|
|
579
580
|
findings.push({ id: 'wildcard_certificate', tone: 'neutral', label: 'Wildcard certificate', detail: 'The leaf certificate includes at least one wildcard DNS name. Wildcard use is common and is not inherently suspicious.' });
|
|
580
|
-
return findings;
|
|
581
|
+
return findings.slice(0, MAX_LOOKUP_TLS_FINDINGS);
|
|
581
582
|
}
|
|
582
583
|
function buildTlsObservation(handshake = {}, options = {}) {
|
|
583
584
|
const state = tracker();
|
|
@@ -590,7 +591,9 @@ function buildTlsObservation(handshake = {}, options = {}) {
|
|
|
590
591
|
const authorized = typeof handshake.authorized === 'boolean' ? handshake.authorized : null;
|
|
591
592
|
const hostnameMatches = typeof handshake.hostnameMatches === 'boolean' ? handshake.hostnameMatches : null;
|
|
592
593
|
const authorizationError = authorized === false
|
|
593
|
-
? boundedString(handshake.authorizationError
|
|
594
|
+
? boundedString(handshake.authorizationError instanceof Error
|
|
595
|
+
? handshake.authorizationError.message
|
|
596
|
+
: handshake.authorizationError, MAX_ERROR_LENGTH, state)
|
|
594
597
|
: null;
|
|
595
598
|
const hostnameError = hostnameMatches === false
|
|
596
599
|
? boundedString(handshake.hostnameError, MAX_ERROR_LENGTH, state)
|
|
@@ -668,7 +671,7 @@ function failedTlsObservation(error, options = {}) {
|
|
|
668
671
|
source: 'tls',
|
|
669
672
|
durationMs: options.durationMs,
|
|
670
673
|
complete: false,
|
|
671
|
-
limitations: ['TLS
|
|
674
|
+
limitations: ['TLS evidence collection was inconclusive; DNS, connection, timeout, policy, or handshake failure is not proof that no TLS service exists.'],
|
|
672
675
|
diagnostics: { connectionAttempts: options.connectionAttempts || 0, error: detail },
|
|
673
676
|
}),
|
|
674
677
|
profileVersion: TLS_PROFILE_VERSION,
|
|
@@ -689,7 +692,7 @@ function failedTlsObservation(error, options = {}) {
|
|
|
689
692
|
findings: [],
|
|
690
693
|
};
|
|
691
694
|
}
|
|
692
|
-
function skippedTlsObservation(detail = 'TLS
|
|
695
|
+
function skippedTlsObservation(detail = 'TLS evidence collection is disabled by deployment policy.') {
|
|
693
696
|
return {
|
|
694
697
|
...failedTlsObservation(detail, { connectionAttempts: 0 }),
|
|
695
698
|
...createObservation({
|
|
@@ -780,6 +783,9 @@ async function collectTlsIntelligence(hostname, options = {}) {
|
|
|
780
783
|
port: TLS_PORT,
|
|
781
784
|
servername: normalizedHostname,
|
|
782
785
|
rejectUnauthorized: false,
|
|
786
|
+
// Keep runtime CA-path authorization independent from the separately
|
|
787
|
+
// recorded endpoint-identity comparison below.
|
|
788
|
+
checkServerIdentity: () => undefined,
|
|
783
789
|
ALPNProtocols: ['h2', 'http/1.1'],
|
|
784
790
|
}, () => {
|
|
785
791
|
try {
|
package/lib/tlsa-evidence.mjs
CHANGED
|
@@ -128,7 +128,15 @@ function analyzeTlsaEvidence(input) {
|
|
|
128
128
|
const normalized = raw.slice(0, MAX_TLSA_RECORDS).map(normalizeTlsaRecord);
|
|
129
129
|
const rejectedCount = normalized.filter((item) => item === null).length + Math.max(0, raw.length - MAX_TLSA_RECORDS);
|
|
130
130
|
const records = normalized.filter((item) => item !== null);
|
|
131
|
+
const smtpRelayService = service.port === 25 && service.transport === 'tcp';
|
|
131
132
|
const results = records.map((item) => {
|
|
133
|
+
if (smtpRelayService && (item.usage === 0 || item.usage === 1)) {
|
|
134
|
+
return Object.freeze({
|
|
135
|
+
...item,
|
|
136
|
+
state: 'unsupported',
|
|
137
|
+
reason: 'PKIX-TA and PKIX-EE certificate usages are not treated as usable TLSA records for SMTP relay on port 25.',
|
|
138
|
+
});
|
|
139
|
+
}
|
|
132
140
|
const materials = item.usage === 0 || item.usage === 2
|
|
133
141
|
? authorityMaterials
|
|
134
142
|
: leafMaterial ? [leafMaterial] : [];
|
|
@@ -147,6 +155,7 @@ function analyzeTlsaEvidence(input) {
|
|
|
147
155
|
const hasMatch = results.some((item) => item.state === 'matched');
|
|
148
156
|
const hasDifferent = results.some((item) => item.state === 'different');
|
|
149
157
|
const hasUnavailable = results.some((item) => item.state === 'unavailable');
|
|
158
|
+
const hasUnsupported = results.some((item) => item.state === 'unsupported');
|
|
150
159
|
const limitations = [
|
|
151
160
|
'This offline comparison does not connect to the target, retrieve DNS, negotiate SMTP STARTTLS, or validate a DNSSEC chain.',
|
|
152
161
|
'A TLSA match is usable as DANE evidence only when the DNSSEC state was independently validated for the same observation.',
|
|
@@ -155,24 +164,45 @@ function analyzeTlsaEvidence(input) {
|
|
|
155
164
|
if (dnssecState !== 'validated' && hasMatch) {
|
|
156
165
|
limitations.push('Certificate material matched, but DNSSEC was not validated, so the result remains partial.');
|
|
157
166
|
}
|
|
158
|
-
|
|
159
|
-
|
|
167
|
+
if (smtpRelayService && hasUnsupported) {
|
|
168
|
+
limitations.push('RFC 7672 leaves SMTP relay treatment of PKIX-TA usage 0 and PKIX-EE usage 1 undefined; this review treats those records as unusable and they cannot complete SMTP DANE assurance.');
|
|
169
|
+
}
|
|
170
|
+
const matchedPkixTa = results.some((item) => item.state === 'matched' && item.usage === 0);
|
|
171
|
+
const matchedPkixEe = results.some((item) => item.state === 'matched' && item.usage === 1);
|
|
172
|
+
const matchedPkixDependent = matchedPkixTa || matchedPkixEe;
|
|
173
|
+
const matchedDaneEe = results.some((item) => item.state === 'matched' && item.usage === 3);
|
|
174
|
+
const hasPkixTaAssociation = results.some((item) => item.usage === 0);
|
|
175
|
+
const hasDaneTaAssociation = results.some((item) => item.usage === 2);
|
|
160
176
|
if (matchedPkixDependent && pkixValidationState !== 'validated') {
|
|
161
177
|
limitations.push('A PKIX-dependent association matched, but a validated PKIX path was not supplied, so the result is not complete.');
|
|
162
178
|
}
|
|
163
179
|
if (authorityMaterialRejectedCount > 0 || authorityMaterialTruncated) {
|
|
164
180
|
limitations.push('Authority certificate material was malformed or exceeded the review bound, so an unmatched trust-anchor association remains partial.');
|
|
165
181
|
}
|
|
182
|
+
if (hasPkixTaAssociation) {
|
|
183
|
+
limitations.push('PKIX-TA usage 0 association bytes were compared, but the supplied authority material was not proven to be part of the independently validated leaf path, so the usage-0 association cannot complete PKIX-TA assurance.');
|
|
184
|
+
}
|
|
185
|
+
if (hasDaneTaAssociation) {
|
|
186
|
+
limitations.push('DANE-TA usage 2 association bytes were compared, but no certificate path from the observed leaf to the supplied trust-anchor material was constructed or validated, so the usage-2 association cannot complete DANE-TA assurance.');
|
|
187
|
+
}
|
|
188
|
+
if (hasUnavailable || rejectedCount > 0 || truncated) {
|
|
189
|
+
limitations.push('Some TLSA associations or required certificate material were unavailable, malformed, or beyond the review bound, so the comparison remains incomplete.');
|
|
190
|
+
}
|
|
191
|
+
const comparisonMaterialComplete = !hasUnavailable
|
|
192
|
+
&& rejectedCount === 0
|
|
193
|
+
&& !truncated
|
|
194
|
+
&& authorityMaterialRejectedCount === 0
|
|
195
|
+
&& !authorityMaterialTruncated;
|
|
166
196
|
const trustedMatch = dnssecState === 'validated'
|
|
167
|
-
&& (
|
|
197
|
+
&& (matchedDaneEe || matchedPkixEe && pkixValidationState === 'validated');
|
|
168
198
|
const state = results.length === 0
|
|
169
199
|
? 'unavailable'
|
|
170
|
-
:
|
|
171
|
-
&&
|
|
200
|
+
: dnssecState === 'validated' && hasDifferent && !hasMatch && comparisonMaterialComplete
|
|
201
|
+
&& !hasPkixTaAssociation && !hasDaneTaAssociation
|
|
172
202
|
? 'different'
|
|
173
|
-
: hasMatch && (dnssecState === 'bogus' || matchedPkixDependent && pkixValidationState === 'failed' && !
|
|
203
|
+
: hasMatch && (dnssecState === 'bogus' || matchedPkixDependent && pkixValidationState === 'failed' && !matchedDaneEe)
|
|
174
204
|
? 'untrusted'
|
|
175
|
-
: trustedMatch &&
|
|
205
|
+
: trustedMatch && comparisonMaterialComplete
|
|
176
206
|
? 'matched'
|
|
177
207
|
: 'partial';
|
|
178
208
|
return Object.freeze({
|
|
@@ -9,6 +9,7 @@ import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
|
|
|
9
9
|
import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
|
|
10
10
|
import { providerPolicyAdmission } from "./provider-policy-admission.mjs";
|
|
11
11
|
import { createThreatIntelligenceResult, defineThreatIntelligenceProvider, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-contract.mjs";
|
|
12
|
+
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
12
13
|
const URLHAUS_HOST_ENDPOINT = 'https://urlhaus-api.abuse.ch/v1/host/';
|
|
13
14
|
const URLHAUS_MAX_RESULTS = 20;
|
|
14
15
|
const URLHAUS_MAX_INPUT_RESULTS = 100;
|
|
@@ -82,8 +83,7 @@ function isoTimestamp(value) {
|
|
|
82
83
|
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?: UTC)?$/u.test(trimmed)
|
|
83
84
|
? `${trimmed.slice(0, 10)}T${trimmed.slice(11, 19)}Z`
|
|
84
85
|
: trimmed;
|
|
85
|
-
|
|
86
|
-
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
|
|
86
|
+
return normalizeExplicitIsoTimestamp(normalized);
|
|
87
87
|
}
|
|
88
88
|
function retryAfterSeconds(response) {
|
|
89
89
|
const value = response.headers.get('retry-after');
|
|
@@ -151,6 +151,8 @@ function normalizeHostFinding(value, targetDomain) {
|
|
|
151
151
|
|| exactDomainFromUrl(value.url) !== targetDomain)
|
|
152
152
|
return null;
|
|
153
153
|
const observedAt = isoTimestamp(value.date_added);
|
|
154
|
+
if (value.date_added !== undefined && value.date_added !== null && !observedAt)
|
|
155
|
+
return null;
|
|
154
156
|
const tags = normalizedTags(value.tags);
|
|
155
157
|
tags.push(`url-status:${status}`);
|
|
156
158
|
return {
|
|
@@ -7,6 +7,7 @@ import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
|
|
|
7
7
|
import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
|
|
8
8
|
import { providerPolicyAdmission } from "./provider-policy-admission.mjs";
|
|
9
9
|
import { createThreatIntelligenceResult, defineThreatIntelligenceProvider, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-contract.mjs";
|
|
10
|
+
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
10
11
|
const URLSCAN_SEARCH_ENDPOINT = 'https://urlscan.io/api/v1/search/';
|
|
11
12
|
const URLSCAN_SEARCH_DAYS = 90;
|
|
12
13
|
const URLSCAN_MAX_RESULTS = 20;
|
|
@@ -80,10 +81,7 @@ function boundedText(value, maxLength) {
|
|
|
80
81
|
return normalized ? normalized.slice(0, maxLength) : null;
|
|
81
82
|
}
|
|
82
83
|
function isoTimestamp(value) {
|
|
83
|
-
|
|
84
|
-
return null;
|
|
85
|
-
const timestamp = Date.parse(value);
|
|
86
|
-
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
|
|
84
|
+
return normalizeExplicitIsoTimestamp(value);
|
|
87
85
|
}
|
|
88
86
|
function retryAfterSeconds(response) {
|
|
89
87
|
for (const name of ['retry-after', 'x-rate-limit-reset-after']) {
|
|
@@ -148,7 +146,10 @@ function normalizeSearchFinding(value, targetDomain) {
|
|
|
148
146
|
if (verdicts.malicious !== true)
|
|
149
147
|
return undefined;
|
|
150
148
|
const categories = normalizedCategories(verdicts.categories);
|
|
151
|
-
const
|
|
149
|
+
const rawObservedAt = task.time ?? value.date;
|
|
150
|
+
const observedAt = isoTimestamp(rawObservedAt);
|
|
151
|
+
if (rawObservedAt !== undefined && rawObservedAt !== null && !observedAt)
|
|
152
|
+
return null;
|
|
152
153
|
const title = boundedText(page.title, 180);
|
|
153
154
|
return {
|
|
154
155
|
id,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// HTTP, static page, TLS, and DNS evidence already collected by a deep Lookup.
|
|
3
3
|
// Findings use fixed explanatory text and bounded counts; upstream header
|
|
4
4
|
// values, certificate errors, URLs, and DNS record contents are not copied.
|
|
5
|
-
import { createObservation } from "
|
|
5
|
+
import { createObservation } from "../packages/evidence/observation.mjs";
|
|
6
6
|
const WEBSITE_SECURITY_POSTURE_VERSION = 2;
|
|
7
7
|
const MAX_SECURITY_POSTURE_FINDINGS = 32;
|
|
8
8
|
const MAX_RETAINED_ORIGINS = 30;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// markup, header values, URL paths, and arbitrary upstream strings are never
|
|
6
6
|
// retained.
|
|
7
7
|
import { analyzeBrowserLibraries } from "./browser-library-profile.mjs";
|
|
8
|
-
import { createObservation } from "
|
|
8
|
+
import { createObservation } from "../packages/evidence/observation.mjs";
|
|
9
9
|
import { MAX_STATIC_HTML_CHARS, MAX_TAG_LENGTH, MAX_TECHNOLOGY_TAGS, analyzeStaticHtml, } from "./static-html-analysis.mjs";
|
|
10
10
|
const TECHNOLOGY_PROFILE_VERSION = 10;
|
|
11
11
|
const MAX_TECHNOLOGY_HTML_CHARS = MAX_STATIC_HTML_CHARS;
|
package/lib/whois-authority.mjs
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
// Authority-aware interpretation of WHOIS referral chains.
|
|
2
|
-
//
|
|
3
|
-
// diagnostic and cannot
|
|
1
|
+
// Authority-aware interpretation of WHOIS referral chains. Only the first
|
|
2
|
+
// registry response referred by IANA can decide existence; later registrar
|
|
3
|
+
// output is diagnostic and cannot replace an inconclusive registry result.
|
|
4
4
|
import { normalizeWhoisChain } from "./whois-normalization.mjs";
|
|
5
5
|
import { parseIndentedWhoisValue, whoisFieldLimit, } from "./whois-values.mjs";
|
|
6
|
-
const NOT_FOUND_RE = /no match for|no match\b|not found|no entries found|domain not found|no object found|not registered|status\s*:\s*(?:available|free)\b|registered\s*:\s*(?:no|false)\b|is available for registration/i;
|
|
7
6
|
const LINE_NOT_FOUND_PATTERNS = Object.freeze([
|
|
7
|
+
/^[ \t]*(?:%{1,2}[ \t]*)?(?:error(?::\d+)?[ \t:.-]*)?no match(?:[ \t]+for(?:[ \t]+domain)?(?:[ \t]+["']?[a-z0-9.-]{1,253}["']?)?)?[.!]?[ \t]*$/im,
|
|
8
|
+
/^[ \t]*(?:%{1,2}[ \t]*)?(?:error(?::\d+)?[ \t:.-]*)?no entries found(?:[ \t]+(?:for(?:[ \t]+(?:the[ \t]+)?(?:selected source\(s\)|this query|query[ \t]+["']?[a-z0-9.-]{1,253}["']?))|in[ \t]+the[ \t]+\.[a-z0-9-]{1,63}[ \t]+database))?[.!]?[ \t]*$/im,
|
|
9
|
+
/^[ \t]*[a-z0-9.-]{1,253}[ \t]*:[ \t]*no entries found\.?[ \t]*$/im,
|
|
10
|
+
/^[ \t]*(?:%{1,2}[ \t]*)?(?:domain[ \t]+)?not found(?::[ \t]*[a-z0-9.-]{1,253})?[.!]?[ \t]*$/im,
|
|
11
|
+
/^[ \t]*(?:the[ \t]+)?domain[ \t]+["']?[a-z0-9.-]{1,253}["']?[ \t]+(?:was[ \t]+)?not found\.?[ \t]*$/im,
|
|
12
|
+
/^[ \t]*(?:domain[ \t]+status|the queried object does not exist)[ \t]*:[ \t]*(?:domain[ \t]+not found|no object found)[.!]?[ \t]*$/im,
|
|
13
|
+
/^[ \t]*no object found!?[ \t]*$/im,
|
|
14
|
+
/^[ \t]*(?:%{1,2}[ \t]*)?(?:error(?::\d+)?[ \t:.-]*)?(?:the[ \t]+)?domain(?:[ \t]+is|[ \t]+has)?[ \t]+not[ \t]+(?:been[ \t]+)?registered\.?[ \t]*$/im,
|
|
15
|
+
/^[ \t]*(?:domain[ \t]+)?status[ \t]*:[ \t]*(?:available|free)[ \t]*$/im,
|
|
16
|
+
/^[ \t]*registration[ \t]+status[ \t]*:[ \t]*available[ \t]*$/im,
|
|
17
|
+
/^[ \t]*registered[ \t]*:[ \t]*(?:no|false)[ \t]*$/im,
|
|
18
|
+
/^[ \t]*[a-z0-9.-]{1,253}[ \t]+is[ \t]+(?:available[ \t]+for[ \t]+(?:purchase|registration)|free)[.!]?[ \t]*$/im,
|
|
8
19
|
/^[ \t]*%[ \t]*nothing found[ \t]*$/im,
|
|
9
20
|
/^[ \t]*[a-z0-9](?:[a-z0-9.-]{0,252})[ \t]+is free[ \t]*$/im,
|
|
10
21
|
/^[ \t]*el dominio no se encuentra registrado en nic argentina[ \t]*$/im,
|
|
@@ -48,8 +59,7 @@ function classifyHopEvidence(hop, index) {
|
|
|
48
59
|
return 'rate_limited';
|
|
49
60
|
if (NZ_NOT_FOUND_RE.test(text))
|
|
50
61
|
return 'negative';
|
|
51
|
-
if (
|
|
52
|
-
|| LINE_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(text))) {
|
|
62
|
+
if (LINE_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(text))) {
|
|
53
63
|
return 'negative';
|
|
54
64
|
}
|
|
55
65
|
if (index > 0
|
|
@@ -62,6 +72,26 @@ function classifyHopEvidence(hop, index) {
|
|
|
62
72
|
}
|
|
63
73
|
return 'inconclusive';
|
|
64
74
|
}
|
|
75
|
+
function normalizedWhoisServer(value) {
|
|
76
|
+
if (typeof value !== 'string')
|
|
77
|
+
return null;
|
|
78
|
+
const normalized = value.trim().toLowerCase().replace(/\.+$/u, '');
|
|
79
|
+
return /^[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$/u.test(normalized) ? normalized : null;
|
|
80
|
+
}
|
|
81
|
+
function ianaRegistryReferral(hop) {
|
|
82
|
+
if (!hop || normalizedWhoisServer(hop.server) !== 'whois.iana.org' || typeof hop.response !== 'string')
|
|
83
|
+
return null;
|
|
84
|
+
for (const pattern of [
|
|
85
|
+
/^[ \t]*refer:[ \t]*([a-zA-Z0-9.\-]+)/mi,
|
|
86
|
+
/^[ \t]*ReferralServer:[ \t]*whois:\/\/([a-zA-Z0-9.\-]+)/mi,
|
|
87
|
+
/^[ \t]*whois:[ \t]*([a-zA-Z0-9.\-]+)/mi,
|
|
88
|
+
]) {
|
|
89
|
+
const referral = normalizedWhoisServer(hop.response.match(pattern)?.[1]);
|
|
90
|
+
if (referral)
|
|
91
|
+
return referral;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
65
95
|
export function analyzeWhoisChainAuthority(chain) {
|
|
66
96
|
const source = normalizeWhoisChain(chain);
|
|
67
97
|
const evidence = source.map((hop, index) => ({
|
|
@@ -70,8 +100,13 @@ export function analyzeWhoisChainAuthority(chain) {
|
|
|
70
100
|
kind: classifyHopEvidence(hop, index),
|
|
71
101
|
}));
|
|
72
102
|
const failed = evidence.filter((item) => item.kind === 'error' || item.kind === 'rate_limited');
|
|
73
|
-
const
|
|
74
|
-
|
|
103
|
+
const registryReferral = ianaRegistryReferral(source[0]);
|
|
104
|
+
const registryServer = normalizedWhoisServer(source[1]?.server);
|
|
105
|
+
const registryEvidence = registryReferral && registryServer === registryReferral ? evidence[1] : null;
|
|
106
|
+
const authoritative = registryEvidence
|
|
107
|
+
&& (registryEvidence.kind === 'positive' || registryEvidence.kind === 'negative')
|
|
108
|
+
? registryEvidence
|
|
109
|
+
: null;
|
|
75
110
|
const conflict = authoritative
|
|
76
111
|
? evidence.find((item) => item.index > authoritative.index
|
|
77
112
|
&& (item.kind === 'positive' || item.kind === 'negative')
|
package/lib/whois-chain.mjs
CHANGED
|
@@ -8,6 +8,17 @@ import { whoisQuery } from "./whois-transport.mjs";
|
|
|
8
8
|
const IANA_WHOIS = 'whois.iana.org';
|
|
9
9
|
const WHOIS_HOP_DEADLINE_MS = 12_000;
|
|
10
10
|
const WHOIS_CHAIN_DEADLINE_MS = 25_000;
|
|
11
|
+
const MAX_WHOIS_QUERY_HOPS = 6;
|
|
12
|
+
function incompleteReferralHop(server, reason) {
|
|
13
|
+
return {
|
|
14
|
+
server,
|
|
15
|
+
queryProfile: 'not-issued',
|
|
16
|
+
responseEncoding: 'utf-8',
|
|
17
|
+
error: reason === 'hop_limit'
|
|
18
|
+
? 'WHOIS referral chain reached the bounded hop limit; this referral was not queried.'
|
|
19
|
+
: 'WHOIS referral chain repeated a previously queried server; this referral was not queried.',
|
|
20
|
+
};
|
|
21
|
+
}
|
|
11
22
|
const WHOIS_QUERY_FORMATTERS = {
|
|
12
23
|
'plain-domain': (domain) => domain,
|
|
13
24
|
'denic-domain-ace': (domain) => `-T dn,ace ${domain}`,
|
|
@@ -65,9 +76,11 @@ export async function buildWhoisChainUncached(query, options = {}) {
|
|
|
65
76
|
const visited = new Set();
|
|
66
77
|
let currentServer = IANA_WHOIS;
|
|
67
78
|
const startedAt = now();
|
|
68
|
-
for (let hop = 0; hop <
|
|
69
|
-
if (visited.has(currentServer.toLowerCase()))
|
|
79
|
+
for (let hop = 0; hop < MAX_WHOIS_QUERY_HOPS; hop += 1) {
|
|
80
|
+
if (visited.has(currentServer.toLowerCase())) {
|
|
81
|
+
chain.push(incompleteReferralHop(currentServer, 'referral_loop'));
|
|
70
82
|
break;
|
|
83
|
+
}
|
|
71
84
|
visited.add(currentServer.toLowerCase());
|
|
72
85
|
const transport = whoisTransportForHop(query, hop);
|
|
73
86
|
const remainingMs = chainDeadlineMs - (now() - startedAt);
|
|
@@ -112,8 +125,17 @@ export async function buildWhoisChainUncached(query, options = {}) {
|
|
|
112
125
|
response: responseText,
|
|
113
126
|
});
|
|
114
127
|
const referral = extractReferral(responseText);
|
|
115
|
-
if (!referral
|
|
128
|
+
if (!referral)
|
|
116
129
|
break;
|
|
130
|
+
const referralKey = referral.toLowerCase();
|
|
131
|
+
if (visited.has(referralKey)) {
|
|
132
|
+
chain.push(incompleteReferralHop(referral, 'referral_loop'));
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
if (hop + 1 >= MAX_WHOIS_QUERY_HOPS) {
|
|
136
|
+
chain.push(incompleteReferralHop(referral, 'hop_limit'));
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
117
139
|
currentServer = referral;
|
|
118
140
|
}
|
|
119
141
|
return chain;
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// Keeping this separate lets authority analysis and field parsing share the
|
|
3
3
|
// exact same defensive input boundary.
|
|
4
4
|
import { MAX_WHOIS_BYTES } from "./whois-transport.mjs";
|
|
5
|
-
|
|
5
|
+
// Collection performs at most six network queries and may retain one seventh,
|
|
6
|
+
// non-issued hop explaining a loop or hop-limit termination.
|
|
7
|
+
const MAX_WHOIS_HOPS = 7;
|
|
6
8
|
export function normalizeWhoisChain(value) {
|
|
7
9
|
if (!Array.isArray(value))
|
|
8
10
|
return [];
|
package/lib/whois-transport.mjs
CHANGED
|
@@ -47,9 +47,18 @@ function queryWhoisAddress(address, server, query, { port = 43, timeoutMs = 1000
|
|
|
47
47
|
}
|
|
48
48
|
chunks.push(buffer);
|
|
49
49
|
});
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
const settleResponse = () => {
|
|
51
|
+
try {
|
|
52
|
+
const responseText = new TextDecoder('utf-8', { fatal: true })
|
|
53
|
+
.decode(Buffer.concat(chunks, totalBytes));
|
|
54
|
+
settle(resolve, responseText);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
settle(reject, new Error(`WHOIS response from ${server} was not valid UTF-8`));
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
socket.on('end', settleResponse);
|
|
61
|
+
socket.on('close', settleResponse);
|
|
53
62
|
socket.on('timeout', () => {
|
|
54
63
|
socket.destroy();
|
|
55
64
|
settle(reject, new Error(`WHOIS request to ${server} timed out`));
|
|
@@ -175,7 +175,7 @@ export function normaliseRdata(type, rawValue, origin, nameMode = 'absolute') {
|
|
|
175
175
|
};
|
|
176
176
|
}
|
|
177
177
|
if (type === 'CDNSKEY') {
|
|
178
|
-
if (tokens.length !== 4 || !/^[
|
|
178
|
+
if (tokens.length !== 4 || !/^[A-Za-z0-9+/=]{1,4096}$/u.test(tokens[3] ?? ''))
|
|
179
179
|
throw new TypeError('CDNSKEY data is invalid.');
|
|
180
180
|
return {
|
|
181
181
|
value: `${integerToken(tokens[0], 0, 65_535, 'CDNSKEY flags')} ${integerToken(tokens[1], 0, 255, 'CDNSKEY protocol')} ${integerToken(tokens[2], 0, 255, 'CDNSKEY algorithm')} ${tokens[3]}`,
|
package/package.json
CHANGED
|
@@ -33,20 +33,28 @@
|
|
|
33
33
|
"bugs": {
|
|
34
34
|
"url": "https://github.com/slicedearth/whoisleuth/issues"
|
|
35
35
|
},
|
|
36
|
-
"version": "
|
|
36
|
+
"version": "2.0.0",
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@peculiar/x509": "2.0.0",
|
|
39
39
|
"maxmind": "5.0.7",
|
|
40
40
|
"parse5": "8.0.1",
|
|
41
41
|
"reflect-metadata": "0.2.2",
|
|
42
|
-
"tldts": "7.4.
|
|
43
|
-
"undici": "8.
|
|
42
|
+
"tldts": "7.4.11",
|
|
43
|
+
"undici": "8.10.0"
|
|
44
44
|
},
|
|
45
45
|
"files": [
|
|
46
46
|
"bin/**/*.mjs",
|
|
47
47
|
"cli/**/*.mjs",
|
|
48
48
|
"lib/**/*.mjs",
|
|
49
|
-
"frontend/src/lib
|
|
49
|
+
"frontend/src/lib/**/*.js",
|
|
50
|
+
"packages/cases/**/*.mjs",
|
|
51
|
+
"packages/comparison/**/*.mjs",
|
|
52
|
+
"packages/contracts/**/*.mjs",
|
|
53
|
+
"packages/evidence/**/*.mjs",
|
|
54
|
+
"packages/interchange/**/*.mjs",
|
|
55
|
+
"packages/investigation/**/*.mjs",
|
|
56
|
+
"packages/monitoring/**/*.mjs",
|
|
57
|
+
"packages/workspace/**/*.mjs",
|
|
50
58
|
"docs/cli.md",
|
|
51
59
|
"docs/cli-reference.md",
|
|
52
60
|
"DISCLOSURE",
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { MAX_CASE_BRAND_PROFILE_ID_CANDIDATES, MAX_CASE_BRAND_PROFILE_IDS, } from "./case-record-contracts.mjs";
|
|
2
|
+
import { normalizeOpaqueReferenceId } from "./opaque-reference-id.mjs";
|
|
3
|
+
/**
|
|
4
|
+
* Reads an untrusted stored or imported reference list without repairing any
|
|
5
|
+
* identifier. At most 32 candidates are inspected and at most eight unique,
|
|
6
|
+
* exact identifiers are retained.
|
|
7
|
+
*/
|
|
8
|
+
export function inspectCaseBrandProfileIds(value) {
|
|
9
|
+
if (!Array.isArray(value))
|
|
10
|
+
return { ids: [], omitted: 0 };
|
|
11
|
+
const ids = [];
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
let omitted = Math.max(0, value.length - MAX_CASE_BRAND_PROFILE_ID_CANDIDATES);
|
|
14
|
+
for (const candidate of value.slice(0, MAX_CASE_BRAND_PROFILE_ID_CANDIDATES)) {
|
|
15
|
+
const id = normalizeOpaqueReferenceId(candidate);
|
|
16
|
+
if (!id || seen.has(id))
|
|
17
|
+
continue;
|
|
18
|
+
seen.add(id);
|
|
19
|
+
if (ids.length < MAX_CASE_BRAND_PROFILE_IDS)
|
|
20
|
+
ids.push(id);
|
|
21
|
+
else
|
|
22
|
+
omitted += 1;
|
|
23
|
+
}
|
|
24
|
+
return { ids, omitted };
|
|
25
|
+
}
|
|
26
|
+
/** Current-store recovery is bounded and lenient, but never repairs ids. */
|
|
27
|
+
export function normalizeCaseBrandProfileIds(value) {
|
|
28
|
+
return inspectCaseBrandProfileIds(value).ids;
|
|
29
|
+
}
|
|
30
|
+
/** Explicit analyst edits replace the complete set and fail closed. */
|
|
31
|
+
export function assertCaseBrandProfileIds(value) {
|
|
32
|
+
if (!Array.isArray(value)) {
|
|
33
|
+
throw new Error('Brand Profile associations must be an array of opaque identifiers.');
|
|
34
|
+
}
|
|
35
|
+
if (value.length > MAX_CASE_BRAND_PROFILE_IDS) {
|
|
36
|
+
throw new Error(`Each case is limited to ${MAX_CASE_BRAND_PROFILE_IDS} Brand Profile associations.`);
|
|
37
|
+
}
|
|
38
|
+
const ids = [];
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
for (const candidate of value) {
|
|
41
|
+
const id = normalizeOpaqueReferenceId(candidate);
|
|
42
|
+
if (!id)
|
|
43
|
+
throw new Error('A Brand Profile association identifier is invalid.');
|
|
44
|
+
if (seen.has(id))
|
|
45
|
+
throw new Error('Brand Profile association identifiers must be unique.');
|
|
46
|
+
seen.add(id);
|
|
47
|
+
ids.push(id);
|
|
48
|
+
}
|
|
49
|
+
return ids;
|
|
50
|
+
}
|
|
51
|
+
/** Add one exact association without accepting a stale whole-list snapshot. */
|
|
52
|
+
export function addCaseBrandProfileId(value, profileId) {
|
|
53
|
+
const ids = assertCaseBrandProfileIds(value);
|
|
54
|
+
const [id] = assertCaseBrandProfileIds([profileId]);
|
|
55
|
+
if (!id || ids.includes(id))
|
|
56
|
+
return [...ids];
|
|
57
|
+
if (ids.length >= MAX_CASE_BRAND_PROFILE_IDS) {
|
|
58
|
+
throw new Error(`Each case is limited to ${MAX_CASE_BRAND_PROFILE_IDS} Brand Profile associations.`);
|
|
59
|
+
}
|
|
60
|
+
return [...ids, id];
|
|
61
|
+
}
|
|
62
|
+
/** Remove one exact association while preserving every unrelated reference. */
|
|
63
|
+
export function removeCaseBrandProfileId(value, profileId) {
|
|
64
|
+
const ids = assertCaseBrandProfileIds(value);
|
|
65
|
+
const [id] = assertCaseBrandProfileIds([profileId]);
|
|
66
|
+
return id ? ids.filter((candidate) => candidate !== id) : [...ids];
|
|
67
|
+
}
|
|
68
|
+
/** Import unions are existing-first so omission can never clear a local ref. */
|
|
69
|
+
export function unionCaseBrandProfileIds(existingValue, importedValue) {
|
|
70
|
+
const existing = normalizeCaseBrandProfileIds(existingValue);
|
|
71
|
+
const imported = normalizeCaseBrandProfileIds(importedValue);
|
|
72
|
+
const ids = [...existing];
|
|
73
|
+
const seen = new Set(ids);
|
|
74
|
+
let omitted = 0;
|
|
75
|
+
for (const id of imported) {
|
|
76
|
+
if (seen.has(id))
|
|
77
|
+
continue;
|
|
78
|
+
seen.add(id);
|
|
79
|
+
if (ids.length < MAX_CASE_BRAND_PROFILE_IDS)
|
|
80
|
+
ids.push(id);
|
|
81
|
+
else
|
|
82
|
+
omitted += 1;
|
|
83
|
+
}
|
|
84
|
+
return { ids, omitted };
|
|
85
|
+
}
|