@slicedearth/whoisleuth-cli 1.39.1 → 1.43.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.
Files changed (66) hide show
  1. package/README.md +15 -0
  2. package/bin/whoisleuth.mjs +13 -2
  3. package/cli/archive-inspect.mjs +37 -2
  4. package/cli/arguments.mjs +361 -256
  5. package/cli/artifact-verify.mjs +35 -1
  6. package/cli/bulk-checkpoint.mjs +7 -11
  7. package/cli/bulk-command-runner.mjs +19 -2
  8. package/cli/case-pack.mjs +136 -0
  9. package/cli/ci-report.mjs +73 -0
  10. package/cli/collection-preflight.mjs +59 -0
  11. package/cli/command-reference.mjs +395 -0
  12. package/cli/completion.mjs +33 -10
  13. package/cli/config-profile.mjs +180 -0
  14. package/cli/ct-event-intake.mjs +159 -0
  15. package/cli/discovery-scan-command-runner.mjs +15 -1
  16. package/cli/domain-control-monitor.mjs +104 -0
  17. package/cli/domain-control-observations.mjs +235 -0
  18. package/cli/evidence-command-arguments.mjs +11 -0
  19. package/cli/evidence-command-runner.mjs +1 -0
  20. package/cli/external-observation-mapping.mjs +162 -0
  21. package/cli/fail-policy.mjs +64 -0
  22. package/cli/formatters/html.mjs +1 -1
  23. package/cli/investigation-manifest.mjs +113 -0
  24. package/cli/investigation-run.mjs +112 -0
  25. package/cli/lookup-brief.mjs +116 -0
  26. package/cli/lookup-command-runner.mjs +8 -1
  27. package/cli/lookup-diff.mjs +17 -19
  28. package/cli/network-command-runner.mjs +4 -1
  29. package/cli/offline-evidence-review.mjs +105 -8
  30. package/cli/open-asset-model-bridge.mjs +169 -0
  31. package/cli/registry-cohort.mjs +121 -0
  32. package/cli/registry-doctor.mjs +106 -11
  33. package/cli/registry-fixture-scaffold.mjs +73 -0
  34. package/cli/runner.mjs +368 -306
  35. package/docs/cli-reference.md +240 -14
  36. package/docs/cli.md +42 -7
  37. package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
  38. package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
  39. package/frontend/src/lib/analysis/case-report.js +573 -0
  40. package/frontend/src/lib/analysis/case-response-model.js +30 -1
  41. package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
  42. package/frontend/src/lib/analysis/external-findings-import.js +320 -0
  43. package/frontend/src/lib/analysis/lookup-decision-support.js +7 -0
  44. package/frontend/src/lib/analysis/lookup-presentation.js +10 -20
  45. package/frontend/src/lib/analysis/scoring.js +1 -1
  46. package/frontend/src/lib/analysis/visualization-models.js +2 -3
  47. package/frontend/src/lib/analysis/workspace-archive.js +2 -0
  48. package/lib/abusech-auth.mjs +1 -1
  49. package/lib/availability.mjs +1 -1
  50. package/lib/bounded-contract-normalizers.mjs +33 -0
  51. package/lib/dns-convergence-review.mjs +183 -0
  52. package/lib/dns-delegation-health.mjs +196 -3
  53. package/lib/domain-change-packet.mjs +133 -0
  54. package/lib/domain-change-review.mjs +307 -0
  55. package/lib/domain-control-flight-recorder.mjs +268 -0
  56. package/lib/domain-portfolio-review.mjs +151 -0
  57. package/lib/evidence-export.mjs +1 -1
  58. package/lib/html-signals.mjs +5 -11
  59. package/lib/nameserver-preflight-review.mjs +159 -0
  60. package/lib/page-language-signals.mjs +124 -0
  61. package/lib/risk-scoring.mjs +42 -1
  62. package/lib/static-html-analysis.mjs +29 -1
  63. package/lib/trust-store-comparison.mjs +146 -0
  64. package/lib/website-technology.mjs +24 -10
  65. package/lib/zone-intent-review.mjs +534 -0
  66. package/package.json +1 -1
@@ -0,0 +1,112 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { buildInvestigationPlan, } from "./investigation-plan.mjs";
3
+ import { CliUsageError } from "./errors.mjs";
4
+ export const CLI_INVESTIGATION_RUN_SCHEMA = 'whoisleuth.cli.investigation-run';
5
+ export const CLI_INVESTIGATION_RUN_VERSION = 1;
6
+ export const MAX_INVESTIGATION_RUN_BYTES = 24 * 1024 * 1024;
7
+ function boundedResult(value) {
8
+ if (Buffer.byteLength(value, 'utf8') > MAX_INVESTIGATION_RUN_BYTES) {
9
+ throw new CliUsageError(`Investigation step output is limited to ${MAX_INVESTIGATION_RUN_BYTES} bytes.`);
10
+ }
11
+ try {
12
+ return JSON.parse(value);
13
+ }
14
+ catch {
15
+ return value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, '').slice(0, MAX_INVESTIGATION_RUN_BYTES);
16
+ }
17
+ }
18
+ function parseResumeState(input, recipe, subject) {
19
+ if (!input)
20
+ return [];
21
+ if (Buffer.byteLength(input, 'utf8') > MAX_INVESTIGATION_RUN_BYTES)
22
+ throw new CliUsageError('Investigation resume state exceeds the 24 MiB limit.');
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(input.replace(/^\uFEFF/u, ''));
26
+ }
27
+ catch {
28
+ throw new CliUsageError('Investigation resume state must be valid JSON.');
29
+ }
30
+ const root = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
31
+ if (root.schema !== CLI_INVESTIGATION_RUN_SCHEMA || root.version !== CLI_INVESTIGATION_RUN_VERSION || root.recipe !== recipe || root.subject !== subject || !Array.isArray(root.completedSteps)) {
32
+ throw new CliUsageError('Investigation resume state must match this versioned recipe and subject.');
33
+ }
34
+ return root.completedSteps.slice(0, 16).flatMap((value) => {
35
+ if (!value || typeof value !== 'object' || Array.isArray(value))
36
+ return [];
37
+ const item = value;
38
+ if (typeof item.id !== 'string' || typeof item.command !== 'string' || !Array.isArray(item.arguments) || !['offline', 'network'].includes(String(item.mode)) || !Number.isSafeInteger(item.exitCode))
39
+ return [];
40
+ return [Object.freeze({
41
+ id: item.id,
42
+ command: item.command,
43
+ arguments: Object.freeze(item.arguments.filter((entry) => typeof entry === 'string').slice(0, 32)),
44
+ mode: item.mode,
45
+ exitCode: Number(item.exitCode),
46
+ result: item.result,
47
+ })];
48
+ });
49
+ }
50
+ export async function runInvestigationRecipe(recipe, subjectValue, options) {
51
+ const plan = buildInvestigationPlan(recipe, subjectValue, options.generatedAt);
52
+ const prior = parseResumeState(options.resumeInput, recipe, plan.subject);
53
+ const allowedStepIds = new Set(plan.steps.map((step) => step.id));
54
+ if (prior.some((step) => !allowedStepIds.has(step.id)))
55
+ throw new CliUsageError('Investigation resume state contains a step outside the fixed recipe.');
56
+ const completed = [...prior];
57
+ let state = 'complete';
58
+ let currentStep = null;
59
+ for (const step of plan.steps) {
60
+ if (completed.some((item) => item.id === step.id))
61
+ continue;
62
+ currentStep = step;
63
+ if (step.arguments.some((argument) => /^<[^>]+>$/u.test(argument))) {
64
+ state = 'awaiting_analyst_selection';
65
+ break;
66
+ }
67
+ if (step.mode === 'network' && !options.approveNetwork) {
68
+ state = 'awaiting_network_approval';
69
+ break;
70
+ }
71
+ const result = await options.execute(step.command, step.arguments);
72
+ completed.push(Object.freeze({
73
+ id: step.id,
74
+ command: step.command,
75
+ arguments: step.arguments,
76
+ mode: step.mode,
77
+ exitCode: result.exitCode,
78
+ result: boundedResult(result.stdout),
79
+ }));
80
+ if (result.exitCode !== 0 && result.exitCode !== 2) {
81
+ state = 'step_failed';
82
+ break;
83
+ }
84
+ currentStep = null;
85
+ }
86
+ return Object.freeze({
87
+ schema: CLI_INVESTIGATION_RUN_SCHEMA,
88
+ version: CLI_INVESTIGATION_RUN_VERSION,
89
+ generatedAt: options.generatedAt,
90
+ recipe,
91
+ subject: plan.subject,
92
+ state,
93
+ networkApprovedForThisRun: options.approveNetwork,
94
+ completedSteps: Object.freeze(completed),
95
+ currentStep,
96
+ limitations: Object.freeze([
97
+ 'Only commands and arguments from the installed fixed recipe can execute; no shell, script, arbitrary command, or enforcement action is accepted.',
98
+ 'Network steps run only with --approve-network for the current invocation. Analyst-selection placeholders always pause and are never interpreted as paths or values.',
99
+ 'A resume file is a local checkpoint, not proof that prior evidence remains current or that a human reviewed each stored result.',
100
+ ]),
101
+ });
102
+ }
103
+ export function formatInvestigationRun(document) {
104
+ return [
105
+ `Investigation run: ${document.recipe}`,
106
+ `Subject ${document.subject}`,
107
+ `State ${document.state.replaceAll('_', ' ')}`,
108
+ `Completed ${document.completedSteps.length}`,
109
+ ...(document.currentStep ? [`Next ${document.currentStep.label}`, `Approval ${document.currentStep.approval.replaceAll('_', ' ')}`] : []),
110
+ '',
111
+ ].join('\n');
112
+ }
@@ -0,0 +1,116 @@
1
+ import { parseSavedLookupDocument } from "./saved-lookup.mjs";
2
+ export const CLI_LOOKUP_BRIEF_SCHEMA = 'whoisleuth.cli.lookup-brief';
3
+ export const CLI_LOOKUP_BRIEF_VERSION = 2;
4
+ function record(value) {
5
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
6
+ }
7
+ function text(value, maximum = 300) {
8
+ if (typeof value !== 'string')
9
+ return null;
10
+ const normalized = value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum);
11
+ return normalized || null;
12
+ }
13
+ function sourceState(value) {
14
+ return text(value, 40)?.toLowerCase() ?? 'unavailable';
15
+ }
16
+ export function buildCliLookupBrief(input, generatedAt = new Date().toISOString()) {
17
+ const document = parseSavedLookupDocument(input, { label: 'Lookup brief input' });
18
+ const diagnostics = record(document.diagnostics);
19
+ const availability = record(document.availability);
20
+ const rdap = record(record(document.rdap).parsed);
21
+ const whois = record(record(document.whois).parsed);
22
+ const preferred = Object.keys(rdap).length ? rdap : whois;
23
+ const sources = ['rdap', 'whois', 'availability', 'dns', 'http', 'tls'].map((id) => {
24
+ const nested = id === 'dns' || id === 'http' || id === 'tls' ? record(availability[id]) : record(diagnostics[id]);
25
+ return Object.freeze({ id, state: sourceState(nested.status ?? nested.state), observedAt: text(nested.observedAt ?? nested.fetchedAt ?? nested.queriedAt, 64) });
26
+ });
27
+ const facts = [
28
+ ['Registration state', availability.state, 'Authority-aware availability'],
29
+ ['Registrar', record(preferred.registrar).name ?? preferred.registrar, Object.keys(rdap).length ? 'Registry RDAP' : 'WHOIS'],
30
+ ['Created', record(preferred.lifecycle).createdIso ?? preferred.createdDateIso, Object.keys(rdap).length ? 'Registry RDAP' : 'WHOIS'],
31
+ ['Expires', record(preferred.lifecycle).expiryIso ?? preferred.expiryDateIso, Object.keys(rdap).length ? 'Registry RDAP' : 'WHOIS'],
32
+ ['Website activity', availability.activityStatus, 'HTTP'],
33
+ ['Page title', availability.pageTitle, 'Static page identity'],
34
+ ].flatMap(([label, value, source]) => {
35
+ const normalized = text(value);
36
+ return normalized ? [Object.freeze({ label: String(label), value: normalized, source: String(source) })] : [];
37
+ });
38
+ const incomplete = sources.filter((item) => !['complete', 'success'].includes(item.state));
39
+ const analysis = record(document.analysis);
40
+ const contradictions = [analysis.registryComparison, document.registryComparison, document.registrarPublicationComparison]
41
+ .flatMap((value) => Array.isArray(record(value).fields) ? record(value).fields : [])
42
+ .flatMap((value) => {
43
+ const item = record(value);
44
+ return item.status === 'conflict' ? [text(item.label, 120) ?? 'Registration publication conflict'] : [];
45
+ }).slice(0, 16);
46
+ const actionPlan = [
47
+ ...(contradictions.length ? [{
48
+ id: 'registration-publication-review',
49
+ action: 'Review the separately attributed registration publications before relying on the conflicting fields.',
50
+ reason: `${contradictions.length} conflicting publication field${contradictions.length === 1 ? '' : 's'} were retained.`,
51
+ expectedOutcome: 'Establish which observation is current, authoritative, or still unresolved.',
52
+ evidence: 'registry publication comparison',
53
+ }] : []),
54
+ ...(incomplete.length ? [{
55
+ id: 'source-state-review',
56
+ action: `Refresh or explain the ${incomplete.map((item) => item.id).join(', ')} source state before treating missing values as meaningful.`,
57
+ reason: 'At least one source was not complete in the saved observation.',
58
+ expectedOutcome: 'Determine whether each limitation is transient, persistent, or expected for that source.',
59
+ evidence: 'source health',
60
+ }] : []),
61
+ ...(document.mode === 'fast' ? [{
62
+ id: 'collection-depth-review',
63
+ action: 'Run a deliberate Deep lookup if DNS, HTTP, TLS, page, or network evidence is required.',
64
+ reason: 'The saved observation used the intentionally narrower Fast contract.',
65
+ expectedOutcome: 'Collect the explicitly selected additional source classes without implying that unavailable evidence is absent.',
66
+ evidence: 'collection mode',
67
+ }] : []),
68
+ {
69
+ id: 'case-evidence-review',
70
+ action: 'Pin only the facts needed for the decision and keep analyst hypotheses separate from observations.',
71
+ reason: 'The brief organises one observation but does not create an analyst decision.',
72
+ expectedOutcome: 'Preserve a reviewable boundary between observed facts, hypotheses, unknowns, and decisions.',
73
+ evidence: 'case workflow',
74
+ },
75
+ ].slice(0, 6).map((item) => Object.freeze(item));
76
+ const recommendedActions = actionPlan.map((item) => item.action);
77
+ return Object.freeze({
78
+ schema: CLI_LOOKUP_BRIEF_SCHEMA,
79
+ version: CLI_LOOKUP_BRIEF_VERSION,
80
+ generatedAt,
81
+ target: document.registrableDomain,
82
+ observedAt: document.generatedAt,
83
+ mode: document.mode,
84
+ facts: Object.freeze(facts),
85
+ sourceHealth: Object.freeze(sources),
86
+ contradictions: Object.freeze(contradictions),
87
+ unknowns: Object.freeze(incomplete.map((item) => `${item.id}: ${item.state}`)),
88
+ actionPlan: Object.freeze(actionPlan),
89
+ recommendedActions: Object.freeze(recommendedActions.slice(0, 6)),
90
+ limitations: Object.freeze([
91
+ 'This brief is derived from one saved Lookup and makes no request.',
92
+ 'It organises bounded facts and uncertainty but does not create an analyst assertion, decide maliciousness, or establish current state.',
93
+ 'Raw registry, provider, HTTP and page payloads are excluded.',
94
+ ]),
95
+ });
96
+ }
97
+ export function formatCliLookupBrief(document) {
98
+ return [
99
+ `Lookup brief: ${document.target}`,
100
+ `Observed ${document.observedAt}`,
101
+ `Mode ${document.mode}`,
102
+ '',
103
+ 'Verified facts',
104
+ ...(document.facts.length ? document.facts.map((item) => ` ${item.label}: ${item.value} (${item.source})`) : [' No bounded fact was available.']),
105
+ '',
106
+ 'Unknown or incomplete',
107
+ ...(document.unknowns.length ? document.unknowns.map((item) => ` ${item}`) : [' No incomplete source state was identified.']),
108
+ '',
109
+ 'Recommended manual actions',
110
+ ...document.actionPlan.flatMap((item, index) => [
111
+ ` ${index + 1}. ${item.action}`,
112
+ ` Expected outcome: ${item.expectedOutcome}`,
113
+ ]),
114
+ '',
115
+ ].join('\n');
116
+ }
@@ -11,6 +11,8 @@ import { formatTerminalLookup } from "./formatters/terminal.mjs";
11
11
  import { buildCliLookupPlan, formatCliLookupPlan } from "./lookup-plan.mjs";
12
12
  import { createCliProgressEvents } from "./progress-events.mjs";
13
13
  import { lookupStrictExitFindings } from "./strict-exit.mjs";
14
+ import { evaluateCliFailPolicies, formatFailPolicyNotice } from "./fail-policy.mjs";
15
+ import { formatCliJunit } from "./ci-report.mjs";
14
16
  async function runLookupCommand(args, dependencies, context) {
15
17
  const eventProgress = createCliProgressEvents(context.stderr, {
16
18
  command: 'lookup',
@@ -75,6 +77,8 @@ async function runLookupCommand(args, dependencies, context) {
75
77
  if (!args.quiet) {
76
78
  if (args.output === 'json')
77
79
  context.writeStdout(formatJsonDocument(document));
80
+ else if (args.output === 'junit')
81
+ context.writeStdout(formatCliJunit(document));
78
82
  else if (args.output === 'markdown' || args.output === 'html') {
79
83
  const loadEvidence = dependencies.loadEvidenceExport || (() => import("../lib/evidence-export.mjs"));
80
84
  const evidenceModule = await loadEvidence();
@@ -88,10 +92,13 @@ async function runLookupCommand(args, dependencies, context) {
88
92
  }
89
93
  }
90
94
  const strictFindings = args.strictExit ? lookupStrictExitFindings(document) : [];
91
- const exitCode = strictFindings.length ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
95
+ const policyFindings = evaluateCliFailPolicies(document, args.failOn || []);
96
+ const exitCode = strictFindings.length || policyFindings.length ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
92
97
  if (strictFindings.length && !args.events) {
93
98
  context.writeStderr(`Strict exit: ${strictFindings.length} requested source state${strictFindings.length === 1 ? '' : 's'} were incomplete.\n`);
94
99
  }
100
+ if (policyFindings.length && !args.events)
101
+ context.writeStderr(formatFailPolicyNotice(policyFindings));
95
102
  eventProgress.emit({ event: 'completed', exitCode });
96
103
  return exitCode;
97
104
  }
@@ -1,13 +1,11 @@
1
1
  import { buildBulkComparisonEvidence } from "../lib/bulk-comparison-evidence.mjs";
2
+ import { recordOrEmpty } from "../lib/bounded-contract-normalizers.mjs";
2
3
  import { buildBulkDomainComparison, } from "../frontend/src/lib/analysis/bulk-domain-comparison.js";
3
4
  import { relationshipObservation } from "../frontend/src/lib/analysis/relationship-evidence.js";
4
5
  import { CliUsageError } from "./errors.mjs";
5
6
  import { parseSavedLookupDocument } from "./saved-lookup.mjs";
6
7
  export const CLI_LOOKUP_DIFF_SCHEMA = 'whoisleuth.cli.lookup-diff';
7
8
  export const CLI_LOOKUP_DIFF_VERSION = 1;
8
- function record(value) {
9
- return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
10
- }
11
9
  function text(value, maximum = 300) {
12
10
  if (typeof value !== 'string')
13
11
  return null;
@@ -23,14 +21,14 @@ function stringList(value, maximum = 100) {
23
21
  }))].slice(0, maximum);
24
22
  }
25
23
  function lifecycleValue(parsed, field) {
26
- const lifecycle = record(parsed.lifecycle);
24
+ const lifecycle = recordOrEmpty(parsed.lifecycle);
27
25
  return text(lifecycle[`${field}Iso`] ?? lifecycle[field] ?? parsed[`${field}Iso`] ?? parsed[field], 64);
28
26
  }
29
27
  function registrarLabel(parsed) {
30
28
  const registrar = parsed.registrar;
31
29
  if (typeof registrar === 'string')
32
30
  return text(registrar);
33
- const source = record(registrar);
31
+ const source = recordOrEmpty(registrar);
34
32
  return text(source.name ?? source.org ?? source.handle);
35
33
  }
36
34
  function sourceState(value) {
@@ -50,25 +48,25 @@ function sourceState(value) {
50
48
  return 'unavailable';
51
49
  }
52
50
  function sourceCoverage(document, availability) {
53
- const diagnostics = record(document.diagnostics);
51
+ const diagnostics = recordOrEmpty(document.diagnostics);
54
52
  const entries = [
55
- ['rdap', record(diagnostics.rdap).status],
56
- ['whois', record(diagnostics.whois).status],
57
- ['availability', record(diagnostics.availability).status],
58
- ['dns', record(availability.dns).status],
59
- ['http', record(availability.http).status],
60
- ['tls', record(availability.tls).status],
53
+ ['rdap', recordOrEmpty(diagnostics.rdap).status],
54
+ ['whois', recordOrEmpty(diagnostics.whois).status],
55
+ ['availability', recordOrEmpty(diagnostics.availability).status],
56
+ ['dns', recordOrEmpty(availability.dns).status],
57
+ ['http', recordOrEmpty(availability.http).status],
58
+ ['tls', recordOrEmpty(availability.tls).status],
61
59
  ];
62
60
  return entries.map(([source, status]) => ({ source, state: sourceState(status) }));
63
61
  }
64
62
  function lookupComparisonInput(document) {
65
- const availability = record(document.availability);
66
- const rdapParsed = record(record(document.rdap).parsed);
67
- const whoisParsed = record(record(document.whois).parsed);
63
+ const availability = recordOrEmpty(document.availability);
64
+ const rdapParsed = recordOrEmpty(recordOrEmpty(document.rdap).parsed);
65
+ const whoisParsed = recordOrEmpty(recordOrEmpty(document.whois).parsed);
68
66
  const preferredRegistry = Object.keys(rdapParsed).length ? rdapParsed : whoisParsed;
69
67
  const relationship = relationshipObservation(availability);
70
- const dns = record(availability.dns);
71
- const dnsRecords = record(dns.records);
68
+ const dns = recordOrEmpty(availability.dns);
69
+ const dnsRecords = recordOrEmpty(dns.records);
72
70
  const comparisonEvidence = buildBulkComparisonEvidence(availability);
73
71
  return {
74
72
  domain: document.registrableDomain,
@@ -97,8 +95,8 @@ function lookupComparisonInput(document) {
97
95
  faviconMatch: false,
98
96
  faviconNearMatch: false,
99
97
  reusesOfficialAssets: false,
100
- hasPasswordField: record(record(availability.credentialSurfaceProfile).inputs).categories
101
- ? Number(record(record(record(availability.credentialSurfaceProfile).inputs).categories).password) > 0
98
+ hasPasswordField: recordOrEmpty(recordOrEmpty(availability.credentialSurfaceProfile).inputs).categories
99
+ ? Number(recordOrEmpty(recordOrEmpty(recordOrEmpty(availability.credentialSurfaceProfile).inputs).categories).password) > 0
102
100
  : false,
103
101
  hasExternalFormAction: null,
104
102
  phishingLanguageMatch: null,
@@ -8,6 +8,7 @@ import { formatTerminalCtSearch, formatTerminalHttp, formatTerminalPosture, form
8
8
  import { buildHttpProbeResult } from "./http.mjs";
9
9
  import { normalizePostureSelectors } from "./posture.mjs";
10
10
  import EXIT_CODES from "./exit-codes.mjs";
11
+ import { buildPostureSarif } from "./ci-report.mjs";
11
12
  async function runNetworkCommand(args, dependencies, context) {
12
13
  if (args.action === 'ct-search') {
13
14
  const keyword = args.keyword || await context.readSingleInput();
@@ -46,7 +47,9 @@ async function runNetworkCommand(args, dependencies, context) {
46
47
  if (!args.quiet) {
47
48
  context.writeStdout(args.output === 'json'
48
49
  ? formatJsonDocument(document)
49
- : context.terminal(formatTerminalPosture(document), args.color));
50
+ : args.output === 'sarif'
51
+ ? formatJsonDocument(buildPostureSarif(document))
52
+ : context.terminal(formatTerminalPosture(document), args.color));
50
53
  }
51
54
  return EXIT_CODES.SUCCESS;
52
55
  }
@@ -1,10 +1,16 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { normalizeEncryptedDnsAdapter, planEncryptedDnsQuery, } from "../lib/encrypted-dns-contract.mjs";
3
3
  import { validateDnssecEvidence } from "../lib/dnssec-evidence-validation.mjs";
4
+ import { reviewDomainChange } from "../lib/domain-change-review.mjs";
5
+ import { reviewDomainPortfolio } from "../lib/domain-portfolio-review.mjs";
6
+ import { reviewNameserverPreflight } from "../lib/nameserver-preflight-review.mjs";
4
7
  import { buildLocalGeoIpDatabase, lookupLocalGeoIp, } from "../lib/local-geoip-evidence.mjs";
5
- import { normalizeRdapSearchHelp, planRdapReverseSearch, } from "../lib/rdap-search-workbench.mjs";
8
+ import { inspectRdapReverseSearchResponse, normalizeRdapSearchHelp, planRdapReverseSearch, } from "../lib/rdap-search-workbench.mjs";
6
9
  import { reviewRpkiRoute } from "../lib/rpki-evidence.mjs";
7
10
  import { analyzeTlsaEvidence } from "../lib/tlsa-evidence.mjs";
11
+ import { reviewZoneIntent } from "../lib/zone-intent-review.mjs";
12
+ import { reviewDnsConvergence } from "../lib/dns-convergence-review.mjs";
13
+ import { compareTrustStoreEvidence } from "../lib/trust-store-comparison.mjs";
8
14
  import { CliUsageError } from "./errors.mjs";
9
15
  import { LOCAL_MMDB_QUERY_SCHEMA, reviewLocalMmdb } from "./local-mmdb-review.mjs";
10
16
  const OFFLINE_EVIDENCE_REVIEW_SCHEMA = 'whoisleuth.cli.offline-evidence-review';
@@ -38,14 +44,18 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
38
44
  kind = 'rdap_search';
39
45
  const request = record(input.request);
40
46
  const help = normalizeRdapSearchHelp(input.help);
47
+ const plan = planRdapReverseSearch(help, {
48
+ searchableResourceType: String(request.searchableResourceType ?? ''),
49
+ relatedResourceType: String(request.relatedResourceType ?? ''),
50
+ property: String(request.property ?? ''),
51
+ value: request.value,
52
+ });
41
53
  result = Object.freeze({
42
54
  help,
43
- plan: planRdapReverseSearch(help, {
44
- searchableResourceType: String(request.searchableResourceType ?? ''),
45
- relatedResourceType: String(request.relatedResourceType ?? ''),
46
- property: String(request.property ?? ''),
47
- value: request.value,
48
- }),
55
+ plan,
56
+ responseInspection: input.response === undefined
57
+ ? null
58
+ : inspectRdapReverseSearchResponse(input.response, [request.property]),
49
59
  });
50
60
  }
51
61
  else if (input.schema === 'whoisleuth.dnssec-evidence-input') {
@@ -88,6 +98,30 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
88
98
  const query = record(input.query);
89
99
  result = planEncryptedDnsQuery(normalizeEncryptedDnsAdapter(input.adapter), { name: query.name, type: query.type });
90
100
  }
101
+ else if (input.schema === 'whoisleuth.zone-intent.input') {
102
+ kind = 'zone_intent';
103
+ result = reviewZoneIntent(input, generatedAt);
104
+ }
105
+ else if (input.schema === 'whoisleuth.domain-portfolio.input') {
106
+ kind = 'domain_portfolio';
107
+ result = reviewDomainPortfolio(input, generatedAt);
108
+ }
109
+ else if (input.schema === 'whoisleuth.domain-change.input') {
110
+ kind = 'domain_change';
111
+ result = reviewDomainChange(input, generatedAt);
112
+ }
113
+ else if (input.schema === 'whoisleuth.dns-convergence.input') {
114
+ kind = 'dns_convergence';
115
+ result = reviewDnsConvergence(input, generatedAt);
116
+ }
117
+ else if (input.schema === 'whoisleuth.nameserver-preflight.input') {
118
+ kind = 'nameserver_preflight';
119
+ result = reviewNameserverPreflight(input, generatedAt);
120
+ }
121
+ else if (input.schema === 'whoisleuth.trust-store-comparison.input') {
122
+ kind = 'trust_store';
123
+ result = compareTrustStoreEvidence(input, generatedAt);
124
+ }
91
125
  else {
92
126
  throw new CliUsageError('Offline evidence review does not recognise this input schema.');
93
127
  }
@@ -121,7 +155,20 @@ async function buildOfflineEvidenceReviewWithLocalResources(value, generatedAt =
121
155
  }
122
156
  function formatOfflineEvidenceReview(document) {
123
157
  const result = record(document.result);
124
- const state = typeof result.state === 'string' ? result.state : 'reviewed';
158
+ const gate = record(result.gate);
159
+ const counts = record(result.counts);
160
+ const count = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0;
161
+ const listLength = (value) => Array.isArray(value) ? value.length : 0;
162
+ const state = typeof result.state === 'string'
163
+ ? result.state
164
+ : document.kind === 'zone_intent'
165
+ ? result.complete === true
166
+ && ['different', 'missing', 'unexpected', 'incomplete'].every((key) => count(counts[key]) === 0)
167
+ ? 'aligned'
168
+ : 'review'
169
+ : typeof gate.pass === 'boolean'
170
+ ? gate.pass ? 'ready' : 'review'
171
+ : document.kind === 'domain_portfolio' ? 'inventory' : 'reviewed';
125
172
  const lines = [
126
173
  'Offline evidence review',
127
174
  `Kind ${document.kind.replaceAll('_', ' ')}`,
@@ -130,8 +177,48 @@ function formatOfflineEvidenceReview(document) {
130
177
  if (document.kind === 'rdap_search') {
131
178
  const help = record(result.help);
132
179
  const plan = record(result.plan);
180
+ const responseInspection = record(result.responseInspection);
133
181
  lines.push(`Help ${String(help.state ?? 'unavailable').replaceAll('_', ' ')}`);
134
182
  lines.push(`Plan ${String(plan.state ?? 'unavailable').replaceAll('_', ' ')}`);
183
+ if (result.responseInspection !== null)
184
+ lines.push(`Result ${String(responseInspection.state ?? 'invalid').replaceAll('_', ' ')}`);
185
+ }
186
+ else if (document.kind === 'zone_intent') {
187
+ const desired = record(result.desired);
188
+ lines.push(`Desired ${listLength(desired.records)}`);
189
+ lines.push(`Compared ${listLength(result.comparisons)}`);
190
+ lines.push(`Aligned ${count(counts.aligned)}`);
191
+ lines.push(`Changed ${count(counts.different) + count(counts.missing) + count(counts.unexpected)}`);
192
+ lines.push(`Partial ${count(counts.incomplete)}`);
193
+ lines.push(`Rejected ${listLength(desired.rejected)}`);
194
+ }
195
+ else if (document.kind === 'domain_change') {
196
+ lines.push(`Authority ${listLength(result.authoritativeRecordMatrix)} rows`);
197
+ lines.push(`Resolvers ${listLength(result.resolverDivergenceMatrix)} rows`);
198
+ }
199
+ else if (document.kind === 'dns_convergence') {
200
+ lines.push(`Observers ${listLength(result.observers)}`);
201
+ lines.push(`Snapshots ${listLength(result.snapshots)}`);
202
+ lines.push(`Record sets ${listLength(result.rows)}`);
203
+ }
204
+ else if (document.kind === 'nameserver_preflight') {
205
+ const rows = Array.isArray(result.rows) ? result.rows : [];
206
+ lines.push(`Servers ${rows.length}`);
207
+ lines.push(`Ready ${rows.filter((item) => record(item).ready === true).length}`);
208
+ }
209
+ else if (document.kind === 'trust_store') {
210
+ lines.push(`Stores ${count(counts.stores)}`);
211
+ lines.push(`Matched ${count(counts.anchorObserved)}`);
212
+ lines.push(`Not observed ${count(counts.notObserved)}`);
213
+ lines.push(`Inconclusive ${count(counts.inconclusive)}`);
214
+ }
215
+ else if (document.kind === 'domain_portfolio') {
216
+ const unknownCounts = record(result.unknownCounts);
217
+ lines.push(`Assets ${listLength(result.assets)}`);
218
+ lines.push(`Dependencies ${listLength(result.simulations)}`);
219
+ lines.push(`Renewals ${listLength(result.renewalQueue)}`);
220
+ lines.push(`Recovery ${listLength(result.recoveryCycles)}`);
221
+ lines.push(`Unknown ${Object.values(unknownCounts).reduce((total, value) => total + count(value), 0)}`);
135
222
  }
136
223
  else {
137
224
  for (const [label, key] of [
@@ -146,6 +233,16 @@ function formatOfflineEvidenceReview(document) {
146
233
  lines.push(`${label.padEnd(7)}${candidate}`);
147
234
  }
148
235
  }
236
+ const reasons = Array.isArray(gate.reasons) ? gate.reasons : [];
237
+ if (reasons.length) {
238
+ lines.push('', 'Review reasons:');
239
+ for (const reason of reasons.slice(0, 8)) {
240
+ if (typeof reason === 'string')
241
+ lines.push(` - ${reason.replace(/[\u0000-\u001f\u007f]+/gu, ' ').trim().slice(0, 240)}`);
242
+ }
243
+ if (reasons.length > 8)
244
+ lines.push(` - ${reasons.length - 8} more reason(s) omitted.`);
245
+ }
149
246
  lines.push('', 'Limitations:');
150
247
  for (const limitation of document.limitations)
151
248
  lines.push(` - ${limitation}`);