@slicedearth/whoisleuth-cli 2.7.0 → 2.8.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/cli/bulk-command-runner.mjs +5 -4
- package/cli/collection-arguments.mjs +6 -0
- package/cli/collection-command-definitions.mjs +13 -3
- package/cli/command-definition.mjs +2 -0
- package/cli/discovery-scan-command-runner.mjs +13 -7
- package/cli/domain-control-monitor.mjs +25 -2
- package/cli/domain-control-observations.mjs +52 -15
- package/cli/fail-policy.mjs +31 -22
- package/cli/lookup-command-runner.mjs +6 -5
- package/cli/lookup-diff.mjs +1 -0
- package/cli/risk-policy.mjs +83 -0
- package/cli/workflow-command-runner.mjs +5 -1
- package/docs/cli-reference.md +7 -0
- package/docs/cli.md +53 -1
- package/lib/generated/cisa-kev-catalog.mjs +5 -3
- package/lib/sslbl-certificates.generated.mjs +139 -139
- package/package.json +1 -1
- package/packages/analysis/brand-profile-signals.mjs +42 -0
- package/packages/investigation/bulk-domain-comparison.mjs +17 -8
- package/third-party-notices.txt +1 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { analyzeDomainIdn } from "../lib/idn-confusables.mjs";
|
|
2
|
+
import { profileSignals } from "../packages/analysis/brand-profile-signals.mjs";
|
|
3
|
+
import { boundedJsonLimitsForBytes, parseBoundedJsonObject } from "../packages/analysis/bounded-json.mjs";
|
|
4
|
+
import { canonicalRegistrableDomain } from "../packages/analysis/registrable-domain.mjs";
|
|
5
|
+
import { explainRiskScore } from "../packages/analysis/risk-scoring.mjs";
|
|
6
|
+
import { BRAND_PROFILE_SCHEMA, MAX_PROFILES, MAX_PROFILE_IMPORT_BYTES } from "../packages/contracts/workspace-portability.mjs";
|
|
7
|
+
import { CliUsageError } from "./errors.mjs";
|
|
8
|
+
function record(value) {
|
|
9
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
10
|
+
}
|
|
11
|
+
/** Read one explicitly selected export, not browser storage or configuration. */
|
|
12
|
+
export async function readCliRiskProfile(source, readInput, selectedId) {
|
|
13
|
+
if (!source)
|
|
14
|
+
return null;
|
|
15
|
+
const text = await readInput(source, MAX_PROFILE_IMPORT_BYTES, 'Brand Profile export');
|
|
16
|
+
try {
|
|
17
|
+
const raw = parseBoundedJsonObject(text, {
|
|
18
|
+
maximumBytes: MAX_PROFILE_IMPORT_BYTES, limits: boundedJsonLimitsForBytes(MAX_PROFILE_IMPORT_BYTES), label: 'Brand Profile export',
|
|
19
|
+
});
|
|
20
|
+
if (raw.schema !== BRAND_PROFILE_SCHEMA || typeof raw.version !== 'number'
|
|
21
|
+
|| !Array.isArray(raw.profiles) || !raw.profiles.length || raw.profiles.length > MAX_PROFILES)
|
|
22
|
+
throw new TypeError();
|
|
23
|
+
const { normalizeBrandProfileId, normalizeBrandProfileStore } = await import("../packages/workspace/brand-profile-model.mjs");
|
|
24
|
+
if (selectedId !== undefined && normalizeBrandProfileId(selectedId) !== selectedId)
|
|
25
|
+
throw new TypeError();
|
|
26
|
+
const selected = selectedId === undefined ? raw.profiles : raw.profiles.filter(value => record(value).id === selectedId);
|
|
27
|
+
if (selected.length !== 1)
|
|
28
|
+
throw new TypeError();
|
|
29
|
+
const profile = normalizeBrandProfileStore({ ...raw, profiles: selected }).profiles[0];
|
|
30
|
+
if (!profile || !profile.officialDomains.length)
|
|
31
|
+
throw new TypeError();
|
|
32
|
+
// Private notes, contacts and other profile fields never enter output or
|
|
33
|
+
// checkpoints. Keep only the existing comparison inputs for this run.
|
|
34
|
+
return {
|
|
35
|
+
officialDomains: profile.officialDomains,
|
|
36
|
+
approvedPartnerDomains: profile.approvedPartnerDomains,
|
|
37
|
+
allowlistedDomains: profile.allowlistedDomains,
|
|
38
|
+
officialFaviconHash: profile.officialFaviconHash,
|
|
39
|
+
officialFaviconPHash: profile.officialFaviconPHash,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new CliUsageError('Use a supported Brand Profile export with a valid official domain; select one unambiguous profile with --brand-id if the export contains multiple profiles.');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Adapt collected evidence, never an asserted score, lane or severity label. */
|
|
47
|
+
export function explainCliLookupRisk(document, profile, mode = document.mode) {
|
|
48
|
+
if (document.type !== undefined && document.type !== 'domain')
|
|
49
|
+
return null;
|
|
50
|
+
const availability = record(document.availability);
|
|
51
|
+
const domain = canonicalRegistrableDomain(document.registrableDomain ?? document.domain ?? availability.domain);
|
|
52
|
+
if (!domain || canonicalRegistrableDomain(availability.domain) !== domain)
|
|
53
|
+
return null;
|
|
54
|
+
const diagnostics = record(document.diagnostics);
|
|
55
|
+
const sourceCoverage = [
|
|
56
|
+
...['rdap', 'whois', 'availability'].map(source => ({ source, state: record(diagnostics[source]).status })),
|
|
57
|
+
...['dns', 'http', 'tls', 'pageIdentity'].map(source => ({
|
|
58
|
+
source: source === 'pageIdentity' ? 'page-identity' : source,
|
|
59
|
+
state: record(availability[source]).status,
|
|
60
|
+
})),
|
|
61
|
+
].map(item => ({ ...item, state: item.state === 'disabled' ? 'skipped' : item.state }));
|
|
62
|
+
return explainRiskScore({
|
|
63
|
+
domain,
|
|
64
|
+
availability: availability.state,
|
|
65
|
+
...profileSignals(domain, availability, profile),
|
|
66
|
+
hasActiveBrandProfile: Boolean(profile),
|
|
67
|
+
idnReferenceMatch: profile ? Boolean(analyzeDomainIdn(domain, profile.officialDomains)?.referenceMatches.length) : null,
|
|
68
|
+
mutationTypes: document.mutationTypes,
|
|
69
|
+
hasPasswordField: availability.hasPasswordField,
|
|
70
|
+
hasExternalFormAction: availability.hasExternalFormAction,
|
|
71
|
+
hasExternalPasswordForm: availability.hasExternalPasswordForm,
|
|
72
|
+
phishingLanguageMatch: availability.phishingLanguageMatch,
|
|
73
|
+
activityStatus: availability.activityStatus,
|
|
74
|
+
hasMx: availability.hasMx,
|
|
75
|
+
hasSpf: availability.hasSpf,
|
|
76
|
+
hasDmarc: availability.hasDmarc,
|
|
77
|
+
domainAgeDays: availability.domainAgeDays,
|
|
78
|
+
privacyProtected: availability.privacyProtected,
|
|
79
|
+
scanDepth: mode,
|
|
80
|
+
observedAt: document.observedAt ?? record(availability.http).observedAt,
|
|
81
|
+
sourceCoverage,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -88,6 +88,7 @@ async function runMonitorOnceCommand(args, dependencies, context) {
|
|
|
88
88
|
const executeLookup = dependencies.runUnifiedLookup || (await import("../lib/lookup.mjs")).runUnifiedLookup;
|
|
89
89
|
const progress = context.beginProgress('Collecting bounded domain-control evidence');
|
|
90
90
|
let document;
|
|
91
|
+
let currentSourceFailureCount = 0;
|
|
91
92
|
try {
|
|
92
93
|
document = await runDomainControlMonitor(manifestInput, previousInput, {
|
|
93
94
|
executeLookup,
|
|
@@ -95,6 +96,9 @@ async function runMonitorOnceCommand(args, dependencies, context) {
|
|
|
95
96
|
limit: args.limit,
|
|
96
97
|
concurrency: args.concurrency,
|
|
97
98
|
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
99
|
+
...(args.failOn?.includes('source-failure') ? {
|
|
100
|
+
onSourceFailures: (count) => { currentSourceFailureCount = count; },
|
|
101
|
+
} : {}),
|
|
98
102
|
onSettled: (completed, total) => progress.update(`Collected ${completed} of ${total} owned domains`),
|
|
99
103
|
});
|
|
100
104
|
}
|
|
@@ -108,7 +112,7 @@ async function runMonitorOnceCommand(args, dependencies, context) {
|
|
|
108
112
|
? formatCliJunit(document)
|
|
109
113
|
: context.terminal(formatDomainControlMonitor(document), args.color));
|
|
110
114
|
}
|
|
111
|
-
const policyFindings = evaluateCliFailPolicies(document, args.failOn || []);
|
|
115
|
+
const policyFindings = evaluateCliFailPolicies(document, args.failOn || [], null, currentSourceFailureCount);
|
|
112
116
|
if (policyFindings.length)
|
|
113
117
|
context.writeStderr(formatFailPolicyNotice(policyFindings));
|
|
114
118
|
return document.collection.failed || policyFindings.length
|
package/docs/cli-reference.md
CHANGED
|
@@ -128,6 +128,13 @@ source state is unmeasured, not a negative result. `--csv` keeps the compact col
|
|
|
128
128
|
`--fail-on` and `--strict-exit` expose selected evidence states to automation
|
|
129
129
|
without changing the result document. Review focused help for the policies a
|
|
130
130
|
command supports.
|
|
131
|
+
Lookup, Bulk and Discover scan evaluate all collected and restored evidence,
|
|
132
|
+
not only the rows retained by a filter. `danger` uses the canonical Risk model;
|
|
133
|
+
select `--deep --brand-profile brand.json` for local reference comparisons, adding
|
|
134
|
+
`--brand-id <exact-profile-id>` for a multi-profile export. A match returns 4 and identifies model and evidence
|
|
135
|
+
quality on stderr. Missing/ineligible evidence is not zero risk and does not
|
|
136
|
+
implicitly select another failure policy. See the
|
|
137
|
+
[automation examples and limits](cli.md#formats-and-exit-behaviour).
|
|
131
138
|
|
|
132
139
|
| Code | Meaning |
|
|
133
140
|
| ---: | --- |
|
package/docs/cli.md
CHANGED
|
@@ -649,7 +649,10 @@ second input. Supply every input for an uncompleted step to use files instead,
|
|
|
649
649
|
or override a connection with `--use-artifact <step-id>:<input-number>=<earlier-step-id>`.
|
|
650
650
|
Input numbers start at 1 and refer to the fixed recipe's placeholders.
|
|
651
651
|
`diff` compares saved observations of the same or different domains; `timeline`
|
|
652
|
-
orders observations of one domain.
|
|
652
|
+
orders observations of one domain. Web-derived fields require complete collection
|
|
653
|
+
for the relevant page or favicon; failed, partial and historical unknown collection
|
|
654
|
+
keep their values visible without establishing equality or change. Source-health
|
|
655
|
+
changes remain comparable separately. Candidate and domain-control intent inputs
|
|
653
656
|
remain analyst selections. No external file is inferred or extracted automatically.
|
|
654
657
|
`reconcile` lists each observation time and declared observer/vantage, keeping
|
|
655
658
|
disagreement and unavailable evidence separate. Distinct labels do not verify
|
|
@@ -763,6 +766,55 @@ and machine output contains no ANSI or transient progress text.
|
|
|
763
766
|
- `--output` writes a private local file atomically and refuses an existing path
|
|
764
767
|
unless `--force` is also selected.
|
|
765
768
|
|
|
769
|
+
Lookup, Bulk and Discover scan evaluate `--fail-on` against every collected or
|
|
770
|
+
restored result, before output filters. A fulfilled lookup can still match
|
|
771
|
+
`source-failure` through its diagnostics. Quiet and event output do not change
|
|
772
|
+
the policy exit: matches return **4**, with diagnostics on stderr. Ordinary
|
|
773
|
+
collection/checkpoint failures and Lookup's separate `--strict-exit` still apply.
|
|
774
|
+
|
|
775
|
+
For `monitor-once`, `source-failure` also checks the current run's constituent
|
|
776
|
+
source diagnostics, even when the Lookup fulfils. `inconclusive` checks incomplete
|
|
777
|
+
requested comparisons independently of the headline: MX drift does not hide
|
|
778
|
+
unavailable nameserver evidence. Skipped or unsupported sources are not failed
|
|
779
|
+
requests; an unsupported requested comparison can still be inconclusive.
|
|
780
|
+
Unconfigured expectations and historical source failures do not match these
|
|
781
|
+
policies. `material-drift` continues to mean unexpected complete observation
|
|
782
|
+
changes in the flight recorder, not a desired-state mismatch or missing evidence.
|
|
783
|
+
Policy matches return **4** in JSON and quiet modes too, without adding diagnostic
|
|
784
|
+
fields to monitor reports or checkpoints. Without a policy, rejected Lookups
|
|
785
|
+
retain their existing failure exit; constituent-source states alone do not.
|
|
786
|
+
|
|
787
|
+
Domain-control review keeps registration evidence at the registrable domain and
|
|
788
|
+
DNS, TLS and web evidence at the observed hostname. It selects the newest eligible
|
|
789
|
+
evidence within each scope; a newer child-host capture cannot replace an apex or
|
|
790
|
+
sibling's service evidence. Separately declared delegation scope is retained.
|
|
791
|
+
Historical Lookups use their retained availability domain, or their original
|
|
792
|
+
registration scope when no hostname was retained, never the submitted query.
|
|
793
|
+
`monitor-once` accepts only registrable-domain targets and rejects an admitted
|
|
794
|
+
child target before any collection starts. A wrong-host collector response is a
|
|
795
|
+
failed Lookup, not an extra successful observation or a retargeted check.
|
|
796
|
+
|
|
797
|
+
For `danger`, export your Brand Profiles from Brands and explicitly select Deep
|
|
798
|
+
collection. The same Risk model and Brand comparisons used by the console
|
|
799
|
+
evaluate the collected evidence without extra requests:
|
|
800
|
+
|
|
801
|
+
```bash
|
|
802
|
+
whoisleuth lookup candidate.example --deep --brand-profile brand.json --fail-on danger --json
|
|
803
|
+
whoisleuth bulk domains.txt --deep --brand-profile brand.json --fail-on danger --errors-only --json
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
The selected file uses the existing versioned Brand Profile export format,
|
|
807
|
+
must provide one unambiguous valid profile with an official domain, and is bounded
|
|
808
|
+
by the existing Brand Profile import byte limit. If the export contains multiple
|
|
809
|
+
profiles, add `--brand-id <exact-profile-id>` from the selected profile's `id`
|
|
810
|
+
field; no first-profile default is chosen. Its private fields are not copied into
|
|
811
|
+
results or checkpoints or sent to sources. Restore it explicitly when resuming.
|
|
812
|
+
Danger matches report the scoring-model version and evidence quality. The
|
|
813
|
+
threshold prioritises review, not confirmed maliciousness. Fast collection,
|
|
814
|
+
missing Brand context or ineligible registration evidence cannot establish a
|
|
815
|
+
match; exit **0** does not establish low risk, complete coverage or safety.
|
|
816
|
+
`inconclusive` and `source-failure` remain independently selected policies.
|
|
817
|
+
|
|
766
818
|
Local configuration profiles can set presentation, Fast collection, bounded
|
|
767
819
|
concurrency and observer labels. They cannot add targets, enable Deep
|
|
768
820
|
collection, choose output paths or approve network work. Explicit command
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Generated by tools/cisa-kev-catalog.mts from the pinned CISA KEV feed.
|
|
2
2
|
// United States Government work; do not edit by hand.
|
|
3
3
|
const CISA_KEV_CATALOG = Object.freeze({
|
|
4
|
-
catalogVersion: "2026.10.
|
|
5
|
-
releasedAt: "2026-10-
|
|
6
|
-
sourceSha256: "
|
|
4
|
+
catalogVersion: "2026.10.16",
|
|
5
|
+
releasedAt: "2026-10-16T17:15:48.8752Z",
|
|
6
|
+
sourceSha256: "b6e27975f6635ecfc546fbd4fd6e444ba63a07ab2abc8876229d77fe083d357d",
|
|
7
7
|
sourceUrl: "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json",
|
|
8
8
|
identifiers: Object.freeze([
|
|
9
9
|
"CVE-2002-0367",
|
|
@@ -1547,6 +1547,7 @@ const CISA_KEV_CATALOG = Object.freeze({
|
|
|
1547
1547
|
"CVE-2025-59689",
|
|
1548
1548
|
"CVE-2025-59718",
|
|
1549
1549
|
"CVE-2025-60710",
|
|
1550
|
+
"CVE-2025-61624",
|
|
1550
1551
|
"CVE-2025-61757",
|
|
1551
1552
|
"CVE-2025-61882",
|
|
1552
1553
|
"CVE-2025-61884",
|
|
@@ -1582,6 +1583,7 @@ const CISA_KEV_CATALOG = Object.freeze({
|
|
|
1582
1583
|
"CVE-2026-0770",
|
|
1583
1584
|
"CVE-2026-102489",
|
|
1584
1585
|
"CVE-2026-102490",
|
|
1586
|
+
"CVE-2026-102764",
|
|
1585
1587
|
"CVE-2026-104286",
|
|
1586
1588
|
"CVE-2026-104935",
|
|
1587
1589
|
"CVE-2026-105133",
|