@slicedearth/whoisleuth-cli 1.36.1 → 1.43.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 (139) hide show
  1. package/README.md +32 -2
  2. package/bin/whoisleuth.mjs +52 -24
  3. package/cli/archive-inspect.mjs +37 -2
  4. package/cli/arguments.mjs +493 -157
  5. package/cli/artifact-verify.mjs +91 -15
  6. package/cli/bulk-checkpoint.mjs +7 -11
  7. package/cli/bulk-command-runner.mjs +19 -2
  8. package/cli/case-pack.mjs +136 -0
  9. package/cli/ci-report.mjs +73 -0
  10. package/cli/collection-preflight.mjs +59 -0
  11. package/cli/command-reference.mjs +395 -0
  12. package/cli/compare.mjs +1 -1
  13. package/cli/completion.mjs +49 -11
  14. package/cli/config-profile.mjs +180 -0
  15. package/cli/ct-event-intake.mjs +159 -0
  16. package/cli/discovery-scan-command-runner.mjs +15 -1
  17. package/cli/doctor.mjs +11 -5
  18. package/cli/domain-control-monitor.mjs +104 -0
  19. package/cli/domain-control-observations.mjs +235 -0
  20. package/cli/evidence-command-arguments.mjs +12 -1
  21. package/cli/evidence-command-runner.mjs +3 -2
  22. package/cli/evidence-signing.mjs +4 -4
  23. package/cli/external-observation-mapping.mjs +162 -0
  24. package/cli/fail-policy.mjs +64 -0
  25. package/cli/formatters/html.mjs +2 -2
  26. package/cli/formatters/json.mjs +13 -1
  27. package/cli/formatters/terminal.mjs +7 -3
  28. package/cli/investigation-manifest.mjs +113 -0
  29. package/cli/investigation-plan.mjs +2 -2
  30. package/cli/investigation-run.mjs +112 -0
  31. package/cli/local-mmdb-review.mjs +76 -0
  32. package/cli/lookalike-calibration.mjs +121 -0
  33. package/cli/lookup-brief.mjs +116 -0
  34. package/cli/lookup-command-runner.mjs +12 -2
  35. package/cli/lookup-diff.mjs +17 -19
  36. package/cli/lookup-plan.mjs +3 -3
  37. package/cli/lookup-reconcile.mjs +141 -0
  38. package/cli/lookup-timeline.mjs +1 -1
  39. package/cli/mail-review.mjs +1 -1
  40. package/cli/network-command-runner.mjs +4 -1
  41. package/cli/offline-evidence-review.mjs +125 -10
  42. package/cli/open-asset-model-bridge.mjs +169 -0
  43. package/cli/registry-cohort.mjs +121 -0
  44. package/cli/registry-doctor.mjs +220 -0
  45. package/cli/registry-fixture-scaffold.mjs +73 -0
  46. package/cli/risk-calibration.mjs +117 -9
  47. package/cli/runner.mjs +561 -280
  48. package/cli/saved-lookup.mjs +2 -2
  49. package/cli/sharing-review.mjs +202 -0
  50. package/cli/source-reliability.mjs +1 -1
  51. package/docs/cli-reference.md +1336 -0
  52. package/docs/cli.md +259 -940
  53. package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
  54. package/frontend/src/lib/analysis/acquisition-due-diligence.js +7 -7
  55. package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
  56. package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
  57. package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
  58. package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
  59. package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
  60. package/frontend/src/lib/analysis/case-migration-model.js +6 -1
  61. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  62. package/frontend/src/lib/analysis/case-record-core.js +5 -1
  63. package/frontend/src/lib/analysis/case-record-operations.js +6 -1
  64. package/frontend/src/lib/analysis/case-report.js +573 -0
  65. package/frontend/src/lib/analysis/case-response-model.js +30 -1
  66. package/frontend/src/lib/analysis/case-response-packet.js +7 -7
  67. package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
  68. package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
  69. package/frontend/src/lib/analysis/external-findings-import.js +320 -0
  70. package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
  71. package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
  72. package/frontend/src/lib/analysis/lookup-decision-support.js +487 -0
  73. package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
  74. package/frontend/src/lib/analysis/lookup-presentation.js +64 -0
  75. package/frontend/src/lib/analysis/lookup-response.js +2 -0
  76. package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
  77. package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
  78. package/frontend/src/lib/analysis/page-similarity.js +1 -1
  79. package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
  80. package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
  81. package/frontend/src/lib/analysis/scoring.js +9 -89
  82. package/frontend/src/lib/analysis/shortlist-model.js +8 -4
  83. package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
  84. package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
  85. package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
  86. package/frontend/src/lib/analysis/visualization-models.js +369 -0
  87. package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
  88. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
  89. package/frontend/src/lib/analysis/workspace-archive.js +4 -2
  90. package/lib/abusech-auth.mjs +1 -1
  91. package/lib/analyst-taxonomy.mjs +40 -0
  92. package/lib/availability.mjs +2 -2
  93. package/lib/bounded-contract-normalizers.mjs +33 -0
  94. package/lib/browser-library-profile.mjs +15 -2
  95. package/lib/certificate-extension-profile.mjs +71 -0
  96. package/lib/credential-surface-profile.mjs +1 -1
  97. package/lib/dns-convergence-review.mjs +183 -0
  98. package/lib/dns-delegation-health.mjs +196 -3
  99. package/lib/domain-assurance.mjs +368 -0
  100. package/lib/domain-change-packet.mjs +133 -0
  101. package/lib/domain-change-review.mjs +307 -0
  102. package/lib/domain-control-flight-recorder.mjs +268 -0
  103. package/lib/domain-control-manifest.mjs +50 -7
  104. package/lib/domain-portfolio-review.mjs +151 -0
  105. package/lib/domain-posture.mjs +9 -9
  106. package/lib/encrypted-dns-contract.mjs +1 -1
  107. package/lib/evidence-export.mjs +1 -1
  108. package/lib/evidence-report-markdown.mjs +1 -1
  109. package/lib/evidence-report.mjs +1 -1
  110. package/lib/generated/cisa-kev-catalog.mjs +1668 -0
  111. package/lib/html-signals.mjs +5 -11
  112. package/lib/ip-prefix.mjs +34 -1
  113. package/lib/local-geoip-evidence.mjs +2 -2
  114. package/lib/lookup-response-contract.mjs +346 -0
  115. package/lib/nameserver-preflight-review.mjs +159 -0
  116. package/lib/observed-network-context.mjs +1 -1
  117. package/lib/opportunity-scoring.mjs +107 -0
  118. package/lib/page-fingerprints.mjs +1 -1
  119. package/lib/page-language-signals.mjs +124 -0
  120. package/lib/rdap-capabilities.mjs +6 -6
  121. package/lib/rdap-search-workbench.mjs +96 -6
  122. package/lib/registry-access.mjs +1 -1
  123. package/lib/registry-capability-catalogue.mjs +4 -4
  124. package/lib/registry-insights.mjs +6 -6
  125. package/lib/risk-scoring.mjs +247 -83
  126. package/lib/scoring-evidence-quality.mjs +107 -0
  127. package/lib/security-txt.mjs +3 -3
  128. package/lib/static-html-analysis.mjs +29 -1
  129. package/lib/threat-intelligence-manifests.mjs +1 -1
  130. package/lib/threatfox-intelligence.mjs +1 -1
  131. package/lib/tls-intelligence.mjs +13 -3
  132. package/lib/trust-store-comparison.mjs +146 -0
  133. package/lib/urlhaus-intelligence.mjs +1 -1
  134. package/lib/website-security-posture.mjs +5 -5
  135. package/lib/website-technology.mjs +25 -11
  136. package/lib/whois-chain.mjs +1 -1
  137. package/lib/zone-intent-review.mjs +534 -0
  138. package/package.json +5 -1
  139. package/third-party-notices.txt +911 -19
@@ -12,7 +12,8 @@ import * as tls from 'node:tls';
12
12
  import { domainToASCII } from 'node:url';
13
13
  import { createObservation } from "./observation.mjs";
14
14
  import { isPrivateAddress, resolvePublicAddresses } from "./safe-fetch.mjs";
15
- const TLS_PROFILE_VERSION = 2;
15
+ import { parseCertificateExtensionProfile, } from "./certificate-extension-profile.mjs";
16
+ const TLS_PROFILE_VERSION = 3;
16
17
  const TLS_PORT = 443;
17
18
  const TLS_TIMEOUT_MS = 5000;
18
19
  const MAX_RESOLVED_ADDRESSES = 64;
@@ -486,6 +487,15 @@ function normalizeCertificate(value, state, options = {}) {
486
487
  };
487
488
  certificate.extendedKeyUsage = certificatePurposes(parsedCertificate?.keyUsage || record.ext_key_usage, state);
488
489
  certificate.authorityInformationAccess = authorityInformationAccess(parsedCertificate?.infoAccess || record.infoAccess, state);
490
+ const extensionProfile = parseCertificateExtensionProfile(record.raw);
491
+ if (extensionProfile.parsed || extensionProfile.partial) {
492
+ certificate.extensionProfile = extensionProfile;
493
+ if (extensionProfile.partial)
494
+ state.discarded += 1;
495
+ if (extensionProfile.certificatePolicies.truncated || extensionProfile.crlDistributionPoints.truncated) {
496
+ state.truncated = true;
497
+ }
498
+ }
489
499
  }
490
500
  return certificate;
491
501
  }
@@ -562,7 +572,7 @@ function tlsFindings(profile) {
562
572
  if (profile.validity.status === 'not_yet_valid')
563
573
  findings.push({ id: 'certificate_not_yet_valid', tone: 'warning', label: 'Certificate not yet valid', detail: 'The leaf certificate validity period had not started at observation time.' });
564
574
  if (profile.authorization.authorized === false)
565
- findings.push({ id: 'certificate_unauthorized', tone: 'warning', label: 'Certificate not authorized', detail: 'The runtime trust store did not authorize the observed certificate chain. This can reflect an incomplete chain, private CA, self-signed certificate, expiry, or another validation failure.' });
575
+ findings.push({ id: 'certificate_unauthorized', tone: 'warning', label: 'Certificate not authorised', detail: 'The runtime trust store did not authorise the observed certificate chain. This can reflect an incomplete chain, private CA, self-signed certificate, expiry, or another validation failure.' });
566
576
  if (profile.hostname.matches === false)
567
577
  findings.push({ id: 'hostname_mismatch', tone: 'warning', label: 'Hostname mismatch', detail: 'The observed leaf certificate did not match the SNI hostname.' });
568
578
  if (profile.certificate?.subjectAltNames?.dnsNames.some((name) => name.startsWith('*.')))
@@ -631,7 +641,7 @@ function buildTlsObservation(handshake = {}, options = {}) {
631
641
  truncated: state.truncated,
632
642
  limitations: [
633
643
  'This is a point-in-time TLS handshake to one validated public address; other addresses or edge locations may present different results.',
634
- 'Authorization reflects the certificate authorities and verification behavior available to this runtime.',
644
+ 'Authorisation reflects the certificate authorities and verification behaviour available to this runtime.',
635
645
  'The profile records the negotiated connection only and does not enumerate supported TLS versions or cipher suites.',
636
646
  ...(state.truncated ? ['One or more certificate fields or chain entries reached a retention limit.'] : []),
637
647
  ],
@@ -0,0 +1,146 @@
1
+ import { enumValue, exactKeys, requireBoundedString, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
2
+ export const TRUST_STORE_COMPARISON_INPUT_SCHEMA = 'whoisleuth.trust-store-comparison.input';
3
+ export const TRUST_STORE_COMPARISON_REVIEW_SCHEMA = 'whoisleuth.trust-store-comparison.review';
4
+ export const TRUST_STORE_COMPARISON_REVIEW_VERSION = 1;
5
+ export const MAX_TRUST_STORE_COMPARISON_STORES = 16;
6
+ export const MAX_TRUST_STORE_ANCHORS = 2_048;
7
+ export const MAX_TRUST_STORE_CHAIN_CERTIFICATES = 16;
8
+ const ROOT_KEYS = new Set(['schema', 'version', 'certificate', 'stores']);
9
+ const CERTIFICATE_KEYS = new Set([
10
+ 'source', 'observedAt', 'state', 'chainTruncated',
11
+ 'fingerprintsSha256', 'runtimeAuthorisation',
12
+ ]);
13
+ const STORE_KEYS = new Set(['name', 'version', 'source', 'reviewedAt', 'state', 'anchorsSha256']);
14
+ const EVIDENCE_STATES = new Set(['observed', 'partial', 'unavailable']);
15
+ const RUNTIME_AUTHORISATION = new Set(['authorised', 'unauthorised', 'unknown']);
16
+ const SHA256_RE = /^[a-f0-9]{64}$/u;
17
+ function digest(value, label) {
18
+ const normalised = requireBoundedString(value, label, 64).toLowerCase();
19
+ if (!SHA256_RE.test(normalised))
20
+ throw new TypeError(`${label} must be a SHA-256 hexadecimal digest.`);
21
+ return normalised;
22
+ }
23
+ function digests(value, label, maximum, sort = true) {
24
+ if (!Array.isArray(value) || value.length > maximum) {
25
+ throw new TypeError(`${label} must contain no more than ${maximum} SHA-256 digests.`);
26
+ }
27
+ const values = [...new Set(value.map((entry, index) => digest(entry, `${label}[${index}]`)))];
28
+ return Object.freeze(sort ? values.sort() : values);
29
+ }
30
+ export function compareTrustStoreEvidence(inputRaw, generatedAtValue = new Date().toISOString()) {
31
+ const input = requireRecord(inputRaw, 'Trust-store comparison input');
32
+ if (input.schema !== TRUST_STORE_COMPARISON_INPUT_SCHEMA || input.version !== 1) {
33
+ throw new TypeError(`Trust-store comparison input must use ${TRUST_STORE_COMPARISON_INPUT_SCHEMA} version 1.`);
34
+ }
35
+ exactKeys(input, ROOT_KEYS, 'Trust-store comparison input');
36
+ const certificateInput = requireRecord(input.certificate, 'certificate');
37
+ exactKeys(certificateInput, CERTIFICATE_KEYS, 'certificate');
38
+ const certificateState = enumValue(certificateInput.state, EVIDENCE_STATES, 'certificate.state');
39
+ if (typeof certificateInput.chainTruncated !== 'boolean')
40
+ throw new TypeError('certificate.chainTruncated must be a Boolean.');
41
+ const certificateFingerprints = digests(certificateInput.fingerprintsSha256, 'certificate.fingerprintsSha256', MAX_TRUST_STORE_CHAIN_CERTIFICATES, false);
42
+ if (certificateState === 'unavailable' && certificateFingerprints.length) {
43
+ throw new TypeError('An unavailable certificate observation cannot contain fingerprints.');
44
+ }
45
+ if (certificateState !== 'unavailable' && !certificateFingerprints.length) {
46
+ throw new TypeError('An observed or partial certificate observation requires at least one fingerprint.');
47
+ }
48
+ const certificate = Object.freeze({
49
+ source: requireBoundedString(certificateInput.source, 'certificate.source', 240),
50
+ observedAt: requireIsoTimestamp(certificateInput.observedAt, 'certificate.observedAt'),
51
+ state: certificateState,
52
+ chainTruncated: certificateInput.chainTruncated,
53
+ leafSha256: certificateFingerprints[0] ?? null,
54
+ fingerprintsSha256: certificateFingerprints,
55
+ runtimeAuthorisation: enumValue(certificateInput.runtimeAuthorisation, RUNTIME_AUTHORISATION, 'certificate.runtimeAuthorisation'),
56
+ });
57
+ if (!Array.isArray(input.stores) || input.stores.length < 1 || input.stores.length > MAX_TRUST_STORE_COMPARISON_STORES) {
58
+ throw new TypeError(`stores must contain between 1 and ${MAX_TRUST_STORE_COMPARISON_STORES} entries.`);
59
+ }
60
+ const storeKeys = new Set();
61
+ const stores = input.stores.map((raw, index) => {
62
+ const store = requireRecord(raw, `stores[${index}]`);
63
+ exactKeys(store, STORE_KEYS, `stores[${index}]`);
64
+ const name = requireBoundedString(store.name, `stores[${index}].name`, 120);
65
+ const version = requireBoundedString(store.version, `stores[${index}].version`, 120);
66
+ const key = `${name}\u0000${version}`;
67
+ if (storeKeys.has(key))
68
+ throw new TypeError(`stores[${index}] repeats a store name and version.`);
69
+ storeKeys.add(key);
70
+ const storeState = enumValue(store.state, EVIDENCE_STATES, `stores[${index}].state`);
71
+ const anchors = digests(store.anchorsSha256, `stores[${index}].anchorsSha256`, MAX_TRUST_STORE_ANCHORS);
72
+ if (storeState === 'unavailable' && anchors.length)
73
+ throw new TypeError(`stores[${index}] cannot contain anchors when unavailable.`);
74
+ if (storeState === 'observed' && !anchors.length)
75
+ throw new TypeError(`stores[${index}] requires at least one anchor when observed.`);
76
+ return Object.freeze({
77
+ name,
78
+ version,
79
+ source: requireBoundedString(store.source, `stores[${index}].source`, 240),
80
+ reviewedAt: requireIsoTimestamp(store.reviewedAt, `stores[${index}].reviewedAt`),
81
+ state: storeState,
82
+ anchors,
83
+ });
84
+ }).sort((left, right) => left.name.localeCompare(right.name) || left.version.localeCompare(right.version));
85
+ const chainSet = new Set(certificate.fingerprintsSha256);
86
+ const comparisons = stores.map((store) => {
87
+ const matchedAnchorSha256 = store.anchors.filter((anchor) => chainSet.has(anchor));
88
+ const completeInputs = certificate.state === 'observed'
89
+ && certificate.chainTruncated === false
90
+ && store.state === 'observed';
91
+ const state = matchedAnchorSha256.length
92
+ ? 'anchor_observed'
93
+ : completeInputs
94
+ ? 'not_observed'
95
+ : 'inconclusive';
96
+ return Object.freeze({
97
+ store: Object.freeze({
98
+ name: store.name,
99
+ version: store.version,
100
+ source: store.source,
101
+ reviewedAt: store.reviewedAt,
102
+ state: store.state,
103
+ anchorCount: store.anchors.length,
104
+ }),
105
+ state,
106
+ matchedAnchorSha256: Object.freeze(matchedAnchorSha256),
107
+ explanation: state === 'anchor_observed'
108
+ ? 'At least one supplied chain fingerprint exactly matched a supplied trust-anchor fingerprint.'
109
+ : state === 'not_observed'
110
+ ? 'No supplied chain fingerprint matched this complete supplied trust-store snapshot.'
111
+ : 'Certificate-chain or trust-store evidence was incomplete or unavailable, so non-observation cannot be established.',
112
+ });
113
+ });
114
+ const incomplete = certificate.state !== 'observed'
115
+ || certificate.chainTruncated
116
+ || stores.some((store) => store.state !== 'observed');
117
+ return Object.freeze({
118
+ schema: TRUST_STORE_COMPARISON_REVIEW_SCHEMA,
119
+ version: TRUST_STORE_COMPARISON_REVIEW_VERSION,
120
+ generatedAt: requireIsoTimestamp(generatedAtValue, 'generatedAt'),
121
+ state: incomplete ? 'partial' : 'complete',
122
+ certificate: Object.freeze({
123
+ source: certificate.source,
124
+ observedAt: certificate.observedAt,
125
+ state: certificate.state,
126
+ chainTruncated: certificate.chainTruncated,
127
+ leafSha256: certificate.leafSha256,
128
+ chainFingerprintCount: certificate.fingerprintsSha256.length,
129
+ runtimeAuthorisation: certificate.runtimeAuthorisation,
130
+ }),
131
+ comparisons: Object.freeze(comparisons),
132
+ counts: Object.freeze({
133
+ stores: comparisons.length,
134
+ anchorObserved: comparisons.filter((comparison) => comparison.state === 'anchor_observed').length,
135
+ notObserved: comparisons.filter((comparison) => comparison.state === 'not_observed').length,
136
+ inconclusive: comparisons.filter((comparison) => comparison.state === 'inconclusive').length,
137
+ }),
138
+ limitations: Object.freeze([
139
+ 'This local comparison uses only supplied SHA-256 fingerprints and makes no TLS, certificate-authority, operating-system, or trust-store request.',
140
+ 'An exact anchor fingerprint intersection does not build or validate a certificate path, check signatures, names, validity, constraints, revocation, policy, or application-specific trust.',
141
+ 'A complete supplied chain can omit its root certificate. A not-observed result therefore does not mean the runtime, browser, operating system, or another client would reject the certificate.',
142
+ 'Trust stores differ by product, platform, version, policy, update state, and local administration. The analyst-supplied source and version remain part of every comparison.',
143
+ 'Runtime authorisation is preserved as separate context and is not overridden by this fingerprint comparison.',
144
+ ]),
145
+ });
146
+ }
@@ -333,7 +333,7 @@ function createUrlhausIntelligenceAdapter(dependencies = {}) {
333
333
  truncated,
334
334
  detail: findings.length
335
335
  ? `Found ${findings.length} bounded malware-distribution record${findings.length === 1 ? '' : 's'} for this host${truncated ? '; additional provider records may exist' : ''}.`
336
- : 'The provider reported this host but no returned record could be safely normalized.',
336
+ : 'The provider reported this host but no returned record could be safely normalised.',
337
337
  upstreamStatus,
338
338
  findings,
339
339
  limitations: [
@@ -50,7 +50,7 @@ function httpFindings(httpValue) {
50
50
  || !response.securityHeaders
51
51
  || typeof response.securityHeaders !== 'object'
52
52
  || Array.isArray(response.securityHeaders)) {
53
- findings.push(finding('http_headers_unavailable', 'response headers', 'unavailable', 'neutral', 'Response-header posture unavailable', 'The selected HTTP observation did not include the normalized security-header set.', ['Selected HTTP response headers']));
53
+ findings.push(finding('http_headers_unavailable', 'response headers', 'unavailable', 'neutral', 'Response-header posture unavailable', 'The selected HTTP observation did not include the normalised security-header set.', ['Selected HTTP response headers']));
54
54
  return findings;
55
55
  }
56
56
  const headerChecks = [
@@ -221,10 +221,10 @@ function tlsFindings(tlsValue) {
221
221
  : finding('modern_tls_negotiated', 'certificate', 'observed', 'configured', 'Modern TLS negotiated', 'The single retained connection negotiated TLS 1.2 or later. This does not enumerate every protocol the endpoint supports.', ['TLS handshake']));
222
222
  }
223
223
  findings.push(authorization.authorized === true
224
- ? finding('certificate_authorized', 'certificate', 'observed', 'configured', 'Certificate chain authorized', 'The runtime trust store authorized the observed certificate chain for the retained connection.', ['TLS handshake'])
224
+ ? finding('certificate_authorized', 'certificate', 'observed', 'configured', 'Certificate chain authorised', 'The runtime trust store authorised the observed certificate chain for the retained connection.', ['TLS handshake'])
225
225
  : authorization.authorized === false
226
- ? finding('certificate_not_authorized', 'certificate', 'potential_exposure', 'review', 'Certificate chain not authorized', 'The runtime trust store did not authorize the observed certificate chain. Review the separately attributed TLS evidence for context.', ['TLS handshake'])
227
- : finding('certificate_authorization_unavailable', 'certificate', 'unavailable', 'neutral', 'Certificate authorization unavailable', 'The retained TLS observation did not include a conclusive chain-authorization result.', ['TLS handshake']));
226
+ ? finding('certificate_not_authorized', 'certificate', 'potential_exposure', 'review', 'Certificate chain not authorised', 'The runtime trust store did not authorise the observed certificate chain. Review the separately attributed TLS evidence for context.', ['TLS handshake'])
227
+ : finding('certificate_authorization_unavailable', 'certificate', 'unavailable', 'neutral', 'Certificate authorisation unavailable', 'The retained TLS observation did not include a conclusive chain-authorization result.', ['TLS handshake']));
228
228
  findings.push(hostname.matches === true
229
229
  ? finding('certificate_hostname_match', 'certificate', 'observed', 'configured', 'Certificate hostname matched', 'The observed leaf certificate matched the hostname used for the retained TLS connection.', ['TLS handshake'])
230
230
  : hostname.matches === false
@@ -274,7 +274,7 @@ function dnsFindings(dnsValue, dnssecValue) {
274
274
  ? finding('dnssec_signed', 'domain controls', 'observed', 'configured', 'DNSSEC signing reported', 'The registry or WHOIS source reported a signed DNSSEC delegation.', ['Registry or WHOIS DNSSEC publication'])
275
275
  : dnssec === 'unsigned'
276
276
  ? finding('dnssec_unsigned', 'domain controls', 'observed_absence', 'review', 'DNSSEC signing not reported', 'The registry or WHOIS source reported an unsigned DNSSEC delegation.', ['Registry or WHOIS DNSSEC publication'])
277
- : finding('dnssec_unavailable', 'domain controls', 'unavailable', 'neutral', 'DNSSEC state unavailable', 'The retained registry and WHOIS evidence did not provide a recognized DNSSEC state.', ['Registry or WHOIS DNSSEC publication']));
277
+ : finding('dnssec_unavailable', 'domain controls', 'unavailable', 'neutral', 'DNSSEC state unavailable', 'The retained registry and WHOIS evidence did not provide a recognised DNSSEC state.', ['Registry or WHOIS DNSSEC publication']));
278
278
  findings.push(!caaAvailable
279
279
  ? finding('caa_unavailable', 'domain controls', 'unavailable', 'neutral', 'CAA posture unavailable', 'The CAA query did not produce a conclusive response for this observation.', [caaSource])
280
280
  : caaCount > 0
@@ -7,7 +7,7 @@
7
7
  import { analyzeBrowserLibraries } from "./browser-library-profile.mjs";
8
8
  import { createObservation } from "./observation.mjs";
9
9
  import { MAX_STATIC_HTML_CHARS, MAX_TAG_LENGTH, MAX_TECHNOLOGY_TAGS, analyzeStaticHtml, } from "./static-html-analysis.mjs";
10
- const TECHNOLOGY_PROFILE_VERSION = 6;
10
+ const TECHNOLOGY_PROFILE_VERSION = 8;
11
11
  const MAX_TECHNOLOGY_HTML_CHARS = MAX_STATIC_HTML_CHARS;
12
12
  const MAX_TECHNOLOGY_TAG_LENGTH = MAX_TAG_LENGTH;
13
13
  const MAX_TECHNOLOGY_FINDINGS = 24;
@@ -40,15 +40,17 @@ function normalizedResourceHosts(value) {
40
40
  }
41
41
  return hosts;
42
42
  }
43
- const PASSIVE_HEADER_NAMES = new Set([
43
+ const PASSIVE_TECHNOLOGY_HEADER_NAMES = Object.freeze([
44
44
  'cf-ray',
45
45
  'x-drupal-cache',
46
- 'x-fastly-request-id',
46
+ 'x-served-by',
47
+ 'x-nf-request-id',
47
48
  'x-powered-by',
48
49
  'x-shopify-stage',
49
50
  'x-sorting-hat-podid',
50
51
  'x-vercel-id',
51
52
  ]);
53
+ const PASSIVE_HEADER_NAMES = new Set(PASSIVE_TECHNOLOGY_HEADER_NAMES);
52
54
  function normalizedResponseHeaders(value) {
53
55
  const input = value && typeof value === 'object' && !Array.isArray(value)
54
56
  ? value
@@ -148,7 +150,10 @@ const TECHNOLOGY_SIGNATURES = [
148
150
  },
149
151
  {
150
152
  id: 'craft-cms', name: 'Craft CMS', category: 'content management',
151
- evidence: [generatorEvidence(/^craft cms(?:\s|$)/i, 'Generator metadata identifies Craft CMS.')],
153
+ evidence: [
154
+ generatorEvidence(/^craft cms(?:\s|$)/i, 'Generator metadata identifies Craft CMS.'),
155
+ responseHeaderEvidence('x-powered-by', /(?:^|,\s*)craft cms(?:\s|$|\/)/i, 'The passive X-Powered-By response header identifies Craft CMS.'),
156
+ ],
152
157
  },
153
158
  {
154
159
  id: 'typo3', name: 'TYPO3 CMS', category: 'content management',
@@ -179,7 +184,7 @@ const TECHNOLOGY_SIGNATURES = [
179
184
  {
180
185
  id: 'adobe-commerce-magento', name: 'Adobe Commerce / Magento Open Source', category: 'commerce',
181
186
  evidence: [
182
- htmlEvidence(['data-mage-init=', 'type="text/x-magento-init"', "type='text/x-magento-init'"], 'Static markup contains Commerce frontend initialization markers.'),
187
+ htmlEvidence(['data-mage-init=', 'type="text/x-magento-init"', "type='text/x-magento-init'"], 'Static markup contains Commerce frontend initialisation markers.'),
183
188
  ],
184
189
  },
185
190
  {
@@ -198,17 +203,23 @@ const TECHNOLOGY_SIGNATURES = [
198
203
  },
199
204
  {
200
205
  id: 'opencart', name: 'OpenCart', category: 'commerce',
201
- evidence: [generatorEvidence(/^opencart(?:\s|$)/i, 'Generator metadata identifies OpenCart.')],
206
+ evidence: [
207
+ generatorEvidence(/^opencart(?:\s|$)/i, 'Generator metadata identifies OpenCart.'),
208
+ htmlEvidence(['index.php?route=common/home', 'image/catalog/opencart-logo.png'], 'Static markup contains OpenCart routing or default asset conventions.'),
209
+ ],
202
210
  },
203
211
  {
204
212
  id: 'prestashop', name: 'PrestaShop', category: 'commerce',
205
- evidence: [generatorEvidence(/^prestashop(?:\s|$)/i, 'Generator metadata identifies PrestaShop.')],
213
+ evidence: [
214
+ generatorEvidence(/^prestashop(?:\s|$)/i, 'Generator metadata identifies PrestaShop.'),
215
+ htmlEvidence(['/modules/ps_'], 'Static resource paths use PrestaShop module conventions.'),
216
+ ],
206
217
  },
207
218
  {
208
219
  id: 'wix', name: 'Wix', category: 'site builder',
209
220
  requiresNonResourceEvidence: true,
210
221
  evidence: [
211
- generatorEvidence(/^wix(?:\s|$)/i, 'Generator metadata identifies Wix.'),
222
+ generatorEvidence(/^wix(?:\.com)?(?:\s|$)/i, 'Generator metadata identifies Wix.'),
212
223
  htmlEvidence(['data-mesh-id='], 'Static markup contains a Wix-specific document attribute.', 'medium'),
213
224
  resourceEvidence(['static.parastorage.com', 'wixstatic.com'], 'A retained resource origin uses Wix delivery infrastructure.'),
214
225
  ],
@@ -316,7 +327,10 @@ const TECHNOLOGY_SIGNATURES = [
316
327
  },
317
328
  {
318
329
  id: 'netlify', name: 'Netlify', category: 'delivery platform',
319
- evidence: [serverEvidence(/^netlify(?:\s|$|\/)/i, 'The selected response server header identifies Netlify.')],
330
+ evidence: [
331
+ serverEvidence(/^netlify(?:\s|$|\/)/i, 'The selected response server header identifies Netlify.'),
332
+ responseHeaderEvidence('x-nf-request-id', null, 'A Netlify request identifier response header was observed.', 'medium'),
333
+ ],
320
334
  },
321
335
  {
322
336
  id: 'vercel', name: 'Vercel', category: 'delivery platform',
@@ -327,7 +341,7 @@ const TECHNOLOGY_SIGNATURES = [
327
341
  },
328
342
  {
329
343
  id: 'fastly', name: 'Fastly', category: 'delivery platform',
330
- evidence: [responseHeaderEvidence('x-fastly-request-id', null, 'A Fastly request identifier response header was observed.', 'medium')],
344
+ evidence: [responseHeaderEvidence('x-served-by', /(?:^|,\s*)cache-[a-z0-9-]+-[a-z]{3}(?:\s*,|$)/i, 'The passive X-Served-By response header contains a Fastly cache-node identifier.', 'medium')],
331
345
  },
332
346
  {
333
347
  id: 'nginx', name: 'nginx', category: 'web server',
@@ -434,4 +448,4 @@ function analyzeWebsiteTechnology(input = {}) {
434
448
  browserLibraryProfile,
435
449
  };
436
450
  }
437
- export { MAX_EVIDENCE_PER_TECHNOLOGY, MAX_TECHNOLOGY_EVIDENCE_DESCRIPTION_LENGTH, MAX_TECHNOLOGY_FINDINGS, MAX_TECHNOLOGY_HTML_CHARS, MAX_TECHNOLOGY_TAGS, TECHNOLOGY_PROFILE_VERSION, TECHNOLOGY_SIGNATURE_CATALOGUE, analyzeWebsiteTechnology, };
451
+ export { MAX_EVIDENCE_PER_TECHNOLOGY, MAX_TECHNOLOGY_EVIDENCE_DESCRIPTION_LENGTH, MAX_TECHNOLOGY_FINDINGS, MAX_TECHNOLOGY_HTML_CHARS, MAX_TECHNOLOGY_TAGS, PASSIVE_TECHNOLOGY_HEADER_NAMES, TECHNOLOGY_PROFILE_VERSION, TECHNOLOGY_SIGNATURE_CATALOGUE, analyzeWebsiteTechnology, };
@@ -54,7 +54,7 @@ export async function buildWhoisChainUncached(query, options = {}) {
54
54
  queryProfile: 'not-issued',
55
55
  responseEncoding: 'utf-8',
56
56
  error: admission.state === 'permission_required'
57
- ? 'WHOIS collection requires registry permission or source authorization; no socket was opened.'
57
+ ? 'WHOIS collection requires registry permission or source authorisation; no socket was opened.'
58
58
  : 'IANA publishes no domain WHOIS service for this suffix; no socket was opened.',
59
59
  }];
60
60
  }