@slicedearth/whoisleuth-cli 2.4.0 → 2.6.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 (340) hide show
  1. package/DISCLOSURE +1 -1
  2. package/NOTICE +2 -2
  3. package/cli/argument-values.mjs +55 -0
  4. package/cli/arguments.mjs +24 -470
  5. package/cli/artifact-validation/case-response.mjs +67 -9
  6. package/cli/artifact-validation/lookup-evidence.mjs +18 -4
  7. package/cli/artifact-validation/signed-review.mjs +61 -64
  8. package/cli/artifact-verify.mjs +11 -2
  9. package/cli/assurance-arguments.mjs +56 -0
  10. package/cli/assurance-command-definitions.mjs +179 -0
  11. package/cli/assurance-command-runner.mjs +1 -1
  12. package/cli/bounded-json.mjs +1 -1
  13. package/cli/bulk-checkpoint.mjs +1 -1
  14. package/cli/bulk-command-runner.mjs +6 -8
  15. package/cli/bulk.mjs +2 -3
  16. package/cli/case-command.mjs +99 -6
  17. package/cli/case-pack.mjs +19 -4
  18. package/cli/ci-report.mjs +6 -3
  19. package/cli/collection-arguments.mjs +123 -0
  20. package/cli/collection-command-definitions.mjs +330 -0
  21. package/cli/collection-preflight.mjs +8 -1
  22. package/cli/command-argument-grammar.mjs +8 -210
  23. package/cli/command-definition.mjs +330 -0
  24. package/cli/command-reference.mjs +55 -1388
  25. package/cli/command-seeds.mjs +18 -0
  26. package/cli/compare.mjs +1 -1
  27. package/cli/config-profile.mjs +1 -1
  28. package/cli/ct-event-intake.mjs +4 -4
  29. package/cli/discover.mjs +2 -2
  30. package/cli/discovery-observation-snapshot.mjs +2 -2
  31. package/cli/discovery-scan-command-runner.mjs +7 -9
  32. package/cli/discovery-scan.mjs +1 -1
  33. package/cli/discovery-snapshot.mjs +2 -2
  34. package/cli/discovery-workflow.mjs +4 -4
  35. package/cli/domain-control-monitor.mjs +6 -6
  36. package/cli/domain-control-observations.mjs +2 -1
  37. package/cli/domain-feed.mjs +61 -0
  38. package/cli/errors.mjs +5 -6
  39. package/cli/evidence-arguments.mjs +46 -0
  40. package/cli/evidence-command-definitions.mjs +69 -0
  41. package/cli/evidence-command-runner.mjs +2 -3
  42. package/cli/evidence-signing.mjs +3 -2
  43. package/cli/external-observation-mapping.mjs +2 -2
  44. package/cli/formatters/json.mjs +1 -13
  45. package/cli/formatters/terminal-command-formats.mjs +6 -0
  46. package/cli/formatters/terminal-lookup.mjs +6 -0
  47. package/cli/history-arguments.mjs +39 -0
  48. package/cli/history-command-definitions.mjs +88 -0
  49. package/cli/indicator-set-command.mjs +32 -0
  50. package/cli/inline-command-families.mjs +6 -47
  51. package/cli/inline-command-runner.mjs +4 -31
  52. package/cli/input.mjs +2 -2
  53. package/cli/intake-command.mjs +147 -0
  54. package/cli/interchange-report.mjs +4 -2
  55. package/cli/investigation-artifacts.mjs +3 -4
  56. package/cli/investigation-package-review.mjs +2 -0
  57. package/cli/investigation-plan.mjs +32 -154
  58. package/cli/investigation-recipes.mjs +177 -0
  59. package/cli/investigation-run.mjs +78 -12
  60. package/cli/local-mmdb-review.mjs +220 -17
  61. package/cli/lookalike-calibration.mjs +1 -1
  62. package/cli/lookup-brief.mjs +2 -2
  63. package/cli/lookup-command-runner.mjs +19 -35
  64. package/cli/lookup-diff.mjs +10 -9
  65. package/cli/lookup-plan.mjs +10 -14
  66. package/cli/lookup-reconcile.mjs +6 -5
  67. package/cli/lookup-timeline.mjs +7 -6
  68. package/cli/mail-header-review.mjs +24 -247
  69. package/cli/mail-review.mjs +9 -7
  70. package/cli/network-arguments.mjs +51 -0
  71. package/cli/network-command-definitions.mjs +130 -0
  72. package/cli/network-command-runner.mjs +43 -31
  73. package/cli/offline-evidence-review.mjs +85 -6
  74. package/cli/posture.mjs +1 -1
  75. package/cli/registry-fixture-scaffold.mjs +1 -1
  76. package/cli/retained-artifact-diff.mjs +8 -7
  77. package/cli/review-arguments.mjs +132 -0
  78. package/cli/review-command-definitions.mjs +279 -0
  79. package/cli/review-command-runner.mjs +72 -159
  80. package/cli/risk-calibration.mjs +14 -12
  81. package/cli/runner.mjs +20 -32
  82. package/cli/saved-lookup.mjs +2 -2
  83. package/cli/selected-input-worker.mjs +73 -0
  84. package/cli/sharing-review.mjs +5 -64
  85. package/cli/source-reliability.mjs +2 -2
  86. package/cli/support-arguments.mjs +49 -0
  87. package/cli/support-command-definitions.mjs +209 -0
  88. package/cli/support-command-runner.mjs +4 -4
  89. package/cli/watchlist-review.mjs +43 -0
  90. package/cli/workflow-arguments.mjs +100 -0
  91. package/cli/workflow-command-definitions.mjs +103 -0
  92. package/cli/workflow-command-runner.mjs +40 -1
  93. package/docs/cli-reference.md +27 -0
  94. package/docs/cli.md +322 -4
  95. package/docs/contextual-reviews.md +178 -0
  96. package/docs/infrastructure-observations.md +126 -0
  97. package/docs/offline-intake-context.md +213 -0
  98. package/lib/analyst-taxonomy.mjs +2 -40
  99. package/lib/application-version.mjs +1 -1
  100. package/lib/availability.mjs +13 -789
  101. package/lib/bounded-contract-normalizers.mjs +4 -0
  102. package/lib/bounded-json.mjs +2 -330
  103. package/lib/browser-library-profile.mjs +62 -84
  104. package/lib/candidate-provenance-bounds.mjs +2 -0
  105. package/lib/classify.mjs +1 -1
  106. package/lib/credential-form-attribution.mjs +136 -0
  107. package/lib/credential-surface-profile.mjs +6 -2
  108. package/lib/ct-query.mjs +2 -27
  109. package/lib/ct-response-bounds.mjs +2 -9
  110. package/lib/ct-search.mjs +83 -47
  111. package/lib/dmarc-discovery.mjs +62 -0
  112. package/lib/dns-convergence-review.mjs +21 -8
  113. package/lib/dns-delegation-health.mjs +35 -15
  114. package/lib/dns-inheritance-review.mjs +53 -63
  115. package/lib/dns-intelligence.mjs +27 -11
  116. package/lib/dns-query-coverage.mjs +40 -0
  117. package/lib/domain-assurance.mjs +1 -1
  118. package/lib/domain-change-packet.mjs +12 -13
  119. package/lib/domain-change-review.mjs +32 -14
  120. package/lib/domain-control-flight-recorder.mjs +103 -27
  121. package/lib/domain-control-manifest.mjs +25 -17
  122. package/lib/domain-posture-analysis.mjs +75 -7
  123. package/lib/domain-posture-parsers.mjs +9 -0
  124. package/lib/domain-posture.mjs +45 -25
  125. package/lib/domain-registration.mjs +392 -0
  126. package/lib/evidence-export-privacy.mjs +27 -17
  127. package/lib/evidence-export.mjs +3 -3
  128. package/lib/evidence-report.mjs +5 -1
  129. package/lib/external-intelligence-risk.mjs +2 -124
  130. package/lib/favicon.mjs +13 -4
  131. package/lib/generated/cisa-kev-catalog.mjs +41 -3
  132. package/lib/generated/registrar-standing-catalogue.mjs +18 -10
  133. package/lib/generated/retire-browser-catalog.mjs +332 -19
  134. package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
  135. package/lib/html-signals.mjs +13 -2
  136. package/lib/http-evidence-bounds.mjs +2 -8
  137. package/lib/http-intelligence.mjs +13 -1
  138. package/lib/idn-confusable-policy.mjs +6 -4
  139. package/lib/idn-confusables.mjs +1 -1
  140. package/lib/interchange-fidelity-registry.mjs +10 -0
  141. package/lib/lookup-child-profile-contract.mjs +28 -11
  142. package/lib/lookup-contract-primitives.mjs +1 -1
  143. package/lib/lookup-diagnostics.mjs +1 -23
  144. package/lib/lookup-network-evidence-bounds.mjs +1 -10
  145. package/lib/lookup-progress.mjs +50 -1
  146. package/lib/lookup-response-contract.mjs +70 -40
  147. package/lib/lookup-response.mjs +5 -15
  148. package/lib/lookup-source-progress.mjs +8 -22
  149. package/lib/lookup.mjs +1 -1
  150. package/lib/operation-budget.mjs +4 -0
  151. package/lib/opportunity-scoring.mjs +2 -107
  152. package/lib/outbound-identity.mjs +1 -1
  153. package/lib/page-fingerprints.mjs +18 -1
  154. package/lib/perceptual-hash-comparison.mjs +2 -34
  155. package/lib/perceptual-hash.mjs +36 -10
  156. package/lib/portable-generator.mjs +2 -29
  157. package/lib/project-metadata.mjs +2 -8
  158. package/lib/rdap-client.mjs +11 -7
  159. package/lib/registrable-domain.mjs +2 -56
  160. package/lib/registrar-standing-contract.mjs +1 -1
  161. package/lib/registrar-standing.mjs +9 -13
  162. package/lib/registry-capabilities.mjs +2 -2
  163. package/lib/registry-capability-catalogue.mjs +27 -19
  164. package/lib/risk-calibration-summary.mjs +2 -352
  165. package/lib/risk-scoring.mjs +2 -345
  166. package/lib/scoring-evidence-quality.mjs +2 -107
  167. package/lib/semantic-version.mjs +2 -54
  168. package/lib/service-binding-dns.mjs +46 -2
  169. package/lib/smtp-transport-review.mjs +48 -19
  170. package/lib/sslbl-certificates.generated.mjs +142 -139
  171. package/lib/static-html-analysis.mjs +32 -4
  172. package/lib/structured-data-identity.mjs +1 -1
  173. package/lib/technology-evidence-role.mjs +1 -51
  174. package/lib/threat-intelligence-manifests.mjs +1 -1
  175. package/lib/threat-intelligence-runtime.mjs +1 -1
  176. package/lib/threat-intelligence-targets.mjs +1 -1
  177. package/lib/threat-intelligence-types.mjs +2 -75
  178. package/lib/tls-intelligence.mjs +30 -5
  179. package/lib/typosquat-generator.mjs +11 -2
  180. package/lib/website-enrichment.mjs +404 -0
  181. package/lib/whois-authority.mjs +43 -5
  182. package/lib/whois-chain.mjs +12 -3
  183. package/lib/whois-common-formats.mjs +3 -1
  184. package/lib/whois-contacts.mjs +13 -2
  185. package/lib/whois-normalization.mjs +3 -1
  186. package/lib/whois-parser.mjs +5 -3
  187. package/package.json +15 -14
  188. package/packages/analysis/analyst-taxonomy.mjs +40 -0
  189. package/packages/analysis/bounded-json.mjs +330 -0
  190. package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
  191. package/packages/analysis/cli-argument-grammar.mjs +216 -0
  192. package/packages/analysis/ct-query.mjs +27 -0
  193. package/packages/analysis/ct-response-bounds.mjs +9 -0
  194. package/packages/analysis/external-intelligence-risk.mjs +124 -0
  195. package/packages/analysis/http-evidence-bounds.mjs +8 -0
  196. package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
  197. package/packages/analysis/opportunity-scoring.mjs +107 -0
  198. package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
  199. package/packages/analysis/portable-generator.mjs +29 -0
  200. package/packages/analysis/project-metadata.mjs +8 -0
  201. package/packages/analysis/registrable-domain.mjs +56 -0
  202. package/packages/analysis/risk-calibration-summary.mjs +352 -0
  203. package/packages/analysis/risk-scoring.mjs +348 -0
  204. package/packages/analysis/scoring-evidence-quality.mjs +107 -0
  205. package/packages/analysis/semantic-version.mjs +54 -0
  206. package/packages/analysis/threat-intelligence-types.mjs +75 -0
  207. package/packages/cases/case-containment-handoff.mjs +195 -0
  208. package/packages/cases/case-evidence-links.mjs +151 -0
  209. package/packages/cases/case-evidence-model.mjs +89 -12
  210. package/packages/cases/case-evidence-quality.mjs +5 -0
  211. package/packages/cases/case-export-input.mjs +7 -5
  212. package/packages/cases/case-incident-context.mjs +52 -0
  213. package/packages/cases/case-markdown.mjs +11 -0
  214. package/packages/cases/case-migration-model.mjs +40 -24
  215. package/packages/cases/case-object-coverage.mjs +33 -0
  216. package/packages/cases/case-packet-correction.mjs +106 -0
  217. package/packages/cases/case-recheck-model.mjs +124 -36
  218. package/packages/cases/case-record-contracts.mjs +1 -1
  219. package/packages/cases/case-record-core.mjs +1 -1
  220. package/packages/cases/case-record-operations.mjs +111 -118
  221. package/packages/cases/case-record-projection.mjs +23 -4
  222. package/packages/cases/case-record-version-input.mjs +41 -0
  223. package/packages/cases/case-report.mjs +93 -27
  224. package/packages/cases/case-requested-evidence.mjs +146 -0
  225. package/packages/cases/case-response-actions.mjs +159 -1
  226. package/packages/cases/case-response-model.mjs +16 -1
  227. package/packages/cases/case-response-object.mjs +119 -0
  228. package/packages/cases/case-response-outcomes.mjs +108 -20
  229. package/packages/cases/case-response-packet-render.mjs +35 -12
  230. package/packages/cases/case-response-packet.mjs +55 -13
  231. package/packages/cases/case-response-records.mjs +0 -1
  232. package/packages/cases/case-response-review-inputs.mjs +60 -8
  233. package/packages/cases/case-storage-model.mjs +1 -1
  234. package/packages/cases/case-workflow-metadata.mjs +217 -0
  235. package/packages/cases/case-workflow-migration.mjs +43 -0
  236. package/packages/cases/http-summary.mjs +6 -1
  237. package/packages/comparison/favicon-similarity.mjs +1 -1
  238. package/packages/comparison/page-similarity.mjs +1 -1
  239. package/packages/comparison/relationship-evidence.mjs +21 -9
  240. package/packages/comparison/relationship-provenance.mjs +7 -1
  241. package/packages/contracts/analyst-interchange.mjs +35 -15
  242. package/packages/contracts/candidate-watch-review.mjs +54 -0
  243. package/packages/contracts/case-portability.mjs +360 -49
  244. package/packages/contracts/cli-command-semantics.mjs +5 -0
  245. package/packages/contracts/cli-grammar.mjs +6 -0
  246. package/packages/contracts/cli-lookup-brief.mjs +2 -0
  247. package/packages/contracts/context-review.mjs +25 -0
  248. package/packages/contracts/document-intake.mjs +11 -0
  249. package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
  250. package/packages/contracts/domain-control-manifest.mjs +1 -1
  251. package/packages/contracts/domain-control-monitor.mjs +13 -2
  252. package/packages/contracts/domain-control-review.mjs +36 -15
  253. package/packages/contracts/external-observation-interchange.mjs +96 -28
  254. package/packages/contracts/har-review.mjs +2 -0
  255. package/packages/contracts/identity-events.mjs +4 -0
  256. package/packages/contracts/investigation-run.mjs +2 -0
  257. package/packages/contracts/ip-address.mjs +10 -0
  258. package/packages/contracts/lookup-evidence.mjs +3 -2
  259. package/packages/contracts/mail-authentication.mjs +5 -0
  260. package/packages/contracts/message-intake.mjs +37 -0
  261. package/packages/contracts/offline-comparison.mjs +4 -1
  262. package/packages/contracts/passive-mail.mjs +10 -0
  263. package/packages/contracts/risk-calibration.mjs +44 -29
  264. package/packages/contracts/schema-lifecycle.mjs +3 -1
  265. package/packages/contracts/web-capture.mjs +2 -2
  266. package/packages/contracts/workspace-portability.mjs +144 -28
  267. package/packages/evidence/collection-quality.mjs +67 -0
  268. package/packages/evidence/credential-form-attribution.mjs +60 -0
  269. package/packages/evidence/dns-query-outcome.mjs +72 -0
  270. package/packages/evidence/email-recipient.mjs +18 -0
  271. package/packages/evidence/image-regions.mjs +15 -0
  272. package/packages/evidence/lookup-target.mjs +5 -1
  273. package/packages/evidence/observation.mjs +2 -0
  274. package/packages/evidence/png-pixels.mjs +117 -0
  275. package/packages/evidence/public-address-policy.mjs +83 -0
  276. package/packages/evidence/record-identity.mjs +60 -16
  277. package/packages/evidence/technology-evidence-role.mjs +51 -0
  278. package/packages/interchange/defensive-indicator-export.mjs +264 -0
  279. package/packages/interchange/external-findings-import.mjs +36 -29
  280. package/packages/interchange/json-output.mjs +14 -0
  281. package/packages/interchange/managed-indicator-export.mjs +36 -0
  282. package/packages/interchange/managed-indicator-set.mjs +223 -0
  283. package/packages/interchange/misp-indicator-export.mjs +93 -0
  284. package/packages/interchange/sharing-policy.mjs +56 -0
  285. package/packages/interchange/stix-indicator-export.mjs +129 -0
  286. package/packages/interchange/web-capture-import.mjs +22 -15
  287. package/packages/investigation/acquisition-decision-packet.mjs +79 -1
  288. package/packages/investigation/bulk-mail-exposure.mjs +7 -13
  289. package/packages/investigation/capture-coverage.mjs +57 -0
  290. package/packages/investigation/connector-provenance-review.mjs +100 -0
  291. package/packages/investigation/context-review.mjs +35 -0
  292. package/packages/investigation/document-intake.mjs +87 -0
  293. package/packages/investigation/docx-intake.mjs +133 -0
  294. package/packages/investigation/domain-history-review.mjs +132 -0
  295. package/packages/investigation/domain-transition-review.mjs +49 -0
  296. package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
  297. package/packages/investigation/har-intake.mjs +68 -0
  298. package/packages/investigation/identity-event-intake.mjs +131 -0
  299. package/packages/investigation/identity-incident-review.mjs +39 -0
  300. package/packages/investigation/incident-sequence-review.mjs +58 -0
  301. package/packages/investigation/infrastructure-observation.mjs +234 -0
  302. package/packages/investigation/intake-context.mjs +284 -0
  303. package/packages/investigation/intake-indicators.mjs +116 -0
  304. package/packages/investigation/intake-phones.mjs +155 -0
  305. package/packages/investigation/intake-report.mjs +20 -0
  306. package/packages/investigation/investigation-manifest.mjs +35 -9
  307. package/packages/investigation/link-intake.mjs +209 -0
  308. package/packages/investigation/lookup-asset-graph.mjs +1 -1
  309. package/packages/investigation/mail-authentication-review.mjs +222 -0
  310. package/packages/investigation/mail-header-identity.mjs +209 -0
  311. package/packages/investigation/message-intake.mjs +235 -0
  312. package/packages/investigation/page-behaviour.mjs +113 -0
  313. package/packages/investigation/pdf-intake.mjs +155 -0
  314. package/packages/investigation/platform-continuity-review.mjs +60 -0
  315. package/packages/investigation/qr-intake.mjs +56 -0
  316. package/packages/investigation/requested-action-hints.mjs +11 -0
  317. package/packages/investigation/selected-input-review.mjs +17 -0
  318. package/packages/investigation/storefront-review.mjs +70 -0
  319. package/packages/monitoring/domain-feed.mjs +321 -0
  320. package/packages/workspace/brand-candidate-workflow.mjs +351 -0
  321. package/packages/workspace/brand-keyword-campaign.mjs +231 -0
  322. package/packages/workspace/brand-profile-model.mjs +48 -4
  323. package/packages/workspace/bulk-review-model.mjs +28 -26
  324. package/packages/workspace/bulk-session-model.mjs +27 -13
  325. package/packages/workspace/campaign-model.mjs +1 -1
  326. package/packages/workspace/candidate-watch-handoff.mjs +195 -0
  327. package/packages/workspace/detection-rule-model.mjs +11 -3
  328. package/packages/workspace/hostile-input.mjs +1 -1
  329. package/packages/workspace/investigation-guide.mjs +2 -2
  330. package/packages/workspace/investigation-template-model.mjs +26 -2
  331. package/packages/workspace/page-baseline.mjs +2 -2
  332. package/packages/workspace/relationship-observation-model.mjs +17 -13
  333. package/packages/workspace/shortlist-model.mjs +24 -7
  334. package/packages/workspace/watchlist-history.mjs +59 -16
  335. package/packages/workspace/watchlist-store.mjs +152 -11
  336. package/packages/workspace/website-snapshot-model.mjs +28 -3
  337. package/packages/workspace/workspace-archive-crypto.mjs +1 -1
  338. package/packages/workspace/workspace-archive.mjs +5 -4
  339. package/third-party-notices.txt +549 -16
  340. package/lib/hostname.mjs +0 -2
@@ -106,6 +106,8 @@ const REGISTERED_DIAGNOSTIC_KEYS = [
106
106
  'queriedAuthorityCount',
107
107
  'redirectCount',
108
108
  'referencesExamined',
109
+ 'referenceSignatureTimedOut',
110
+ 'referenceSignatureUnavailable',
109
111
  'rejectedRows',
110
112
  'relationshipTagsExamined',
111
113
  'relationshipUrlsDiscarded',
@@ -0,0 +1,117 @@
1
+ import { decode, convertIndexedToRgb, hasPngSignature } from 'fast-png';
2
+ import { Unzlib } from 'fflate';
3
+ import { updateCrc32 } from "../interchange/crc32.mjs";
4
+ import { readEvidenceImageDimensions } from "./image-regions.mjs";
5
+ /** Admit PNG structure and inflated work before the image library allocates it. */
6
+ export function decodeEvidencePng(bytes) {
7
+ if (!(bytes instanceof Uint8Array) || !(bytes.buffer instanceof ArrayBuffer) || !hasPngSignature(bytes) || bytes.byteLength < 33)
8
+ throw new TypeError('Select a valid PNG image.');
9
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
10
+ if (view.getUint32(8) !== 13 || String.fromCharCode(...bytes.subarray(12, 16)) !== 'IHDR')
11
+ throw new TypeError('The PNG header is malformed.');
12
+ const { width, height } = readEvidenceImageDimensions(view.getUint32(16), view.getUint32(20));
13
+ if (bytes[24] < 8 && bytes[28] !== 0)
14
+ throw new TypeError('Convert this interlaced low-bit-depth PNG to a non-interlaced image before review.');
15
+ const maximumInflatedBytes = width * height * 8 + height * 8 + 64;
16
+ let inflatedBytes = 0;
17
+ const inflater = new Unzlib(chunk => {
18
+ inflatedBytes += chunk.byteLength;
19
+ if (inflatedBytes > maximumInflatedBytes)
20
+ throw new TypeError('The PNG exceeds its dimension-derived decompression bound.');
21
+ });
22
+ const selected = [bytes.subarray(0, 8)];
23
+ let offset = 8, total = 8, ended = false, imageData = false, dataEnded = false, header = false, palette = false, transparency = false;
24
+ while (offset < bytes.byteLength) {
25
+ if (bytes.byteLength - offset < 12)
26
+ throw new TypeError('The PNG contains a truncated chunk.');
27
+ const length = view.getUint32(offset), end = offset + length + 12;
28
+ if (end > bytes.byteLength)
29
+ throw new TypeError('The PNG contains a truncated chunk.');
30
+ const kind = String.fromCharCode(...bytes.subarray(offset + 4, offset + 8));
31
+ if (!/^[A-Za-z]{4}$/u.test(kind))
32
+ throw new TypeError('The PNG contains an invalid chunk type.');
33
+ const crc = (updateCrc32(0xffff_ffff, bytes.subarray(offset + 4, end - 4)) ^ 0xffff_ffff) >>> 0;
34
+ if (crc !== view.getUint32(end - 4))
35
+ throw new TypeError('The PNG chunk checksum does not match.');
36
+ if (kind === 'acTL' || kind === 'fcTL' || kind === 'fdAT')
37
+ throw new TypeError('Select a still PNG image; animated frames are not silently omitted.');
38
+ if (kind === 'IHDR') {
39
+ if (header || offset !== 8 || length !== 13)
40
+ throw new TypeError('The PNG has conflicting headers.');
41
+ header = true;
42
+ }
43
+ else if (kind === 'PLTE') {
44
+ if (palette || imageData || !length || length > 768 || length % 3)
45
+ throw new TypeError('The PNG palette is malformed.');
46
+ palette = true;
47
+ }
48
+ else if (kind === 'tRNS') {
49
+ if (transparency || imageData || length > 256)
50
+ throw new TypeError('The PNG transparency data is malformed.');
51
+ transparency = true;
52
+ }
53
+ else if (kind === 'IDAT') {
54
+ if (dataEnded)
55
+ throw new TypeError('The PNG image-data chunks are not consecutive.');
56
+ imageData = true;
57
+ // A small compressed slice bounds transient inflation before the callback.
58
+ for (let position = offset + 8; position < end - 4; position += 1_024)
59
+ inflater.push(bytes.subarray(position, Math.min(position + 1_024, end - 4)), false);
60
+ }
61
+ else if (kind === 'IEND') {
62
+ if (length || !imageData || end !== bytes.byteLength)
63
+ throw new TypeError('The PNG trailer is malformed.');
64
+ ended = true;
65
+ }
66
+ else if (kind[0] === kind[0].toUpperCase())
67
+ throw new TypeError('The PNG has an unsupported critical chunk.');
68
+ if (imageData && kind !== 'IDAT')
69
+ dataEnded = true;
70
+ // Colour-profile and text metadata are irrelevant to QR pixel analysis and
71
+ // may contain independent compressed payloads. Never decode those payloads.
72
+ if (['IHDR', 'PLTE', 'tRNS', 'IDAT', 'IEND'].includes(kind)) {
73
+ const part = bytes.subarray(offset, end);
74
+ selected.push(part);
75
+ total += part.byteLength;
76
+ }
77
+ offset = end;
78
+ }
79
+ if (!ended)
80
+ throw new TypeError('The PNG trailer is missing.');
81
+ inflater.push(new Uint8Array(), true);
82
+ const admitted = new Uint8Array(total);
83
+ let position = 0;
84
+ for (const part of selected) {
85
+ admitted.set(part, position);
86
+ position += part.byteLength;
87
+ }
88
+ let image = decode(admitted, { checkCrc: true });
89
+ if (image.width !== width || image.height !== height)
90
+ throw new TypeError('The decoded PNG dimensions changed.');
91
+ if (image.depth < 8 && !image.palette && image.channels === 1) {
92
+ const maximum = (1 << image.depth) - 1;
93
+ // Use the library's packed-sample conversion for grayscale as well as
94
+ // indexed images; a generated grayscale palette does not parse new bytes.
95
+ image = { ...image, palette: Array.from({ length: maximum + 1 }, (_, value) => {
96
+ const grey = Math.round(value * 255 / maximum);
97
+ return [grey, grey, grey, image.transparency?.[0] === value ? 0 : 255];
98
+ }) };
99
+ }
100
+ const indexed = Boolean(image.palette), samples = indexed ? convertIndexedToRgb(image) : image.data;
101
+ const channels = indexed ? image.palette[0].length : image.channels;
102
+ if (![1, 2, 3, 4].includes(channels) || samples.length !== width * height * channels)
103
+ throw new TypeError('The PNG pixel layout is unsupported.');
104
+ const pixels = new Uint8ClampedArray(width * height * 4);
105
+ const sample = (index) => image.depth === 16 && !indexed ? Math.round(samples[index] / 257) : samples[index];
106
+ for (let index = 0; index < width * height; index++) {
107
+ const source = index * channels, target = index * 4;
108
+ pixels[target] = sample(source);
109
+ pixels[target + 1] = channels <= 2 ? sample(source) : sample(source + 1);
110
+ pixels[target + 2] = channels <= 2 ? sample(source) : sample(source + 2);
111
+ pixels[target + 3] = channels === 2 || channels === 4 ? sample(source + channels - 1) : 255;
112
+ if (!indexed && image.transparency && (channels === 1 ? samples[source] === image.transparency[0]
113
+ : channels === 3 && [0, 1, 2].every(channel => samples[source + channel] === image.transparency[channel])))
114
+ pixels[target + 3] = 0;
115
+ }
116
+ return { width, height, pixels };
117
+ }
@@ -0,0 +1,83 @@
1
+ // Browser-safe canonical public-address policy for analyst-supplied address
2
+ // evidence. This performs no resolution or network work; transport owners must
3
+ // still revalidate and pin addresses before making a request.
4
+ import { ipv6Groups } from "../contracts/ip-address.mjs";
5
+ const IPV4_RE = /^\d{1,3}(?:\.\d{1,3}){3}$/u;
6
+ const IPV6_RE = /^[0-9a-f:]+$/iu;
7
+ function canonicalIpv4(value) {
8
+ if (typeof value !== 'string' || !IPV4_RE.test(value.trim()))
9
+ return null;
10
+ const octets = value.trim().split('.').map(Number);
11
+ if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255))
12
+ return null;
13
+ return octets.join('.');
14
+ }
15
+ function isPublicIpv4(value) {
16
+ const octets = value.split('.').map(Number);
17
+ const [first = -1, second = -1, third = -1] = octets;
18
+ return octets.length === 4
19
+ && first !== 0
20
+ && first !== 10
21
+ && first !== 127
22
+ && first < 224
23
+ && !(first === 100 && second >= 64 && second <= 127)
24
+ && !(first === 169 && second === 254)
25
+ && !(first === 172 && second >= 16 && second <= 31)
26
+ && !(first === 192 && second === 168)
27
+ && !(first === 198 && (second === 18 || second === 19))
28
+ && !(first === 192 && second === 0 && third === 0)
29
+ && !(first === 192 && second === 0 && third === 2)
30
+ && !(first === 192 && second === 88 && third === 99)
31
+ && !(first === 198 && second === 51 && third === 100)
32
+ && !(first === 203 && second === 0 && third === 113);
33
+ }
34
+ function canonicalIpv6(value) {
35
+ if (typeof value !== 'string')
36
+ return null;
37
+ const candidate = value.trim().toLowerCase();
38
+ if (!candidate.includes(':') || candidate.includes('%') || candidate.includes('.') || !IPV6_RE.test(candidate)) {
39
+ return null;
40
+ }
41
+ try {
42
+ const host = new URL(`https://[${candidate}]/`).hostname;
43
+ return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : null;
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ function isPublicIpv6(value) {
50
+ const groups = ipv6Groups(value);
51
+ if (groups.length !== 8)
52
+ return false;
53
+ const [first = '', second = '', third = ''] = groups;
54
+ if (groups.every((group) => group === '0000'))
55
+ return false;
56
+ if (groups.slice(0, 7).every((group) => group === '0000') && groups[7] === '0001')
57
+ return false;
58
+ if (first.startsWith('ff') || /^fe[89ab]/u.test(first) || /^fe[cdef]/u.test(first) || /^f[cd]/u.test(first))
59
+ return false;
60
+ if (first === '0100' && groups.slice(1, 4).every((group) => group === '0000'))
61
+ return false;
62
+ if (first === '2001' && second === '0db8')
63
+ return false;
64
+ if (first === '3fff' && Number.parseInt(second, 16) <= 0x0fff)
65
+ return false;
66
+ if (first === '2001' && second === '0002' && third === '0000')
67
+ return false;
68
+ if (first === '2001' && /^00[1-2][0-9a-f]$/u.test(second))
69
+ return false;
70
+ if (first === '2001' && second === '0000')
71
+ return false;
72
+ if (first === '2002')
73
+ return false;
74
+ const firstValue = Number.parseInt(first, 16);
75
+ return Number.isInteger(firstValue) && firstValue >= 0x2000 && firstValue <= 0x3fff;
76
+ }
77
+ export function canonicalPublicIpAddress(value) {
78
+ const ipv4 = canonicalIpv4(value);
79
+ if (ipv4)
80
+ return isPublicIpv4(ipv4) ? ipv4 : null;
81
+ const ipv6 = canonicalIpv6(value);
82
+ return ipv6 && isPublicIpv6(ipv6) ? ipv6 : null;
83
+ }
@@ -1,6 +1,7 @@
1
1
  // Shared collision-resistant identities for bounded, already normalised records.
2
2
  // Platform cryptography remains the owner of signatures and authentication.
3
3
  export const MAX_IDENTITY_DIGEST_BYTES = 1024 * 1024;
4
+ export const MAX_INCREMENTAL_DIGEST_BYTES = 256 * 1024 * 1024;
4
5
  const SHA256_CONSTANTS = Object.freeze([
5
6
  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
6
7
  0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
@@ -14,27 +15,21 @@ const SHA256_CONSTANTS = Object.freeze([
14
15
  function rotateRight(value, bits) {
15
16
  return (value >>> bits) | (value << (32 - bits));
16
17
  }
17
- export function sha256IdentityHex(bytes) {
18
- if (bytes.byteLength > MAX_IDENTITY_DIGEST_BYTES)
19
- throw new RangeError('Record identity exceeds its byte limit.');
20
- // This digest is a collision-resistant record identity, not a password,
21
- // signature, MAC, or substitute for the platform cryptography used there.
22
- const paddedLength = Math.ceil((bytes.byteLength + 9) / 64) * 64;
23
- const padded = new Uint8Array(paddedLength);
24
- padded.set(bytes);
25
- padded[bytes.byteLength] = 0x80;
26
- const view = new DataView(padded.buffer);
27
- const bitLength = bytes.byteLength * 8;
28
- view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x1_0000_0000), false);
29
- view.setUint32(paddedLength - 4, bitLength >>> 0, false);
18
+ /** Incremental content identity only; never authentication or publisher proof. */
19
+ export function createIncrementalSha256(maximumBytes = MAX_IDENTITY_DIGEST_BYTES) {
20
+ if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0 || maximumBytes > MAX_INCREMENTAL_DIGEST_BYTES)
21
+ throw new RangeError('Content identity has an invalid byte limit.');
30
22
  const state = new Uint32Array([
31
23
  0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
32
24
  0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
33
25
  ]);
34
26
  const words = new Uint32Array(64);
35
- for (let offset = 0; offset < paddedLength; offset += 64) {
27
+ const pending = new Uint8Array(64);
28
+ let pendingBytes = 0, totalBytes = 0, finished = false;
29
+ function block(bytes, offset) {
30
+ const view = new DataView(bytes.buffer, bytes.byteOffset + offset, 64);
36
31
  for (let index = 0; index < 16; index += 1)
37
- words[index] = view.getUint32(offset + index * 4, false);
32
+ words[index] = view.getUint32(index * 4, false);
38
33
  for (let index = 16; index < 64; index += 1) {
39
34
  const left = words[index - 15];
40
35
  const right = words[index - 2];
@@ -68,5 +63,54 @@ export function sha256IdentityHex(bytes) {
68
63
  state[6] = (state[6] + g) >>> 0;
69
64
  state[7] = (state[7] + h) >>> 0;
70
65
  }
71
- return [...state].map((value) => value.toString(16).padStart(8, '0')).join('');
66
+ return Object.freeze({
67
+ update(bytes) {
68
+ if (finished)
69
+ throw new Error('Content identity is already finalised.');
70
+ if (!(bytes instanceof Uint8Array) || bytes.byteLength > maximumBytes - totalBytes)
71
+ throw new RangeError('Content identity exceeds its byte limit.');
72
+ totalBytes += bytes.byteLength;
73
+ let offset = 0;
74
+ if (pendingBytes) {
75
+ const length = Math.min(64 - pendingBytes, bytes.byteLength);
76
+ pending.set(bytes.subarray(0, length), pendingBytes);
77
+ pendingBytes += length;
78
+ offset += length;
79
+ if (pendingBytes === 64) {
80
+ block(pending, 0);
81
+ pendingBytes = 0;
82
+ }
83
+ }
84
+ while (offset + 64 <= bytes.byteLength) {
85
+ block(bytes, offset);
86
+ offset += 64;
87
+ }
88
+ if (offset < bytes.byteLength) {
89
+ pending.set(bytes.subarray(offset), 0);
90
+ pendingBytes = bytes.byteLength - offset;
91
+ }
92
+ },
93
+ digestHex() {
94
+ if (finished)
95
+ throw new Error('Content identity is already finalised.');
96
+ finished = true;
97
+ const padding = new Uint8Array(pendingBytes < 56 ? 64 : 128);
98
+ padding.set(pending.subarray(0, pendingBytes));
99
+ padding[pendingBytes] = 0x80;
100
+ const view = new DataView(padding.buffer), bits = totalBytes * 8;
101
+ view.setUint32(padding.length - 8, Math.floor(bits / 0x1_0000_0000), false);
102
+ view.setUint32(padding.length - 4, bits >>> 0, false);
103
+ block(padding, 0);
104
+ if (padding.length === 128)
105
+ block(padding, 64);
106
+ return [...state].map(value => value.toString(16).padStart(8, '0')).join('');
107
+ },
108
+ });
109
+ }
110
+ export function sha256IdentityHex(bytes) {
111
+ if (bytes.byteLength > MAX_IDENTITY_DIGEST_BYTES)
112
+ throw new RangeError('Record identity exceeds its byte limit.');
113
+ const digest = createIncrementalSha256();
114
+ digest.update(bytes);
115
+ return digest.digestHex();
72
116
  }
@@ -0,0 +1,51 @@
1
+ const TECHNOLOGY_EVIDENCE_ROLE_ORDER = Object.freeze([
2
+ 'observed_edge',
3
+ 'application_platform',
4
+ 'framework_runtime',
5
+ 'embedded_dependency',
6
+ ]);
7
+ const TECHNOLOGY_EVIDENCE_ROLE_SET = new Set(TECHNOLOGY_EVIDENCE_ROLE_ORDER);
8
+ function record(value) {
9
+ return value && typeof value === 'object' && !Array.isArray(value)
10
+ ? value
11
+ : {};
12
+ }
13
+ function technologyEvidenceRoles(value) {
14
+ const finding = record(value);
15
+ const roles = new Set();
16
+ let hasExplicitRole = false;
17
+ for (const role of Array.isArray(finding.roles) ? finding.roles.slice(0, 4) : []) {
18
+ if (typeof role === 'string' && TECHNOLOGY_EVIDENCE_ROLE_SET.has(role)) {
19
+ roles.add(role);
20
+ hasExplicitRole = true;
21
+ }
22
+ }
23
+ const evidenceValues = Array.isArray(finding.evidence) ? finding.evidence.slice(0, 4) : [];
24
+ for (const evidenceValue of evidenceValues) {
25
+ const evidence = record(evidenceValue);
26
+ if (typeof evidence.role === 'string'
27
+ && TECHNOLOGY_EVIDENCE_ROLE_SET.has(evidence.role)) {
28
+ roles.add(evidence.role);
29
+ hasExplicitRole = true;
30
+ }
31
+ }
32
+ if (!hasExplicitRole) {
33
+ const resourceOriginOnly = evidenceValues.length > 0
34
+ && evidenceValues.every((evidence) => (String(record(evidence).source).toLowerCase() === 'resource origin'));
35
+ if (evidenceValues.some((evidence) => String(record(evidence).source).toLowerCase() === 'resource origin')) {
36
+ roles.add('embedded_dependency');
37
+ }
38
+ if (!resourceOriginOnly) {
39
+ const category = String(finding.category).toLowerCase();
40
+ if (category === 'delivery platform')
41
+ roles.add('observed_edge');
42
+ else if (['application runtime', 'web framework', 'static site generator', 'web server'].includes(category)) {
43
+ roles.add('framework_runtime');
44
+ }
45
+ else
46
+ roles.add('application_platform');
47
+ }
48
+ }
49
+ return TECHNOLOGY_EVIDENCE_ROLE_ORDER.filter((role) => roles.has(role));
50
+ }
51
+ export { TECHNOLOGY_EVIDENCE_ROLE_ORDER, technologyEvidenceRoles, };
@@ -0,0 +1,264 @@
1
+ // Pure defensive-domain export formatting for filtered Bulk findings. These
2
+ // files are generated locally and never submitted or applied automatically.
3
+ import { caseDispositionSupportsDefensiveResponse } from "../cases/case-record-decisions.mjs";
4
+ import { normalizeDomain } from "../evidence/domain-name.mjs";
5
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
6
+ import { DEFENSIVE_INDICATOR_EXPORT_VERSION, DEFENSIVE_INDICATOR_MANIFEST_SCHEMA, DEFENSIVE_INDICATOR_ROLLBACK_SCHEMA, } from "../contracts/analyst-interchange.mjs";
7
+ export { DEFENSIVE_INDICATOR_EXPORT_VERSION, DEFENSIVE_INDICATOR_MANIFEST_SCHEMA, DEFENSIVE_INDICATOR_ROLLBACK_SCHEMA, } from "../contracts/analyst-interchange.mjs";
8
+ export const MAX_DEFENSIVE_INDICATORS = 2000;
9
+ export const MAX_DEFENSIVE_INDICATOR_INPUTS = MAX_DEFENSIVE_INDICATORS * 4;
10
+ export const DEFENSIVE_INDICATOR_FORMATS = Object.freeze(['domains', 'hosts', 'dnsmasq', 'rpz']);
11
+ const REGISTERED_STATES = new Set(['registered', 'for_sale', 'expiring']);
12
+ const MINIMUM_RISK_SCORE = 70;
13
+ const CONTROL_RE = /[\x00-\x1f\x7f]/;
14
+ function plainRecord(value) {
15
+ return value && typeof value === 'object' && !Array.isArray(value)
16
+ ? value
17
+ : null;
18
+ }
19
+ function riskScore(record) {
20
+ const value = record.risk ?? record.riskScore;
21
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
22
+ }
23
+ /** Source metadata shared by interchange writers; export time is not evidence time. */
24
+ export function defensiveIndicatorProvenance(value) {
25
+ const source = plainRecord(value) ?? {};
26
+ const saved = plainRecord(source.saved) ?? {};
27
+ const score = riskScore(source);
28
+ const version = source.riskModelVersion ?? saved.riskModelVersion;
29
+ const depth = source.scanDepth ?? saved.scanDepth;
30
+ return Object.freeze({
31
+ riskScore: score === null ? null : Math.max(0, Math.min(100, Math.round(score))),
32
+ riskModelVersion: typeof version === 'number' && Number.isSafeInteger(version) && version > 0 && version <= 1_000 ? version : null,
33
+ scanDepth: depth === 'fast' || depth === 'deep' ? depth : 'unknown',
34
+ observedAt: normalizeExplicitIsoTimestamp(source.observedAt) || normalizeExplicitIsoTimestamp(saved.observedAt),
35
+ });
36
+ }
37
+ function normalizedDomainSet(value) {
38
+ if (!Array.isArray(value))
39
+ return new Set();
40
+ return new Set(value.slice(0, MAX_DEFENSIVE_INDICATOR_INPUTS)
41
+ .map((item) => normalizeDomain(item))
42
+ .filter((item) => Boolean(item)));
43
+ }
44
+ function analystDisposition(record) {
45
+ return typeof record.analystDisposition === 'string'
46
+ ? record.analystDisposition
47
+ : typeof record.disposition === 'string'
48
+ ? record.disposition
49
+ : '';
50
+ }
51
+ function hasReadyProfileContext(record) {
52
+ const saved = plainRecord(record.saved);
53
+ const profileContext = plainRecord(saved?.profileContext ?? record.profileContext);
54
+ return profileContext?.sourceState === 'ready';
55
+ }
56
+ export function isDefensiveIndicatorCandidate(value) {
57
+ const record = plainRecord(value);
58
+ if (!record
59
+ || !hasReadyProfileContext(record)
60
+ || record.trusted
61
+ || record.status === 'error'
62
+ || typeof record.availability !== 'string'
63
+ || !REGISTERED_STATES.has(record.availability))
64
+ return false;
65
+ return Boolean(normalizeDomain(record.domain)) && (riskScore(record) ?? -1) >= MINIMUM_RISK_SCORE;
66
+ }
67
+ export function collectDefensiveIndicatorCandidates(records, limit = MAX_DEFENSIVE_INDICATORS) {
68
+ if (!Array.isArray(records))
69
+ throw new TypeError('Defensive indicator export requires an array of Bulk results.');
70
+ const retainedLimit = Number.isSafeInteger(limit) && limit > 0
71
+ ? Math.min(limit, MAX_DEFENSIVE_INDICATORS)
72
+ : MAX_DEFENSIVE_INDICATORS;
73
+ const byDomain = new Map();
74
+ for (const item of records.slice(0, MAX_DEFENSIVE_INDICATOR_INPUTS)) {
75
+ if (!isDefensiveIndicatorCandidate(item))
76
+ continue;
77
+ const record = plainRecord(item);
78
+ const domain = normalizeDomain(record?.domain);
79
+ if (record && domain && !byDomain.has(domain))
80
+ byDomain.set(domain, record);
81
+ }
82
+ const candidates = [...byDomain.entries()]
83
+ .sort(([left], [right]) => left.localeCompare(right))
84
+ .map(([domain, source]) => ({ domain, source }));
85
+ const entries = candidates.slice(0, retainedLimit);
86
+ return {
87
+ entries,
88
+ domains: entries.map((entry) => entry.domain),
89
+ truncated: records.length > MAX_DEFENSIVE_INDICATOR_INPUTS || candidates.length > retainedLimit,
90
+ };
91
+ }
92
+ export function prepareDefensiveIndicatorExport(records, options = {}, limit = MAX_DEFENSIVE_INDICATORS) {
93
+ if (!Array.isArray(records))
94
+ throw new TypeError('Defensive indicator export requires an array of Bulk results.');
95
+ const explicitSelection = Array.isArray(options.selectedDomains);
96
+ const selected = normalizedDomainSet(options.selectedDomains);
97
+ const official = normalizedDomainSet(options.officialDomains);
98
+ const allowlisted = normalizedDomainSet(options.allowlistedDomains);
99
+ const common = normalizedDomainSet(options.commonInfrastructureDomains);
100
+ const retainedLimit = Number.isSafeInteger(limit) && limit > 0
101
+ ? Math.min(limit, MAX_DEFENSIVE_INDICATORS)
102
+ : MAX_DEFENSIVE_INDICATORS;
103
+ const exclusions = [];
104
+ const seenExclusions = new Set();
105
+ const accepted = new Map();
106
+ const exclude = (domain, reason) => {
107
+ const key = `${domain}\u0000${reason}`;
108
+ if (!seenExclusions.has(key)) {
109
+ exclusions.push({ domain, reason });
110
+ seenExclusions.add(key);
111
+ }
112
+ };
113
+ for (const item of records.slice(0, MAX_DEFENSIVE_INDICATOR_INPUTS)) {
114
+ const source = plainRecord(item);
115
+ const domain = normalizeDomain(source?.domain);
116
+ if (!source || !domain)
117
+ continue;
118
+ if (explicitSelection && !selected.has(domain)) {
119
+ exclude(domain, 'not_selected');
120
+ continue;
121
+ }
122
+ if (!hasReadyProfileContext(source)) {
123
+ exclude(domain, 'profile_context_unavailable');
124
+ continue;
125
+ }
126
+ if (official.has(domain)) {
127
+ exclude(domain, 'official_domain');
128
+ continue;
129
+ }
130
+ if (allowlisted.has(domain)) {
131
+ exclude(domain, 'allowlisted_domain');
132
+ continue;
133
+ }
134
+ if (common.has(domain) || source.commonInfrastructure === true) {
135
+ exclude(domain, 'common_infrastructure');
136
+ continue;
137
+ }
138
+ if (!isDefensiveIndicatorCandidate(source)) {
139
+ exclude(domain, 'not_eligible');
140
+ continue;
141
+ }
142
+ const disposition = analystDisposition(source);
143
+ if (explicitSelection && !caseDispositionSupportsDefensiveResponse(disposition)) {
144
+ exclude(domain, 'unreviewed_disposition');
145
+ continue;
146
+ }
147
+ if (!accepted.has(domain))
148
+ accepted.set(domain, source);
149
+ }
150
+ const candidates = [...accepted.entries()]
151
+ .sort(([left], [right]) => left.localeCompare(right))
152
+ .map(([domain, source]) => ({ domain, source }));
153
+ const entries = candidates.slice(0, retainedLimit);
154
+ return {
155
+ entries,
156
+ domains: entries.map((entry) => entry.domain),
157
+ exclusions: exclusions.sort((left, right) => left.domain.localeCompare(right.domain) || left.reason.localeCompare(right.reason)),
158
+ truncated: records.length > MAX_DEFENSIVE_INDICATOR_INPUTS || candidates.length > retainedLimit,
159
+ explicitSelection,
160
+ };
161
+ }
162
+ function timestamp(value) {
163
+ if (typeof value !== 'string' || value.length > 64 || CONTROL_RE.test(value))
164
+ return null;
165
+ const parsed = Date.parse(value);
166
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
167
+ }
168
+ function header(prefix, generatedAt, count) {
169
+ return [
170
+ `${prefix} WHOISleuth defensive domain indicators v${DEFENSIVE_INDICATOR_EXPORT_VERSION}`,
171
+ `${prefix} Generated ${generatedAt}`,
172
+ `${prefix} ${count} high-risk registered domain${count === 1 ? '' : 's'}`,
173
+ `${prefix} Review before use. Heuristic findings can include false positives.`,
174
+ ];
175
+ }
176
+ function formatDomains(domains, generatedAt) {
177
+ return [...header('#', generatedAt, domains.length), ...domains].join('\n');
178
+ }
179
+ function formatHosts(domains, generatedAt) {
180
+ return [...header('#', generatedAt, domains.length), ...domains.map((domain) => `0.0.0.0 ${domain}`)].join('\n');
181
+ }
182
+ function formatDnsmasq(domains, generatedAt) {
183
+ return [...header('#', generatedAt, domains.length), ...domains.map((domain) => `address=/${domain}/0.0.0.0`)].join('\n');
184
+ }
185
+ function formatRpz(domains, generatedAt, includeWildcards) {
186
+ const serial = Math.floor(Date.parse(generatedAt) / 1000) >>> 0;
187
+ return [
188
+ ...header(';', generatedAt, domains.length),
189
+ '$TTL 60',
190
+ `@ IN SOA localhost. root.localhost. (${serial} 60 60 60 60)`,
191
+ '@ IN NS localhost.',
192
+ ...domains.flatMap((domain) => [
193
+ `${domain} CNAME .`,
194
+ ...(includeWildcards ? [`*.${domain} CNAME .`] : []),
195
+ ]),
196
+ ].join('\n');
197
+ }
198
+ function contentFor(format, domains, generatedAt, includeWildcards) {
199
+ if (format === 'hosts')
200
+ return formatHosts(domains, generatedAt);
201
+ if (format === 'dnsmasq')
202
+ return formatDnsmasq(domains, generatedAt);
203
+ if (format === 'rpz')
204
+ return formatRpz(domains, generatedAt, includeWildcards);
205
+ return formatDomains(domains, generatedAt);
206
+ }
207
+ export function buildDefensiveIndicatorExport(records, options = {}) {
208
+ const format = (typeof options.format === 'string'
209
+ && DEFENSIVE_INDICATOR_FORMATS.includes(options.format)) ? options.format : 'domains';
210
+ const generatedAt = timestamp(options.generatedAt) || new Date().toISOString();
211
+ const includeWildcards = options.includeWildcards === true;
212
+ const expiryDays = typeof options.expiryDays === 'number' && Number.isSafeInteger(options.expiryDays)
213
+ ? Math.max(1, Math.min(365, options.expiryDays))
214
+ : 30;
215
+ const collected = prepareDefensiveIndicatorExport(records, options);
216
+ const expiresAt = new Date(Date.parse(generatedAt) + expiryDays * 86_400_000).toISOString();
217
+ const manifest = {
218
+ schema: DEFENSIVE_INDICATOR_MANIFEST_SCHEMA,
219
+ version: DEFENSIVE_INDICATOR_EXPORT_VERSION,
220
+ generatedAt,
221
+ expiresAt,
222
+ reviewRequired: true,
223
+ explicitSelection: collected.explicitSelection,
224
+ includeWildcards,
225
+ entries: collected.entries.map(({ domain, source }) => ({
226
+ domain,
227
+ disposition: analystDisposition(source) || 'not_recorded',
228
+ reason: `High-risk registered Bulk finding (risk ${riskScore(source) ?? 'unknown'}).`,
229
+ source: 'bulk',
230
+ generatedAt,
231
+ expiresAt,
232
+ })),
233
+ exclusions: collected.exclusions,
234
+ };
235
+ const rollback = {
236
+ schema: DEFENSIVE_INDICATOR_ROLLBACK_SCHEMA,
237
+ version: DEFENSIVE_INDICATOR_EXPORT_VERSION,
238
+ generatedAt,
239
+ removes: collected.domains.map((domain) => ({
240
+ domain,
241
+ includeWildcard: includeWildcards,
242
+ reason: 'Remove the indicator generated by the paired reviewed manifest.',
243
+ })),
244
+ };
245
+ return {
246
+ version: DEFENSIVE_INDICATOR_EXPORT_VERSION,
247
+ format,
248
+ generatedAt,
249
+ expiresAt,
250
+ domains: collected.domains,
251
+ entries: collected.entries,
252
+ exclusions: collected.exclusions,
253
+ explicitSelection: collected.explicitSelection,
254
+ includeWildcards,
255
+ truncated: collected.truncated,
256
+ filename: `whoisleuth-defensive-domains-${generatedAt.slice(0, 10)}.${format === 'rpz' ? 'zone' : 'txt'}`,
257
+ manifestFilename: `whoisleuth-defensive-domains-${generatedAt.slice(0, 10)}.manifest.json`,
258
+ rollbackFilename: `whoisleuth-defensive-domains-${generatedAt.slice(0, 10)}.rollback.json`,
259
+ mimeType: 'text/plain;charset=utf-8',
260
+ content: `${contentFor(format, collected.domains, generatedAt, includeWildcards)}\n`,
261
+ manifestContent: `${JSON.stringify(manifest, null, 2)}\n`,
262
+ rollbackContent: `${JSON.stringify(rollback, null, 2)}\n`,
263
+ };
264
+ }