@slicedearth/whoisleuth-cli 2.5.1 → 2.7.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 +9 -6
- package/NOTICE +3 -2
- package/README.md +60 -67
- package/cli/artifact-validation/case-response.mjs +6 -814
- package/cli/artifact-validation/lookup-evidence.mjs +30 -3
- package/cli/artifact-validation/signed-review.mjs +2 -51
- package/cli/artifact-verify.mjs +41 -3
- package/cli/bulk-command-runner.mjs +4 -6
- package/cli/case-command.mjs +92 -16
- package/cli/case-pack.mjs +14 -3
- package/cli/ci-report.mjs +17 -5
- package/cli/command-definition.mjs +7 -6
- package/cli/discovery-scan-command-runner.mjs +4 -6
- package/cli/doctor.mjs +37 -3
- package/cli/domain-feed.mjs +61 -0
- package/cli/formatters/html.mjs +3 -0
- package/cli/formatters/json.mjs +9 -14
- package/cli/formatters/terminal-lookup.mjs +24 -1
- package/cli/history-command-definitions.mjs +1 -1
- package/cli/indicator-set-command.mjs +1 -2
- package/cli/intake-command.mjs +43 -3
- package/cli/investigation-artifacts.mjs +2 -3
- package/cli/investigation-package-review.mjs +2 -0
- package/cli/investigation-plan.mjs +15 -0
- package/cli/local-mmdb-review.mjs +27 -4
- package/cli/lookup-brief.mjs +11 -5
- package/cli/lookup-command-runner.mjs +21 -13
- package/cli/offline-evidence-review.mjs +51 -5
- package/cli/review-arguments.mjs +3 -2
- package/cli/review-command-definitions.mjs +23 -17
- package/cli/review-command-runner.mjs +6 -3
- package/cli/risk-calibration.mjs +6 -4
- package/cli/saved-lookup.mjs +22 -7
- package/cli/selected-input-worker.mjs +12 -2
- package/cli/source-reliability.mjs +12 -10
- package/cli/support-arguments.mjs +1 -1
- package/cli/support-command-definitions.mjs +5 -4
- package/cli/support-command-runner.mjs +9 -7
- package/cli/watchlist-review.mjs +43 -0
- package/cli/workflow-arguments.mjs +14 -0
- package/cli/workflow-command-definitions.mjs +27 -1
- package/cli/workflow-command-runner.mjs +32 -0
- package/docs/cli-reference.md +49 -11
- package/docs/cli.md +200 -26
- package/docs/contextual-reviews.md +38 -5
- package/docs/infrastructure-observations.md +135 -0
- package/docs/offline-intake-context.md +213 -0
- package/lib/credential-form-attribution.mjs +136 -0
- package/lib/credential-surface-profile.mjs +6 -2
- package/lib/dmarc-discovery.mjs +4 -2
- package/lib/dns-delegation-health.mjs +23 -7
- package/lib/dns-inheritance-review.mjs +14 -1
- package/lib/dns-intelligence.mjs +18 -4
- package/lib/domain-posture-analysis.mjs +53 -2
- package/lib/domain-posture-parsers.mjs +9 -0
- package/lib/domain-posture.mjs +29 -10
- package/lib/evidence-export-privacy.mjs +5 -2
- package/lib/evidence-export.mjs +10 -4
- package/lib/evidence-report.mjs +3 -1
- package/lib/favicon.mjs +13 -4
- package/lib/generated/cisa-kev-catalog.mjs +17 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +178 -5
- package/lib/html-signals.mjs +6 -0
- package/lib/idn-confusables.mjs +5 -1
- package/lib/lookup-child-profile-contract.mjs +28 -11
- package/lib/lookup-collection-receipt.mjs +52 -0
- package/lib/lookup-diagnostics.mjs +2 -12
- package/lib/lookup-progress.mjs +29 -31
- package/lib/lookup-response-contract.mjs +10 -0
- package/lib/lookup-response.mjs +15 -14
- package/lib/lookup-source-progress.mjs +4 -2
- package/lib/lookup.mjs +33 -2
- package/lib/operation-budget.mjs +4 -0
- package/lib/rdap-capabilities.mjs +43 -9
- package/lib/registry-insights.mjs +17 -6
- package/lib/service-binding-dns.mjs +19 -2
- package/lib/sslbl-certificates.generated.mjs +142 -140
- package/lib/static-html-analysis.mjs +32 -4
- package/lib/technology-evidence-role.mjs +1 -51
- package/lib/tls-intelligence.mjs +30 -5
- package/lib/website-enrichment.mjs +39 -23
- package/lib/whois-authority.mjs +56 -1
- package/lib/whois-chain.mjs +14 -4
- package/lib/whois-common-formats.mjs +3 -1
- package/lib/whois-contacts.mjs +13 -2
- package/lib/whois-contracts.mjs +2 -0
- package/lib/whois-normalization.mjs +4 -4
- package/package.json +9 -7
- package/packages/analysis/bounded-json.mjs +12 -2
- package/packages/analysis/risk-scoring.mjs +12 -9
- package/packages/cases/case-attachment-model.mjs +36 -1
- package/packages/cases/case-checkpoint-receipts.mjs +68 -0
- package/packages/cases/case-containment-handoff.mjs +195 -0
- package/packages/cases/case-evidence-links.mjs +44 -0
- package/packages/cases/case-evidence-model.mjs +51 -7
- package/packages/cases/case-migration-model.mjs +9 -1
- package/packages/cases/case-object-coverage.mjs +119 -0
- package/packages/cases/case-packet-correction.mjs +106 -0
- package/packages/cases/case-recheck-model.mjs +35 -5
- package/packages/cases/case-record-operations.mjs +87 -9
- package/packages/cases/case-record-projection.mjs +43 -5
- package/packages/cases/case-record-version-input.mjs +23 -1
- package/packages/cases/case-report.mjs +35 -4
- package/packages/cases/case-requested-evidence.mjs +1 -1
- package/packages/cases/case-response-actions.mjs +57 -1
- package/packages/cases/case-response-model.mjs +7 -0
- package/packages/cases/case-response-object.mjs +119 -0
- package/packages/cases/case-response-outcomes.mjs +81 -14
- package/packages/cases/case-response-packet-render.mjs +22 -3
- package/packages/cases/case-response-packet-validation.mjs +880 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
- package/packages/cases/case-response-packet.mjs +32 -17
- package/packages/cases/case-response-review-inputs.mjs +66 -8
- package/packages/cases/case-type-presentation.mjs +10 -0
- package/packages/cases/case-workflow-metadata.mjs +1 -17
- package/packages/comparison/capture-context.mjs +3 -2
- package/packages/contracts/analyst-interchange.mjs +7 -5
- package/packages/contracts/analyst-review-state-contract.mjs +1 -1
- package/packages/contracts/candidate-watch-review.mjs +54 -0
- package/packages/contracts/case-portability.mjs +292 -43
- package/packages/contracts/cli-command-semantics.mjs +3 -0
- package/packages/contracts/cli-doctor.mjs +4 -0
- package/packages/contracts/cli-lookup.mjs +57 -46
- package/packages/contracts/context-review.mjs +2 -1
- package/packages/contracts/external-observation-interchange.mjs +96 -28
- package/packages/contracts/lookup-evidence.mjs +13 -2
- package/packages/contracts/message-intake.mjs +7 -1
- package/packages/contracts/registry-insights.mjs +6 -0
- package/packages/contracts/risk-calibration.mjs +44 -29
- package/packages/contracts/schema-lifecycle.mjs +3 -1
- package/packages/contracts/web-capture.mjs +1 -1
- package/packages/contracts/workspace-portability.mjs +86 -16
- package/packages/evidence/collection-quality.mjs +1 -1
- package/packages/evidence/credential-form-attribution.mjs +60 -0
- package/packages/evidence/dmarc-policy-context.mjs +80 -0
- package/packages/evidence/image-regions.mjs +15 -0
- package/packages/evidence/lookup-collection-receipt.mjs +140 -0
- package/packages/evidence/lookup-collection-review.mjs +74 -0
- package/packages/evidence/record-identity.mjs +60 -16
- package/packages/evidence/technology-evidence-role.mjs +51 -0
- package/packages/interchange/external-findings-import.mjs +40 -21
- package/packages/interchange/json-output.mjs +14 -0
- package/packages/interchange/managed-indicator-export.mjs +3 -2
- package/packages/interchange/managed-indicator-set.mjs +13 -0
- package/packages/investigation/acquisition-decision-packet.mjs +78 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -2
- package/packages/investigation/context-review.mjs +3 -3
- package/packages/investigation/document-intake.mjs +9 -3
- package/packages/investigation/domain-history-review.mjs +50 -12
- package/packages/investigation/domain-transition-review.mjs +2 -0
- package/packages/investigation/infrastructure-observation.mjs +234 -0
- package/packages/investigation/intake-context.mjs +284 -0
- package/packages/investigation/intake-indicators.mjs +116 -0
- package/packages/investigation/intake-phones.mjs +155 -0
- package/packages/investigation/intake-report.mjs +2 -1
- package/packages/investigation/investigation-manifest.mjs +33 -7
- package/packages/investigation/link-intake.mjs +38 -8
- package/packages/investigation/message-intake.mjs +55 -15
- package/packages/monitoring/domain-feed.mjs +321 -0
- package/packages/workspace/brand-candidate-workflow.mjs +351 -0
- package/packages/workspace/brand-keyword-campaign.mjs +241 -0
- package/packages/workspace/brand-profile-model.mjs +46 -2
- package/packages/workspace/bulk-session-model.mjs +12 -0
- package/packages/workspace/candidate-watch-handoff.mjs +195 -0
- package/packages/workspace/detection-rule-model.mjs +10 -2
- package/packages/workspace/shortlist-model.mjs +10 -2
- package/packages/workspace/watchlist-history.mjs +31 -4
- package/packages/workspace/watchlist-store.mjs +136 -7
- package/third-party-notices.txt +30 -30
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { normalizeDomain } from "../evidence/domain-name.mjs";
|
|
2
|
+
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
3
|
+
import { createIncrementalSha256, sha256IdentityHex } from "../evidence/record-identity.mjs";
|
|
4
|
+
import { MAX_CANDIDATE_MATCHES, normalizeCandidateObservation } from "../workspace/brand-candidate-workflow.mjs";
|
|
5
|
+
import { planCandidateWatchHandoff } from "../workspace/candidate-watch-handoff.mjs";
|
|
6
|
+
import { CANDIDATE_WATCH_INPUT_SCHEMA, CANDIDATE_WATCH_INPUT_VERSION } from "../contracts/candidate-watch-review.mjs";
|
|
7
|
+
import { assertWorkspaceInputGraph, ordinaryWorkspaceRecord } from "../workspace/hostile-input.mjs";
|
|
8
|
+
import { matchBrandKeywordTerms, normalizeBrandKeywordTerms } from "../workspace/brand-keyword-campaign.mjs";
|
|
9
|
+
export const DOMAIN_FEED_LIMITS = Object.freeze({ bytes: 256 * 1024 * 1024, rows: 10_000_000,
|
|
10
|
+
lineBytes: 1024, durationMs: 10 * 60 * 1000, matches: 200, hosts: 200, terms: 20, batch: 256 });
|
|
11
|
+
const catalogue = [
|
|
12
|
+
['tif-full', 'Threat Intelligence Feeds — full', 'threat-intelligence', 'dns-blocklists', 'wildcard/tif-onlydomains.txt'],
|
|
13
|
+
['tif-medium', 'Threat Intelligence Feeds — medium', 'threat-intelligence', 'dns-blocklists', 'wildcard/tif.medium-onlydomains.txt'],
|
|
14
|
+
['tif-mini', 'Threat Intelligence Feeds — mini', 'threat-intelligence', 'dns-blocklists', 'wildcard/tif.mini-onlydomains.txt'],
|
|
15
|
+
['nrd7', 'Newly registered domains — seven-day cohort excluding today', 'recent-domain', 'nrd', 'domains/nrd7.txt'],
|
|
16
|
+
['nrd14-8', 'Newly registered domains — days 8–14', 'recent-domain', 'nrd', 'domains/nrd14-8.txt'],
|
|
17
|
+
['nrd21-15', 'Newly registered domains — days 15–21', 'recent-domain', 'nrd', 'domains/nrd21-15.txt'],
|
|
18
|
+
['nrd28-22', 'Newly registered domains — days 22–28', 'recent-domain', 'nrd', 'domains/nrd28-22.txt'],
|
|
19
|
+
['nrd35-29', 'Newly registered domains — days 29–35', 'recent-domain', 'nrd', 'domains/nrd35-29.txt'],
|
|
20
|
+
['entropy7', 'High-entropy subset — 7 days', 'entropy-subset', 'nrd', 'domains/dga7.txt'],
|
|
21
|
+
['entropy14', 'High-entropy subset — 14 days', 'entropy-subset', 'nrd', 'domains/dga14.txt'],
|
|
22
|
+
['entropy30', 'High-entropy subset — 30 days', 'entropy-subset', 'nrd', 'domains/dga30.txt'],
|
|
23
|
+
];
|
|
24
|
+
export const DOMAIN_FEED_CATALOGUE = Object.freeze(catalogue.map(([id, label, kind, repository, path]) => Object.freeze({
|
|
25
|
+
id, label, kind, url: `https://raw.githubusercontent.com/hagezi/${repository}/main/${path}`,
|
|
26
|
+
licence: 'GPL-3.0', licenceUrl: `https://github.com/hagezi/${repository}/blob/main/LICENSE`,
|
|
27
|
+
})));
|
|
28
|
+
export function domainFeedDefinition(id) {
|
|
29
|
+
const definition = DOMAIN_FEED_CATALOGUE.find(entry => entry.id === id);
|
|
30
|
+
if (!definition)
|
|
31
|
+
throw new TypeError('Choose a supported plain-domain feed identifier.');
|
|
32
|
+
return definition;
|
|
33
|
+
}
|
|
34
|
+
export const DOMAIN_FEED_LIMITATIONS = Object.freeze([
|
|
35
|
+
'Feed inclusion is third-party candidate evidence, not a finding of maliciousness, activity, ownership or domain existence.',
|
|
36
|
+
'Recent-registration cohorts and high-entropy subsets do not verify a registration event or a domain-generation algorithm for an individual hostname.',
|
|
37
|
+
'The raw-file SHA-256 identifies the scanned bytes; it does not authenticate the publisher or establish freshness.',
|
|
38
|
+
'File publication comments are declarations, not per-host observation clocks. Source first and last observation times remain unknown.',
|
|
39
|
+
'Exact hosts and literal terms only; no parent-domain equivalence, regular expressions, lookup, score change or collection authority.',
|
|
40
|
+
'Literal terms are compared with lowercased canonical ASCII/punycode hostname text; Unicode terms are not expanded into IDN variants.',
|
|
41
|
+
]);
|
|
42
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u;
|
|
43
|
+
/** Gate plain host syntax before the compatibility normaliser can strip URL parts. */
|
|
44
|
+
export function strictDomainFeedHostname(value) {
|
|
45
|
+
if (typeof value !== 'string' || value.length > 253 || value !== value.trim() || CONTROL.test(value)
|
|
46
|
+
|| !/^[\p{L}\p{N}\p{M}.-]+$/u.test(value) || value.endsWith('.') || !value.includes('.'))
|
|
47
|
+
return '';
|
|
48
|
+
return normalizeDomain(value);
|
|
49
|
+
}
|
|
50
|
+
export function normalizeDomainFeedSelection(value) {
|
|
51
|
+
assertWorkspaceInputGraph(value, 'Domain feed selection');
|
|
52
|
+
if (!value || typeof value !== 'object' || Object.keys(value).some(key => !['hosts', 'terms', 'negativeTerms', 'brandProfileId'].includes(key)))
|
|
53
|
+
throw new TypeError('Use only exact hosts, literal terms and optional Brand context.');
|
|
54
|
+
const hosts = value.hosts ?? [], terms = value.terms ?? [];
|
|
55
|
+
if (!Array.isArray(hosts) || hosts.length > DOMAIN_FEED_LIMITS.hosts || !Array.isArray(terms) || terms.length > DOMAIN_FEED_LIMITS.terms)
|
|
56
|
+
throw new RangeError('Feed selection is limited to 200 exact hosts and 20 literal terms.');
|
|
57
|
+
const canonicalHosts = hosts.map(strictDomainFeedHostname);
|
|
58
|
+
if (canonicalHosts.some(host => !host))
|
|
59
|
+
throw new TypeError('Feed hosts must be literal plain domain names.');
|
|
60
|
+
const canonicalTerms = normalizeBrandKeywordTerms(terms);
|
|
61
|
+
const negativeTerms = Object.hasOwn(value, 'negativeTerms') ? normalizeBrandKeywordTerms(value.negativeTerms) : undefined;
|
|
62
|
+
const brandProfileId = value.brandProfileId ?? null;
|
|
63
|
+
if (brandProfileId !== null && (typeof brandProfileId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(brandProfileId)))
|
|
64
|
+
throw new TypeError('The selected Brand context identifier is invalid.');
|
|
65
|
+
return Object.freeze({ hosts: Object.freeze([...new Set(canonicalHosts)]), terms: Object.freeze([...canonicalTerms]),
|
|
66
|
+
...(negativeTerms ? { negativeTerms: Object.freeze([...negativeTerms]) } : {}), brandProfileId });
|
|
67
|
+
}
|
|
68
|
+
function snapshotMetadata(value) {
|
|
69
|
+
domainFeedDefinition(value.feedId);
|
|
70
|
+
const importedAt = normalizeExplicitIsoTimestamp(value.importedAt);
|
|
71
|
+
if (!importedAt || !/^sha256:[a-f0-9]{64}$/u.test(value.revision)
|
|
72
|
+
|| !Number.isSafeInteger(value.bytes) || value.bytes < 0 || value.bytes > DOMAIN_FEED_LIMITS.bytes
|
|
73
|
+
|| !Number.isSafeInteger(value.rows) || value.rows < 0 || value.rows > DOMAIN_FEED_LIMITS.rows)
|
|
74
|
+
throw new TypeError('Feed snapshot metadata is invalid or exceeds its bounds.');
|
|
75
|
+
for (const clock of [value.acquiredAt, value.declaredPublishedAt])
|
|
76
|
+
if (clock !== null && !normalizeExplicitIsoTimestamp(clock))
|
|
77
|
+
throw new TypeError('Feed snapshot clocks require explicit valid timestamps.');
|
|
78
|
+
if (value.declaredVersion !== null && (typeof value.declaredVersion !== 'string' || !/^[A-Za-z0-9._+-]{1,80}$/u.test(value.declaredVersion)))
|
|
79
|
+
throw new TypeError('The declared feed version is invalid.');
|
|
80
|
+
return Object.freeze({ feedId: value.feedId, revision: value.revision, importedAt,
|
|
81
|
+
acquiredAt: value.acquiredAt, declaredPublishedAt: value.declaredPublishedAt, declaredVersion: value.declaredVersion, bytes: value.bytes, rows: value.rows });
|
|
82
|
+
}
|
|
83
|
+
export function projectDomainFeedMatch(domain, metadata, input) {
|
|
84
|
+
const host = strictDomainFeedHostname(domain), snapshot = snapshotMetadata(metadata), selection = normalizeDomainFeedSelection(input);
|
|
85
|
+
if (!host)
|
|
86
|
+
throw new TypeError('A feed match must be a plain hostname.');
|
|
87
|
+
const exactHost = selection.hosts.includes(host), { terms, excludedTerms } = matchBrandKeywordTerms(host, selection.terms, selection.negativeTerms);
|
|
88
|
+
if ((!exactHost && !terms.length) || excludedTerms.length)
|
|
89
|
+
return null;
|
|
90
|
+
const candidate = { domain: host,
|
|
91
|
+
matches: selection.brandProfileId ? [...(exactHost ? [{ brandProfileId: selection.brandProfileId,
|
|
92
|
+
ruleKey: `feed-host:${sha256IdentityHex(new TextEncoder().encode(host)).slice(0, 32)}`,
|
|
93
|
+
term: host, reason: 'The analyst explicitly selected this exact feed hostname for Brand review; inclusion is not a verdict.' }] : []), ...terms.map(term => ({ brandProfileId: selection.brandProfileId,
|
|
94
|
+
ruleKey: `feed-literal:${sha256IdentityHex(new TextEncoder().encode(term)).slice(0, 32)}`,
|
|
95
|
+
term, reason: 'An explicit literal term occurs in this exact feed hostname; analyst review is required.' }))] : [],
|
|
96
|
+
sources: [{ source: `domain-feed:${snapshot.feedId}`, revision: snapshot.revision, observedHostname: host,
|
|
97
|
+
sourceFirstObservedAt: null, sourceLastObservedAt: null, firstLocalObservedAt: snapshot.importedAt,
|
|
98
|
+
completeness: 'unknown', gap: 'Feed membership and file publication do not establish a per-host event time, maliciousness or completeness.' }] };
|
|
99
|
+
if (candidate.matches.length > MAX_CANDIDATE_MATCHES)
|
|
100
|
+
throw new RangeError('This hostname matches too many explicit selectors for retained Brand provenance. Reduce the literal selection; no rule was silently discarded.');
|
|
101
|
+
if (!normalizeCandidateObservation(candidate))
|
|
102
|
+
throw new TypeError('The feed candidate provenance could not be admitted.');
|
|
103
|
+
return freezeOwned({ domain: host, exactHost, terms, candidate });
|
|
104
|
+
}
|
|
105
|
+
function freezeOwned(value) {
|
|
106
|
+
if (value && typeof value === 'object') {
|
|
107
|
+
for (const child of Object.values(value))
|
|
108
|
+
freezeOwned(child);
|
|
109
|
+
Object.freeze(value);
|
|
110
|
+
}
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
export function buildDomainFeedReview(metadata, input, domains, counts) {
|
|
114
|
+
const snapshot = snapshotMetadata(metadata), selection = normalizeDomainFeedSelection(input);
|
|
115
|
+
if (!Array.isArray(domains) || domains.length > DOMAIN_FEED_LIMITS.matches || new Set(domains).size !== domains.length)
|
|
116
|
+
throw new RangeError('Retain at most 200 distinct exact feed matches.');
|
|
117
|
+
const matches = domains.map(domain => {
|
|
118
|
+
const match = projectDomainFeedMatch(domain, snapshot, selection);
|
|
119
|
+
if (!match)
|
|
120
|
+
throw new TypeError('The returned feed hostname does not match the reviewed selection.');
|
|
121
|
+
return match;
|
|
122
|
+
});
|
|
123
|
+
for (const count of [counts.matched, counts.omitted])
|
|
124
|
+
if (count !== null && (!Number.isSafeInteger(count) || count < 0 || count > DOMAIN_FEED_LIMITS.rows))
|
|
125
|
+
throw new RangeError('Feed matching-row counts exceed their bound.');
|
|
126
|
+
if (typeof counts.truncated !== 'boolean' || (counts.matched === null) !== (counts.omitted === null)
|
|
127
|
+
|| (counts.matched !== null && (counts.matched < matches.length || counts.omitted !== counts.matched - matches.length)))
|
|
128
|
+
throw new TypeError('Disclose exact matching-row counts together, or keep both unknown.');
|
|
129
|
+
return freezeOwned({ ...snapshot, selection, matches, ...counts, limitations: DOMAIN_FEED_LIMITATIONS });
|
|
130
|
+
}
|
|
131
|
+
function canonicalValue(value) {
|
|
132
|
+
if (Array.isArray(value))
|
|
133
|
+
return value.map(canonicalValue);
|
|
134
|
+
if (value && typeof value === 'object')
|
|
135
|
+
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, child]) => [key, canonicalValue(child)]));
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
/** Strict transient reply boundary; never accept altered attribution or extra fields. */
|
|
139
|
+
export function normalizeDomainFeedReview(value) {
|
|
140
|
+
try {
|
|
141
|
+
assertWorkspaceInputGraph(value, 'Domain feed review');
|
|
142
|
+
const raw = ordinaryWorkspaceRecord(value, 'Domain feed review');
|
|
143
|
+
if (!raw || !Array.isArray(raw.matches) || raw.matches.length > DOMAIN_FEED_LIMITS.matches)
|
|
144
|
+
return null;
|
|
145
|
+
const domains = raw.matches.map(match => {
|
|
146
|
+
const row = ordinaryWorkspaceRecord(match, 'Domain feed match');
|
|
147
|
+
if (!row || typeof row.domain !== 'string')
|
|
148
|
+
throw new TypeError('Invalid feed match.');
|
|
149
|
+
return row.domain;
|
|
150
|
+
});
|
|
151
|
+
const reviewed = buildDomainFeedReview(raw, raw.selection, domains, { matched: raw.matched, omitted: raw.omitted, truncated: raw.truncated });
|
|
152
|
+
return JSON.stringify(canonicalValue(raw)) === JSON.stringify(canonicalValue(reviewed)) ? reviewed : null;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** Batches are provisional; consumers must discard staged ingestion on any rejection. */
|
|
159
|
+
export async function scanDomainFeed(chunks, options) {
|
|
160
|
+
domainFeedDefinition(options.feedId);
|
|
161
|
+
const selection = normalizeDomainFeedSelection(options.selection);
|
|
162
|
+
if (!selection.hosts.length && !selection.terms.length && !options.onDomains)
|
|
163
|
+
throw new TypeError('Select at least one exact host or literal term.');
|
|
164
|
+
const limits = { ...DOMAIN_FEED_LIMITS, ...options.limits };
|
|
165
|
+
for (const key of ['bytes', 'rows', 'lineBytes', 'durationMs'])
|
|
166
|
+
if (!Number.isSafeInteger(limits[key]) || limits[key] < 1 || limits[key] > DOMAIN_FEED_LIMITS[key])
|
|
167
|
+
throw new RangeError('Feed limits may only reduce the hard safety ceilings.');
|
|
168
|
+
const importedAt = normalizeExplicitIsoTimestamp(options.importedAt);
|
|
169
|
+
if (!importedAt)
|
|
170
|
+
throw new TypeError('Feed import time requires an explicit timestamp.');
|
|
171
|
+
const now = options.now ?? Date.now, started = now(), deadline = started + limits.durationMs;
|
|
172
|
+
const digest = createIncrementalSha256(limits.bytes), decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
173
|
+
const line = new Uint8Array(limits.lineBytes), retained = new Set(), hosts = new Set(selection.hosts);
|
|
174
|
+
let lineLength = 0, bytes = 0, rows = 0, lines = 0, matched = 0, truncated = false, firstLine = true, declaredPublishedAt = null, declaredVersion = null;
|
|
175
|
+
let batch = [];
|
|
176
|
+
function check() {
|
|
177
|
+
if (options.signal?.aborted)
|
|
178
|
+
throw new Error('Feed scan was cancelled; no review was produced.');
|
|
179
|
+
if (!Number.isFinite(now()) || now() >= deadline)
|
|
180
|
+
throw new RangeError('Feed scan exceeded its deadline; no review was produced.');
|
|
181
|
+
}
|
|
182
|
+
async function boundedAwait(promise) {
|
|
183
|
+
check();
|
|
184
|
+
let timer;
|
|
185
|
+
let abort;
|
|
186
|
+
try {
|
|
187
|
+
return await Promise.race([promise, new Promise((_, reject) => {
|
|
188
|
+
timer = setTimeout(() => reject(new RangeError('Feed scan exceeded its deadline; no review was produced.')), Math.max(1, deadline - now()));
|
|
189
|
+
abort = () => reject(new Error('Feed scan was cancelled; no review was produced.'));
|
|
190
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
191
|
+
})]);
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
if (timer !== undefined)
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
if (abort)
|
|
197
|
+
options.signal?.removeEventListener('abort', abort);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function consumeLine() {
|
|
201
|
+
if (++lines > limits.rows)
|
|
202
|
+
throw new RangeError('Feed rows exceed their limit; no review was produced.');
|
|
203
|
+
let text = decoder.decode(line.subarray(0, lineLength && line[lineLength - 1] === 13 ? lineLength - 1 : lineLength));
|
|
204
|
+
lineLength = 0;
|
|
205
|
+
if (firstLine) {
|
|
206
|
+
firstLine = false;
|
|
207
|
+
text = text.replace(/^\uFEFF/u, '');
|
|
208
|
+
}
|
|
209
|
+
if (CONTROL.test(text))
|
|
210
|
+
throw new TypeError('Feed content contains control characters; no review was produced.');
|
|
211
|
+
if (!text)
|
|
212
|
+
return;
|
|
213
|
+
if (text.startsWith('#')) {
|
|
214
|
+
const publication = /^#\s*Last modified:\s*(\d{2}) ([A-Z][a-z]{2}) (\d{4}) (\d{2}):(\d{2}) UTC\s*$/u.exec(text);
|
|
215
|
+
if (publication) {
|
|
216
|
+
const month = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'].indexOf(publication[2]);
|
|
217
|
+
const date = new Date(Date.UTC(Number(publication[3]), month, Number(publication[1]), Number(publication[4]), Number(publication[5])));
|
|
218
|
+
if (month < 0 || date.getUTCDate() !== Number(publication[1]) || date.getUTCMonth() !== month || Number(publication[4]) > 23 || Number(publication[5]) > 59)
|
|
219
|
+
throw new TypeError('The declared file publication timestamp is malformed.');
|
|
220
|
+
const value = date.toISOString();
|
|
221
|
+
if (declaredPublishedAt && declaredPublishedAt !== value)
|
|
222
|
+
throw new TypeError('Feed publication declarations conflict.');
|
|
223
|
+
declaredPublishedAt = value;
|
|
224
|
+
}
|
|
225
|
+
else if (/^#\s*Last modified:/u.test(text))
|
|
226
|
+
throw new TypeError('The declared file publication timestamp is malformed.');
|
|
227
|
+
const version = /^#\s*Version:\s*([A-Za-z0-9._+-]{1,80})\s*$/u.exec(text);
|
|
228
|
+
if (version) {
|
|
229
|
+
if (declaredVersion && declaredVersion !== version[1])
|
|
230
|
+
throw new TypeError('Feed version declarations conflict.');
|
|
231
|
+
declaredVersion = version[1];
|
|
232
|
+
}
|
|
233
|
+
else if (/^#\s*Version:/u.test(text))
|
|
234
|
+
throw new TypeError('The declared file version is malformed.');
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const domain = strictDomainFeedHostname(text);
|
|
238
|
+
if (!domain)
|
|
239
|
+
throw new TypeError('Feed rows must contain only plain domain names; no review was produced.');
|
|
240
|
+
rows++;
|
|
241
|
+
if ((hosts.has(domain) || selection.terms.some(term => domain.includes(term)))
|
|
242
|
+
&& !selection.negativeTerms?.some(term => domain.includes(term))) {
|
|
243
|
+
matched++;
|
|
244
|
+
if (retained.size < DOMAIN_FEED_LIMITS.matches)
|
|
245
|
+
retained.add(domain);
|
|
246
|
+
else if (!retained.has(domain))
|
|
247
|
+
truncated = true;
|
|
248
|
+
}
|
|
249
|
+
if (options.onDomains)
|
|
250
|
+
batch.push(domain);
|
|
251
|
+
}
|
|
252
|
+
async function flush() { if (batch.length) {
|
|
253
|
+
check();
|
|
254
|
+
const outgoing = Object.freeze(batch);
|
|
255
|
+
batch = [];
|
|
256
|
+
await boundedAwait(Promise.resolve(options.onDomains(outgoing)));
|
|
257
|
+
} }
|
|
258
|
+
const iterator = chunks[Symbol.asyncIterator]();
|
|
259
|
+
let complete = false;
|
|
260
|
+
try {
|
|
261
|
+
while (true) {
|
|
262
|
+
check();
|
|
263
|
+
const next = await boundedAwait(Promise.resolve(iterator.next()));
|
|
264
|
+
if (next.done)
|
|
265
|
+
break;
|
|
266
|
+
const chunk = next.value;
|
|
267
|
+
if (!(chunk instanceof Uint8Array) || chunk.byteLength > limits.bytes - bytes)
|
|
268
|
+
throw new RangeError('Feed bytes exceed their limit; no review was produced.');
|
|
269
|
+
bytes += chunk.byteLength;
|
|
270
|
+
for (let offset = 0; offset < chunk.byteLength; offset += 64 * 1024) {
|
|
271
|
+
check();
|
|
272
|
+
const slice = chunk.subarray(offset, Math.min(offset + 64 * 1024, chunk.byteLength));
|
|
273
|
+
digest.update(slice);
|
|
274
|
+
for (const byte of slice) {
|
|
275
|
+
if (byte === 10)
|
|
276
|
+
consumeLine();
|
|
277
|
+
else {
|
|
278
|
+
if (lineLength >= limits.lineBytes)
|
|
279
|
+
throw new RangeError('Feed line exceeds its byte limit; no review was produced.');
|
|
280
|
+
line[lineLength++] = byte;
|
|
281
|
+
}
|
|
282
|
+
if (batch.length === DOMAIN_FEED_LIMITS.batch)
|
|
283
|
+
await flush();
|
|
284
|
+
}
|
|
285
|
+
if (offset && offset % (1024 * 1024) === 0)
|
|
286
|
+
await boundedAwait(new Promise(resolve => setTimeout(resolve, 0)));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (lineLength)
|
|
290
|
+
consumeLine();
|
|
291
|
+
await flush();
|
|
292
|
+
check();
|
|
293
|
+
if (!rows)
|
|
294
|
+
throw new TypeError('The feed contains no plain domain rows.');
|
|
295
|
+
const result = buildDomainFeedReview({ feedId: options.feedId, revision: `sha256:${digest.digestHex()}`, importedAt,
|
|
296
|
+
acquiredAt: options.acquiredAt ?? null, declaredPublishedAt, declaredVersion, bytes, rows }, selection, [...retained], { matched, omitted: matched - retained.size, truncated });
|
|
297
|
+
complete = true;
|
|
298
|
+
return result;
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
if (!complete) {
|
|
302
|
+
try {
|
|
303
|
+
const closing = iterator.return?.();
|
|
304
|
+
if (closing)
|
|
305
|
+
void Promise.resolve(closing).catch(() => { });
|
|
306
|
+
}
|
|
307
|
+
catch { /* Preserve the scan failure. */ }
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/** Existing portable handoff only; selected domains must come from this review. */
|
|
312
|
+
export function buildDomainFeedWatchInput(review, domains, context, watchlists = null) {
|
|
313
|
+
if (!Array.isArray(domains) || domains.length > DOMAIN_FEED_LIMITS.matches || new Set(domains).size !== domains.length || !domains.length)
|
|
314
|
+
throw new TypeError('Choose 1–200 distinct reviewed feed domains.');
|
|
315
|
+
const available = new Map(review.matches.map(match => [match.domain, match.candidate]));
|
|
316
|
+
const candidates = domains.map(domain => { const candidate = available.get(domain); if (!candidate)
|
|
317
|
+
throw new TypeError('The selection contains an unreviewed hostname.'); return candidate; });
|
|
318
|
+
const selection = { ...context, candidates };
|
|
319
|
+
planCandidateWatchHandoff(watchlists, selection);
|
|
320
|
+
return freezeOwned(structuredClone({ schema: CANDIDATE_WATCH_INPUT_SCHEMA, version: CANDIDATE_WATCH_INPUT_VERSION, watchlists, selection }));
|
|
321
|
+
}
|