@slicedearth/whoisleuth-cli 1.39.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 (66) hide show
  1. package/README.md +15 -0
  2. package/bin/whoisleuth.mjs +13 -2
  3. package/cli/archive-inspect.mjs +37 -2
  4. package/cli/arguments.mjs +361 -256
  5. package/cli/artifact-verify.mjs +35 -1
  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/completion.mjs +33 -10
  13. package/cli/config-profile.mjs +180 -0
  14. package/cli/ct-event-intake.mjs +159 -0
  15. package/cli/discovery-scan-command-runner.mjs +15 -1
  16. package/cli/domain-control-monitor.mjs +104 -0
  17. package/cli/domain-control-observations.mjs +235 -0
  18. package/cli/evidence-command-arguments.mjs +11 -0
  19. package/cli/evidence-command-runner.mjs +1 -0
  20. package/cli/external-observation-mapping.mjs +162 -0
  21. package/cli/fail-policy.mjs +64 -0
  22. package/cli/formatters/html.mjs +1 -1
  23. package/cli/investigation-manifest.mjs +113 -0
  24. package/cli/investigation-run.mjs +112 -0
  25. package/cli/lookup-brief.mjs +116 -0
  26. package/cli/lookup-command-runner.mjs +8 -1
  27. package/cli/lookup-diff.mjs +17 -19
  28. package/cli/network-command-runner.mjs +4 -1
  29. package/cli/offline-evidence-review.mjs +105 -8
  30. package/cli/open-asset-model-bridge.mjs +169 -0
  31. package/cli/registry-cohort.mjs +121 -0
  32. package/cli/registry-doctor.mjs +106 -11
  33. package/cli/registry-fixture-scaffold.mjs +73 -0
  34. package/cli/runner.mjs +368 -306
  35. package/docs/cli-reference.md +240 -14
  36. package/docs/cli.md +42 -7
  37. package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
  38. package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
  39. package/frontend/src/lib/analysis/case-report.js +573 -0
  40. package/frontend/src/lib/analysis/case-response-model.js +30 -1
  41. package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
  42. package/frontend/src/lib/analysis/external-findings-import.js +320 -0
  43. package/frontend/src/lib/analysis/lookup-decision-support.js +7 -0
  44. package/frontend/src/lib/analysis/lookup-presentation.js +10 -20
  45. package/frontend/src/lib/analysis/scoring.js +1 -1
  46. package/frontend/src/lib/analysis/visualization-models.js +2 -3
  47. package/frontend/src/lib/analysis/workspace-archive.js +2 -0
  48. package/lib/abusech-auth.mjs +1 -1
  49. package/lib/availability.mjs +1 -1
  50. package/lib/bounded-contract-normalizers.mjs +33 -0
  51. package/lib/dns-convergence-review.mjs +183 -0
  52. package/lib/dns-delegation-health.mjs +196 -3
  53. package/lib/domain-change-packet.mjs +133 -0
  54. package/lib/domain-change-review.mjs +307 -0
  55. package/lib/domain-control-flight-recorder.mjs +268 -0
  56. package/lib/domain-portfolio-review.mjs +151 -0
  57. package/lib/evidence-export.mjs +1 -1
  58. package/lib/html-signals.mjs +5 -11
  59. package/lib/nameserver-preflight-review.mjs +159 -0
  60. package/lib/page-language-signals.mjs +124 -0
  61. package/lib/risk-scoring.mjs +42 -1
  62. package/lib/static-html-analysis.mjs +29 -1
  63. package/lib/trust-store-comparison.mjs +146 -0
  64. package/lib/website-technology.mjs +24 -10
  65. package/lib/zone-intent-review.mjs +534 -0
  66. package/package.json +1 -1
@@ -15,6 +15,7 @@ const MAX_SCRIPT_REFERENCE_LENGTH = 2_048;
15
15
  const MAX_SCRIPT_MEDIA_TYPE_LENGTH = 120;
16
16
  const MAX_INLINE_SCRIPT_CHARS = 32_768;
17
17
  const MAX_INLINE_SCRIPT_TOTAL_CHARS = 65_536;
18
+ const MAX_STATIC_VISIBLE_TEXT_CHARS = MAX_STATIC_HTML_CHARS;
18
19
  const MAX_STATIC_FORMS = 50;
19
20
  const MAX_STATIC_INPUTS = 500;
20
21
  const MAX_FORM_ATTRIBUTE_LENGTH = 2_048;
@@ -48,6 +49,7 @@ const VOID_TAGS = new Set([
48
49
  'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta',
49
50
  'param', 'source', 'track', 'wbr',
50
51
  ]);
52
+ const NON_VISIBLE_TEXT_TAGS = new Set(['script', 'style', 'template']);
51
53
  function attributeValue(attributes, name, maximumLength = MAX_FORM_ATTRIBUTE_LENGTH) {
52
54
  const attribute = attributes
53
55
  .slice(0, MAX_ATTRIBUTES_PER_TAG)
@@ -190,7 +192,9 @@ function analyzeStaticHtml(value, options = {}) {
190
192
  const inputLimitReached = supplied.length > MAX_STATIC_HTML_CHARS;
191
193
  const html = supplied.slice(0, MAX_STATIC_HTML_CHARS);
192
194
  const baseUrl = safeBaseUrl(options.baseUrl);
195
+ const includeVisibleText = options.includeVisibleText === true;
193
196
  const markup = [];
197
+ const visibleTextParts = [];
194
198
  const structureTokens = [];
195
199
  const scripts = [];
196
200
  const cspMetaPolicies = [];
@@ -211,9 +215,12 @@ function analyzeStaticHtml(value, options = {}) {
211
215
  let structureLimitReached = false;
212
216
  let scriptLimitReached = false;
213
217
  let inlineLimitReached = false;
218
+ let visibleTextLimitReached = false;
214
219
  let cspMetaLimitReached = false;
215
220
  let insideExplicitHead = false;
216
221
  let scriptElementSeen = false;
222
+ let nonVisibleTextDepth = 0;
223
+ let visibleTextCharacters = 0;
217
224
  function appendInlineScript(chars) {
218
225
  if (!activeInlineScript || !chars)
219
226
  return;
@@ -234,12 +241,26 @@ function analyzeStaticHtml(value, options = {}) {
234
241
  }
235
242
  structureTokens.push(value);
236
243
  }
244
+ function appendVisibleText(chars) {
245
+ if (!includeVisibleText || nonVisibleTextDepth > 0 || !chars)
246
+ return;
247
+ const remaining = MAX_STATIC_VISIBLE_TEXT_CHARS - visibleTextCharacters;
248
+ const retainedLength = Math.max(0, Math.min(chars.length, remaining));
249
+ if (retainedLength > 0) {
250
+ visibleTextParts.push(chars.slice(0, retainedLength));
251
+ visibleTextCharacters += retainedLength;
252
+ }
253
+ if (retainedLength < chars.length)
254
+ visibleTextLimitReached = true;
255
+ }
237
256
  const handler = {
238
257
  onStartTag(token) {
239
258
  const tagName = token.tagName.toLowerCase();
240
259
  const rawTextMode = RAW_TEXT_MODES[tagName];
241
260
  if (!token.selfClosing && rawTextMode !== undefined)
242
261
  tokenizer.state = rawTextMode;
262
+ if (!token.selfClosing && NON_VISIBLE_TEXT_TAGS.has(tagName))
263
+ nonVisibleTextDepth += 1;
243
264
  if (tagsExamined >= MAX_STATIC_HTML_TAGS) {
244
265
  tagLimitReached = true;
245
266
  activeInlineScript = null;
@@ -332,15 +353,20 @@ function analyzeStaticHtml(value, options = {}) {
332
353
  insideExplicitHead = false;
333
354
  if (!VOID_TAGS.has(tagName))
334
355
  appendStructureToken(`/${tagName}`);
356
+ if (NON_VISIBLE_TEXT_TAGS.has(tagName) && nonVisibleTextDepth > 0)
357
+ nonVisibleTextDepth -= 1;
335
358
  },
336
359
  onCharacter(token) {
337
360
  appendInlineScript(token.chars);
361
+ appendVisibleText(token.chars);
338
362
  },
339
363
  onNullCharacter(token) {
340
364
  appendInlineScript(token.chars);
365
+ appendVisibleText(token.chars);
341
366
  },
342
367
  onWhitespaceCharacter(token) {
343
368
  appendInlineScript(token.chars);
369
+ appendVisibleText(token.chars);
344
370
  },
345
371
  onComment() { },
346
372
  onDoctype() { },
@@ -350,6 +376,7 @@ function analyzeStaticHtml(value, options = {}) {
350
376
  tokenizer.write(html, true);
351
377
  return {
352
378
  markup: markup.join('\n'),
379
+ visibleText: visibleTextParts.join(''),
353
380
  structureTokens,
354
381
  scripts,
355
382
  cspMetaPolicies,
@@ -360,8 +387,9 @@ function analyzeStaticHtml(value, options = {}) {
360
387
  structureLimitReached,
361
388
  scriptLimitReached,
362
389
  inlineLimitReached,
390
+ visibleTextLimitReached,
363
391
  tagsExamined,
364
392
  inlineCharactersExamined,
365
393
  };
366
394
  }
367
- export { MAX_ATTRIBUTES_PER_TAG, MAX_INLINE_SCRIPT_CHARS, MAX_INLINE_SCRIPT_TOTAL_CHARS, MAX_SCRIPT_ELEMENTS, MAX_SCRIPT_MEDIA_TYPE_LENGTH, MAX_STATIC_FORMS, MAX_STATIC_HTML_CHARS, MAX_STATIC_INPUTS, MAX_STATIC_HTML_TAGS, MAX_STATIC_STRUCTURE_TOKENS, MAX_TAG_LENGTH, MAX_TECHNOLOGY_TAGS, analyzeStaticHtml, };
395
+ export { MAX_ATTRIBUTES_PER_TAG, MAX_INLINE_SCRIPT_CHARS, MAX_INLINE_SCRIPT_TOTAL_CHARS, MAX_SCRIPT_ELEMENTS, MAX_SCRIPT_MEDIA_TYPE_LENGTH, MAX_STATIC_FORMS, MAX_STATIC_HTML_CHARS, MAX_STATIC_INPUTS, MAX_STATIC_HTML_TAGS, MAX_STATIC_STRUCTURE_TOKENS, MAX_STATIC_VISIBLE_TEXT_CHARS, MAX_TAG_LENGTH, MAX_TECHNOLOGY_TAGS, analyzeStaticHtml, };
@@ -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
+ }
@@ -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',
@@ -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, };