@slicedearth/whoisleuth-cli 2.4.0 → 2.5.1

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 (300) hide show
  1. package/NOTICE +2 -2
  2. package/cli/argument-values.mjs +55 -0
  3. package/cli/arguments.mjs +24 -470
  4. package/cli/artifact-validation/case-response.mjs +21 -6
  5. package/cli/artifact-validation/lookup-evidence.mjs +3 -3
  6. package/cli/artifact-validation/signed-review.mjs +59 -13
  7. package/cli/artifact-verify.mjs +2 -0
  8. package/cli/assurance-arguments.mjs +56 -0
  9. package/cli/assurance-command-definitions.mjs +179 -0
  10. package/cli/assurance-command-runner.mjs +1 -1
  11. package/cli/bounded-json.mjs +1 -1
  12. package/cli/bulk-checkpoint.mjs +1 -1
  13. package/cli/bulk-command-runner.mjs +2 -2
  14. package/cli/bulk.mjs +2 -3
  15. package/cli/case-command.mjs +20 -3
  16. package/cli/case-pack.mjs +12 -2
  17. package/cli/ci-report.mjs +6 -3
  18. package/cli/collection-arguments.mjs +123 -0
  19. package/cli/collection-command-definitions.mjs +330 -0
  20. package/cli/collection-preflight.mjs +8 -1
  21. package/cli/command-argument-grammar.mjs +8 -210
  22. package/cli/command-definition.mjs +329 -0
  23. package/cli/command-reference.mjs +55 -1388
  24. package/cli/command-seeds.mjs +18 -0
  25. package/cli/compare.mjs +1 -1
  26. package/cli/config-profile.mjs +1 -1
  27. package/cli/ct-event-intake.mjs +4 -4
  28. package/cli/discover.mjs +2 -2
  29. package/cli/discovery-observation-snapshot.mjs +2 -2
  30. package/cli/discovery-scan-command-runner.mjs +3 -3
  31. package/cli/discovery-scan.mjs +1 -1
  32. package/cli/discovery-snapshot.mjs +2 -2
  33. package/cli/discovery-workflow.mjs +4 -4
  34. package/cli/domain-control-monitor.mjs +6 -6
  35. package/cli/domain-control-observations.mjs +2 -1
  36. package/cli/errors.mjs +5 -6
  37. package/cli/evidence-arguments.mjs +46 -0
  38. package/cli/evidence-command-definitions.mjs +69 -0
  39. package/cli/evidence-command-runner.mjs +2 -3
  40. package/cli/evidence-signing.mjs +3 -2
  41. package/cli/external-observation-mapping.mjs +2 -2
  42. package/cli/formatters/terminal-command-formats.mjs +6 -0
  43. package/cli/formatters/terminal-lookup.mjs +6 -0
  44. package/cli/history-arguments.mjs +39 -0
  45. package/cli/history-command-definitions.mjs +88 -0
  46. package/cli/indicator-set-command.mjs +33 -0
  47. package/cli/inline-command-families.mjs +6 -47
  48. package/cli/inline-command-runner.mjs +4 -31
  49. package/cli/input.mjs +2 -2
  50. package/cli/intake-command.mjs +107 -0
  51. package/cli/interchange-report.mjs +4 -2
  52. package/cli/investigation-artifacts.mjs +1 -1
  53. package/cli/investigation-plan.mjs +32 -154
  54. package/cli/investigation-recipes.mjs +177 -0
  55. package/cli/investigation-run.mjs +78 -12
  56. package/cli/local-mmdb-review.mjs +197 -17
  57. package/cli/lookalike-calibration.mjs +1 -1
  58. package/cli/lookup-brief.mjs +2 -2
  59. package/cli/lookup-command-runner.mjs +18 -35
  60. package/cli/lookup-diff.mjs +10 -9
  61. package/cli/lookup-plan.mjs +10 -14
  62. package/cli/lookup-reconcile.mjs +6 -5
  63. package/cli/lookup-timeline.mjs +7 -6
  64. package/cli/mail-header-review.mjs +24 -247
  65. package/cli/mail-review.mjs +9 -7
  66. package/cli/network-arguments.mjs +51 -0
  67. package/cli/network-command-definitions.mjs +130 -0
  68. package/cli/network-command-runner.mjs +43 -31
  69. package/cli/offline-evidence-review.mjs +39 -6
  70. package/cli/posture.mjs +1 -1
  71. package/cli/registry-fixture-scaffold.mjs +1 -1
  72. package/cli/retained-artifact-diff.mjs +8 -7
  73. package/cli/review-arguments.mjs +131 -0
  74. package/cli/review-command-definitions.mjs +273 -0
  75. package/cli/review-command-runner.mjs +68 -158
  76. package/cli/risk-calibration.mjs +8 -8
  77. package/cli/runner.mjs +20 -32
  78. package/cli/saved-lookup.mjs +2 -2
  79. package/cli/selected-input-worker.mjs +63 -0
  80. package/cli/sharing-review.mjs +5 -64
  81. package/cli/source-reliability.mjs +2 -2
  82. package/cli/support-arguments.mjs +49 -0
  83. package/cli/support-command-definitions.mjs +209 -0
  84. package/cli/support-command-runner.mjs +2 -2
  85. package/cli/workflow-arguments.mjs +86 -0
  86. package/cli/workflow-command-definitions.mjs +78 -0
  87. package/cli/workflow-command-runner.mjs +8 -1
  88. package/docs/cli-reference.md +27 -0
  89. package/docs/cli.md +223 -2
  90. package/docs/contextual-reviews.md +155 -0
  91. package/lib/analyst-taxonomy.mjs +2 -40
  92. package/lib/application-version.mjs +1 -1
  93. package/lib/availability.mjs +13 -789
  94. package/lib/bounded-contract-normalizers.mjs +4 -0
  95. package/lib/bounded-json.mjs +2 -330
  96. package/lib/browser-library-profile.mjs +62 -84
  97. package/lib/candidate-provenance-bounds.mjs +2 -0
  98. package/lib/classify.mjs +1 -1
  99. package/lib/ct-query.mjs +2 -27
  100. package/lib/ct-response-bounds.mjs +2 -9
  101. package/lib/ct-search.mjs +83 -47
  102. package/lib/dmarc-discovery.mjs +62 -0
  103. package/lib/dns-convergence-review.mjs +21 -8
  104. package/lib/dns-delegation-health.mjs +12 -8
  105. package/lib/dns-inheritance-review.mjs +53 -63
  106. package/lib/dns-intelligence.mjs +9 -7
  107. package/lib/dns-query-coverage.mjs +40 -0
  108. package/lib/domain-assurance.mjs +1 -1
  109. package/lib/domain-change-packet.mjs +12 -13
  110. package/lib/domain-change-review.mjs +32 -14
  111. package/lib/domain-control-flight-recorder.mjs +103 -27
  112. package/lib/domain-control-manifest.mjs +25 -17
  113. package/lib/domain-posture-analysis.mjs +22 -5
  114. package/lib/domain-posture.mjs +16 -15
  115. package/lib/domain-registration.mjs +392 -0
  116. package/lib/evidence-export-privacy.mjs +22 -15
  117. package/lib/evidence-export.mjs +3 -3
  118. package/lib/evidence-report.mjs +4 -1
  119. package/lib/external-intelligence-risk.mjs +2 -124
  120. package/lib/generated/cisa-kev-catalog.mjs +41 -3
  121. package/lib/generated/registrar-standing-catalogue.mjs +18 -10
  122. package/lib/generated/retire-browser-catalog.mjs +332 -19
  123. package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
  124. package/lib/html-signals.mjs +7 -2
  125. package/lib/http-evidence-bounds.mjs +2 -8
  126. package/lib/http-intelligence.mjs +13 -1
  127. package/lib/idn-confusable-policy.mjs +6 -4
  128. package/lib/idn-confusables.mjs +1 -1
  129. package/lib/interchange-fidelity-registry.mjs +10 -0
  130. package/lib/lookup-contract-primitives.mjs +1 -1
  131. package/lib/lookup-diagnostics.mjs +1 -23
  132. package/lib/lookup-network-evidence-bounds.mjs +1 -10
  133. package/lib/lookup-progress.mjs +50 -1
  134. package/lib/lookup-response-contract.mjs +69 -40
  135. package/lib/lookup-response.mjs +5 -15
  136. package/lib/lookup-source-progress.mjs +8 -22
  137. package/lib/lookup.mjs +1 -1
  138. package/lib/opportunity-scoring.mjs +2 -107
  139. package/lib/outbound-identity.mjs +1 -1
  140. package/lib/page-fingerprints.mjs +18 -1
  141. package/lib/perceptual-hash-comparison.mjs +2 -34
  142. package/lib/perceptual-hash.mjs +36 -10
  143. package/lib/portable-generator.mjs +2 -29
  144. package/lib/project-metadata.mjs +2 -8
  145. package/lib/rdap-client.mjs +11 -7
  146. package/lib/registrable-domain.mjs +2 -56
  147. package/lib/registrar-standing-contract.mjs +1 -1
  148. package/lib/registrar-standing.mjs +9 -13
  149. package/lib/registry-capabilities.mjs +2 -2
  150. package/lib/registry-capability-catalogue.mjs +27 -19
  151. package/lib/risk-calibration-summary.mjs +2 -352
  152. package/lib/risk-scoring.mjs +2 -345
  153. package/lib/scoring-evidence-quality.mjs +2 -107
  154. package/lib/semantic-version.mjs +2 -54
  155. package/lib/service-binding-dns.mjs +27 -0
  156. package/lib/smtp-transport-review.mjs +48 -19
  157. package/lib/sslbl-certificates.generated.mjs +141 -139
  158. package/lib/static-html-analysis.mjs +1 -1
  159. package/lib/structured-data-identity.mjs +1 -1
  160. package/lib/threat-intelligence-manifests.mjs +1 -1
  161. package/lib/threat-intelligence-runtime.mjs +1 -1
  162. package/lib/threat-intelligence-targets.mjs +1 -1
  163. package/lib/threat-intelligence-types.mjs +2 -75
  164. package/lib/typosquat-generator.mjs +11 -2
  165. package/lib/website-enrichment.mjs +388 -0
  166. package/lib/whois-authority.mjs +43 -5
  167. package/lib/whois-normalization.mjs +3 -1
  168. package/lib/whois-parser.mjs +5 -3
  169. package/package.json +13 -14
  170. package/packages/analysis/analyst-taxonomy.mjs +40 -0
  171. package/packages/analysis/bounded-json.mjs +330 -0
  172. package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
  173. package/packages/analysis/cli-argument-grammar.mjs +216 -0
  174. package/packages/analysis/ct-query.mjs +27 -0
  175. package/packages/analysis/ct-response-bounds.mjs +9 -0
  176. package/packages/analysis/external-intelligence-risk.mjs +124 -0
  177. package/packages/analysis/http-evidence-bounds.mjs +8 -0
  178. package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
  179. package/packages/analysis/opportunity-scoring.mjs +107 -0
  180. package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
  181. package/packages/analysis/portable-generator.mjs +29 -0
  182. package/packages/analysis/project-metadata.mjs +8 -0
  183. package/packages/analysis/registrable-domain.mjs +56 -0
  184. package/packages/analysis/risk-calibration-summary.mjs +352 -0
  185. package/packages/analysis/risk-scoring.mjs +345 -0
  186. package/packages/analysis/scoring-evidence-quality.mjs +107 -0
  187. package/packages/analysis/semantic-version.mjs +54 -0
  188. package/packages/analysis/threat-intelligence-types.mjs +75 -0
  189. package/packages/cases/case-evidence-links.mjs +135 -0
  190. package/packages/cases/case-evidence-model.mjs +78 -12
  191. package/packages/cases/case-evidence-quality.mjs +5 -0
  192. package/packages/cases/case-export-input.mjs +7 -5
  193. package/packages/cases/case-incident-context.mjs +52 -0
  194. package/packages/cases/case-markdown.mjs +11 -0
  195. package/packages/cases/case-migration-model.mjs +40 -24
  196. package/packages/cases/case-recheck-model.mjs +90 -32
  197. package/packages/cases/case-record-contracts.mjs +1 -1
  198. package/packages/cases/case-record-core.mjs +1 -1
  199. package/packages/cases/case-record-operations.mjs +40 -112
  200. package/packages/cases/case-record-projection.mjs +8 -1
  201. package/packages/cases/case-record-version-input.mjs +19 -0
  202. package/packages/cases/case-report.mjs +63 -27
  203. package/packages/cases/case-requested-evidence.mjs +146 -0
  204. package/packages/cases/case-response-actions.mjs +103 -1
  205. package/packages/cases/case-response-model.mjs +9 -1
  206. package/packages/cases/case-response-outcomes.mjs +29 -8
  207. package/packages/cases/case-response-packet-render.mjs +13 -9
  208. package/packages/cases/case-response-packet.mjs +25 -11
  209. package/packages/cases/case-response-records.mjs +0 -1
  210. package/packages/cases/case-response-review-inputs.mjs +14 -3
  211. package/packages/cases/case-storage-model.mjs +1 -1
  212. package/packages/cases/case-workflow-metadata.mjs +225 -0
  213. package/packages/cases/case-workflow-migration.mjs +43 -0
  214. package/packages/cases/http-summary.mjs +6 -1
  215. package/packages/comparison/favicon-similarity.mjs +1 -1
  216. package/packages/comparison/page-similarity.mjs +1 -1
  217. package/packages/comparison/relationship-evidence.mjs +21 -9
  218. package/packages/comparison/relationship-provenance.mjs +7 -1
  219. package/packages/contracts/analyst-interchange.mjs +28 -10
  220. package/packages/contracts/case-portability.mjs +118 -25
  221. package/packages/contracts/cli-command-semantics.mjs +2 -0
  222. package/packages/contracts/cli-grammar.mjs +6 -0
  223. package/packages/contracts/cli-lookup-brief.mjs +2 -0
  224. package/packages/contracts/context-review.mjs +24 -0
  225. package/packages/contracts/document-intake.mjs +11 -0
  226. package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
  227. package/packages/contracts/domain-control-manifest.mjs +1 -1
  228. package/packages/contracts/domain-control-monitor.mjs +13 -2
  229. package/packages/contracts/domain-control-review.mjs +36 -15
  230. package/packages/contracts/har-review.mjs +2 -0
  231. package/packages/contracts/identity-events.mjs +4 -0
  232. package/packages/contracts/investigation-run.mjs +2 -0
  233. package/packages/contracts/ip-address.mjs +10 -0
  234. package/packages/contracts/mail-authentication.mjs +5 -0
  235. package/packages/contracts/message-intake.mjs +31 -0
  236. package/packages/contracts/offline-comparison.mjs +4 -1
  237. package/packages/contracts/passive-mail.mjs +10 -0
  238. package/packages/contracts/web-capture.mjs +2 -2
  239. package/packages/contracts/workspace-portability.mjs +63 -17
  240. package/packages/evidence/collection-quality.mjs +67 -0
  241. package/packages/evidence/dns-query-outcome.mjs +72 -0
  242. package/packages/evidence/email-recipient.mjs +18 -0
  243. package/packages/evidence/lookup-target.mjs +5 -1
  244. package/packages/evidence/observation.mjs +2 -0
  245. package/packages/evidence/png-pixels.mjs +117 -0
  246. package/packages/evidence/public-address-policy.mjs +83 -0
  247. package/packages/interchange/defensive-indicator-export.mjs +264 -0
  248. package/packages/interchange/external-findings-import.mjs +4 -12
  249. package/packages/interchange/managed-indicator-export.mjs +35 -0
  250. package/packages/interchange/managed-indicator-set.mjs +210 -0
  251. package/packages/interchange/misp-indicator-export.mjs +93 -0
  252. package/packages/interchange/sharing-policy.mjs +56 -0
  253. package/packages/interchange/stix-indicator-export.mjs +129 -0
  254. package/packages/interchange/web-capture-import.mjs +22 -15
  255. package/packages/investigation/acquisition-decision-packet.mjs +1 -1
  256. package/packages/investigation/bulk-mail-exposure.mjs +7 -13
  257. package/packages/investigation/capture-coverage.mjs +57 -0
  258. package/packages/investigation/connector-provenance-review.mjs +100 -0
  259. package/packages/investigation/context-review.mjs +35 -0
  260. package/packages/investigation/document-intake.mjs +81 -0
  261. package/packages/investigation/docx-intake.mjs +133 -0
  262. package/packages/investigation/domain-history-review.mjs +100 -0
  263. package/packages/investigation/domain-transition-review.mjs +47 -0
  264. package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
  265. package/packages/investigation/har-intake.mjs +68 -0
  266. package/packages/investigation/identity-event-intake.mjs +131 -0
  267. package/packages/investigation/identity-incident-review.mjs +39 -0
  268. package/packages/investigation/incident-sequence-review.mjs +58 -0
  269. package/packages/investigation/intake-report.mjs +19 -0
  270. package/packages/investigation/investigation-manifest.mjs +2 -2
  271. package/packages/investigation/link-intake.mjs +179 -0
  272. package/packages/investigation/lookup-asset-graph.mjs +1 -1
  273. package/packages/investigation/mail-authentication-review.mjs +222 -0
  274. package/packages/investigation/mail-header-identity.mjs +209 -0
  275. package/packages/investigation/message-intake.mjs +195 -0
  276. package/packages/investigation/page-behaviour.mjs +113 -0
  277. package/packages/investigation/pdf-intake.mjs +155 -0
  278. package/packages/investigation/platform-continuity-review.mjs +60 -0
  279. package/packages/investigation/qr-intake.mjs +56 -0
  280. package/packages/investigation/requested-action-hints.mjs +11 -0
  281. package/packages/investigation/selected-input-review.mjs +17 -0
  282. package/packages/investigation/storefront-review.mjs +70 -0
  283. package/packages/workspace/brand-profile-model.mjs +2 -2
  284. package/packages/workspace/bulk-review-model.mjs +28 -26
  285. package/packages/workspace/bulk-session-model.mjs +23 -13
  286. package/packages/workspace/campaign-model.mjs +1 -1
  287. package/packages/workspace/detection-rule-model.mjs +1 -1
  288. package/packages/workspace/hostile-input.mjs +1 -1
  289. package/packages/workspace/investigation-guide.mjs +2 -2
  290. package/packages/workspace/investigation-template-model.mjs +26 -2
  291. package/packages/workspace/page-baseline.mjs +2 -2
  292. package/packages/workspace/relationship-observation-model.mjs +17 -13
  293. package/packages/workspace/shortlist-model.mjs +14 -5
  294. package/packages/workspace/watchlist-history.mjs +28 -12
  295. package/packages/workspace/watchlist-store.mjs +17 -5
  296. package/packages/workspace/website-snapshot-model.mjs +28 -3
  297. package/packages/workspace/workspace-archive-crypto.mjs +1 -1
  298. package/packages/workspace/workspace-archive.mjs +5 -4
  299. package/third-party-notices.txt +549 -16
  300. package/lib/hostname.mjs +0 -2
@@ -1,4 +1,5 @@
1
- const MAX_ENTRIES = 24;
1
+ export const MAX_EVIDENCE_COVERAGE_ENTRIES = 24;
2
+ const MAX_ENTRIES = MAX_EVIDENCE_COVERAGE_ENTRIES;
2
3
  const MAX_ID_LENGTH = 64;
3
4
  const MAX_LABEL_LENGTH = 120;
4
5
  const MAX_LIMITATIONS = 8;
@@ -330,6 +331,13 @@ export function buildLookupEvidenceCoverageLedger(input) {
330
331
  const providers = Array.isArray(input.threatIntelligenceProviders)
331
332
  ? input.threatIntelligenceProviders
332
333
  : [];
334
+ if (Array.isArray(input.threatIntelligenceWithheld) && input.threatIntelligenceWithheld.length) {
335
+ items.push({
336
+ id: 'external-withheld', label: 'Withheld external-intelligence records', category: 'external',
337
+ status: 'unsupported', complete: false,
338
+ limitations: ['Some provider data was unsupported, unbound, malformed or over-limit. Withheld records are not evidence of no findings and cannot contribute to Risk.'],
339
+ });
340
+ }
333
341
  for (const [providerIndex, providerValue] of providers.entries()) {
334
342
  if (items.length >= MAX_ENTRIES)
335
343
  break;
@@ -0,0 +1,68 @@
1
+ import { parseBoundedJsonObject, boundedJsonLimitsForBytes } from "../analysis/bounded-json.mjs";
2
+ import { parseCredentialFreeHttpUrl } from "../evidence/lookup-target.mjs";
3
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
4
+ import { MAX_MESSAGE_INTAKE_BYTES, MAX_INTAKE_URL_LENGTH } from "../contracts/message-intake.mjs";
5
+ import { MAX_HAR_ENTRIES, HAR_TIMING_PHASES } from "../contracts/har-review.mjs";
6
+ import { createIntakeReport } from "./intake-report.mjs";
7
+ import { createLinkIntake } from "./link-intake.mjs";
8
+ const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
9
+ const milliseconds = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER ? value : null;
10
+ function mimeCategory(raw) {
11
+ if (typeof raw !== 'string' || !raw.trim())
12
+ return 'unknown';
13
+ const mime = raw.split(';', 1)[0].trim().toLowerCase();
14
+ if (['text/html', 'application/xhtml+xml'].includes(mime))
15
+ return 'html';
16
+ if (['text/javascript', 'application/javascript', 'application/ecmascript', 'text/ecmascript'].includes(mime))
17
+ return 'script';
18
+ if (mime === 'application/json' || mime.endsWith('+json'))
19
+ return 'json';
20
+ if (['application/xml', 'text/xml'].includes(mime) || mime.endsWith('+xml'))
21
+ return 'xml';
22
+ if (mime.startsWith('image/'))
23
+ return 'image';
24
+ if (mime.startsWith('font/') || mime.includes('font'))
25
+ return 'font';
26
+ if (mime.startsWith('audio/') || mime.startsWith('video/'))
27
+ return 'media';
28
+ if (mime.startsWith('text/'))
29
+ return 'text';
30
+ return mime === 'application/octet-stream' ? 'binary' : 'other';
31
+ }
32
+ /** Retain declared request sequence, not cookies, headers, bodies or replay authority. */
33
+ export async function reviewHarInput(bytes, reviewedAt) {
34
+ const base = await createIntakeReport(bytes, 'har', reviewedAt);
35
+ const root = parseBoundedJsonObject(new TextDecoder('utf-8', { fatal: true }).decode(bytes), { label: 'HAR input', maximumBytes: MAX_MESSAGE_INTAKE_BYTES,
36
+ limits: { ...boundedJsonLimitsForBytes(MAX_MESSAGE_INTAKE_BYTES), maximumContainerItems: MAX_HAR_ENTRIES } });
37
+ const log = record(root.log);
38
+ if (log.version !== '1.2' || !Array.isArray(log.entries) || log.entries.length > MAX_HAR_ENTRIES)
39
+ throw new TypeError('Select a HAR 1.2 archive within the entry review bound.');
40
+ const links = createLinkIntake(), entries = [];
41
+ let invalidEntries = 0;
42
+ for (const [index, raw] of log.entries.entries()) {
43
+ const entry = record(raw), request = record(entry.request), response = record(entry.response), content = record(response.content), timing = record(entry.timings);
44
+ if (!Object.keys(request).length || !Object.keys(response).length) {
45
+ invalidEntries++;
46
+ continue;
47
+ }
48
+ const url = parseCredentialFreeHttpUrl(request.url, MAX_INTAKE_URL_LENGTH);
49
+ const method = typeof request.method === 'string' ? request.method.toUpperCase() : null;
50
+ const resource = typeof entry._resourceType === 'string' ? entry._resourceType.toLowerCase() : null;
51
+ entries.push({ sequence: index + 1, startedAt: typeof entry.startedDateTime === 'string' ? normalizeExplicitIsoTimestamp(entry.startedDateTime) : null,
52
+ origin: url?.origin ?? null,
53
+ method: method === null ? 'unknown' : ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH', 'CONNECT', 'TRACE'].includes(method) ? method : 'other',
54
+ status: typeof response.status === 'number' && Number.isInteger(response.status) && response.status >= 100 && response.status <= 599 ? response.status : null,
55
+ durationMs: milliseconds(entry.time), mimeCategory: mimeCategory(content.mimeType),
56
+ timings: Object.fromEntries(HAR_TIMING_PHASES.map(phase => [phase, milliseconds(timing[phase])])),
57
+ resourceType: resource === null ? 'unknown' : ['document', 'script', 'stylesheet', 'image', 'font', 'media', 'xhr', 'fetch', 'ping', 'websocket'].includes(resource) ? resource : 'other',
58
+ reportedFailure: typeof entry._error === 'string' && Boolean(entry._error) });
59
+ // Queries may carry access tokens or account identifiers. Even transient
60
+ // destination actions from this archive contain only its origin.
61
+ if (url)
62
+ links.add(url.origin, 'har_request');
63
+ }
64
+ const extracted = links.result();
65
+ return { targets: extracted.targets, report: { ...base, links: extracted.links, harReview: { entries, invalidEntries },
66
+ coverage: { ...base.coverage, state: invalidEntries || extracted.bounded || extracted.rejected ? 'partial' : 'reviewed', reviewedParts: entries.length,
67
+ rejectedLinks: extracted.rejected, boundsReached: [...extracted.limitations, ...(extracted.bounded ? ['Destination list; the admitted request timeline remains available'] : [])] } } };
68
+ }
@@ -0,0 +1,131 @@
1
+ import { parseBoundedJson, boundedJsonLimitsForBytes } from "../analysis/bounded-json.mjs";
2
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
3
+ import { exact, exactOptional } from "../evidence/artifact-structure.mjs";
4
+ import { MAX_MESSAGE_INTAKE_BYTES } from "../contracts/message-intake.mjs";
5
+ import { IDENTITY_EVENTS_INPUT_SCHEMA, IDENTITY_EVENTS_INPUT_VERSION, MAX_IDENTITY_EVENTS } from "../contracts/identity-events.mjs";
6
+ import { createIntakeReport } from "./intake-report.mjs";
7
+ const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
8
+ const instant = (value) => typeof value === 'string' ? normalizeExplicitIsoTimestamp(value) : null;
9
+ const guid = (value) => typeof value === 'string' && /^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/iu.test(value) ? value.toLowerCase() : null;
10
+ function applicationId(value, provider) {
11
+ return provider === 'entra' ? guid(value) : typeof value === 'string' && /^0oa[a-z\d]{17}$/iu.test(value) ? value : null;
12
+ }
13
+ function protocol(value) {
14
+ switch (value) {
15
+ case 'oAuth2': return 'oauth2';
16
+ case 'deviceCode': return 'device_code';
17
+ case 'saml20': return 'saml';
18
+ case 'wsFederation': return 'ws_federation';
19
+ case 'ropc': return 'password_grant';
20
+ default: return 'unknown';
21
+ }
22
+ }
23
+ function eventKind(value) {
24
+ switch (value) {
25
+ case 'user.authentication.sso': return 'application_access';
26
+ case 'user.session.start': return 'session_start';
27
+ case 'user.authentication.auth_via_mfa':
28
+ case 'user.authentication.verify': return 'credential_check';
29
+ case 'app.oauth2.consent.grant': return 'consent';
30
+ default: return 'other';
31
+ }
32
+ }
33
+ /** IDs are replaced in memory; actor labels are meaningful only within this file. */
34
+ function extractEvents(input, provider) {
35
+ const actors = new Map(), events = [];
36
+ let invalidEvents = 0;
37
+ const actor = (value) => {
38
+ if (typeof value !== 'string' || !value || value.length > 240)
39
+ return null;
40
+ let label = actors.get(value);
41
+ if (!label) {
42
+ label = `Actor ${actors.size + 1}`;
43
+ actors.set(value, label);
44
+ }
45
+ return label;
46
+ };
47
+ for (const [index, value] of input.entries()) {
48
+ const row = record(value);
49
+ if (provider === 'entra') {
50
+ if (!Object.hasOwn(row, 'createdDateTime') || !Object.hasOwn(row, 'status')) {
51
+ invalidEvents++;
52
+ continue;
53
+ }
54
+ const app = applicationId(row.appId, provider), code = record(row.status).errorCode;
55
+ events.push({ sequence: index + 1, actorLabel: actor(row.userId), applicationIds: app ? [app] : [], tenantId: guid(row.resourceTenantId),
56
+ // A client category (for example Browser) is not an authentication protocol.
57
+ protocol: protocol(row.authenticationProtocol), kind: 'sign_in', occurredAt: instant(row.createdDateTime),
58
+ result: typeof code === 'number' && Number.isSafeInteger(code) && code >= 0 ? code === 0 ? 'success' : 'failure' : 'unknown' });
59
+ }
60
+ else {
61
+ if (typeof row.eventType !== 'string' || !Object.hasOwn(row, 'published')) {
62
+ invalidEvents++;
63
+ continue;
64
+ }
65
+ const targets = Array.isArray(row.target) ? row.target : [];
66
+ const apps = targets.filter(target => record(target).type === 'AppInstance').map(target => applicationId(record(target).id, provider)).filter((id) => id !== null);
67
+ const outcome = record(row.outcome).result;
68
+ events.push({ sequence: index + 1, actorLabel: actor(record(row.actor).id), applicationIds: [...new Set(apps)], tenantId: null,
69
+ protocol: 'unknown', kind: eventKind(row.eventType), occurredAt: instant(row.published),
70
+ result: outcome === 'SUCCESS' ? 'success' : outcome === 'FAILURE' ? 'failure' : outcome === 'DENY' ? 'denied' : outcome === 'CHALLENGE' ? 'challenge' : 'unknown' });
71
+ }
72
+ }
73
+ return { events, invalidEvents };
74
+ }
75
+ export function readIdentityMatchScope(raw, provider) {
76
+ const source = exact(raw, ['applicationId', 'tenantId', 'actorLabel', 'startedAt', 'endedAt'], 'Identity comparison scope');
77
+ const app = applicationId(source.applicationId, provider), tenant = source.tenantId === null ? null : guid(source.tenantId);
78
+ const start = instant(source.startedAt), end = instant(source.endedAt);
79
+ if (!app || !start || !end || Date.parse(end) < Date.parse(start) || source.tenantId !== null && !tenant
80
+ || source.actorLabel !== null && (typeof source.actorLabel !== 'string' || !/^Actor [1-9]\d{0,4}$/u.test(source.actorLabel))) {
81
+ throw new TypeError('Select a valid application ID, explicit-timezone interval, and optional retained tenant or file-local actor label.');
82
+ }
83
+ return { applicationId: app, tenantId: tenant, actorLabel: source.actorLabel, startedAt: start, endedAt: end };
84
+ }
85
+ /** Exact supplied-field corroboration, not a causal or account-compromise judgement. */
86
+ export function compareIdentityEvents(review, rawScope) {
87
+ const scope = readIdentityMatchScope(rawScope, review.provider);
88
+ const compare = (selected, supplied) => selected === null ? 'not_selected' : supplied === null ? 'unavailable' : selected === supplied ? 'matched' : 'different';
89
+ return { ...review, comparison: { scope, events: review.events.map(event => {
90
+ const application = !event.applicationIds.length ? 'unavailable' : event.applicationIds.includes(scope.applicationId) ? 'matched' : 'different';
91
+ const tenant = compare(scope.tenantId, event.tenantId), actor = compare(scope.actorLabel, event.actorLabel);
92
+ const time = !event.occurredAt ? 'unavailable' : Date.parse(event.occurredAt) >= Date.parse(scope.startedAt) && Date.parse(event.occurredAt) <= Date.parse(scope.endedAt) ? 'matched' : 'different';
93
+ const fields = [application, tenant, actor, time];
94
+ return { sequence: event.sequence, application, tenant, actor, time,
95
+ state: fields.includes('different') ? 'different' : fields.includes('unavailable') ? 'incomplete' : 'matched' };
96
+ }) } };
97
+ }
98
+ export async function reviewIdentityEventInput(bytes, reviewedAt) {
99
+ const base = await createIntakeReport(bytes, 'identity', reviewedAt);
100
+ const parsed = parseBoundedJson(new TextDecoder('utf-8', { fatal: true }).decode(bytes), { label: 'Identity event input', maximumBytes: MAX_MESSAGE_INTAKE_BYTES,
101
+ limits: { ...boundedJsonLimitsForBytes(MAX_MESSAGE_INTAKE_BYTES), maximumContainerItems: MAX_IDENTITY_EVENTS } });
102
+ const root = record(parsed);
103
+ let provider, input, match = null;
104
+ if (Object.hasOwn(root, 'schema')) {
105
+ exactOptional(root, ['schema', 'version', 'provider', 'events'], ['match'], 'Identity event input');
106
+ if (root.schema !== IDENTITY_EVENTS_INPUT_SCHEMA || root.version !== IDENTITY_EVENTS_INPUT_VERSION || !['entra', 'okta'].includes(String(root.provider)))
107
+ throw new TypeError('Unsupported identity event input schema, version or provider.');
108
+ provider = root.provider;
109
+ input = root.events;
110
+ match = root.match ?? null;
111
+ }
112
+ else if (Array.isArray(parsed)) {
113
+ provider = 'okta';
114
+ input = parsed;
115
+ }
116
+ else {
117
+ provider = 'entra';
118
+ input = root.value;
119
+ }
120
+ if (!Array.isArray(input) || input.length > MAX_IDENTITY_EVENTS)
121
+ throw new TypeError('Select a sign-in value array, a System Log array, or a versioned identity event input.');
122
+ const extracted = extractEvents(input, provider);
123
+ if (input.length && !extracted.events.length)
124
+ throw new TypeError('No supported identity event records were found.');
125
+ let identityEventReview = { provider, ...extracted, sourceHasMore: typeof root['@odata.nextLink'] === 'string' && Boolean(root['@odata.nextLink']), comparison: null };
126
+ if (match !== null)
127
+ identityEventReview = compareIdentityEvents(identityEventReview, match);
128
+ return { targets: [], report: { ...base, identityEventReview, coverage: { ...base.coverage,
129
+ state: identityEventReview.invalidEvents || identityEventReview.sourceHasMore ? 'partial' : 'reviewed', reviewedParts: identityEventReview.events.length,
130
+ boundsReached: identityEventReview.sourceHasMore ? ['The selected export declares another page; it was not requested'] : [] } } };
131
+ }
@@ -0,0 +1,39 @@
1
+ import { array, enumeration, exact } from "../evidence/artifact-structure.mjs";
2
+ import { IDENTITY_ACTIONS } from "../contracts/message-intake.mjs";
3
+ /** Reported actions select recovery guidance; they do not establish compromise. */
4
+ export function reviewIdentityIncident(value) {
5
+ const input = exact(value, ['reportedActions'], 'Identity incident review');
6
+ const allowed = IDENTITY_ACTIONS.map(action => action.id);
7
+ const reportedActions = [...new Set(array(input.reportedActions, 'Reported actions', allowed.length)
8
+ .map(item => enumeration(item, allowed, 'Reported action')))];
9
+ const has = (...actions) => actions.some(action => reportedActions.includes(action));
10
+ const nextSteps = [];
11
+ const add = (id, title, detail) => nextSteps.push({ id, title, detail });
12
+ if (reportedActions.length)
13
+ add('preserve', 'Preserve the message and sign-in timeline', 'Record the reported time and action, retain relevant account audit events and keep the original privately. Use an independently known account portal, not the supplied link.');
14
+ if (has('entered_password', 'approved_signin', 'entered_device_code')) {
15
+ add('sessions', 'Review and revoke suspicious account sessions', 'Use the identity provider’s documented response process. Review recent sign-ins, new authentication methods and registered devices; resetting a password alone may not invalidate existing sessions.');
16
+ }
17
+ if (has('entered_password'))
18
+ add('password', 'Change exposed and reused credentials', 'Change the affected password through a trusted route. Review other accounts where the same credential was used, without testing the supplied site.');
19
+ if (has('granted_consent', 'entered_device_code'))
20
+ add('grants', 'Review application grants and tokens', 'Identify the application/client ID in account audit records. Revoke unwanted consent and refresh tokens using the provider’s controls; check accessed mail and files and newly created inbox rules.');
21
+ if (has('executed_command'))
22
+ add('endpoint', 'Escalate for endpoint containment', 'Stop using the affected device for account recovery. Follow the organisation’s endpoint incident process, preserve the command as inert evidence and use a separate trusted device.');
23
+ if (has('entered_password', 'approved_signin', 'entered_device_code', 'granted_consent'))
24
+ add('persistence', 'Check persistence and downstream access', 'Review forwarding rules, delegates, newly authorised applications and affected data. Record the findings independently of public domain evidence.');
25
+ if (reportedActions.length)
26
+ add('followup', 'Record recovery actions and a follow-up', 'Track account recovery separately from provider reports and domain availability. A removed page does not show that an account session or application grant was revoked.');
27
+ return { reportedActions, nextSteps };
28
+ }
29
+ /** A deliberate follow-up request, never a completed recovery or compromise claim. */
30
+ export function identityRecoveryFollowUp(reportedActions, stepId) {
31
+ const review = reviewIdentityIncident({ reportedActions });
32
+ const step = review.nextSteps.find(item => item.id === stepId);
33
+ if (!step)
34
+ throw new TypeError('Select a recommendation supported by the reported actions.');
35
+ return {
36
+ statement: `Requested follow-up: ${step.title}`,
37
+ rationale: `Conditional analyst request based on reported actions, not independently verified account telemetry. ${step.detail} No recovery action or independent result is established by this request.`,
38
+ };
39
+ }
@@ -0,0 +1,58 @@
1
+ import { CASE_PIN_COMPLETENESS } from "../cases/case-response-records.mjs";
2
+ import { MAX_RESPONSE_VALUE_LENGTH, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH } from "../contracts/case-portability.mjs";
3
+ import { CONTEXT_REVIEW_SCHEMA, CONTEXT_REVIEW_VERSION, MAX_CONTEXT_RECORDS } from "../contracts/context-review.mjs";
4
+ import { array, domain, enumeration, exact, HEX_DIGEST_RE, iso, text } from "../evidence/artifact-structure.mjs";
5
+ export { INCIDENT_SEQUENCE_INPUT_SCHEMA, INCIDENT_SEQUENCE_INPUT_VERSION } from "../contracts/context-review.mjs";
6
+ export const INCIDENT_STAGE_KINDS = ['message', 'navigation', 'identity_prompt', 'credential_entry', 'consent', 'browser_instruction', 'clipboard', 'local_execution', 'payment', 'recovery', 'other'];
7
+ export const INCIDENT_STAGE_BASES = ['retained_observation', 'imported_record', 'reported_action'];
8
+ export function readIncidentStages(raw) {
9
+ const stages = array(raw, 'Incident stages', MAX_CONTEXT_RECORDS).map(value => {
10
+ const row = exact(value, ['id', 'kind', 'basis', 'description', 'occurredAt', 'hostname', 'source', 'reference', 'referenceSha256', 'completeness', 'limitations'], 'Incident stage');
11
+ const id = text(row.id, 'Stage ID', 64);
12
+ if (!/^[A-Za-z0-9_-]+$/u.test(id))
13
+ throw new TypeError('Stage ID must be a bounded identifier.');
14
+ iso(row.occurredAt, 'Stage occurrence time', true);
15
+ if (row.hostname !== null)
16
+ domain(row.hostname, 'Stage hostname');
17
+ if (row.referenceSha256 !== null && (typeof row.referenceSha256 !== 'string' || !HEX_DIGEST_RE.test(row.referenceSha256)))
18
+ throw new TypeError('Stage reference digest must be hexadecimal SHA-256 or null.');
19
+ return { id, kind: enumeration(row.kind, INCIDENT_STAGE_KINDS, 'Stage kind'), basis: enumeration(row.basis, INCIDENT_STAGE_BASES, 'Stage basis'),
20
+ description: text(row.description, 'Stage description', MAX_RESPONSE_VALUE_LENGTH), occurredAt: row.occurredAt, hostname: row.hostname,
21
+ source: text(row.source, 'Stage source', MAX_RESPONSE_LABEL_LENGTH), reference: text(row.reference, 'Stage reference', 500), referenceSha256: row.referenceSha256,
22
+ completeness: enumeration(row.completeness, CASE_PIN_COMPLETENESS, 'Stage completeness'),
23
+ limitations: array(row.limitations, 'Stage limitations', MAX_RESPONSE_LIMITATIONS).map(item => text(item, 'Stage limitation', MAX_RESPONSE_LIMITATION_LENGTH)), };
24
+ });
25
+ if (new Set(stages.map(row => row.id)).size !== stages.length)
26
+ throw new TypeError('Incident stage IDs must be unique.');
27
+ return stages;
28
+ }
29
+ /** Copy the selected pin's provenance without giving it stronger assurance. */
30
+ export function incidentStageFromPin(pin, kind, id) {
31
+ return readIncidentStages([{ id, kind, basis: 'retained_observation', description: pin.value, occurredAt: pin.observedAt,
32
+ hostname: pin.observationHostname ?? null, source: pin.source, reference: pin.id, referenceSha256: pin.importContentSha256 ?? null,
33
+ completeness: pin.truncated === true && pin.completeness === 'complete' ? 'partial' : pin.completeness, limitations: [...pin.limitations],
34
+ }])[0];
35
+ }
36
+ export function reviewIncidentSequence(raw, reviewedAt) {
37
+ iso(reviewedAt, 'Review time');
38
+ const stages = readIncidentStages(raw);
39
+ const counts = Object.fromEntries(INCIDENT_STAGE_BASES.map(basis => [basis, stages.filter(row => row.basis === basis).length]));
40
+ let priorTime = null, reversedTimes = 0;
41
+ for (const row of stages) {
42
+ if (row.occurredAt === null)
43
+ continue;
44
+ const time = Date.parse(row.occurredAt);
45
+ if (priorTime !== null && time < priorTime)
46
+ reversedTimes++;
47
+ priorTime = time;
48
+ }
49
+ return { schema: CONTEXT_REVIEW_SCHEMA, version: CONTEXT_REVIEW_VERSION, kind: 'incident_sequence', reviewedAt, title: 'Incident sequence',
50
+ state: !stages.length || stages.some(row => row.occurredAt === null || row.completeness !== 'complete') || reversedTimes ? 'partial' : 'reviewed',
51
+ summary: `Analyst-ordered stages: ${stages.length} (retained observations: ${counts.retained_observation}; imported records: ${counts.imported_record}; reported actions: ${counts.reported_action}).${reversedTimes ? ` Timestamp reversals needing review: ${reversedTimes}; the selected order was preserved.` : ''}`,
52
+ observations: stages.map((row, index) => ({ label: `${index + 1}. ${row.kind.replaceAll('_', ' ')} · ${row.basis.replaceAll('_', ' ')}`,
53
+ state: row.completeness === 'complete' ? 'reported' : 'partial', detail: `${row.description} · Source completeness: ${row.completeness}.${row.limitations.length ? ` ${row.limitations.join(' ')}` : ''}`,
54
+ source: `${row.source} · reference ${row.reference}${row.referenceSha256 ? ` · reference SHA-256 ${row.referenceSha256}` : ''}`, observedAt: row.occurredAt, hostname: row.hostname })),
55
+ nextSteps: ['Compare stages with the selected message and capture records; retain gaps and conflicting source times.', 'Use reported credential entry, consent or local execution to select the relevant account or device recovery review, separately from domain reporting.'],
56
+ limitations: ['Order is analyst-selected, not proof of causation or a verified attack chain. An instruction, prompt or clipboard attempt does not establish that a person followed it.', 'Sources, occurrence times and reference digests retain their supplied meaning; importing this input does not authenticate them. Unknown times are not replaced by review or save time.'],
57
+ };
58
+ }
@@ -0,0 +1,19 @@
1
+ import { sha256ArtifactBytes } from "../evidence/artifact-integrity.mjs";
2
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
3
+ import { MESSAGE_INTAKE_KINDS, MESSAGE_INTAKE_SCHEMA, MESSAGE_INTAKE_VERSION, MAX_MESSAGE_INTAKE_BYTES } from "../contracts/message-intake.mjs";
4
+ export function assertMessageBytes(bytes) {
5
+ if (!(bytes instanceof Uint8Array) || !(bytes.buffer instanceof ArrayBuffer) || !bytes.byteLength || bytes.byteLength > MAX_MESSAGE_INTAKE_BYTES) {
6
+ throw new TypeError('Select one non-empty input of at most 16 MiB. No input was retained.');
7
+ }
8
+ }
9
+ export async function createIntakeReport(bytes, kind, reviewedAt) {
10
+ assertMessageBytes(bytes);
11
+ const instant = normalizeExplicitIsoTimestamp(reviewedAt);
12
+ if (!instant || !MESSAGE_INTAKE_KINDS.includes(kind))
13
+ throw new TypeError('Input review requires a supported format and a timestamp with an explicit timezone.');
14
+ return { schema: MESSAGE_INTAKE_SCHEMA, schemaVersion: MESSAGE_INTAKE_VERSION, reviewedAt: instant,
15
+ source: { kind, digestSha256: await sha256ArtifactBytes(bytes), byteLength: bytes.byteLength },
16
+ coverage: { state: 'reviewed', reviewedParts: 0, unreviewedAttachments: 0, rejectedLinks: 0, boundsReached: [] },
17
+ identities: [], authenticationClaims: [], authenticationReview: { headers: [], omittedHeaders: 0 }, messageParts: [],
18
+ links: [], actionHints: [], identityRecovery: { reportedActions: [], nextSteps: [] } };
19
+ }
@@ -1,5 +1,5 @@
1
- import { boundedJsonLimitsForBytes, parseBoundedJson, parseBoundedJsonObject } from "../../lib/bounded-json.mjs";
2
- import { normalizeBoundedSemanticVersion } from "../../lib/semantic-version.mjs";
1
+ import { boundedJsonLimitsForBytes, parseBoundedJson, parseBoundedJsonObject } from "../analysis/bounded-json.mjs";
2
+ import { normalizeBoundedSemanticVersion } from "../analysis/semantic-version.mjs";
3
3
  import { canonicalArtifactJsonV2, sha256ArtifactBytes, sha256ArtifactDigestV2, SORTED_JSON_V2 } from "../evidence/artifact-integrity.mjs";
4
4
  import { array, digest, enumeration, exact, fail, integer, iso, strings, text, validateIntegrity } from "../evidence/artifact-structure.mjs";
5
5
  import { SELECTED_FILE_MEDIA_TYPES as INVESTIGATION_FILE_MEDIA_TYPES } from "../contracts/selected-file-limits.mjs";
@@ -0,0 +1,179 @@
1
+ import { canonicalRegistrableDomain } from "../analysis/registrable-domain.mjs";
2
+ import { parseCredentialFreeHttpUrl } from "../evidence/lookup-target.mjs";
3
+ import { MAX_INTAKE_LINKS, MAX_INTAKE_URL_LENGTH, MAX_EMBEDDED_LINK_DEPTH, MAX_AUTH_SCOPES } from "../contracts/message-intake.mjs";
4
+ const EXCLUDED_INPUTS = {
5
+ malformed: 'Malformed or overlong links were not reviewed',
6
+ credentials: 'Links containing credentials were not reviewed',
7
+ protocol: 'Non-HTTP(S) links were not reviewed',
8
+ port: 'Links using non-default ports were not reviewed',
9
+ host: 'IP-address or non-registrable-host links were not reviewed',
10
+ qrNetwork: 'QR network settings were not interpreted',
11
+ qrContact: 'QR contact details were not interpreted',
12
+ qrProtocol: 'QR non-HTTP(S) payloads were not interpreted',
13
+ qrHost: 'QR hostnames without an HTTP(S) scheme were not offered for collection',
14
+ qrText: 'QR text without an HTTP(S) link was not interpreted',
15
+ };
16
+ const AUTH_KEYS = ['client_id', 'scope', 'redirect_uri', 'response_type', 'prompt'];
17
+ const EMBEDDED_KEYS = new Set(['url', 'u', 'target', 'redirect', 'redirect_uri', 'redirect_url', 'return', 'returnurl', 'continue', 'next', 'dest', 'destination']);
18
+ const safeIdentifier = (value) => value && /^[a-zA-Z0-9._~:-]{1,160}$/u.test(value) ? value : null;
19
+ const tokens = (value) => (value ?? '').split(/\s+/u).filter(Boolean);
20
+ export function reviewAuthorisationLink(url) {
21
+ const params = url.searchParams;
22
+ const authorisation = params.has('client_id') && (params.has('scope') || params.has('response_type') || params.has('redirect_uri'));
23
+ const device = /\/(?:device|devicecode|devicelogin)(?:\/|$)/iu.test(url.pathname);
24
+ if (!authorisation && !device)
25
+ return null;
26
+ const duplicateParameters = AUTH_KEYS.filter(key => params.getAll(key).length > 1);
27
+ const single = (key) => duplicateParameters.includes(key) ? null : params.get(key);
28
+ const scopes = tokens(single('scope'));
29
+ const retainedScopes = scopes.filter(scope => /^[a-zA-Z0-9._:/-]{1,160}$/u.test(scope)).slice(0, MAX_AUTH_SCOPES);
30
+ const redirect = parseCredentialFreeHttpUrl(single('redirect_uri'), MAX_INTAKE_URL_LENGTH);
31
+ const responseTypes = tokens(single('response_type')).filter(value => ['code', 'token', 'id_token', 'none'].includes(value));
32
+ const prompt = tokens(single('prompt')).filter(value => ['none', 'login', 'consent', 'select_account', 'create'].includes(value));
33
+ return {
34
+ kind: authorisation ? 'authorisation_parameters' : 'device_code_reference',
35
+ clientId: safeIdentifier(single('client_id')), scopes: retainedScopes,
36
+ redirectOrigin: redirect?.origin ?? null, responseTypes, prompt, duplicateParameters,
37
+ omittedParameters: scopes.length !== retainedScopes.length || (params.has('client_id') && !safeIdentifier(single('client_id')))
38
+ || (params.has('redirect_uri') && !redirect) || tokens(single('response_type')).length !== responseTypes.length
39
+ || tokens(single('prompt')).length !== prompt.length,
40
+ };
41
+ }
42
+ export function refangIntakeUrl(raw) {
43
+ // Only the scheme and authority are defanged. Path/query/fragment bytes may be
44
+ // significant to an exact supplied target, even when they resemble defanging.
45
+ return raw.replace(/^(https?|hxxps?)(?::|\[:\])\/\/([^/?#]*)/iu, (_match, scheme, authority) => `${scheme.toLowerCase().replace('hxxp', 'http')}://${authority.replace(/\[\.\]|\(\.\)/gu, '.')}`);
46
+ }
47
+ const HTTP_PREFIX = /^(?:https?|hxxps?)(?::|\[:\])\/\//iu;
48
+ function proseUrl(value) {
49
+ // Prose delimiters are not part of a URL; balanced path brackets are. Direct
50
+ // supplied links bypass this text-scanning cleanup entirely.
51
+ let result = value.replace(/[.,;!?]+$/u, '');
52
+ let round = 0, square = 0;
53
+ for (const char of result) {
54
+ if (char === '(')
55
+ round++;
56
+ else if (char === ')')
57
+ round--;
58
+ else if (char === '[')
59
+ square++;
60
+ else if (char === ']')
61
+ square--;
62
+ }
63
+ for (;;) {
64
+ const end = result.at(-1);
65
+ if (end === ')' && round < 0)
66
+ round++;
67
+ else if (end === ']' && square < 0)
68
+ square++;
69
+ else
70
+ return result;
71
+ result = result.slice(0, -1).replace(/[.,;!?]+$/u, '');
72
+ }
73
+ }
74
+ function displayedHost(value) {
75
+ const bounded = value.trim();
76
+ if (bounded.length > MAX_INTAKE_URL_LENGTH || /\s/u.test(bounded))
77
+ return null;
78
+ const parsed = parseCredentialFreeHttpUrl(refangIntakeUrl(HTTP_PREFIX.test(bounded) ? bounded : `https://${bounded}`), MAX_INTAKE_URL_LENGTH);
79
+ return parsed && canonicalRegistrableDomain(parsed.hostname) ? parsed.hostname : null;
80
+ }
81
+ /** No resolution or request occurs here; embedded targets remain supplied text. */
82
+ export function createLinkIntake() {
83
+ const links = [], targets = [];
84
+ const seen = new Set();
85
+ const exclusions = new Map();
86
+ let rejected = 0, bounded = false;
87
+ function exclude(reason, isLink = true) {
88
+ if (isLink)
89
+ rejected++;
90
+ exclusions.set(reason, (exclusions.get(reason) ?? 0) + 1);
91
+ }
92
+ function add(raw, source, displayed = '', parentId = null, depth = 0, location) {
93
+ if (links.length >= MAX_INTAKE_LINKS) {
94
+ bounded = true;
95
+ return;
96
+ }
97
+ const value = refangIntakeUrl(raw.trim());
98
+ const url = parseCredentialFreeHttpUrl(value, MAX_INTAKE_URL_LENGTH);
99
+ if (!url) {
100
+ let reason = 'malformed';
101
+ if (value.length <= MAX_INTAKE_URL_LENGTH && !/[\x00-\x20\x7f]/u.test(value)) {
102
+ try {
103
+ const parsed = new URL(value);
104
+ if (!['http:', 'https:'].includes(parsed.protocol))
105
+ reason = 'protocol';
106
+ else if (parsed.username || parsed.password)
107
+ reason = 'credentials';
108
+ }
109
+ catch { /* Retain only the fixed malformed-input category. */ }
110
+ }
111
+ exclude(reason);
112
+ return;
113
+ }
114
+ if (url.port) {
115
+ exclude('port');
116
+ return;
117
+ }
118
+ if (!canonicalRegistrableDomain(url.hostname)) {
119
+ exclude('host');
120
+ return;
121
+ }
122
+ const shown = displayedHost(displayed);
123
+ const key = JSON.stringify([url.href, source, parentId, shown, location ?? null]);
124
+ if (seen.has(key))
125
+ return;
126
+ seen.add(key);
127
+ const id = `link-${links.length + 1}`;
128
+ links.push({ id, origin: url.origin, hostname: url.hostname, registrationDomain: canonicalRegistrableDomain(url.hostname),
129
+ source, ...(location ? { location } : {}), parentId, displayedHostname: shown, displayedDestination: shown ? shown === url.hostname ? 'same_host' : 'different_host' : 'not_a_hostname',
130
+ hasPrivateLocation: url.pathname !== '/' || Boolean(url.search || url.hash), authorisation: reviewAuthorisationLink(url) });
131
+ targets.push({ id, exactUrl: url.href });
132
+ for (const [name, value] of url.searchParams) {
133
+ if (!EMBEDDED_KEYS.has(name.toLowerCase()) || !HTTP_PREFIX.test(value))
134
+ continue;
135
+ if (depth >= MAX_EMBEDDED_LINK_DEPTH) {
136
+ bounded = true;
137
+ continue;
138
+ }
139
+ add(value, 'embedded_parameter', '', id, depth + 1, location);
140
+ }
141
+ }
142
+ function addText(text, source = 'text', location) {
143
+ // The caller admits bytes before this scan; iteration stops at the work bound.
144
+ for (const match of text.matchAll(/(?:https?|hxxps?)(?::|\[:\])\/\/[^\s<>"'`]+/giu)) {
145
+ add(match[0].length > MAX_INTAKE_URL_LENGTH ? match[0] : proseUrl(match[0]), source, '', null, 0, location);
146
+ if (links.length >= MAX_INTAKE_LINKS) {
147
+ bounded = true;
148
+ break;
149
+ }
150
+ }
151
+ }
152
+ function addQr(text, source = 'qr', location) {
153
+ const value = refangIntakeUrl(text.trim());
154
+ const scheme = /^([a-z][a-z0-9+.-]*):/iu.exec(value)?.[1]?.toLowerCase();
155
+ if (/^WIFI:/iu.test(value)) {
156
+ exclude('qrNetwork', false);
157
+ return;
158
+ }
159
+ if (/^(?:BEGIN:VCARD|MECARD:)/iu.test(value)) {
160
+ exclude('qrContact', false);
161
+ return;
162
+ }
163
+ if (scheme && !['http', 'https'].includes(scheme)) {
164
+ exclude('qrProtocol', false);
165
+ return;
166
+ }
167
+ if (!/(?:https?|hxxps?):\/\//iu.test(value)) {
168
+ exclude(displayedHost(value) ? 'qrHost' : 'qrText', false);
169
+ return;
170
+ }
171
+ addText(value, source, location);
172
+ }
173
+ return { add, addText, addQr, result: () => ({ links: [...links], targets: [...targets], rejected, bounded,
174
+ limitations: Object.keys(EXCLUDED_INPUTS).flatMap(reason => {
175
+ const count = exclusions.get(reason);
176
+ return count ? [`${EXCLUDED_INPUTS[reason]} (${count}).`] : [];
177
+ }),
178
+ }) };
179
+ }
@@ -1,7 +1,7 @@
1
1
  import { LOOKUP_ASSET_GRAPH_SCHEMA, LOOKUP_ASSET_GRAPH_VERSION, MAX_LOOKUP_ASSET_LIMITATIONS, } from "../contracts/investigation-portability.mjs";
2
2
  import { MAX_PROFILE_VALUES } from "../contracts/workspace-portability.mjs";
3
3
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
4
- import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
4
+ import { canonicalRegistrableDomain } from "../analysis/registrable-domain.mjs";
5
5
  import { createGraphInputReader, graphInputIsIncomplete } from "./lookup-asset-graph-inputs.mjs";
6
6
  export { LOOKUP_ASSET_GRAPH_SCHEMA, LOOKUP_ASSET_GRAPH_VERSION };
7
7
  const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/gu;