@slicedearth/whoisleuth-cli 1.45.0 → 2.0.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 (268) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +74 -124
  4. package/SECURITY.md +6 -3
  5. package/bin/whoisleuth.mjs +7 -4
  6. package/cli/archive-inspect.mjs +28 -10
  7. package/cli/arguments.mjs +450 -173
  8. package/cli/artifact-structure.mjs +2507 -0
  9. package/cli/artifact-verify.mjs +286 -64
  10. package/cli/bounded-json.mjs +1 -0
  11. package/cli/bulk-checkpoint.mjs +35 -23
  12. package/cli/bulk-command-runner.mjs +2 -5
  13. package/cli/bulk.mjs +9 -2
  14. package/cli/case-pack.mjs +435 -46
  15. package/cli/ci-report.mjs +6 -1
  16. package/cli/collection-preflight.mjs +2 -2
  17. package/cli/command-catalogue.mjs +12 -3
  18. package/cli/command-reference.mjs +948 -106
  19. package/cli/completion.mjs +504 -218
  20. package/cli/config-profile.mjs +13 -3
  21. package/cli/ct-event-intake.mjs +32 -2
  22. package/cli/discover.mjs +7 -1
  23. package/cli/discovery-observation-snapshot.mjs +24 -28
  24. package/cli/discovery-scan-command-runner.mjs +13 -14
  25. package/cli/discovery-scan.mjs +17 -3
  26. package/cli/discovery-snapshot.mjs +15 -7
  27. package/cli/discovery-workflow.mjs +2 -3
  28. package/cli/doctor.mjs +2 -2
  29. package/cli/domain-control-monitor.mjs +215 -36
  30. package/cli/domain-control-observations.mjs +34 -17
  31. package/cli/errors.mjs +8 -3
  32. package/cli/evidence-command-runner.mjs +17 -11
  33. package/cli/evidence-signing.mjs +68 -50
  34. package/cli/export-evidence.mjs +11 -4
  35. package/cli/external-observation-mapping.mjs +3 -3
  36. package/cli/formatters/html.mjs +3 -3
  37. package/cli/formatters/json.mjs +20 -32
  38. package/cli/formatters/terminal.mjs +586 -22
  39. package/cli/input.mjs +62 -0
  40. package/cli/interactive-launcher.mjs +226 -0
  41. package/cli/interchange-report.mjs +226 -0
  42. package/cli/investigation-manifest.mjs +16 -17
  43. package/cli/investigation-plan.mjs +167 -16
  44. package/cli/investigation-run.mjs +80 -24
  45. package/cli/local-mmdb-review.mjs +45 -14
  46. package/cli/lookalike-calibration.mjs +3 -1
  47. package/cli/lookup-brief.mjs +13 -8
  48. package/cli/lookup-browser.mjs +844 -0
  49. package/cli/lookup-command-runner.mjs +96 -33
  50. package/cli/lookup-diff.mjs +11 -2
  51. package/cli/lookup-plan.mjs +1 -0
  52. package/cli/mail-review.mjs +206 -94
  53. package/cli/manual.mjs +5 -5
  54. package/cli/network-command-runner.mjs +78 -0
  55. package/cli/offline-evidence-review.mjs +45 -14
  56. package/cli/open-asset-model-bridge.mjs +1 -1
  57. package/cli/output-file.mjs +70 -14
  58. package/cli/page-compare.mjs +18 -10
  59. package/cli/progress-events.mjs +2 -1
  60. package/cli/progress.mjs +3 -3
  61. package/cli/registry-cohort.mjs +343 -61
  62. package/cli/registry-doctor.mjs +67 -28
  63. package/cli/registry-support.mjs +2 -1
  64. package/cli/retained-artifact-diff.mjs +375 -0
  65. package/cli/risk-calibration.mjs +89 -40
  66. package/cli/runner.mjs +322 -246
  67. package/cli/saved-lookup.mjs +439 -49
  68. package/cli/sharing-review.mjs +18 -7
  69. package/cli/source-reliability.mjs +75 -36
  70. package/cli/terminal-presentation.mjs +43 -18
  71. package/docs/cli-reference.md +125 -1334
  72. package/docs/cli.md +87 -236
  73. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -0
  74. package/frontend/src/lib/analysis/domain-control-records.js +3 -0
  75. package/lib/application-version.mjs +3 -0
  76. package/lib/availability.mjs +72 -52
  77. package/lib/bounded-contract-normalizers.mjs +2 -4
  78. package/lib/bounded-file.mjs +91 -4
  79. package/lib/bounded-json.mjs +287 -0
  80. package/lib/browser-library-profile.mjs +1 -1
  81. package/lib/certificate-extension-profile.mjs +2 -1
  82. package/lib/classify.mjs +56 -5
  83. package/lib/client-behavior-profile.mjs +1 -1
  84. package/lib/credential-surface-profile.mjs +1 -1
  85. package/lib/cryptographic-assurance.mjs +173 -0
  86. package/lib/ct-query.mjs +3 -3
  87. package/lib/ct-search.mjs +120 -64
  88. package/lib/distributed-operation-budget.mjs +4 -2
  89. package/lib/dns-delegation-health.mjs +9 -6
  90. package/lib/dns-intelligence.mjs +4 -3
  91. package/lib/dns-resolver-selection.mjs +6 -4
  92. package/lib/dnssec-chain-validation.mjs +1052 -0
  93. package/lib/dnssec-evidence-validation.mjs +23 -10
  94. package/lib/domain-assurance.mjs +12 -12
  95. package/lib/domain-change-packet.mjs +7 -5
  96. package/lib/domain-control-flight-recorder.mjs +442 -78
  97. package/lib/domain-control-manifest.mjs +314 -171
  98. package/lib/domain-posture-analysis.mjs +5 -2
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/encrypted-dns-contract.mjs +2 -3
  101. package/lib/evidence-export.mjs +1243 -88
  102. package/lib/evidence-report-markdown.mjs +3 -0
  103. package/lib/evidence-report.mjs +5 -0
  104. package/lib/external-intelligence-risk.mjs +26 -10
  105. package/lib/favicon.mjs +8 -5
  106. package/lib/feature-policy.mjs +1 -1
  107. package/lib/homepage-metadata-contract.mjs +290 -0
  108. package/lib/hostname.mjs +1 -1
  109. package/lib/html-signals.mjs +89 -5
  110. package/lib/http-evidence-bounds.mjs +3 -0
  111. package/lib/http-intelligence.mjs +332 -6
  112. package/lib/interchange-fidelity-registry.mjs +124 -0
  113. package/lib/local-geoip-evidence.mjs +2 -1
  114. package/lib/lookup-cache.mjs +6 -4
  115. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  116. package/lib/lookup.mjs +27 -4
  117. package/lib/observed-network-context.mjs +1 -1
  118. package/lib/outbound-identity.mjs +2 -3
  119. package/lib/page-fingerprints.mjs +2 -2
  120. package/lib/page-role-profile.mjs +1 -1
  121. package/lib/perceptual-hash.mjs +12 -4
  122. package/lib/portable-generator.mjs +29 -0
  123. package/lib/provider-policy-admission.mjs +11 -4
  124. package/lib/rdap-bootstrap.mjs +76 -16
  125. package/lib/rdap-client.mjs +20 -12
  126. package/lib/rdap-normalization.mjs +324 -80
  127. package/lib/rdap-registrar.mjs +5 -1
  128. package/lib/rdap-transport.mjs +2 -2
  129. package/lib/rdap-validation.mjs +65 -14
  130. package/lib/rdap.mjs +2 -2
  131. package/lib/registrable-domain.mjs +36 -0
  132. package/lib/registry-capabilities.mjs +3 -0
  133. package/lib/registry-capability-catalogue.mjs +12 -2
  134. package/lib/registry-comparison.mjs +28 -11
  135. package/lib/registry-insights.mjs +7 -0
  136. package/lib/response-policy.mjs +27 -4
  137. package/lib/risk-calibration-summary.mjs +340 -0
  138. package/lib/risk-scoring.mjs +4 -3
  139. package/lib/rpki-evidence.mjs +9 -8
  140. package/lib/safe-fetch.mjs +121 -50
  141. package/lib/security-txt.mjs +27 -4
  142. package/lib/service-binding-dns.mjs +6 -1
  143. package/lib/smtp-transport-review.mjs +787 -0
  144. package/lib/sslbl-certificates.generated.mjs +136 -134
  145. package/lib/sslbl-intelligence.mjs +6 -8
  146. package/lib/static-html-analysis.mjs +301 -4
  147. package/lib/structured-data-identity.mjs +51 -19
  148. package/lib/threat-intelligence-fixtures.mjs +2 -4
  149. package/lib/threat-intelligence-manifests.mjs +2 -7
  150. package/lib/threat-intelligence-runtime.mjs +5 -15
  151. package/lib/threat-intelligence-types.mjs +21 -1
  152. package/lib/threatfox-intelligence.mjs +7 -3
  153. package/lib/tls-intelligence.mjs +15 -9
  154. package/lib/tlsa-evidence.mjs +37 -7
  155. package/lib/urlhaus-intelligence.mjs +4 -2
  156. package/lib/urlscan-intelligence.mjs +6 -5
  157. package/lib/website-security-posture.mjs +1 -1
  158. package/lib/website-technology.mjs +1 -1
  159. package/lib/whois-authority.mjs +43 -8
  160. package/lib/whois-chain.mjs +25 -3
  161. package/lib/whois-normalization.mjs +3 -1
  162. package/lib/whois-transport.mjs +12 -3
  163. package/lib/zone-intent-review.mjs +1 -1
  164. package/package.json +12 -4
  165. package/packages/cases/case-brand-profile-references.mjs +85 -0
  166. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +43 -14
  167. package/packages/cases/case-investigation-branch-model.mjs +132 -0
  168. package/packages/cases/case-migration-model.mjs +476 -0
  169. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  170. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -19
  171. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  172. package/packages/cases/case-record-model.mjs +9 -0
  173. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +106 -33
  174. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +143 -16
  175. package/packages/cases/case-response-model.mjs +1604 -0
  176. package/packages/cases/case-response-packet.mjs +1312 -0
  177. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  178. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  179. package/packages/cases/opaque-reference-id.mjs +9 -0
  180. package/packages/comparison/comparison-ledger-bulk.mjs +366 -0
  181. package/packages/comparison/comparison-ledger-contract.mjs +241 -0
  182. package/packages/comparison/comparison-ledger-json.mjs +80 -0
  183. package/packages/comparison/comparison-ledger-serialization.mjs +52 -0
  184. package/packages/comparison/favicon-similarity.mjs +78 -0
  185. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  186. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  187. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  188. package/packages/contracts/analyst-review-state.mjs +50 -0
  189. package/packages/contracts/case-portability.mjs +1310 -0
  190. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  191. package/packages/contracts/cli-lookup.mjs +394 -0
  192. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  193. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  194. package/packages/contracts/domain-control-monitor.mjs +310 -0
  195. package/packages/contracts/domain-control-review.mjs +828 -0
  196. package/packages/contracts/domain-name.mjs +4 -0
  197. package/packages/contracts/external-observation-interchange.mjs +224 -0
  198. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  199. package/packages/contracts/investigation-portability.mjs +331 -0
  200. package/packages/contracts/offline-comparison.mjs +160 -0
  201. package/packages/contracts/risk-calibration.mjs +1288 -0
  202. package/packages/contracts/schema-compatibility.mjs +71 -0
  203. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  204. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  205. package/packages/contracts/workspace-portability.mjs +1347 -0
  206. package/packages/evidence/artifact-integrity.mjs +65 -0
  207. package/packages/evidence/decision-fact.mjs +735 -0
  208. package/packages/evidence/domain-control-runtime.mjs +547 -0
  209. package/packages/evidence/domain-name.mjs +41 -0
  210. package/packages/evidence/observation.mjs +384 -0
  211. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +86 -20
  212. package/packages/interchange/retained-text.mjs +8 -0
  213. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +11 -8
  214. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +28 -9
  215. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +67 -18
  216. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +10 -9
  217. package/packages/investigation/bulk-review-policy.mjs +1 -0
  218. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +50 -18
  219. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  220. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  221. package/packages/investigation/lookup-claim-passport.mjs +232 -0
  222. package/packages/investigation/lookup-claim-readiness.mjs +287 -0
  223. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  224. package/packages/monitoring/analyst-review-state.mjs +680 -0
  225. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +97 -50
  226. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +15 -15
  227. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +179 -39
  228. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +12 -5
  229. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  230. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  231. package/packages/workspace/hostile-input.mjs +172 -0
  232. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +51 -46
  233. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +15 -15
  234. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  235. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  236. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  237. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  238. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  239. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +18 -12
  240. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  241. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +195 -84
  242. package/third-party-notices.txt +112 -5834
  243. package/frontend/src/lib/analysis/artifact-integrity.js +0 -21
  244. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  245. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  246. package/frontend/src/lib/analysis/case-migration-model.js +0 -253
  247. package/frontend/src/lib/analysis/case-record-model.js +0 -7
  248. package/frontend/src/lib/analysis/case-response-model.js +0 -669
  249. package/frontend/src/lib/analysis/case-response-packet.js +0 -573
  250. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -487
  251. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  252. package/frontend/src/lib/analysis/lookup-presentation.js +0 -58
  253. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  254. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  255. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  256. package/frontend/src/lib/analysis/scoring.js +0 -55
  257. package/frontend/src/lib/analysis/utils.js +0 -232
  258. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  259. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  260. package/frontend/src/lib/analysis/visualization-matrix.js +0 -88
  261. package/frontend/src/lib/analysis/visualization-models.js +0 -369
  262. package/frontend/src/lib/analysis/visualization-time-series.js +0 -144
  263. package/lib/bounded-json-response.mjs +0 -157
  264. package/lib/lookup-response-contract.mjs +0 -346
  265. package/lib/observation.mjs +0 -114
  266. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  267. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  268. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -1,12 +1,20 @@
1
1
  import { constants } from 'node:fs';
2
- import { open } from 'node:fs/promises';
2
+ import { lstat, open, realpath } from 'node:fs/promises';
3
+ import path from 'node:path';
3
4
  const MAX_BOUNDED_FILE_BYTES = 64 * 1024 * 1024;
4
5
  function boundedSize(value, fallback) {
5
6
  return Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= MAX_BOUNDED_FILE_BYTES
6
7
  ? Number(value)
7
8
  : fallback;
8
9
  }
9
- export async function readBoundedRegularFile(filePath, options, dependencies = {}) {
10
+ function throwIfAborted(signal) {
11
+ if (!signal?.aborted)
12
+ return;
13
+ throw signal.reason instanceof Error
14
+ ? signal.reason
15
+ : new DOMException('The operation was aborted.', 'AbortError');
16
+ }
17
+ async function readBoundedRegularFileIdentity(filePath, options, dependencies = {}) {
10
18
  const label = options.label?.trim().slice(0, 120) || 'Input file';
11
19
  const maximumBytes = boundedSize(options.maximumBytes, 0);
12
20
  const minimumBytes = boundedSize(options.minimumBytes, 0);
@@ -19,6 +27,7 @@ export async function readBoundedRegularFile(filePath, options, dependencies = {
19
27
  if (expectedBytes !== null && (expectedBytes < minimumBytes || expectedBytes > maximumBytes)) {
20
28
  throw new TypeError(`${label} expected size is outside its byte boundary.`);
21
29
  }
30
+ throwIfAborted(options.signal);
22
31
  // Non-blocking open prevents a named pipe from stalling the process before
23
32
  // the descriptor can be classified. Final-component symlinks remain denied
24
33
  // unless an analyst-facing input explicitly opts in; artefacts resolved
@@ -28,7 +37,9 @@ export async function readBoundedRegularFile(filePath, options, dependencies = {
28
37
  | (options.allowSymbolicLink ? 0 : constants.O_NOFOLLOW);
29
38
  const handle = await (dependencies.openFile ?? open)(filePath, flags);
30
39
  try {
40
+ throwIfAborted(options.signal);
31
41
  const before = await handle.stat();
42
+ throwIfAborted(options.signal);
32
43
  if (!before.isFile())
33
44
  throw new TypeError(`${label} must be a regular file.`);
34
45
  if (before.size < minimumBytes)
@@ -44,12 +55,15 @@ export async function readBoundedRegularFile(filePath, options, dependencies = {
44
55
  const storage = Buffer.allocUnsafe(Math.min(maximumBytes + 1, before.size + 1));
45
56
  let offset = 0;
46
57
  while (offset < storage.length) {
58
+ throwIfAborted(options.signal);
47
59
  const { bytesRead } = await handle.read(storage, offset, storage.length - offset, null);
48
60
  if (bytesRead === 0)
49
61
  break;
50
62
  offset += bytesRead;
51
63
  }
64
+ throwIfAborted(options.signal);
52
65
  const after = await handle.stat();
66
+ throwIfAborted(options.signal);
53
67
  if (offset > maximumBytes
54
68
  || after.size !== before.size
55
69
  || offset !== after.size
@@ -57,13 +71,86 @@ export async function readBoundedRegularFile(filePath, options, dependencies = {
57
71
  || after.ctimeMs !== before.ctimeMs) {
58
72
  throw new TypeError(`${label} changed while it was being read.`);
59
73
  }
60
- return Buffer.from(storage.subarray(0, offset));
74
+ return {
75
+ bytes: Buffer.from(storage.subarray(0, offset)),
76
+ device: before.dev,
77
+ inode: before.ino,
78
+ };
61
79
  }
62
80
  finally {
63
81
  await handle.close();
64
82
  }
65
83
  }
84
+ export async function readBoundedRegularFile(filePath, options, dependencies = {}) {
85
+ return (await readBoundedRegularFileIdentity(filePath, options, dependencies)).bytes;
86
+ }
87
+ /**
88
+ * Decode admitted bytes without replacement-character normalisation. The BOM
89
+ * is deliberately retained so re-encoding the returned string preserves the
90
+ * exact bytes used by artefact identity and manifest checks.
91
+ */
92
+ export function decodeBoundedUtf8(bytes, label = 'Input') {
93
+ try {
94
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
95
+ }
96
+ catch {
97
+ throw new TypeError(`${label.trim().slice(0, 120) || 'Input'} must contain valid UTF-8 text.`);
98
+ }
99
+ }
66
100
  export async function readBoundedRegularTextFile(filePath, options, dependencies = {}) {
67
- return (await readBoundedRegularFile(filePath, options, dependencies)).toString('utf8');
101
+ return decodeBoundedUtf8(await readBoundedRegularFile(filePath, options, dependencies), options.label);
102
+ }
103
+ function safeRelativeFilePath(value, label) {
104
+ if (!value || path.isAbsolute(value) || value.includes('\\')
105
+ || value.split('/').some((part) => !part || part === '.' || part === '..')) {
106
+ throw new TypeError(`${label} must be a safe relative file path.`);
107
+ }
108
+ return value;
109
+ }
110
+ /**
111
+ * Reads one regular file beneath a canonical root while rejecting final and
112
+ * intermediate symbolic links. The ordinary descriptor-level size/change
113
+ * checks still apply after path confinement is established.
114
+ */
115
+ export async function readBoundedRegularFileWithin(rootDirectory, relativeFilePath, options, dependencies = {}) {
116
+ const label = options.label?.trim().slice(0, 120) || 'Input file';
117
+ const relativePath = safeRelativeFilePath(relativeFilePath, label);
118
+ const canonicalRoot = await realpath(rootDirectory);
119
+ const targetPath = path.resolve(canonicalRoot, relativePath);
120
+ const confined = path.relative(canonicalRoot, targetPath);
121
+ if (!confined || confined.startsWith(`..${path.sep}`) || confined === '..' || path.isAbsolute(confined)) {
122
+ throw new TypeError(`${label} escapes its configured root.`);
123
+ }
124
+ let cursor = canonicalRoot;
125
+ const components = relativePath.split('/');
126
+ let admittedDevice = -1;
127
+ let admittedInode = -1;
128
+ for (const [index, component] of components.entries()) {
129
+ cursor = path.join(cursor, component);
130
+ const metadata = await lstat(cursor);
131
+ if (metadata.isSymbolicLink())
132
+ throw new TypeError(`${label} must not traverse a symbolic link.`);
133
+ if (index === components.length - 1) {
134
+ admittedDevice = metadata.dev;
135
+ admittedInode = metadata.ino;
136
+ }
137
+ }
138
+ const canonicalTarget = await realpath(targetPath);
139
+ if (canonicalTarget !== targetPath)
140
+ throw new TypeError(`${label} must not traverse a symbolic link.`);
141
+ const identity = await readBoundedRegularFileIdentity(targetPath, {
142
+ ...options,
143
+ allowSymbolicLink: false,
144
+ }, dependencies);
145
+ const finalMetadata = await lstat(targetPath);
146
+ if (finalMetadata.isSymbolicLink()
147
+ || identity.device !== admittedDevice
148
+ || identity.inode !== admittedInode
149
+ || identity.device !== finalMetadata.dev
150
+ || identity.inode !== finalMetadata.ino
151
+ || await realpath(targetPath) !== canonicalTarget) {
152
+ throw new TypeError(`${label} changed while it was being read.`);
153
+ }
154
+ return identity.bytes;
68
155
  }
69
156
  export { MAX_BOUNDED_FILE_BYTES };
@@ -0,0 +1,287 @@
1
+ export const MAX_BOUNDED_JSON_DEPTH = 48;
2
+ export const MAX_BOUNDED_JSON_KEYS = 50_000;
3
+ export const MAX_BOUNDED_JSON_VALUES = 100_000;
4
+ export const MAX_BOUNDED_JSON_CONTAINER_ITEMS = 10_000;
5
+ export const UNSAFE_JSON_OBJECT_KEYS = Object.freeze(['__proto__']);
6
+ function syntaxError() {
7
+ throw new TypeError('Artefact input is not valid JSON.');
8
+ }
9
+ export function isSafeJsonObjectKey(value) {
10
+ return !UNSAFE_JSON_OBJECT_KEYS.includes(value);
11
+ }
12
+ export function assertBoundedJsonStructure(value, label = 'JSON value', limits = {}) {
13
+ const maximumDepth = limits.maximumDepth ?? MAX_BOUNDED_JSON_DEPTH;
14
+ const maximumKeys = limits.maximumKeys ?? MAX_BOUNDED_JSON_KEYS;
15
+ const maximumValues = limits.maximumValues ?? MAX_BOUNDED_JSON_VALUES;
16
+ const maximumContainerItems = limits.maximumContainerItems ?? MAX_BOUNDED_JSON_CONTAINER_ITEMS;
17
+ let keys = 0;
18
+ let values = 0;
19
+ const ancestors = new Set();
20
+ const visit = (current, depth) => {
21
+ values += 1;
22
+ if (values > maximumValues) {
23
+ throw new TypeError(`${label} exceeds the ${maximumValues}-value limit.`);
24
+ }
25
+ if (depth > maximumDepth) {
26
+ throw new TypeError(`${label} exceeds the ${maximumDepth}-level nesting limit.`);
27
+ }
28
+ if (current === null || typeof current === 'string' || typeof current === 'boolean')
29
+ return;
30
+ if (typeof current === 'number') {
31
+ if (!Number.isFinite(current))
32
+ throw new TypeError(`${label} contains a non-JSON number.`);
33
+ return;
34
+ }
35
+ if (typeof current !== 'object')
36
+ throw new TypeError(`${label} contains a non-JSON value.`);
37
+ if (ancestors.has(current))
38
+ throw new TypeError(`${label} contains a cyclic object reference.`);
39
+ let prototype;
40
+ let ownKeys;
41
+ let descriptors;
42
+ try {
43
+ prototype = Object.getPrototypeOf(current);
44
+ ownKeys = Reflect.ownKeys(current);
45
+ descriptors = Object.getOwnPropertyDescriptors(current);
46
+ }
47
+ catch {
48
+ throw new TypeError(`${label} could not be inspected safely.`);
49
+ }
50
+ if (ownKeys.some((key) => typeof key !== 'string')) {
51
+ throw new TypeError(`${label} contains a symbol key.`);
52
+ }
53
+ if (ownKeys.some((key) => {
54
+ const descriptor = descriptors[String(key)];
55
+ return !descriptor || !Object.hasOwn(descriptor, 'value');
56
+ })) {
57
+ throw new TypeError(`${label} contains an accessor property.`);
58
+ }
59
+ ancestors.add(current);
60
+ if (Array.isArray(current)) {
61
+ if (prototype !== Array.prototype) {
62
+ throw new TypeError(`${label} contains an array with a custom prototype.`);
63
+ }
64
+ const length = descriptors.length?.value;
65
+ if (!Number.isSafeInteger(length) || length < 0 || length > maximumContainerItems) {
66
+ throw new TypeError(`${label} contains a container with more than ${maximumContainerItems} items.`);
67
+ }
68
+ const indexes = ownKeys.filter((key) => key !== 'length');
69
+ if (indexes.length !== length
70
+ || indexes.some((key, index) => key !== String(index) || descriptors[key]?.enumerable !== true)) {
71
+ throw new TypeError(`${label} contains a sparse, extended, or non-enumerable array.`);
72
+ }
73
+ for (const key of indexes)
74
+ visit(descriptors[key].value, depth + 1);
75
+ }
76
+ else {
77
+ if (prototype !== Object.prototype && prototype !== null) {
78
+ throw new TypeError(`${label} contains an object with a custom prototype.`);
79
+ }
80
+ const objectKeys = ownKeys;
81
+ if (objectKeys.length > maximumContainerItems) {
82
+ throw new TypeError(`${label} contains a container with more than ${maximumContainerItems} items.`);
83
+ }
84
+ if (objectKeys.some((key) => descriptors[key]?.enumerable !== true)) {
85
+ throw new TypeError(`${label} contains a non-enumerable object property.`);
86
+ }
87
+ keys += objectKeys.length;
88
+ if (keys > maximumKeys) {
89
+ throw new TypeError(`${label} exceeds the ${maximumKeys}-key limit.`);
90
+ }
91
+ for (const key of objectKeys) {
92
+ if (!isSafeJsonObjectKey(key))
93
+ throw new TypeError(`${label} contains an unsafe object key.`);
94
+ visit(descriptors[key].value, depth + 1);
95
+ }
96
+ }
97
+ ancestors.delete(current);
98
+ };
99
+ visit(value, 0);
100
+ }
101
+ export function scanBoundedJson(raw, limits = {}) {
102
+ const maximumDepth = limits.maximumDepth ?? MAX_BOUNDED_JSON_DEPTH;
103
+ const maximumKeys = limits.maximumKeys ?? MAX_BOUNDED_JSON_KEYS;
104
+ const maximumValues = limits.maximumValues ?? MAX_BOUNDED_JSON_VALUES;
105
+ const maximumContainerItems = limits.maximumContainerItems ?? MAX_BOUNDED_JSON_CONTAINER_ITEMS;
106
+ let index = 0;
107
+ let keys = 0;
108
+ let values = 0;
109
+ const whitespace = () => {
110
+ while (index < raw.length && /[\t\n\r ]/u.test(raw[index]))
111
+ index += 1;
112
+ };
113
+ const stringToken = () => {
114
+ const start = index;
115
+ index += 1;
116
+ while (index < raw.length) {
117
+ const character = raw[index];
118
+ if (character === '"') {
119
+ index += 1;
120
+ try {
121
+ return JSON.parse(raw.slice(start, index));
122
+ }
123
+ catch {
124
+ syntaxError();
125
+ }
126
+ }
127
+ if (character === '\\') {
128
+ index += 1;
129
+ if (index >= raw.length)
130
+ syntaxError();
131
+ if (raw[index] === 'u') {
132
+ if (!/^[a-fA-F0-9]{4}$/u.test(raw.slice(index + 1, index + 5)))
133
+ syntaxError();
134
+ index += 5;
135
+ }
136
+ else {
137
+ if (!/["\\/bfnrt]/u.test(raw[index]))
138
+ syntaxError();
139
+ index += 1;
140
+ }
141
+ continue;
142
+ }
143
+ if (character.charCodeAt(0) <= 0x1f)
144
+ syntaxError();
145
+ index += 1;
146
+ }
147
+ return syntaxError();
148
+ };
149
+ const literal = (token) => {
150
+ if (raw.slice(index, index + token.length) !== token)
151
+ syntaxError();
152
+ index += token.length;
153
+ };
154
+ const number = () => {
155
+ const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/u.exec(raw.slice(index));
156
+ if (!match)
157
+ syntaxError();
158
+ if (!Number.isFinite(Number(match[0]))) {
159
+ throw new TypeError('Artefact JSON contains a non-finite number.');
160
+ }
161
+ index += match[0].length;
162
+ };
163
+ const value = (depth) => {
164
+ values += 1;
165
+ if (values > maximumValues) {
166
+ throw new TypeError(`Artefact JSON exceeds the ${maximumValues}-value limit.`);
167
+ }
168
+ if (depth > maximumDepth) {
169
+ throw new TypeError(`Artefact JSON exceeds the ${maximumDepth}-level nesting limit.`);
170
+ }
171
+ whitespace();
172
+ const character = raw[index];
173
+ if (character === '"') {
174
+ stringToken();
175
+ return;
176
+ }
177
+ if (character === '{') {
178
+ index += 1;
179
+ whitespace();
180
+ const seen = new Set();
181
+ if (raw[index] === '}') {
182
+ index += 1;
183
+ return;
184
+ }
185
+ while (index < raw.length) {
186
+ if (raw[index] !== '"')
187
+ syntaxError();
188
+ const key = stringToken();
189
+ if (seen.size >= maximumContainerItems) {
190
+ throw new TypeError(`Artefact JSON contains a container with more than ${maximumContainerItems} items.`);
191
+ }
192
+ keys += 1;
193
+ if (keys > maximumKeys) {
194
+ throw new TypeError(`Artefact JSON exceeds the ${maximumKeys}-key limit.`);
195
+ }
196
+ if (seen.has(key))
197
+ throw new TypeError('Artefact JSON contains a duplicate object key.');
198
+ if (!isSafeJsonObjectKey(key))
199
+ throw new TypeError('Artefact JSON contains an unsafe object key.');
200
+ seen.add(key);
201
+ whitespace();
202
+ if (raw[index] !== ':')
203
+ syntaxError();
204
+ index += 1;
205
+ value(depth + 1);
206
+ whitespace();
207
+ if (raw[index] === '}') {
208
+ index += 1;
209
+ return;
210
+ }
211
+ if (raw[index] !== ',')
212
+ syntaxError();
213
+ index += 1;
214
+ whitespace();
215
+ }
216
+ syntaxError();
217
+ }
218
+ if (character === '[') {
219
+ index += 1;
220
+ whitespace();
221
+ if (raw[index] === ']') {
222
+ index += 1;
223
+ return;
224
+ }
225
+ let items = 0;
226
+ while (index < raw.length) {
227
+ items += 1;
228
+ if (items > maximumContainerItems) {
229
+ throw new TypeError(`Artefact JSON contains a container with more than ${maximumContainerItems} items.`);
230
+ }
231
+ value(depth + 1);
232
+ whitespace();
233
+ if (raw[index] === ']') {
234
+ index += 1;
235
+ return;
236
+ }
237
+ if (raw[index] !== ',')
238
+ syntaxError();
239
+ index += 1;
240
+ whitespace();
241
+ }
242
+ syntaxError();
243
+ }
244
+ if (character === 't') {
245
+ literal('true');
246
+ return;
247
+ }
248
+ if (character === 'f') {
249
+ literal('false');
250
+ return;
251
+ }
252
+ if (character === 'n') {
253
+ literal('null');
254
+ return;
255
+ }
256
+ number();
257
+ };
258
+ whitespace();
259
+ value(0);
260
+ whitespace();
261
+ if (index !== raw.length)
262
+ syntaxError();
263
+ }
264
+ export function parseBoundedJson(raw, options) {
265
+ const label = options.label ?? 'Artefact input';
266
+ if (typeof raw !== 'string')
267
+ throw new TypeError(`${label} must be UTF-8 JSON text.`);
268
+ const bytes = new TextEncoder().encode(raw).byteLength;
269
+ if (bytes < 1 || bytes > options.maximumBytes) {
270
+ throw new TypeError(`${label} must be between 1 byte and ${options.maximumBytes} bytes.`);
271
+ }
272
+ scanBoundedJson(raw, options.limits);
273
+ try {
274
+ return JSON.parse(raw);
275
+ }
276
+ catch {
277
+ return syntaxError();
278
+ }
279
+ }
280
+ export function parseBoundedJsonObject(raw, options) {
281
+ const label = options.label ?? 'Artefact input';
282
+ const parsed = parseBoundedJson(raw, options);
283
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
284
+ throw new TypeError(`${label} must contain one JSON object.`);
285
+ }
286
+ return parsed;
287
+ }
@@ -7,7 +7,7 @@ import { createHash } from 'node:crypto';
7
7
  import { Worker } from 'node:worker_threads';
8
8
  import { RETIRE_BROWSER_CATALOG } from "./generated/retire-browser-catalog.mjs";
9
9
  import { CISA_KEV_CATALOG } from "./generated/cisa-kev-catalog.mjs";
10
- import { createObservation } from "./observation.mjs";
10
+ import { createObservation } from "../packages/evidence/observation.mjs";
11
11
  import { MAX_INLINE_SCRIPT_CHARS, MAX_INLINE_SCRIPT_TOTAL_CHARS, MAX_SCRIPT_ELEMENTS, MAX_STATIC_HTML_CHARS, analyzeStaticHtml, } from "./static-html-analysis.mjs";
12
12
  // Catalogue expressions are reviewed third-party inputs. Keep every individual
13
13
  // regular-expression evaluation and the aggregate inline-signature work small
@@ -3,7 +3,8 @@
3
3
  // retained; malformed extensions fail soft and leave an explicit partial flag.
4
4
  import 'reflect-metadata';
5
5
  import { CertificatePolicyExtension, CRLDistributionPointsExtension, X509Certificate, } from '@peculiar/x509';
6
- export const MAX_CERTIFICATE_POLICIES = 16;
6
+ import { MAX_LOOKUP_TLS_CERTIFICATE_POLICIES } from "./lookup-network-evidence-bounds.mjs";
7
+ export const MAX_CERTIFICATE_POLICIES = MAX_LOOKUP_TLS_CERTIFICATE_POLICIES;
7
8
  export const MAX_CRL_DISTRIBUTION_POINTS = 32;
8
9
  function emptyProfile(partial = false) {
9
10
  return Object.freeze({
package/lib/classify.mjs CHANGED
@@ -14,7 +14,9 @@
14
14
  // original input hostname and the resolved registrable domain are returned so
15
15
  // callers can look up the latter while still showing the user what they typed.
16
16
  import { isIP } from 'node:net';
17
+ import { domainToASCII } from 'node:url';
17
18
  import { parse } from 'tldts';
19
+ import { canonicalRegistrableDomain } from "./registrable-domain.mjs";
18
20
  // A CR/LF here would let one query become multiple lines once it reaches
19
21
  // lib/whois.mts's raw `socket.write(query + '\r\n')` - a normal URL-encoded
20
22
  // query string (?q=example.com%0D%0AHELP) decodes back into literal control
@@ -24,6 +26,9 @@ const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/;
24
26
  // 32-bit ASN ceiling (RFC 6793 four-octet ASNs). AS0 and AS4294967295 are
25
27
  // reserved but syntactically valid; anything above is not an ASN at all.
26
28
  const MAX_ASN = 4294967295;
29
+ const MAX_DIRECT_LOOKUP_TARGET_LENGTH = 1024;
30
+ const DIRECT_LOOKUP_RESERVED_SUFFIXES = new Set(['example', 'test']);
31
+ const DIRECT_LOOKUP_EXCLUDED_PUBLIC_SUFFIXES = new Set(['onion']);
27
32
  // A registrable-domain label: LDH (letter/digit/hyphen), 1-63 characters, no
28
33
  // leading or trailing hyphen. tldts already rejects empty labels, over-long
29
34
  // labels, leading/trailing hyphens, and public-suffix-only inputs (all yield
@@ -33,6 +38,52 @@ const MAX_ASN = 4294967295;
33
38
  function isValidRegistrableLabel(label) {
34
39
  return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label);
35
40
  }
41
+ // Direct CLI shorthand is deliberately narrower than normal query
42
+ // classification. classifyQuery() accepts pasted URLs and other convenient
43
+ // input forms after normalisation, while a value occupying command position
44
+ // must be unambiguously a target before it may select the networked Lookup
45
+ // command. This predicate therefore accepts only a complete hostname, literal
46
+ // IP address, or bounded ASN and never attempts to repair URL-like input.
47
+ function isDirectLookupTarget(raw) {
48
+ if (typeof raw !== 'string'
49
+ || !raw
50
+ || raw.length > MAX_DIRECT_LOOKUP_TARGET_LENGTH
51
+ || raw.startsWith('-')
52
+ || raw.trim() !== raw
53
+ || /\s/u.test(raw)
54
+ || CONTROL_CHAR_RE.test(raw)) {
55
+ return false;
56
+ }
57
+ if (isIP(raw) !== 0)
58
+ return !raw.includes('%') && !raw.startsWith('[');
59
+ const asnMatch = raw.match(/^(?:AS)?(\d+)$/i);
60
+ if (asnMatch) {
61
+ const number = Number(asnMatch[1]);
62
+ return Number.isInteger(number) && number >= 0 && number <= MAX_ASN;
63
+ }
64
+ if (/[\\/@:?#%\[\]]/u.test(raw) || raw.endsWith('..'))
65
+ return false;
66
+ const hostname = raw.endsWith('.') ? raw.slice(0, -1) : raw;
67
+ if (!hostname.includes('.'))
68
+ return false;
69
+ const ascii = domainToASCII(hostname).toLowerCase();
70
+ if (!ascii || ascii.length > 253 || !ascii.split('.').every(isValidRegistrableLabel))
71
+ return false;
72
+ const parsed = parse(ascii);
73
+ const publicSuffix = parsed.publicSuffix || '';
74
+ if (DIRECT_LOOKUP_EXCLUDED_PUBLIC_SUFFIXES.has(publicSuffix)
75
+ || publicSuffix === 'arpa'
76
+ || publicSuffix.endsWith('.arpa'))
77
+ return false;
78
+ if (parsed.isIcann !== true && !DIRECT_LOOKUP_RESERVED_SUFFIXES.has(publicSuffix))
79
+ return false;
80
+ try {
81
+ return classifyQuery(raw).type === 'domain';
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ }
36
87
  function classifyQuery(raw) {
37
88
  const trimmed = raw.trim();
38
89
  if (CONTROL_CHAR_RE.test(trimmed)) {
@@ -75,13 +126,13 @@ function classifyQuery(raw) {
75
126
  if (!inputHostname || !inputHostname.includes('.') || inputHostname.length > 253) {
76
127
  throw new Error(`"${trimmed}" is not a valid domain, IP, or ASN.`);
77
128
  }
78
- const registrableDomain = parse(inputHostname).domain;
129
+ if (!inputHostname.split('.').every(isValidRegistrableLabel)) {
130
+ throw new Error(`"${trimmed}" contains an invalid domain label (underscores, empty, or malformed labels are not registrable).`);
131
+ }
132
+ const registrableDomain = canonicalRegistrableDomain(inputHostname);
79
133
  if (!registrableDomain) {
80
134
  throw new Error(`"${trimmed}" is not a registrable domain (no public-suffix match).`);
81
135
  }
82
- if (!registrableDomain.split('.').every(isValidRegistrableLabel)) {
83
- throw new Error(`"${trimmed}" contains an invalid domain label (underscores, empty, or malformed labels are not registrable).`);
84
- }
85
136
  return {
86
137
  type: 'domain',
87
138
  value: registrableDomain,
@@ -90,4 +141,4 @@ function classifyQuery(raw) {
90
141
  isSubdomain: inputHostname !== registrableDomain,
91
142
  };
92
143
  }
93
- export { classifyQuery, MAX_ASN };
144
+ export { classifyQuery, isDirectLookupTarget, MAX_ASN };
@@ -2,7 +2,7 @@
2
2
  // HTML tokenizer pass. Scripts are never fetched or executed. Only fixed
3
3
  // indicator identifiers, descriptions, evidence classes, and bounded counts
4
4
  // leave this module; script references and contents are discarded.
5
- import { createObservation } from "./observation.mjs";
5
+ import { createObservation } from "../packages/evidence/observation.mjs";
6
6
  import { analyzeStaticHtml, } from "./static-html-analysis.mjs";
7
7
  const CLIENT_BEHAVIOR_PROFILE_VERSION = 1;
8
8
  const MAX_CLIENT_BEHAVIOR_INDICATORS = 12;
@@ -3,7 +3,7 @@
3
3
  // shared tokenizer projects only fixed categories and bounded counts; field
4
4
  // names, values, labels, placeholders, arbitrary attributes, and action URLs
5
5
  // never enter this profile.
6
- import { createObservation } from "./observation.mjs";
6
+ import { createObservation } from "../packages/evidence/observation.mjs";
7
7
  import { MAX_STATIC_FORMS, MAX_STATIC_INPUTS, analyzeStaticHtml, } from "./static-html-analysis.mjs";
8
8
  const CREDENTIAL_SURFACE_PROFILE_VERSION = 1;
9
9
  function analyzeCredentialSurfaceProfile(input = {}) {