@slicedearth/whoisleuth-cli 1.32.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 (189) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
  3. package/NOTICE +76 -0
  4. package/README.md +84 -0
  5. package/TRADEMARKS.md +41 -0
  6. package/bin/whoisleuth.mjs +35 -0
  7. package/cli/archive-inspect.mjs +198 -0
  8. package/cli/arguments.mjs +1022 -0
  9. package/cli/artifact-verify.mjs +210 -0
  10. package/cli/bulk-checkpoint.mjs +218 -0
  11. package/cli/bulk-command-runner.mjs +109 -0
  12. package/cli/bulk-output.mjs +112 -0
  13. package/cli/bulk.mjs +133 -0
  14. package/cli/compare.mjs +238 -0
  15. package/cli/completion.mjs +175 -0
  16. package/cli/csv.mjs +1 -0
  17. package/cli/discover.mjs +50 -0
  18. package/cli/discovery-command-runner.mjs +36 -0
  19. package/cli/discovery-observation-snapshot.mjs +315 -0
  20. package/cli/discovery-scan-command-runner.mjs +141 -0
  21. package/cli/discovery-scan.mjs +325 -0
  22. package/cli/discovery-snapshot.mjs +133 -0
  23. package/cli/discovery-workflow.mjs +73 -0
  24. package/cli/doctor.mjs +137 -0
  25. package/cli/errors.mjs +21 -0
  26. package/cli/evidence-command-arguments.mjs +145 -0
  27. package/cli/evidence-command-runner.mjs +106 -0
  28. package/cli/evidence-signing.mjs +215 -0
  29. package/cli/exit-codes.mjs +9 -0
  30. package/cli/export-evidence.mjs +28 -0
  31. package/cli/formatters/evidence-report.mjs +3 -0
  32. package/cli/formatters/html.mjs +69 -0
  33. package/cli/formatters/json.mjs +135 -0
  34. package/cli/formatters/markdown.mjs +3 -0
  35. package/cli/formatters/terminal.mjs +658 -0
  36. package/cli/http.mjs +27 -0
  37. package/cli/lookup-command-runner.mjs +87 -0
  38. package/cli/lookup-diff.mjs +171 -0
  39. package/cli/mail-review.mjs +204 -0
  40. package/cli/manual.mjs +34 -0
  41. package/cli/network-command-runner.mjs +88 -0
  42. package/cli/output-file.mjs +97 -0
  43. package/cli/page-compare.mjs +123 -0
  44. package/cli/posture.mjs +20 -0
  45. package/cli/progress-events.mjs +53 -0
  46. package/cli/progress.mjs +56 -0
  47. package/cli/registry-access.mjs +3 -0
  48. package/cli/registry-support.mjs +120 -0
  49. package/cli/risk-calibration.mjs +294 -0
  50. package/cli/runner-types.mjs +1 -0
  51. package/cli/runner.mjs +726 -0
  52. package/cli/saved-lookup.mjs +87 -0
  53. package/cli/source-reliability.mjs +727 -0
  54. package/cli/strict-exit.mjs +55 -0
  55. package/cli/terminal-presentation.mjs +147 -0
  56. package/docs/cli.md +827 -0
  57. package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
  58. package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
  59. package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
  60. package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
  61. package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
  62. package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
  63. package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
  64. package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
  65. package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
  66. package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
  67. package/frontend/src/lib/analysis/bulk-sort.js +49 -0
  68. package/frontend/src/lib/analysis/campaign-model.js +265 -0
  69. package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
  70. package/frontend/src/lib/analysis/case-migration-model.js +248 -0
  71. package/frontend/src/lib/analysis/case-model.js +5 -0
  72. package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
  73. package/frontend/src/lib/analysis/case-record-core.js +232 -0
  74. package/frontend/src/lib/analysis/case-record-model.js +7 -0
  75. package/frontend/src/lib/analysis/case-record-operations.js +224 -0
  76. package/frontend/src/lib/analysis/case-response-model.js +640 -0
  77. package/frontend/src/lib/analysis/case-response-packet.js +573 -0
  78. package/frontend/src/lib/analysis/case-storage-model.js +93 -0
  79. package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
  80. package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
  81. package/frontend/src/lib/analysis/http-summary.js +165 -0
  82. package/frontend/src/lib/analysis/investigation-guide.js +500 -0
  83. package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
  84. package/frontend/src/lib/analysis/page-baseline.js +272 -0
  85. package/frontend/src/lib/analysis/page-similarity.js +179 -0
  86. package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
  87. package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
  88. package/frontend/src/lib/analysis/scoring.js +135 -0
  89. package/frontend/src/lib/analysis/shortlist-model.js +209 -0
  90. package/frontend/src/lib/analysis/utils.js +265 -0
  91. package/frontend/src/lib/analysis/watchlist-history.js +643 -0
  92. package/frontend/src/lib/analysis/watchlist-store.js +127 -0
  93. package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
  94. package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
  95. package/frontend/src/lib/analysis/workspace-archive.js +472 -0
  96. package/lib/abort.mjs +26 -0
  97. package/lib/abusech-auth.mjs +19 -0
  98. package/lib/availability.mjs +716 -0
  99. package/lib/bounded-contract-normalizers.mjs +61 -0
  100. package/lib/browser-library-profile.mjs +310 -0
  101. package/lib/bulk-comparison-evidence.mjs +83 -0
  102. package/lib/classify.mjs +93 -0
  103. package/lib/client-behavior-profile.mjs +83 -0
  104. package/lib/credential-surface-profile.mjs +66 -0
  105. package/lib/csv.mjs +23 -0
  106. package/lib/ct-query.mjs +27 -0
  107. package/lib/ct-search.mjs +393 -0
  108. package/lib/distributed-operation-budget.mjs +461 -0
  109. package/lib/dns-delegation-health.mjs +344 -0
  110. package/lib/dns-intelligence.mjs +739 -0
  111. package/lib/dns-mx.mjs +24 -0
  112. package/lib/dns-resolver-selection.mjs +33 -0
  113. package/lib/domain-posture-analysis.mjs +254 -0
  114. package/lib/domain-posture-parsers.mjs +358 -0
  115. package/lib/domain-posture.mjs +745 -0
  116. package/lib/error-detail.mjs +10 -0
  117. package/lib/evidence-export.mjs +431 -0
  118. package/lib/evidence-report-markdown.mjs +71 -0
  119. package/lib/evidence-report.mjs +391 -0
  120. package/lib/external-intelligence-risk.mjs +108 -0
  121. package/lib/favicon.mjs +146 -0
  122. package/lib/feature-policy.mjs +70 -0
  123. package/lib/generated/retire-browser-catalog.mjs +8724 -0
  124. package/lib/generated/unicode-confusables-17.mjs +93 -0
  125. package/lib/hostname.mjs +38 -0
  126. package/lib/html-signals.mjs +680 -0
  127. package/lib/http-intelligence.mjs +232 -0
  128. package/lib/idn-confusable-policy.mjs +86 -0
  129. package/lib/idn-confusables.mjs +465 -0
  130. package/lib/lookup-cache.mjs +130 -0
  131. package/lib/lookup-progress.mjs +291 -0
  132. package/lib/lookup-source-progress.mjs +109 -0
  133. package/lib/lookup-threat-provider-inventory.mjs +13 -0
  134. package/lib/lookup.mjs +592 -0
  135. package/lib/observation.mjs +114 -0
  136. package/lib/observed-network-context.mjs +355 -0
  137. package/lib/operation-budget.mjs +489 -0
  138. package/lib/page-fingerprints.mjs +481 -0
  139. package/lib/page-role-profile.mjs +117 -0
  140. package/lib/perceptual-hash.mjs +451 -0
  141. package/lib/rdap-attempts.mjs +34 -0
  142. package/lib/rdap-bootstrap.mjs +173 -0
  143. package/lib/rdap-capabilities.mjs +292 -0
  144. package/lib/rdap-client.mjs +125 -0
  145. package/lib/rdap-normalization.mjs +691 -0
  146. package/lib/rdap-registrar.mjs +196 -0
  147. package/lib/rdap-transport.mjs +39 -0
  148. package/lib/rdap-types.mjs +1 -0
  149. package/lib/rdap-validation.mjs +94 -0
  150. package/lib/rdap.mjs +16 -0
  151. package/lib/registry-access.mjs +32 -0
  152. package/lib/registry-capabilities.mjs +117 -0
  153. package/lib/registry-capability-catalogue.mjs +1206 -0
  154. package/lib/registry-comparison.mjs +318 -0
  155. package/lib/registry-dates.mjs +190 -0
  156. package/lib/registry-insights.mjs +325 -0
  157. package/lib/response-policy.mjs +256 -0
  158. package/lib/risk-scoring.mjs +164 -0
  159. package/lib/safe-fetch.mjs +446 -0
  160. package/lib/security-txt.mjs +359 -0
  161. package/lib/service-binding-dns.mjs +608 -0
  162. package/lib/sslbl-certificates.generated.mjs +143 -0
  163. package/lib/sslbl-intelligence.mjs +241 -0
  164. package/lib/static-html-analysis.mjs +340 -0
  165. package/lib/structured-data-identity.mjs +323 -0
  166. package/lib/threat-intelligence-contract.mjs +5 -0
  167. package/lib/threat-intelligence-definition-registry.mjs +34 -0
  168. package/lib/threat-intelligence-fixtures.mjs +82 -0
  169. package/lib/threat-intelligence-manifests.mjs +512 -0
  170. package/lib/threat-intelligence-runtime.mjs +662 -0
  171. package/lib/threat-intelligence-targets.mjs +168 -0
  172. package/lib/threat-intelligence-types.mjs +3 -0
  173. package/lib/threatfox-intelligence.mjs +307 -0
  174. package/lib/tls-intelligence.mjs +814 -0
  175. package/lib/typosquat-generator.mjs +884 -0
  176. package/lib/urlhaus-intelligence.mjs +361 -0
  177. package/lib/urlscan-intelligence.mjs +349 -0
  178. package/lib/website-security-posture.mjs +325 -0
  179. package/lib/website-technology.mjs +437 -0
  180. package/lib/whois-authority.mjs +98 -0
  181. package/lib/whois-chain.mjs +261 -0
  182. package/lib/whois-contacts.mjs +163 -0
  183. package/lib/whois-contracts.mjs +3 -0
  184. package/lib/whois-normalization.mjs +27 -0
  185. package/lib/whois-transport.mjs +100 -0
  186. package/lib/whois-values.mjs +92 -0
  187. package/lib/whois.mjs +1187 -0
  188. package/package.json +55 -0
  189. package/third-party-notices.txt +5571 -0
@@ -0,0 +1,662 @@
1
+ // Provider-neutral contracts for optional external threat-intelligence sources
2
+ // and curated discovery or enrichment connectors. This module performs no
3
+ // network requests and contains no provider credentials. Adapters added later
4
+ // must declare exact input exposure, reviewed terms, and bounded request and
5
+ // output budgets before producing separately attributed normalized evidence.
6
+ import { createObservation } from "./observation.mjs";
7
+ import { assertCuratedConnectorDefinition, assertThreatIntelligenceProvider, } from "./threat-intelligence-definition-registry.mjs";
8
+ import { boundedHttpsUrl, boundedInteger, boundedString, isRecord, isoTimestamp, strictBoundedString, } from "./bounded-contract-normalizers.mjs";
9
+ import { CURATED_CONNECTOR_ENTITY_TYPES, MAX_THREAT_INTELLIGENCE_URL_LENGTH, normalizeCuratedConnectorTarget, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-targets.mjs";
10
+ const THREAT_INTELLIGENCE_CONTRACT_VERSION = 1;
11
+ const THREAT_INTELLIGENCE_SCHEMA = 'whoisleuth.threat-intelligence-result';
12
+ const CURATED_CONNECTOR_CONTRACT_VERSION = 1;
13
+ const CURATED_CONNECTOR_RESULT_SCHEMA = 'whoisleuth.curated-connector-result';
14
+ const RESULT_STATES = new Set([
15
+ 'success',
16
+ 'partial',
17
+ 'not_found',
18
+ 'unsupported',
19
+ 'skipped',
20
+ 'rate_limited',
21
+ 'unavailable',
22
+ 'error',
23
+ ]);
24
+ const TERMINAL_STATES_WITHOUT_FINDINGS = new Set([
25
+ 'not_found',
26
+ 'unsupported',
27
+ 'skipped',
28
+ 'rate_limited',
29
+ 'unavailable',
30
+ 'error',
31
+ ]);
32
+ const CATEGORIES = new Set(['phishing', 'malware', 'spam', 'suspicious', 'abuse', 'unknown']);
33
+ const SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'unknown']);
34
+ const CONFIDENCES = new Set(['high', 'medium', 'low', 'unknown']);
35
+ const CONNECTOR_RELATIONSHIP_TYPES = new Set([
36
+ 'domain_resolves_to_ip',
37
+ 'domain_uses_nameserver',
38
+ 'domain_uses_mail_server',
39
+ 'domain_presented_certificate',
40
+ 'certificate_names_domain',
41
+ 'ip_hosts_domain',
42
+ 'domain_related_to_domain',
43
+ ]);
44
+ const CONNECTOR_RELATIONSHIP_CLASSIFICATIONS = new Set([
45
+ 'direct',
46
+ 'normalized',
47
+ 'derived',
48
+ ]);
49
+ const CONNECTOR_RELATIONSHIP_ENDPOINTS = Object.freeze({
50
+ domain_resolves_to_ip: {
51
+ from: new Set(['domain', 'hostname']),
52
+ to: new Set(['ipv4', 'ipv6']),
53
+ },
54
+ domain_uses_nameserver: {
55
+ from: new Set(['domain']),
56
+ to: new Set(['hostname']),
57
+ },
58
+ domain_uses_mail_server: {
59
+ from: new Set(['domain']),
60
+ to: new Set(['hostname']),
61
+ },
62
+ domain_presented_certificate: {
63
+ from: new Set(['domain', 'hostname']),
64
+ to: new Set(['certificate']),
65
+ },
66
+ certificate_names_domain: {
67
+ from: new Set(['certificate']),
68
+ to: new Set(['domain', 'hostname']),
69
+ },
70
+ ip_hosts_domain: {
71
+ from: new Set(['ipv4', 'ipv6']),
72
+ to: new Set(['domain', 'hostname']),
73
+ },
74
+ domain_related_to_domain: {
75
+ from: new Set(['domain', 'hostname']),
76
+ to: new Set(['domain', 'hostname']),
77
+ },
78
+ });
79
+ // createObservation() retains at most 40 source characters, so provider IDs use
80
+ // the same ceiling and can never be truncated at the provenance boundary.
81
+ const MAX_PROVIDER_ID_LENGTH = 40;
82
+ const MAX_PROVIDER_LABEL_LENGTH = 100;
83
+ const MAX_DETAIL_LENGTH = 500;
84
+ const MAX_FINDING_ID_LENGTH = 160;
85
+ const MAX_VERDICT_LENGTH = 160;
86
+ const MAX_TAGS = 20;
87
+ const MAX_TAG_LENGTH = 64;
88
+ // Match the shared observation envelope so the contract never promises more
89
+ // limitations than createObservation() can retain.
90
+ const MAX_LIMITATIONS = 10;
91
+ const MAX_FINDINGS = 100;
92
+ const MAX_INPUT_FINDINGS = 500;
93
+ const MAX_TIMEOUT_MS = 15_000;
94
+ const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
95
+ const MAX_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
96
+ const MAX_CONNECTOR_ENTITIES = 200;
97
+ const MAX_CONNECTOR_RELATIONSHIPS = 400;
98
+ const MAX_CONNECTOR_INPUT_ENTITIES = 800;
99
+ const MAX_CONNECTOR_INPUT_RELATIONSHIPS = 1_600;
100
+ const MAX_CONNECTOR_ATTRIBUTES = 20;
101
+ const MAX_CONNECTOR_ATTRIBUTE_LENGTH = 300;
102
+ const MAX_CONNECTOR_METHOD_LENGTH = 160;
103
+ const MAX_CONNECTOR_KEY_LENGTH = 80;
104
+ const BASE_LIMITATION = 'External provider observations are attributed context, not proof that a target is safe, malicious, active, or controlled by any party.';
105
+ const NO_MATCH_LIMITATION = 'No matching provider record is not evidence that the target is safe.';
106
+ const CONNECTOR_BASE_LIMITATION = 'Connector observations and relationships are attributed investigation pivots, not proof of ownership, coordination, activity, safety, or maliciousness.';
107
+ const CONNECTOR_NO_MATCH_LIMITATION = 'No matching connector output is not evidence that an entity or relationship is absent or safe.';
108
+ function httpsUrl(value) {
109
+ return boundedHttpsUrl(value, MAX_THREAT_INTELLIGENCE_URL_LENGTH);
110
+ }
111
+ function connectorEntityExposure(type) {
112
+ if (type === 'domain')
113
+ return 'registrable_domain';
114
+ if (type === 'hostname')
115
+ return 'hostname';
116
+ if (type === 'url')
117
+ return 'full_url';
118
+ if (type === 'ipv4' || type === 'ipv6')
119
+ return 'ip_address';
120
+ if (type === 'asn')
121
+ return 'asn';
122
+ return 'certificate_fingerprint';
123
+ }
124
+ function stableHash(value, seed) {
125
+ let hash = seed >>> 0;
126
+ for (let index = 0; index < value.length; index += 1) {
127
+ hash ^= value.charCodeAt(index);
128
+ hash = Math.imul(hash, 16777619) >>> 0;
129
+ }
130
+ return hash.toString(36);
131
+ }
132
+ function connectorStableId(prefix, canonical) {
133
+ return `${prefix}:${stableHash(canonical, 2166136261)}-${stableHash(canonical, 3339675911)}`;
134
+ }
135
+ function hasOnlyKeys(value, allowed) {
136
+ return Object.keys(value).every((key) => allowed.has(key));
137
+ }
138
+ function normalizeConnectorAttributes(value) {
139
+ if (value == null)
140
+ return { values: {}, discarded: 0 };
141
+ if (!isRecord(value))
142
+ return { values: {}, discarded: 1 };
143
+ const output = {};
144
+ const inputKeys = Object.keys(value).sort();
145
+ let discarded = Math.max(0, inputKeys.length - MAX_CONNECTOR_ATTRIBUTES * 2);
146
+ for (const key of inputKeys.slice(0, MAX_CONNECTOR_ATTRIBUTES * 2)) {
147
+ if (Object.keys(output).length >= MAX_CONNECTOR_ATTRIBUTES
148
+ || key.length > 40
149
+ || !/^[a-z0-9][a-z0-9_-]*$/iu.test(key)) {
150
+ discarded += 1;
151
+ continue;
152
+ }
153
+ const raw = value[key];
154
+ if (typeof raw === 'boolean')
155
+ output[key] = raw;
156
+ else if (typeof raw === 'number' && Number.isFinite(raw))
157
+ output[key] = raw;
158
+ else {
159
+ const normalized = strictBoundedString(raw, MAX_CONNECTOR_ATTRIBUTE_LENGTH);
160
+ if (normalized !== null)
161
+ output[key] = normalized;
162
+ else
163
+ discarded += 1;
164
+ }
165
+ }
166
+ return { values: output, discarded };
167
+ }
168
+ function normalizeConnectorEntity(value, allowedTypes) {
169
+ if (!isRecord(value)
170
+ || !hasOnlyKeys(value, new Set(['key', 'type', 'value', 'label', 'attributes'])))
171
+ return null;
172
+ const key = strictBoundedString(value.key, MAX_CONNECTOR_KEY_LENGTH);
173
+ const type = typeof value.type === 'string' && CURATED_CONNECTOR_ENTITY_TYPES.has(value.type)
174
+ ? value.type
175
+ : null;
176
+ if (!key || !/^[a-z0-9][a-z0-9._:-]*$/iu.test(key) || !type || !allowedTypes.has(type))
177
+ return null;
178
+ let normalized;
179
+ try {
180
+ normalized = normalizeCuratedConnectorTarget({ type, value: value.value }, connectorEntityExposure(type));
181
+ }
182
+ catch {
183
+ return null;
184
+ }
185
+ const canonicalKey = `${type}:${normalized.value}`;
186
+ const attributes = normalizeConnectorAttributes(value.attributes);
187
+ return {
188
+ key,
189
+ canonicalKey,
190
+ discardedAttributes: attributes.discarded,
191
+ entity: {
192
+ id: connectorStableId('entity', canonicalKey),
193
+ type,
194
+ canonical: normalized.value,
195
+ label: boundedString(value.label, MAX_PROVIDER_LABEL_LENGTH) || normalized.value,
196
+ attributes: attributes.values,
197
+ },
198
+ };
199
+ }
200
+ function compareConnectorEntities(left, right) {
201
+ return left.entity.id.localeCompare(right.entity.id)
202
+ || left.key.localeCompare(right.key)
203
+ || JSON.stringify(left.entity).localeCompare(JSON.stringify(right.entity));
204
+ }
205
+ function normalizeConnectorLimitations(value) {
206
+ if (value == null)
207
+ return { values: [], discarded: 0 };
208
+ if (!Array.isArray(value))
209
+ return { values: [], discarded: 1 };
210
+ const seen = new Set();
211
+ let discarded = Math.max(0, value.length - MAX_LIMITATIONS * 2);
212
+ for (const item of value.slice(0, MAX_LIMITATIONS * 2)) {
213
+ const normalized = strictBoundedString(item, MAX_DETAIL_LENGTH);
214
+ if (!normalized) {
215
+ discarded += 1;
216
+ continue;
217
+ }
218
+ if (seen.has(normalized))
219
+ continue;
220
+ if (seen.size >= MAX_LIMITATIONS) {
221
+ discarded += 1;
222
+ continue;
223
+ }
224
+ seen.add(normalized);
225
+ }
226
+ return { values: [...seen], discarded };
227
+ }
228
+ function normalizeConnectorRelationship(value, allowedTypes, entitiesByKey) {
229
+ if (!isRecord(value)
230
+ || !hasOnlyKeys(value, new Set([
231
+ 'type',
232
+ 'fromKey',
233
+ 'toKey',
234
+ 'classification',
235
+ 'method',
236
+ 'firstObservedAt',
237
+ 'lastObservedAt',
238
+ 'complete',
239
+ 'truncated',
240
+ 'limitations',
241
+ ])))
242
+ return null;
243
+ const type = typeof value.type === 'string'
244
+ && CONNECTOR_RELATIONSHIP_TYPES.has(value.type)
245
+ && allowedTypes.has(value.type)
246
+ ? value.type
247
+ : null;
248
+ const fromKey = strictBoundedString(value.fromKey, MAX_CONNECTOR_KEY_LENGTH);
249
+ const toKey = strictBoundedString(value.toKey, MAX_CONNECTOR_KEY_LENGTH);
250
+ const fromEntity = fromKey ? entitiesByKey.get(fromKey) : null;
251
+ const toEntity = toKey ? entitiesByKey.get(toKey) : null;
252
+ const classification = typeof value.classification === 'string'
253
+ && CONNECTOR_RELATIONSHIP_CLASSIFICATIONS.has(value.classification)
254
+ ? value.classification
255
+ : null;
256
+ const method = strictBoundedString(value.method, MAX_CONNECTOR_METHOD_LENGTH);
257
+ const firstObservedAt = value.firstObservedAt == null ? null : isoTimestamp(value.firstObservedAt);
258
+ const lastObservedAt = value.lastObservedAt == null ? null : isoTimestamp(value.lastObservedAt);
259
+ const endpoints = type ? CONNECTOR_RELATIONSHIP_ENDPOINTS[type] : null;
260
+ if (!type || !fromEntity || !toEntity || fromEntity.id === toEntity.id
261
+ || !endpoints?.from.has(fromEntity.type) || !endpoints.to.has(toEntity.type)
262
+ || !classification || !method
263
+ || (value.firstObservedAt != null && !firstObservedAt)
264
+ || (value.lastObservedAt != null && !lastObservedAt)
265
+ || (firstObservedAt && lastObservedAt && firstObservedAt > lastObservedAt)
266
+ || (value.complete != null && typeof value.complete !== 'boolean')
267
+ || (value.truncated != null && typeof value.truncated !== 'boolean'))
268
+ return null;
269
+ const canonical = JSON.stringify([type, fromEntity.id, toEntity.id, classification, method]);
270
+ const limitations = normalizeConnectorLimitations(value.limitations);
271
+ return {
272
+ canonical,
273
+ discardedMetadata: limitations.discarded,
274
+ relationship: {
275
+ id: connectorStableId('relationship', canonical),
276
+ type,
277
+ from: fromEntity.id,
278
+ to: toEntity.id,
279
+ classification,
280
+ method,
281
+ firstObservedAt,
282
+ lastObservedAt,
283
+ complete: typeof value.complete === 'boolean' ? value.complete : null,
284
+ truncated: typeof value.truncated === 'boolean' ? value.truncated : null,
285
+ limitations: limitations.values,
286
+ },
287
+ };
288
+ }
289
+ function compareConnectorRelationships(left, right) {
290
+ return left.relationship.type.localeCompare(right.relationship.type)
291
+ || left.relationship.from.localeCompare(right.relationship.from)
292
+ || left.relationship.to.localeCompare(right.relationship.to)
293
+ || left.relationship.id.localeCompare(right.relationship.id)
294
+ || JSON.stringify(left.relationship).localeCompare(JSON.stringify(right.relationship));
295
+ }
296
+ function normalizeTags(value) {
297
+ if (!Array.isArray(value))
298
+ return [];
299
+ return [...new Set(value
300
+ .slice(0, MAX_TAGS * 2)
301
+ .map((item) => boundedString(item, MAX_TAG_LENGTH))
302
+ .filter((item) => item !== null))]
303
+ .sort()
304
+ .slice(0, MAX_TAGS);
305
+ }
306
+ function normalizeFinding(value) {
307
+ if (!isRecord(value))
308
+ return null;
309
+ const category = typeof value.category === 'string' && CATEGORIES.has(value.category)
310
+ ? value.category
311
+ : null;
312
+ if (!category)
313
+ return null;
314
+ const firstObservedAt = value.firstObservedAt == null ? null : isoTimestamp(value.firstObservedAt);
315
+ const lastObservedAt = value.lastObservedAt == null ? null : isoTimestamp(value.lastObservedAt);
316
+ if ((value.firstObservedAt != null && !firstObservedAt)
317
+ || (value.lastObservedAt != null && !lastObservedAt)
318
+ || (firstObservedAt && lastObservedAt && firstObservedAt > lastObservedAt))
319
+ return null;
320
+ const referenceUrl = value.referenceUrl == null ? null : httpsUrl(value.referenceUrl);
321
+ if (value.referenceUrl != null && !referenceUrl)
322
+ return null;
323
+ return {
324
+ id: strictBoundedString(value.id, MAX_FINDING_ID_LENGTH),
325
+ category,
326
+ severity: typeof value.severity === 'string' && SEVERITIES.has(value.severity)
327
+ ? value.severity
328
+ : 'unknown',
329
+ confidence: typeof value.confidence === 'string' && CONFIDENCES.has(value.confidence)
330
+ ? value.confidence
331
+ : 'unknown',
332
+ providerVerdict: boundedString(value.providerVerdict, MAX_VERDICT_LENGTH),
333
+ detail: boundedString(value.detail, MAX_DETAIL_LENGTH),
334
+ firstObservedAt,
335
+ lastObservedAt,
336
+ referenceUrl,
337
+ tags: normalizeTags(value.tags),
338
+ };
339
+ }
340
+ function findingKey(value) {
341
+ return value.id || JSON.stringify([
342
+ value.category,
343
+ value.severity,
344
+ value.providerVerdict,
345
+ value.firstObservedAt,
346
+ value.lastObservedAt,
347
+ value.referenceUrl,
348
+ ]);
349
+ }
350
+ function compareFindings(left, right) {
351
+ const timeOrder = (right.lastObservedAt || '').localeCompare(left.lastObservedAt || '');
352
+ if (timeOrder)
353
+ return timeOrder;
354
+ const keyOrder = findingKey(left).localeCompare(findingKey(right));
355
+ return keyOrder || JSON.stringify(left).localeCompare(JSON.stringify(right));
356
+ }
357
+ function normalizeLimitations(value) {
358
+ return [...new Set((Array.isArray(value) ? value : [])
359
+ .slice(0, MAX_LIMITATIONS * 2)
360
+ .map((item) => boundedString(item, MAX_DETAIL_LENGTH))
361
+ .filter((item) => item !== null))]
362
+ .slice(0, MAX_LIMITATIONS);
363
+ }
364
+ function createThreatIntelligenceResult(provider, target, input = {}, observedAt = new Date().toISOString()) {
365
+ assertThreatIntelligenceProvider(provider);
366
+ const targetType = isRecord(target) && (target.type === 'domain' || target.type === 'url')
367
+ ? target.type
368
+ : null;
369
+ const exposure = targetType ? provider.targets[targetType] : undefined;
370
+ if (!exposure)
371
+ throw new TypeError('Provider does not support this target type');
372
+ const normalizedTarget = normalizeThreatIntelligenceTarget(target, exposure);
373
+ const resultInput = isRecord(input) ? input : {};
374
+ const requestedState = typeof resultInput.state === 'string'
375
+ && RESULT_STATES.has(resultInput.state)
376
+ ? resultInput.state
377
+ : 'error';
378
+ const rawFindings = Array.isArray(resultInput.findings)
379
+ ? resultInput.findings.slice(0, MAX_INPUT_FINDINGS)
380
+ : [];
381
+ const normalizedFindings = [];
382
+ let discarded = Array.isArray(resultInput.findings)
383
+ ? Math.max(0, resultInput.findings.length - MAX_INPUT_FINDINGS)
384
+ : 0;
385
+ for (const item of rawFindings) {
386
+ const finding = normalizeFinding(item);
387
+ if (!finding) {
388
+ discarded += 1;
389
+ continue;
390
+ }
391
+ normalizedFindings.push(finding);
392
+ }
393
+ normalizedFindings.sort(compareFindings);
394
+ const findings = [];
395
+ const seen = new Set();
396
+ for (const finding of normalizedFindings) {
397
+ const key = findingKey(finding);
398
+ if (seen.has(key))
399
+ continue;
400
+ seen.add(key);
401
+ findings.push(finding);
402
+ }
403
+ if (findings.length > MAX_FINDINGS) {
404
+ discarded += findings.length - MAX_FINDINGS;
405
+ findings.length = MAX_FINDINGS;
406
+ }
407
+ let state = requestedState;
408
+ const sourceTruncated = resultInput.truncated === true;
409
+ if (TERMINAL_STATES_WITHOUT_FINDINGS.has(state) && findings.length)
410
+ state = 'partial';
411
+ if (state === 'success' && findings.length === 0)
412
+ state = 'error';
413
+ if ((discarded > 0 || sourceTruncated) && !['error', 'unavailable', 'rate_limited'].includes(state))
414
+ state = 'partial';
415
+ const limitations = normalizeLimitations(resultInput.limitations);
416
+ if (state === 'not_found')
417
+ limitations.unshift(NO_MATCH_LIMITATION);
418
+ if (discarded > 0)
419
+ limitations.unshift(`${discarded} invalid or over-limit provider finding${discarded === 1 ? ' was' : 's were'} omitted.`);
420
+ limitations.unshift(BASE_LIMITATION);
421
+ const boundedLimitations = [...new Set(limitations)].slice(0, MAX_LIMITATIONS);
422
+ const complete = ['success', 'not_found'].includes(state) && discarded === 0 && !sourceTruncated;
423
+ const observationStatus = state === 'rate_limited' || state === 'unavailable'
424
+ ? 'error'
425
+ : state;
426
+ const upstreamStatus = typeof resultInput.upstreamStatus === 'number'
427
+ && Number.isInteger(resultInput.upstreamStatus)
428
+ && resultInput.upstreamStatus >= 100
429
+ && resultInput.upstreamStatus <= 599
430
+ ? resultInput.upstreamStatus
431
+ : null;
432
+ const retryAfterSeconds = typeof resultInput.retryAfterSeconds === 'number'
433
+ && Number.isInteger(resultInput.retryAfterSeconds)
434
+ && resultInput.retryAfterSeconds >= 1
435
+ && resultInput.retryAfterSeconds <= 86_400
436
+ ? resultInput.retryAfterSeconds
437
+ : null;
438
+ return {
439
+ schema: THREAT_INTELLIGENCE_SCHEMA,
440
+ version: THREAT_INTELLIGENCE_CONTRACT_VERSION,
441
+ provider: { id: provider.id, label: provider.label },
442
+ target: normalizedTarget,
443
+ state,
444
+ detail: boundedString(resultInput.detail, MAX_DETAIL_LENGTH),
445
+ upstreamStatus,
446
+ retryAfterSeconds,
447
+ findings,
448
+ observation: createObservation({
449
+ status: observationStatus,
450
+ observedAt: isoTimestamp(observedAt) || new Date().toISOString(),
451
+ source: provider.id,
452
+ complete,
453
+ truncated: discarded > 0 || sourceTruncated,
454
+ limitations: boundedLimitations,
455
+ diagnostics: { discarded },
456
+ }),
457
+ };
458
+ }
459
+ function createCuratedConnectorResult(connector, target, input = {}, observedAt = new Date().toISOString()) {
460
+ assertCuratedConnectorDefinition(connector);
461
+ const normalizedObservedAt = isoTimestamp(observedAt);
462
+ if (!normalizedObservedAt)
463
+ throw new TypeError('Connector observation timestamp is invalid');
464
+ const targetType = isRecord(target)
465
+ && typeof target.type === 'string'
466
+ && CURATED_CONNECTOR_ENTITY_TYPES.has(target.type)
467
+ ? target.type
468
+ : null;
469
+ const inputDeclaration = targetType
470
+ ? connector.inputs.find((item) => item.type === targetType)
471
+ : null;
472
+ if (!inputDeclaration)
473
+ throw new TypeError('Connector does not support this target type');
474
+ const normalizedTarget = normalizeCuratedConnectorTarget(target, inputDeclaration.exposure);
475
+ const resultInput = isRecord(input) ? input : {};
476
+ const requestedState = typeof resultInput.state === 'string'
477
+ && RESULT_STATES.has(resultInput.state)
478
+ ? resultInput.state
479
+ : 'error';
480
+ const rawEntities = Array.isArray(resultInput.entities)
481
+ ? resultInput.entities.slice(0, MAX_CONNECTOR_INPUT_ENTITIES)
482
+ : [];
483
+ let discardedEntities = Array.isArray(resultInput.entities)
484
+ ? Math.max(0, resultInput.entities.length - MAX_CONNECTOR_INPUT_ENTITIES)
485
+ : 0;
486
+ let discardedAttributes = 0;
487
+ const entityCandidates = [];
488
+ const allowedEntityTypes = new Set(connector.outputs.entities);
489
+ for (const item of rawEntities) {
490
+ const candidate = normalizeConnectorEntity(item, allowedEntityTypes);
491
+ if (!candidate) {
492
+ discardedEntities += 1;
493
+ continue;
494
+ }
495
+ discardedAttributes += candidate.discardedAttributes;
496
+ entityCandidates.push(candidate);
497
+ }
498
+ entityCandidates.sort(compareConnectorEntities);
499
+ const entitiesByCanonical = new Map();
500
+ const entityCanonicalById = new Map();
501
+ const entitiesByKey = new Map();
502
+ const seenEntityKeys = new Set();
503
+ for (const candidate of entityCandidates) {
504
+ if (seenEntityKeys.has(candidate.key)) {
505
+ discardedEntities += 1;
506
+ continue;
507
+ }
508
+ seenEntityKeys.add(candidate.key);
509
+ const retained = entitiesByCanonical.get(candidate.canonicalKey);
510
+ if (retained) {
511
+ entitiesByKey.set(candidate.key, { id: retained.id, type: retained.type });
512
+ if (JSON.stringify(retained) !== JSON.stringify(candidate.entity))
513
+ discardedEntities += 1;
514
+ continue;
515
+ }
516
+ if (entityCanonicalById.has(candidate.entity.id)
517
+ || entitiesByCanonical.size >= connector.limits.maxEntities) {
518
+ discardedEntities += 1;
519
+ continue;
520
+ }
521
+ entitiesByCanonical.set(candidate.canonicalKey, candidate.entity);
522
+ entityCanonicalById.set(candidate.entity.id, candidate.canonicalKey);
523
+ entitiesByKey.set(candidate.key, { id: candidate.entity.id, type: candidate.entity.type });
524
+ }
525
+ const entities = [...entitiesByCanonical.values()].sort((left, right) => left.id.localeCompare(right.id));
526
+ const rawRelationships = Array.isArray(resultInput.relationships)
527
+ ? resultInput.relationships.slice(0, MAX_CONNECTOR_INPUT_RELATIONSHIPS)
528
+ : [];
529
+ let discardedRelationships = Array.isArray(resultInput.relationships)
530
+ ? Math.max(0, resultInput.relationships.length - MAX_CONNECTOR_INPUT_RELATIONSHIPS)
531
+ : 0;
532
+ const relationshipCandidates = [];
533
+ let discardedRelationshipMetadata = 0;
534
+ const allowedRelationshipTypes = new Set(connector.outputs.relationships);
535
+ for (const item of rawRelationships) {
536
+ const relationship = normalizeConnectorRelationship(item, allowedRelationshipTypes, entitiesByKey);
537
+ if (!relationship) {
538
+ discardedRelationships += 1;
539
+ continue;
540
+ }
541
+ discardedRelationshipMetadata += relationship.discardedMetadata;
542
+ relationshipCandidates.push(relationship);
543
+ }
544
+ relationshipCandidates.sort(compareConnectorRelationships);
545
+ const relationships = [];
546
+ const relationshipsById = new Map();
547
+ for (const candidate of relationshipCandidates) {
548
+ const { relationship } = candidate;
549
+ const retained = relationshipsById.get(relationship.id);
550
+ if (retained?.canonical === candidate.canonical) {
551
+ if (JSON.stringify(retained.relationship) !== JSON.stringify(relationship))
552
+ discardedRelationships += 1;
553
+ continue;
554
+ }
555
+ if (retained) {
556
+ discardedRelationships += 1;
557
+ continue;
558
+ }
559
+ if (relationships.length >= connector.limits.maxRelationships) {
560
+ discardedRelationships += 1;
561
+ continue;
562
+ }
563
+ relationshipsById.set(relationship.id, candidate);
564
+ relationships.push(relationship);
565
+ }
566
+ let state = requestedState;
567
+ const sourceTruncated = resultInput.truncated === true;
568
+ const relationshipPartial = relationships.some((item) => item.complete === false || item.truncated === true);
569
+ const hasOutput = entities.length > 0 || relationships.length > 0;
570
+ if (TERMINAL_STATES_WITHOUT_FINDINGS.has(state) && hasOutput)
571
+ state = 'partial';
572
+ if (state === 'success' && !hasOutput)
573
+ state = 'error';
574
+ const resultLimitations = normalizeConnectorLimitations(resultInput.limitations);
575
+ discardedRelationshipMetadata += resultLimitations.discarded;
576
+ if ((discardedEntities > 0
577
+ || discardedAttributes > 0
578
+ || discardedRelationships > 0
579
+ || discardedRelationshipMetadata > 0
580
+ || sourceTruncated
581
+ || relationshipPartial)
582
+ && !['error', 'unavailable', 'rate_limited'].includes(state))
583
+ state = 'partial';
584
+ const limitations = resultLimitations.values;
585
+ if (state === 'not_found')
586
+ limitations.unshift(CONNECTOR_NO_MATCH_LIMITATION);
587
+ if (discardedRelationships > 0) {
588
+ limitations.unshift(`${discardedRelationships} invalid, dangling, or over-limit connector relationship${discardedRelationships === 1 ? ' was' : 's were'} omitted.`);
589
+ }
590
+ if (discardedEntities > 0) {
591
+ limitations.unshift(`${discardedEntities} invalid, duplicate-key, hash-colliding, or over-limit connector entit${discardedEntities === 1 ? 'y was' : 'ies were'} omitted.`);
592
+ }
593
+ if (discardedAttributes > 0) {
594
+ limitations.unshift(`${discardedAttributes} invalid or over-limit connector attribute value${discardedAttributes === 1 ? ' was' : 's were'} omitted.`);
595
+ }
596
+ if (discardedRelationshipMetadata > 0) {
597
+ limitations.unshift(`${discardedRelationshipMetadata} invalid or over-limit connector limitation value${discardedRelationshipMetadata === 1 ? ' was' : 's were'} omitted.`);
598
+ }
599
+ limitations.unshift(CONNECTOR_BASE_LIMITATION);
600
+ const boundedLimitations = [...new Set(limitations)].slice(0, MAX_LIMITATIONS);
601
+ const complete = ['success', 'not_found'].includes(state)
602
+ && discardedEntities === 0
603
+ && discardedAttributes === 0
604
+ && discardedRelationships === 0
605
+ && discardedRelationshipMetadata === 0
606
+ && !sourceTruncated
607
+ && !relationshipPartial;
608
+ const observationStatus = state === 'rate_limited' || state === 'unavailable'
609
+ ? 'error'
610
+ : state;
611
+ const upstreamStatus = typeof resultInput.upstreamStatus === 'number'
612
+ && Number.isInteger(resultInput.upstreamStatus)
613
+ && resultInput.upstreamStatus >= 100
614
+ && resultInput.upstreamStatus <= 599
615
+ ? resultInput.upstreamStatus
616
+ : null;
617
+ const retryAfterSeconds = typeof resultInput.retryAfterSeconds === 'number'
618
+ && Number.isInteger(resultInput.retryAfterSeconds)
619
+ && resultInput.retryAfterSeconds >= 1
620
+ && resultInput.retryAfterSeconds <= 86_400
621
+ ? resultInput.retryAfterSeconds
622
+ : null;
623
+ return {
624
+ schema: CURATED_CONNECTOR_RESULT_SCHEMA,
625
+ version: CURATED_CONNECTOR_CONTRACT_VERSION,
626
+ connector: {
627
+ id: connector.id,
628
+ label: connector.label,
629
+ kinds: [...connector.kinds],
630
+ collection: connector.collection,
631
+ },
632
+ target: normalizedTarget,
633
+ state,
634
+ detail: boundedString(resultInput.detail, MAX_DETAIL_LENGTH),
635
+ upstreamStatus,
636
+ retryAfterSeconds,
637
+ entities,
638
+ relationships,
639
+ observation: createObservation({
640
+ status: observationStatus,
641
+ observedAt: normalizedObservedAt,
642
+ source: connector.id,
643
+ complete,
644
+ truncated: discardedEntities > 0
645
+ || discardedAttributes > 0
646
+ || discardedRelationships > 0
647
+ || discardedRelationshipMetadata > 0
648
+ || sourceTruncated
649
+ || relationships.some((item) => item.truncated === true),
650
+ limitations: boundedLimitations,
651
+ diagnostics: {
652
+ entities: entities.length,
653
+ relationships: relationships.length,
654
+ discarded_entities: discardedEntities,
655
+ discarded_attributes: discardedAttributes,
656
+ discarded_relationships: discardedRelationships,
657
+ discarded_metadata: discardedRelationshipMetadata,
658
+ },
659
+ }),
660
+ };
661
+ }
662
+ export { THREAT_INTELLIGENCE_CONTRACT_VERSION, THREAT_INTELLIGENCE_SCHEMA, CURATED_CONNECTOR_CONTRACT_VERSION, CURATED_CONNECTOR_RESULT_SCHEMA, MAX_FINDINGS, MAX_INPUT_FINDINGS, MAX_TIMEOUT_MS, MAX_RESPONSE_BYTES, MAX_CACHE_TTL_MS, MAX_CONNECTOR_ENTITIES, MAX_CONNECTOR_RELATIONSHIPS, MAX_CONNECTOR_INPUT_ENTITIES, MAX_CONNECTOR_INPUT_RELATIONSHIPS, normalizeThreatIntelligenceTarget, normalizeCuratedConnectorTarget, createThreatIntelligenceResult, createCuratedConnectorResult, };