@slicedearth/whoisleuth-cli 1.39.1 → 1.45.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/README.md +23 -4
- package/bin/whoisleuth.mjs +31 -9
- package/cli/archive-inspect.mjs +37 -2
- package/cli/arguments.mjs +361 -256
- package/cli/artifact-verify.mjs +35 -1
- package/cli/bulk-checkpoint.mjs +72 -39
- package/cli/bulk-command-runner.mjs +34 -4
- package/cli/bulk.mjs +18 -7
- package/cli/case-pack.mjs +136 -0
- package/cli/ci-report.mjs +73 -0
- package/cli/collection-preflight.mjs +59 -0
- package/cli/command-reference.mjs +395 -0
- package/cli/completion.mjs +67 -13
- package/cli/config-profile.mjs +183 -0
- package/cli/ct-event-intake.mjs +159 -0
- package/cli/discovery-observation-snapshot.mjs +8 -14
- package/cli/discovery-scan-command-runner.mjs +29 -2
- package/cli/discovery-snapshot.mjs +8 -15
- package/cli/domain-control-monitor.mjs +104 -0
- package/cli/domain-control-observations.mjs +235 -0
- package/cli/errors.mjs +7 -1
- package/cli/evidence-command-arguments.mjs +11 -0
- package/cli/evidence-command-runner.mjs +1 -0
- package/cli/external-observation-mapping.mjs +162 -0
- package/cli/fail-policy.mjs +64 -0
- package/cli/formatters/html.mjs +1 -1
- package/cli/formatters/json.mjs +12 -4
- package/cli/formatters/terminal.mjs +1 -1
- package/cli/http.mjs +2 -1
- package/cli/investigation-manifest.mjs +113 -0
- package/cli/investigation-run.mjs +112 -0
- package/cli/lookup-brief.mjs +116 -0
- package/cli/lookup-command-runner.mjs +8 -1
- package/cli/lookup-diff.mjs +17 -19
- package/cli/lookup-reconcile.mjs +1 -1
- package/cli/mail-review.mjs +5 -5
- package/cli/manual.mjs +1 -1
- package/cli/network-command-runner.mjs +4 -1
- package/cli/offline-evidence-review.mjs +105 -8
- package/cli/open-asset-model-bridge.mjs +169 -0
- package/cli/registry-cohort.mjs +121 -0
- package/cli/registry-doctor.mjs +106 -11
- package/cli/registry-fixture-scaffold.mjs +74 -0
- package/cli/runner.mjs +388 -315
- package/docs/cli-reference.md +266 -21
- package/docs/cli.md +43 -7
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +2 -2
- package/frontend/src/lib/analysis/brand-profile-model.js +50 -6
- package/frontend/src/lib/analysis/case-evidence-model.js +1 -1
- package/frontend/src/lib/analysis/case-record-core.js +1 -1
- package/frontend/src/lib/analysis/case-record-operations.js +1 -1
- package/frontend/src/lib/analysis/case-report.js +573 -0
- package/frontend/src/lib/analysis/case-response-model.js +30 -1
- package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
- package/frontend/src/lib/analysis/external-findings-import.js +320 -0
- package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
- package/frontend/src/lib/analysis/lookup-decision-support.js +9 -2
- package/frontend/src/lib/analysis/lookup-presentation.js +14 -30
- package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
- package/frontend/src/lib/analysis/scoring.js +1 -1
- package/frontend/src/lib/analysis/utils.js +1 -1
- package/frontend/src/lib/analysis/visualization-models.js +2 -3
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
- package/frontend/src/lib/analysis/workspace-archive.js +5 -7
- package/lib/abusech-auth.mjs +1 -1
- package/lib/availability.mjs +1 -1
- package/lib/bounded-contract-normalizers.mjs +33 -0
- package/lib/bounded-file.mjs +69 -0
- package/lib/bounded-json-response.mjs +157 -0
- package/lib/browser-library-profile.mjs +167 -38
- package/lib/dns-convergence-review.mjs +183 -0
- package/lib/dns-delegation-health.mjs +196 -3
- package/lib/domain-change-packet.mjs +133 -0
- package/lib/domain-change-review.mjs +307 -0
- package/lib/domain-control-flight-recorder.mjs +268 -0
- package/lib/domain-portfolio-review.mjs +151 -0
- package/lib/evidence-export.mjs +1 -1
- package/lib/html-signals.mjs +5 -11
- package/lib/lookup-cache.mjs +5 -1
- package/lib/nameserver-preflight-review.mjs +159 -0
- package/lib/page-language-signals.mjs +124 -0
- package/lib/rdap-transport.mjs +6 -6
- package/lib/risk-scoring.mjs +42 -1
- package/lib/safe-fetch.mjs +37 -32
- package/lib/static-html-analysis.mjs +29 -1
- package/lib/threat-intelligence-runtime.mjs +1 -2
- package/lib/trust-store-comparison.mjs +146 -0
- package/lib/website-technology.mjs +22 -12
- package/lib/zone-intent-review.mjs +534 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
}
|
package/cli/lookup-diff.mjs
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
51
|
+
const diagnostics = recordOrEmpty(document.diagnostics);
|
|
54
52
|
const entries = [
|
|
55
|
-
['rdap',
|
|
56
|
-
['whois',
|
|
57
|
-
['availability',
|
|
58
|
-
['dns',
|
|
59
|
-
['http',
|
|
60
|
-
['tls',
|
|
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 =
|
|
66
|
-
const rdapParsed =
|
|
67
|
-
const whoisParsed =
|
|
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 =
|
|
71
|
-
const dnsRecords =
|
|
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:
|
|
101
|
-
? Number(
|
|
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,
|
package/cli/lookup-reconcile.mjs
CHANGED
|
@@ -37,7 +37,7 @@ function buildCliLookupReconciliation(inputs, generatedAt = new Date().toISOStri
|
|
|
37
37
|
label: `Lookup reconciliation observation ${index + 1}`,
|
|
38
38
|
}));
|
|
39
39
|
const domain = documents[0]?.registrableDomain;
|
|
40
|
-
if (!domain || documents.some((
|
|
40
|
+
if (!domain || documents.some((candidate) => candidate.registrableDomain !== domain)) {
|
|
41
41
|
throw new CliUsageError('Lookup reconciliation requires observations for one domain.');
|
|
42
42
|
}
|
|
43
43
|
const comparisons = documents.slice(1).map((document, index) => buildCliLookupDiff(inputs[0], inputs[index + 1], generatedAt, { domainMode: 'same' }));
|
package/cli/mail-review.mjs
CHANGED
|
@@ -128,14 +128,14 @@ function parseInput(textValue) {
|
|
|
128
128
|
try {
|
|
129
129
|
const parsed = JSON.parse(textInput);
|
|
130
130
|
const document = record(parsed);
|
|
131
|
-
if (document.schema === 'whoisleuth.cli.bulk' && document.version
|
|
131
|
+
if (document.schema === 'whoisleuth.cli.bulk' && [2, 3].includes(Number(document.version)) && Array.isArray(document.results)) {
|
|
132
132
|
values = document.results;
|
|
133
133
|
}
|
|
134
|
-
else if (document.schema === 'whoisleuth.cli.bulk.item' && document.version
|
|
134
|
+
else if (document.schema === 'whoisleuth.cli.bulk.item' && [2, 3].includes(Number(document.version))) {
|
|
135
135
|
values = [document];
|
|
136
136
|
}
|
|
137
137
|
else {
|
|
138
|
-
throw new CliUsageError('Mail review requires WHOISleuth Bulk JSON schema version 2.');
|
|
138
|
+
throw new CliUsageError('Mail review requires WHOISleuth Bulk JSON schema version 2 or 3.');
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
catch (error) {
|
|
@@ -152,8 +152,8 @@ function parseInput(textValue) {
|
|
|
152
152
|
throw new CliUsageError(`Mail review supports between 1 and ${MAX_MAIL_REVIEW_ROWS} Bulk rows.`);
|
|
153
153
|
}
|
|
154
154
|
const rows = values.map((value) => record(value));
|
|
155
|
-
if (rows.some((value) => value.schema !== 'whoisleuth.cli.bulk.item' || value.version
|
|
156
|
-
throw new CliUsageError('Every Mail review row must use WHOISleuth Bulk item schema version 2.');
|
|
155
|
+
if (rows.some((value) => value.schema !== 'whoisleuth.cli.bulk.item' || ![2, 3].includes(Number(value.version)))) {
|
|
156
|
+
throw new CliUsageError('Every Mail review row must use WHOISleuth Bulk item schema version 2 or 3.');
|
|
157
157
|
}
|
|
158
158
|
return rows;
|
|
159
159
|
}
|
package/cli/manual.mjs
CHANGED
|
@@ -23,7 +23,7 @@ ${commands}
|
|
|
23
23
|
.SH OUTPUT
|
|
24
24
|
Human-readable output is the default. Versioned JSON and JSONL are available where documented. Diagnostics and optional progress events are written to standard error. Use --output with an optional --force flag for atomic private file output.
|
|
25
25
|
.SH EXIT STATUS
|
|
26
|
-
0 indicates command completion, 2 invalid usage, 3 a collection or comparison failure, 4 an explicitly detected partial result, 70 an internal bootstrap failure,
|
|
26
|
+
0 indicates command completion, 2 invalid usage, 3 a collection or comparison failure, 4 an explicitly detected partial result, 70 an internal bootstrap failure, 130 analyst cancellation, and 143 service termination.
|
|
27
27
|
.SH PRIVACY
|
|
28
28
|
Network commands disclose the target to the directly queried upstream services. Offline commands do not make network requests. Output files are created with private permissions and are never uploaded by the CLI.
|
|
29
29
|
.SH LICENSE
|
|
@@ -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
|
-
:
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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}`);
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
|
+
import { parseExternalFindingsDocument, } from "../frontend/src/lib/analysis/external-findings-import.js";
|
|
3
|
+
import { requireIsoTimestamp } from "../lib/bounded-contract-normalizers.mjs";
|
|
4
|
+
export const OPEN_ASSET_MODEL_BRIDGE_SCHEMA = 'whoisleuth.open-asset-model-bridge';
|
|
5
|
+
export const OPEN_ASSET_MODEL_BRIDGE_VERSION = 1;
|
|
6
|
+
const SHA256_RE = /^[a-f0-9]{64}$/u;
|
|
7
|
+
function fqdnId(domain) {
|
|
8
|
+
return `FQDN/${domain}`;
|
|
9
|
+
}
|
|
10
|
+
function certificateId(digest) {
|
|
11
|
+
return `TLSCertificate/sha256:${digest}`;
|
|
12
|
+
}
|
|
13
|
+
function ipId(address) {
|
|
14
|
+
return `IPAddress/${address}`;
|
|
15
|
+
}
|
|
16
|
+
function earlier(left, right) {
|
|
17
|
+
return left.localeCompare(right) <= 0 ? left : right;
|
|
18
|
+
}
|
|
19
|
+
function later(left, right) {
|
|
20
|
+
return left.localeCompare(right) >= 0 ? left : right;
|
|
21
|
+
}
|
|
22
|
+
function combinedCompleteness(left, right) {
|
|
23
|
+
return left === right ? right : 'partial';
|
|
24
|
+
}
|
|
25
|
+
function setObservedAsset(assets, key, value, observedAt, completeness) {
|
|
26
|
+
const existing = assets.get(key);
|
|
27
|
+
const existingDiscovery = existing?.discovery && typeof existing.discovery === 'object'
|
|
28
|
+
? existing.discovery
|
|
29
|
+
: null;
|
|
30
|
+
if (!existing || !existingDiscovery || typeof existingDiscovery.observed_at !== 'string') {
|
|
31
|
+
assets.set(key, value);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
assets.set(key, {
|
|
35
|
+
...existing,
|
|
36
|
+
discovery: {
|
|
37
|
+
...existingDiscovery,
|
|
38
|
+
observed_at: later(existingDiscovery.observed_at, observedAt),
|
|
39
|
+
completeness: combinedCompleteness(existingDiscovery.completeness, completeness),
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
function setObservedRelation(relations, key, value, observedAt, completeness) {
|
|
44
|
+
const existing = relations.get(key);
|
|
45
|
+
const existingDiscovery = existing?.discovery && typeof existing.discovery === 'object'
|
|
46
|
+
? existing.discovery
|
|
47
|
+
: null;
|
|
48
|
+
if (!existing
|
|
49
|
+
|| typeof existing.created_at !== 'string'
|
|
50
|
+
|| typeof existing.last_seen !== 'string'
|
|
51
|
+
|| !existingDiscovery) {
|
|
52
|
+
relations.set(key, value);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
relations.set(key, {
|
|
56
|
+
...existing,
|
|
57
|
+
created_at: earlier(existing.created_at, observedAt),
|
|
58
|
+
last_seen: later(existing.last_seen, observedAt),
|
|
59
|
+
discovery: {
|
|
60
|
+
...existingDiscovery,
|
|
61
|
+
completeness: combinedCompleteness(existingDiscovery.completeness, completeness),
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
export function buildOpenAssetModelBridge(input, generatedAtValue = new Date().toISOString()) {
|
|
66
|
+
const document = parseExternalFindingsDocument(input);
|
|
67
|
+
const generatedAt = requireIsoTimestamp(generatedAtValue, 'generatedAt');
|
|
68
|
+
const assets = new Map();
|
|
69
|
+
const relations = new Map();
|
|
70
|
+
for (const finding of document.findings) {
|
|
71
|
+
const domainAssetId = fqdnId(finding.domain);
|
|
72
|
+
setObservedAsset(assets, domainAssetId, {
|
|
73
|
+
key: domainAssetId,
|
|
74
|
+
type: 'FQDN',
|
|
75
|
+
attributes: { name: finding.domain },
|
|
76
|
+
discovery: { source: document.source.name, observed_at: finding.observedAt, completeness: finding.completeness },
|
|
77
|
+
}, finding.observedAt, finding.completeness);
|
|
78
|
+
const observation = finding.structuredObservation;
|
|
79
|
+
if (!observation)
|
|
80
|
+
continue;
|
|
81
|
+
if (observation.sourceSchema === 'whoisleuth.certificate-observation-rows'
|
|
82
|
+
&& (observation.field === 'certificateSha256' || observation.field === 'fingerprintSha256')) {
|
|
83
|
+
const digest = observation.value.toLowerCase();
|
|
84
|
+
if (!SHA256_RE.test(digest))
|
|
85
|
+
throw new TypeError('Certificate bridge observations require a SHA-256 hexadecimal digest.');
|
|
86
|
+
const certificateAssetId = certificateId(digest);
|
|
87
|
+
setObservedAsset(assets, certificateAssetId, {
|
|
88
|
+
key: certificateAssetId,
|
|
89
|
+
type: 'TLSCertificate',
|
|
90
|
+
attributes: {
|
|
91
|
+
sha256: digest,
|
|
92
|
+
issuer_common_name: observation.issuer,
|
|
93
|
+
not_after: observation.notAfter,
|
|
94
|
+
},
|
|
95
|
+
discovery: { source: document.source.name, observed_at: finding.observedAt, completeness: finding.completeness },
|
|
96
|
+
}, finding.observedAt, finding.completeness);
|
|
97
|
+
const relationKey = `${certificateAssetId}\u0000san_dns_name\u0000${domainAssetId}`;
|
|
98
|
+
setObservedRelation(relations, relationKey, {
|
|
99
|
+
type: 'SimpleRelation',
|
|
100
|
+
label: 'san_dns_name',
|
|
101
|
+
source: certificateAssetId,
|
|
102
|
+
target: domainAssetId,
|
|
103
|
+
created_at: finding.observedAt,
|
|
104
|
+
last_seen: finding.observedAt,
|
|
105
|
+
discovery: { source: document.source.name, completeness: finding.completeness },
|
|
106
|
+
}, finding.observedAt, finding.completeness);
|
|
107
|
+
}
|
|
108
|
+
if (observation.sourceSchema === 'whoisleuth.dns-observation-rows'
|
|
109
|
+
&& (observation.field === 'A' || observation.field === 'AAAA')) {
|
|
110
|
+
const ipVersion = isIP(observation.value);
|
|
111
|
+
const targetId = ipVersion ? ipId(observation.value) : null;
|
|
112
|
+
if (!targetId)
|
|
113
|
+
continue;
|
|
114
|
+
if ((observation.field === 'A' && ipVersion !== 4) || (observation.field === 'AAAA' && ipVersion !== 6))
|
|
115
|
+
continue;
|
|
116
|
+
setObservedAsset(assets, targetId, {
|
|
117
|
+
key: targetId,
|
|
118
|
+
type: 'IPAddress',
|
|
119
|
+
attributes: { address: observation.value, type: ipVersion === 4 ? 'IPv4' : 'IPv6' },
|
|
120
|
+
discovery: { source: document.source.name, observed_at: finding.observedAt, completeness: finding.completeness },
|
|
121
|
+
}, finding.observedAt, finding.completeness);
|
|
122
|
+
const relationKey = `${domainAssetId}\u0000dns_record\u0000${targetId}`;
|
|
123
|
+
setObservedRelation(relations, relationKey, {
|
|
124
|
+
type: 'BasicDNSRelation',
|
|
125
|
+
label: 'dns_record',
|
|
126
|
+
source: domainAssetId,
|
|
127
|
+
target: targetId,
|
|
128
|
+
created_at: finding.observedAt,
|
|
129
|
+
last_seen: finding.observedAt,
|
|
130
|
+
discovery: { source: document.source.name, completeness: finding.completeness },
|
|
131
|
+
}, finding.observedAt, finding.completeness);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return Object.freeze({
|
|
135
|
+
schema: OPEN_ASSET_MODEL_BRIDGE_SCHEMA,
|
|
136
|
+
version: OPEN_ASSET_MODEL_BRIDGE_VERSION,
|
|
137
|
+
generatedAt,
|
|
138
|
+
model: Object.freeze({
|
|
139
|
+
name: 'OWASP Open Asset Model',
|
|
140
|
+
profile: 'WHOISleuth bounded external-findings projection',
|
|
141
|
+
reference: 'https://github.com/owasp-amass/open-asset-model',
|
|
142
|
+
}),
|
|
143
|
+
assets: Object.freeze([...assets.values()].sort((left, right) => String(left.key).localeCompare(String(right.key)))),
|
|
144
|
+
relations: Object.freeze([...relations.values()].sort((left, right) => String(left.source).localeCompare(String(right.source)) || String(left.target).localeCompare(String(right.target)))),
|
|
145
|
+
provenance: Object.freeze({
|
|
146
|
+
source: document.source,
|
|
147
|
+
findingsReviewed: document.findings.length,
|
|
148
|
+
evidenceClasses: Object.freeze([...new Set(document.findings.map((finding) => finding.evidenceClass))].sort()),
|
|
149
|
+
}),
|
|
150
|
+
limitations: Object.freeze([
|
|
151
|
+
'This is a versioned WHOISleuth bridge projection using documented Open Asset Model asset and relation vocabulary; receiving tools must still validate it against their supported model version.',
|
|
152
|
+
'Only bounded FQDN, IPAddress, TLSCertificate, dns_record, and san_dns_name projections are emitted. Unsupported finding categories remain represented only by their FQDN asset.',
|
|
153
|
+
'Source completeness is preserved as discovery metadata and is not converted into an Open Asset Model confidence score.',
|
|
154
|
+
'Relations are observations and investigation pivots, not proof of ownership, control, coordination, intent, safety, or maliciousness.',
|
|
155
|
+
]),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
export function formatOpenAssetModelBridge(document) {
|
|
159
|
+
return [
|
|
160
|
+
'Open Asset Model bridge',
|
|
161
|
+
`Assets ${document.assets.length}`,
|
|
162
|
+
`Relations ${document.relations.length}`,
|
|
163
|
+
`Findings ${document.provenance.findingsReviewed}`,
|
|
164
|
+
`Source ${document.provenance.source.name}`,
|
|
165
|
+
'',
|
|
166
|
+
'Output is a bounded bridge projection; validate it against the receiving implementation before import.',
|
|
167
|
+
'',
|
|
168
|
+
].join('\n');
|
|
169
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { CliUsageError } from "./errors.mjs";
|
|
3
|
+
import { buildRegistryDoctorReport } from "./registry-doctor.mjs";
|
|
4
|
+
export const REGISTRY_COHORT_SCHEMA = 'whoisleuth.cli.registry-cohort';
|
|
5
|
+
export const REGISTRY_COHORT_VERSION = 1;
|
|
6
|
+
export const MAX_REGISTRY_COHORT_INPUT_BYTES = 16 * 1024 * 1024;
|
|
7
|
+
export const MAX_REGISTRY_COHORT_SAMPLES = 500;
|
|
8
|
+
export const MIN_REGISTRY_COHORT_SAMPLE = 5;
|
|
9
|
+
function record(value) {
|
|
10
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
11
|
+
}
|
|
12
|
+
function documents(text) {
|
|
13
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_REGISTRY_COHORT_INPUT_BYTES) {
|
|
14
|
+
throw new CliUsageError(`Registry cohort input is limited to ${MAX_REGISTRY_COHORT_INPUT_BYTES} bytes.`);
|
|
15
|
+
}
|
|
16
|
+
const trimmed = text.replace(/^\uFEFF/u, '').trim();
|
|
17
|
+
if (!trimmed)
|
|
18
|
+
throw new CliUsageError('Registry cohort input is empty.');
|
|
19
|
+
let values;
|
|
20
|
+
if (trimmed.startsWith('[')) {
|
|
21
|
+
let parsed;
|
|
22
|
+
try {
|
|
23
|
+
parsed = JSON.parse(trimmed);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new CliUsageError('Registry cohort input must be valid JSON or JSONL.');
|
|
27
|
+
}
|
|
28
|
+
if (!Array.isArray(parsed))
|
|
29
|
+
throw new CliUsageError('Registry cohort JSON input must be an array.');
|
|
30
|
+
values = parsed;
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
const lines = trimmed.split(/\r?\n/u).filter((line) => line.trim());
|
|
34
|
+
values = lines.map((line, index) => {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(line);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new CliUsageError(`Registry cohort JSONL line ${index + 1} is invalid.`);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (!values.length || values.length > MAX_REGISTRY_COHORT_SAMPLES) {
|
|
44
|
+
throw new CliUsageError(`Registry cohort input must contain from 1 to ${MAX_REGISTRY_COHORT_SAMPLES} saved Lookups.`);
|
|
45
|
+
}
|
|
46
|
+
const output = values.map(record);
|
|
47
|
+
if (output.some((item) => !Object.keys(item).length))
|
|
48
|
+
throw new CliUsageError('Every registry cohort item must be one saved Lookup object.');
|
|
49
|
+
return output;
|
|
50
|
+
}
|
|
51
|
+
export function buildRegistryCohortReport(text, generatedAt = new Date().toISOString()) {
|
|
52
|
+
const reports = documents(text).map((item) => buildRegistryDoctorReport(JSON.stringify(item), generatedAt));
|
|
53
|
+
const groups = new Map();
|
|
54
|
+
for (const report of reports) {
|
|
55
|
+
const key = `${report.suffix}\u0000${report.profile.id}`;
|
|
56
|
+
const group = groups.get(key) ?? [];
|
|
57
|
+
group.push(report);
|
|
58
|
+
groups.set(key, group);
|
|
59
|
+
}
|
|
60
|
+
const cohorts = [...groups.values()].map((group) => {
|
|
61
|
+
const sample = group[0];
|
|
62
|
+
if (!sample)
|
|
63
|
+
throw new CliUsageError('Registry cohort grouping failed.');
|
|
64
|
+
const sampleCount = group.length;
|
|
65
|
+
const investigate = group.reduce((total, item) => total + item.summary.investigate, 0);
|
|
66
|
+
const publicationReviewItems = group.reduce((total, item) => total + item.publication.reviewItems, 0);
|
|
67
|
+
return Object.freeze({
|
|
68
|
+
suffix: sample.suffix,
|
|
69
|
+
profileId: sample.profile.id,
|
|
70
|
+
sampleCount,
|
|
71
|
+
state: sampleCount < MIN_REGISTRY_COHORT_SAMPLE
|
|
72
|
+
? 'insufficient_sample'
|
|
73
|
+
: investigate || publicationReviewItems
|
|
74
|
+
? 'review'
|
|
75
|
+
: 'consistent',
|
|
76
|
+
sourceAlignment: Object.freeze({
|
|
77
|
+
investigate,
|
|
78
|
+
expectedConstraints: group.reduce((total, item) => total + item.summary.expectedConstraints, 0),
|
|
79
|
+
observed: group.reduce((total, item) => total + item.summary.observed, 0),
|
|
80
|
+
}),
|
|
81
|
+
publication: Object.freeze({
|
|
82
|
+
reviewItems: publicationReviewItems,
|
|
83
|
+
objectIdentifiersObserved: group.filter((item) => item.publication.objectIdentifier.state === 'observed').length,
|
|
84
|
+
baseConformanceObserved: group.filter((item) => item.publication.baseConformance.state === 'observed').length,
|
|
85
|
+
selfLinksObserved: group.filter((item) => item.publication.selfLink.state === 'observed').length,
|
|
86
|
+
redactionMetadataObserved: group.filter((item) => item.publication.redactionMetadata.state === 'observed').length,
|
|
87
|
+
}),
|
|
88
|
+
});
|
|
89
|
+
}).sort((left, right) => left.suffix.localeCompare(right.suffix) || left.profileId.localeCompare(right.profileId));
|
|
90
|
+
return Object.freeze({
|
|
91
|
+
schema: REGISTRY_COHORT_SCHEMA,
|
|
92
|
+
version: REGISTRY_COHORT_VERSION,
|
|
93
|
+
generatedAt,
|
|
94
|
+
sampleCount: reports.length,
|
|
95
|
+
minimumCohortSample: MIN_REGISTRY_COHORT_SAMPLE,
|
|
96
|
+
cohorts: Object.freeze(cohorts),
|
|
97
|
+
limitations: Object.freeze([
|
|
98
|
+
'The report intentionally omits domains, queries and raw evidence. It groups saved observations only by suffix and capability profile.',
|
|
99
|
+
`Cohorts with fewer than ${MIN_REGISTRY_COHORT_SAMPLE} observations remain insufficient sample and must not drive catalogue changes.`,
|
|
100
|
+
'Repeated observations from one environment are not representative by themselves. Review fixture provenance and source health before changing a parser or access profile.',
|
|
101
|
+
]),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
export function formatRegistryCohortReport(report) {
|
|
105
|
+
const output = [
|
|
106
|
+
'Registry quality cohort',
|
|
107
|
+
`Saved observations ${report.sampleCount}`,
|
|
108
|
+
`Minimum sample ${report.minimumCohortSample}`,
|
|
109
|
+
'',
|
|
110
|
+
];
|
|
111
|
+
for (const cohort of report.cohorts) {
|
|
112
|
+
output.push(`.${cohort.suffix} · ${cohort.profileId} [${cohort.state.replaceAll('_', ' ')}]`);
|
|
113
|
+
output.push(` Samples: ${cohort.sampleCount}`);
|
|
114
|
+
output.push(` Source review items: ${cohort.sourceAlignment.investigate}`);
|
|
115
|
+
output.push(` Publication review items: ${cohort.publication.reviewItems}`);
|
|
116
|
+
}
|
|
117
|
+
output.push('', 'Limitations:');
|
|
118
|
+
for (const limitation of report.limitations)
|
|
119
|
+
output.push(` - ${limitation}`);
|
|
120
|
+
return `${output.join('\n')}\n`;
|
|
121
|
+
}
|