@slicedearth/whoisleuth-cli 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +2 -1
  2. package/cli/arguments.mjs +389 -1745
  3. package/cli/artifact-validation/case-response.mjs +55 -30
  4. package/cli/artifact-validation/signed-review.mjs +30 -12
  5. package/cli/bulk-checkpoint.mjs +34 -3
  6. package/cli/case-pack.mjs +39 -92
  7. package/cli/command-argument-grammar.mjs +211 -0
  8. package/cli/command-reference.mjs +200 -213
  9. package/cli/completion.mjs +5 -1
  10. package/cli/discovery-observation-snapshot.mjs +16 -3
  11. package/cli/discovery-scan-command-runner.mjs +19 -0
  12. package/cli/discovery-scan.mjs +6 -0
  13. package/cli/domain-control-observations.mjs +1 -1
  14. package/cli/inline-command-families.mjs +1 -0
  15. package/cli/input.mjs +116 -0
  16. package/cli/interchange-report.mjs +14 -0
  17. package/cli/lookalike-calibration.mjs +7 -2
  18. package/cli/lookup-brief.mjs +4 -2
  19. package/cli/lookup-browser-view.mjs +1 -1
  20. package/cli/mail-header-review.mjs +467 -0
  21. package/cli/review-command-runner.mjs +25 -0
  22. package/cli/risk-calibration.mjs +19 -17
  23. package/cli/runner.mjs +7 -1
  24. package/docs/cli-reference.md +8 -1
  25. package/docs/cli.md +24 -1
  26. package/lib/bounded-json.mjs +9 -6
  27. package/lib/dns-convergence-review.mjs +1 -0
  28. package/lib/dns-intelligence.mjs +37 -3
  29. package/lib/dnssec-chain-validation.mjs +6 -0
  30. package/lib/domain-change-packet.mjs +42 -14
  31. package/lib/domain-control-flight-recorder.mjs +4 -1
  32. package/lib/domain-control-manifest.mjs +16 -16
  33. package/lib/domain-posture-parsers.mjs +114 -3
  34. package/lib/domain-posture.mjs +34 -3
  35. package/lib/evidence-export.mjs +63 -13
  36. package/lib/evidence-report.mjs +2 -2
  37. package/lib/html-signals.mjs +39 -33
  38. package/lib/local-geoip-evidence.mjs +19 -3
  39. package/lib/lookup-response.mjs +5 -4
  40. package/lib/lookup-source-progress.mjs +27 -4
  41. package/lib/nameserver-preflight-review.mjs +1 -0
  42. package/lib/rdap-capabilities.mjs +1 -1
  43. package/lib/registry-insights.mjs +35 -22
  44. package/lib/risk-calibration-summary.mjs +1 -3
  45. package/lib/security-txt.mjs +33 -1
  46. package/lib/static-html-analysis.mjs +69 -13
  47. package/lib/website-technology.mjs +79 -11
  48. package/lib/zone-intent-review.mjs +16 -6
  49. package/package.json +1 -1
  50. package/packages/cases/case-migration-model.mjs +71 -22
  51. package/packages/cases/case-record-contracts.mjs +5 -20
  52. package/packages/cases/case-record-core.mjs +10 -21
  53. package/packages/cases/case-record-decisions.mjs +72 -0
  54. package/packages/cases/case-record-model.mjs +1 -0
  55. package/packages/cases/case-record-operations.mjs +240 -3
  56. package/packages/cases/case-record-projection.mjs +218 -0
  57. package/packages/cases/case-report.mjs +11 -6
  58. package/packages/cases/case-response-model.mjs +43 -6
  59. package/packages/cases/case-response-packet.mjs +78 -27
  60. package/packages/cases/case-storage-model.mjs +6 -2
  61. package/packages/comparison/comparison-ledger-bulk.mjs +9 -10
  62. package/packages/comparison/page-similarity.mjs +3 -1
  63. package/packages/contracts/case-portability.mjs +183 -53
  64. package/packages/contracts/cli-command-semantics.mjs +93 -0
  65. package/packages/contracts/risk-calibration.mjs +15 -10
  66. package/packages/contracts/workspace-portability.mjs +44 -7
  67. package/packages/evidence/domain-control-runtime.mjs +46 -6
  68. package/packages/interchange/external-findings-import.mjs +115 -46
  69. package/packages/investigation/lookup-asset-graph.mjs +35 -27
  70. package/packages/workspace/brand-profile-model.mjs +118 -2
  71. package/packages/workspace/bulk-review-model.mjs +13 -7
  72. package/packages/workspace/bulk-session-model.mjs +5 -4
  73. package/packages/workspace/detection-rule-model.mjs +70 -22
  74. package/packages/workspace/relationship-observation-model.mjs +4 -1
  75. package/packages/workspace/website-snapshot-model.mjs +106 -8
  76. package/packages/workspace/workspace-archive.mjs +2 -0
  77. package/cli/evidence-command-arguments.mjs +0 -156
@@ -1,11 +1,103 @@
1
1
  // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
2
  // record normalization, and analyst updates.
3
+ import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
3
4
  import { appendCaseAction, appendCaseAssertion, appendCaseClosure, appendCaseDecision, appendCaseEvidencePin, appendCaseEvidencePins, appendCaseManualTrailEvent, appendCaseObservedEffectReview, appendCaseSighting, buildCaseClosureLinkContext, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseClosureHistory, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseObservedEffectHistory, normalizeCaseSightings, updateCaseAction, updateCaseAssertion, } from "./case-response-model.mjs";
4
5
  import { CASE_SCHEMA_VERSION, MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.mjs";
6
+ import { caseDispositionSupportsDefensiveResponse, caseStatusIsClosed, caseStatusOptionsForDirectEdit, caseStatusRequiresClosure, isReviewedCaseDisposition, } from "./case-record-decisions.mjs";
5
7
  import { DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_SET, caseTimestampOrNull, deterministicId, makeId, normalizeDisposition, normalizeDomain, normalizeNoteBody, normalizeReviewReasonCode, normalizeNotes, normalizeSource, normalizeStatus, normalizeTags, objectRecord, safeId, } from "./case-record-core.mjs";
6
8
  import { assertCaseBrandProfileIds, normalizeCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
7
9
  import { normalizeEvidenceHistory, } from "./case-evidence-model.mjs";
8
10
  import { appendCaseInvestigationBranch, caseInvestigationBranchReferences, normalizeCaseInvestigationBranches, updateCaseInvestigationBranch, } from "./case-investigation-branch-model.mjs";
11
+ export { caseDispositionSupportsDefensiveResponse, caseStatusIsClosed, caseStatusOptionsForDirectEdit, caseStatusRequiresClosure, isReviewedCaseDisposition, };
12
+ export const MAX_CASE_OBJECTIVE_LENGTH = 320;
13
+ export const MAX_CASE_INCIDENT_URL_LENGTH = 1_850;
14
+ export const INCIDENT_CONTEXT_STATEMENT_PREFIX = 'Investigate incident URL: ';
15
+ const OBJECTIVE_PREFIX = 'Objective: ';
16
+ const RETENTION_SEPARATOR = ' | URL retained: ';
17
+ function boundedContextText(value, maximum) {
18
+ return typeof value === 'string'
19
+ ? value.replace(/[\u0000-\u001f\u007f]/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum)
20
+ : '';
21
+ }
22
+ export function normalizeCaseObjective(value) {
23
+ return boundedContextText(value, MAX_CASE_OBJECTIVE_LENGTH);
24
+ }
25
+ export function parseIncidentUrlContext(value) {
26
+ if (typeof value !== 'string' || !value || value.length > MAX_CASE_INCIDENT_URL_LENGTH)
27
+ return null;
28
+ if (/[\u0000-\u001f\u007f]/u.test(value) || value.trim() !== value)
29
+ return null;
30
+ let parsed;
31
+ try {
32
+ parsed = new URL(value);
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || !parsed.hostname)
38
+ return null;
39
+ const hostname = parsed.hostname.toLowerCase().replace(/\.$/u, '');
40
+ const registrableDomain = canonicalRegistrableDomain(hostname);
41
+ if (!registrableDomain)
42
+ return null;
43
+ const exactUrl = parsed.toString();
44
+ if (exactUrl.length > MAX_CASE_INCIDENT_URL_LENGTH)
45
+ return null;
46
+ return Object.freeze({
47
+ exactUrl,
48
+ hostname,
49
+ registrableDomain,
50
+ originUrl: parsed.origin,
51
+ hasPath: parsed.pathname !== '/',
52
+ hasQuery: Boolean(parsed.search),
53
+ hasFragment: Boolean(parsed.hash),
54
+ });
55
+ }
56
+ export function caseInvestigationContext(record) {
57
+ if (!record)
58
+ return null;
59
+ for (const assertion of [...record.assertions].reverse()) {
60
+ if (assertion.kind !== 'next_step' || assertion.state !== 'open'
61
+ || !assertion.statement.startsWith(INCIDENT_CONTEXT_STATEMENT_PREFIX))
62
+ continue;
63
+ const incidentUrl = assertion.statement.slice(INCIDENT_CONTEXT_STATEMENT_PREFIX.length);
64
+ const parsed = parseIncidentUrlContext(incidentUrl);
65
+ if (!parsed || parsed.registrableDomain !== record.domain)
66
+ continue;
67
+ const rationale = assertion.rationale ?? '';
68
+ const separatorIndex = rationale.lastIndexOf(RETENTION_SEPARATOR);
69
+ const objective = separatorIndex > OBJECTIVE_PREFIX.length && rationale.startsWith(OBJECTIVE_PREFIX)
70
+ ? normalizeCaseObjective(rationale.slice(OBJECTIVE_PREFIX.length, separatorIndex))
71
+ : '';
72
+ const retention = separatorIndex >= 0 ? rationale.slice(separatorIndex + RETENTION_SEPARATOR.length) : '';
73
+ if (!objective || (retention !== 'exact' && retention !== 'origin_only'))
74
+ continue;
75
+ return Object.freeze({
76
+ objective,
77
+ incidentUrl: parsed.exactUrl,
78
+ urlRetention: retention,
79
+ assertionId: assertion.id,
80
+ updatedAt: assertion.updatedAt,
81
+ });
82
+ }
83
+ return null;
84
+ }
85
+ export function caseInvestigationContextAssertion(input) {
86
+ const objective = normalizeCaseObjective(input.objective);
87
+ if (!objective)
88
+ throw new Error('Enter the investigation objective before retaining Incident context.');
89
+ const parsed = parseIncidentUrlContext(input.incidentUrl);
90
+ if (!parsed)
91
+ throw new Error(`Enter one absolute HTTP(S) Incident URL of at most ${MAX_CASE_INCIDENT_URL_LENGTH} characters without credentials.`);
92
+ const retention = input.retainExactUrl ? 'exact' : 'origin_only';
93
+ const retainedUrl = input.retainExactUrl ? parsed.exactUrl : parsed.originUrl;
94
+ return Object.freeze({
95
+ statement: `${INCIDENT_CONTEXT_STATEMENT_PREFIX}${retainedUrl}`,
96
+ rationale: `${OBJECTIVE_PREFIX}${objective}${RETENTION_SEPARATOR}${retention}`,
97
+ retainedUrl,
98
+ retention,
99
+ });
100
+ }
9
101
  // ---------------------------------------------------------------------------
10
102
  // Case normalization
11
103
  // ---------------------------------------------------------------------------
@@ -66,7 +158,7 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
66
158
  return {
67
159
  id: existing ? existing.id : safeId(record.id) || deterministicId(domain),
68
160
  domain,
69
- status: normalizedStatus === 'resolved'
161
+ status: caseStatusRequiresClosure(normalizedStatus)
70
162
  && closures.records.length === 0 && !closures.preV13HistoryUnavailable
71
163
  ? 'reviewing'
72
164
  : normalizedStatus,
@@ -124,7 +216,7 @@ export function createCase(input, nowIso) {
124
216
  const closures = input.closure !== undefined
125
217
  ? appendCaseClosure(normalizeCaseClosureHistory(undefined, now), input.closure, now, observedEffects, actions)
126
218
  : normalizeCaseClosureHistory(undefined, now);
127
- if (normalizeStatus(input.status) === 'resolved' && input.closure === undefined) {
219
+ if (caseStatusRequiresClosure(normalizeStatus(input.status)) && input.closure === undefined) {
128
220
  throw new Error('Opening a resolved case requires a deliberate closure reason and its linked review context.');
129
221
  }
130
222
  return {
@@ -266,7 +358,7 @@ export function updateCase(cases, id, patch, nowIso) {
266
358
  if (patch.closure !== undefined) {
267
359
  closures = appendCaseClosure(closures, patch.closure, now, observedEffects, actions);
268
360
  }
269
- if (patch.status === 'resolved' && patch.closure === undefined) {
361
+ if (caseStatusRequiresClosure(patch.status) && patch.closure === undefined) {
270
362
  throw new Error('Resolve this case through the deliberate closure review so the reason and evidence state remain explicit.');
271
363
  }
272
364
  const record = {
@@ -300,3 +392,148 @@ export function updateCase(cases, id, patch, nowIso) {
300
392
  next[index] = record;
301
393
  return { cases: next, record };
302
394
  }
395
+ /**
396
+ * Records one reviewed conclusion as a single pure Case mutation. The selected
397
+ * observations become bounded Case pins before the decision is created, so the
398
+ * decision cannot point at absent evidence. Counterevidence is retained as a
399
+ * separate resolved contradiction assertion rather than being hidden inside a
400
+ * favourable disposition.
401
+ */
402
+ export function recordCaseConclusion(cases, id, input, nowIso) {
403
+ const now = caseTimestampOrNull(nowIso) || new Date().toISOString();
404
+ const disposition = normalizeDisposition(input.disposition);
405
+ const reviewReasonCode = normalizeReviewReasonCode(input.reviewReasonCode);
406
+ if (!isReviewedCaseDisposition(disposition)) {
407
+ throw new Error('Select a reviewed disposition before recording a conclusion.');
408
+ }
409
+ if (!reviewReasonCode) {
410
+ throw new Error('Select the reviewed reason before recording a conclusion.');
411
+ }
412
+ if (!Array.isArray(input.evidence) || !input.evidence.length) {
413
+ throw new Error('Select at least one observed fact for this conclusion.');
414
+ }
415
+ const invalidStance = input.evidence.find((item) => (!item || !['supports', 'contradicts', 'unresolved'].includes(item.stance)));
416
+ if (invalidStance)
417
+ throw new Error('A conclusion evidence relationship is invalid.');
418
+ if (!input.evidence.some((item) => item.stance === 'supports')) {
419
+ throw new Error('A conclusion requires at least one observed fact that supports it. Record only contradictory or unresolved material as an assertion instead.');
420
+ }
421
+ const current = cases.find((item) => item.id === id);
422
+ if (!current)
423
+ throw new Error('That case no longer exists.');
424
+ const existingPinIds = new Set(current.evidencePins.map((pin) => pin.id));
425
+ const withPins = updateCase(cases, id, {
426
+ evidencePins: input.evidence.map((item) => item.pin),
427
+ }, now);
428
+ const addedPins = withPins.record.evidencePins.filter((pin) => !existingPinIds.has(pin.id));
429
+ if (addedPins.length !== input.evidence.length) {
430
+ throw new Error('The selected conclusion evidence could not be retained completely.');
431
+ }
432
+ const supportingPinIds = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'supports' ? [pin.id] : []));
433
+ let concluded = updateCase(withPins.cases, id, {
434
+ disposition,
435
+ reviewReasonCode,
436
+ decision: {
437
+ summary: input.summary,
438
+ rationale: input.rationale,
439
+ evidencePinIds: supportingPinIds,
440
+ },
441
+ }, now);
442
+ const contradictionRelations = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'contradicts'
443
+ ? [{ evidencePinId: pin.id, stance: 'contradicts' }]
444
+ : []));
445
+ if (contradictionRelations.length) {
446
+ const retainedSummary = concluded.record.decisions.at(-1)?.summary ?? 'Analyst conclusion';
447
+ concluded = updateCase(concluded.cases, id, {
448
+ assertion: {
449
+ kind: 'contradiction',
450
+ statement: `Counterevidence considered for: ${retainedSummary}`,
451
+ rationale: input.rationale,
452
+ evidenceRelations: contradictionRelations,
453
+ state: 'resolved',
454
+ },
455
+ }, now);
456
+ }
457
+ const unresolvedRelations = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'unresolved'
458
+ ? [{ evidencePinId: pin.id, stance: 'unresolved' }]
459
+ : []));
460
+ if (unresolvedRelations.length) {
461
+ const retainedSummary = concluded.record.decisions.at(-1)?.summary ?? 'Analyst conclusion';
462
+ concluded = updateCase(concluded.cases, id, {
463
+ assertion: {
464
+ kind: 'unknown',
465
+ statement: `Unresolved evidence considered for: ${retainedSummary}`,
466
+ rationale: input.rationale,
467
+ evidenceRelations: unresolvedRelations,
468
+ state: 'open',
469
+ },
470
+ }, now);
471
+ }
472
+ return concluded;
473
+ }
474
+ export function recordCaseInvestigationContext(cases, id, input, nowIso) {
475
+ const current = cases.find((item) => item.id === id);
476
+ if (!current)
477
+ throw new Error('That case no longer exists.');
478
+ const parsed = parseIncidentUrlContext(input.incidentUrl);
479
+ if (!parsed || parsed.registrableDomain !== current.domain) {
480
+ throw new Error(`The Incident URL must belong to the Case domain ${current.domain}.`);
481
+ }
482
+ const context = caseInvestigationContextAssertion(input);
483
+ const existing = caseInvestigationContext(current);
484
+ return updateCase(cases, id, existing
485
+ ? {
486
+ assertionUpdate: {
487
+ id: existing.assertionId,
488
+ statement: context.statement,
489
+ rationale: context.rationale,
490
+ state: 'open',
491
+ },
492
+ }
493
+ : {
494
+ assertion: {
495
+ kind: 'next_step',
496
+ statement: context.statement,
497
+ rationale: context.rationale,
498
+ evidenceRelations: [],
499
+ state: 'open',
500
+ },
501
+ }, nowIso);
502
+ }
503
+ export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
504
+ const now = caseTimestampOrNull(nowIso) || new Date().toISOString();
505
+ const current = cases.find((item) => item.id === id);
506
+ if (!current)
507
+ throw new Error('That case no longer exists.');
508
+ const beforePinIds = new Set(current.evidencePins.map((pin) => pin.id));
509
+ const withPin = updateCase(cases, id, {
510
+ evidencePin: {
511
+ field: 'case.recheck_comparison',
512
+ category: 'recheck',
513
+ label: 'Recheck comparison',
514
+ value: input.comparisonSummary,
515
+ source: input.source,
516
+ sourceState: 'reviewed',
517
+ observedAt: input.observedAt,
518
+ collectionDepth: input.collectionDepth,
519
+ completeness: input.completeness,
520
+ truncated: false,
521
+ limitations: input.limitations,
522
+ },
523
+ }, now);
524
+ const comparisonPin = withPin.record.evidencePins.find((pin) => !beforePinIds.has(pin.id));
525
+ if (!comparisonPin)
526
+ throw new Error('The recheck comparison could not be retained.');
527
+ return updateCase(withPin.cases, id, {
528
+ observedEffectReview: {
529
+ state: input.state,
530
+ observedAt: input.observedAt,
531
+ sourceClass: 'analyst',
532
+ source: input.source,
533
+ completeness: input.completeness,
534
+ evidencePinId: comparisonPin.id,
535
+ followUpAt: input.followUpAt,
536
+ limitations: input.limitations,
537
+ },
538
+ }, now);
539
+ }
@@ -0,0 +1,218 @@
1
+ import { PUBLIC_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
2
+ import { caseStatusIsClosed } from "./case-record-decisions.mjs";
3
+ const PRESERVE = Object.freeze({
4
+ durable: 'preserve', internal: 'preserve', trusted: 'preserve', public: 'preserve',
5
+ });
6
+ const PUBLIC_TRANSFORM = Object.freeze({ ...PRESERVE, public: 'transform' });
7
+ const PUBLIC_EXCLUDE = Object.freeze({ ...PRESERVE, public: 'exclude' });
8
+ const SHARED_EXCLUDE = Object.freeze({ ...PRESERVE, trusted: 'exclude', public: 'exclude' });
9
+ const SHARED_REDACT = Object.freeze({ ...PRESERVE, trusted: 'redact', public: 'redact' });
10
+ const TRUSTED_REDACT_PUBLIC_EXCLUDE = Object.freeze({
11
+ ...PRESERVE, trusted: 'redact', public: 'exclude',
12
+ });
13
+ const PUBLIC_REDACT = Object.freeze({ ...PRESERVE, public: 'redact' });
14
+ function fieldRule(key, treatment, value, options = {}) {
15
+ const audienceExclusions = Object.freeze(Object.fromEntries(Object.entries(options.audienceExclusions ?? {}).map(([audience, exclusion]) => [
16
+ audience,
17
+ Object.freeze({ ...exclusion }),
18
+ ])));
19
+ for (const audience of ['internal', 'trusted', 'public']) {
20
+ const requiresExplanation = treatment[audience] === 'exclude' || treatment[audience] === 'redact';
21
+ if (requiresExplanation !== Boolean(audienceExclusions[audience])) {
22
+ throw new TypeError(`Case field ${key} must explain every redacted or excluded ${audience} treatment exactly once.`);
23
+ }
24
+ }
25
+ return Object.freeze({
26
+ key,
27
+ treatment,
28
+ value,
29
+ nestedSensitiveFields: Object.freeze([...(options.nestedSensitiveFields ?? [])]),
30
+ audienceExclusions,
31
+ });
32
+ }
33
+ function preservedField(key) {
34
+ return fieldRule(key, PRESERVE, (record) => record[key]);
35
+ }
36
+ const PUBLIC_OBSERVED_EFFECT_LIMITATION = 'Independent observed-effect review records were excluded from this public Case pack.';
37
+ const PUBLIC_CLOSURE_LIMITATION = 'Deliberate closure records were excluded from this public Case pack.';
38
+ function publicObservedEffectHistory(preV13HistoryUnavailable) {
39
+ return {
40
+ reviews: [],
41
+ omitted: 0,
42
+ preV13HistoryUnavailable,
43
+ limitations: [
44
+ ...(preV13HistoryUnavailable
45
+ ? ['Migrated from a pre-v13 Case; earlier independent observed-effect review history is unavailable.']
46
+ : []),
47
+ 'Observed-effect reviews are independent point-in-time records; provider workflow events do not create or replace them.',
48
+ PUBLIC_OBSERVED_EFFECT_LIMITATION,
49
+ ].sort(),
50
+ };
51
+ }
52
+ function publicClosureHistory(preV13HistoryUnavailable) {
53
+ return {
54
+ records: [],
55
+ omitted: 0,
56
+ preV13HistoryUnavailable,
57
+ limitations: [
58
+ ...(preV13HistoryUnavailable
59
+ ? ['Migrated from a pre-v13 Case; earlier deliberate closure history is unavailable.']
60
+ : []),
61
+ 'Closure records are deliberate analyst actions and do not establish absence, safety, provider performance, or legal sufficiency.',
62
+ PUBLIC_CLOSURE_LIMITATION,
63
+ ].sort(),
64
+ };
65
+ }
66
+ /**
67
+ * The complete current Case field policy. Browser persistence and ordinary
68
+ * exports deliberately share the `durable` projection. Case-pack audiences
69
+ * must classify every field here before a new Case property can compile.
70
+ */
71
+ const CASE_FIELD_RULES = Object.freeze({
72
+ id: preservedField('id'),
73
+ domain: preservedField('domain'),
74
+ status: fieldRule('status', PUBLIC_TRANSFORM, (record, profile) => (profile === 'public' && caseStatusIsClosed(record.status) && record.closures.records.length
75
+ ? 'reviewing'
76
+ : record.status)),
77
+ disposition: preservedField('disposition'),
78
+ reviewReasonCode: fieldRule('reviewReasonCode', PRESERVE, (record) => record.reviewReasonCode ?? null),
79
+ brandProfileIds: fieldRule('brandProfileIds', PUBLIC_EXCLUDE, (record, profile) => (profile === 'public' ? [] : [...record.brandProfileIds]), {
80
+ audienceExclusions: { public: { label: 'Brand Profile references', order: 2 } },
81
+ }),
82
+ tags: fieldRule('tags', PRESERVE, (record) => [...record.tags]),
83
+ notes: fieldRule('notes', SHARED_EXCLUDE, (record, profile) => (profile === 'trusted' || profile === 'public'
84
+ ? []
85
+ : record.notes.map((item) => ({ ...item }))), {
86
+ audienceExclusions: {
87
+ trusted: { label: 'Case notes', order: 1 },
88
+ public: { label: 'Case notes', order: 1 },
89
+ },
90
+ }),
91
+ source: preservedField('source'),
92
+ evidenceHistory: fieldRule('evidenceHistory', PRESERVE, (record) => structuredClone(record.evidenceHistory)),
93
+ evidencePins: fieldRule('evidencePins', PRESERVE, (record) => structuredClone(record.evidencePins)),
94
+ decisions: fieldRule('decisions', PRESERVE, (record) => structuredClone(record.decisions)),
95
+ actions: fieldRule('actions', TRUSTED_REDACT_PUBLIC_EXCLUDE, (record, profile) => (profile === 'public'
96
+ ? []
97
+ : profile === 'trusted'
98
+ ? structuredClone(record.actions).map((item) => ({ ...item, recipient: '[redacted]' }))
99
+ : structuredClone(record.actions)), {
100
+ nestedSensitiveFields: ['recipient'],
101
+ audienceExclusions: {
102
+ trusted: { label: 'Recipient values', order: 2 },
103
+ public: { label: 'Actions and recipient values', order: 3 },
104
+ },
105
+ }),
106
+ assertions: fieldRule('assertions', PUBLIC_EXCLUDE, (record, profile) => (profile === 'public' ? [] : structuredClone(record.assertions)), {
107
+ audienceExclusions: { public: { label: 'Analyst assertions', order: 4 } },
108
+ }),
109
+ manualTrail: fieldRule('manualTrail', SHARED_REDACT, (record, profile) => (profile === 'trusted' || profile === 'public'
110
+ ? structuredClone(record.manualTrail).map((item) => ({ ...item, target: null }))
111
+ : structuredClone(record.manualTrail)), {
112
+ nestedSensitiveFields: ['target'],
113
+ audienceExclusions: {
114
+ trusted: { label: 'Manual trail targets', order: 3 },
115
+ public: { label: 'Manual trail targets', order: 6 },
116
+ },
117
+ }),
118
+ sightings: fieldRule('sightings', PRESERVE, (record) => structuredClone(record.sightings)),
119
+ observedEffects: fieldRule('observedEffects', PUBLIC_REDACT, (record, profile) => (profile === 'public'
120
+ ? publicObservedEffectHistory(record.observedEffects.preV13HistoryUnavailable)
121
+ : structuredClone(record.observedEffects)), {
122
+ audienceExclusions: {
123
+ public: { label: 'Independent observed-effect reviews and closure history', order: 8 },
124
+ },
125
+ }),
126
+ closures: fieldRule('closures', PUBLIC_REDACT, (record, profile) => (profile === 'public'
127
+ ? publicClosureHistory(record.closures.preV13HistoryUnavailable)
128
+ : structuredClone(record.closures)), {
129
+ audienceExclusions: {
130
+ public: { label: 'Independent observed-effect reviews and closure history', order: 8 },
131
+ },
132
+ }),
133
+ branches: fieldRule('branches', PUBLIC_EXCLUDE, (record, profile) => (profile === 'public' ? [] : structuredClone(record.branches ?? [])), {
134
+ audienceExclusions: { public: { label: 'Investigation branches', order: 5 } },
135
+ }),
136
+ createdAt: preservedField('createdAt'),
137
+ updatedAt: preservedField('updatedAt'),
138
+ });
139
+ function projectCase(record, profile) {
140
+ const projected = {};
141
+ for (const key of Object.keys(CASE_FIELD_RULES)) {
142
+ const rule = CASE_FIELD_RULES[key];
143
+ Object.defineProperty(projected, key, {
144
+ configurable: true,
145
+ enumerable: true,
146
+ value: rule.value(record, profile),
147
+ writable: true,
148
+ });
149
+ }
150
+ return projected;
151
+ }
152
+ export function projectCaseForDurableWrite(record) {
153
+ return projectCase(record, 'durable');
154
+ }
155
+ export function projectCaseForAudience(record, audience) {
156
+ return projectCase(record, audience);
157
+ }
158
+ /** Field names used by the bounded hidden-copy defence, derived from policy. */
159
+ export const CASE_AUDIENCE_SENSITIVE_FIELD_NAMES = Object.freeze([...new Set(Object.entries(CASE_FIELD_RULES).flatMap(([key, rule]) => {
160
+ const audienceTreatments = [rule.treatment.trusted, rule.treatment.public];
161
+ const sensitive = audienceTreatments.some((value) => value === 'exclude' || value === 'redact');
162
+ return sensitive ? [key, ...rule.nestedSensitiveFields] : [];
163
+ }))]);
164
+ export const CASE_RECORD_FIELD_NAMES = Object.freeze(Object.values(CASE_FIELD_RULES).map((rule) => rule.key));
165
+ const OUTSIDE_SCHEMA_EXCLUSIONS = Object.freeze({
166
+ internal: Object.freeze({ label: 'Raw upstream payloads and credentials are outside the case schema.', order: 1 }),
167
+ trusted: Object.freeze({ label: 'Raw upstream payloads and credentials', order: 4 }),
168
+ public: Object.freeze({ label: 'Raw upstream payloads and credentials', order: 7 }),
169
+ });
170
+ function currentAudienceExclusions(audience) {
171
+ const entries = [
172
+ ...Object.values(CASE_FIELD_RULES).flatMap((rule) => (rule.audienceExclusions[audience] ? [rule.audienceExclusions[audience]] : [])),
173
+ OUTSIDE_SCHEMA_EXCLUSIONS[audience],
174
+ ];
175
+ const orderByLabel = new Map();
176
+ const labelByOrder = new Map();
177
+ for (const entry of entries) {
178
+ if (!entry.label || entry.label.length > 160 || entry.label.trim() !== entry.label
179
+ || !Number.isSafeInteger(entry.order) || entry.order < 1 || entry.order > 64) {
180
+ throw new TypeError('Case audience exclusion metadata is malformed or exceeds its bound.');
181
+ }
182
+ const prior = orderByLabel.get(entry.label);
183
+ if (prior !== undefined && prior !== entry.order) {
184
+ throw new TypeError(`Case audience exclusion ${entry.label} has conflicting presentation order.`);
185
+ }
186
+ const priorLabel = labelByOrder.get(entry.order);
187
+ if (priorLabel !== undefined && priorLabel !== entry.label) {
188
+ throw new TypeError(`Case audience exclusions ${priorLabel} and ${entry.label} share a presentation order.`);
189
+ }
190
+ orderByLabel.set(entry.label, entry.order);
191
+ labelByOrder.set(entry.order, entry.label);
192
+ }
193
+ return Object.freeze([...orderByLabel]
194
+ .sort((left, right) => left[1] - right[1])
195
+ .map(([label]) => label));
196
+ }
197
+ const CURRENT_AUDIENCE_EXCLUSIONS = Object.freeze({
198
+ internal: currentAudienceExclusions('internal'),
199
+ trusted: currentAudienceExclusions('trusted'),
200
+ public: currentAudienceExclusions('public'),
201
+ });
202
+ // Schema 12 is an immutable public compatibility contract and deliberately
203
+ // remains independent of the current field-policy projection.
204
+ const PUBLIC_V12_EXCLUSIONS = Object.freeze([
205
+ 'Case notes',
206
+ 'Brand Profile references',
207
+ 'Actions and recipient values',
208
+ 'Analyst assertions',
209
+ 'Investigation branches',
210
+ 'Manual trail targets',
211
+ 'Raw upstream payloads and credentials',
212
+ ]);
213
+ export function caseAudienceExclusions(audience, caseVersion) {
214
+ return audience === 'public' && caseVersion === PUBLIC_CASE_SCHEMA_VERSION
215
+ ? PUBLIC_V12_EXCLUSIONS
216
+ : CURRENT_AUDIENCE_EXCLUSIONS[audience];
217
+ }
218
+ export { CASE_FIELD_RULES };
@@ -182,10 +182,9 @@ export function buildCaseReport(caseRecord, options = {}) {
182
182
  if (!previous)
183
183
  continue;
184
184
  const rawChanges = compareCaseEvidence(previous, snapshot);
185
- const allIncomparableReasons = caseEvidenceIncomparableReasons(previous, snapshot);
186
- const hostnameContextChanged = allIncomparableReasons.includes('observation-context');
187
- incomparableReasons = allIncomparableReasons.filter((reason) => reason !== 'observation-context');
188
- if (rawChanges.length > 0) {
185
+ incomparableReasons = caseEvidenceIncomparableReasons(previous, snapshot);
186
+ const hostnameContextChanged = incomparableReasons.includes('observation-context');
187
+ if (rawChanges.length > 0 && !hostnameContextChanged) {
189
188
  changes = rawChanges.map((change) => ({
190
189
  field: change.field,
191
190
  label: change.label,
@@ -195,8 +194,7 @@ export function buildCaseReport(caseRecord, options = {}) {
195
194
  }));
196
195
  }
197
196
  else if (snapshot.fingerprint !== previous.fingerprint
198
- && incomparableReasons.length === 0
199
- && !hostnameContextChanged) {
197
+ && incomparableReasons.length === 0) {
200
198
  incomparableReasons = ['other'];
201
199
  }
202
200
  hasIncomparableChange = incomparableReasons.length > 0;
@@ -452,6 +450,8 @@ function buildMarkdown(report, includeAttribution) {
452
450
  }
453
451
  if (entry.hasIncomparableChange) {
454
452
  const reasons = Array.isArray(entry.incomparableReasons) ? entry.incomparableReasons : [];
453
+ if (reasons.includes('observation-context'))
454
+ lines.push('> Observation targets differ or are unknown. Field-level additions, removals and beneficial changes are withheld because their scopes cannot be compared reliably. Exact hostnames remain excluded from this report.');
455
455
  if (reasons.includes('risk-model'))
456
456
  lines.push('> Risk scores and factors use different or unversioned models, so their numeric difference is not treated as a domain change.');
457
457
  if (reasons.includes('opportunity-model'))
@@ -544,6 +544,7 @@ function buildMarkdown(report, includeAttribution) {
544
544
  for (const decision of response.decisions) {
545
545
  lines.push(`- **${escapeMarkdownInline(decision.summary)}** (${escapeMarkdownInline(decision.createdAt)})`);
546
546
  lines.push(` ${escapeMarkdownInline(decision.rationale)}`);
547
+ lines.push(` Analyst confidence: ${escapeMarkdownInline(decision.confidence)}${decision.confidenceBasis ? ` — ${escapeMarkdownInline(decision.confidenceBasis)}` : ''}.`);
547
548
  if (decision.evidencePinIds.length)
548
549
  lines.push(` Evidence pins: ${escapeMarkdownInline(decision.evidencePinIds.join(', '))}`);
549
550
  }
@@ -555,6 +556,10 @@ function buildMarkdown(report, includeAttribution) {
555
556
  for (const action of response.actions) {
556
557
  lines.push(`- **${escapeMarkdownInline(action.type.replaceAll('_', ' '))}:** ${escapeMarkdownInline(action.recipient)} (${escapeMarkdownInline(action.state)})`);
557
558
  lines.push(` Contact source: ${escapeMarkdownInline(action.contactSource)}; updated ${escapeMarkdownInline(action.updatedAt)}.`);
559
+ if (action.routeObservedAt)
560
+ lines.push(` Route reviewed: ${escapeMarkdownInline(action.routeObservedAt)}`);
561
+ if (action.routeReviewAfter)
562
+ lines.push(` Route review due: ${escapeMarkdownInline(action.routeReviewAfter)}`);
558
563
  if (action.originActionId)
559
564
  lines.push(` Originating action: ${escapeMarkdownInline(action.originActionId)}`);
560
565
  if (action.dueAt)
@@ -13,9 +13,11 @@ export const CASE_ACTION_TYPES = [
13
13
  'registry_report',
14
14
  'network_hosting_report',
15
15
  'security_contact_report',
16
+ 'platform_report',
16
17
  'defensive_control',
17
18
  'internal_review',
18
19
  ];
20
+ export const CASE_DECISION_CONFIDENCE_LEVELS = ['unknown', 'low', 'moderate', 'high'];
19
21
  export const CASE_ACTION_STATES = [
20
22
  'drafting',
21
23
  'ready_for_review',
@@ -101,6 +103,7 @@ const CONTROL_REPLACE_RE = /[\u0000-\u001f\u007f]+/gu;
101
103
  const COMPLETENESS = new Set(CASE_PIN_COMPLETENESS);
102
104
  const TRANSITION_EXPECTATIONS = new Set(CASE_TRANSITION_EXPECTATIONS);
103
105
  const ACTION_TYPES = new Set(CASE_ACTION_TYPES);
106
+ const DECISION_CONFIDENCE_LEVELS = new Set(CASE_DECISION_CONFIDENCE_LEVELS);
104
107
  const ACTION_STATES = new Set(CASE_ACTION_STATES);
105
108
  const PROVIDER_OUTCOMES = new Set(CASE_PROVIDER_OUTCOMES);
106
109
  const ACTION_EVENT_SOURCE_CLASSES = new Set(CASE_ACTION_EVENT_SOURCE_CLASSES);
@@ -337,6 +340,9 @@ export function normalizeCaseEvidencePins(raw, fallback, options = {}) {
337
340
  .slice(-MAX_CASE_EVIDENCE_PINS);
338
341
  }
339
342
  export function appendCaseEvidencePin(current, raw, now) {
343
+ if (current.length >= MAX_CASE_EVIDENCE_PINS) {
344
+ throw new Error(`A Case can retain at most ${MAX_CASE_EVIDENCE_PINS} evidence pins. No existing evidence was removed.`);
345
+ }
340
346
  const item = record(raw);
341
347
  const created = normalizePin({ ...item, id: freshId('pin'), createdAt: now }, now);
342
348
  if (!created)
@@ -363,10 +369,17 @@ function normalizeDecision(raw, fallback, validPinIds, options = {}) {
363
369
  if (!summary || !rationale)
364
370
  return null;
365
371
  const createdAt = iso(item.createdAt, fallback, options);
372
+ const currentConfidence = typeof item.confidence === 'string' && DECISION_CONFIDENCE_LEVELS.has(item.confidence)
373
+ ? item.confidence
374
+ : 'unknown';
366
375
  return {
367
376
  id: safeId(item.id, 'decision', { summary, rationale, createdAt }),
368
377
  summary,
369
378
  rationale,
379
+ confidence: options.sourceVersion != null && options.sourceVersion < 15 ? 'unknown' : currentConfidence,
380
+ confidenceBasis: options.sourceVersion != null && options.sourceVersion < 15
381
+ ? ''
382
+ : text(item.confidenceBasis, MAX_RESPONSE_RATIONALE_LENGTH),
370
383
  evidencePinIds: uniqueIds(item.evidencePinIds, validPinIds),
371
384
  createdAt,
372
385
  };
@@ -385,6 +398,9 @@ export function normalizeCaseDecisions(raw, fallback, validPinIds, options = {})
385
398
  .slice(-MAX_CASE_DECISIONS);
386
399
  }
387
400
  export function appendCaseDecision(current, raw, now, validPinIds) {
401
+ if (current.length >= MAX_CASE_DECISIONS) {
402
+ throw new Error(`A Case can retain at most ${MAX_CASE_DECISIONS} decisions. No existing decision was removed.`);
403
+ }
388
404
  const item = record(raw);
389
405
  const created = normalizeDecision({ ...item, id: freshId('decision'), createdAt: now }, now, validPinIds);
390
406
  if (!created)
@@ -661,16 +677,28 @@ function normalizeAction(raw, fallback, options = {}) {
661
677
  const latestEventAt = applied.at(-1)?.occurredAt ?? createdAt;
662
678
  const metadataUpdatedAt = iso(item.metadataUpdatedAt ?? item.updatedAt, createdAt, options);
663
679
  const updatedAt = Date.parse(latestEventAt) > Date.parse(metadataUpdatedAt) ? latestEventAt : metadataUpdatedAt;
680
+ const contactSource = text(item.contactSource, MAX_RESPONSE_LABEL_LENGTH) || 'analyst_supplied';
681
+ const legacyPlatformReview = options.sourceVersion != null && options.sourceVersion < 15
682
+ && item.type === 'security_contact_report'
683
+ ? /^Official .+, reviewed (\d{4}-\d{2}-\d{2})$/u.exec(contactSource)
684
+ : null;
664
685
  return {
665
686
  id: actionId,
666
- type: typeof item.type === 'string' && ACTION_TYPES.has(item.type)
667
- ? item.type
668
- : 'internal_review',
687
+ type: legacyPlatformReview
688
+ ? 'platform_report'
689
+ : typeof item.type === 'string' && ACTION_TYPES.has(item.type)
690
+ ? item.type
691
+ : 'internal_review',
669
692
  recipient,
670
- contactSource: text(item.contactSource, MAX_RESPONSE_LABEL_LENGTH) || 'analyst_supplied',
671
- routeObservedAt: options.sourceVersion != null && options.sourceVersion < 14
693
+ contactSource,
694
+ routeObservedAt: legacyPlatformReview
695
+ ? normalizeExplicitIsoTimestamp(`${legacyPlatformReview[1]}T00:00:00.000Z`)
696
+ : options.sourceVersion != null && options.sourceVersion < 14
697
+ ? null
698
+ : optionalIso(item.routeObservedAt, options),
699
+ routeReviewAfter: options.sourceVersion != null && options.sourceVersion < 15
672
700
  ? null
673
- : optionalIso(item.routeObservedAt, options),
701
+ : optionalIso(item.routeReviewAfter, options),
674
702
  contactLimitations: limitations(item.contactLimitations),
675
703
  dueAt: optionalIso(item.dueAt, options),
676
704
  state: history.state,
@@ -1112,6 +1140,9 @@ export function normalizeCaseAssertions(raw, fallback, validPinIds, options = {}
1112
1140
  .slice(-MAX_CASE_ASSERTIONS);
1113
1141
  }
1114
1142
  export function appendCaseAssertion(current, raw, now, validPinIds) {
1143
+ if (current.length >= MAX_CASE_ASSERTIONS) {
1144
+ throw new Error(`A Case can retain at most ${MAX_CASE_ASSERTIONS} assertions. No existing assertion was removed.`);
1145
+ }
1115
1146
  const item = record(raw);
1116
1147
  const created = normalizeAssertion({
1117
1148
  ...item,
@@ -1170,6 +1201,9 @@ export function normalizeCaseManualTrail(raw, fallback, options = {}) {
1170
1201
  .slice(-MAX_CASE_MANUAL_TRAIL_EVENTS);
1171
1202
  }
1172
1203
  export function appendCaseManualTrailEvent(current, raw, now) {
1204
+ if (current.length >= MAX_CASE_MANUAL_TRAIL_EVENTS) {
1205
+ throw new Error(`A Case can retain at most ${MAX_CASE_MANUAL_TRAIL_EVENTS} investigation-trail entries. No existing entry was removed.`);
1206
+ }
1173
1207
  const item = record(raw);
1174
1208
  const created = normalizeManualTrailEvent({ ...item, id: freshId('trail'), createdAt: now }, now);
1175
1209
  if (!created)
@@ -1234,6 +1268,9 @@ export function normalizeCaseSightings(raw, fallback, validPinIds, options = {})
1234
1268
  .slice(-MAX_CASE_SIGHTINGS);
1235
1269
  }
1236
1270
  export function appendCaseSighting(current, raw, now, validPinIds) {
1271
+ if (current.length >= MAX_CASE_SIGHTINGS) {
1272
+ throw new Error(`A Case can retain at most ${MAX_CASE_SIGHTINGS} sightings. No existing sighting was removed.`);
1273
+ }
1237
1274
  const item = record(raw);
1238
1275
  const created = normalizeCaseSighting({
1239
1276
  ...item,