@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.
- package/README.md +15 -0
- package/bin/whoisleuth.mjs +13 -2
- 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 +7 -11
- package/cli/bulk-command-runner.mjs +19 -2
- 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 +33 -10
- package/cli/config-profile.mjs +180 -0
- package/cli/ct-event-intake.mjs +159 -0
- package/cli/discovery-scan-command-runner.mjs +15 -1
- package/cli/domain-control-monitor.mjs +104 -0
- package/cli/domain-control-observations.mjs +235 -0
- 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/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/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 +73 -0
- package/cli/runner.mjs +368 -306
- package/docs/cli-reference.md +240 -14
- package/docs/cli.md +42 -7
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
- package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
- 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-decision-support.js +7 -0
- package/frontend/src/lib/analysis/lookup-presentation.js +10 -20
- package/frontend/src/lib/analysis/scoring.js +1 -1
- package/frontend/src/lib/analysis/visualization-models.js +2 -3
- package/frontend/src/lib/analysis/workspace-archive.js +2 -0
- package/lib/abusech-auth.mjs +1 -1
- package/lib/availability.mjs +1 -1
- package/lib/bounded-contract-normalizers.mjs +33 -0
- 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/nameserver-preflight-review.mjs +159 -0
- package/lib/page-language-signals.mjs +124 -0
- package/lib/risk-scoring.mjs +42 -1
- package/lib/static-html-analysis.mjs +29 -1
- package/lib/trust-store-comparison.mjs +146 -0
- package/lib/website-technology.mjs +24 -10
- package/lib/zone-intent-review.mjs +534 -0
- package/package.json +1 -1
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { DOMAIN_CONTROL_FLIGHT_RECORDER_FIELDS, } from "../lib/domain-control-flight-recorder.mjs";
|
|
3
|
+
import { DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, reviewDomainControlManifest, verifyDomainControlManifest, } from "../lib/domain-control-manifest.mjs";
|
|
4
|
+
import { CliUsageError } from "./errors.mjs";
|
|
5
|
+
import { parseSavedLookupDocument } from "./saved-lookup.mjs";
|
|
6
|
+
export const CLI_DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA = 'whoisleuth.cli.domain-control-review-input';
|
|
7
|
+
export const CLI_DOMAIN_CONTROL_REVIEW_SCHEMA = 'whoisleuth.cli.domain-control-review';
|
|
8
|
+
export const CLI_DOMAIN_CONTROL_REVIEW_VERSION = 1;
|
|
9
|
+
export const MAX_DOMAIN_CONTROL_REVIEW_INPUT_BYTES = 32 * 1024 * 1024;
|
|
10
|
+
function record(value) {
|
|
11
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
12
|
+
}
|
|
13
|
+
function state(value) {
|
|
14
|
+
const normalized = typeof value === 'string' ? value.toLowerCase() : '';
|
|
15
|
+
if (normalized === 'success' || normalized === 'complete')
|
|
16
|
+
return 'observed';
|
|
17
|
+
if (normalized === 'partial' || normalized === 'not_found')
|
|
18
|
+
return 'partial';
|
|
19
|
+
if (normalized === 'unsupported' || normalized === 'not_applicable')
|
|
20
|
+
return 'unsupported';
|
|
21
|
+
return 'unavailable';
|
|
22
|
+
}
|
|
23
|
+
function text(value, maximum = 500) {
|
|
24
|
+
if (typeof value !== 'string')
|
|
25
|
+
return null;
|
|
26
|
+
const normalized = value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum);
|
|
27
|
+
return normalized || null;
|
|
28
|
+
}
|
|
29
|
+
function list(value, normalizer = (item) => text(item)) {
|
|
30
|
+
if (!Array.isArray(value))
|
|
31
|
+
return [];
|
|
32
|
+
return [...new Set(value.slice(0, 128).flatMap((item) => {
|
|
33
|
+
const normalized = normalizer(item);
|
|
34
|
+
return normalized ? [normalized.toLowerCase()] : [];
|
|
35
|
+
}))].sort().slice(0, 32);
|
|
36
|
+
}
|
|
37
|
+
function hostname(value) {
|
|
38
|
+
return text(value, 253)?.toLowerCase().replace(/\.$/u, '') ?? null;
|
|
39
|
+
}
|
|
40
|
+
function mxValue(value) {
|
|
41
|
+
const item = record(value);
|
|
42
|
+
const exchange = hostname(item.exchange ?? item.host ?? item.value);
|
|
43
|
+
if (!exchange)
|
|
44
|
+
return text(value);
|
|
45
|
+
const priority = Number.isSafeInteger(item.priority) && Number(item.priority) >= 0 ? Number(item.priority) : null;
|
|
46
|
+
return priority === null ? exchange : `${priority} ${exchange}`;
|
|
47
|
+
}
|
|
48
|
+
function caaValue(value) {
|
|
49
|
+
const item = record(value);
|
|
50
|
+
const tag = text(item.tag, 32)?.toLowerCase();
|
|
51
|
+
const recordValue = text(item.value, 500)?.toLowerCase();
|
|
52
|
+
if (!tag || !recordValue)
|
|
53
|
+
return text(value);
|
|
54
|
+
const critical = Number.isSafeInteger(item.critical) ? Number(item.critical) : Number.isSafeInteger(item.flags) ? Number(item.flags) : 0;
|
|
55
|
+
return `${critical} ${tag} ${recordValue}`;
|
|
56
|
+
}
|
|
57
|
+
function dsValue(value) {
|
|
58
|
+
const item = record(value);
|
|
59
|
+
const keyTag = Number.isSafeInteger(item.keyTag) ? item.keyTag : null;
|
|
60
|
+
const algorithm = Number.isSafeInteger(item.algorithm) ? item.algorithm : null;
|
|
61
|
+
const digestType = Number.isSafeInteger(item.digestType) ? item.digestType : null;
|
|
62
|
+
const digest = text(item.digest, 512)?.toLowerCase();
|
|
63
|
+
return keyTag !== null && algorithm !== null && digestType !== null && digest
|
|
64
|
+
? `${keyTag} ${algorithm} ${digestType} ${digest}`
|
|
65
|
+
: text(value);
|
|
66
|
+
}
|
|
67
|
+
function field(id, source, sourceState, values) {
|
|
68
|
+
return Object.freeze({ id, source, state: sourceState, values: Object.freeze([...values].sort()) });
|
|
69
|
+
}
|
|
70
|
+
function diagnostic(document, key) {
|
|
71
|
+
return state(record(document.diagnostics)[key] && record(record(document.diagnostics)[key]).status);
|
|
72
|
+
}
|
|
73
|
+
function registryParsed(document) {
|
|
74
|
+
const rdapState = diagnostic(document, 'rdap');
|
|
75
|
+
const whoisState = diagnostic(document, 'whois');
|
|
76
|
+
const rdap = record(record(document.rdap).parsed);
|
|
77
|
+
if (rdapState === 'observed' && Object.keys(rdap).length)
|
|
78
|
+
return { parsed: rdap, source: 'Registry RDAP', sourceState: rdapState };
|
|
79
|
+
const whois = record(record(document.whois).parsed);
|
|
80
|
+
return { parsed: whois, source: 'WHOIS', sourceState: whoisState };
|
|
81
|
+
}
|
|
82
|
+
function lockValue(parsed) {
|
|
83
|
+
const statuses = list(parsed.statuses ?? parsed.status, (item) => text(item, 120)?.replace(/[\s_-]+/gu, '').toLowerCase() ?? null);
|
|
84
|
+
return [statuses.some((item) => item.includes('transferprohibited')) ? 'required' : 'not_required'];
|
|
85
|
+
}
|
|
86
|
+
function pageIdentityValue(availability) {
|
|
87
|
+
const identity = record(availability.pageIdentity);
|
|
88
|
+
const components = [
|
|
89
|
+
text(identity.bodySha256 ?? availability.pageBodySha256, 64),
|
|
90
|
+
text(identity.faviconHash ?? availability.faviconHash, 128),
|
|
91
|
+
text(identity.title ?? availability.pageTitle, 240),
|
|
92
|
+
].filter((item) => Boolean(item));
|
|
93
|
+
return components.length ? [components.join(' | ').toLowerCase()] : [];
|
|
94
|
+
}
|
|
95
|
+
export function domainControlObservationFromSavedLookup(document) {
|
|
96
|
+
const availability = record(document.availability);
|
|
97
|
+
const dns = record(availability.dns);
|
|
98
|
+
const dnsRecords = record(dns.records);
|
|
99
|
+
const delegation = record(dns.delegation);
|
|
100
|
+
const delegationRecords = record(delegation.records);
|
|
101
|
+
const tls = record(availability.tls);
|
|
102
|
+
const tlsState = state(tls.status);
|
|
103
|
+
const certificate = record(tls.certificate);
|
|
104
|
+
const publicKey = record(certificate.publicKey);
|
|
105
|
+
const http = record(availability.http);
|
|
106
|
+
const httpState = state(http.status);
|
|
107
|
+
const registry = registryParsed(document);
|
|
108
|
+
const rdapParsed = record(record(document.rdap).parsed);
|
|
109
|
+
const rdapState = diagnostic(document, 'rdap');
|
|
110
|
+
const dnsState = state(dns.status);
|
|
111
|
+
const fields = [
|
|
112
|
+
field('registrar', registry.source, registry.sourceState, list([record(registry.parsed.registrar).name ?? registry.parsed.registrar], (item) => text(item, 300))),
|
|
113
|
+
field('registrar_lock', registry.source, registry.sourceState, registry.sourceState === 'observed' ? lockValue(registry.parsed) : []),
|
|
114
|
+
field('registry_dnssec', 'Registry RDAP', rdapState, list([rdapParsed.dnssec], (item) => text(item, 80))),
|
|
115
|
+
field('registry_nameservers', 'Registry RDAP', rdapState, list(rdapParsed.nameservers, hostname)),
|
|
116
|
+
field('whois_nameservers', 'WHOIS', diagnostic(document, 'whois'), list(record(record(document.whois).parsed).nameservers, hostname)),
|
|
117
|
+
field('delegated_nameservers', 'DNS', dnsState, list(dnsRecords.ns, hostname)),
|
|
118
|
+
field('delegation_ds', 'DNS delegation', state(delegation.status ?? dns.status), list(delegationRecords.ds ?? rdapParsed.dsData, dsValue)),
|
|
119
|
+
field('mail_exchangers', 'DNS', dnsState, list(dnsRecords.mx, mxValue)),
|
|
120
|
+
field('caa_policy', 'DNS', dnsState, list(record(dns.caaPolicy).records ?? dnsRecords.caa, caaValue)),
|
|
121
|
+
field('tls_certificate', 'TLS', tlsState, list([certificate.fingerprintSha256 ?? tls.fingerprintSha256], (item) => text(item, 128))),
|
|
122
|
+
field('tls_public_key', 'TLS', tlsState, list([publicKey.fingerprintSha256 ?? tls.spkiSha256], (item) => text(item, 128))),
|
|
123
|
+
field('http_origin', 'HTTP', httpState, list([http.finalOrigin ?? availability.httpFinalOrigin], (item) => text(item, 500))),
|
|
124
|
+
field('page_identity', 'Static page identity', state(record(availability.pageIdentity).status ?? availability.pageIdentityStatus ?? http.status), pageIdentityValue(availability)),
|
|
125
|
+
];
|
|
126
|
+
return Object.freeze({
|
|
127
|
+
domain: document.registrableDomain,
|
|
128
|
+
observedAt: document.generatedAt,
|
|
129
|
+
collectionDepth: document.mode,
|
|
130
|
+
fields: Object.freeze(fields.filter((item) => DOMAIN_CONTROL_FLIGHT_RECORDER_FIELDS.includes(item.id))),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
export function buildCliDomainControlReview(inputText, generatedAt = new Date().toISOString()) {
|
|
134
|
+
if (Buffer.byteLength(inputText, 'utf8') > MAX_DOMAIN_CONTROL_REVIEW_INPUT_BYTES) {
|
|
135
|
+
throw new CliUsageError(`Domain-control review input is limited to ${MAX_DOMAIN_CONTROL_REVIEW_INPUT_BYTES} bytes.`);
|
|
136
|
+
}
|
|
137
|
+
let parsed;
|
|
138
|
+
try {
|
|
139
|
+
parsed = JSON.parse(inputText.replace(/^\uFEFF/u, ''));
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
throw new CliUsageError('Domain-control review input must be valid JSON.');
|
|
143
|
+
}
|
|
144
|
+
const input = record(parsed);
|
|
145
|
+
if (input.schema !== CLI_DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA || input.version !== 1 || !Array.isArray(input.lookups)) {
|
|
146
|
+
throw new CliUsageError(`Domain-control review input must use ${CLI_DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA} version 1.`);
|
|
147
|
+
}
|
|
148
|
+
if (Object.keys(input).some((key) => !new Set(['schema', 'version', 'manifest', 'lookups']).has(key))) {
|
|
149
|
+
throw new CliUsageError('Domain-control review input contains an unsupported field.');
|
|
150
|
+
}
|
|
151
|
+
if (input.lookups.length < 1 || input.lookups.length > 100) {
|
|
152
|
+
throw new CliUsageError('Domain-control review requires from 1 to 100 saved Lookup documents.');
|
|
153
|
+
}
|
|
154
|
+
const manifest = verifyDomainControlManifest(input.manifest);
|
|
155
|
+
const lookups = input.lookups.map((item, index) => parseSavedLookupDocument(JSON.stringify(item), { label: `Lookup ${index + 1}` }));
|
|
156
|
+
const latest = new Map();
|
|
157
|
+
for (const lookup of lookups) {
|
|
158
|
+
const current = latest.get(lookup.registrableDomain);
|
|
159
|
+
if (!current || Date.parse(lookup.generatedAt) > Date.parse(current.generatedAt))
|
|
160
|
+
latest.set(lookup.registrableDomain, lookup);
|
|
161
|
+
}
|
|
162
|
+
const observations = [...latest.values()].map(domainControlObservationFromSavedLookup);
|
|
163
|
+
const review = reviewDomainControlManifest({
|
|
164
|
+
schema: DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA,
|
|
165
|
+
version: 1,
|
|
166
|
+
manifest,
|
|
167
|
+
observations: observations.map((item) => ({
|
|
168
|
+
domain: item.domain,
|
|
169
|
+
fields: Object.fromEntries(item.fields.flatMap((candidate) => {
|
|
170
|
+
const mapping = {
|
|
171
|
+
registry_nameservers: 'nameservers',
|
|
172
|
+
delegation_ds: 'ds',
|
|
173
|
+
mail_exchangers: 'mx',
|
|
174
|
+
caa_policy: 'caa',
|
|
175
|
+
tls_certificate: 'tlsIssuer',
|
|
176
|
+
tls_public_key: 'tlsSpkiSha256',
|
|
177
|
+
registrar_lock: 'registrarLock',
|
|
178
|
+
};
|
|
179
|
+
const target = mapping[candidate.id];
|
|
180
|
+
if (!target)
|
|
181
|
+
return [];
|
|
182
|
+
const candidateValues = candidate.id === 'tls_certificate'
|
|
183
|
+
? list([record(record(record(latest.get(item.domain)?.availability).tls).certificate).issuer], (value) => {
|
|
184
|
+
const issuer = record(value);
|
|
185
|
+
const commonNames = Array.isArray(issuer.commonNames) ? issuer.commonNames : [];
|
|
186
|
+
const organisationNames = Array.isArray(issuer.organizationNames) ? issuer.organizationNames : [];
|
|
187
|
+
return text(commonNames[0] ?? organisationNames[0] ?? value, 300);
|
|
188
|
+
})
|
|
189
|
+
: candidate.values;
|
|
190
|
+
return [[target, { state: candidate.state, values: candidateValues, source: candidate.source, observedAt: item.observedAt }]];
|
|
191
|
+
})),
|
|
192
|
+
})),
|
|
193
|
+
}, generatedAt);
|
|
194
|
+
return Object.freeze({
|
|
195
|
+
schema: CLI_DOMAIN_CONTROL_REVIEW_SCHEMA,
|
|
196
|
+
version: CLI_DOMAIN_CONTROL_REVIEW_VERSION,
|
|
197
|
+
generatedAt,
|
|
198
|
+
review,
|
|
199
|
+
observations: Object.freeze(observations),
|
|
200
|
+
input: Object.freeze({
|
|
201
|
+
lookupsReceived: lookups.length,
|
|
202
|
+
latestDomainObservations: observations.length,
|
|
203
|
+
ignoredHistoricalLookups: lookups.length - observations.length,
|
|
204
|
+
}),
|
|
205
|
+
limitations: Object.freeze([
|
|
206
|
+
'The review uses only supplied saved Lookup documents and performs no request.',
|
|
207
|
+
'The newest supplied observation per domain is used for desired-state comparison; all supplied observations remain available to a separate flight-recorder review.',
|
|
208
|
+
'Raw RDAP, WHOIS, HTTP, TLS, and page payloads are not copied into this output.',
|
|
209
|
+
]),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
export function buildControlReviewInput(manifest, lookupDocuments) {
|
|
213
|
+
return Object.freeze({
|
|
214
|
+
schema: CLI_DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA,
|
|
215
|
+
version: 1,
|
|
216
|
+
manifest,
|
|
217
|
+
lookups: Object.freeze(lookupDocuments),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
export function formatCliDomainControlReview(document) {
|
|
221
|
+
const review = document.review;
|
|
222
|
+
return [
|
|
223
|
+
'Domain-control evidence review',
|
|
224
|
+
`State ${review.state}`,
|
|
225
|
+
`Domains ${review.domains.length}`,
|
|
226
|
+
`Lookups ${document.input.lookupsReceived}`,
|
|
227
|
+
`Drift ${review.counts.drift ?? 0}`,
|
|
228
|
+
`Incomplete ${(review.counts.partial ?? 0) + (review.counts.unavailable ?? 0) + (review.counts.unsupported ?? 0)}`,
|
|
229
|
+
'',
|
|
230
|
+
...review.domains.map((item) => `${item.domain} ${item.state}`),
|
|
231
|
+
'',
|
|
232
|
+
...document.limitations.map((item) => `Limitation: ${item}`),
|
|
233
|
+
'',
|
|
234
|
+
].join('\n');
|
|
235
|
+
}
|
|
@@ -5,6 +5,7 @@ export function parseInspectArchiveArguments(argv) {
|
|
|
5
5
|
let search = null;
|
|
6
6
|
let reveal = false;
|
|
7
7
|
let requireMatch = false;
|
|
8
|
+
let expectedContentDigest = null;
|
|
8
9
|
let output = 'terminal';
|
|
9
10
|
let quiet = false;
|
|
10
11
|
let color = true;
|
|
@@ -43,6 +44,15 @@ export function parseInspectArchiveArguments(argv) {
|
|
|
43
44
|
throw new CliUsageError('--require-match may be supplied only once.');
|
|
44
45
|
requireMatch = true;
|
|
45
46
|
}
|
|
47
|
+
else if (argument === '--expect-content-digest') {
|
|
48
|
+
if (expectedContentDigest !== null)
|
|
49
|
+
throw new CliUsageError('--expect-content-digest may be supplied only once.');
|
|
50
|
+
const value = argv[++index];
|
|
51
|
+
if (!value || !/^sha256:[a-f0-9]{64}$/u.test(value)) {
|
|
52
|
+
throw new CliUsageError('--expect-content-digest requires sha256 followed by 64 lowercase hexadecimal characters.');
|
|
53
|
+
}
|
|
54
|
+
expectedContentDigest = value;
|
|
55
|
+
}
|
|
46
56
|
else if (argument === '--quiet')
|
|
47
57
|
quiet = true;
|
|
48
58
|
else if (argument === '--no-color')
|
|
@@ -67,6 +77,7 @@ export function parseInspectArchiveArguments(argv) {
|
|
|
67
77
|
search,
|
|
68
78
|
reveal,
|
|
69
79
|
requireMatch,
|
|
80
|
+
expectedContentDigest,
|
|
70
81
|
output,
|
|
71
82
|
quiet,
|
|
72
83
|
color,
|
|
@@ -71,6 +71,7 @@ export async function runEvidenceCommand(args, dependencies) {
|
|
|
71
71
|
search: args.search,
|
|
72
72
|
reveal: args.reveal,
|
|
73
73
|
requireMatch: args.requireMatch,
|
|
74
|
+
expectedContentDigest: args.expectedContentDigest,
|
|
74
75
|
});
|
|
75
76
|
if (!args.quiet) {
|
|
76
77
|
dependencies.stdout.write(args.output === 'json'
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { domainToASCII } from 'node:url';
|
|
2
|
+
import { exactKeys, requireBoundedString, requireIsoTimestamp, requireRecord, } from "../lib/bounded-contract-normalizers.mjs";
|
|
3
|
+
import { EXTERNAL_FINDING_CATEGORIES, MAX_EXTERNAL_FINDINGS, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, MAX_EXTERNAL_FINDING_DOMAINS, parseExternalFindingsDocument, } from "../frontend/src/lib/analysis/external-findings-import.js";
|
|
4
|
+
export const EXTERNAL_OBSERVATION_MAPPING_SCHEMA = 'whoisleuth.external-observation-mapping';
|
|
5
|
+
export const EXTERNAL_OBSERVATION_MAPPING_VERSION = 1;
|
|
6
|
+
export const MAX_EXTERNAL_OBSERVATION_MAPPING_BYTES = 4 * 1024 * 1024;
|
|
7
|
+
export const MAX_EXTERNAL_OBSERVATION_RECORDS = 500;
|
|
8
|
+
const ROOT_KEYS = new Set(['schema', 'version', 'source', 'profile', 'records']);
|
|
9
|
+
const SOURCE_KEYS = new Set(['name', 'reference', 'collectedAt']);
|
|
10
|
+
const PROFILE_KEYS = new Set([
|
|
11
|
+
'id', 'version', 'domainField', 'summaryField', 'observedAtField', 'referenceField',
|
|
12
|
+
'completenessField', 'category', 'evidenceClass', 'limitations',
|
|
13
|
+
]);
|
|
14
|
+
const PATH_RE = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*){0,7}$/u;
|
|
15
|
+
const FORBIDDEN_PATH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
16
|
+
const COMPLETENESS = new Set(['complete', 'inconclusive', 'partial', 'unknown']);
|
|
17
|
+
const CATEGORIES = new Set(EXTERNAL_FINDING_CATEGORIES);
|
|
18
|
+
function optionalText(value, label, maximum) {
|
|
19
|
+
if (value === undefined || value === null || value === '')
|
|
20
|
+
return null;
|
|
21
|
+
return requireBoundedString(value, label, maximum);
|
|
22
|
+
}
|
|
23
|
+
function path(value, label, optional = false) {
|
|
24
|
+
if (optional && (value === undefined || value === null || value === ''))
|
|
25
|
+
return null;
|
|
26
|
+
const candidate = requireBoundedString(value, label, 200);
|
|
27
|
+
if (!PATH_RE.test(candidate) || candidate.split('.').some((segment) => FORBIDDEN_PATH_SEGMENTS.has(segment))) {
|
|
28
|
+
throw new TypeError(`${label} must be a safe bounded dotted field path.`);
|
|
29
|
+
}
|
|
30
|
+
return candidate;
|
|
31
|
+
}
|
|
32
|
+
function readPath(value, fieldPath) {
|
|
33
|
+
let current = value;
|
|
34
|
+
for (const segment of fieldPath.split('.')) {
|
|
35
|
+
if (!current || typeof current !== 'object' || Array.isArray(current))
|
|
36
|
+
return undefined;
|
|
37
|
+
current = current[segment];
|
|
38
|
+
}
|
|
39
|
+
return current;
|
|
40
|
+
}
|
|
41
|
+
function domain(value, label) {
|
|
42
|
+
const supplied = requireBoundedString(value, label, 253).trim().toLowerCase().replace(/^\*\./u, '').replace(/\.$/u, '');
|
|
43
|
+
const ascii = domainToASCII(supplied).toLowerCase();
|
|
44
|
+
if (!ascii || ascii.length > 253 || !ascii.includes('.') || ascii.split('.').some((part) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(part))) {
|
|
45
|
+
throw new TypeError(`${label} must resolve to a valid DNS name.`);
|
|
46
|
+
}
|
|
47
|
+
return ascii;
|
|
48
|
+
}
|
|
49
|
+
function limitations(value) {
|
|
50
|
+
if (value === undefined)
|
|
51
|
+
return Object.freeze([]);
|
|
52
|
+
if (!Array.isArray(value) || value.length > 8)
|
|
53
|
+
throw new TypeError('profile.limitations must contain no more than 8 entries.');
|
|
54
|
+
return Object.freeze([...new Set(value.map((entry, index) => requireBoundedString(entry, `profile.limitations[${index}]`, 240)))]);
|
|
55
|
+
}
|
|
56
|
+
export function mapExternalObservations(inputRaw) {
|
|
57
|
+
const input = requireRecord(inputRaw, 'External observation mapping');
|
|
58
|
+
if (input.schema !== EXTERNAL_OBSERVATION_MAPPING_SCHEMA || input.version !== EXTERNAL_OBSERVATION_MAPPING_VERSION) {
|
|
59
|
+
throw new TypeError(`External observation mapping must use ${EXTERNAL_OBSERVATION_MAPPING_SCHEMA} version ${EXTERNAL_OBSERVATION_MAPPING_VERSION}.`);
|
|
60
|
+
}
|
|
61
|
+
exactKeys(input, ROOT_KEYS, 'External observation mapping');
|
|
62
|
+
const sourceInput = requireRecord(input.source, 'source');
|
|
63
|
+
exactKeys(sourceInput, SOURCE_KEYS, 'source');
|
|
64
|
+
const profileInput = requireRecord(input.profile, 'profile');
|
|
65
|
+
exactKeys(profileInput, PROFILE_KEYS, 'profile');
|
|
66
|
+
const profile = Object.freeze({
|
|
67
|
+
id: requireBoundedString(profileInput.id, 'profile.id', 80),
|
|
68
|
+
version: Number(profileInput.version),
|
|
69
|
+
domainField: path(profileInput.domainField, 'profile.domainField'),
|
|
70
|
+
summaryField: path(profileInput.summaryField, 'profile.summaryField'),
|
|
71
|
+
observedAtField: path(profileInput.observedAtField, 'profile.observedAtField'),
|
|
72
|
+
referenceField: path(profileInput.referenceField, 'profile.referenceField', true),
|
|
73
|
+
completenessField: path(profileInput.completenessField, 'profile.completenessField', true),
|
|
74
|
+
category: profileInput.category,
|
|
75
|
+
evidenceClass: profileInput.evidenceClass,
|
|
76
|
+
limitations: limitations(profileInput.limitations),
|
|
77
|
+
});
|
|
78
|
+
if (!Number.isSafeInteger(profile.version) || profile.version < 1 || profile.version > 1_000)
|
|
79
|
+
throw new TypeError('profile.version is invalid.');
|
|
80
|
+
if (typeof profile.category !== 'string' || !CATEGORIES.has(profile.category))
|
|
81
|
+
throw new TypeError('profile.category is unsupported.');
|
|
82
|
+
if (profile.evidenceClass !== 'deployment_observation' && profile.evidenceClass !== 'provider_report')
|
|
83
|
+
throw new TypeError('profile.evidenceClass is unsupported.');
|
|
84
|
+
if (!Array.isArray(input.records) || input.records.length < 1 || input.records.length > MAX_EXTERNAL_OBSERVATION_RECORDS) {
|
|
85
|
+
throw new TypeError(`records must contain between 1 and ${MAX_EXTERNAL_OBSERVATION_RECORDS} entries.`);
|
|
86
|
+
}
|
|
87
|
+
const source = Object.freeze({
|
|
88
|
+
name: requireBoundedString(sourceInput.name, 'source.name', 80),
|
|
89
|
+
reference: optionalText(sourceInput.reference, 'source.reference', 500),
|
|
90
|
+
collectedAt: sourceInput.collectedAt === null || sourceInput.collectedAt === undefined
|
|
91
|
+
? null
|
|
92
|
+
: requireIsoTimestamp(sourceInput.collectedAt, 'source.collectedAt'),
|
|
93
|
+
});
|
|
94
|
+
const candidates = input.records.map((record, index) => {
|
|
95
|
+
const domainValue = domain(readPath(record, profile.domainField), `records[${index}].${profile.domainField}`);
|
|
96
|
+
const summary = requireBoundedString(readPath(record, profile.summaryField), `records[${index}].${profile.summaryField}`, 900);
|
|
97
|
+
const observedAt = requireIsoTimestamp(readPath(record, profile.observedAtField), `records[${index}].${profile.observedAtField}`);
|
|
98
|
+
const completenessValue = profile.completenessField ? readPath(record, profile.completenessField) : 'unknown';
|
|
99
|
+
if (typeof completenessValue !== 'string' || !COMPLETENESS.has(completenessValue)) {
|
|
100
|
+
throw new TypeError(`records[${index}] completeness is unsupported.`);
|
|
101
|
+
}
|
|
102
|
+
return Object.freeze({
|
|
103
|
+
domain: domainValue,
|
|
104
|
+
category: profile.category,
|
|
105
|
+
evidenceClass: profile.evidenceClass,
|
|
106
|
+
summary,
|
|
107
|
+
observedAt,
|
|
108
|
+
completeness: completenessValue,
|
|
109
|
+
limitations: profile.limitations,
|
|
110
|
+
reference: profile.referenceField
|
|
111
|
+
? optionalText(readPath(record, profile.referenceField), `records[${index}].${profile.referenceField}`, 500)
|
|
112
|
+
: null,
|
|
113
|
+
structuredObservation: null,
|
|
114
|
+
});
|
|
115
|
+
}).sort((left, right) => left.domain.localeCompare(right.domain) || left.observedAt.localeCompare(right.observedAt) || left.summary.localeCompare(right.summary));
|
|
116
|
+
const selected = [];
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
const domainCounts = new Map();
|
|
119
|
+
const domains = new Set();
|
|
120
|
+
for (const finding of candidates) {
|
|
121
|
+
const key = `${finding.domain}\u0000${finding.category}\u0000${finding.summary}\u0000${finding.observedAt}`;
|
|
122
|
+
if (seen.has(key))
|
|
123
|
+
continue;
|
|
124
|
+
seen.add(key);
|
|
125
|
+
if (!domains.has(finding.domain) && domains.size >= MAX_EXTERNAL_FINDING_DOMAINS)
|
|
126
|
+
continue;
|
|
127
|
+
const count = domainCounts.get(finding.domain) ?? 0;
|
|
128
|
+
if (count >= MAX_EXTERNAL_FINDINGS_PER_DOMAIN)
|
|
129
|
+
continue;
|
|
130
|
+
if (selected.length >= MAX_EXTERNAL_FINDINGS)
|
|
131
|
+
continue;
|
|
132
|
+
domains.add(finding.domain);
|
|
133
|
+
domainCounts.set(finding.domain, count + 1);
|
|
134
|
+
selected.push(finding);
|
|
135
|
+
}
|
|
136
|
+
const truncated = selected.length < seen.size;
|
|
137
|
+
const document = {
|
|
138
|
+
schema: 'whoisleuth.external-findings',
|
|
139
|
+
schemaVersion: 3,
|
|
140
|
+
source,
|
|
141
|
+
findings: selected.map((finding) => ({
|
|
142
|
+
...finding,
|
|
143
|
+
limitations: [
|
|
144
|
+
...finding.limitations,
|
|
145
|
+
`Mapped locally with declarative profile ${profile.id} version ${profile.version}; the profile does not verify the upstream field semantics.`,
|
|
146
|
+
...(truncated ? ['The supplied records exceeded the browser import boundary; only the first deterministic bounded findings were retained.'] : []),
|
|
147
|
+
].slice(0, 8),
|
|
148
|
+
})),
|
|
149
|
+
};
|
|
150
|
+
return parseExternalFindingsDocument(document);
|
|
151
|
+
}
|
|
152
|
+
export function formatExternalObservationMapping(document) {
|
|
153
|
+
return [
|
|
154
|
+
'External observation mapping',
|
|
155
|
+
`Source ${document.source.name}`,
|
|
156
|
+
`Findings ${document.findings.length}`,
|
|
157
|
+
`Domains ${new Set(document.findings.map((finding) => finding.domain)).size}`,
|
|
158
|
+
'',
|
|
159
|
+
'The JSON form can be deliberately imported through the Console external-findings workflow.',
|
|
160
|
+
'',
|
|
161
|
+
].join('\n');
|
|
162
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { lookupStrictExitFindings } from "./strict-exit.mjs";
|
|
2
|
+
import { CliUsageError } from "./errors.mjs";
|
|
3
|
+
export const CLI_FAIL_POLICIES = ['source-failure', 'inconclusive', 'danger', 'material-drift'];
|
|
4
|
+
function record(value) {
|
|
5
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
6
|
+
}
|
|
7
|
+
export function parseCliFailPolicies(value) {
|
|
8
|
+
if (typeof value !== 'string' || !value || value.length > 100)
|
|
9
|
+
throw new CliUsageError('--fail-on requires a comma-separated policy list.');
|
|
10
|
+
const policies = [...new Set(value.split(',').map((item) => item.trim().toLowerCase()).filter(Boolean))];
|
|
11
|
+
if (!policies.length || policies.some((item) => !CLI_FAIL_POLICIES.includes(item))) {
|
|
12
|
+
throw new CliUsageError(`--fail-on supports: ${CLI_FAIL_POLICIES.join(', ')}.`);
|
|
13
|
+
}
|
|
14
|
+
return policies;
|
|
15
|
+
}
|
|
16
|
+
function availabilityStates(document) {
|
|
17
|
+
const direct = String(record(document.availability).state || '').toLowerCase();
|
|
18
|
+
const results = Array.isArray(document.results) ? document.results : [];
|
|
19
|
+
const resultStates = results.flatMap((value) => {
|
|
20
|
+
const item = record(value);
|
|
21
|
+
const state = item.availabilityState ?? record(item.availability).state;
|
|
22
|
+
return typeof state === 'string' ? [state.toLowerCase()] : [];
|
|
23
|
+
});
|
|
24
|
+
return [direct, ...resultStates].filter(Boolean);
|
|
25
|
+
}
|
|
26
|
+
export function evaluateCliFailPolicies(document, policies) {
|
|
27
|
+
const root = record(document);
|
|
28
|
+
const findings = [];
|
|
29
|
+
for (const policy of policies) {
|
|
30
|
+
if (policy === 'source-failure') {
|
|
31
|
+
const resultDocuments = Array.isArray(root.results) ? root.results.map(record).filter((item) => Object.keys(record(item.diagnostics)).length) : [];
|
|
32
|
+
const sourceFindings = Object.keys(record(root.diagnostics)).length
|
|
33
|
+
? lookupStrictExitFindings(root)
|
|
34
|
+
: resultDocuments.flatMap((item) => lookupStrictExitFindings(item));
|
|
35
|
+
const collection = record(root.collection);
|
|
36
|
+
const summary = record(root.summary);
|
|
37
|
+
const failed = Number(collection.failed ?? summary.failed ?? 0);
|
|
38
|
+
if (sourceFindings.length || failed > 0)
|
|
39
|
+
findings.push({ policy, reason: `${sourceFindings.length + failed} failed or incomplete collection state(s)` });
|
|
40
|
+
}
|
|
41
|
+
else if (policy === 'inconclusive') {
|
|
42
|
+
const count = availabilityStates(root).filter((state) => ['unknown', 'inconclusive', 'partial', 'unavailable'].includes(state)).length;
|
|
43
|
+
if (count)
|
|
44
|
+
findings.push({ policy, reason: `${count} authority or availability state(s) remained inconclusive` });
|
|
45
|
+
}
|
|
46
|
+
else if (policy === 'danger') {
|
|
47
|
+
const risk = Number(record(root.risk).score ?? root.riskScore);
|
|
48
|
+
const severity = String(root.severity || record(root.review).severity || '').toLowerCase();
|
|
49
|
+
if ((Number.isFinite(risk) && risk >= 70) || severity === 'danger' || severity === 'critical') {
|
|
50
|
+
findings.push({ policy, reason: Number.isFinite(risk) ? `risk score ${risk} met the 70 review threshold` : `explicit ${severity} severity was present` });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const summary = record(record(root.flightRecorder).summary);
|
|
55
|
+
const unexpected = Number(summary.unexpectedChanges ?? 0);
|
|
56
|
+
if (unexpected > 0)
|
|
57
|
+
findings.push({ policy, reason: `${unexpected} unexpected complete observation change(s)` });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return findings;
|
|
61
|
+
}
|
|
62
|
+
export function formatFailPolicyNotice(findings) {
|
|
63
|
+
return `Failure policy matched: ${findings.map((item) => `${item.policy} (${item.reason})`).join('; ')}.\n`;
|
|
64
|
+
}
|
package/cli/formatters/html.mjs
CHANGED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { canonicalArtifactJson, sha256ArtifactDigest, } from "../frontend/src/lib/analysis/artifact-integrity.js";
|
|
4
|
+
import { recordOrNull, requireBoundedString, requireIsoTimestamp, } from "../lib/bounded-contract-normalizers.mjs";
|
|
5
|
+
import { normalizeBoundedSemanticVersion } from "../lib/semantic-version.mjs";
|
|
6
|
+
export const INVESTIGATION_MANIFEST_SCHEMA = 'whoisleuth.investigation-manifest';
|
|
7
|
+
export const INVESTIGATION_MANIFEST_VERSION = 1;
|
|
8
|
+
export const MAX_INVESTIGATION_MANIFEST_ARTIFACTS = 16;
|
|
9
|
+
export const MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES = 15 * 1024 * 1024;
|
|
10
|
+
export const MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES = 32 * 1024 * 1024;
|
|
11
|
+
const SHA256_RE = /^sha256:[a-f0-9]{64}$/u;
|
|
12
|
+
function digest(value) {
|
|
13
|
+
return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`;
|
|
14
|
+
}
|
|
15
|
+
function artifactVersion(value) {
|
|
16
|
+
const candidate = value.version ?? value.schemaVersion;
|
|
17
|
+
return Number.isSafeInteger(candidate) && Number(candidate) > 0 && Number(candidate) <= 1_000
|
|
18
|
+
? Number(candidate)
|
|
19
|
+
: null;
|
|
20
|
+
}
|
|
21
|
+
export async function buildInvestigationManifest(input, generatedAtValue, applicationVersionValue) {
|
|
22
|
+
if (!Array.isArray(input.artifacts)
|
|
23
|
+
|| input.artifacts.length < 1
|
|
24
|
+
|| input.artifacts.length > MAX_INVESTIGATION_MANIFEST_ARTIFACTS) {
|
|
25
|
+
throw new TypeError(`A manifest requires between 1 and ${MAX_INVESTIGATION_MANIFEST_ARTIFACTS} JSON artefacts.`);
|
|
26
|
+
}
|
|
27
|
+
const generatedAt = requireIsoTimestamp(generatedAtValue, 'generatedAt');
|
|
28
|
+
const workflow = requireBoundedString(input.workflow, 'workflow', 160);
|
|
29
|
+
const applicationVersion = normalizeBoundedSemanticVersion(applicationVersionValue, 'Application');
|
|
30
|
+
const configurationDigestSha256 = input.configurationDigestSha256;
|
|
31
|
+
if (configurationDigestSha256 !== null && !SHA256_RE.test(configurationDigestSha256)) {
|
|
32
|
+
throw new TypeError('configurationDigestSha256 must be a sha256: hexadecimal digest.');
|
|
33
|
+
}
|
|
34
|
+
let totalBytes = 0;
|
|
35
|
+
const artifacts = input.artifacts.map((artifact, index) => {
|
|
36
|
+
if (typeof artifact.content !== 'string')
|
|
37
|
+
throw new TypeError(`Artifact ${index + 1} must be UTF-8 JSON text.`);
|
|
38
|
+
const byteLength = Buffer.byteLength(artifact.content, 'utf8');
|
|
39
|
+
totalBytes += byteLength;
|
|
40
|
+
if (byteLength < 1 || byteLength > MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES) {
|
|
41
|
+
throw new TypeError(`Artifact ${index + 1} must be between 1 byte and ${MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES} bytes.`);
|
|
42
|
+
}
|
|
43
|
+
let parsed;
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(artifact.content);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new TypeError(`Artifact ${index + 1} must be valid JSON.`);
|
|
49
|
+
}
|
|
50
|
+
const value = recordOrNull(parsed);
|
|
51
|
+
if (!value)
|
|
52
|
+
throw new TypeError(`Artifact ${index + 1} must contain one JSON object.`);
|
|
53
|
+
const schema = value.schema === undefined
|
|
54
|
+
? null
|
|
55
|
+
: requireBoundedString(value.schema, `Artifact ${index + 1} schema`, 160);
|
|
56
|
+
const canonical = canonicalArtifactJson(value);
|
|
57
|
+
const contentDigestSha256 = digest(artifact.content);
|
|
58
|
+
return Object.freeze({
|
|
59
|
+
sequence: index + 1,
|
|
60
|
+
id: `artifact-${index + 1}`,
|
|
61
|
+
schema,
|
|
62
|
+
version: artifactVersion(value),
|
|
63
|
+
byteLength,
|
|
64
|
+
contentDigestSha256,
|
|
65
|
+
canonicalDigestSha256: digest(canonical),
|
|
66
|
+
});
|
|
67
|
+
});
|
|
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
|
+
const unsigned = Object.freeze({
|
|
72
|
+
schema: INVESTIGATION_MANIFEST_SCHEMA,
|
|
73
|
+
version: INVESTIGATION_MANIFEST_VERSION,
|
|
74
|
+
generatedAt,
|
|
75
|
+
application: Object.freeze({ name: 'WHOISleuth CLI', version: applicationVersion }),
|
|
76
|
+
workflow,
|
|
77
|
+
configuration: Object.freeze({ digestSha256: configurationDigestSha256 }),
|
|
78
|
+
artifacts: Object.freeze(artifacts),
|
|
79
|
+
steps: Object.freeze(artifacts.map((artifact) => Object.freeze({
|
|
80
|
+
sequence: artifact.sequence,
|
|
81
|
+
artifactId: artifact.id,
|
|
82
|
+
contentDigestSha256: artifact.contentDigestSha256,
|
|
83
|
+
}))),
|
|
84
|
+
summary: Object.freeze({ artifactCount: artifacts.length, totalBytes }),
|
|
85
|
+
limitations: Object.freeze([
|
|
86
|
+
'The manifest records ordered content digests, schema metadata, tool version, and an optional configuration digest; it deliberately omits source file paths and content values.',
|
|
87
|
+
'Reproduction still depends on access to the exact input artefacts, compatible source services, and any separately retained configuration represented by the supplied digest.',
|
|
88
|
+
'Matching digests establish content identity, not the accuracy, provenance, or current validity of an observation.',
|
|
89
|
+
]),
|
|
90
|
+
});
|
|
91
|
+
return Object.freeze({
|
|
92
|
+
...unsigned,
|
|
93
|
+
integrity: Object.freeze({ algorithm: 'SHA-256', digestSha256: await sha256ArtifactDigest(unsigned) }),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
export function formatInvestigationManifest(manifest) {
|
|
97
|
+
const lines = [
|
|
98
|
+
'Investigation manifest',
|
|
99
|
+
`Workflow ${manifest.workflow}`,
|
|
100
|
+
`Tool version ${manifest.application.version}`,
|
|
101
|
+
`Artifacts ${manifest.summary.artifactCount}`,
|
|
102
|
+
`Total bytes ${manifest.summary.totalBytes}`,
|
|
103
|
+
`Configuration ${manifest.configuration.digestSha256 ?? 'not supplied'}`,
|
|
104
|
+
`Integrity ${manifest.integrity.digestSha256}`,
|
|
105
|
+
'',
|
|
106
|
+
'Ordered artefacts:',
|
|
107
|
+
];
|
|
108
|
+
for (const artifact of manifest.artifacts) {
|
|
109
|
+
lines.push(` ${artifact.sequence}. ${artifact.schema ?? 'unversioned JSON'}${artifact.version ? ` v${artifact.version}` : ''} · ${artifact.contentDigestSha256}`);
|
|
110
|
+
}
|
|
111
|
+
lines.push('', 'No source file paths or artefact contents are retained in this manifest.', '');
|
|
112
|
+
return lines.join('\n');
|
|
113
|
+
}
|