@slicedearth/whoisleuth-cli 2.0.1 → 2.2.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 (97) hide show
  1. package/cli/arguments.mjs +21 -5
  2. package/cli/artifact-validation/case-response.mjs +771 -0
  3. package/cli/artifact-validation/investigation-capsule.mjs +426 -0
  4. package/cli/artifact-validation/lookup-evidence.mjs +718 -0
  5. package/cli/artifact-validation/signed-review.mjs +598 -0
  6. package/cli/artifact-validation/structure-primitives.mjs +105 -0
  7. package/cli/artifact-verify.mjs +1 -1
  8. package/cli/assurance-command-runner.mjs +324 -0
  9. package/cli/command-reference.mjs +1175 -843
  10. package/cli/completion.mjs +56 -9
  11. package/cli/discriminated-command-handlers.mjs +11 -0
  12. package/cli/doctor.mjs +2 -2
  13. package/cli/evidence-command-runner.mjs +41 -31
  14. package/cli/export-evidence.mjs +1 -1
  15. package/cli/fail-policy.mjs +33 -8
  16. package/cli/formatters/terminal-command-formats.mjs +437 -0
  17. package/cli/formatters/terminal-lookup.mjs +758 -0
  18. package/cli/formatters/terminal-metadata.mjs +58 -0
  19. package/cli/formatters/terminal-shared.mjs +83 -0
  20. package/cli/formatters/terminal.mjs +5 -1232
  21. package/cli/history-command-runner.mjs +132 -0
  22. package/cli/inline-command-families.mjs +45 -0
  23. package/cli/inline-command-runner.mjs +55 -0
  24. package/cli/investigation-plan.mjs +9 -2
  25. package/cli/investigation-run.mjs +114 -9
  26. package/cli/lookup-brief.mjs +141 -23
  27. package/cli/lookup-browser-view.mjs +289 -0
  28. package/cli/lookup-browser.mjs +3 -288
  29. package/cli/mail-review.mjs +2 -6
  30. package/cli/offline-artifact-validation.mjs +19 -0
  31. package/cli/review-command-runner.mjs +269 -0
  32. package/cli/runner.mjs +15 -972
  33. package/cli/saved-lookup.mjs +19 -2
  34. package/cli/support-command-runner.mjs +229 -0
  35. package/cli/workflow-command-runner.mjs +129 -0
  36. package/docs/cli-reference.md +16 -9
  37. package/docs/cli.md +16 -2
  38. package/lib/availability.mjs +12 -9
  39. package/lib/bounded-contract-normalizers.mjs +2 -9
  40. package/lib/browser-library-profile.mjs +1 -2
  41. package/lib/client-behavior-profile.mjs +1 -2
  42. package/lib/credential-surface-profile.mjs +1 -1
  43. package/lib/domain-posture-analysis.mjs +10 -1
  44. package/lib/domain-posture.mjs +51 -23
  45. package/lib/evidence-export.mjs +47 -5
  46. package/lib/evidence-report-markdown.mjs +4 -0
  47. package/lib/evidence-report.mjs +50 -0
  48. package/lib/generated/cisa-kev-catalog.mjs +34 -4
  49. package/lib/generated/registrar-standing-catalogue.mjs +367 -0
  50. package/lib/json-record.mjs +12 -0
  51. package/lib/lookup-child-profile-contract.mjs +858 -0
  52. package/lib/lookup-contract-primitives.mjs +98 -0
  53. package/lib/lookup-diagnostics.mjs +96 -0
  54. package/lib/lookup-network-evidence-bounds.mjs +1 -0
  55. package/lib/lookup-response.mjs +367 -0
  56. package/lib/lookup.mjs +28 -443
  57. package/lib/page-role-profile.mjs +1 -3
  58. package/lib/registrar-standing-catalogue-contract.mjs +70 -0
  59. package/lib/registrar-standing-contract.mjs +300 -0
  60. package/lib/registrar-standing.mjs +269 -0
  61. package/lib/registry-insights.mjs +2 -8
  62. package/lib/risk-scoring.mjs +26 -10
  63. package/lib/sslbl-certificates.generated.mjs +138 -136
  64. package/lib/structured-data-identity.mjs +1 -3
  65. package/lib/technology-evidence-role.mjs +51 -0
  66. package/lib/threat-intelligence-runtime.mjs +3 -3
  67. package/lib/threat-intelligence-types.mjs +13 -0
  68. package/lib/threatfox-intelligence.mjs +1 -3
  69. package/lib/tls-intelligence.mjs +1 -2
  70. package/lib/urlhaus-intelligence.mjs +1 -3
  71. package/lib/urlscan-intelligence.mjs +1 -3
  72. package/lib/website-security-posture.mjs +1 -2
  73. package/lib/website-technology.mjs +72 -23
  74. package/lib/whois-common-formats.mjs +276 -0
  75. package/lib/whois-dialects.mjs +534 -0
  76. package/lib/whois-parser-support.mjs +305 -0
  77. package/lib/whois-parser.mjs +135 -0
  78. package/lib/whois.mjs +6 -1187
  79. package/package.json +2 -2
  80. package/packages/cases/case-evidence-model.mjs +9 -0
  81. package/packages/cases/case-migration-model.mjs +17 -11
  82. package/packages/cases/case-record-operations.mjs +235 -0
  83. package/packages/cases/case-response-model.mjs +13 -9
  84. package/packages/cases/case-response-packet.mjs +297 -112
  85. package/packages/comparison/page-similarity.mjs +23 -0
  86. package/packages/contracts/case-portability.mjs +235 -61
  87. package/packages/contracts/cli-lookup.mjs +4 -3
  88. package/packages/contracts/domain-control-manifest.mjs +1 -1
  89. package/packages/contracts/investigation-portability.mjs +1 -1
  90. package/packages/contracts/risk-calibration.mjs +2 -2
  91. package/packages/contracts/workspace-portability.mjs +34 -6
  92. package/packages/interchange/external-findings-import.mjs +113 -45
  93. package/packages/investigation/evidence-coverage-ledger.mjs +21 -0
  94. package/packages/investigation/lookup-claim-readiness.mjs +2 -2
  95. package/packages/workspace/website-snapshot-model.mjs +79 -6
  96. package/third-party-notices.txt +1 -1
  97. package/cli/artifact-structure.mjs +0 -2507
@@ -5,9 +5,9 @@
5
5
  // discarded immediately after analysis.
6
6
  import { domainToASCII } from 'node:url';
7
7
  import { createObservation } from "../packages/evidence/observation.mjs";
8
+ import { MAX_STRUCTURED_DATA_ENTITIES, MAX_STRUCTURED_DATA_SAME_AS_HOSTS, STRUCTURED_DATA_IDENTITY_VERSION, } from "./lookup-child-profile-contract.mjs";
8
9
  import { isUriShapedLabel } from "./portable-generator.mjs";
9
10
  import { analyzeStaticHtml, } from "./static-html-analysis.mjs";
10
- const STRUCTURED_DATA_IDENTITY_VERSION = 1;
11
11
  const MAX_STRUCTURED_DATA_SCRIPTS = 8;
12
12
  const MAX_STRUCTURED_DATA_SCRIPT_CHARS = 16_384;
13
13
  const MAX_STRUCTURED_DATA_TOTAL_CHARS = 32_768;
@@ -15,11 +15,9 @@ const MAX_STRUCTURED_DATA_DEPTH = 16;
15
15
  const MAX_STRUCTURED_DATA_OBJECTS = 256;
16
16
  const MAX_STRUCTURED_DATA_ARRAY_ITEMS = 256;
17
17
  const MAX_STRUCTURED_DATA_PROPERTIES = 64;
18
- const MAX_STRUCTURED_DATA_ENTITIES = 16;
19
18
  const MAX_STRUCTURED_DATA_TYPES = 8;
20
19
  const MAX_STRUCTURED_DATA_NAME_LENGTH = 160;
21
20
  const MAX_STRUCTURED_DATA_URL_LENGTH = 2_048;
22
- const MAX_STRUCTURED_DATA_SAME_AS_HOSTS = 12;
23
21
  const CONTROL_AND_DIRECTIONAL_RE = /[\u0000-\u001f\u007f-\u009f]|\p{Default_Ignorable_Code_Point}/u;
24
22
  const CONTROL_AND_DIRECTIONAL_GLOBAL_RE = /[\u0000-\u001f\u007f-\u009f]|\p{Default_Ignorable_Code_Point}/gu;
25
23
  const HOSTNAME_RE = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/iu;
@@ -0,0 +1,51 @@
1
+ const TECHNOLOGY_EVIDENCE_ROLE_ORDER = Object.freeze([
2
+ 'observed_edge',
3
+ 'application_platform',
4
+ 'framework_runtime',
5
+ 'embedded_dependency',
6
+ ]);
7
+ const TECHNOLOGY_EVIDENCE_ROLE_SET = new Set(TECHNOLOGY_EVIDENCE_ROLE_ORDER);
8
+ function record(value) {
9
+ return value && typeof value === 'object' && !Array.isArray(value)
10
+ ? value
11
+ : {};
12
+ }
13
+ function technologyEvidenceRoles(value) {
14
+ const finding = record(value);
15
+ const roles = new Set();
16
+ let hasExplicitRole = false;
17
+ for (const role of Array.isArray(finding.roles) ? finding.roles.slice(0, 4) : []) {
18
+ if (typeof role === 'string' && TECHNOLOGY_EVIDENCE_ROLE_SET.has(role)) {
19
+ roles.add(role);
20
+ hasExplicitRole = true;
21
+ }
22
+ }
23
+ const evidenceValues = Array.isArray(finding.evidence) ? finding.evidence.slice(0, 4) : [];
24
+ for (const evidenceValue of evidenceValues) {
25
+ const evidence = record(evidenceValue);
26
+ if (typeof evidence.role === 'string'
27
+ && TECHNOLOGY_EVIDENCE_ROLE_SET.has(evidence.role)) {
28
+ roles.add(evidence.role);
29
+ hasExplicitRole = true;
30
+ }
31
+ }
32
+ if (!hasExplicitRole) {
33
+ const resourceOriginOnly = evidenceValues.length > 0
34
+ && evidenceValues.every((evidence) => (String(record(evidence).source).toLowerCase() === 'resource origin'));
35
+ if (evidenceValues.some((evidence) => String(record(evidence).source).toLowerCase() === 'resource origin')) {
36
+ roles.add('embedded_dependency');
37
+ }
38
+ if (!resourceOriginOnly) {
39
+ const category = String(finding.category).toLowerCase();
40
+ if (category === 'delivery platform')
41
+ roles.add('observed_edge');
42
+ else if (['application runtime', 'web framework', 'static site generator', 'web server'].includes(category)) {
43
+ roles.add('framework_runtime');
44
+ }
45
+ else
46
+ roles.add('application_platform');
47
+ }
48
+ }
49
+ return TECHNOLOGY_EVIDENCE_ROLE_ORDER.filter((role) => roles.has(role));
50
+ }
51
+ export { TECHNOLOGY_EVIDENCE_ROLE_ORDER, technologyEvidenceRoles, };
@@ -4,7 +4,7 @@
4
4
  // must declare exact input exposure, reviewed terms, and bounded request and
5
5
  // output budgets before producing separately attributed normalized evidence.
6
6
  import { createObservation } from "../packages/evidence/observation.mjs";
7
- import { THREAT_INTELLIGENCE_CONTRACT_VERSION, THREAT_INTELLIGENCE_CATEGORIES, THREAT_INTELLIGENCE_ENVELOPE_VERSION, THREAT_INTELLIGENCE_RESULT_STATES, THREAT_INTELLIGENCE_SCHEMA, } from "./threat-intelligence-types.mjs";
7
+ import { THREAT_INTELLIGENCE_CONTRACT_VERSION, THREAT_INTELLIGENCE_CATEGORIES, THREAT_INTELLIGENCE_CONFIDENCES, THREAT_INTELLIGENCE_ENVELOPE_VERSION, THREAT_INTELLIGENCE_RESULT_STATES, THREAT_INTELLIGENCE_SCHEMA, THREAT_INTELLIGENCE_SEVERITIES, } from "./threat-intelligence-types.mjs";
8
8
  import { assertCuratedConnectorDefinition, assertThreatIntelligenceProvider, } from "./threat-intelligence-definition-registry.mjs";
9
9
  import { boundedHttpsUrl, boundedString, isRecord, isoTimestamp, strictBoundedString, } from "./bounded-contract-normalizers.mjs";
10
10
  import { CURATED_CONNECTOR_ENTITY_TYPES, MAX_THREAT_INTELLIGENCE_URL_LENGTH, normalizeCuratedConnectorTarget, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-targets.mjs";
@@ -20,8 +20,8 @@ const TERMINAL_STATES_WITHOUT_FINDINGS = new Set([
20
20
  'error',
21
21
  ]);
22
22
  const CATEGORIES = new Set(THREAT_INTELLIGENCE_CATEGORIES);
23
- const SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'unknown']);
24
- const CONFIDENCES = new Set(['high', 'medium', 'low', 'unknown']);
23
+ const SEVERITIES = new Set(THREAT_INTELLIGENCE_SEVERITIES);
24
+ const CONFIDENCES = new Set(THREAT_INTELLIGENCE_CONFIDENCES);
25
25
  const CONNECTOR_RELATIONSHIP_TYPES = new Set([
26
26
  'domain_resolves_to_ip',
27
27
  'domain_uses_nameserver',
@@ -11,6 +11,19 @@ export const THREAT_INTELLIGENCE_CATEGORIES = Object.freeze([
11
11
  'abuse',
12
12
  'unknown',
13
13
  ]);
14
+ export const THREAT_INTELLIGENCE_SEVERITIES = Object.freeze([
15
+ 'critical',
16
+ 'high',
17
+ 'medium',
18
+ 'low',
19
+ 'unknown',
20
+ ]);
21
+ export const THREAT_INTELLIGENCE_CONFIDENCES = Object.freeze([
22
+ 'high',
23
+ 'medium',
24
+ 'low',
25
+ 'unknown',
26
+ ]);
14
27
  export const THREAT_INTELLIGENCE_RESULT_STATES = Object.freeze([
15
28
  'success',
16
29
  'partial',
@@ -7,6 +7,7 @@ import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
7
7
  import { providerPolicyAdmission } from "./provider-policy-admission.mjs";
8
8
  import { createThreatIntelligenceResult, defineThreatIntelligenceProvider, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-contract.mjs";
9
9
  import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
10
+ import { isRecord } from "./json-record.mjs";
10
11
  const THREATFOX_SEARCH_ENDPOINT = 'https://threatfox-api.abuse.ch/api/v1/';
11
12
  const THREATFOX_MAX_RESULTS = 20;
12
13
  const THREATFOX_MAX_INPUT_RESULTS = 100;
@@ -39,9 +40,6 @@ const THREATFOX_PROVIDER = defineThreatIntelligenceProvider({
39
40
  monthlyRequests: THREATFOX_MONTHLY_REQUESTS,
40
41
  },
41
42
  });
42
- function isRecord(value) {
43
- return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
44
- }
45
43
  function enabledValue(value) {
46
44
  return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase());
47
45
  }
@@ -11,10 +11,9 @@ import * as net from 'node:net';
11
11
  import * as tls from 'node:tls';
12
12
  import { domainToASCII } from 'node:url';
13
13
  import { createObservation } from "../packages/evidence/observation.mjs";
14
- import { MAX_LOOKUP_TLS_ALT_NAMES, MAX_LOOKUP_TLS_CHAIN_CERTIFICATES, MAX_LOOKUP_TLS_FINDINGS, MAX_LOOKUP_TLS_NAME_VALUES, } from "./lookup-network-evidence-bounds.mjs";
14
+ import { MAX_LOOKUP_TLS_ALT_NAMES, MAX_LOOKUP_TLS_CHAIN_CERTIFICATES, MAX_LOOKUP_TLS_FINDINGS, MAX_LOOKUP_TLS_NAME_VALUES, TLS_PROFILE_VERSION, } from "./lookup-network-evidence-bounds.mjs";
15
15
  import { isPrivateAddress, resolvePublicAddresses } from "./safe-fetch.mjs";
16
16
  import { parseCertificateExtensionProfile, } from "./certificate-extension-profile.mjs";
17
- const TLS_PROFILE_VERSION = 3;
18
17
  const TLS_PORT = 443;
19
18
  const TLS_TIMEOUT_MS = 5000;
20
19
  const MAX_RESOLVED_ADDRESSES = 64;
@@ -10,6 +10,7 @@ import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
10
10
  import { providerPolicyAdmission } from "./provider-policy-admission.mjs";
11
11
  import { createThreatIntelligenceResult, defineThreatIntelligenceProvider, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-contract.mjs";
12
12
  import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
13
+ import { isRecord } from "./json-record.mjs";
13
14
  const URLHAUS_HOST_ENDPOINT = 'https://urlhaus-api.abuse.ch/v1/host/';
14
15
  const URLHAUS_MAX_RESULTS = 20;
15
16
  const URLHAUS_MAX_INPUT_RESULTS = 100;
@@ -46,9 +47,6 @@ const URLHAUS_PROVIDER = defineThreatIntelligenceProvider({
46
47
  monthlyRequests: URLHAUS_MONTHLY_REQUESTS,
47
48
  },
48
49
  });
49
- function isRecord(value) {
50
- return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
51
- }
52
50
  function enabledValue(value) {
53
51
  return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase());
54
52
  }
@@ -8,6 +8,7 @@ import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
8
8
  import { providerPolicyAdmission } from "./provider-policy-admission.mjs";
9
9
  import { createThreatIntelligenceResult, defineThreatIntelligenceProvider, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-contract.mjs";
10
10
  import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
11
+ import { isRecord } from "./json-record.mjs";
11
12
  const URLSCAN_SEARCH_ENDPOINT = 'https://urlscan.io/api/v1/search/';
12
13
  const URLSCAN_SEARCH_DAYS = 90;
13
14
  const URLSCAN_MAX_RESULTS = 20;
@@ -42,9 +43,6 @@ const URLSCAN_PROVIDER = defineThreatIntelligenceProvider({
42
43
  monthlyRequests: URLSCAN_MONTHLY_REQUESTS,
43
44
  },
44
45
  });
45
- function isRecord(value) {
46
- return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
47
- }
48
46
  function enabledValue(value) {
49
47
  return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase());
50
48
  }
@@ -3,8 +3,7 @@
3
3
  // Findings use fixed explanatory text and bounded counts; upstream header
4
4
  // values, certificate errors, URLs, and DNS record contents are not copied.
5
5
  import { createObservation } from "../packages/evidence/observation.mjs";
6
- const WEBSITE_SECURITY_POSTURE_VERSION = 2;
7
- const MAX_SECURITY_POSTURE_FINDINGS = 32;
6
+ import { MAX_SECURITY_POSTURE_FINDINGS, WEBSITE_SECURITY_POSTURE_VERSION, } from "./lookup-child-profile-contract.mjs";
8
7
  const MAX_RETAINED_ORIGINS = 30;
9
8
  function record(value) {
10
9
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
@@ -7,16 +7,14 @@
7
7
  import { analyzeBrowserLibraries } from "./browser-library-profile.mjs";
8
8
  import { createObservation } from "../packages/evidence/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 = 10;
10
+ import { TECHNOLOGY_EVIDENCE_ROLE_ORDER, } from "./technology-evidence-role.mjs";
11
+ import { MAX_EVIDENCE_PER_TECHNOLOGY, MAX_TECHNOLOGY_EVIDENCE_DESCRIPTION_LENGTH, MAX_TECHNOLOGY_FINDINGS, TECHNOLOGY_PROFILE_VERSION, } from "./lookup-child-profile-contract.mjs";
11
12
  const MAX_TECHNOLOGY_HTML_CHARS = MAX_STATIC_HTML_CHARS;
12
13
  const MAX_TECHNOLOGY_TAG_LENGTH = MAX_TAG_LENGTH;
13
- const MAX_TECHNOLOGY_FINDINGS = 24;
14
- const MAX_EVIDENCE_PER_TECHNOLOGY = 4;
15
14
  const MAX_RESOURCE_ORIGINS = 30;
16
15
  const MAX_GENERATOR_INPUT = 160;
17
16
  const MAX_SERVER_INPUT = 240;
18
17
  const MAX_PASSIVE_RESPONSE_HEADERS = 8;
19
- const MAX_TECHNOLOGY_EVIDENCE_DESCRIPTION_LENGTH = 180;
20
18
  const CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/;
21
19
  function boundedLowercase(value, maxLength) {
22
20
  if (typeof value !== 'string' || value.length > maxLength || CONTROL_CHARACTER_RE.test(value))
@@ -51,14 +49,37 @@ const PASSIVE_TECHNOLOGY_HEADER_NAMES = Object.freeze([
51
49
  'x-vercel-id',
52
50
  ]);
53
51
  const PASSIVE_HEADER_NAMES = new Set(PASSIVE_TECHNOLOGY_HEADER_NAMES);
54
- function normalizedResponseHeaders(value) {
55
- const input = value && typeof value === 'object' && !Array.isArray(value)
52
+ function technologyHeaderValue(value) {
53
+ if (typeof value !== 'string' || value.length > 240 || CONTROL_CHARACTER_RE.test(value))
54
+ return '';
55
+ return value.trim();
56
+ }
57
+ function captureTechnologyResponseHeaders(value) {
58
+ const output = {};
59
+ const getter = value && typeof value === 'object' && typeof value.get === 'function'
60
+ ? value.get.bind(value)
61
+ : null;
62
+ const record = value && typeof value === 'object' && !Array.isArray(value)
56
63
  ? value
57
- : {};
64
+ : null;
65
+ for (const name of PASSIVE_TECHNOLOGY_HEADER_NAMES) {
66
+ let rawValue;
67
+ try {
68
+ rawValue = getter ? getter(name) : record?.[name];
69
+ }
70
+ catch {
71
+ rawValue = undefined;
72
+ }
73
+ const headerValue = technologyHeaderValue(rawValue);
74
+ if (headerValue)
75
+ output[name] = headerValue;
76
+ }
77
+ return output;
78
+ }
79
+ function normalizedResponseHeaders(value) {
58
80
  const output = new Map();
59
- for (const [rawName, rawValue] of Object.entries(input).slice(0, MAX_PASSIVE_RESPONSE_HEADERS * 4)) {
60
- const name = rawName.trim().toLowerCase();
61
- if (!PASSIVE_HEADER_NAMES.has(name) || typeof rawValue !== 'string')
81
+ for (const [name, rawValue] of Object.entries(captureTechnologyResponseHeaders(value))) {
82
+ if (!PASSIVE_HEADER_NAMES.has(name))
62
83
  continue;
63
84
  const normalized = boundedLowercase(rawValue, 240);
64
85
  if (!normalized)
@@ -121,6 +142,18 @@ function responseHeaderEvidence(name, pattern, description, confidence = 'high')
121
142
  },
122
143
  };
123
144
  }
145
+ function evidenceRole(signature, evidence) {
146
+ if (evidence.role)
147
+ return evidence.role;
148
+ if (evidence.source === 'resource origin')
149
+ return 'embedded_dependency';
150
+ if (signature.category === 'delivery platform')
151
+ return 'observed_edge';
152
+ if (['application runtime', 'web framework', 'static site generator', 'web server'].includes(signature.category)) {
153
+ return 'framework_runtime';
154
+ }
155
+ return 'application_platform';
156
+ }
124
157
  const TECHNOLOGY_SIGNATURES = [
125
158
  {
126
159
  id: 'wordpress', name: 'WordPress', category: 'content management',
@@ -325,14 +358,14 @@ const TECHNOLOGY_SIGNATURES = [
325
358
  id: 'netlify', name: 'Netlify', category: 'delivery platform',
326
359
  evidence: [
327
360
  serverEvidence(/^netlify(?:\s|$|\/)/i, 'The selected response server header identifies Netlify.'),
328
- responseHeaderEvidence('x-nf-request-id', null, 'A Netlify request identifier response header was observed.', 'medium'),
361
+ { ...responseHeaderEvidence('x-nf-request-id', null, 'A Netlify application-platform response header was observed.', 'medium'), role: 'application_platform' },
329
362
  ],
330
363
  },
331
364
  {
332
365
  id: 'vercel', name: 'Vercel', category: 'delivery platform',
333
366
  evidence: [
334
367
  serverEvidence(/^vercel(?:\s|$|\/)/i, 'The selected response server header identifies Vercel.'),
335
- responseHeaderEvidence('x-vercel-id', null, 'A Vercel request-trace response header was observed.', 'medium'),
368
+ { ...responseHeaderEvidence('x-vercel-id', null, 'A Vercel application-platform response header was observed.', 'medium'), role: 'application_platform' },
336
369
  ],
337
370
  },
338
371
  {
@@ -366,19 +399,26 @@ const TECHNOLOGY_SIGNATURE_CATALOGUE = Object.freeze(TECHNOLOGY_SIGNATURES.map((
366
399
  category: signature.category,
367
400
  minimumEvidenceMatches: signature.minimumEvidenceMatches || 1,
368
401
  requiresNonResourceEvidence: signature.requiresNonResourceEvidence === true,
369
- evidence: Object.freeze(signature.evidence.map(({ source, description, confidence }) => Object.freeze({
370
- source,
371
- description,
372
- confidence,
402
+ evidence: Object.freeze(signature.evidence.map((evidence) => Object.freeze({
403
+ source: evidence.source,
404
+ role: evidenceRole(signature, evidence),
405
+ description: evidence.description,
406
+ confidence: evidence.confidence,
373
407
  }))),
374
408
  })));
375
409
  function analyzeWebsiteTechnology(input = {}) {
410
+ // Existing direct callers pass minimised, already-derived page evidence rather
411
+ // than the page body. The real collector declares false for header-only
412
+ // responses, while an explicit malformed declaration also fails closed.
413
+ const htmlAvailable = input.htmlAvailable === undefined
414
+ ? true
415
+ : input.htmlAvailable === true;
376
416
  const htmlAnalysis = input.htmlAnalysis ?? analyzeStaticHtml(input.html);
377
- const browserLibraryProfile = analyzeBrowserLibraries({
417
+ const browserLibraryProfile = htmlAvailable ? analyzeBrowserLibraries({
378
418
  htmlAnalysis,
379
419
  observedAt: input.observedAt,
380
420
  sourceTruncated: input.sourceTruncated,
381
- });
421
+ }) : null;
382
422
  const context = {
383
423
  html: htmlAnalysis.markup,
384
424
  generator: boundedLowercase(input.generator, MAX_GENERATOR_INPUT),
@@ -393,12 +433,18 @@ function analyzeWebsiteTechnology(input = {}) {
393
433
  continue;
394
434
  if (signature.requiresNonResourceEvidence && matched.every((evidence) => evidence.source === 'resource origin'))
395
435
  continue;
436
+ const matchedEvidence = matched.slice(0, MAX_EVIDENCE_PER_TECHNOLOGY).map((evidence) => ({
437
+ source: evidence.source,
438
+ role: evidenceRole(signature, evidence),
439
+ description: evidence.description,
440
+ }));
396
441
  findings.push({
397
442
  id: signature.id,
398
443
  name: signature.name,
399
444
  category: signature.category,
400
445
  confidence: matched.some((evidence) => evidence.confidence === 'high') ? 'high' : 'medium',
401
- evidence: matched.slice(0, MAX_EVIDENCE_PER_TECHNOLOGY).map(({ source, description }) => ({ source, description })),
446
+ roles: TECHNOLOGY_EVIDENCE_ROLE_ORDER.filter((role) => matchedEvidence.some((evidence) => evidence.role === role)),
447
+ evidence: matchedEvidence,
402
448
  });
403
449
  }
404
450
  findings.sort((left, right) => left.category.localeCompare(right.category) || left.name.localeCompare(right.name));
@@ -407,11 +453,14 @@ function analyzeWebsiteTechnology(input = {}) {
407
453
  || htmlAnalysis.inputLimitReached
408
454
  || htmlAnalysis.tagLimitReached
409
455
  || findingLimitReached;
456
+ const partial = truncated || !htmlAvailable;
410
457
  const limitations = [
411
458
  'Curated signature matching is selective; an unmatched technology may still be present.',
412
459
  'Static response evidence cannot identify JavaScript-rendered or deliberately concealed technologies.',
413
460
  'Technology indicators describe observed implementation clues, not ownership, safety, or maliciousness.',
414
461
  ];
462
+ if (!htmlAvailable)
463
+ limitations.push('HTML page identity was unavailable; only bounded permitted response-header indicators were evaluated.');
415
464
  if (input.sourceTruncated === true)
416
465
  limitations.push('The captured homepage body was truncated, so technology indicators may be incomplete.');
417
466
  if (htmlAnalysis.inputLimitReached)
@@ -423,16 +472,16 @@ function analyzeWebsiteTechnology(input = {}) {
423
472
  return {
424
473
  profileVersion: TECHNOLOGY_PROFILE_VERSION,
425
474
  ...createObservation({
426
- status: truncated ? 'partial' : 'success',
475
+ status: partial ? 'partial' : 'success',
427
476
  observedAt: input.observedAt,
428
477
  scanMode: 'deep',
429
478
  source: 'derived',
430
- complete: !truncated,
479
+ complete: !partial,
431
480
  truncated,
432
481
  limitations,
433
482
  diagnostics: {
434
483
  findings: findings.length,
435
- htmlEvaluated: Boolean(context.html),
484
+ htmlEvaluated: htmlAvailable,
436
485
  generatorEvaluated: Boolean(context.generator),
437
486
  serverEvaluated: Boolean(context.httpServer),
438
487
  resourceOriginsEvaluated: context.resourceHosts.size,
@@ -444,4 +493,4 @@ function analyzeWebsiteTechnology(input = {}) {
444
493
  browserLibraryProfile,
445
494
  };
446
495
  }
447
- 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, };
496
+ 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, captureTechnologyResponseHeaders, };