@slicedearth/whoisleuth-cli 2.2.0 → 2.4.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 (282) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +20 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/arguments.mjs +454 -1748
  7. package/cli/artifact-validation/case-response.mjs +66 -35
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +112 -72
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-checkpoint.mjs +34 -3
  18. package/cli/bulk-command-runner.mjs +2 -0
  19. package/cli/bulk-output.mjs +29 -3
  20. package/cli/case-command.mjs +195 -0
  21. package/cli/case-pack.mjs +66 -111
  22. package/cli/command-argument-grammar.mjs +215 -0
  23. package/cli/command-reference.mjs +342 -285
  24. package/cli/completion.mjs +303 -80
  25. package/cli/config-profile.mjs +7 -5
  26. package/cli/ct-event-intake.mjs +12 -12
  27. package/cli/discover.mjs +4 -3
  28. package/cli/discovery-observation-snapshot.mjs +59 -18
  29. package/cli/discovery-scan-command-runner.mjs +21 -0
  30. package/cli/discovery-scan.mjs +13 -4
  31. package/cli/discovery-workflow.mjs +1 -1
  32. package/cli/domain-control-monitor.mjs +50 -20
  33. package/cli/domain-control-observations.mjs +149 -102
  34. package/cli/errors.mjs +33 -1
  35. package/cli/evidence-command-runner.mjs +16 -5
  36. package/cli/evidence-signing.mjs +2 -2
  37. package/cli/external-observation-mapping.mjs +7 -8
  38. package/cli/formatters/terminal-lookup.mjs +18 -46
  39. package/cli/formatters/terminal-shared.mjs +5 -3
  40. package/cli/formatters/terminal-technology.mjs +114 -0
  41. package/cli/inline-command-families.mjs +2 -0
  42. package/cli/input.mjs +116 -0
  43. package/cli/interactive-launcher.mjs +2 -160
  44. package/cli/interchange-report.mjs +18 -8
  45. package/cli/investigation-artifacts.mjs +176 -0
  46. package/cli/investigation-checkpoint.mjs +10 -0
  47. package/cli/investigation-folder.mjs +114 -0
  48. package/cli/investigation-manifest.mjs +1 -112
  49. package/cli/investigation-package-review.mjs +103 -0
  50. package/cli/investigation-plan.mjs +32 -19
  51. package/cli/investigation-run.mjs +188 -33
  52. package/cli/local-document-checkpoint.mjs +170 -0
  53. package/cli/lookalike-calibration.mjs +7 -2
  54. package/cli/lookup-brief.mjs +4 -2
  55. package/cli/lookup-browser-view.mjs +1 -1
  56. package/cli/lookup-browser.mjs +5 -12
  57. package/cli/lookup-command-runner.mjs +17 -6
  58. package/cli/lookup-diff.mjs +37 -3
  59. package/cli/lookup-plan.mjs +11 -5
  60. package/cli/lookup-reconcile.mjs +10 -8
  61. package/cli/mail-header-review.mjs +467 -0
  62. package/cli/mail-review.mjs +66 -14
  63. package/cli/manual.mjs +1 -0
  64. package/cli/network-command-runner.mjs +2 -0
  65. package/cli/output-file.mjs +31 -5
  66. package/cli/registry-doctor.mjs +3 -1
  67. package/cli/registry-support.mjs +1 -1
  68. package/cli/retained-artifact-diff.mjs +2 -2
  69. package/cli/review-command-runner.mjs +70 -0
  70. package/cli/risk-calibration.mjs +19 -17
  71. package/cli/runner.mjs +62 -7
  72. package/cli/sharing-review.mjs +21 -34
  73. package/cli/signer-trust.mjs +104 -0
  74. package/cli/source-reliability.mjs +74 -83
  75. package/cli/terminal-input.mjs +168 -0
  76. package/cli/workflow-command-runner.mjs +68 -13
  77. package/docs/cli-reference.md +77 -24
  78. package/docs/cli.md +300 -25
  79. package/lib/abort.mjs +4 -1
  80. package/lib/availability.mjs +161 -112
  81. package/lib/bounded-file.mjs +3 -1
  82. package/lib/bounded-html-document.mjs +125 -0
  83. package/lib/bounded-json.mjs +51 -8
  84. package/lib/browser-library-profile.mjs +42 -51
  85. package/lib/browser-library-worker.mjs +112 -0
  86. package/lib/client-behavior-profile.mjs +3 -3
  87. package/lib/ct-search.mjs +11 -4
  88. package/lib/dns-convergence-review.mjs +1 -0
  89. package/lib/dns-delegation-health.mjs +24 -14
  90. package/lib/dns-inheritance-review.mjs +217 -0
  91. package/lib/dns-intelligence.mjs +73 -26
  92. package/lib/dns-mx.mjs +1 -1
  93. package/lib/dnssec-chain-validation.mjs +6 -0
  94. package/lib/dnssec-evidence-validation.mjs +11 -3
  95. package/lib/domain-change-packet.mjs +49 -16
  96. package/lib/domain-change-review.mjs +14 -8
  97. package/lib/domain-control-flight-recorder.mjs +221 -117
  98. package/lib/domain-control-manifest.mjs +129 -69
  99. package/lib/domain-posture-analysis.mjs +20 -7
  100. package/lib/domain-posture-parsers.mjs +201 -8
  101. package/lib/domain-posture.mjs +147 -29
  102. package/lib/evidence-export-privacy.mjs +561 -0
  103. package/lib/evidence-export.mjs +11 -543
  104. package/lib/evidence-report.mjs +5 -3
  105. package/lib/favicon.mjs +117 -41
  106. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  107. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  108. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  109. package/lib/hostname.mjs +2 -38
  110. package/lib/html-signals.mjs +111 -108
  111. package/lib/http-evidence-bounds.mjs +2 -1
  112. package/lib/http-intelligence.mjs +39 -26
  113. package/lib/local-geoip-evidence.mjs +19 -3
  114. package/lib/lookup-cache.mjs +34 -11
  115. package/lib/lookup-child-profile-contract.mjs +24 -15
  116. package/lib/lookup-diagnostics.mjs +24 -1
  117. package/lib/lookup-progress.mjs +30 -13
  118. package/lib/lookup-response-contract.mjs +1048 -0
  119. package/lib/lookup-response.mjs +8 -10
  120. package/lib/lookup-source-progress.mjs +40 -14
  121. package/lib/lookup.mjs +48 -27
  122. package/lib/nameserver-preflight-review.mjs +1 -0
  123. package/lib/observed-network-context.mjs +43 -27
  124. package/lib/outbound-request-bounds.mjs +4 -1
  125. package/lib/page-fingerprints.mjs +69 -133
  126. package/lib/page-role-profile.mjs +12 -8
  127. package/lib/perceptual-hash.mjs +43 -14
  128. package/lib/rdap-bootstrap.mjs +72 -22
  129. package/lib/rdap-capabilities.mjs +1 -1
  130. package/lib/rdap-client.mjs +15 -7
  131. package/lib/rdap-normalization.mjs +33 -18
  132. package/lib/rdap-search-workbench.mjs +1 -1
  133. package/lib/rdap-transport.mjs +47 -20
  134. package/lib/rdap-types.mjs +2 -1
  135. package/lib/rdap.mjs +18 -3
  136. package/lib/registrable-domain.mjs +21 -1
  137. package/lib/registrar-standing-contract.mjs +1 -0
  138. package/lib/registry-capabilities.mjs +5 -1
  139. package/lib/registry-capability-catalogue.mjs +2 -1
  140. package/lib/registry-dates.mjs +2 -190
  141. package/lib/registry-insights.mjs +85 -34
  142. package/lib/response-policy.mjs +86 -48
  143. package/lib/risk-calibration-summary.mjs +18 -6
  144. package/lib/rpki-evidence.mjs +8 -5
  145. package/lib/safe-fetch.mjs +54 -47
  146. package/lib/security-txt.mjs +42 -7
  147. package/lib/service-binding-dns.mjs +8 -0
  148. package/lib/smtp-transport-review.mjs +27 -15
  149. package/lib/sslbl-certificates.generated.mjs +136 -135
  150. package/lib/static-html-analysis.mjs +178 -151
  151. package/lib/structured-data-identity.mjs +1 -0
  152. package/lib/technology-markup.mjs +100 -0
  153. package/lib/threat-intelligence-manifests.mjs +11 -139
  154. package/lib/threat-intelligence-runtime.mjs +24 -52
  155. package/lib/threat-intelligence-targets.mjs +6 -34
  156. package/lib/threat-intelligence-types.mjs +39 -0
  157. package/lib/typosquat-generator.mjs +19 -15
  158. package/lib/urlhaus-intelligence.mjs +8 -3
  159. package/lib/urlscan-intelligence.mjs +1 -1
  160. package/lib/website-security-posture.mjs +30 -6
  161. package/lib/website-technology.mjs +153 -45
  162. package/lib/whois-authority.mjs +14 -2
  163. package/lib/whois-chain.mjs +9 -2
  164. package/lib/whois-common-formats.mjs +5 -5
  165. package/lib/whois-contracts.mjs +2 -1
  166. package/lib/whois-dialects.mjs +2 -2
  167. package/lib/whois-parser-support.mjs +8 -1
  168. package/lib/whois-transport.mjs +23 -7
  169. package/lib/zone-intent-review.mjs +16 -6
  170. package/package.json +2 -1
  171. package/packages/cases/case-attachment-model.mjs +83 -0
  172. package/packages/cases/case-evidence-model.mjs +156 -62
  173. package/packages/cases/case-export-input.mjs +53 -0
  174. package/packages/cases/case-migration-model.mjs +127 -44
  175. package/packages/cases/case-model.mjs +1 -0
  176. package/packages/cases/case-recheck-model.mjs +91 -0
  177. package/packages/cases/case-record-contracts.mjs +5 -20
  178. package/packages/cases/case-record-core.mjs +12 -22
  179. package/packages/cases/case-record-decisions.mjs +72 -0
  180. package/packages/cases/case-record-model.mjs +1 -0
  181. package/packages/cases/case-record-operations.mjs +95 -31
  182. package/packages/cases/case-record-projection.mjs +221 -0
  183. package/packages/cases/case-report.mjs +70 -54
  184. package/packages/cases/case-response-actions.mjs +654 -0
  185. package/packages/cases/case-response-model.mjs +64 -1115
  186. package/packages/cases/case-response-outcomes.mjs +317 -0
  187. package/packages/cases/case-response-packet-render.mjs +153 -0
  188. package/packages/cases/case-response-packet-types.mjs +1 -0
  189. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  190. package/packages/cases/case-response-packet.mjs +74 -572
  191. package/packages/cases/case-response-records.mjs +94 -0
  192. package/packages/cases/case-response-review-inputs.mjs +371 -0
  193. package/packages/cases/case-response-values.mjs +85 -0
  194. package/packages/cases/case-review-package.mjs +45 -0
  195. package/packages/cases/case-selection.mjs +46 -0
  196. package/packages/cases/case-storage-model.mjs +31 -2
  197. package/packages/cases/response-route-freshness.mjs +16 -0
  198. package/packages/comparison/capture-context.mjs +61 -0
  199. package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
  200. package/packages/comparison/page-similarity.mjs +5 -2
  201. package/packages/comparison/relationship-evidence.mjs +85 -8
  202. package/packages/comparison/relationship-provenance.mjs +93 -0
  203. package/packages/contracts/analyst-interchange.mjs +216 -0
  204. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  205. package/packages/contracts/analyst-review-state.mjs +5 -4
  206. package/packages/contracts/case-portability.mjs +366 -83
  207. package/packages/contracts/case-views-contract.mjs +12 -0
  208. package/packages/contracts/case-views.mjs +26 -0
  209. package/packages/contracts/cli-command-semantics.mjs +94 -0
  210. package/packages/contracts/cli-lookup.mjs +0 -1
  211. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  212. package/packages/contracts/domain-control-manifest.mjs +127 -73
  213. package/packages/contracts/domain-control-monitor.mjs +48 -45
  214. package/packages/contracts/domain-control-review.mjs +179 -121
  215. package/packages/contracts/domain-name.mjs +21 -0
  216. package/packages/contracts/external-observation-interchange.mjs +2 -1
  217. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  218. package/packages/contracts/http-url.mjs +2 -0
  219. package/packages/contracts/investigation-package-limits.mjs +24 -0
  220. package/packages/contracts/investigation-portability.mjs +56 -27
  221. package/packages/contracts/investigation-run.mjs +16 -0
  222. package/packages/contracts/ip-address.mjs +34 -0
  223. package/packages/contracts/lookup-evidence.mjs +35 -0
  224. package/packages/contracts/offline-comparison.mjs +1 -2
  225. package/packages/contracts/page-fingerprints.mjs +15 -0
  226. package/packages/contracts/risk-calibration.mjs +15 -11
  227. package/packages/contracts/schema-compatibility.mjs +1 -1
  228. package/packages/contracts/schema-lifecycle.mjs +83 -153
  229. package/packages/contracts/selected-file-limits.mjs +6 -0
  230. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  231. package/packages/contracts/web-capture.mjs +10 -0
  232. package/packages/contracts/workspace-portability.mjs +183 -211
  233. package/packages/evidence/artifact-integrity.mjs +8 -2
  234. package/packages/evidence/artifact-structure.mjs +105 -0
  235. package/packages/evidence/decision-fact.mjs +4 -1
  236. package/packages/evidence/domain-control-runtime.mjs +201 -33
  237. package/packages/evidence/domain-posture-context.mjs +79 -0
  238. package/packages/evidence/image-regions.mjs +56 -0
  239. package/packages/evidence/latest-observations.mjs +28 -0
  240. package/packages/evidence/lookup-target.mjs +104 -0
  241. package/packages/evidence/observation.mjs +16 -1
  242. package/packages/evidence/passphrase-encryption.mjs +44 -0
  243. package/packages/evidence/record-identity.mjs +72 -0
  244. package/packages/evidence/registry-dates.mjs +190 -0
  245. package/packages/evidence/retained-file.mjs +69 -0
  246. package/packages/evidence/review-age.mjs +14 -0
  247. package/packages/interchange/bagit.mjs +342 -0
  248. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  249. package/packages/interchange/crc32.mjs +17 -0
  250. package/packages/interchange/external-findings-import.mjs +92 -51
  251. package/packages/interchange/web-capture-import.mjs +382 -0
  252. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  253. package/packages/investigation/investigation-bagit.mjs +14 -0
  254. package/packages/investigation/investigation-capsule.mjs +33 -5
  255. package/packages/investigation/investigation-manifest.mjs +200 -0
  256. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  257. package/packages/investigation/investigation-package.mjs +200 -0
  258. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  259. package/packages/investigation/lookup-asset-graph.mjs +193 -165
  260. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  261. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  262. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  263. package/packages/monitoring/analyst-review-state.mjs +38 -90
  264. package/packages/workspace/brand-profile-model.mjs +276 -22
  265. package/packages/workspace/bulk-columns.mjs +17 -0
  266. package/packages/workspace/bulk-review-model.mjs +23 -11
  267. package/packages/workspace/bulk-session-model.mjs +100 -24
  268. package/packages/workspace/bulk-sort.mjs +1 -0
  269. package/packages/workspace/case-views.mjs +102 -0
  270. package/packages/workspace/detection-rule-model.mjs +70 -22
  271. package/packages/workspace/hostile-input.mjs +29 -8
  272. package/packages/workspace/investigation-template-model.mjs +19 -4
  273. package/packages/workspace/page-baseline.mjs +18 -15
  274. package/packages/workspace/relationship-observation-model.mjs +41 -23
  275. package/packages/workspace/watchlist-history.mjs +2 -2
  276. package/packages/workspace/watchlist-store.mjs +8 -2
  277. package/packages/workspace/website-snapshot-model.mjs +164 -26
  278. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  279. package/packages/workspace/workspace-archive.mjs +87 -21
  280. package/third-party-notices.txt +29 -2
  281. package/cli/evidence-command-arguments.mjs +0 -156
  282. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -1,12 +1,15 @@
1
- import { LOOKUP_ASSET_GRAPH_SCHEMA, LOOKUP_ASSET_GRAPH_VERSION, } from "../contracts/investigation-portability.mjs";
1
+ import { LOOKUP_ASSET_GRAPH_SCHEMA, LOOKUP_ASSET_GRAPH_VERSION, MAX_LOOKUP_ASSET_LIMITATIONS, } from "../contracts/investigation-portability.mjs";
2
+ import { MAX_PROFILE_VALUES } from "../contracts/workspace-portability.mjs";
3
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
4
+ import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
5
+ import { createGraphInputReader, graphInputIsIncomplete } from "./lookup-asset-graph-inputs.mjs";
2
6
  export { LOOKUP_ASSET_GRAPH_SCHEMA, LOOKUP_ASSET_GRAPH_VERSION };
3
7
  const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/gu;
4
- const MAX_NODES = 72;
5
- const MAX_EDGES = 120;
6
- const MAX_VALUES = 16;
7
- const MAX_LIMITATIONS = 5;
8
+ const MAX_VALUES = 64;
9
+ const MAX_NESTED_VALUES = 32;
10
+ const MAX_LIMITATIONS = MAX_LOOKUP_ASSET_LIMITATIONS;
8
11
  const MAX_VISUAL_EDGES_PER_HUB = 10;
9
- function projectedNodeGroup(node, edges) {
12
+ function projectedNodeGroup(node, incident) {
10
13
  if (node.kind === 'target')
11
14
  return { id: 'focus', label: 'Lookup target' };
12
15
  if (node.kind === 'address' || node.kind === 'network' || node.kind === 'prefix') {
@@ -23,7 +26,6 @@ function projectedNodeGroup(node, edges) {
23
26
  return { id: 'identity', label: 'Web identity' };
24
27
  }
25
28
  if (node.kind === 'hostname') {
26
- const incident = edges.filter((edge) => edge.source === node.id || edge.target === node.id);
27
29
  if (incident.some((edge) => edge.kind === 'authorizes-name')) {
28
30
  return { id: 'certificate', label: 'Certificates' };
29
31
  }
@@ -36,12 +38,22 @@ function projectedNodeGroup(node, edges) {
36
38
  return { id: 'summary', label: 'Grouped evidence' };
37
39
  return { id: 'evidence', label: 'Other evidence' };
38
40
  }
39
- function interleaveVisualEdgesByFamily(edges, nodesById) {
41
+ function nodeGroups(nodes, edges) {
42
+ const incident = new Map();
43
+ for (const edge of edges) {
44
+ for (const id of [edge.source, edge.target]) {
45
+ const list = incident.get(id) ?? [];
46
+ list.push(edge);
47
+ incident.set(id, list);
48
+ }
49
+ }
50
+ return new Map(nodes.map((node) => [node.id, projectedNodeGroup(node, incident.get(node.id) ?? [])]));
51
+ }
52
+ function interleaveVisualEdgesByFamily(edges, groups) {
40
53
  const buckets = new Map();
41
54
  for (const edge of edges) {
42
- const endpointGroups = [nodesById.get(edge.source), nodesById.get(edge.target)]
43
- .filter((node) => Boolean(node))
44
- .map((node) => projectedNodeGroup(node, edges).id)
55
+ const endpointGroups = [groups.get(edge.source)?.id, groups.get(edge.target)?.id]
56
+ .filter((group) => Boolean(group))
45
57
  .filter((group) => group !== 'focus')
46
58
  .sort();
47
59
  const family = [...new Set(endpointGroups)].join('+') || 'evidence';
@@ -75,37 +87,13 @@ function record(value) {
75
87
  ? value
76
88
  : {};
77
89
  }
78
- function records(value, maximum = MAX_VALUES) {
79
- return Array.isArray(value)
80
- ? value.slice(0, maximum).map(record).filter((item) => Object.keys(item).length > 0)
81
- : [];
82
- }
83
90
  function text(value, maximum = 240) {
84
- return String(value ?? '')
91
+ return (typeof value === 'string' ? value.slice(0, maximum * 4) : typeof value === 'number' && Number.isFinite(value) ? String(value) : '')
85
92
  .replace(CONTROL_CHARACTERS, ' ')
86
93
  .replace(/\s+/gu, ' ')
87
94
  .trim()
88
95
  .slice(0, maximum);
89
96
  }
90
- function textList(value, maximum = MAX_VALUES) {
91
- if (!Array.isArray(value))
92
- return [];
93
- const output = [];
94
- const seen = new Set();
95
- for (const item of value.slice(0, maximum * 2)) {
96
- const normalized = text(item, 320);
97
- if (!normalized || seen.has(normalized))
98
- continue;
99
- seen.add(normalized);
100
- output.push(normalized);
101
- if (output.length >= maximum)
102
- break;
103
- }
104
- return output;
105
- }
106
- function limitations(value) {
107
- return textList(value, MAX_LIMITATIONS).map((item) => text(item, 280));
108
- }
109
97
  function hash(value) {
110
98
  let state = 2_166_136_261;
111
99
  for (let index = 0; index < value.length; index += 1) {
@@ -119,10 +107,7 @@ function nodeId(kind, value) {
119
107
  return `${kind}-${slug || 'value'}-${hash(value)}`;
120
108
  }
121
109
  function isoDate(value) {
122
- if (typeof value !== 'string')
123
- return null;
124
- const timestamp = Date.parse(value);
125
- return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
110
+ return normalizeExplicitIsoTimestamp(value);
126
111
  }
127
112
  function hostname(value) {
128
113
  const candidate = text(value, 320).toLowerCase().replace(/\.$/u, '');
@@ -130,13 +115,16 @@ function hostname(value) {
130
115
  return null;
131
116
  return candidate;
132
117
  }
133
- function urlHost(value) {
118
+ function webOrigin(value) {
134
119
  const candidate = text(value, 2_048);
135
120
  if (!candidate)
136
121
  return null;
137
122
  try {
138
- return new URL(/^[a-z][a-z\d+.-]*:\/\//iu.test(candidate) ? candidate : `https://${candidate}`)
139
- .hostname.toLowerCase().replace(/\.$/u, '');
123
+ const parsed = new URL(/^[a-z][a-z\d+.-]*:\/\//iu.test(candidate) ? candidate : `https://${candidate}`);
124
+ const parsedHostname = hostname(parsed.hostname);
125
+ return parsedHostname && parsed.origin !== 'null'
126
+ ? Object.freeze({ origin: parsed.origin, hostname: parsedHostname })
127
+ : null;
140
128
  }
141
129
  catch {
142
130
  return null;
@@ -158,6 +146,10 @@ function sourceCompleteness(value) {
158
146
  }
159
147
  export function buildLookupAssetGraph(input) {
160
148
  const target = hostname(input.target);
149
+ const suppliedRegistrationDomain = hostname(input.registrationDomain);
150
+ const registrationDomain = suppliedRegistrationDomain && target
151
+ && canonicalRegistrableDomain(target) === suppliedRegistrationDomain
152
+ ? suppliedRegistrationDomain : target;
161
153
  if (!target) {
162
154
  return {
163
155
  version: LOOKUP_ASSET_GRAPH_VERSION,
@@ -165,15 +157,28 @@ export function buildLookupAssetGraph(input) {
165
157
  nodes: [],
166
158
  edges: [],
167
159
  sources: [],
160
+ coverage: { inputs: [] },
168
161
  truncated: false,
169
162
  limitations: ['A normalised domain target is required before an asset graph can be projected.'],
170
163
  };
171
164
  }
172
- const observedAt = isoDate(input.observedAt);
165
+ const reader = createGraphInputReader();
166
+ const textList = (value, id, maximum = MAX_VALUES) => reader.values(id, value, maximum, (item) => {
167
+ if (typeof item !== 'string' || item.length > 320 || item.search(CONTROL_CHARACTERS) >= 0) {
168
+ return null;
169
+ }
170
+ return text(item, 320) || null;
171
+ }, (item) => item);
172
+ const records = (value, id, maximum = MAX_NESTED_VALUES) => reader.values(id, value, maximum, (item) => item && typeof item === 'object' && !Array.isArray(item) ? item : null);
173
+ const limitations = (value, id, mandatory = []) => {
174
+ const optional = textList(value, `${id}.limitations`, MAX_LIMITATIONS - mandatory.length - 1);
175
+ const row = reader.coverage.get(`${id}.limitations`);
176
+ const omitted = row.supplied - row.inspected + row.omitted + row.invalid;
177
+ return [...new Set([...mandatory, ...optional]), ...(omitted ? [`${omitted} additional source limitation entries were not retained; review the source evidence.`] : [])];
178
+ };
173
179
  const nodes = new Map();
174
180
  const edges = new Map();
175
181
  const sources = new Map();
176
- let truncated = false;
177
182
  const targetId = nodeId('target', target);
178
183
  nodes.set(targetId, { id: targetId, label: target, kind: 'target', detail: 'Lookup target' });
179
184
  const addNode = (kind, rawLabel, detail = '') => {
@@ -182,11 +187,7 @@ export function buildLookupAssetGraph(input) {
182
187
  return null;
183
188
  const id = nodeId(kind, label);
184
189
  if (!nodes.has(id)) {
185
- if (nodes.size >= MAX_NODES) {
186
- truncated = true;
187
- return null;
188
- }
189
- nodes.set(id, { id, label, kind, detail: text(detail, 180) });
190
+ nodes.set(id, { id, label, kind, detail: text(detail, 500) });
190
191
  }
191
192
  return id;
192
193
  };
@@ -196,10 +197,6 @@ export function buildLookupAssetGraph(input) {
196
197
  const id = `${edge.kind}-${hash(`${edge.source}|${edge.target}|${edge.sourceLabel}`)}`;
197
198
  if (edges.has(id))
198
199
  return;
199
- if (edges.size >= MAX_EDGES) {
200
- truncated = true;
201
- return;
202
- }
203
200
  const sourceId = `source-${hash(`${edge.sourceLabel}|${edge.href}`)}`;
204
201
  const existingSource = sources.get(sourceId);
205
202
  const sourceLimitations = [...new Set([
@@ -215,7 +212,7 @@ export function buildLookupAssetGraph(input) {
215
212
  id: sourceId,
216
213
  label: edge.sourceLabel,
217
214
  href: edge.href,
218
- observedAt: existingSource?.observedAt ?? edge.observedAt,
215
+ observedAt: existingSource && existingSource.observedAt !== edge.observedAt ? null : edge.observedAt,
219
216
  completeness: sourceCompleteness,
220
217
  limitations: sourceLimitations,
221
218
  });
@@ -228,51 +225,56 @@ export function buildLookupAssetGraph(input) {
228
225
  addEdge({ ...edge, source, target: targetNode });
229
226
  return targetNode;
230
227
  };
231
- const reviewedProfileDomains = new Set([
232
- ...textList(input.profileDomains?.official, 32),
233
- ...textList(input.profileDomains?.partner, 32),
234
- ...textList(input.profileDomains?.allowlisted, 32),
235
- ].map((value) => value.toLowerCase().replace(/\.$/u, '')));
236
- const trustBoundary = (candidate, sourceHost = target) => {
228
+ const registrationId = registrationDomain && registrationDomain !== target
229
+ && target.endsWith(`.${registrationDomain}`)
230
+ ? connect('hostname', registrationDomain, {
231
+ kind: 'registration-namespace', label: 'within registration namespace', sourceLabel: 'Hostname classification',
232
+ observedAt: null, completeness: 'complete', limitations: ['Hostname structure does not establish common ownership or control.'],
233
+ lenses: ['all', 'delegation'], href: '#evidence-registry',
234
+ }, 'Registrable-domain authority') ?? targetId
235
+ : targetId;
236
+ const reviewedProfileDomains = new Set(['official', 'partner', 'allowlisted'].flatMap((key) => reader.values(`profile.${key}`, input.profileDomains?.[key], MAX_PROFILE_VALUES, (value) => typeof value === 'string' && value.length <= 253 ? hostname(value) : null, (value) => value)));
237
+ const trustBoundary = (candidate, sourceHost = target, candidateOrigin = null, sourceOrigin = null) => {
237
238
  if (!candidate)
238
239
  return 'unresolved';
239
- if (candidate === sourceHost)
240
+ if (candidateOrigin && sourceOrigin && candidateOrigin === sourceOrigin)
240
241
  return 'same_origin';
241
- if (candidate === target || candidate.endsWith(`.${target}`))
242
+ if (candidate === sourceHost || candidate === registrationDomain || (registrationDomain && candidate.endsWith(`.${registrationDomain}`)))
242
243
  return 'same_registrable_domain';
243
244
  if (reviewedProfileDomains.has(candidate))
244
245
  return 'reviewed_profile';
246
+ if ([...reader.coverage.values()].some((row) => row.id.startsWith('profile.') && graphInputIsIncomplete(row)))
247
+ return 'unresolved';
245
248
  return 'external';
246
249
  };
247
250
  const dnsEvidence = record(input.dnsEvidence);
248
251
  const dnsRecords = record(input.dnsRecords);
249
252
  const dnsCompleteness = sourceCompleteness(dnsEvidence);
250
- const dnsLimits = limitations(dnsEvidence.limitations);
253
+ const dnsLimits = limitations(dnsEvidence.limitations, 'dns');
251
254
  const dnsEdge = (kind, label, lenses, href = '#evidence-dns') => ({
252
255
  kind,
253
256
  label,
254
257
  sourceLabel: 'DNS',
255
- observedAt: isoDate(dnsEvidence.observedAt) || observedAt,
258
+ observedAt: isoDate(dnsEvidence.observedAt),
256
259
  completeness: dnsCompleteness,
257
260
  limitations: dnsLimits,
258
261
  lenses,
259
262
  href,
260
263
  });
261
- for (const address of [...textList(dnsRecords.a), ...textList(dnsRecords.aaaa)]) {
264
+ for (const address of [...textList(dnsRecords.a, 'dns.a'), ...textList(dnsRecords.aaaa, 'dns.aaaa')]) {
262
265
  connect('address', address, dnsEdge('resolves-to', 'resolves to', ['all']));
263
266
  }
264
- for (const alias of textList(dnsRecords.cname)) {
267
+ for (const alias of textList(dnsRecords.cname, 'dns.cname')) {
265
268
  connect('hostname', alias, dnsEdge('aliases-to', 'aliases to', ['all']));
266
269
  }
267
- for (const nameserver of textList(dnsRecords.ns)) {
270
+ for (const nameserver of textList(dnsRecords.ns, 'dns.ns')) {
268
271
  connect('hostname', nameserver, dnsEdge('uses-nameserver', 'uses nameserver', ['all', 'delegation']));
269
272
  }
270
- for (const mx of Array.isArray(dnsRecords.mx) ? dnsRecords.mx.slice(0, MAX_VALUES) : []) {
271
- const host = mxHostname(mx);
273
+ for (const host of reader.values('dns.mx', dnsRecords.mx, MAX_VALUES, mxHostname, (item) => item)) {
272
274
  if (host)
273
275
  connect('hostname', host, dnsEdge('routes-mail-to', 'routes mail to', ['all']));
274
276
  }
275
- for (const binding of records(dnsRecords.https)) {
277
+ for (const binding of records(dnsRecords.https, 'dns.https', MAX_VALUES)) {
276
278
  const serviceTarget = hostname(binding.target);
277
279
  if (serviceTarget && serviceTarget !== '.') {
278
280
  connect('hostname', serviceTarget, dnsEdge('service-binding', 'publishes HTTPS service target', ['all']), `Priority ${text(binding.priority, 12) || 'unknown'}`);
@@ -290,65 +292,69 @@ export function buildLookupAssetGraph(input) {
290
292
  kind: 'registered-via',
291
293
  label: 'registered via',
292
294
  sourceLabel: 'Registry RDAP',
293
- observedAt,
295
+ observedAt: isoDate(rdapEvidence.fetchedAt ?? rdapEvidence.observedAt),
294
296
  completeness: sourceCompleteness(rdapEvidence),
295
- limitations: limitations(rdapEvidence.limitations),
297
+ limitations: limitations(rdapEvidence.limitations, 'rdap'),
296
298
  lenses: ['all'],
297
299
  href: '#evidence-registry',
298
- }, 'Published registrar of record');
300
+ }, 'Published registrar of record', registrationId);
299
301
  }
300
302
  const delegation = record(dnsEvidence.delegation);
301
- const delegationLimits = limitations(delegation.limitations);
303
+ const delegationLimits = limitations(delegation.limitations, 'delegation');
302
304
  const registryObservationId = addNode('observation', 'Registry publication', 'Registry nameserver and glue evidence');
303
- const parentObservationId = addNode('observation', 'Recursive parent view', 'Point-in-time parent delegation observation');
304
- for (const nameserver of textList(record(delegation.registry).nameservers)) {
305
+ const parentObservationId = addNode('observation', 'Recursive nameserver observation', 'Point-in-time recursive resolver observation, not a direct parent-server query');
306
+ for (const nameserver of textList(record(delegation.registry).nameservers, 'delegation.registry.nameservers')) {
305
307
  const nameserverId = addNode('hostname', nameserver, 'Authoritative nameserver');
306
308
  if (registryObservationId && nameserverId) {
307
309
  addEdge({
308
310
  source: registryObservationId,
309
311
  target: nameserverId,
310
312
  ...dnsEdge('registry-publishes', 'registry publishes', ['delegation']),
313
+ observedAt: isoDate(rdapEvidence.fetchedAt ?? rdapEvidence.observedAt),
311
314
  sourceLabel: 'Registry delegation',
312
315
  completeness: sourceCompleteness(delegation),
313
316
  limitations: delegationLimits,
314
317
  });
315
318
  }
316
319
  }
317
- for (const nameserver of textList(record(delegation.parent).nameservers)) {
320
+ for (const nameserver of textList(record(delegation.parent).nameservers, 'delegation.parent.nameservers')) {
318
321
  const nameserverId = addNode('hostname', nameserver, 'Authoritative nameserver');
319
322
  if (parentObservationId && nameserverId) {
320
323
  addEdge({
321
324
  source: parentObservationId,
322
325
  target: nameserverId,
323
- ...dnsEdge('parent-observes', 'parent view observes', ['delegation']),
324
- sourceLabel: 'Recursive parent view',
326
+ ...dnsEdge('parent-observes', 'recursive resolver observes', ['delegation']),
327
+ observedAt: isoDate(delegation.observedAt),
328
+ sourceLabel: 'Recursive nameserver observation',
325
329
  completeness: sourceCompleteness(delegation),
326
330
  limitations: delegationLimits,
327
331
  });
328
332
  }
329
333
  }
330
- for (const authority of records(delegation.authorities)) {
334
+ for (const [authorityIndex, authority] of records(delegation.authorities, 'delegation.authorities').entries()) {
331
335
  const nameserver = hostname(authority.nameserver);
332
336
  if (!nameserver)
333
337
  continue;
334
338
  const nameserverId = addNode('hostname', nameserver, `Direct authority state: ${text(authority.state, 40) || 'unknown'}`);
335
339
  if (nameserverId) {
336
340
  addEdge({
337
- source: targetId,
341
+ source: registrationId,
338
342
  target: nameserverId,
339
343
  ...dnsEdge('direct-authority', 'queried directly', ['delegation']),
344
+ observedAt: isoDate(delegation.observedAt),
340
345
  sourceLabel: 'Direct authoritative DNS',
341
346
  completeness: authority.state === 'success' ? 'complete' : authority.state ? 'partial' : 'unknown',
342
347
  limitations: delegationLimits,
343
348
  });
344
349
  }
345
- for (const address of textList(authority.addresses, 4)) {
350
+ for (const address of textList(authority.addresses, `delegation.authorities.${authorityIndex}.addresses`)) {
346
351
  const addressId = addNode('address', address, text(authority.addressSource, 80));
347
352
  if (nameserverId && addressId) {
348
353
  addEdge({
349
354
  source: nameserverId,
350
355
  target: addressId,
351
356
  ...dnsEdge('nameserver-address', 'answered at', ['delegation']),
357
+ observedAt: isoDate(delegation.observedAt),
352
358
  sourceLabel: text(authority.addressSource, 80) || 'Nameserver address',
353
359
  completeness: authority.state === 'success' ? 'complete' : 'partial',
354
360
  limitations: delegationLimits,
@@ -370,16 +376,16 @@ export function buildLookupAssetGraph(input) {
370
376
  kind: 'observed-endpoint',
371
377
  label: 'was observed at',
372
378
  sourceLabel: 'Observed network context',
373
- observedAt: isoDate(networkContext.observedAt) || observedAt,
379
+ observedAt: isoDate(networkContext.observedAt),
374
380
  completeness: sourceCompleteness(networkContext),
375
- limitations: limitations(networkContext.limitations),
381
+ limitations: limitations(networkContext.limitations, 'network'),
376
382
  lenses: ['all'],
377
383
  href: '#evidence-network',
378
384
  });
379
385
  }
380
386
  const networkLabel = text(network.name || network.holder || network.handle, 180);
381
387
  const networkId = networkLabel
382
- ? addNode('network', networkLabel, textList(network.cidrs, 4).join(', '))
388
+ ? addNode('network', networkLabel, textList(network.cidrs, 'network.cidrs').join(', '))
383
389
  : null;
384
390
  if (endpointId && networkId) {
385
391
  addEdge({
@@ -388,14 +394,14 @@ export function buildLookupAssetGraph(input) {
388
394
  kind: 'registered-with',
389
395
  label: 'registered within',
390
396
  sourceLabel: 'IP RDAP',
391
- observedAt: isoDate(networkContext.observedAt) || observedAt,
397
+ observedAt: isoDate(networkContext.observedAt),
392
398
  completeness: sourceCompleteness(networkContext),
393
- limitations: limitations(networkContext.limitations),
399
+ limitations: limitations(networkContext.limitations, 'network'),
394
400
  lenses: ['all'],
395
401
  href: '#evidence-network',
396
402
  });
397
403
  }
398
- for (const cidr of textList(network.cidrs, 8)) {
404
+ for (const cidr of textList(network.cidrs, 'network.cidrs')) {
399
405
  const prefixId = addNode('prefix', cidr, 'Published IP RDAP network prefix');
400
406
  const prefixSource = networkId || endpointId;
401
407
  if (prefixId && prefixSource) {
@@ -405,148 +411,149 @@ export function buildLookupAssetGraph(input) {
405
411
  kind: 'publishes-prefix',
406
412
  label: 'publishes prefix',
407
413
  sourceLabel: 'IP RDAP',
408
- observedAt: isoDate(networkContext.observedAt) || observedAt,
414
+ observedAt: isoDate(networkContext.observedAt),
409
415
  completeness: sourceCompleteness(networkContext),
410
- limitations: limitations(networkContext.limitations),
416
+ limitations: limitations(networkContext.limitations, 'network'),
411
417
  lenses: ['all'],
412
418
  href: '#evidence-network',
413
419
  });
414
420
  }
415
421
  }
416
422
  const httpEvidence = record(input.httpEvidence);
417
- const finalHost = urlHost(httpEvidence.finalUrl);
423
+ const finalOrigin = webOrigin(httpEvidence.finalUrl);
424
+ const finalHost = finalOrigin?.hostname ?? null;
418
425
  const finalHostId = finalHost
419
426
  ? connect('hostname', finalHost, {
420
427
  kind: 'redirects-to',
421
428
  label: finalHost === target ? 'served from' : 'redirects to',
422
429
  sourceLabel: 'HTTP',
423
- observedAt: isoDate(httpEvidence.observedAt) || observedAt,
430
+ observedAt: isoDate(httpEvidence.observedAt),
424
431
  completeness: sourceCompleteness(httpEvidence),
425
- limitations: limitations(httpEvidence.limitations),
432
+ limitations: limitations(httpEvidence.limitations, 'http'),
426
433
  lenses: ['all', 'identity'],
427
434
  href: '#evidence-http',
428
435
  boundary: trustBoundary(finalHost),
429
436
  }, 'Observed final website origin')
430
437
  : null;
431
438
  const identitySource = finalHostId || targetId;
432
- const canonicalHost = urlHost(record(input.pageCanonical).url);
433
- if (canonicalHost) {
434
- connect('origin', canonicalHost, {
439
+ const pageObservedAt = isoDate(record(input.pageIdentity).observedAt);
440
+ const canonicalOrigin = webOrigin(record(input.pageCanonical).url);
441
+ if (canonicalOrigin) {
442
+ connect('origin', canonicalOrigin.origin, {
435
443
  kind: 'declares-canonical',
436
444
  label: 'declares canonical origin',
437
445
  sourceLabel: 'HTML canonical metadata',
438
- observedAt,
446
+ observedAt: pageObservedAt,
439
447
  completeness: sourceCompleteness(input.pageIdentity),
440
- limitations: limitations(record(input.pageIdentity).limitations),
448
+ limitations: limitations(record(input.pageIdentity).limitations, 'page'),
441
449
  lenses: ['identity'],
442
450
  href: '#evidence-page',
443
- boundary: trustBoundary(canonicalHost, finalHost || target),
451
+ boundary: trustBoundary(canonicalOrigin.hostname, finalHost || target, canonicalOrigin.origin, finalOrigin?.origin ?? null),
444
452
  }, 'Publisher-declared canonical origin', identitySource);
445
453
  }
446
- const openGraphHost = urlHost(record(input.pageOpenGraphUrl).url);
447
- if (openGraphHost) {
448
- connect('origin', openGraphHost, {
454
+ const openGraphOrigin = webOrigin(record(input.pageOpenGraphUrl).url);
455
+ if (openGraphOrigin) {
456
+ connect('origin', openGraphOrigin.origin, {
449
457
  kind: 'declares-open-graph',
450
458
  label: 'declares Open Graph origin',
451
459
  sourceLabel: 'Open Graph metadata',
452
- observedAt,
460
+ observedAt: pageObservedAt,
453
461
  completeness: sourceCompleteness(input.pageIdentity),
454
- limitations: limitations(record(input.pageIdentity).limitations),
462
+ limitations: limitations(record(input.pageIdentity).limitations, 'page'),
455
463
  lenses: ['identity'],
456
464
  href: '#evidence-page',
457
- boundary: trustBoundary(openGraphHost, finalHost || target),
465
+ boundary: trustBoundary(openGraphOrigin.hostname, finalHost || target, openGraphOrigin.origin, finalOrigin?.origin ?? null),
458
466
  }, 'Publisher-declared Open Graph origin', identitySource);
459
467
  }
460
- for (const origin of textList(record(input.pageForms).externalActionOrigins)) {
461
- const host = urlHost(origin);
462
- if (host) {
463
- connect('origin', host, {
468
+ for (const origin of textList(record(input.pageForms).externalActionOrigins, 'page.formOrigins')) {
469
+ const destination = webOrigin(origin);
470
+ if (destination) {
471
+ connect('origin', destination.origin, {
464
472
  kind: 'form-destination',
465
473
  label: 'form may submit to',
466
474
  sourceLabel: 'Static HTML form',
467
- observedAt,
475
+ observedAt: pageObservedAt,
468
476
  completeness: sourceCompleteness(input.pageIdentity),
469
- limitations: ['A declared form action does not prove that a user submitted data or that the endpoint received it.'],
477
+ limitations: limitations(record(input.pageIdentity).limitations, 'page.forms', ['A declared form action does not prove that a user submitted data or that the endpoint received it.']),
470
478
  lenses: ['identity'],
471
479
  href: '#evidence-page',
472
- boundary: trustBoundary(host, finalHost || target),
480
+ boundary: trustBoundary(destination.hostname, finalHost || target, destination.origin, finalOrigin?.origin ?? null),
473
481
  }, 'External form-action origin', identitySource);
474
482
  }
475
483
  }
476
- for (const origin of textList(record(input.pageResources).externalOrigins)) {
477
- const host = urlHost(origin);
478
- if (host) {
479
- connect('hostname', host, {
484
+ for (const origin of textList(record(input.pageResources).externalOrigins, 'page.resourceOrigins')) {
485
+ const resource = webOrigin(origin);
486
+ if (resource) {
487
+ connect('origin', resource.origin, {
480
488
  kind: 'loads-from',
481
489
  label: 'references resources from',
482
490
  sourceLabel: 'Static HTML resources',
483
- observedAt,
491
+ observedAt: pageObservedAt,
484
492
  completeness: sourceCompleteness(input.pageIdentity),
485
- limitations: ['A static resource reference does not prove that a browser loaded the resource or disclosed data to it.'],
493
+ limitations: limitations(record(input.pageIdentity).limitations, 'page.resources', ['A static resource reference does not prove that a browser loaded the resource or disclosed data to it.']),
486
494
  lenses: ['identity'],
487
495
  href: '#evidence-page',
488
- boundary: trustBoundary(host, finalHost || target),
496
+ boundary: trustBoundary(resource.hostname, finalHost || target, resource.origin, finalOrigin?.origin ?? null),
489
497
  }, 'External resource origin', identitySource);
490
498
  }
491
499
  }
492
- for (const identifier of records(record(input.pageIdentity).trackingIdentifiers, 12)) {
500
+ for (const identifier of records(record(input.pageIdentity).trackingIdentifiers, 'page.trackingIdentifiers', MAX_VALUES)) {
493
501
  const value = text(identifier.value, 160);
494
502
  if (value) {
495
503
  connect('tracker', value, {
496
504
  kind: 'declares-tracker',
497
505
  label: 'declares tracker identifier',
498
506
  sourceLabel: 'Static HTML identity',
499
- observedAt,
507
+ observedAt: pageObservedAt,
500
508
  completeness: sourceCompleteness(input.pageIdentity),
501
- limitations: ['A shared identifier is a relationship lead and does not establish common ownership or control.'],
509
+ limitations: limitations(record(input.pageIdentity).limitations, 'page.trackers', ['A shared identifier is a relationship lead and does not establish common ownership or control.']),
502
510
  lenses: ['identity'],
503
511
  href: '#evidence-page',
504
512
  }, text(identifier.type, 80), identitySource);
505
513
  }
506
514
  }
507
515
  const structuredIdentity = record(input.structuredDataIdentity);
508
- for (const entity of records(structuredIdentity.entities, 12)) {
516
+ const structuredObservedAt = isoDate(structuredIdentity.observedAt);
517
+ const structuredLimits = limitations(structuredIdentity.limitations, 'structured', [
518
+ 'Publisher-declared structured metadata is not independent identity verification.',
519
+ 'A sameAs declaration is a site-authored claim and does not establish ownership or control.',
520
+ ]);
521
+ for (const [entityIndex, entity] of records(structuredIdentity.entities, 'structured.entities').entries()) {
509
522
  const entityLabel = text(entity.name, 180);
510
- const declaredOrigin = urlHost(entity.declaredOrigin);
523
+ const declaredOrigin = webOrigin(entity.declaredOrigin);
511
524
  const entityId = entityLabel
512
525
  ? connect('identity', entityLabel, {
513
526
  kind: 'declares-publisher',
514
527
  label: 'declares publisher',
515
528
  sourceLabel: 'Structured identity metadata',
516
- observedAt,
529
+ observedAt: structuredObservedAt,
517
530
  completeness: sourceCompleteness(structuredIdentity),
518
- limitations: [
519
- ...limitations(structuredIdentity.limitations),
520
- 'Publisher-declared structured metadata is not independent identity verification.',
521
- ].slice(0, MAX_LIMITATIONS),
531
+ limitations: structuredLimits,
522
532
  lenses: ['identity'],
523
533
  href: '#evidence-structured-identity',
524
- }, textList(entity.types, 5).join(', ') || 'Publisher-declared entity', identitySource)
534
+ }, textList(entity.types, `structured.entities.${entityIndex}.types`, MAX_NESTED_VALUES).join(', ') || 'Publisher-declared entity', identitySource)
525
535
  : identitySource;
526
536
  if (declaredOrigin) {
527
- connect('origin', declaredOrigin, {
537
+ connect('origin', declaredOrigin.origin, {
528
538
  kind: 'declares-origin',
529
539
  label: 'declares origin',
530
540
  sourceLabel: 'Structured identity metadata',
531
- observedAt,
541
+ observedAt: structuredObservedAt,
532
542
  completeness: sourceCompleteness(structuredIdentity),
533
- limitations: limitations(structuredIdentity.limitations),
543
+ limitations: structuredLimits,
534
544
  lenses: ['identity'],
535
545
  href: '#evidence-structured-identity',
536
- boundary: trustBoundary(declaredOrigin, finalHost || target),
546
+ boundary: trustBoundary(declaredOrigin.hostname, finalHost || target, declaredOrigin.origin, finalOrigin?.origin ?? null),
537
547
  }, 'Publisher-declared structured-data origin', entityId || identitySource);
538
548
  }
539
- for (const sameAsHost of textList(entity.sameAsHosts, 12)) {
549
+ for (const sameAsHost of textList(entity.sameAsHosts, `structured.entities.${entityIndex}.sameAsHosts`, MAX_NESTED_VALUES)) {
540
550
  connect('origin', sameAsHost, {
541
551
  kind: 'declares-same-as',
542
552
  label: 'declares sameAs',
543
553
  sourceLabel: 'Structured identity metadata',
544
- observedAt,
554
+ observedAt: structuredObservedAt,
545
555
  completeness: sourceCompleteness(structuredIdentity),
546
- limitations: [
547
- ...limitations(structuredIdentity.limitations),
548
- 'A sameAs declaration is a site-authored claim and does not establish ownership or control.',
549
- ].slice(0, MAX_LIMITATIONS),
556
+ limitations: structuredLimits,
550
557
  lenses: ['identity'],
551
558
  href: '#evidence-structured-identity',
552
559
  boundary: trustBoundary(hostname(sameAsHost), finalHost || target),
@@ -568,9 +575,9 @@ export function buildLookupAssetGraph(input) {
568
575
  kind: 'presents-certificate',
569
576
  label: 'presented certificate',
570
577
  sourceLabel: 'TLS',
571
- observedAt: isoDate(tlsEvidence.observedAt) || observedAt,
578
+ observedAt: isoDate(tlsEvidence.observedAt),
572
579
  completeness: sourceCompleteness(tlsEvidence),
573
- limitations: limitations(tlsEvidence.limitations),
580
+ limitations: limitations(tlsEvidence.limitations, 'tls'),
574
581
  lenses: ['all', 'certificate'],
575
582
  href: '#evidence-tls',
576
583
  }, certificateDetail)
@@ -588,9 +595,9 @@ export function buildLookupAssetGraph(input) {
588
595
  kind: 'reviewed-hostname-match',
589
596
  label: 'reviewed hostname match',
590
597
  sourceLabel: 'TLS hostname verification',
591
- observedAt: isoDate(tlsEvidence.observedAt) || observedAt,
598
+ observedAt: isoDate(tlsEvidence.observedAt),
592
599
  completeness: sourceCompleteness(tlsEvidence),
593
- limitations: limitations(tlsEvidence.limitations),
600
+ limitations: limitations(tlsEvidence.limitations, 'tls'),
594
601
  lenses: ['certificate', 'identity'],
595
602
  href: '#evidence-tls',
596
603
  });
@@ -600,9 +607,9 @@ export function buildLookupAssetGraph(input) {
600
607
  kind: 'evaluated-certificate',
601
608
  label: 'evaluated certificate',
602
609
  sourceLabel: 'TLS hostname verification',
603
- observedAt: isoDate(tlsEvidence.observedAt) || observedAt,
610
+ observedAt: isoDate(tlsEvidence.observedAt),
604
611
  completeness: sourceCompleteness(tlsEvidence),
605
- limitations: limitations(tlsEvidence.limitations),
612
+ limitations: limitations(tlsEvidence.limitations, 'tls'),
606
613
  lenses: ['certificate'],
607
614
  href: '#evidence-tls',
608
615
  });
@@ -620,15 +627,15 @@ export function buildLookupAssetGraph(input) {
620
627
  kind: 'reviewed-runtime-trust',
621
628
  label: 'reviewed runtime trust',
622
629
  sourceLabel: 'TLS chain verification',
623
- observedAt: isoDate(tlsEvidence.observedAt) || observedAt,
630
+ observedAt: isoDate(tlsEvidence.observedAt),
624
631
  completeness: sourceCompleteness(tlsEvidence),
625
- limitations: limitations(tlsEvidence.limitations),
632
+ limitations: limitations(tlsEvidence.limitations, 'tls'),
626
633
  lenses: ['certificate'],
627
634
  href: '#evidence-tls',
628
635
  });
629
636
  }
630
637
  }
631
- for (const name of textList(record(input.tlsAltNames).dnsNames, 16)) {
638
+ for (const name of textList(record(input.tlsAltNames).dnsNames, 'tls.dnsNames')) {
632
639
  const sanId = addNode('hostname', name, 'Certificate DNS subject alternative name');
633
640
  if (sanId) {
634
641
  addEdge({
@@ -637,9 +644,9 @@ export function buildLookupAssetGraph(input) {
637
644
  kind: 'authorizes-name',
638
645
  label: 'contains SAN',
639
646
  sourceLabel: 'TLS certificate',
640
- observedAt: isoDate(tlsEvidence.observedAt) || observedAt,
647
+ observedAt: isoDate(tlsEvidence.observedAt),
641
648
  completeness: sourceCompleteness(tlsEvidence),
642
- limitations: limitations(tlsEvidence.limitations),
649
+ limitations: limitations(tlsEvidence.limitations, 'tls'),
643
650
  lenses: ['certificate', 'identity'],
644
651
  href: '#evidence-tls',
645
652
  boundary: trustBoundary(hostname(name)),
@@ -657,34 +664,48 @@ export function buildLookupAssetGraph(input) {
657
664
  kind: 'uses-key',
658
665
  label: 'uses public key',
659
666
  sourceLabel: 'TLS certificate',
660
- observedAt: isoDate(tlsEvidence.observedAt) || observedAt,
667
+ observedAt: isoDate(tlsEvidence.observedAt),
661
668
  completeness: sourceCompleteness(tlsEvidence),
662
- limitations: limitations(tlsEvidence.limitations),
669
+ limitations: limitations(tlsEvidence.limitations, 'tls'),
663
670
  lenses: ['certificate'],
664
671
  href: '#evidence-tls',
665
672
  });
666
673
  }
667
674
  }
668
675
  const issuer = record(input.tlsIssuer);
669
- const issuerLabel = text(issuer.organization || issuer.commonName || issuer.CN, 180);
676
+ const issuerOrganizations = textList(issuer.organizations, 'tls.issuer.organizations');
677
+ const issuerCommonNames = textList(issuer.commonNames, 'tls.issuer.commonNames');
678
+ const issuerLabel = issuerOrganizations[0]
679
+ || issuerCommonNames[0]
680
+ || text(issuer.organization || issuer.commonName || issuer.CN, 180);
670
681
  if (issuerLabel) {
671
682
  const issuerId = addNode('issuer', issuerLabel, 'Certificate issuer');
672
683
  if (issuerId) {
684
+ for (const [attribute, values] of [['organization', issuerOrganizations], ['common-name', issuerCommonNames]]) {
685
+ for (const value of values) {
686
+ connect('identity', value, {
687
+ kind: `issuer-${attribute}`, label: `declares issuer ${attribute === 'organization' ? 'organisation' : 'common name'}`,
688
+ sourceLabel: 'TLS certificate', observedAt: isoDate(tlsEvidence.observedAt),
689
+ completeness: sourceCompleteness(tlsEvidence), limitations: limitations(tlsEvidence.limitations, 'tls'),
690
+ lenses: ['certificate'], href: '#evidence-tls',
691
+ }, 'Certificate issuer distinguished-name attribute', issuerId);
692
+ }
693
+ }
673
694
  addEdge({
674
695
  source: certificateId,
675
696
  target: issuerId,
676
697
  kind: 'issued-by',
677
698
  label: 'issued by',
678
699
  sourceLabel: 'TLS certificate',
679
- observedAt: isoDate(tlsEvidence.observedAt) || observedAt,
700
+ observedAt: isoDate(tlsEvidence.observedAt),
680
701
  completeness: sourceCompleteness(tlsEvidence),
681
- limitations: limitations(tlsEvidence.limitations),
702
+ limitations: limitations(tlsEvidence.limitations, 'tls'),
682
703
  lenses: ['certificate'],
683
704
  href: '#evidence-tls',
684
705
  });
685
706
  }
686
707
  }
687
- for (const finding of records(record(input.certificatePolicyReview).findings, 8)) {
708
+ for (const [findingIndex, finding] of records(record(input.certificatePolicyReview).findings, 'certificatePolicy.findings').entries()) {
688
709
  const findingId = text(finding.id, 80);
689
710
  const state = text(finding.state, 80);
690
711
  if (!findingId || state === 'not_configured')
@@ -697,21 +718,27 @@ export function buildLookupAssetGraph(input) {
697
718
  kind: 'reviewed-against-policy',
698
719
  label: 'reviewed against',
699
720
  sourceLabel: 'DNS / TLS / Brand Profile',
700
- observedAt: isoDate(record(input.certificatePolicyReview).observedAt) || observedAt,
721
+ observedAt: isoDate(record(input.certificatePolicyReview).observedAt),
701
722
  completeness: state === 'indeterminate' || state === 'no_target_policy_observed' ? 'partial' : 'complete',
702
- limitations: limitations(finding.limitations),
723
+ limitations: limitations(finding.limitations, `certificatePolicy.${findingIndex}`),
703
724
  lenses: ['certificate'],
704
725
  href: '#evidence-certificate-policy',
705
726
  });
706
727
  }
707
728
  }
708
729
  }
730
+ const retainedEdges = [...edges.values()];
731
+ const connectedNodeIds = new Set([targetId, ...[...edges.values()].flatMap((edge) => [edge.source, edge.target])]);
732
+ const retainedSourceIds = new Set(retainedEdges.map((edge) => edge.sourceId));
733
+ const inputCoverage = [...reader.coverage.values()];
734
+ const truncated = inputCoverage.some(graphInputIsIncomplete);
709
735
  return {
710
736
  version: LOOKUP_ASSET_GRAPH_VERSION,
711
737
  targetId,
712
- nodes: [...nodes.values()],
713
- edges: [...edges.values()],
714
- sources: [...sources.values()].sort((left, right) => left.label.localeCompare(right.label)),
738
+ nodes: [...nodes.values()].filter((node) => connectedNodeIds.has(node.id)),
739
+ edges: retainedEdges,
740
+ sources: [...sources.values()].filter((source) => retainedSourceIds.has(source.id)).sort((left, right) => left.label.localeCompare(right.label)),
741
+ coverage: { inputs: inputCoverage },
715
742
  truncated,
716
743
  limitations: [
717
744
  'The graph contains only settled evidence from this Lookup and does not start additional requests.',
@@ -732,10 +759,11 @@ export function countLookupAssetGraphEdgesByLens(graph) {
732
759
  export function projectLookupAssetGraph(graph, lens) {
733
760
  const acceptedEdges = graph.edges.filter((edge) => edgeMatchesLens(edge, lens));
734
761
  const graphNodeById = new Map(graph.nodes.map((node) => [node.id, node]));
762
+ const groups = nodeGroups(graph.nodes, acceptedEdges);
735
763
  const visualEdges = [];
736
764
  const visualDegree = new Map();
737
765
  const omittedByHub = new Map();
738
- for (const edge of interleaveVisualEdgesByFamily(acceptedEdges, graphNodeById)) {
766
+ for (const edge of interleaveVisualEdgesByFamily(acceptedEdges, groups)) {
739
767
  const sourceDegree = visualDegree.get(edge.source) ?? 0;
740
768
  const targetDegree = visualDegree.get(edge.target) ?? 0;
741
769
  const saturatedHub = sourceDegree >= MAX_VISUAL_EDGES_PER_HUB
@@ -764,7 +792,7 @@ export function projectLookupAssetGraph(graph, lens) {
764
792
  const nodes = graph.nodes
765
793
  .filter((node) => nodeIds.has(node.id))
766
794
  .map((node) => {
767
- const group = projectedNodeGroup(node, visualEdges);
795
+ const group = groups.get(node.id);
768
796
  return {
769
797
  id: node.id,
770
798
  label: node.label,