@slicedearth/whoisleuth-cli 2.3.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 (264) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +19 -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 +68 -6
  7. package/cli/artifact-validation/case-response.mjs +47 -41
  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 +83 -61
  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-command-runner.mjs +2 -0
  18. package/cli/bulk-output.mjs +29 -3
  19. package/cli/case-command.mjs +195 -0
  20. package/cli/case-pack.mjs +39 -31
  21. package/cli/command-argument-grammar.mjs +4 -0
  22. package/cli/command-reference.mjs +145 -75
  23. package/cli/completion.mjs +298 -79
  24. package/cli/config-profile.mjs +7 -5
  25. package/cli/ct-event-intake.mjs +12 -12
  26. package/cli/discover.mjs +4 -3
  27. package/cli/discovery-observation-snapshot.mjs +44 -16
  28. package/cli/discovery-scan-command-runner.mjs +2 -0
  29. package/cli/discovery-scan.mjs +7 -4
  30. package/cli/discovery-workflow.mjs +1 -1
  31. package/cli/domain-control-monitor.mjs +50 -20
  32. package/cli/domain-control-observations.mjs +149 -102
  33. package/cli/errors.mjs +33 -1
  34. package/cli/evidence-command-runner.mjs +16 -5
  35. package/cli/evidence-signing.mjs +2 -2
  36. package/cli/external-observation-mapping.mjs +7 -8
  37. package/cli/formatters/terminal-lookup.mjs +18 -46
  38. package/cli/formatters/terminal-shared.mjs +5 -3
  39. package/cli/formatters/terminal-technology.mjs +114 -0
  40. package/cli/inline-command-families.mjs +1 -0
  41. package/cli/interactive-launcher.mjs +2 -160
  42. package/cli/interchange-report.mjs +4 -8
  43. package/cli/investigation-artifacts.mjs +176 -0
  44. package/cli/investigation-checkpoint.mjs +10 -0
  45. package/cli/investigation-folder.mjs +114 -0
  46. package/cli/investigation-manifest.mjs +1 -112
  47. package/cli/investigation-package-review.mjs +103 -0
  48. package/cli/investigation-plan.mjs +32 -19
  49. package/cli/investigation-run.mjs +188 -33
  50. package/cli/local-document-checkpoint.mjs +170 -0
  51. package/cli/lookup-browser.mjs +5 -12
  52. package/cli/lookup-command-runner.mjs +17 -6
  53. package/cli/lookup-diff.mjs +37 -3
  54. package/cli/lookup-plan.mjs +11 -5
  55. package/cli/lookup-reconcile.mjs +10 -8
  56. package/cli/mail-review.mjs +66 -14
  57. package/cli/manual.mjs +1 -0
  58. package/cli/network-command-runner.mjs +2 -0
  59. package/cli/output-file.mjs +31 -5
  60. package/cli/registry-doctor.mjs +3 -1
  61. package/cli/registry-support.mjs +1 -1
  62. package/cli/retained-artifact-diff.mjs +2 -2
  63. package/cli/review-command-runner.mjs +45 -0
  64. package/cli/runner.mjs +55 -6
  65. package/cli/sharing-review.mjs +21 -34
  66. package/cli/signer-trust.mjs +104 -0
  67. package/cli/source-reliability.mjs +74 -83
  68. package/cli/terminal-input.mjs +168 -0
  69. package/cli/workflow-command-runner.mjs +68 -13
  70. package/docs/cli-reference.md +76 -30
  71. package/docs/cli.md +289 -25
  72. package/lib/abort.mjs +4 -1
  73. package/lib/availability.mjs +161 -112
  74. package/lib/bounded-file.mjs +3 -1
  75. package/lib/bounded-html-document.mjs +125 -0
  76. package/lib/bounded-json.mjs +43 -3
  77. package/lib/browser-library-profile.mjs +42 -51
  78. package/lib/browser-library-worker.mjs +112 -0
  79. package/lib/client-behavior-profile.mjs +3 -3
  80. package/lib/ct-search.mjs +11 -4
  81. package/lib/dns-delegation-health.mjs +24 -14
  82. package/lib/dns-inheritance-review.mjs +217 -0
  83. package/lib/dns-intelligence.mjs +36 -23
  84. package/lib/dns-mx.mjs +1 -1
  85. package/lib/dnssec-evidence-validation.mjs +11 -3
  86. package/lib/domain-change-packet.mjs +7 -2
  87. package/lib/domain-change-review.mjs +14 -8
  88. package/lib/domain-control-flight-recorder.mjs +219 -118
  89. package/lib/domain-control-manifest.mjs +117 -57
  90. package/lib/domain-posture-analysis.mjs +20 -7
  91. package/lib/domain-posture-parsers.mjs +88 -6
  92. package/lib/domain-posture.mjs +113 -26
  93. package/lib/evidence-export-privacy.mjs +561 -0
  94. package/lib/evidence-export.mjs +11 -592
  95. package/lib/evidence-report.mjs +3 -1
  96. package/lib/favicon.mjs +117 -41
  97. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  98. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  99. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  100. package/lib/hostname.mjs +2 -38
  101. package/lib/html-signals.mjs +82 -85
  102. package/lib/http-evidence-bounds.mjs +2 -1
  103. package/lib/http-intelligence.mjs +39 -26
  104. package/lib/lookup-cache.mjs +34 -11
  105. package/lib/lookup-child-profile-contract.mjs +24 -15
  106. package/lib/lookup-diagnostics.mjs +24 -1
  107. package/lib/lookup-progress.mjs +30 -13
  108. package/lib/lookup-response-contract.mjs +1048 -0
  109. package/lib/lookup-response.mjs +7 -10
  110. package/lib/lookup-source-progress.mjs +17 -14
  111. package/lib/lookup.mjs +48 -27
  112. package/lib/observed-network-context.mjs +43 -27
  113. package/lib/outbound-request-bounds.mjs +4 -1
  114. package/lib/page-fingerprints.mjs +69 -133
  115. package/lib/page-role-profile.mjs +12 -8
  116. package/lib/perceptual-hash.mjs +43 -14
  117. package/lib/rdap-bootstrap.mjs +72 -22
  118. package/lib/rdap-client.mjs +15 -7
  119. package/lib/rdap-normalization.mjs +33 -18
  120. package/lib/rdap-search-workbench.mjs +1 -1
  121. package/lib/rdap-transport.mjs +47 -20
  122. package/lib/rdap-types.mjs +2 -1
  123. package/lib/rdap.mjs +18 -3
  124. package/lib/registrable-domain.mjs +21 -1
  125. package/lib/registrar-standing-contract.mjs +1 -0
  126. package/lib/registry-capabilities.mjs +5 -1
  127. package/lib/registry-capability-catalogue.mjs +2 -1
  128. package/lib/registry-dates.mjs +2 -190
  129. package/lib/registry-insights.mjs +56 -18
  130. package/lib/response-policy.mjs +86 -48
  131. package/lib/risk-calibration-summary.mjs +17 -3
  132. package/lib/rpki-evidence.mjs +8 -5
  133. package/lib/safe-fetch.mjs +54 -47
  134. package/lib/security-txt.mjs +10 -7
  135. package/lib/service-binding-dns.mjs +8 -0
  136. package/lib/smtp-transport-review.mjs +27 -15
  137. package/lib/sslbl-certificates.generated.mjs +136 -135
  138. package/lib/static-html-analysis.mjs +117 -146
  139. package/lib/structured-data-identity.mjs +1 -0
  140. package/lib/technology-markup.mjs +100 -0
  141. package/lib/threat-intelligence-manifests.mjs +11 -139
  142. package/lib/threat-intelligence-runtime.mjs +24 -52
  143. package/lib/threat-intelligence-targets.mjs +6 -34
  144. package/lib/threat-intelligence-types.mjs +39 -0
  145. package/lib/typosquat-generator.mjs +19 -15
  146. package/lib/urlhaus-intelligence.mjs +8 -3
  147. package/lib/urlscan-intelligence.mjs +1 -1
  148. package/lib/website-security-posture.mjs +30 -6
  149. package/lib/website-technology.mjs +111 -71
  150. package/lib/whois-authority.mjs +14 -2
  151. package/lib/whois-chain.mjs +9 -2
  152. package/lib/whois-common-formats.mjs +5 -5
  153. package/lib/whois-contracts.mjs +2 -1
  154. package/lib/whois-dialects.mjs +2 -2
  155. package/lib/whois-parser-support.mjs +8 -1
  156. package/lib/whois-transport.mjs +23 -7
  157. package/package.json +2 -1
  158. package/packages/cases/case-attachment-model.mjs +83 -0
  159. package/packages/cases/case-evidence-model.mjs +156 -62
  160. package/packages/cases/case-export-input.mjs +53 -0
  161. package/packages/cases/case-migration-model.mjs +59 -25
  162. package/packages/cases/case-model.mjs +1 -0
  163. package/packages/cases/case-recheck-model.mjs +91 -0
  164. package/packages/cases/case-record-core.mjs +3 -2
  165. package/packages/cases/case-record-operations.mjs +89 -27
  166. package/packages/cases/case-record-projection.mjs +23 -20
  167. package/packages/cases/case-report.mjs +63 -52
  168. package/packages/cases/case-response-actions.mjs +654 -0
  169. package/packages/cases/case-response-model.mjs +41 -1129
  170. package/packages/cases/case-response-outcomes.mjs +317 -0
  171. package/packages/cases/case-response-packet-render.mjs +153 -0
  172. package/packages/cases/case-response-packet-types.mjs +1 -0
  173. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  174. package/packages/cases/case-response-packet.mjs +38 -587
  175. package/packages/cases/case-response-records.mjs +94 -0
  176. package/packages/cases/case-response-review-inputs.mjs +371 -0
  177. package/packages/cases/case-response-values.mjs +85 -0
  178. package/packages/cases/case-review-package.mjs +45 -0
  179. package/packages/cases/case-selection.mjs +46 -0
  180. package/packages/cases/case-storage-model.mjs +25 -0
  181. package/packages/cases/response-route-freshness.mjs +16 -0
  182. package/packages/comparison/capture-context.mjs +61 -0
  183. package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
  184. package/packages/comparison/page-similarity.mjs +2 -1
  185. package/packages/comparison/relationship-evidence.mjs +85 -8
  186. package/packages/comparison/relationship-provenance.mjs +93 -0
  187. package/packages/contracts/analyst-interchange.mjs +216 -0
  188. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  189. package/packages/contracts/analyst-review-state.mjs +5 -4
  190. package/packages/contracts/case-portability.mjs +279 -116
  191. package/packages/contracts/case-views-contract.mjs +12 -0
  192. package/packages/contracts/case-views.mjs +26 -0
  193. package/packages/contracts/cli-command-semantics.mjs +1 -0
  194. package/packages/contracts/cli-lookup.mjs +0 -1
  195. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  196. package/packages/contracts/domain-control-manifest.mjs +127 -73
  197. package/packages/contracts/domain-control-monitor.mjs +48 -45
  198. package/packages/contracts/domain-control-review.mjs +179 -121
  199. package/packages/contracts/domain-name.mjs +21 -0
  200. package/packages/contracts/external-observation-interchange.mjs +2 -1
  201. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  202. package/packages/contracts/http-url.mjs +2 -0
  203. package/packages/contracts/investigation-package-limits.mjs +24 -0
  204. package/packages/contracts/investigation-portability.mjs +56 -27
  205. package/packages/contracts/investigation-run.mjs +16 -0
  206. package/packages/contracts/ip-address.mjs +34 -0
  207. package/packages/contracts/lookup-evidence.mjs +35 -0
  208. package/packages/contracts/offline-comparison.mjs +1 -2
  209. package/packages/contracts/page-fingerprints.mjs +15 -0
  210. package/packages/contracts/risk-calibration.mjs +0 -1
  211. package/packages/contracts/schema-compatibility.mjs +1 -1
  212. package/packages/contracts/schema-lifecycle.mjs +83 -153
  213. package/packages/contracts/selected-file-limits.mjs +6 -0
  214. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  215. package/packages/contracts/web-capture.mjs +10 -0
  216. package/packages/contracts/workspace-portability.mjs +160 -225
  217. package/packages/evidence/artifact-integrity.mjs +8 -2
  218. package/packages/evidence/artifact-structure.mjs +105 -0
  219. package/packages/evidence/decision-fact.mjs +4 -1
  220. package/packages/evidence/domain-control-runtime.mjs +155 -27
  221. package/packages/evidence/domain-posture-context.mjs +79 -0
  222. package/packages/evidence/image-regions.mjs +56 -0
  223. package/packages/evidence/latest-observations.mjs +28 -0
  224. package/packages/evidence/lookup-target.mjs +104 -0
  225. package/packages/evidence/observation.mjs +16 -1
  226. package/packages/evidence/passphrase-encryption.mjs +44 -0
  227. package/packages/evidence/record-identity.mjs +72 -0
  228. package/packages/evidence/registry-dates.mjs +190 -0
  229. package/packages/evidence/retained-file.mjs +69 -0
  230. package/packages/evidence/review-age.mjs +14 -0
  231. package/packages/interchange/bagit.mjs +342 -0
  232. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  233. package/packages/interchange/crc32.mjs +17 -0
  234. package/packages/interchange/external-findings-import.mjs +89 -49
  235. package/packages/interchange/web-capture-import.mjs +382 -0
  236. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  237. package/packages/investigation/investigation-bagit.mjs +14 -0
  238. package/packages/investigation/investigation-capsule.mjs +33 -5
  239. package/packages/investigation/investigation-manifest.mjs +200 -0
  240. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  241. package/packages/investigation/investigation-package.mjs +200 -0
  242. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  243. package/packages/investigation/lookup-asset-graph.mjs +161 -141
  244. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  245. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  246. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  247. package/packages/monitoring/analyst-review-state.mjs +38 -90
  248. package/packages/workspace/brand-profile-model.mjs +158 -20
  249. package/packages/workspace/bulk-columns.mjs +17 -0
  250. package/packages/workspace/bulk-review-model.mjs +11 -5
  251. package/packages/workspace/bulk-session-model.mjs +95 -20
  252. package/packages/workspace/bulk-sort.mjs +1 -0
  253. package/packages/workspace/case-views.mjs +102 -0
  254. package/packages/workspace/hostile-input.mjs +29 -8
  255. package/packages/workspace/investigation-template-model.mjs +19 -4
  256. package/packages/workspace/page-baseline.mjs +18 -15
  257. package/packages/workspace/relationship-observation-model.mjs +41 -26
  258. package/packages/workspace/watchlist-history.mjs +2 -2
  259. package/packages/workspace/watchlist-store.mjs +8 -2
  260. package/packages/workspace/website-snapshot-model.mjs +70 -30
  261. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  262. package/packages/workspace/workspace-archive.mjs +85 -21
  263. package/third-party-notices.txt +29 -2
  264. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -1,5 +1,7 @@
1
1
  import { registerCuratedConnector, registerThreatIntelligenceProvider, isCuratedConnectorDefinition, isThreatIntelligenceProvider, } from "./threat-intelligence-definition-registry.mjs";
2
2
  import { CURATED_CONNECTOR_CONTRACT_VERSION, MAX_CACHE_TTL_MS, MAX_CONNECTOR_ENTITIES, MAX_CONNECTOR_RELATIONSHIPS, MAX_RESPONSE_BYTES, MAX_TIMEOUT_MS, THREAT_INTELLIGENCE_CONTRACT_VERSION, } from "./threat-intelligence-runtime.mjs";
3
+ import { boundedInteger, boundedHttpsUrl, enumValue, exactKeys, isRecord, strictBoundedString, } from "./bounded-contract-normalizers.mjs";
4
+ import { THREAT_INTELLIGENCE_CAPABILITIES, THREAT_INTELLIGENCE_TARGET_EXPOSURES, CURATED_CONNECTOR_KINDS, CURATED_CONNECTOR_COLLECTIONS, CURATED_CONNECTOR_CREDENTIAL_MODES, CURATED_CONNECTOR_ENTITY_VALUES, CURATED_CONNECTOR_RELATIONSHIP_TYPES, CURATED_CONNECTOR_TARGET_EXPOSURES, CURATED_CONNECTOR_RELATIONSHIP_ENDPOINTS as CONNECTOR_RELATIONSHIP_ENDPOINTS, } from "./threat-intelligence-types.mjs";
3
5
  import { normalizeLegacyIsoTimestamp } from "../packages/evidence/observation.mjs";
4
6
  const MAX_PROVIDER_ID_LENGTH = 40;
5
7
  const MAX_PROVIDER_LABEL_LENGTH = 100;
@@ -7,17 +9,8 @@ const MAX_URL_LENGTH = 2_048;
7
9
  const MAX_REQUEST_BUDGET = 1_000_000;
8
10
  const MAX_CONNECTOR_SCOPES = 20;
9
11
  const MAX_CONNECTOR_SCOPE_LENGTH = 80;
10
- const CAPABILITIES = new Set([
11
- 'domain_lookup',
12
- 'url_lookup',
13
- 'indicator_search',
14
- ]);
15
- const URL_EXPOSURES = new Set([
16
- 'registrable_domain',
17
- 'hostname',
18
- 'origin',
19
- 'full_url',
20
- ]);
12
+ const CAPABILITIES = new Set(THREAT_INTELLIGENCE_CAPABILITIES);
13
+ const URL_EXPOSURES = new Set(THREAT_INTELLIGENCE_TARGET_EXPOSURES);
21
14
  const COMMERCIAL_USE = new Set(['allowed', 'restricted', 'unknown']);
22
15
  const ATTRIBUTION = new Set(['required', 'not_required', 'unknown']);
23
16
  const CACHING = new Set([
@@ -39,137 +32,16 @@ const REDISTRIBUTION = new Set([
39
32
  'prohibited',
40
33
  'unknown',
41
34
  ]);
42
- const CONNECTOR_KINDS = new Set([
43
- 'discovery',
44
- 'enrichment',
45
- ]);
46
- const CONNECTOR_COLLECTIONS = new Set([
47
- 'passive',
48
- 'active',
49
- 'third_party',
50
- ]);
51
- const CONNECTOR_CREDENTIAL_MODES = new Set([
52
- 'none',
53
- 'optional',
54
- 'required',
55
- ]);
56
- const CONNECTOR_ENTITY_TYPES = new Set([
57
- 'domain',
58
- 'hostname',
59
- 'url',
60
- 'ipv4',
61
- 'ipv6',
62
- 'asn',
63
- 'certificate',
64
- ]);
65
- const CONNECTOR_RELATIONSHIP_TYPES = new Set([
66
- 'domain_resolves_to_ip',
67
- 'domain_uses_nameserver',
68
- 'domain_uses_mail_server',
69
- 'domain_presented_certificate',
70
- 'certificate_names_domain',
71
- 'ip_hosts_domain',
72
- 'domain_related_to_domain',
73
- ]);
74
- const CONNECTOR_TARGET_EXPOSURES = Object.freeze({
75
- domain: new Set(['registrable_domain']),
76
- hostname: new Set(['hostname']),
77
- url: new Set([
78
- 'registrable_domain',
79
- 'hostname',
80
- 'origin',
81
- 'full_url',
82
- ]),
83
- ipv4: new Set(['ip_address']),
84
- ipv6: new Set(['ip_address']),
85
- asn: new Set(['asn']),
86
- certificate: new Set([
87
- 'certificate_fingerprint',
88
- ]),
89
- });
90
- const CONNECTOR_RELATIONSHIP_ENDPOINTS = Object.freeze({
91
- domain_resolves_to_ip: {
92
- from: new Set(['domain', 'hostname']),
93
- to: new Set(['ipv4', 'ipv6']),
94
- },
95
- domain_uses_nameserver: {
96
- from: new Set(['domain']),
97
- to: new Set(['hostname']),
98
- },
99
- domain_uses_mail_server: {
100
- from: new Set(['domain']),
101
- to: new Set(['hostname']),
102
- },
103
- domain_presented_certificate: {
104
- from: new Set(['domain', 'hostname']),
105
- to: new Set(['certificate']),
106
- },
107
- certificate_names_domain: {
108
- from: new Set(['certificate']),
109
- to: new Set(['domain', 'hostname']),
110
- },
111
- ip_hosts_domain: {
112
- from: new Set(['ipv4', 'ipv6']),
113
- to: new Set(['domain', 'hostname']),
114
- },
115
- domain_related_to_domain: {
116
- from: new Set(['domain', 'hostname']),
117
- to: new Set(['domain', 'hostname']),
118
- },
119
- });
120
- function isRecord(value) {
121
- return Boolean(value && typeof value === 'object' && !Array.isArray(value));
122
- }
123
- function exactKeys(value, allowed, label) {
124
- if (!isRecord(value))
125
- throw new TypeError(`${label} must be an object`);
126
- const unknown = Object.keys(value).find((key) => !allowed.has(key));
127
- if (unknown) {
128
- throw new TypeError(`${label} contains an unknown field: ${unknown}`);
129
- }
130
- }
131
- function strictBoundedString(value, maximum) {
132
- if (typeof value !== 'string' ||
133
- value.length > maximum ||
134
- /[\u0000-\u001f\u007f]/u.test(value)) {
135
- return null;
136
- }
137
- const normalized = value.trim().replace(/\s+/gu, ' ');
138
- return normalized && normalized.length <= maximum ? normalized : null;
139
- }
35
+ const CONNECTOR_KINDS = new Set(CURATED_CONNECTOR_KINDS);
36
+ const CONNECTOR_COLLECTIONS = new Set(CURATED_CONNECTOR_COLLECTIONS);
37
+ const CONNECTOR_CREDENTIAL_MODES = new Set(CURATED_CONNECTOR_CREDENTIAL_MODES);
38
+ const CONNECTOR_ENTITY_TYPES = new Set(CURATED_CONNECTOR_ENTITY_VALUES);
39
+ const CONNECTOR_RELATIONSHIP_TYPES = new Set(CURATED_CONNECTOR_RELATIONSHIP_TYPES);
140
40
  function isoTimestamp(value) {
141
41
  return normalizeLegacyIsoTimestamp(value);
142
42
  }
143
43
  function httpsUrl(value) {
144
- const raw = strictBoundedString(value, MAX_URL_LENGTH);
145
- if (!raw)
146
- return null;
147
- try {
148
- const parsed = new URL(raw);
149
- if (parsed.protocol !== 'https:' || parsed.username || parsed.password) {
150
- return null;
151
- }
152
- const normalized = parsed.toString();
153
- return normalized.length <= MAX_URL_LENGTH ? normalized : null;
154
- }
155
- catch {
156
- return null;
157
- }
158
- }
159
- function enumValue(value, allowed, label) {
160
- if (typeof value !== 'string' || !allowed.has(value)) {
161
- throw new TypeError(`${label} is invalid`);
162
- }
163
- return value;
164
- }
165
- function boundedInteger(value, minimum, maximum, label) {
166
- if (typeof value !== 'number' ||
167
- !Number.isSafeInteger(value) ||
168
- value < minimum ||
169
- value > maximum) {
170
- throw new TypeError(`${label} must be an integer between ${minimum} and ${maximum}`);
171
- }
172
- return value;
44
+ return boundedHttpsUrl(value, MAX_URL_LENGTH);
173
45
  }
174
46
  function normalizeTargets(value) {
175
47
  exactKeys(value, new Set(['domain', 'url']), 'Provider targets');
@@ -323,7 +195,7 @@ function normalizeConnectorInputs(value) {
323
195
  const inputs = value.map((item) => {
324
196
  exactKeys(item, new Set(['type', 'exposure']), 'Connector input');
325
197
  const type = enumValue(item.type, CONNECTOR_ENTITY_TYPES, 'Connector input type');
326
- const exposure = enumValue(item.exposure, CONNECTOR_TARGET_EXPOSURES[type], 'Connector input exposure');
198
+ const exposure = enumValue(item.exposure, new Set(CURATED_CONNECTOR_TARGET_EXPOSURES[type]), 'Connector input exposure');
327
199
  if (seen.has(type)) {
328
200
  throw new TypeError('Connector input types must be unique');
329
201
  }
@@ -4,7 +4,8 @@
4
4
  // must declare exact input exposure, reviewed terms, and bounded request and
5
5
  // output budgets before producing separately attributed normalized evidence.
6
6
  import { createObservation } from "../packages/evidence/observation.mjs";
7
- import { THREAT_INTELLIGENCE_CONTRACT_VERSION, THREAT_INTELLIGENCE_CATEGORIES, THREAT_INTELLIGENCE_CONFIDENCES, THREAT_INTELLIGENCE_ENVELOPE_VERSION, THREAT_INTELLIGENCE_RESULT_STATES, THREAT_INTELLIGENCE_SCHEMA, THREAT_INTELLIGENCE_SEVERITIES, } from "./threat-intelligence-types.mjs";
7
+ import { latestObservationCohort } from "../packages/evidence/latest-observations.mjs";
8
+ import { CURATED_CONNECTOR_RELATIONSHIP_TYPES, CURATED_CONNECTOR_RELATIONSHIP_CLASSIFICATIONS, CURATED_CONNECTOR_RELATIONSHIP_ENDPOINTS as CONNECTOR_RELATIONSHIP_ENDPOINTS, THREAT_INTELLIGENCE_CONTRACT_VERSION, THREAT_INTELLIGENCE_CATEGORIES, THREAT_INTELLIGENCE_CONFIDENCES, THREAT_INTELLIGENCE_ENVELOPE_VERSION, THREAT_INTELLIGENCE_RESULT_STATES, THREAT_INTELLIGENCE_SCHEMA, THREAT_INTELLIGENCE_SEVERITIES, } from "./threat-intelligence-types.mjs";
8
9
  import { assertCuratedConnectorDefinition, assertThreatIntelligenceProvider, } from "./threat-intelligence-definition-registry.mjs";
9
10
  import { boundedHttpsUrl, boundedString, isRecord, isoTimestamp, strictBoundedString, } from "./bounded-contract-normalizers.mjs";
10
11
  import { CURATED_CONNECTOR_ENTITY_TYPES, MAX_THREAT_INTELLIGENCE_URL_LENGTH, normalizeCuratedConnectorTarget, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-targets.mjs";
@@ -22,50 +23,8 @@ const TERMINAL_STATES_WITHOUT_FINDINGS = new Set([
22
23
  const CATEGORIES = new Set(THREAT_INTELLIGENCE_CATEGORIES);
23
24
  const SEVERITIES = new Set(THREAT_INTELLIGENCE_SEVERITIES);
24
25
  const CONFIDENCES = new Set(THREAT_INTELLIGENCE_CONFIDENCES);
25
- const CONNECTOR_RELATIONSHIP_TYPES = new Set([
26
- 'domain_resolves_to_ip',
27
- 'domain_uses_nameserver',
28
- 'domain_uses_mail_server',
29
- 'domain_presented_certificate',
30
- 'certificate_names_domain',
31
- 'ip_hosts_domain',
32
- 'domain_related_to_domain',
33
- ]);
34
- const CONNECTOR_RELATIONSHIP_CLASSIFICATIONS = new Set([
35
- 'direct',
36
- 'normalized',
37
- 'derived',
38
- ]);
39
- const CONNECTOR_RELATIONSHIP_ENDPOINTS = Object.freeze({
40
- domain_resolves_to_ip: {
41
- from: new Set(['domain', 'hostname']),
42
- to: new Set(['ipv4', 'ipv6']),
43
- },
44
- domain_uses_nameserver: {
45
- from: new Set(['domain']),
46
- to: new Set(['hostname']),
47
- },
48
- domain_uses_mail_server: {
49
- from: new Set(['domain']),
50
- to: new Set(['hostname']),
51
- },
52
- domain_presented_certificate: {
53
- from: new Set(['domain', 'hostname']),
54
- to: new Set(['certificate']),
55
- },
56
- certificate_names_domain: {
57
- from: new Set(['certificate']),
58
- to: new Set(['domain', 'hostname']),
59
- },
60
- ip_hosts_domain: {
61
- from: new Set(['ipv4', 'ipv6']),
62
- to: new Set(['domain', 'hostname']),
63
- },
64
- domain_related_to_domain: {
65
- from: new Set(['domain', 'hostname']),
66
- to: new Set(['domain', 'hostname']),
67
- },
68
- });
26
+ const CONNECTOR_RELATIONSHIP_TYPES = new Set(CURATED_CONNECTOR_RELATIONSHIP_TYPES);
27
+ const CONNECTOR_RELATIONSHIP_CLASSIFICATIONS = new Set(CURATED_CONNECTOR_RELATIONSHIP_CLASSIFICATIONS);
69
28
  // createObservation() retains at most 40 source characters, so provider IDs use
70
29
  // the same ceiling and can never be truncated at the provenance boundary.
71
30
  const MAX_PROVIDER_LABEL_LENGTH = 100;
@@ -247,7 +206,7 @@ function normalizeConnectorRelationship(value, allowedTypes, entitiesByKey) {
247
206
  const lastObservedAt = value.lastObservedAt == null ? null : isoTimestamp(value.lastObservedAt);
248
207
  const endpoints = type ? CONNECTOR_RELATIONSHIP_ENDPOINTS[type] : null;
249
208
  if (!type || !fromEntity || !toEntity || fromEntity.id === toEntity.id
250
- || !endpoints?.from.has(fromEntity.type) || !endpoints.to.has(toEntity.type)
209
+ || !endpoints?.from.includes(fromEntity.type) || !endpoints.to.includes(toEntity.type)
251
210
  || !classification || !method
252
211
  || (value.firstObservedAt != null && !firstObservedAt)
253
212
  || (value.lastObservedAt != null && !lastObservedAt)
@@ -381,13 +340,24 @@ function createThreatIntelligenceResult(provider, target, input = {}, observedAt
381
340
  }
382
341
  normalizedFindings.sort(compareFindings);
383
342
  const findings = [];
384
- const seen = new Set();
343
+ const byIdentity = new Map();
385
344
  for (const finding of normalizedFindings) {
386
345
  const key = findingKey(finding);
387
- if (seen.has(key))
346
+ const group = byIdentity.get(key) ?? [];
347
+ group.push(finding);
348
+ byIdentity.set(key, group);
349
+ }
350
+ let conflictingIdentities = 0;
351
+ for (const group of byIdentity.values()) {
352
+ const cohort = latestObservationCohort(group, (finding) => finding.lastObservedAt);
353
+ const candidates = [...cohort.latest, ...cohort.undated];
354
+ if (new Set(candidates.map((finding) => JSON.stringify(finding))).size > 1) {
355
+ conflictingIdentities += 1;
356
+ discarded += candidates.length;
388
357
  continue;
389
- seen.add(key);
390
- findings.push(finding);
358
+ }
359
+ if (candidates[0])
360
+ findings.push(candidates[0]);
391
361
  }
392
362
  if (findings.length > MAX_FINDINGS) {
393
363
  discarded += findings.length - MAX_FINDINGS;
@@ -398,14 +368,16 @@ function createThreatIntelligenceResult(provider, target, input = {}, observedAt
398
368
  if (TERMINAL_STATES_WITHOUT_FINDINGS.has(state) && findings.length)
399
369
  state = 'partial';
400
370
  if (state === 'success' && findings.length === 0)
401
- state = 'error';
371
+ state = conflictingIdentities > 0 ? 'partial' : 'error';
402
372
  if ((discarded > 0 || sourceTruncated) && !['error', 'unavailable', 'rate_limited'].includes(state))
403
373
  state = 'partial';
404
374
  const limitations = normalizeLimitations(resultInput.limitations);
405
375
  if (state === 'not_found')
406
376
  limitations.unshift(NO_MATCH_LIMITATION);
407
377
  if (discarded > 0)
408
- limitations.unshift(`${discarded} invalid or over-limit provider finding${discarded === 1 ? ' was' : 's were'} omitted.`);
378
+ limitations.unshift(`${discarded} invalid, conflicting or over-limit provider finding${discarded === 1 ? ' was' : 's were'} omitted.`);
379
+ if (conflictingIdentities > 0)
380
+ limitations.unshift(`${conflictingIdentities} provider identit${conflictingIdentities === 1 ? 'y has' : 'ies have'} conflicting records at the latest or unknown observation time. No stronger record was selected from those conflicts.`);
409
381
  limitations.unshift(BASE_LIMITATION);
410
382
  const boundedLimitations = [...new Set(limitations)].slice(0, MAX_LIMITATIONS);
411
383
  const complete = ['success', 'not_found'].includes(state) && discarded === 0 && !sourceTruncated;
@@ -2,41 +2,13 @@
2
2
  // and curated connectors. These helpers never perform network requests.
3
3
  import { classifyQuery } from "./classify.mjs";
4
4
  import { enumValue, exactKeys, strictBoundedString, } from "./bounded-contract-normalizers.mjs";
5
+ import { CURATED_CONNECTOR_ENTITY_VALUES, CURATED_CONNECTOR_TARGET_EXPOSURES, THREAT_INTELLIGENCE_TARGET_EXPOSURES, } from "./threat-intelligence-types.mjs";
5
6
  export const MAX_THREAT_INTELLIGENCE_URL_LENGTH = 2048;
6
7
  const TARGET_EXPOSURES = Object.freeze({
7
- domain: new Set(['registrable_domain']),
8
- url: new Set([
9
- 'registrable_domain',
10
- 'hostname',
11
- 'origin',
12
- 'full_url',
13
- ]),
14
- });
15
- export const CURATED_CONNECTOR_ENTITY_TYPES = new Set([
16
- 'domain',
17
- 'hostname',
18
- 'url',
19
- 'ipv4',
20
- 'ipv6',
21
- 'asn',
22
- 'certificate',
23
- ]);
24
- const CONNECTOR_TARGET_EXPOSURES = Object.freeze({
25
- domain: new Set(['registrable_domain']),
26
- hostname: new Set(['hostname']),
27
- url: new Set([
28
- 'registrable_domain',
29
- 'hostname',
30
- 'origin',
31
- 'full_url',
32
- ]),
33
- ipv4: new Set(['ip_address']),
34
- ipv6: new Set(['ip_address']),
35
- asn: new Set(['asn']),
36
- certificate: new Set([
37
- 'certificate_fingerprint',
38
- ]),
8
+ domain: Object.freeze(['registrable_domain']),
9
+ url: THREAT_INTELLIGENCE_TARGET_EXPOSURES,
39
10
  });
11
+ export const CURATED_CONNECTOR_ENTITY_TYPES = new Set(CURATED_CONNECTOR_ENTITY_VALUES);
40
12
  function normalizeCertificateFingerprint(value) {
41
13
  const raw = strictBoundedString(value, 128);
42
14
  if (!raw || !/^[0-9a-f:]+$/iu.test(raw))
@@ -49,7 +21,7 @@ export function normalizeThreatIntelligenceTarget(input, exposure) {
49
21
  const type = input.type;
50
22
  if ((type !== 'domain' && type !== 'url')
51
23
  || typeof exposure !== 'string'
52
- || !TARGET_EXPOSURES[type].has(exposure)) {
24
+ || !TARGET_EXPOSURES[type].includes(exposure)) {
53
25
  throw new TypeError('Threat-intelligence target exposure is invalid');
54
26
  }
55
27
  if (type === 'domain') {
@@ -103,7 +75,7 @@ export function normalizeThreatIntelligenceTarget(input, exposure) {
103
75
  export function normalizeCuratedConnectorTarget(input, exposure) {
104
76
  exactKeys(input, new Set(['type', 'value']), 'Connector target');
105
77
  const type = enumValue(input.type, CURATED_CONNECTOR_ENTITY_TYPES, 'Connector target type');
106
- const normalizedExposure = enumValue(exposure, CONNECTOR_TARGET_EXPOSURES[type], 'Connector target exposure');
78
+ const normalizedExposure = enumValue(exposure, new Set(CURATED_CONNECTOR_TARGET_EXPOSURES[type]), 'Connector target exposure');
107
79
  if (type === 'url') {
108
80
  const normalized = normalizeThreatIntelligenceTarget({ type: 'url', value: input.value }, normalizedExposure);
109
81
  return Object.freeze({
@@ -3,6 +3,12 @@
3
3
  export const THREAT_INTELLIGENCE_SCHEMA = 'whoisleuth.threat-intelligence-result';
4
4
  export const THREAT_INTELLIGENCE_CONTRACT_VERSION = 1;
5
5
  export const THREAT_INTELLIGENCE_ENVELOPE_VERSION = 1;
6
+ export const THREAT_INTELLIGENCE_TARGET_EXPOSURES = Object.freeze([
7
+ 'registrable_domain', 'hostname', 'origin', 'full_url',
8
+ ]);
9
+ export const THREAT_INTELLIGENCE_CAPABILITIES = Object.freeze([
10
+ 'domain_lookup', 'url_lookup', 'indicator_search',
11
+ ]);
6
12
  export const THREAT_INTELLIGENCE_CATEGORIES = Object.freeze([
7
13
  'phishing',
8
14
  'malware',
@@ -34,3 +40,36 @@ export const THREAT_INTELLIGENCE_RESULT_STATES = Object.freeze([
34
40
  'unavailable',
35
41
  'error',
36
42
  ]);
43
+ export const CURATED_CONNECTOR_KINDS = Object.freeze(['discovery', 'enrichment']);
44
+ export const CURATED_CONNECTOR_COLLECTIONS = Object.freeze(['passive', 'active', 'third_party']);
45
+ export const CURATED_CONNECTOR_CREDENTIAL_MODES = Object.freeze(['none', 'optional', 'required']);
46
+ export const CURATED_CONNECTOR_ENTITY_VALUES = Object.freeze([
47
+ 'domain', 'hostname', 'url', 'ipv4', 'ipv6', 'asn', 'certificate',
48
+ ]);
49
+ export const CURATED_CONNECTOR_RELATIONSHIP_TYPES = Object.freeze([
50
+ 'domain_resolves_to_ip', 'domain_uses_nameserver', 'domain_uses_mail_server',
51
+ 'domain_presented_certificate', 'certificate_names_domain', 'ip_hosts_domain',
52
+ 'domain_related_to_domain',
53
+ ]);
54
+ export const CURATED_CONNECTOR_RELATIONSHIP_CLASSIFICATIONS = Object.freeze(['direct', 'normalized', 'derived']);
55
+ export const CURATED_CONNECTOR_TARGET_EXPOSURES = Object.freeze({
56
+ domain: Object.freeze(['registrable_domain']),
57
+ hostname: Object.freeze(['hostname']),
58
+ url: THREAT_INTELLIGENCE_TARGET_EXPOSURES,
59
+ ipv4: Object.freeze(['ip_address']),
60
+ ipv6: Object.freeze(['ip_address']),
61
+ asn: Object.freeze(['asn']),
62
+ certificate: Object.freeze(['certificate_fingerprint']),
63
+ });
64
+ function relationshipEndpoints(from, to) {
65
+ return Object.freeze({ from: Object.freeze(from), to: Object.freeze(to) });
66
+ }
67
+ export const CURATED_CONNECTOR_RELATIONSHIP_ENDPOINTS = Object.freeze({
68
+ domain_resolves_to_ip: relationshipEndpoints(['domain', 'hostname'], ['ipv4', 'ipv6']),
69
+ domain_uses_nameserver: relationshipEndpoints(['domain'], ['hostname']),
70
+ domain_uses_mail_server: relationshipEndpoints(['domain'], ['hostname']),
71
+ domain_presented_certificate: relationshipEndpoints(['domain', 'hostname'], ['certificate']),
72
+ certificate_names_domain: relationshipEndpoints(['certificate'], ['domain', 'hostname']),
73
+ ip_hosts_domain: relationshipEndpoints(['ipv4', 'ipv6'], ['domain', 'hostname']),
74
+ domain_related_to_domain: relationshipEndpoints(['domain', 'hostname'], ['domain', 'hostname']),
75
+ });
@@ -2,6 +2,8 @@
2
2
  // objects so a later scan can explain why each domain was generated; callers
3
3
  // that only need the historical string list can use the compatibility wrapper.
4
4
  import { advancedConfusableVariantsForAscii, confusableCharactersForAscii, estimateAdvancedConfusableVariants, MAX_ADVANCED_CONFUSABLE_VARIANTS, wholeLabelConfusableVariantsForAscii, } from "./idn-confusables.mjs";
5
+ import { canonicalRegistrableDomain, normalizeDiscoverySuffix } from "./registrable-domain.mjs";
6
+ import { MAX_DOMAIN_NAME_LENGTH, MAX_DOMAIN_LABEL_LENGTH } from "../packages/contracts/domain-name.mjs";
5
7
  export { MAX_ADVANCED_CONFUSABLE_VARIANTS };
6
8
  function buildKeyboardAdjacency(rows) {
7
9
  const positions = new Map();
@@ -109,16 +111,15 @@ const TLD_TYPOS = {
109
111
  export const MAX_GENERATION_TLDS = 20;
110
112
  export const MAX_NAME_VARIANTS = 1_500;
111
113
  export const MAX_GENERATED_CANDIDATES = 2_000;
112
- export const MAX_GENERATION_INPUT_LENGTH = 253;
114
+ export const MAX_GENERATION_INPUT_LENGTH = MAX_DOMAIN_NAME_LENGTH;
113
115
  export const MAX_CUSTOM_DICTIONARY_TERMS = 100;
114
116
  export const MAX_CUSTOM_DICTIONARY_TERM_LENGTH = 32;
115
117
  export const MAX_CUSTOM_DICTIONARY_TEXT_LENGTH = 4_096;
116
- const MAX_LABEL_LENGTH = 63;
118
+ const MAX_LABEL_LENGTH = MAX_DOMAIN_LABEL_LENGTH;
117
119
  const MAX_TLD_INPUTS_INSPECTED = MAX_GENERATION_TLDS * 4;
118
120
  const MAX_CUSTOM_DICTIONARY_INPUTS_INSPECTED = MAX_CUSTOM_DICTIONARY_TERMS * 4;
119
121
  const CONTROL_CHARACTER_RE = /[\x00-\x1f\x7f]/;
120
122
  const DOMAIN_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
121
- const TLD_RE = /^[a-z]{2,63}$/;
122
123
  const CUSTOM_DICTIONARY_TERM_RE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
123
124
  const FAMILY_NEW_VARIANT_LIMITS = Object.freeze({
124
125
  character_addition: 128,
@@ -246,10 +247,11 @@ function splitDomainParts(input) {
246
247
  if (!trimmed)
247
248
  return null;
248
249
  if (trimmed.includes('.')) {
249
- const match = trimmed.match(/^([a-z0-9-]+)\.([a-z]+)$/);
250
- const name = match?.[1];
251
- const tld = match?.[2];
252
- if (!isValidDomainLabel(name) || typeof tld !== 'string' || !TLD_RE.test(tld))
250
+ const separator = trimmed.indexOf('.');
251
+ const name = trimmed.slice(0, separator);
252
+ const suffix = trimmed.slice(separator + 1);
253
+ const tld = normalizeDiscoverySuffix(suffix);
254
+ if (!isValidDomainLabel(name) || !tld || tld !== suffix || canonicalRegistrableDomain(trimmed) !== trimmed)
253
255
  return null;
254
256
  const tokens = name.split('-');
255
257
  return { name, tld, wordTokens: tokens.length >= 2 && tokens.length <= 4 && tokens.every(Boolean) ? tokens : [] };
@@ -294,17 +296,15 @@ function distinctWordOrders(tokens) {
294
296
  visit();
295
297
  return orders;
296
298
  }
297
- function normalizeTlds(values) {
299
+ export function normalizeGenerationTlds(values) {
298
300
  if (!Array.isArray(values))
299
301
  return { values: [], truncated: false };
300
302
  const normalized = [];
301
303
  const seen = new Set();
302
304
  let truncated = values.length > MAX_TLD_INPUTS_INSPECTED;
303
305
  for (const raw of values.slice(0, MAX_TLD_INPUTS_INSPECTED)) {
304
- if (typeof raw !== 'string' || raw.length > MAX_LABEL_LENGTH + 1 || CONTROL_CHARACTER_RE.test(raw))
305
- continue;
306
- const value = raw.trim().toLowerCase().replace(/^\./, '');
307
- if (!TLD_RE.test(value) || seen.has(value))
306
+ const value = normalizeDiscoverySuffix(raw);
307
+ if (!value || seen.has(value))
308
308
  continue;
309
309
  if (normalized.length >= MAX_GENERATION_TLDS) {
310
310
  truncated = true;
@@ -415,7 +415,7 @@ function selectCandidateTlds(sourceTld, fallbackTlds, enabledFamilies) {
415
415
  if (sourceTld && !enabledFamilies.has('tld_substitution')) {
416
416
  return { values: [sourceTld], truncated: false };
417
417
  }
418
- const normalizedFallbackTlds = normalizeTlds(fallbackTlds);
418
+ const normalizedFallbackTlds = normalizeGenerationTlds(fallbackTlds);
419
419
  const combinedTlds = sourceTld
420
420
  ? [sourceTld, ...normalizedFallbackTlds.values.filter((value) => value !== sourceTld)]
421
421
  : normalizedFallbackTlds.values;
@@ -673,8 +673,8 @@ export function generateTyposquatCandidateSet(rawInput, fallbackTlds, options =
673
673
  addVariant(state, variant, 'pluralization');
674
674
  }
675
675
  if (enabledFamilies.has('tld_embedding') && tld) {
676
- addVariant(state, `${name}${tld}`, 'tld_embedding');
677
- addVariant(state, `${name}-${tld}`, 'tld_embedding');
676
+ addVariant(state, `${name}${tld.replaceAll('.', '')}`, 'tld_embedding');
677
+ addVariant(state, `${name}-${tld.replaceAll('.', '-')}`, 'tld_embedding');
678
678
  }
679
679
  if (enabledFamilies.has('www_prefix')) {
680
680
  addVariant(state, `ww${name}`, 'www_prefix');
@@ -821,6 +821,10 @@ export function generateTyposquatCandidateSet(rawInput, fallbackTlds, options =
821
821
  const byDomain = new Map();
822
822
  const source = tld ? `${name}.${tld}` : name;
823
823
  function addCandidate(domain, candidateTld, mutationTypes) {
824
+ if (domain.length > MAX_DOMAIN_NAME_LENGTH) {
825
+ state.rejectedVariantCount += 1;
826
+ return;
827
+ }
824
828
  const existing = byDomain.get(domain);
825
829
  if (existing) {
826
830
  for (const mutationType of mutationTypes) {
@@ -74,6 +74,11 @@ function boundedText(value, maxLength) {
74
74
  const normalized = value.trim().replace(/\s+/gu, ' ');
75
75
  return normalized ? normalized.slice(0, maxLength) : null;
76
76
  }
77
+ function identityText(value, maxLength) {
78
+ if (typeof value !== 'string' || value.length > maxLength || /[\u0000-\u0020\u007f]/u.test(value))
79
+ return null;
80
+ return value || null;
81
+ }
77
82
  function isoTimestamp(value) {
78
83
  if (typeof value !== 'string' || value.length > 64 || /[\u0000-\u001f\u007f]/u.test(value))
79
84
  return null;
@@ -91,7 +96,7 @@ function retryAfterSeconds(response) {
91
96
  return Number.isSafeInteger(seconds) && seconds >= 1 && seconds <= 86_400 ? seconds : null;
92
97
  }
93
98
  function exactDomainFromUrl(value) {
94
- const raw = boundedText(value, 2_048);
99
+ const raw = identityText(value, 2_048);
95
100
  if (!raw)
96
101
  return null;
97
102
  try {
@@ -106,7 +111,7 @@ function exactDomainFromUrl(value) {
106
111
  }
107
112
  }
108
113
  function exactDomain(value) {
109
- const raw = boundedText(value, 253);
114
+ const raw = identityText(value, 253);
110
115
  if (!raw)
111
116
  return null;
112
117
  try {
@@ -138,7 +143,7 @@ function normalizedUrlCount(value) {
138
143
  function normalizeHostFinding(value, targetDomain) {
139
144
  if (!isRecord(value))
140
145
  return null;
141
- const id = boundedText(value.id, MAX_URL_ID_LENGTH);
146
+ const id = identityText(value.id, MAX_URL_ID_LENGTH);
142
147
  const status = boundedText(value.url_status, 16)?.toLowerCase();
143
148
  const threat = boundedText(value.threat, 64)?.toLowerCase();
144
149
  if (!id
@@ -299,7 +299,7 @@ function createUrlscanIntelligenceAdapter(dependencies = {}) {
299
299
  const overLimit = parsed.results.length > URLSCAN_MAX_RESULTS || parsed.has_more === true;
300
300
  const invalid = normalized.filter((item) => item === null).length;
301
301
  const reviewed = rawResults.length - invalid;
302
- if (!findings.length && parsed.results.length === 0) {
302
+ if (parsed.results.length === 0 && !overLimit) {
303
303
  return result(targetDomain, {
304
304
  state: 'not_found',
305
305
  detail: `No archived malicious-verdict match was returned for the last ${URLSCAN_SEARCH_DAYS} days.`,
@@ -3,7 +3,27 @@
3
3
  // Findings use fixed explanatory text and bounded counts; upstream header
4
4
  // values, certificate errors, URLs, and DNS record contents are not copied.
5
5
  import { createObservation } from "../packages/evidence/observation.mjs";
6
+ import { RESPONSE_POLICY_VERSION } from "./response-policy.mjs";
6
7
  import { MAX_SECURITY_POSTURE_FINDINGS, WEBSITE_SECURITY_POSTURE_VERSION, } from "./lookup-child-profile-contract.mjs";
8
+ // Observation state and review significance are separate. These presentation
9
+ // projections leave retained findings, wire counts and model versions intact.
10
+ function securityPostureReview(value) {
11
+ const item = record(value);
12
+ if (item.state !== 'observed' && item.state !== 'potential_exposure' && item.state !== 'observed_absence') {
13
+ return { kind: 'unavailable', label: 'Could not assess' };
14
+ }
15
+ if (item.state === 'potential_exposure' || item.tone === 'review') {
16
+ return { kind: 'needsReview', label: 'Needs review' };
17
+ }
18
+ return { kind: 'otherFindings', label: null };
19
+ }
20
+ function summarizeSecurityPostureReview(value) {
21
+ const counts = { needsReview: 0, otherFindings: 0, unavailable: 0 };
22
+ for (const item of (Array.isArray(value) ? value : []).slice(0, MAX_SECURITY_POSTURE_FINDINGS)) {
23
+ counts[securityPostureReview(item).kind] += 1;
24
+ }
25
+ return counts;
26
+ }
7
27
  const MAX_RETAINED_ORIGINS = 30;
8
28
  function record(value) {
9
29
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
@@ -86,12 +106,16 @@ const RESPONSE_POLICY_FINDINGS = Object.freeze({
86
106
  detail: () => 'The effective selected script source included a wildcard or broad scheme source. This fixed observation does not establish that an unsafe script is reachable.',
87
107
  },
88
108
  csp_unsafe_eval: {
89
- label: 'CSP permits unsafe evaluation',
90
- detail: () => "The effective selected script source included 'unsafe-eval'.",
109
+ label: 'CSP evaluation allowance observed',
110
+ detail: () => "The selected response policies allow evaluation through script-src or default-src. Element and attribute directives do not govern this allowance; other browser controls were not evaluated.",
91
111
  },
92
112
  csp_unsafe_inline: {
93
- label: 'Response CSP header permits unqualified inline script',
94
- detail: () => "The selected response header included 'unsafe-inline' without a nonce or hash source in that directive. A separate page policy was not observed early enough to qualify this response-scoped finding.",
113
+ label: 'Response CSP allows unqualified inline script blocks',
114
+ detail: () => "The selected response policies allow inline script blocks through script-src-elem, script-src or default-src without a valid nonce/hash source or strict-dynamic suppressing unsafe-inline. No earlier page policy qualified this response-scoped finding.",
115
+ },
116
+ csp_unsafe_inline_attributes: {
117
+ label: 'Response CSP allows unqualified inline event handlers',
118
+ detail: () => 'The selected response policies allow inline event attributes through script-src-attr, script-src or default-src. This header-only observation does not establish whether a handler exists or executes; page-policy timing was not evaluated for attributes.',
95
119
  },
96
120
  csp_inline_constrained_by_meta: {
97
121
  label: 'Page CSP further constrains inline script',
@@ -131,7 +155,7 @@ const RESPONSE_POLICY_FINDINGS = Object.freeze({
131
155
  });
132
156
  function responsePolicyFindings(value) {
133
157
  const policy = record(value);
134
- if (![1, 2].includes(Number(policy.responsePolicyVersion)))
158
+ if (![1, 2, RESPONSE_POLICY_VERSION].includes(Number(policy.responsePolicyVersion)))
135
159
  return [];
136
160
  const findings = [];
137
161
  const components = record(policy.components);
@@ -328,4 +352,4 @@ function analyzeWebsiteSecurityPosture(input = {}) {
328
352
  findings,
329
353
  };
330
354
  }
331
- export { MAX_SECURITY_POSTURE_FINDINGS, WEBSITE_SECURITY_POSTURE_VERSION, analyzeWebsiteSecurityPosture, };
355
+ export { MAX_SECURITY_POSTURE_FINDINGS, WEBSITE_SECURITY_POSTURE_VERSION, analyzeWebsiteSecurityPosture, securityPostureReview, summarizeSecurityPostureReview, };