@slicedearth/whoisleuth-cli 1.45.0 → 1.47.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DISCLOSURE +7 -3
- package/NOTICE +5 -4
- package/README.md +59 -6
- package/bin/whoisleuth.mjs +5 -3
- package/cli/arguments.mjs +256 -9
- package/cli/artifact-structure.mjs +1833 -0
- package/cli/artifact-verify.mjs +242 -49
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +2 -3
- package/cli/bulk-command-runner.mjs +2 -5
- package/cli/case-pack.mjs +819 -34
- package/cli/command-reference.mjs +48 -24
- package/cli/completion.mjs +16 -8
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discovery-scan-command-runner.mjs +4 -5
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +21 -15
- package/cli/domain-control-observations.mjs +25 -2
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +73 -45
- package/cli/export-evidence.mjs +11 -4
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/terminal.mjs +3 -0
- package/cli/input.mjs +57 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-run.mjs +71 -23
- package/cli/local-mmdb-review.mjs +45 -14
- package/cli/lookup-command-runner.mjs +2 -2
- package/cli/lookup-diff.mjs +9 -0
- package/cli/network-command-runner.mjs +78 -0
- package/cli/offline-evidence-review.mjs +28 -8
- package/cli/page-compare.mjs +8 -5
- package/cli/registry-cohort.mjs +385 -61
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +376 -0
- package/cli/risk-calibration.mjs +7 -5
- package/cli/runner.mjs +147 -185
- package/cli/saved-lookup.mjs +26 -3
- package/cli/sharing-review.mjs +12 -6
- package/docs/cli-reference.md +444 -70
- package/docs/cli.md +24 -5
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
- package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +29 -5
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
- package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
- package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
- package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
- package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
- package/frontend/src/lib/analysis/bulk-sort.js +4 -0
- package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +8 -0
- package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
- package/frontend/src/lib/analysis/case-migration-model.js +83 -31
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-model.js +2 -0
- package/frontend/src/lib/analysis/case-record-operations.js +42 -12
- package/frontend/src/lib/analysis/case-report.js +39 -6
- package/frontend/src/lib/analysis/case-response-model.js +66 -7
- package/frontend/src/lib/analysis/case-response-packet.js +15 -5
- package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
- package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
- package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
- package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
- package/frontend/src/lib/analysis/domain-control-records.js +88 -0
- package/frontend/src/lib/analysis/external-findings-import.js +70 -6
- package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
- package/frontend/src/lib/analysis/investigation-guide.js +52 -10
- package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
- package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
- package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +61 -6
- package/frontend/src/lib/analysis/lookup-presentation.js +62 -0
- package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
- package/frontend/src/lib/analysis/utils.js +26 -3
- package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
- package/frontend/src/lib/analysis/visualization-models.js +63 -94
- package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
- package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
- package/frontend/src/lib/analysis/workspace-archive.js +104 -14
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +14 -12
- package/lib/bounded-file.mjs +13 -0
- package/lib/bounded-json-response.mjs +17 -4
- package/lib/bounded-json.mjs +249 -0
- package/lib/certificate-extension-profile.mjs +2 -1
- package/lib/cryptographic-assurance.mjs +155 -0
- package/lib/ct-search.mjs +87 -47
- package/lib/dns-intelligence.mjs +3 -2
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1053 -0
- package/lib/dnssec-evidence-validation.mjs +10 -3
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-manifest.mjs +17 -129
- package/lib/domain-posture.mjs +7 -4
- package/lib/evidence-export.mjs +751 -78
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/favicon.mjs +8 -5
- package/lib/http-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +2 -5
- package/lib/interchange-fidelity-registry.mjs +122 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup-response-contract.mjs +691 -3
- package/lib/lookup.mjs +2 -2
- package/lib/observation.mjs +7 -6
- package/lib/outbound-identity.mjs +2 -3
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +21 -0
- package/lib/provider-policy-admission.mjs +9 -4
- package/lib/rdap-normalization.mjs +18 -5
- package/lib/rdap-registrar.mjs +5 -1
- package/lib/registry-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/risk-calibration-summary.mjs +338 -0
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +59 -10
- package/lib/security-txt.mjs +26 -3
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +784 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- package/lib/threat-intelligence-runtime.mjs +3 -11
- package/lib/threat-intelligence-types.mjs +18 -1
- package/lib/tls-intelligence.mjs +11 -5
- package/lib/tlsa-evidence.mjs +37 -7
- package/package.json +1 -1
- package/third-party-notices.txt +95 -5817
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { canonicalArtifactJsonV2, SORTED_JSON_V2, sha256ArtifactDigestV2, } from "../frontend/src/lib/analysis/artifact-integrity.js";
|
|
4
|
+
import { requireBoundedString, requireIsoTimestamp, } from "../lib/bounded-contract-normalizers.mjs";
|
|
5
5
|
import { normalizeBoundedSemanticVersion } from "../lib/semantic-version.mjs";
|
|
6
|
+
import { parseBoundedJsonObject } from "./bounded-json.mjs";
|
|
6
7
|
export const INVESTIGATION_MANIFEST_SCHEMA = 'whoisleuth.investigation-manifest';
|
|
7
|
-
export const INVESTIGATION_MANIFEST_VERSION =
|
|
8
|
+
export const INVESTIGATION_MANIFEST_VERSION = 2;
|
|
8
9
|
export const MAX_INVESTIGATION_MANIFEST_ARTIFACTS = 16;
|
|
9
10
|
export const MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES = 15 * 1024 * 1024;
|
|
10
11
|
export const MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES = 32 * 1024 * 1024;
|
|
@@ -40,20 +41,17 @@ export async function buildInvestigationManifest(input, generatedAtValue, applic
|
|
|
40
41
|
if (byteLength < 1 || byteLength > MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES) {
|
|
41
42
|
throw new TypeError(`Artifact ${index + 1} must be between 1 byte and ${MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES} bytes.`);
|
|
42
43
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
parsed = JSON.parse(artifact.content);
|
|
44
|
+
if (totalBytes > MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES) {
|
|
45
|
+
throw new TypeError(`Manifest artefacts exceed the ${MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES}-byte combined limit.`);
|
|
46
46
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (!value)
|
|
52
|
-
throw new TypeError(`Artifact ${index + 1} must contain one JSON object.`);
|
|
47
|
+
const value = parseBoundedJsonObject(artifact.content, {
|
|
48
|
+
label: `Artifact ${index + 1}`,
|
|
49
|
+
maximumBytes: MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES,
|
|
50
|
+
});
|
|
53
51
|
const schema = value.schema === undefined
|
|
54
52
|
? null
|
|
55
53
|
: requireBoundedString(value.schema, `Artifact ${index + 1} schema`, 160);
|
|
56
|
-
const canonical =
|
|
54
|
+
const canonical = canonicalArtifactJsonV2(value);
|
|
57
55
|
const contentDigestSha256 = digest(artifact.content);
|
|
58
56
|
return Object.freeze({
|
|
59
57
|
sequence: index + 1,
|
|
@@ -65,9 +63,6 @@ export async function buildInvestigationManifest(input, generatedAtValue, applic
|
|
|
65
63
|
canonicalDigestSha256: digest(canonical),
|
|
66
64
|
});
|
|
67
65
|
});
|
|
68
|
-
if (totalBytes > MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES) {
|
|
69
|
-
throw new TypeError(`Manifest artefacts exceed the ${MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES}-byte combined limit.`);
|
|
70
|
-
}
|
|
71
66
|
const unsigned = Object.freeze({
|
|
72
67
|
schema: INVESTIGATION_MANIFEST_SCHEMA,
|
|
73
68
|
version: INVESTIGATION_MANIFEST_VERSION,
|
|
@@ -90,7 +85,11 @@ export async function buildInvestigationManifest(input, generatedAtValue, applic
|
|
|
90
85
|
});
|
|
91
86
|
return Object.freeze({
|
|
92
87
|
...unsigned,
|
|
93
|
-
integrity: Object.freeze({
|
|
88
|
+
integrity: Object.freeze({
|
|
89
|
+
algorithm: 'SHA-256',
|
|
90
|
+
canonicalization: SORTED_JSON_V2,
|
|
91
|
+
digestSha256: await sha256ArtifactDigestV2(unsigned),
|
|
92
|
+
}),
|
|
94
93
|
});
|
|
95
94
|
}
|
|
96
95
|
export function formatInvestigationManifest(manifest) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
2
|
import { buildInvestigationPlan, } from "./investigation-plan.mjs";
|
|
3
3
|
import { CliUsageError } from "./errors.mjs";
|
|
4
|
+
import { scanBoundedJson } from "../lib/bounded-json.mjs";
|
|
4
5
|
export const CLI_INVESTIGATION_RUN_SCHEMA = 'whoisleuth.cli.investigation-run';
|
|
5
6
|
export const CLI_INVESTIGATION_RUN_VERSION = 1;
|
|
6
7
|
export const MAX_INVESTIGATION_RUN_BYTES = 24 * 1024 * 1024;
|
|
@@ -9,50 +10,93 @@ function boundedResult(value) {
|
|
|
9
10
|
throw new CliUsageError(`Investigation step output is limited to ${MAX_INVESTIGATION_RUN_BYTES} bytes.`);
|
|
10
11
|
}
|
|
11
12
|
try {
|
|
13
|
+
scanBoundedJson(value);
|
|
12
14
|
return JSON.parse(value);
|
|
13
15
|
}
|
|
14
|
-
catch {
|
|
16
|
+
catch (cause) {
|
|
17
|
+
if (cause instanceof TypeError && cause.message !== 'Artefact input is not valid JSON.') {
|
|
18
|
+
throw new CliUsageError(`Investigation step output ${cause.message.replace(/^Artefact JSON /u, '')}`);
|
|
19
|
+
}
|
|
15
20
|
return value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, '').slice(0, MAX_INVESTIGATION_RUN_BYTES);
|
|
16
21
|
}
|
|
17
22
|
}
|
|
18
|
-
function
|
|
23
|
+
function resultSchema(value) {
|
|
24
|
+
return value && typeof value === 'object' && !Array.isArray(value) && typeof value.schema === 'string'
|
|
25
|
+
? String(value.schema)
|
|
26
|
+
: null;
|
|
27
|
+
}
|
|
28
|
+
function sameArguments(left, right) {
|
|
29
|
+
return Array.isArray(left)
|
|
30
|
+
&& left.length === right.length
|
|
31
|
+
&& left.every((value, index) => typeof value === 'string' && value === right[index]);
|
|
32
|
+
}
|
|
33
|
+
function parseResumeState(input, plan) {
|
|
19
34
|
if (!input)
|
|
20
35
|
return [];
|
|
21
36
|
if (Buffer.byteLength(input, 'utf8') > MAX_INVESTIGATION_RUN_BYTES)
|
|
22
37
|
throw new CliUsageError('Investigation resume state exceeds the 24 MiB limit.');
|
|
38
|
+
const normalizedInput = input.replace(/^\uFEFF/u, '');
|
|
39
|
+
try {
|
|
40
|
+
scanBoundedJson(normalizedInput);
|
|
41
|
+
}
|
|
42
|
+
catch (cause) {
|
|
43
|
+
const detail = cause instanceof Error ? cause.message : '';
|
|
44
|
+
if (detail === 'Artefact input is not valid JSON.')
|
|
45
|
+
throw new CliUsageError('Investigation resume state must be valid JSON.');
|
|
46
|
+
throw new CliUsageError(detail ? `Investigation resume state ${detail.replace(/^Artefact JSON /u, '')}` : 'Investigation resume state must be valid JSON.');
|
|
47
|
+
}
|
|
23
48
|
let parsed;
|
|
24
49
|
try {
|
|
25
|
-
parsed = JSON.parse(
|
|
50
|
+
parsed = JSON.parse(normalizedInput);
|
|
26
51
|
}
|
|
27
52
|
catch {
|
|
28
53
|
throw new CliUsageError('Investigation resume state must be valid JSON.');
|
|
29
54
|
}
|
|
30
55
|
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)) {
|
|
56
|
+
if (root.schema !== CLI_INVESTIGATION_RUN_SCHEMA || root.version !== CLI_INVESTIGATION_RUN_VERSION || root.recipe !== plan.recipe.id || root.subject !== plan.subject || !Array.isArray(root.completedSteps)) {
|
|
32
57
|
throw new CliUsageError('Investigation resume state must match this versioned recipe and subject.');
|
|
33
58
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
59
|
+
if (root.completedSteps.length > plan.steps.length) {
|
|
60
|
+
throw new CliUsageError('Investigation resume state contains more steps than the installed fixed recipe.');
|
|
61
|
+
}
|
|
62
|
+
const completed = [];
|
|
63
|
+
for (const [index, value] of root.completedSteps.entries()) {
|
|
64
|
+
const planned = plan.steps[index];
|
|
65
|
+
if (!planned || !value || typeof value !== 'object' || Array.isArray(value)) {
|
|
66
|
+
throw new CliUsageError('Investigation resume state contains a malformed or out-of-order step.');
|
|
67
|
+
}
|
|
37
68
|
const item = value;
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
69
|
+
if (item.id !== planned.id
|
|
70
|
+
|| item.command !== planned.command
|
|
71
|
+
|| item.mode !== planned.mode
|
|
72
|
+
|| !sameArguments(item.arguments, planned.arguments)
|
|
73
|
+
|| !Number.isSafeInteger(item.exitCode)) {
|
|
74
|
+
throw new CliUsageError('Investigation resume state does not match the installed fixed recipe.');
|
|
75
|
+
}
|
|
76
|
+
const exitCode = Number(item.exitCode);
|
|
77
|
+
if (exitCode !== 0 && exitCode !== 2) {
|
|
78
|
+
if (index !== root.completedSteps.length - 1) {
|
|
79
|
+
throw new CliUsageError('A failed investigation step must be the final retained step.');
|
|
80
|
+
}
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
if (resultSchema(item.result) !== planned.produces) {
|
|
84
|
+
throw new CliUsageError('Investigation resume state contains output from an unexpected command contract.');
|
|
85
|
+
}
|
|
86
|
+
completed.push(Object.freeze({
|
|
87
|
+
id: planned.id,
|
|
88
|
+
command: planned.command,
|
|
89
|
+
arguments: planned.arguments,
|
|
90
|
+
mode: planned.mode,
|
|
91
|
+
exitCode,
|
|
92
|
+
result: item.result,
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
return completed;
|
|
49
96
|
}
|
|
50
97
|
export async function runInvestigationRecipe(recipe, subjectValue, options) {
|
|
51
98
|
const plan = buildInvestigationPlan(recipe, subjectValue, options.generatedAt);
|
|
52
|
-
const prior = parseResumeState(options.resumeInput,
|
|
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.');
|
|
99
|
+
const prior = parseResumeState(options.resumeInput, plan);
|
|
56
100
|
const completed = [...prior];
|
|
57
101
|
let state = 'complete';
|
|
58
102
|
let currentStep = null;
|
|
@@ -69,13 +113,17 @@ export async function runInvestigationRecipe(recipe, subjectValue, options) {
|
|
|
69
113
|
break;
|
|
70
114
|
}
|
|
71
115
|
const result = await options.execute(step.command, step.arguments);
|
|
116
|
+
const parsedResult = boundedResult(result.stdout);
|
|
117
|
+
if ((result.exitCode === 0 || result.exitCode === 2) && resultSchema(parsedResult) !== step.produces) {
|
|
118
|
+
throw new CliUsageError(`Investigation step ${step.id} returned an unexpected command contract.`);
|
|
119
|
+
}
|
|
72
120
|
completed.push(Object.freeze({
|
|
73
121
|
id: step.id,
|
|
74
122
|
command: step.command,
|
|
75
123
|
arguments: step.arguments,
|
|
76
124
|
mode: step.mode,
|
|
77
125
|
exitCode: result.exitCode,
|
|
78
|
-
result:
|
|
126
|
+
result: parsedResult,
|
|
79
127
|
}));
|
|
80
128
|
if (result.exitCode !== 0 && result.exitCode !== 2) {
|
|
81
129
|
state = 'step_failed';
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { constants as fsConstants } from 'node:fs';
|
|
2
|
+
import { open, realpath } from 'node:fs/promises';
|
|
2
3
|
import { isIP } from 'node:net';
|
|
3
|
-
import {
|
|
4
|
+
import { Reader } from 'maxmind';
|
|
4
5
|
import { formatIpPrefix, parseIpPrefix } from "../lib/ip-prefix.mjs";
|
|
5
6
|
import { CliUsageError } from "./errors.mjs";
|
|
6
7
|
export const LOCAL_MMDB_QUERY_SCHEMA = 'whoisleuth.local-mmdb-query';
|
|
8
|
+
export const LOCAL_MMDB_QUERY_VERSION = 1;
|
|
7
9
|
export const MAX_LOCAL_MMDB_BYTES = 512 * 1024 * 1024;
|
|
8
10
|
function record(value) {
|
|
9
11
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
@@ -17,6 +19,43 @@ function names(value) {
|
|
|
17
19
|
const source = record(value);
|
|
18
20
|
return boundedText(source.en, 120);
|
|
19
21
|
}
|
|
22
|
+
async function readStableMmdb(databasePath) {
|
|
23
|
+
let handle = null;
|
|
24
|
+
try {
|
|
25
|
+
const resolvedPath = await realpath(databasePath);
|
|
26
|
+
handle = await open(resolvedPath, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW);
|
|
27
|
+
const before = await handle.stat();
|
|
28
|
+
if (!before.isFile() || before.size <= 0 || before.size > MAX_LOCAL_MMDB_BYTES) {
|
|
29
|
+
throw new CliUsageError(`The supplied MMDB must be a file no larger than ${MAX_LOCAL_MMDB_BYTES} bytes.`);
|
|
30
|
+
}
|
|
31
|
+
const database = Buffer.allocUnsafe(before.size);
|
|
32
|
+
let offset = 0;
|
|
33
|
+
while (offset < database.length) {
|
|
34
|
+
const { bytesRead } = await handle.read(database, offset, database.length - offset, offset);
|
|
35
|
+
if (bytesRead === 0)
|
|
36
|
+
break;
|
|
37
|
+
offset += bytesRead;
|
|
38
|
+
}
|
|
39
|
+
const after = await handle.stat();
|
|
40
|
+
if (offset !== database.length
|
|
41
|
+
|| before.dev !== after.dev
|
|
42
|
+
|| before.ino !== after.ino
|
|
43
|
+
|| before.size !== after.size
|
|
44
|
+
|| before.mtimeMs !== after.mtimeMs
|
|
45
|
+
|| before.ctimeMs !== after.ctimeMs) {
|
|
46
|
+
throw new CliUsageError('The supplied MMDB changed while it was being read.');
|
|
47
|
+
}
|
|
48
|
+
return database;
|
|
49
|
+
}
|
|
50
|
+
catch (cause) {
|
|
51
|
+
if (cause instanceof CliUsageError)
|
|
52
|
+
throw cause;
|
|
53
|
+
throw new CliUsageError('The supplied MMDB file could not be read.');
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
await handle?.close().catch(() => { });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
20
59
|
export async function reviewLocalMmdb(input, databasePath) {
|
|
21
60
|
const address = boundedText(input.address, 64)?.toLowerCase() ?? '';
|
|
22
61
|
const sourceLabel = boundedText(input.sourceLabel, 120);
|
|
@@ -27,21 +66,13 @@ export async function reviewLocalMmdb(input, databasePath) {
|
|
|
27
66
|
if (!sourceLabel || !databaseVersion || !license) {
|
|
28
67
|
throw new CliUsageError('Local MMDB review requires bounded sourceLabel, databaseVersion, and licence metadata.');
|
|
29
68
|
}
|
|
30
|
-
let info;
|
|
31
|
-
try {
|
|
32
|
-
info = await stat(databasePath);
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
throw new CliUsageError('The supplied MMDB file could not be read.');
|
|
36
|
-
}
|
|
37
|
-
if (!info.isFile() || info.size <= 0 || info.size > MAX_LOCAL_MMDB_BYTES) {
|
|
38
|
-
throw new CliUsageError(`The supplied MMDB must be a file no larger than ${MAX_LOCAL_MMDB_BYTES} bytes.`);
|
|
39
|
-
}
|
|
40
69
|
let reader;
|
|
41
70
|
try {
|
|
42
|
-
reader = await
|
|
71
|
+
reader = new Reader(await readStableMmdb(databasePath));
|
|
43
72
|
}
|
|
44
|
-
catch {
|
|
73
|
+
catch (cause) {
|
|
74
|
+
if (cause instanceof CliUsageError)
|
|
75
|
+
throw cause;
|
|
45
76
|
throw new CliUsageError('The supplied file is not a readable MaxMind DB database.');
|
|
46
77
|
}
|
|
47
78
|
const [rawMatch, prefixLength] = reader.getWithPrefixLength(address);
|
|
@@ -84,8 +84,8 @@ async function runLookupCommand(args, dependencies, context) {
|
|
|
84
84
|
const evidenceModule = await loadEvidence();
|
|
85
85
|
const report = buildCliEvidenceExport(JSON.stringify(document), evidenceModule, now);
|
|
86
86
|
context.writeStdout(args.output === 'markdown'
|
|
87
|
-
? formatLookupEvidenceMarkdown(report)
|
|
88
|
-
: formatLookupEvidenceHtml(report));
|
|
87
|
+
? formatLookupEvidenceMarkdown(report, { includeAttribution: args.includeAttribution })
|
|
88
|
+
: formatLookupEvidenceHtml(report, { includeAttribution: args.includeAttribution }));
|
|
89
89
|
}
|
|
90
90
|
else {
|
|
91
91
|
context.writeStdout(context.terminal(formatTerminalLookup(document, { detail: args.detail }), args.color));
|
package/cli/lookup-diff.mjs
CHANGED
|
@@ -95,6 +95,9 @@ function lookupComparisonInput(document) {
|
|
|
95
95
|
faviconMatch: false,
|
|
96
96
|
faviconNearMatch: false,
|
|
97
97
|
reusesOfficialAssets: false,
|
|
98
|
+
idnReferenceMatch: false,
|
|
99
|
+
pageBaselineMatch: false,
|
|
100
|
+
hasActiveBrandProfile: false,
|
|
98
101
|
hasPasswordField: recordOrEmpty(recordOrEmpty(availability.credentialSurfaceProfile).inputs).categories
|
|
99
102
|
? Number(recordOrEmpty(recordOrEmpty(recordOrEmpty(availability.credentialSurfaceProfile).inputs).categories).password) > 0
|
|
100
103
|
: false,
|
|
@@ -115,6 +118,12 @@ function lookupComparisonInput(document) {
|
|
|
115
118
|
comparisonEvidence,
|
|
116
119
|
relationship,
|
|
117
120
|
sourceCoverage: sourceCoverage(document, availability),
|
|
121
|
+
profileContext: {
|
|
122
|
+
sourceState: 'ready',
|
|
123
|
+
activeProfileId: null,
|
|
124
|
+
profileUpdatedAt: null,
|
|
125
|
+
limitation: '',
|
|
126
|
+
},
|
|
118
127
|
};
|
|
119
128
|
}
|
|
120
129
|
function buildCliLookupDiff(leftText, rightText, generatedAt = new Date().toISOString(), options = {}) {
|
|
@@ -2,6 +2,8 @@ import { fetchHomepage } from "../lib/availability.mjs";
|
|
|
2
2
|
import { searchCertificateTransparency } from "../lib/ct-search.mjs";
|
|
3
3
|
import { checkDomainPosture, normalizeAuditDomain, normalizeDkimSelectors } from "../lib/domain-posture.mjs";
|
|
4
4
|
import { collectTlsIntelligence, normalizeTlsHostname } from "../lib/tls-intelligence.mjs";
|
|
5
|
+
import { MAX_DNSSEC_TRUST_ANCHOR_BYTES, formatDnssecChainReport, validateDnssecChain, } from "../lib/dnssec-chain-validation.mjs";
|
|
6
|
+
import { MAX_MAIL_TRANSPORT_INPUT_BYTES, collectMailTransportReview, formatMailTransportReview, } from "../lib/smtp-transport-review.mjs";
|
|
5
7
|
import { CliUsageError } from "./errors.mjs";
|
|
6
8
|
import { buildCliCtSearchDocument, buildCliHttpDocument, buildCliPostureDocument, buildCliTlsDocument, formatJsonDocument, } from "./formatters/json.mjs";
|
|
7
9
|
import { formatTerminalCtSearch, formatTerminalHttp, formatTerminalPosture, formatTerminalTls, } from "./formatters/terminal.mjs";
|
|
@@ -71,6 +73,82 @@ async function runNetworkCommand(args, dependencies, context) {
|
|
|
71
73
|
}
|
|
72
74
|
return EXIT_CODES.SUCCESS;
|
|
73
75
|
}
|
|
76
|
+
if (args.action === 'dnssec-validate') {
|
|
77
|
+
let anchorInput;
|
|
78
|
+
try {
|
|
79
|
+
anchorInput = dependencies.readTrustAnchorInput
|
|
80
|
+
? await dependencies.readTrustAnchorInput(args.trustAnchorSource)
|
|
81
|
+
: await context.readInput(args.trustAnchorSource, MAX_DNSSEC_TRUST_ANCHOR_BYTES, 'DNSSEC trust anchor');
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error instanceof CliUsageError)
|
|
85
|
+
throw error;
|
|
86
|
+
throw new CliUsageError(`Could not read DNSSEC trust anchor: ${String(error instanceof Error ? error.message : error).slice(0, 240)}`);
|
|
87
|
+
}
|
|
88
|
+
const validate = dependencies.validateDnssecChain ?? validateDnssecChain;
|
|
89
|
+
const report = await context.withProgress('Validating the isolated DNSSEC chain', () => validate({
|
|
90
|
+
target: args.target,
|
|
91
|
+
resolver: args.resolver,
|
|
92
|
+
trustAnchor: anchorInput,
|
|
93
|
+
observedAt: context.now(),
|
|
94
|
+
ownedOrAuthorized: args.ownedOrAuthorized,
|
|
95
|
+
}));
|
|
96
|
+
if (!args.quiet) {
|
|
97
|
+
context.writeStdout(args.output === 'json'
|
|
98
|
+
? formatJsonDocument(report)
|
|
99
|
+
: context.terminal(formatDnssecChainReport(report), args.color));
|
|
100
|
+
}
|
|
101
|
+
return report.state === 'secure' || report.state === 'insecure'
|
|
102
|
+
? EXIT_CODES.SUCCESS
|
|
103
|
+
: EXIT_CODES.PARTIAL_FAILURE;
|
|
104
|
+
}
|
|
105
|
+
if (args.action === 'mail-transport') {
|
|
106
|
+
let input;
|
|
107
|
+
let anchorInput;
|
|
108
|
+
try {
|
|
109
|
+
input = await (dependencies.readMailTransportInput
|
|
110
|
+
? dependencies.readMailTransportInput(args.source)
|
|
111
|
+
: context.readInput(args.source, MAX_MAIL_TRANSPORT_INPUT_BYTES, 'Mail transport input'));
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error instanceof CliUsageError)
|
|
115
|
+
throw error;
|
|
116
|
+
throw new CliUsageError(`Could not read mail transport input: ${String(error instanceof Error ? error.message : error).slice(0, 240)}`);
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
anchorInput = await (dependencies.readTrustAnchorInput
|
|
120
|
+
? dependencies.readTrustAnchorInput(args.trustAnchorSource)
|
|
121
|
+
: context.readInput(args.trustAnchorSource, MAX_DNSSEC_TRUST_ANCHOR_BYTES, 'DNSSEC trust anchor'));
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (error instanceof CliUsageError)
|
|
125
|
+
throw error;
|
|
126
|
+
throw new CliUsageError(`Could not read DNSSEC trust anchor: ${String(error instanceof Error ? error.message : error).slice(0, 240)}`);
|
|
127
|
+
}
|
|
128
|
+
if (!input.trim())
|
|
129
|
+
throw new CliUsageError('mail-transport requires one versioned JSON file or a document on stdin.');
|
|
130
|
+
const collect = dependencies.collectMailTransportReview ?? collectMailTransportReview;
|
|
131
|
+
let review;
|
|
132
|
+
try {
|
|
133
|
+
review = await context.withProgress('Reviewing selected authorised mail transports', () => collect(input, {
|
|
134
|
+
resolver: args.resolver,
|
|
135
|
+
trustAnchor: anchorInput,
|
|
136
|
+
ownedOrAuthorized: args.ownedOrAuthorized,
|
|
137
|
+
activeProbeAcknowledged: args.activeProbeAcknowledged,
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
if (error instanceof TypeError)
|
|
142
|
+
throw new CliUsageError(error.message);
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
if (!args.quiet) {
|
|
146
|
+
context.writeStdout(args.output === 'json'
|
|
147
|
+
? formatJsonDocument(review)
|
|
148
|
+
: context.terminal(formatMailTransportReview(review), args.color));
|
|
149
|
+
}
|
|
150
|
+
return review.runState === 'complete' ? EXIT_CODES.SUCCESS : EXIT_CODES.PARTIAL_FAILURE;
|
|
151
|
+
}
|
|
74
152
|
const requestedHostname = args.hostname || await context.readSingleInput();
|
|
75
153
|
if (!requestedHostname)
|
|
76
154
|
throw new CliUsageError('tls requires one hostname as an argument or on stdin.');
|
|
@@ -8,6 +8,7 @@ import { buildLocalGeoIpDatabase, lookupLocalGeoIp, } from "../lib/local-geoip-e
|
|
|
8
8
|
import { inspectRdapReverseSearchResponse, normalizeRdapSearchHelp, planRdapReverseSearch, } from "../lib/rdap-search-workbench.mjs";
|
|
9
9
|
import { reviewRpkiRoute } from "../lib/rpki-evidence.mjs";
|
|
10
10
|
import { analyzeTlsaEvidence } from "../lib/tlsa-evidence.mjs";
|
|
11
|
+
import { CRYPTOGRAPHIC_ASSURANCE_INPUT_SCHEMA, buildCryptographicAssuranceReview, } from "../lib/cryptographic-assurance.mjs";
|
|
11
12
|
import { reviewZoneIntent } from "../lib/zone-intent-review.mjs";
|
|
12
13
|
import { reviewDnsConvergence } from "../lib/dns-convergence-review.mjs";
|
|
13
14
|
import { compareTrustStoreEvidence } from "../lib/trust-store-comparison.mjs";
|
|
@@ -16,6 +17,13 @@ import { LOCAL_MMDB_QUERY_SCHEMA, reviewLocalMmdb } from "./local-mmdb-review.mj
|
|
|
16
17
|
const OFFLINE_EVIDENCE_REVIEW_SCHEMA = 'whoisleuth.cli.offline-evidence-review';
|
|
17
18
|
const OFFLINE_EVIDENCE_REVIEW_VERSION = 1;
|
|
18
19
|
const MAX_OFFLINE_EVIDENCE_INPUT_BYTES = 16 * 1024 * 1024;
|
|
20
|
+
const RDAP_SEARCH_INPUT_SCHEMA = 'whoisleuth.rdap-search-input';
|
|
21
|
+
const DNSSEC_EVIDENCE_INPUT_SCHEMA = 'whoisleuth.dnssec-evidence-input';
|
|
22
|
+
const TLSA_EVIDENCE_INPUT_SCHEMA = 'whoisleuth.tlsa-evidence-input';
|
|
23
|
+
const RPKI_ROUTE_INPUT_SCHEMA = 'whoisleuth.rpki-route-input';
|
|
24
|
+
const LOCAL_GEOIP_QUERY_SCHEMA = 'whoisleuth.local-geoip-query';
|
|
25
|
+
const ENCRYPTED_DNS_PLAN_INPUT_SCHEMA = 'whoisleuth.encrypted-dns-plan-input';
|
|
26
|
+
const OFFLINE_EVIDENCE_INPUT_VERSION = 1;
|
|
19
27
|
function record(value) {
|
|
20
28
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
21
29
|
}
|
|
@@ -40,7 +48,7 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
|
|
|
40
48
|
const input = parseInput(value);
|
|
41
49
|
let kind;
|
|
42
50
|
let result;
|
|
43
|
-
if (input.schema ===
|
|
51
|
+
if (input.schema === RDAP_SEARCH_INPUT_SCHEMA) {
|
|
44
52
|
kind = 'rdap_search';
|
|
45
53
|
const request = record(input.request);
|
|
46
54
|
const help = normalizeRdapSearchHelp(input.help);
|
|
@@ -58,7 +66,7 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
|
|
|
58
66
|
: inspectRdapReverseSearchResponse(input.response, [request.property]),
|
|
59
67
|
});
|
|
60
68
|
}
|
|
61
|
-
else if (input.schema ===
|
|
69
|
+
else if (input.schema === DNSSEC_EVIDENCE_INPUT_SCHEMA) {
|
|
62
70
|
kind = 'dnssec';
|
|
63
71
|
result = validateDnssecEvidence({
|
|
64
72
|
ownerName: input.ownerName,
|
|
@@ -69,7 +77,7 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
|
|
|
69
77
|
observedAt: input.observedAt,
|
|
70
78
|
});
|
|
71
79
|
}
|
|
72
|
-
else if (input.schema ===
|
|
80
|
+
else if (input.schema === TLSA_EVIDENCE_INPUT_SCHEMA) {
|
|
73
81
|
kind = 'tlsa';
|
|
74
82
|
result = analyzeTlsaEvidence({
|
|
75
83
|
serviceName: input.serviceName,
|
|
@@ -81,7 +89,7 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
|
|
|
81
89
|
authorityMaterials: input.authorityMaterials,
|
|
82
90
|
});
|
|
83
91
|
}
|
|
84
|
-
else if (input.schema ===
|
|
92
|
+
else if (input.schema === RPKI_ROUTE_INPUT_SCHEMA) {
|
|
85
93
|
kind = 'rpki';
|
|
86
94
|
result = reviewRpkiRoute({
|
|
87
95
|
routePrefix: input.routePrefix,
|
|
@@ -89,11 +97,15 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
|
|
|
89
97
|
authorizations: input.authorizations,
|
|
90
98
|
});
|
|
91
99
|
}
|
|
92
|
-
else if (input.schema ===
|
|
100
|
+
else if (input.schema === CRYPTOGRAPHIC_ASSURANCE_INPUT_SCHEMA) {
|
|
101
|
+
kind = 'cryptographic_assurance';
|
|
102
|
+
result = buildCryptographicAssuranceReview(input, generatedAt);
|
|
103
|
+
}
|
|
104
|
+
else if (input.schema === LOCAL_GEOIP_QUERY_SCHEMA) {
|
|
93
105
|
kind = 'geoip';
|
|
94
106
|
result = lookupLocalGeoIp(buildLocalGeoIpDatabase(input.database), input.address);
|
|
95
107
|
}
|
|
96
|
-
else if (input.schema ===
|
|
108
|
+
else if (input.schema === ENCRYPTED_DNS_PLAN_INPUT_SCHEMA) {
|
|
97
109
|
kind = 'encrypted_dns';
|
|
98
110
|
const query = record(input.query);
|
|
99
111
|
result = planEncryptedDnsQuery(normalizeEncryptedDnsAdapter(input.adapter), { name: query.name, type: query.type });
|
|
@@ -168,7 +180,8 @@ function formatOfflineEvidenceReview(document) {
|
|
|
168
180
|
: 'review'
|
|
169
181
|
: typeof gate.pass === 'boolean'
|
|
170
182
|
? gate.pass ? 'ready' : 'review'
|
|
171
|
-
: document.kind === 'domain_portfolio' ? 'inventory'
|
|
183
|
+
: document.kind === 'domain_portfolio' ? 'inventory'
|
|
184
|
+
: document.kind === 'cryptographic_assurance' ? 'separate' : 'reviewed';
|
|
172
185
|
const lines = [
|
|
173
186
|
'Offline evidence review',
|
|
174
187
|
`Kind ${document.kind.replaceAll('_', ' ')}`,
|
|
@@ -220,6 +233,13 @@ function formatOfflineEvidenceReview(document) {
|
|
|
220
233
|
lines.push(`Recovery ${listLength(result.recoveryCycles)}`);
|
|
221
234
|
lines.push(`Unknown ${Object.values(unknownCounts).reduce((total, value) => total + count(value), 0)}`);
|
|
222
235
|
}
|
|
236
|
+
else if (document.kind === 'cryptographic_assurance') {
|
|
237
|
+
const cards = Array.isArray(result.cards) ? result.cards : [];
|
|
238
|
+
for (const value of cards) {
|
|
239
|
+
const card = record(value);
|
|
240
|
+
lines.push(`${String(card.label ?? card.family ?? 'Evidence')} ${String(card.state ?? 'unavailable').replaceAll('_', ' ')} (${String(card.completeness ?? 'unavailable')})`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
223
243
|
else {
|
|
224
244
|
for (const [label, key] of [
|
|
225
245
|
['Records', 'records'],
|
|
@@ -253,4 +273,4 @@ function formatOfflineEvidenceReview(document) {
|
|
|
253
273
|
}
|
|
254
274
|
return `${lines.join('\n')}\n`;
|
|
255
275
|
}
|
|
256
|
-
export { MAX_OFFLINE_EVIDENCE_INPUT_BYTES, OFFLINE_EVIDENCE_REVIEW_SCHEMA, OFFLINE_EVIDENCE_REVIEW_VERSION, buildOfflineEvidenceReview, buildOfflineEvidenceReviewWithLocalResources, formatOfflineEvidenceReview, };
|
|
276
|
+
export { DNSSEC_EVIDENCE_INPUT_SCHEMA, ENCRYPTED_DNS_PLAN_INPUT_SCHEMA, LOCAL_GEOIP_QUERY_SCHEMA, MAX_OFFLINE_EVIDENCE_INPUT_BYTES, OFFLINE_EVIDENCE_INPUT_VERSION, OFFLINE_EVIDENCE_REVIEW_SCHEMA, OFFLINE_EVIDENCE_REVIEW_VERSION, RDAP_SEARCH_INPUT_SCHEMA, RPKI_ROUTE_INPUT_SCHEMA, TLSA_EVIDENCE_INPUT_SCHEMA, buildOfflineEvidenceReview, buildOfflineEvidenceReviewWithLocalResources, formatOfflineEvidenceReview, };
|
package/cli/page-compare.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { comparePageBaselines } from "../frontend/src/lib/analysis/page-similari
|
|
|
4
4
|
import { CliUsageError } from "./errors.mjs";
|
|
5
5
|
import { parseSavedLookupDocument } from "./saved-lookup.mjs";
|
|
6
6
|
export const CLI_PAGE_COMPARE_SCHEMA = 'whoisleuth.cli.page-compare';
|
|
7
|
-
export const CLI_PAGE_COMPARE_VERSION =
|
|
7
|
+
export const CLI_PAGE_COMPARE_VERSION = 3;
|
|
8
8
|
function record(value) {
|
|
9
9
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
10
10
|
}
|
|
@@ -56,6 +56,8 @@ function buildCliPageComparison(leftText, rightText, generatedAt = new Date().to
|
|
|
56
56
|
&& ['success', 'partial'].includes(right.compact.technology.state);
|
|
57
57
|
const tlsUsable = ['success', 'partial'].includes(left.compact.tls.state)
|
|
58
58
|
&& ['success', 'partial'].includes(right.compact.tls.state);
|
|
59
|
+
const technologyPartial = left.compact.technology.truncated || right.compact.technology.truncated
|
|
60
|
+
|| left.compact.technology.state === 'partial' || right.compact.technology.state === 'partial';
|
|
59
61
|
return {
|
|
60
62
|
schema: CLI_PAGE_COMPARE_SCHEMA,
|
|
61
63
|
version: CLI_PAGE_COMPARE_VERSION,
|
|
@@ -72,14 +74,15 @@ function buildCliPageComparison(leftText, rightText, generatedAt = new Date().to
|
|
|
72
74
|
},
|
|
73
75
|
page,
|
|
74
76
|
technology: {
|
|
75
|
-
state: technologyUsable
|
|
77
|
+
state: technologyUsable
|
|
78
|
+
? technologyPartial ? 'partial' : setComparison(leftTechnology, rightTechnology)
|
|
79
|
+
: 'unavailable',
|
|
76
80
|
leftSourceState: left.compact.technology.state,
|
|
77
81
|
rightSourceState: right.compact.technology.state,
|
|
78
82
|
leftIds: leftTechnology,
|
|
79
83
|
rightIds: rightTechnology,
|
|
80
84
|
sharedIds: sharedTechnology,
|
|
81
|
-
partial:
|
|
82
|
-
|| left.compact.technology.state === 'partial' || right.compact.technology.state === 'partial',
|
|
85
|
+
partial: technologyPartial,
|
|
83
86
|
},
|
|
84
87
|
tls: {
|
|
85
88
|
leftSourceState: left.compact.tls.state,
|
|
@@ -115,7 +118,7 @@ function formatCliPageComparison(document) {
|
|
|
115
118
|
for (const component of document.page.components) {
|
|
116
119
|
lines.push(`${component.label.padEnd(18)} ${component.outcome}`);
|
|
117
120
|
}
|
|
118
|
-
lines.push('', 'Technology', `Relationship ${document.technology.state}`, `Shared ${document.technology.sharedIds.join(', ') || 'None observed'}`, `Left ${document.technology.leftIds.join(', ') || 'Unavailable'}`, `Right ${document.technology.rightIds.join(', ') || 'Unavailable'}`, '', 'TLS', `Issuer ${document.tls.issuer.state}`, `Public key ${document.tls.publicKey.state}`, '', 'Limitations:');
|
|
121
|
+
lines.push('', 'Technology', `Relationship ${document.technology.state}`, `Evidence ${document.technology.partial ? 'Partial; equality and disjointness withheld' : 'Complete retained sets'}`, `Shared ${document.technology.sharedIds.join(', ') || 'None observed'}`, `Left ${document.technology.leftIds.join(', ') || 'Unavailable'}`, `Right ${document.technology.rightIds.join(', ') || 'Unavailable'}`, '', 'TLS', `Issuer ${document.tls.issuer.state}`, `Public key ${document.tls.publicKey.state}`, '', 'Limitations:');
|
|
119
122
|
for (const limitation of document.limitations)
|
|
120
123
|
lines.push(` - ${limitation}`);
|
|
121
124
|
return `${lines.join('\n')}\n`;
|