@slicedearth/whoisleuth-cli 2.2.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 +12 -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 +60 -11
  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 +6 -4
  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 +171 -51
  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 +3 -2
  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,30 +1,15 @@
1
1
  import { ANALYST_REVIEW_REASONS } from "../../lib/analyst-taxonomy.mjs";
2
2
  import { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, PUBLIC_CASE_SCHEMA_VERSION, MAX_CASE_BRAND_PROFILE_ID_CANDIDATES, MAX_CASE_BRAND_PROFILE_IDS, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_CASES, MAX_DOMAIN_LENGTH, MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, MAX_NOTE_LENGTH, MAX_NOTES_PER_CASE, MAX_TAG_LENGTH, MAX_TAGS_PER_CASE, } from "../contracts/case-portability.mjs";
3
+ import { CASE_DISPOSITIONS, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_STATUS, } from "./case-record-decisions.mjs";
3
4
  export { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, PUBLIC_CASE_SCHEMA_VERSION, MAX_CASE_BRAND_PROFILE_ID_CANDIDATES, MAX_CASE_BRAND_PROFILE_IDS, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_CASES, MAX_DOMAIN_LENGTH, MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, MAX_NOTE_LENGTH, MAX_NOTES_PER_CASE, MAX_TAG_LENGTH, MAX_TAGS_PER_CASE, };
4
- export const CASE_STATUSES = [
5
- { value: 'new', label: 'New' },
6
- { value: 'reviewing', label: 'Reviewing' },
7
- { value: 'monitoring', label: 'Monitoring' },
8
- { value: 'escalated', label: 'Escalated' },
9
- { value: 'resolved', label: 'Resolved' },
10
- ];
11
- export const CASE_DISPOSITIONS = [
12
- { value: 'unreviewed', label: 'Unreviewed' },
13
- { value: 'suspicious', label: 'Suspicious' },
14
- { value: 'confirmed_abuse', label: 'Confirmed abuse' },
15
- { value: 'false_positive', label: 'False positive' },
16
- { value: 'expected', label: 'Expected' },
17
- { value: 'closed_no_action', label: 'Closed without action' },
18
- ];
5
+ export { CASE_DISPOSITIONS, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_STATUS, };
19
6
  export const CASE_REVIEW_REASONS = ANALYST_REVIEW_REASONS.map((item) => ({ ...item }));
20
- export const CASE_SOURCES = [
7
+ export const CASE_SOURCES = Object.freeze([
21
8
  { value: 'lookup', label: 'Lookup' },
22
9
  { value: 'bulk', label: 'Bulk' },
23
10
  { value: 'monitor', label: 'Monitor' },
24
11
  { value: 'manual', label: 'Manual' },
25
12
  { value: 'unknown', label: 'Unknown' },
26
- ];
27
- export const EVIDENCE_SOURCES = ['lookup', 'bulk', 'monitor', 'import', 'unknown'];
28
- export const DEFAULT_STATUS = 'new';
29
- export const DEFAULT_DISPOSITION = 'unreviewed';
13
+ ]);
14
+ export const EVIDENCE_SOURCES = Object.freeze(['lookup', 'bulk', 'monitor', 'import', 'unknown']);
30
15
  export const DEFAULT_SOURCE = 'unknown';
@@ -1,6 +1,7 @@
1
1
  // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
2
  // record normalization, and analyst updates.
3
- import { CASE_DISPOSITIONS, CASE_SCHEMA_VERSION, CASE_REVIEW_REASONS, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_DOMAIN_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.mjs";
3
+ import { CASE_SCHEMA_VERSION, CASE_REVIEW_REASONS, CASE_SOURCES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_DOMAIN_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.mjs";
4
+ import { dispositionLabel, isValidDisposition, isValidStatus, statusLabel, } from "./case-record-decisions.mjs";
4
5
  import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../evidence/observation.mjs";
5
6
  import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
6
7
  // Forward-version policy (two distinct guarantees):
@@ -26,13 +27,9 @@ export const EVIDENCE_SOURCE_RANK = {
26
27
  import: 1,
27
28
  unknown: 0,
28
29
  };
29
- const STATUS_VALUES = new Set(CASE_STATUSES.map((item) => item.value));
30
- const DISPOSITION_VALUES = new Set(CASE_DISPOSITIONS.map((item) => item.value));
31
30
  const REVIEW_REASON_VALUES = new Set(CASE_REVIEW_REASONS.map((item) => item.value).filter(Boolean));
32
31
  const SOURCE_VALUES = new Set(CASE_SOURCES.map((item) => item.value));
33
- const STATUS_LABELS = Object.fromEntries(CASE_STATUSES.map((item) => [item.value, item.label]));
34
- const DISPOSITION_LABELS = Object.fromEntries(CASE_DISPOSITIONS.map((item) => [item.value, item.label]));
35
- const SOURCE_LABELS = Object.fromEntries(CASE_SOURCES.map((item) => [item.value, item.label]));
32
+ const SOURCE_LABELS = new Map(CASE_SOURCES.map((item) => [item.value, item.label]));
36
33
  // Availability tokens that actually assert something about the domain. Anything
37
34
  // else ('unknown', 'error', empty) is not, on its own, material evidence.
38
35
  export const CONCLUSIVE_AVAILABILITY = new Set([
@@ -59,21 +56,13 @@ export function objectRecord(value) {
59
56
  ? value
60
57
  : {};
61
58
  }
62
- export function statusLabel(value) {
63
- return (typeof value === 'string' ? STATUS_LABELS[value] : '') || String(value || '');
64
- }
65
- export function dispositionLabel(value) {
66
- return (typeof value === 'string' ? DISPOSITION_LABELS[value] : '') || String(value || '');
67
- }
68
59
  export function sourceLabel(value) {
69
- return (typeof value === 'string' ? SOURCE_LABELS[value] : '') || String(value || '');
70
- }
71
- export function isValidStatus(value) {
72
- return typeof value === 'string' && STATUS_VALUES.has(value);
60
+ return isValidSource(value) ? SOURCE_LABELS.get(value) : String(value || '');
73
61
  }
74
- export function isValidDisposition(value) {
75
- return typeof value === 'string' && DISPOSITION_VALUES.has(value);
62
+ export function isValidSource(value) {
63
+ return typeof value === 'string' && SOURCE_VALUES.has(value);
76
64
  }
65
+ export { dispositionLabel, isValidDisposition, isValidStatus, statusLabel, };
77
66
  /** Fresh, safe, effectively-unique id for a brand-new local record. */
78
67
  export function makeId() {
79
68
  if (typeof globalThis.crypto?.randomUUID === 'function')
@@ -147,16 +136,16 @@ export function normalizeEvidenceHostnameForCase(value, caseDomain) {
147
136
  return canonicalRegistrableDomain(hostname) === canonicalCase ? hostname : null;
148
137
  }
149
138
  export function normalizeStatus(value) {
150
- return typeof value === 'string' && STATUS_VALUES.has(value) ? value : DEFAULT_STATUS;
139
+ return isValidStatus(value) ? value : DEFAULT_STATUS;
151
140
  }
152
141
  export function normalizeDisposition(value) {
153
- return typeof value === 'string' && DISPOSITION_VALUES.has(value) ? value : DEFAULT_DISPOSITION;
142
+ return isValidDisposition(value) ? value : DEFAULT_DISPOSITION;
154
143
  }
155
144
  export function normalizeReviewReasonCode(value) {
156
145
  return typeof value === 'string' && REVIEW_REASON_VALUES.has(value) ? value : null;
157
146
  }
158
147
  export function normalizeSource(value) {
159
- return typeof value === 'string' && SOURCE_VALUES.has(value) ? value : DEFAULT_SOURCE;
148
+ return isValidSource(value) ? value : DEFAULT_SOURCE;
160
149
  }
161
150
  /** Parsed ISO string, or null when missing/invalid (used for import ordering). */
162
151
  export function isoOrNull(value) {
@@ -0,0 +1,72 @@
1
+ // Lightweight canonical Case status and disposition definitions. Persistence,
2
+ // portability and presentation consumers derive these identities and policies
3
+ // without importing the wider Case record or serialisation graph.
4
+ export const CASE_STATUSES = Object.freeze([
5
+ { value: 'new', label: 'New' },
6
+ { value: 'reviewing', label: 'Reviewing' },
7
+ { value: 'monitoring', label: 'Monitoring' },
8
+ { value: 'escalated', label: 'Escalated' },
9
+ { value: 'resolved', label: 'Resolved' },
10
+ ]);
11
+ export const CASE_DISPOSITIONS = Object.freeze([
12
+ { value: 'unreviewed', label: 'Unreviewed' },
13
+ { value: 'suspicious', label: 'Suspicious' },
14
+ { value: 'confirmed_abuse', label: 'Confirmed abuse' },
15
+ { value: 'false_positive', label: 'False positive' },
16
+ { value: 'expected', label: 'Expected' },
17
+ { value: 'closed_no_action', label: 'Closed without action' },
18
+ ]);
19
+ export const DEFAULT_STATUS = 'new';
20
+ export const DEFAULT_DISPOSITION = 'unreviewed';
21
+ const CASE_STATUS_OPERATION_POLICIES = Object.freeze({
22
+ new: { closed: false, closureRequired: false, directEdit: 'allowed' },
23
+ reviewing: { closed: false, closureRequired: false, directEdit: 'allowed' },
24
+ monitoring: { closed: false, closureRequired: false, directEdit: 'allowed' },
25
+ escalated: { closed: false, closureRequired: false, directEdit: 'allowed' },
26
+ resolved: { closed: true, closureRequired: true, directEdit: 'existing_only' },
27
+ });
28
+ const CASE_DISPOSITION_DECISION_POLICIES = Object.freeze({
29
+ unreviewed: { reviewed: false, supportsDefensiveResponse: false },
30
+ suspicious: { reviewed: true, supportsDefensiveResponse: true },
31
+ confirmed_abuse: { reviewed: true, supportsDefensiveResponse: true },
32
+ false_positive: { reviewed: true, supportsDefensiveResponse: false },
33
+ expected: { reviewed: true, supportsDefensiveResponse: false },
34
+ closed_no_action: { reviewed: true, supportsDefensiveResponse: false },
35
+ });
36
+ function statusOption(value) {
37
+ return CASE_STATUSES.find((option) => option.value === value);
38
+ }
39
+ function dispositionOption(value) {
40
+ return CASE_DISPOSITIONS.find((option) => option.value === value);
41
+ }
42
+ export function isValidStatus(value) {
43
+ return Boolean(statusOption(value));
44
+ }
45
+ export function isValidDisposition(value) {
46
+ return Boolean(dispositionOption(value));
47
+ }
48
+ export function statusLabel(value) {
49
+ return statusOption(value)?.label ?? String(value || '');
50
+ }
51
+ export function dispositionLabel(value) {
52
+ return dispositionOption(value)?.label ?? String(value || '');
53
+ }
54
+ export function caseStatusRequiresClosure(value) {
55
+ const option = statusOption(value);
56
+ return option ? CASE_STATUS_OPERATION_POLICIES[option.value].closureRequired : false;
57
+ }
58
+ export function caseStatusIsClosed(value) {
59
+ const option = statusOption(value);
60
+ return option ? CASE_STATUS_OPERATION_POLICIES[option.value].closed : false;
61
+ }
62
+ export function isReviewedCaseDisposition(value) {
63
+ const option = dispositionOption(value);
64
+ return option ? CASE_DISPOSITION_DECISION_POLICIES[option.value].reviewed : false;
65
+ }
66
+ export function caseDispositionSupportsDefensiveResponse(value) {
67
+ const option = dispositionOption(value);
68
+ return option ? CASE_DISPOSITION_DECISION_POLICIES[option.value].supportsDefensiveResponse : false;
69
+ }
70
+ export function caseStatusOptionsForDirectEdit(currentStatus) {
71
+ return CASE_STATUSES.filter((option) => (CASE_STATUS_OPERATION_POLICIES[option.value].directEdit === 'allowed' || option.value === currentStatus));
72
+ }
@@ -6,4 +6,5 @@ export * from "./case-record-core.mjs";
6
6
  export * from "./case-brand-profile-references.mjs";
7
7
  export * from "./case-evidence-model.mjs";
8
8
  export * from "./case-record-operations.mjs";
9
+ export * from "./case-record-projection.mjs";
9
10
  export * from "./case-investigation-branch-model.mjs";
@@ -3,10 +3,12 @@
3
3
  import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
4
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";
5
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";
6
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";
7
8
  import { assertCaseBrandProfileIds, normalizeCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
8
9
  import { normalizeEvidenceHistory, } from "./case-evidence-model.mjs";
9
10
  import { appendCaseInvestigationBranch, caseInvestigationBranchReferences, normalizeCaseInvestigationBranches, updateCaseInvestigationBranch, } from "./case-investigation-branch-model.mjs";
11
+ export { caseDispositionSupportsDefensiveResponse, caseStatusIsClosed, caseStatusOptionsForDirectEdit, caseStatusRequiresClosure, isReviewedCaseDisposition, };
10
12
  export const MAX_CASE_OBJECTIVE_LENGTH = 320;
11
13
  export const MAX_CASE_INCIDENT_URL_LENGTH = 1_850;
12
14
  export const INCIDENT_CONTEXT_STATEMENT_PREFIX = 'Investigate incident URL: ';
@@ -156,7 +158,7 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
156
158
  return {
157
159
  id: existing ? existing.id : safeId(record.id) || deterministicId(domain),
158
160
  domain,
159
- status: normalizedStatus === 'resolved'
161
+ status: caseStatusRequiresClosure(normalizedStatus)
160
162
  && closures.records.length === 0 && !closures.preV13HistoryUnavailable
161
163
  ? 'reviewing'
162
164
  : normalizedStatus,
@@ -214,7 +216,7 @@ export function createCase(input, nowIso) {
214
216
  const closures = input.closure !== undefined
215
217
  ? appendCaseClosure(normalizeCaseClosureHistory(undefined, now), input.closure, now, observedEffects, actions)
216
218
  : normalizeCaseClosureHistory(undefined, now);
217
- if (normalizeStatus(input.status) === 'resolved' && input.closure === undefined) {
219
+ if (caseStatusRequiresClosure(normalizeStatus(input.status)) && input.closure === undefined) {
218
220
  throw new Error('Opening a resolved case requires a deliberate closure reason and its linked review context.');
219
221
  }
220
222
  return {
@@ -356,7 +358,7 @@ export function updateCase(cases, id, patch, nowIso) {
356
358
  if (patch.closure !== undefined) {
357
359
  closures = appendCaseClosure(closures, patch.closure, now, observedEffects, actions);
358
360
  }
359
- if (patch.status === 'resolved' && patch.closure === undefined) {
361
+ if (caseStatusRequiresClosure(patch.status) && patch.closure === undefined) {
360
362
  throw new Error('Resolve this case through the deliberate closure review so the reason and evidence state remain explicit.');
361
363
  }
362
364
  const record = {
@@ -401,7 +403,7 @@ export function recordCaseConclusion(cases, id, input, nowIso) {
401
403
  const now = caseTimestampOrNull(nowIso) || new Date().toISOString();
402
404
  const disposition = normalizeDisposition(input.disposition);
403
405
  const reviewReasonCode = normalizeReviewReasonCode(input.reviewReasonCode);
404
- if (disposition === 'unreviewed') {
406
+ if (!isReviewedCaseDisposition(disposition)) {
405
407
  throw new Error('Select a reviewed disposition before recording a conclusion.');
406
408
  }
407
409
  if (!reviewReasonCode) {
@@ -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,