@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,8 +1,11 @@
|
|
|
1
1
|
import { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, MAX_CASES, MAX_NOTES_PER_CASE, deterministicId, isoOrNull, normalizeCase, normalizeDomain, normalizeEvidenceHistory, normalizeNotes, normalizeReviewReasonCode, normalizeTags, objectRecord, safeId, } from "./case-record-model.js";
|
|
2
|
+
import { inspectCaseBrandProfileIds, unionCaseBrandProfileIds, } from "./case-brand-profile-references.js";
|
|
3
|
+
import { caseInvestigationBranchReferences, mergeCaseInvestigationBranches, normalizeCaseInvestigationBranches, } from "./case-investigation-branch-model.js";
|
|
2
4
|
import { mergeCaseActions, mergeCaseAssertions, mergeCaseDecisions, mergeCaseEvidencePins, mergeCaseManualTrail, mergeCaseSightings, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseSightings, } from "./case-response-model.js";
|
|
3
5
|
const STATUS_VALUES = new Set(CASE_STATUSES.map((item) => item.value));
|
|
4
6
|
const DISPOSITION_VALUES = new Set(CASE_DISPOSITIONS.map((item) => item.value));
|
|
5
7
|
const SOURCE_VALUES = new Set(CASE_SOURCES.map((item) => item.value));
|
|
8
|
+
export const MAX_CASE_INPUT_RECORDS = 2_000;
|
|
6
9
|
function assignUniqueIds(cases) {
|
|
7
10
|
const used = new Set();
|
|
8
11
|
for (const record of [...cases].sort((a, b) => a.domain.localeCompare(b.domain))) {
|
|
@@ -28,11 +31,16 @@ function assignUniqueIds(cases) {
|
|
|
28
31
|
*/
|
|
29
32
|
export function normalizeCaseStore(raw) {
|
|
30
33
|
const now = new Date().toISOString();
|
|
34
|
+
const sourceVersion = parseStoreVersion(raw);
|
|
35
|
+
const acceptsBrandProfileIds = Array.isArray(raw)
|
|
36
|
+
|| sourceVersion === CASE_SCHEMA_VERSION;
|
|
31
37
|
const byDomain = new Map();
|
|
32
|
-
for (const item of
|
|
33
|
-
const normalized = normalizeCase(item, undefined, now);
|
|
38
|
+
for (const item of boundedCaseList(raw).items) {
|
|
39
|
+
const normalized = normalizeCase(item, undefined, now, Array.isArray(raw) ? CASE_SCHEMA_VERSION : sourceVersion);
|
|
34
40
|
if (!normalized)
|
|
35
41
|
continue;
|
|
42
|
+
if (!acceptsBrandProfileIds)
|
|
43
|
+
normalized.brandProfileIds = [];
|
|
36
44
|
const existing = byDomain.get(normalized.domain);
|
|
37
45
|
if (!existing || Date.parse(normalized.updatedAt) >= Date.parse(existing.updatedAt)) {
|
|
38
46
|
byDomain.set(normalized.domain, normalized);
|
|
@@ -64,6 +72,13 @@ function asCaseList(raw) {
|
|
|
64
72
|
return cases;
|
|
65
73
|
return [];
|
|
66
74
|
}
|
|
75
|
+
function boundedCaseList(raw) {
|
|
76
|
+
const source = asCaseList(raw);
|
|
77
|
+
return {
|
|
78
|
+
items: source.slice(0, MAX_CASE_INPUT_RECORDS),
|
|
79
|
+
omitted: Math.max(0, source.length - MAX_CASE_INPUT_RECORDS),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
67
82
|
/**
|
|
68
83
|
* @param {{ domain: unknown, status?: unknown, disposition?: unknown, source?: unknown, tags?: unknown, evidence?: unknown, note?: unknown }} input
|
|
69
84
|
* @param {string} [nowIso]
|
|
@@ -82,7 +97,7 @@ function importScalar(value, valid) {
|
|
|
82
97
|
* @param {unknown} raw
|
|
83
98
|
* @returns {ImportPatch | null}
|
|
84
99
|
*/
|
|
85
|
-
function extractImportPatch(raw) {
|
|
100
|
+
function extractImportPatch(raw, importedVersion) {
|
|
86
101
|
const record = objectRecord(raw);
|
|
87
102
|
const domain = normalizeDomain(record.domain);
|
|
88
103
|
if (!domain)
|
|
@@ -90,8 +105,16 @@ function extractImportPatch(raw) {
|
|
|
90
105
|
const importFallback = isoOrNull(record.updatedAt) || isoOrNull(record.createdAt) || null;
|
|
91
106
|
const normalizedFallback = importFallback || '1970-01-01T00:00:00.000Z';
|
|
92
107
|
const rawEvidence = Array.isArray(record.evidenceHistory) ? record.evidenceHistory : [];
|
|
93
|
-
const evidencePins = normalizeCaseEvidencePins(record.evidencePins, normalizedFallback
|
|
108
|
+
const evidencePins = normalizeCaseEvidencePins(record.evidencePins, normalizedFallback, {
|
|
109
|
+
allowCertificateObservation: importedVersion >= 11,
|
|
110
|
+
});
|
|
94
111
|
const pinIds = new Set(evidencePins.map((item) => item.id));
|
|
112
|
+
const actions = normalizeCaseActions(record.actions, normalizedFallback);
|
|
113
|
+
const assertions = normalizeCaseAssertions(record.assertions, normalizedFallback, pinIds);
|
|
114
|
+
const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
|
|
115
|
+
const brandProfileReferences = importedVersion >= 12
|
|
116
|
+
? inspectCaseBrandProfileIds(record.brandProfileIds)
|
|
117
|
+
: { ids: [], omitted: 0 };
|
|
95
118
|
return {
|
|
96
119
|
domain,
|
|
97
120
|
rawId: typeof record.id === 'string' ? record.id : null,
|
|
@@ -100,14 +123,23 @@ function extractImportPatch(raw) {
|
|
|
100
123
|
reviewReasonCode: Object.hasOwn(record, 'reviewReasonCode')
|
|
101
124
|
? normalizeReviewReasonCode(record.reviewReasonCode)
|
|
102
125
|
: undefined,
|
|
126
|
+
brandProfileIds: brandProfileReferences.ids,
|
|
127
|
+
brandProfileReferencesOmitted: brandProfileReferences.omitted,
|
|
103
128
|
source: importScalar(record.source, SOURCE_VALUES),
|
|
104
|
-
evidenceHistory: normalizeEvidenceHistory(rawEvidence, {
|
|
129
|
+
evidenceHistory: normalizeEvidenceHistory(rawEvidence, {
|
|
130
|
+
source: 'import',
|
|
131
|
+
fallback: importFallback,
|
|
132
|
+
sourceVersion: importedVersion,
|
|
133
|
+
}),
|
|
105
134
|
evidencePins,
|
|
106
135
|
decisions: normalizeCaseDecisions(record.decisions, normalizedFallback, pinIds),
|
|
107
|
-
actions
|
|
108
|
-
assertions
|
|
136
|
+
actions,
|
|
137
|
+
assertions,
|
|
109
138
|
manualTrail: normalizeCaseManualTrail(record.manualTrail, normalizedFallback),
|
|
110
139
|
sightings: normalizeCaseSightings(record.sightings, normalizedFallback, pinIds),
|
|
140
|
+
branches: importedVersion >= 11
|
|
141
|
+
? normalizeCaseInvestigationBranches(record.branches, normalizedFallback, branchReferences)
|
|
142
|
+
: [],
|
|
111
143
|
tags: normalizeTags(record.tags),
|
|
112
144
|
// Imported notes fall back only to the imported record's own timestamps
|
|
113
145
|
// (never "now"), so a timestamp-less note gets a stable, deterministic time
|
|
@@ -143,6 +175,7 @@ function caseFromPatch(patch, now) {
|
|
|
143
175
|
status: patch.status ?? DEFAULT_STATUS,
|
|
144
176
|
disposition: patch.disposition ?? DEFAULT_DISPOSITION,
|
|
145
177
|
reviewReasonCode: patch.reviewReasonCode ?? null,
|
|
178
|
+
brandProfileIds: patch.brandProfileIds,
|
|
146
179
|
tags: patch.tags,
|
|
147
180
|
notes: patch.notes,
|
|
148
181
|
source: patch.source ?? DEFAULT_SOURCE,
|
|
@@ -153,6 +186,7 @@ function caseFromPatch(patch, now) {
|
|
|
153
186
|
assertions: patch.assertions,
|
|
154
187
|
manualTrail: patch.manualTrail,
|
|
155
188
|
sightings: patch.sightings,
|
|
189
|
+
branches: patch.branches,
|
|
156
190
|
createdAt: patch.createdAt || patch.updatedAt || now,
|
|
157
191
|
updatedAt: patch.updatedAt || patch.createdAt || now,
|
|
158
192
|
};
|
|
@@ -171,24 +205,30 @@ function applyImportPatch(local, patch) {
|
|
|
171
205
|
const fallback = patch.updatedAt || local.updatedAt;
|
|
172
206
|
const evidencePins = mergeCaseEvidencePins(local.evidencePins, patch.evidencePins, fallback);
|
|
173
207
|
const pinIds = new Set(evidencePins.map((item) => item.id));
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
208
|
+
const actions = mergeCaseActions(local.actions, patch.actions, fallback);
|
|
209
|
+
const assertions = mergeCaseAssertions(local.assertions, patch.assertions, fallback, pinIds);
|
|
210
|
+
const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
|
|
211
|
+
const brandProfileReferences = unionCaseBrandProfileIds(local.brandProfileIds, patch.brandProfileIds);
|
|
212
|
+
return { record: {
|
|
213
|
+
...local,
|
|
214
|
+
status: patch.status !== undefined && importNewer ? patch.status : local.status,
|
|
215
|
+
disposition: patch.disposition !== undefined && importNewer ? patch.disposition : local.disposition,
|
|
216
|
+
reviewReasonCode: patch.reviewReasonCode !== undefined && importNewer ? patch.reviewReasonCode : local.reviewReasonCode ?? null,
|
|
217
|
+
brandProfileIds: brandProfileReferences.ids,
|
|
218
|
+
source: patch.source !== undefined && importNewer ? patch.source : local.source,
|
|
219
|
+
evidenceHistory: mergeEvidenceHistories(local.evidenceHistory, patch.evidenceHistory),
|
|
220
|
+
evidencePins,
|
|
221
|
+
decisions: mergeCaseDecisions(local.decisions, patch.decisions, fallback, pinIds),
|
|
222
|
+
actions,
|
|
223
|
+
assertions,
|
|
224
|
+
manualTrail: mergeCaseManualTrail(local.manualTrail, patch.manualTrail, fallback),
|
|
225
|
+
sightings: mergeCaseSightings(local.sightings, patch.sightings, fallback, pinIds),
|
|
226
|
+
branches: mergeCaseInvestigationBranches(local.branches ?? [], patch.branches, fallback, branchReferences),
|
|
227
|
+
tags: normalizeTags([...local.tags, ...patch.tags]),
|
|
228
|
+
notes: unionNotes(local.notes, patch.notes),
|
|
229
|
+
createdAt: patch.createdAt && Date.parse(patch.createdAt) < Date.parse(local.createdAt) ? patch.createdAt : local.createdAt,
|
|
230
|
+
updatedAt: importNewer ? (patch.updatedAt ?? local.updatedAt) : local.updatedAt,
|
|
231
|
+
}, brandProfileReferencesOmitted: brandProfileReferences.omitted };
|
|
192
232
|
}
|
|
193
233
|
function pickFreeId(preferred, domain, used) {
|
|
194
234
|
const wanted = safeId(preferred);
|
|
@@ -208,7 +248,7 @@ function pickFreeId(preferred, domain, used) {
|
|
|
208
248
|
* reinterpreted.
|
|
209
249
|
* @param {CaseRecord[]} localCases
|
|
210
250
|
* @param {unknown} importedRaw
|
|
211
|
-
* @returns {{ cases: CaseRecord[], added: number, updated: number, skipped: number }}
|
|
251
|
+
* @returns {{ cases: CaseRecord[], added: number, updated: number, skipped: number, brandProfileReferencesOmitted: number }}
|
|
212
252
|
*/
|
|
213
253
|
export function mergeCases(localCases, importedRaw) {
|
|
214
254
|
const importedVersion = parseStoreVersion(importedRaw);
|
|
@@ -221,21 +261,26 @@ export function mergeCases(localCases, importedRaw) {
|
|
|
221
261
|
throw new Error(`Expected a WHOISleuth case export using schema ${CASE_IMPORT_VERSIONS.join(' or ')}.`);
|
|
222
262
|
}
|
|
223
263
|
const local = normalizeCaseStore(localCases).cases;
|
|
264
|
+
const supportedImportedVersion = importedVersion ?? 0;
|
|
224
265
|
const byDomain = new Map(local.map((item) => [item.domain, item]));
|
|
225
266
|
const usedIds = new Set(local.map((item) => item.id));
|
|
226
267
|
let added = 0;
|
|
227
268
|
let updated = 0;
|
|
228
|
-
|
|
269
|
+
const imported = boundedCaseList(importedRaw);
|
|
270
|
+
let skipped = imported.omitted;
|
|
271
|
+
let brandProfileReferencesOmitted = 0;
|
|
229
272
|
const now = new Date().toISOString();
|
|
230
|
-
for (const item of
|
|
231
|
-
const patch = extractImportPatch(item);
|
|
273
|
+
for (const item of imported.items) {
|
|
274
|
+
const patch = extractImportPatch(item, supportedImportedVersion);
|
|
232
275
|
if (!patch) {
|
|
233
276
|
skipped += 1;
|
|
234
277
|
continue;
|
|
235
278
|
}
|
|
236
279
|
const existing = byDomain.get(patch.domain);
|
|
237
280
|
if (existing) {
|
|
238
|
-
|
|
281
|
+
const merged = applyImportPatch(existing, patch);
|
|
282
|
+
byDomain.set(patch.domain, merged.record);
|
|
283
|
+
brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted + merged.brandProfileReferencesOmitted;
|
|
239
284
|
updated += 1;
|
|
240
285
|
}
|
|
241
286
|
else if (byDomain.size < MAX_CASES) {
|
|
@@ -243,11 +288,18 @@ export function mergeCases(localCases, importedRaw) {
|
|
|
243
288
|
record.id = pickFreeId(patch.rawId, patch.domain, usedIds);
|
|
244
289
|
usedIds.add(record.id);
|
|
245
290
|
byDomain.set(patch.domain, record);
|
|
291
|
+
brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted;
|
|
246
292
|
added += 1;
|
|
247
293
|
}
|
|
248
294
|
else {
|
|
249
295
|
skipped += 1;
|
|
250
296
|
}
|
|
251
297
|
}
|
|
252
|
-
return {
|
|
298
|
+
return {
|
|
299
|
+
cases: normalizeCaseStore([...byDomain.values()]).cases,
|
|
300
|
+
added,
|
|
301
|
+
updated,
|
|
302
|
+
skipped,
|
|
303
|
+
brandProfileReferencesOmitted,
|
|
304
|
+
};
|
|
253
305
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ANALYST_REVIEW_REASONS } from "../../../../lib/analyst-taxonomy.mjs";
|
|
2
|
-
export const CASE_SCHEMA_VERSION =
|
|
3
|
-
export const CASE_IMPORT_VERSIONS = [3, 4, 5, 6, 7, 8, 9, CASE_SCHEMA_VERSION];
|
|
2
|
+
export const CASE_SCHEMA_VERSION = 12;
|
|
3
|
+
export const CASE_IMPORT_VERSIONS = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, CASE_SCHEMA_VERSION];
|
|
4
4
|
export const MAX_CASES = 500;
|
|
5
5
|
export const MAX_NOTES_PER_CASE = 50;
|
|
6
6
|
export const MAX_NOTE_LENGTH = 2000;
|
|
@@ -17,6 +17,8 @@ export const MAX_EVIDENCE_TITLE_LENGTH = 200;
|
|
|
17
17
|
export const MAX_EVIDENCE_DETAIL_LENGTH = 200;
|
|
18
18
|
export const MAX_EVIDENCE_CHANGES = 40;
|
|
19
19
|
export const MAX_CASE_STORE_BYTES = 4 * 1024 * 1024;
|
|
20
|
+
export const MAX_CASE_BRAND_PROFILE_IDS = 8;
|
|
21
|
+
export const MAX_CASE_BRAND_PROFILE_ID_CANDIDATES = 32;
|
|
20
22
|
export const CASE_STATUSES = [
|
|
21
23
|
{ value: 'new', label: 'New' },
|
|
22
24
|
{ value: 'reviewing', label: 'Reviewing' },
|
|
@@ -3,5 +3,7 @@
|
|
|
3
3
|
// testable while existing consumers keep one compatibility import.
|
|
4
4
|
export * from "./case-record-contracts.js";
|
|
5
5
|
export * from "./case-record-core.js";
|
|
6
|
+
export * from "./case-brand-profile-references.js";
|
|
6
7
|
export * from "./case-evidence-model.js";
|
|
7
8
|
export * from "./case-record-operations.js";
|
|
9
|
+
export * from "./case-investigation-branch-model.js";
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
import { appendCaseAction, appendCaseAssertion, appendCaseDecision, appendCaseEvidencePin, appendCaseEvidencePins, appendCaseManualTrailEvent, appendCaseSighting, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseSightings, updateCaseAction, updateCaseAssertion, } from "./case-response-model.js";
|
|
4
4
|
import { MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.js";
|
|
5
5
|
import { DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_SET, deterministicId, isoOrNow, makeId, normalizeDisposition, normalizeDomain, normalizeNoteBody, normalizeReviewReasonCode, normalizeNotes, normalizeSource, normalizeStatus, normalizeTags, objectRecord, safeId, } from "./case-record-core.js";
|
|
6
|
+
import { assertCaseBrandProfileIds, normalizeCaseBrandProfileIds, } from "./case-brand-profile-references.js";
|
|
6
7
|
import { normalizeEvidenceHistory, } from "./case-evidence-model.js";
|
|
8
|
+
import { appendCaseInvestigationBranch, caseInvestigationBranchReferences, normalizeCaseInvestigationBranches, updateCaseInvestigationBranch, } from "./case-investigation-branch-model.js";
|
|
7
9
|
// ---------------------------------------------------------------------------
|
|
8
10
|
// Case normalization
|
|
9
11
|
// ---------------------------------------------------------------------------
|
|
@@ -16,10 +18,14 @@ function inferCaptureSource(caseSource) {
|
|
|
16
18
|
}
|
|
17
19
|
// Builds a case's bounded current-schema evidence history. Uses a lenient
|
|
18
20
|
// local fallback timestamp so recoverable current data always loads.
|
|
19
|
-
function normalizeCaseEvidence(record, createdAt, updatedAt, now) {
|
|
21
|
+
function normalizeCaseEvidence(record, createdAt, updatedAt, now, sourceVersion) {
|
|
20
22
|
const localFallback = updatedAt || createdAt || now;
|
|
21
23
|
if (Array.isArray(record.evidenceHistory)) {
|
|
22
|
-
return normalizeEvidenceHistory(record.evidenceHistory, {
|
|
24
|
+
return normalizeEvidenceHistory(record.evidenceHistory, {
|
|
25
|
+
source: DEFAULT_EVIDENCE_SOURCE,
|
|
26
|
+
fallback: localFallback,
|
|
27
|
+
...(sourceVersion === undefined ? {} : { sourceVersion }),
|
|
28
|
+
});
|
|
23
29
|
}
|
|
24
30
|
return [];
|
|
25
31
|
}
|
|
@@ -34,7 +40,7 @@ function normalizeCaseEvidence(record, createdAt, updatedAt, now) {
|
|
|
34
40
|
* @param {string} [nowIso]
|
|
35
41
|
* @returns {CaseRecord | null}
|
|
36
42
|
*/
|
|
37
|
-
export function normalizeCase(raw, existing, nowIso) {
|
|
43
|
+
export function normalizeCase(raw, existing, nowIso, sourceVersion) {
|
|
38
44
|
const now = nowIso || new Date().toISOString();
|
|
39
45
|
const record = objectRecord(raw);
|
|
40
46
|
const domain = normalizeDomain(existing ? existing.domain : record.domain);
|
|
@@ -44,22 +50,27 @@ export function normalizeCase(raw, existing, nowIso) {
|
|
|
44
50
|
const updatedAt = isoOrNow(record.updatedAt, now);
|
|
45
51
|
const evidencePins = normalizeCaseEvidencePins(record.evidencePins, updatedAt);
|
|
46
52
|
const pinIds = new Set(evidencePins.map((item) => item.id));
|
|
53
|
+
const actions = normalizeCaseActions(record.actions, updatedAt);
|
|
54
|
+
const assertions = normalizeCaseAssertions(record.assertions, updatedAt, pinIds);
|
|
55
|
+
const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
|
|
47
56
|
return {
|
|
48
57
|
id: existing ? existing.id : safeId(record.id) || deterministicId(domain),
|
|
49
58
|
domain,
|
|
50
59
|
status: normalizeStatus(record.status),
|
|
51
60
|
disposition: normalizeDisposition(record.disposition),
|
|
52
61
|
reviewReasonCode: normalizeReviewReasonCode(record.reviewReasonCode),
|
|
62
|
+
brandProfileIds: normalizeCaseBrandProfileIds(record.brandProfileIds),
|
|
53
63
|
tags: normalizeTags(record.tags),
|
|
54
64
|
notes: normalizeNotes(record.notes, now),
|
|
55
65
|
source: normalizeSource(record.source),
|
|
56
|
-
evidenceHistory: normalizeCaseEvidence(record, createdAt, updatedAt, now),
|
|
66
|
+
evidenceHistory: normalizeCaseEvidence(record, createdAt, updatedAt, now, sourceVersion),
|
|
57
67
|
evidencePins,
|
|
58
68
|
decisions: normalizeCaseDecisions(record.decisions, updatedAt, pinIds),
|
|
59
|
-
actions
|
|
60
|
-
assertions
|
|
69
|
+
actions,
|
|
70
|
+
assertions,
|
|
61
71
|
manualTrail: normalizeCaseManualTrail(record.manualTrail, updatedAt),
|
|
62
72
|
sightings: normalizeCaseSightings(record.sightings, updatedAt, pinIds),
|
|
73
|
+
branches: normalizeCaseInvestigationBranches(record.branches, updatedAt, branchReferences),
|
|
63
74
|
createdAt,
|
|
64
75
|
updatedAt,
|
|
65
76
|
};
|
|
@@ -82,12 +93,20 @@ export function createCase(input, nowIso) {
|
|
|
82
93
|
? appendCaseEvidencePin([], input.evidencePin, now)
|
|
83
94
|
: [];
|
|
84
95
|
const pinIds = new Set(evidencePins.map((item) => item.id));
|
|
96
|
+
const actions = input.action !== undefined
|
|
97
|
+
? appendCaseAction([], input.action, now)
|
|
98
|
+
: [];
|
|
99
|
+
const assertions = input.assertion !== undefined
|
|
100
|
+
? appendCaseAssertion([], input.assertion, now)
|
|
101
|
+
: [];
|
|
102
|
+
const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
|
|
85
103
|
return {
|
|
86
104
|
id: makeId(),
|
|
87
105
|
domain,
|
|
88
106
|
status: normalizeStatus(input.status),
|
|
89
107
|
disposition: normalizeDisposition(input.disposition),
|
|
90
108
|
reviewReasonCode: normalizeReviewReasonCode(input.reviewReasonCode),
|
|
109
|
+
brandProfileIds: input.brandProfileIds === undefined ? [] : assertCaseBrandProfileIds(input.brandProfileIds),
|
|
91
110
|
tags: normalizeTags(input.tags),
|
|
92
111
|
notes: noteBody ? [{ id: makeId(), body: noteBody, createdAt: now }] : [],
|
|
93
112
|
source,
|
|
@@ -99,18 +118,17 @@ export function createCase(input, nowIso) {
|
|
|
99
118
|
decisions: input.decision !== undefined
|
|
100
119
|
? appendCaseDecision([], input.decision, now)
|
|
101
120
|
: [],
|
|
102
|
-
actions
|
|
103
|
-
|
|
104
|
-
: [],
|
|
105
|
-
assertions: input.assertion !== undefined
|
|
106
|
-
? appendCaseAssertion([], input.assertion, now)
|
|
107
|
-
: [],
|
|
121
|
+
actions,
|
|
122
|
+
assertions,
|
|
108
123
|
manualTrail: input.trailEvent !== undefined
|
|
109
124
|
? appendCaseManualTrailEvent([], input.trailEvent, now)
|
|
110
125
|
: [],
|
|
111
126
|
sightings: input.sighting !== undefined
|
|
112
127
|
? appendCaseSighting([], input.sighting, now, pinIds)
|
|
113
128
|
: [],
|
|
129
|
+
branches: input.branch !== undefined
|
|
130
|
+
? appendCaseInvestigationBranch([], input.branch, now, branchReferences)
|
|
131
|
+
: [],
|
|
114
132
|
createdAt: now,
|
|
115
133
|
updatedAt: now,
|
|
116
134
|
};
|
|
@@ -196,6 +214,14 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
196
214
|
if (patch.assertionUpdate !== undefined) {
|
|
197
215
|
assertions = updateCaseAssertion(assertions, patch.assertionUpdate, now, pinIds);
|
|
198
216
|
}
|
|
217
|
+
const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
|
|
218
|
+
let branches = normalizeCaseInvestigationBranches(current.branches ?? [], current.updatedAt, branchReferences);
|
|
219
|
+
if (patch.branch !== undefined) {
|
|
220
|
+
branches = appendCaseInvestigationBranch(branches, patch.branch, now, branchReferences);
|
|
221
|
+
}
|
|
222
|
+
if (patch.branchUpdate !== undefined) {
|
|
223
|
+
branches = updateCaseInvestigationBranch(branches, patch.branchUpdate, now, branchReferences);
|
|
224
|
+
}
|
|
199
225
|
let manualTrail = current.manualTrail;
|
|
200
226
|
if (patch.trailEvent !== undefined) {
|
|
201
227
|
manualTrail = appendCaseManualTrailEvent(current.manualTrail, patch.trailEvent, now);
|
|
@@ -211,6 +237,9 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
211
237
|
reviewReasonCode: patch.reviewReasonCode !== undefined
|
|
212
238
|
? normalizeReviewReasonCode(patch.reviewReasonCode)
|
|
213
239
|
: current.reviewReasonCode ?? null,
|
|
240
|
+
brandProfileIds: patch.brandProfileIds !== undefined
|
|
241
|
+
? assertCaseBrandProfileIds(patch.brandProfileIds)
|
|
242
|
+
: current.brandProfileIds,
|
|
214
243
|
tags: patch.tags !== undefined ? normalizeTags(patch.tags) : current.tags,
|
|
215
244
|
source,
|
|
216
245
|
evidenceHistory,
|
|
@@ -220,6 +249,7 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
220
249
|
assertions,
|
|
221
250
|
manualTrail,
|
|
222
251
|
sightings,
|
|
252
|
+
branches,
|
|
223
253
|
notes,
|
|
224
254
|
updatedAt: now,
|
|
225
255
|
};
|
|
@@ -11,19 +11,21 @@
|
|
|
11
11
|
import { caseEvidenceIncomparableReasons, compareCaseEvidence, latestCaseEvidence } from "./case-model.js";
|
|
12
12
|
import { httpSecurityHeaderLabel } from "./http-summary.js";
|
|
13
13
|
import { analystInteroperabilityTags } from "../../../../lib/analyst-taxonomy.mjs";
|
|
14
|
+
import { buildPortableGeneratorMetadata, portableGeneratorAttribution, } from "../../../../lib/portable-generator.mjs";
|
|
14
15
|
import { CASE_EVIDENCE_RELATION_STANCES } from "./case-response-model.js";
|
|
16
|
+
import { normalizeCaseBrandProfileIds } from "./case-brand-profile-references.js";
|
|
15
17
|
// ---------------------------------------------------------------------------
|
|
16
18
|
// Constants
|
|
17
19
|
// ---------------------------------------------------------------------------
|
|
18
20
|
export const CASE_REPORT_SCHEMA = 'whoisleuth.case-report';
|
|
19
|
-
export const CASE_REPORT_SCHEMA_VERSION =
|
|
20
|
-
const APPLICATION_NAME = 'WHOISleuth';
|
|
21
|
+
export const CASE_REPORT_SCHEMA_VERSION = 8;
|
|
21
22
|
const LIMITATIONS_TEXT = [
|
|
22
23
|
'This report contains normalised browser-local observations from WHOISleuth analyst cases.',
|
|
23
24
|
'It is not a live lookup and does not contain raw WHOIS, RDAP, DNS, HTML, or responses collected during website checks.',
|
|
24
25
|
'Absence of a signal (e.g. no MX record observed) does not prove nonexistence. It may not have been evaluated.',
|
|
25
26
|
'Snapshot fingerprints are deduplication identifiers, not cryptographic evidence hashes.',
|
|
26
27
|
'Scan-depth and scoring-model gates prevent misleading comparisons; "incomparable" means observations differ materially but one or more fields cannot be compared reliably.',
|
|
28
|
+
'Brand Profile references record an explicit analyst-selected association only; they do not establish ownership, attribution, intent, safety, or maliciousness.',
|
|
27
29
|
'Generated locally in the browser. Review the package before sharing it.',
|
|
28
30
|
].join(' ');
|
|
29
31
|
function escapeMarkdownInline(text) {
|
|
@@ -139,6 +141,8 @@ function pickKnownSnapshotFields(snapshot) {
|
|
|
139
141
|
idnReferenceMatch: snapshot.idnReferenceMatch ?? null,
|
|
140
142
|
pageBaselineMatch: snapshot.pageBaselineMatch ?? null,
|
|
141
143
|
hasActiveBrandProfile: snapshot.hasActiveBrandProfile ?? null,
|
|
144
|
+
profileContextState: snapshot.profileContextState ?? null,
|
|
145
|
+
profileContextLimitation: snapshot.profileContextLimitation ?? null,
|
|
142
146
|
mutationTypes: Array.isArray(snapshot.mutationTypes) ? [...snapshot.mutationTypes] : [],
|
|
143
147
|
};
|
|
144
148
|
}
|
|
@@ -158,6 +162,7 @@ export function buildCaseReport(caseRecord, options = {}) {
|
|
|
158
162
|
const { generatedAt } = options;
|
|
159
163
|
const includeNotes = options.includeNotes === true;
|
|
160
164
|
const now = generatedAt || new Date().toISOString();
|
|
165
|
+
const generator = buildPortableGeneratorMetadata(options.applicationVersion);
|
|
161
166
|
// --- Build JSON report ---
|
|
162
167
|
const timelineEntries = [];
|
|
163
168
|
if (Array.isArray(caseRecord.evidenceHistory) && caseRecord.evidenceHistory.length > 0) {
|
|
@@ -207,13 +212,14 @@ export function buildCaseReport(caseRecord, options = {}) {
|
|
|
207
212
|
schema: CASE_REPORT_SCHEMA,
|
|
208
213
|
schemaVersion: CASE_REPORT_SCHEMA_VERSION,
|
|
209
214
|
generatedAt: now,
|
|
210
|
-
application:
|
|
215
|
+
application: generator,
|
|
211
216
|
case: {
|
|
212
217
|
id: caseRecord.id,
|
|
213
218
|
domain: caseRecord.domain,
|
|
214
219
|
status: caseRecord.status,
|
|
215
220
|
disposition: caseRecord.disposition,
|
|
216
221
|
reviewReasonCode: caseRecord.reviewReasonCode ?? null,
|
|
222
|
+
brandProfileIds: normalizeCaseBrandProfileIds(caseRecord.brandProfileIds),
|
|
217
223
|
interoperabilityTags: analystInteroperabilityTags(caseRecord.disposition, caseRecord.reviewReasonCode),
|
|
218
224
|
tags: Array.isArray(caseRecord.tags) ? [...caseRecord.tags] : [],
|
|
219
225
|
source: caseRecord.source,
|
|
@@ -242,11 +248,18 @@ export function buildCaseReport(caseRecord, options = {}) {
|
|
|
242
248
|
})),
|
|
243
249
|
manualTrail: caseRecord.manualTrail.map((item) => ({ ...item })),
|
|
244
250
|
sightings: caseRecord.sightings.map((item) => ({ ...item, limitations: [...item.limitations] })),
|
|
251
|
+
branches: (caseRecord.branches ?? []).map((item) => ({
|
|
252
|
+
...item,
|
|
253
|
+
evidencePinIds: [...item.evidencePinIds],
|
|
254
|
+
checkpointIds: [...item.checkpointIds],
|
|
255
|
+
assertionIds: [...item.assertionIds],
|
|
256
|
+
actionIds: [...item.actionIds],
|
|
257
|
+
})),
|
|
245
258
|
},
|
|
246
259
|
limitations: LIMITATIONS_TEXT,
|
|
247
260
|
};
|
|
248
261
|
// --- Build Markdown ---
|
|
249
|
-
const md = buildMarkdown(json);
|
|
262
|
+
const md = buildMarkdown(json, options.includeAttribution !== false);
|
|
250
263
|
return { json, markdown: md };
|
|
251
264
|
}
|
|
252
265
|
// ---------------------------------------------------------------------------
|
|
@@ -257,7 +270,7 @@ export function buildCaseReport(caseRecord, options = {}) {
|
|
|
257
270
|
* @param {object} report
|
|
258
271
|
* @returns {string}
|
|
259
272
|
*/
|
|
260
|
-
function buildMarkdown(report) {
|
|
273
|
+
function buildMarkdown(report, includeAttribution) {
|
|
261
274
|
const lines = [];
|
|
262
275
|
// Title
|
|
263
276
|
const domain = escapeMarkdownInline(report.case.domain || 'unknown');
|
|
@@ -276,6 +289,7 @@ function buildMarkdown(report) {
|
|
|
276
289
|
lines.push(`| Disposition | ${escapeMarkdownInline(report.case.disposition)} |`);
|
|
277
290
|
if (report.case.reviewReasonCode)
|
|
278
291
|
lines.push(`| Review reason | ${escapeMarkdownInline(report.case.reviewReasonCode.replaceAll('_', ' '))} |`);
|
|
292
|
+
lines.push(`| Brand Profile references | ${report.case.brandProfileIds.length ? escapeMarkdownInline(report.case.brandProfileIds.join(', ')) : 'None'} |`);
|
|
279
293
|
lines.push(`| Source | ${escapeMarkdownInline(report.case.source)} |`);
|
|
280
294
|
lines.push(`| Opened | ${escapeMarkdownInline(report.case.openedAt)} |`);
|
|
281
295
|
lines.push(`| Updated | ${escapeMarkdownInline(report.case.updatedAt)} |`);
|
|
@@ -292,6 +306,9 @@ function buildMarkdown(report) {
|
|
|
292
306
|
lines.push(`- **Availability:** ${escapeMarkdownInline(formatReportValue(a.availability))}`);
|
|
293
307
|
lines.push(`- **Risk score:** ${escapeMarkdownInline(formatReportValue(a.riskScore))}`);
|
|
294
308
|
lines.push(`- **Risk model:** ${a.riskModelVersion === null ? 'Unversioned' : `v${escapeMarkdownInline(formatReportValue(a.riskModelVersion))}`}`);
|
|
309
|
+
lines.push(`- **Brand Profile context:** ${escapeMarkdownInline(formatReportValue(a.profileContextState))}`);
|
|
310
|
+
if (a.profileContextLimitation)
|
|
311
|
+
lines.push(`- **Profile-context limitation:** ${escapeMarkdownInline(a.profileContextLimitation)}`);
|
|
295
312
|
lines.push(`- **Opportunity model:** ${a.opportunityModelVersion == null ? 'Unversioned' : `v${escapeMarkdownInline(formatReportValue(a.opportunityModelVersion))}`}`);
|
|
296
313
|
lines.push(`- **Registrar:** ${escapeMarkdownInline(formatReportValue(a.registrar))}`);
|
|
297
314
|
lines.push(`- **Website activity:** ${escapeMarkdownInline(formatReportValue(a.activityStatus))}`);
|
|
@@ -342,6 +359,9 @@ function buildMarkdown(report) {
|
|
|
342
359
|
lines.push(`- Availability: ${escapeMarkdownInline(formatReportValue(snap.availability))}`);
|
|
343
360
|
lines.push(`- Risk score: ${escapeMarkdownInline(formatReportValue(snap.riskScore))}`);
|
|
344
361
|
lines.push(`- Risk model: ${snap.riskModelVersion === null ? 'Unversioned' : `v${escapeMarkdownInline(formatReportValue(snap.riskModelVersion))}`}`);
|
|
362
|
+
lines.push(`- Brand Profile context: ${escapeMarkdownInline(formatReportValue(snap.profileContextState))}`);
|
|
363
|
+
if (snap.profileContextLimitation)
|
|
364
|
+
lines.push(`- Profile-context limitation: ${escapeMarkdownInline(snap.profileContextLimitation)}`);
|
|
345
365
|
lines.push(`- Registrar: ${escapeMarkdownInline(formatReportValue(snap.registrar))}`);
|
|
346
366
|
lines.push(`- Website activity: ${escapeMarkdownInline(formatReportValue(snap.activityStatus))}`);
|
|
347
367
|
if (snap.httpResponseStatus != null)
|
|
@@ -411,7 +431,7 @@ function buildMarkdown(report) {
|
|
|
411
431
|
lines.push('## Analyst Decision Packet');
|
|
412
432
|
lines.push('');
|
|
413
433
|
const response = report.analystResponse;
|
|
414
|
-
if (!response.evidencePins.length && !response.sightings.length && !response.decisions.length && !response.actions.length && !response.assertions.length && !response.manualTrail.length) {
|
|
434
|
+
if (!response.evidencePins.length && !response.sightings.length && !response.decisions.length && !response.actions.length && !response.assertions.length && !response.manualTrail.length && !response.branches.length) {
|
|
415
435
|
lines.push('No evidence pins, source-qualified sightings, structured assertions, decision records, case actions, or manual investigation steps recorded.');
|
|
416
436
|
lines.push('');
|
|
417
437
|
}
|
|
@@ -511,6 +531,15 @@ function buildMarkdown(report) {
|
|
|
511
531
|
lines.push(` Contact limitations: ${escapeMarkdownInline(action.contactLimitations.join('; '))}`);
|
|
512
532
|
}
|
|
513
533
|
lines.push('');
|
|
534
|
+
lines.push('### Investigation branches');
|
|
535
|
+
lines.push('');
|
|
536
|
+
if (!response.branches.length)
|
|
537
|
+
lines.push('No named investigation branches recorded.');
|
|
538
|
+
for (const branch of response.branches) {
|
|
539
|
+
lines.push(`- **${escapeMarkdownInline(branch.name)}** (${escapeMarkdownInline(branch.state)}; updated ${escapeMarkdownInline(branch.updatedAt)})`);
|
|
540
|
+
lines.push(` References: ${branch.evidencePinIds.length} evidence pins; ${branch.checkpointIds.length} checkpoints; ${branch.assertionIds.length} assertions; ${branch.actionIds.length} actions.`);
|
|
541
|
+
}
|
|
542
|
+
lines.push('');
|
|
514
543
|
lines.push('### Explicit investigation trail');
|
|
515
544
|
lines.push('');
|
|
516
545
|
if (!response.manualTrail.length)
|
|
@@ -542,6 +571,10 @@ function buildMarkdown(report) {
|
|
|
542
571
|
lines.push('');
|
|
543
572
|
lines.push(LIMITATIONS_TEXT);
|
|
544
573
|
lines.push('');
|
|
574
|
+
if (includeAttribution) {
|
|
575
|
+
lines.push(portableGeneratorAttribution(report.application));
|
|
576
|
+
lines.push('');
|
|
577
|
+
}
|
|
545
578
|
return lines.join('\n');
|
|
546
579
|
}
|
|
547
580
|
// ---------------------------------------------------------------------------
|
|
@@ -89,6 +89,7 @@ const ASSERTION_EXTERNAL_ENTITY_TYPES = new Set(CASE_ASSERTION_EXTERNAL_ENTITY_T
|
|
|
89
89
|
const TRAIL_KINDS = new Set(CASE_MANUAL_TRAIL_KINDS);
|
|
90
90
|
const SIGHTING_STATES = new Set(CASE_SIGHTING_STATES);
|
|
91
91
|
const SIGHTING_CATEGORIES = new Set(CASE_SIGHTING_CATEGORIES);
|
|
92
|
+
const SHA256_RE = /^[a-f0-9]{64}$/u;
|
|
92
93
|
function record(value) {
|
|
93
94
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
94
95
|
? value
|
|
@@ -159,6 +160,54 @@ function sourceSchema(value) {
|
|
|
159
160
|
: null;
|
|
160
161
|
return collection && schema && version !== null ? { collection, schema, version } : null;
|
|
161
162
|
}
|
|
163
|
+
function certificateObservation(value, pin) {
|
|
164
|
+
const item = record(value);
|
|
165
|
+
if (!Object.keys(item).length)
|
|
166
|
+
return null;
|
|
167
|
+
const keys = new Set([
|
|
168
|
+
'eventId',
|
|
169
|
+
'logId',
|
|
170
|
+
'certificateSha256',
|
|
171
|
+
'issuer',
|
|
172
|
+
'notAfter',
|
|
173
|
+
'dnsNameCount',
|
|
174
|
+
'namesComplete',
|
|
175
|
+
]);
|
|
176
|
+
if (Object.keys(item).some((key) => !keys.has(key)))
|
|
177
|
+
return null;
|
|
178
|
+
if (pin.sourceSchema?.collection !== 'external_observations'
|
|
179
|
+
|| pin.sourceSchema.schema !== 'whoisleuth.certificate-observation-rows'
|
|
180
|
+
|| (pin.field !== 'certificateSha256' && pin.field !== 'fingerprintSha256'))
|
|
181
|
+
return null;
|
|
182
|
+
const eventId = text(item.eventId, 64);
|
|
183
|
+
const logId = text(item.logId, 200);
|
|
184
|
+
const certificateSha256 = text(item.certificateSha256, 64).toLowerCase();
|
|
185
|
+
const issuer = item.issuer === null ? null : text(item.issuer, 160) || null;
|
|
186
|
+
const notAfter = item.notAfter === null ? null : optionalIso(item.notAfter);
|
|
187
|
+
const dnsNameCount = typeof item.dnsNameCount === 'number'
|
|
188
|
+
&& Number.isSafeInteger(item.dnsNameCount)
|
|
189
|
+
&& item.dnsNameCount >= 1
|
|
190
|
+
&& item.dnsNameCount <= 100
|
|
191
|
+
? item.dnsNameCount
|
|
192
|
+
: null;
|
|
193
|
+
if (!SAFE_ID_RE.test(eventId)
|
|
194
|
+
|| !logId
|
|
195
|
+
|| !SHA256_RE.test(certificateSha256)
|
|
196
|
+
|| certificateSha256 !== pin.value.toLowerCase()
|
|
197
|
+
|| dnsNameCount === null
|
|
198
|
+
|| typeof item.namesComplete !== 'boolean'
|
|
199
|
+
|| (item.notAfter !== null && notAfter === null))
|
|
200
|
+
return null;
|
|
201
|
+
return {
|
|
202
|
+
eventId,
|
|
203
|
+
logId,
|
|
204
|
+
certificateSha256,
|
|
205
|
+
issuer,
|
|
206
|
+
notAfter,
|
|
207
|
+
dnsNameCount,
|
|
208
|
+
namesComplete: item.namesComplete,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
162
211
|
function uniqueIds(value, validIds) {
|
|
163
212
|
if (!Array.isArray(value))
|
|
164
213
|
return [];
|
|
@@ -196,25 +245,27 @@ function normalizeEvidenceRelations(value, legacyIds, validIds) {
|
|
|
196
245
|
}
|
|
197
246
|
return [...output].map(([evidencePinId, stance]) => ({ evidencePinId, stance }));
|
|
198
247
|
}
|
|
199
|
-
function normalizePin(raw, fallback) {
|
|
248
|
+
function normalizePin(raw, fallback, options = {}) {
|
|
200
249
|
const item = record(raw);
|
|
201
250
|
const label = text(item.label, MAX_RESPONSE_LABEL_LENGTH);
|
|
202
251
|
const value = text(item.value, MAX_RESPONSE_VALUE_LENGTH);
|
|
203
252
|
if (!label || !value)
|
|
204
253
|
return null;
|
|
205
254
|
const createdAt = iso(item.createdAt, fallback);
|
|
206
|
-
|
|
255
|
+
const normalizedSourceSchema = sourceSchema(item.sourceSchema);
|
|
256
|
+
const normalizedField = text(item.field, 120) || null;
|
|
257
|
+
const normalized = {
|
|
207
258
|
id: safeId(item.id, 'pin', { label, value, createdAt }),
|
|
208
259
|
checkpointId: typeof item.checkpointId === 'string' && SAFE_ID_RE.test(item.checkpointId)
|
|
209
260
|
? item.checkpointId
|
|
210
261
|
: null,
|
|
211
|
-
field:
|
|
262
|
+
field: normalizedField,
|
|
212
263
|
category: text(item.category, 80) || null,
|
|
213
264
|
label,
|
|
214
265
|
value,
|
|
215
266
|
source: text(item.source, MAX_RESPONSE_LABEL_LENGTH) || 'analyst_selected',
|
|
216
267
|
sourceState: text(item.sourceState, 40) || null,
|
|
217
|
-
sourceSchema:
|
|
268
|
+
sourceSchema: normalizedSourceSchema,
|
|
218
269
|
observedAt: iso(item.observedAt, createdAt),
|
|
219
270
|
collectionDepth: item.collectionDepth === 'deep' || item.collectionDepth === 'fast'
|
|
220
271
|
? item.collectionDepth
|
|
@@ -230,13 +281,21 @@ function normalizePin(raw, fallback) {
|
|
|
230
281
|
limitations: limitations(item.limitations),
|
|
231
282
|
createdAt,
|
|
232
283
|
};
|
|
233
|
-
|
|
234
|
-
|
|
284
|
+
normalized.certificateObservation = options.allowCertificateObservation === false
|
|
285
|
+
? null
|
|
286
|
+
: certificateObservation(item.certificateObservation, {
|
|
287
|
+
field: normalizedField,
|
|
288
|
+
value,
|
|
289
|
+
sourceSchema: normalizedSourceSchema,
|
|
290
|
+
});
|
|
291
|
+
return normalized;
|
|
292
|
+
}
|
|
293
|
+
export function normalizeCaseEvidencePins(raw, fallback, options = {}) {
|
|
235
294
|
if (!Array.isArray(raw))
|
|
236
295
|
return [];
|
|
237
296
|
const byId = new Map();
|
|
238
297
|
for (const item of raw.slice(0, MAX_CASE_EVIDENCE_PINS * 2)) {
|
|
239
|
-
const normalized = normalizePin(item, fallback);
|
|
298
|
+
const normalized = normalizePin(item, fallback, options);
|
|
240
299
|
if (normalized && !byId.has(normalized.id))
|
|
241
300
|
byId.set(normalized.id, normalized);
|
|
242
301
|
}
|