@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.
- package/README.md +32 -2
- package/bin/whoisleuth.mjs +52 -24
- package/cli/archive-inspect.mjs +37 -2
- package/cli/arguments.mjs +493 -157
- package/cli/artifact-verify.mjs +91 -15
- package/cli/bulk-checkpoint.mjs +7 -11
- package/cli/bulk-command-runner.mjs +19 -2
- package/cli/case-pack.mjs +136 -0
- package/cli/ci-report.mjs +73 -0
- package/cli/collection-preflight.mjs +59 -0
- package/cli/command-reference.mjs +395 -0
- package/cli/compare.mjs +1 -1
- package/cli/completion.mjs +49 -11
- package/cli/config-profile.mjs +180 -0
- package/cli/ct-event-intake.mjs +159 -0
- package/cli/discovery-scan-command-runner.mjs +15 -1
- package/cli/doctor.mjs +11 -5
- package/cli/domain-control-monitor.mjs +104 -0
- package/cli/domain-control-observations.mjs +235 -0
- package/cli/evidence-command-arguments.mjs +12 -1
- package/cli/evidence-command-runner.mjs +3 -2
- package/cli/evidence-signing.mjs +4 -4
- package/cli/external-observation-mapping.mjs +162 -0
- package/cli/fail-policy.mjs +64 -0
- package/cli/formatters/html.mjs +2 -2
- package/cli/formatters/json.mjs +13 -1
- package/cli/formatters/terminal.mjs +7 -3
- package/cli/investigation-manifest.mjs +113 -0
- package/cli/investigation-plan.mjs +2 -2
- package/cli/investigation-run.mjs +112 -0
- package/cli/local-mmdb-review.mjs +76 -0
- package/cli/lookalike-calibration.mjs +121 -0
- package/cli/lookup-brief.mjs +116 -0
- package/cli/lookup-command-runner.mjs +12 -2
- package/cli/lookup-diff.mjs +17 -19
- package/cli/lookup-plan.mjs +3 -3
- package/cli/lookup-reconcile.mjs +141 -0
- package/cli/lookup-timeline.mjs +1 -1
- package/cli/mail-review.mjs +1 -1
- package/cli/network-command-runner.mjs +4 -1
- package/cli/offline-evidence-review.mjs +125 -10
- package/cli/open-asset-model-bridge.mjs +169 -0
- package/cli/registry-cohort.mjs +121 -0
- package/cli/registry-doctor.mjs +220 -0
- package/cli/registry-fixture-scaffold.mjs +73 -0
- package/cli/risk-calibration.mjs +117 -9
- package/cli/runner.mjs +561 -280
- package/cli/saved-lookup.mjs +2 -2
- package/cli/sharing-review.mjs +202 -0
- package/cli/source-reliability.mjs +1 -1
- package/docs/cli-reference.md +1336 -0
- package/docs/cli.md +259 -940
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +7 -7
- package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
- package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
- package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
- package/frontend/src/lib/analysis/case-migration-model.js +6 -1
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-core.js +5 -1
- package/frontend/src/lib/analysis/case-record-operations.js +6 -1
- package/frontend/src/lib/analysis/case-report.js +573 -0
- package/frontend/src/lib/analysis/case-response-model.js +30 -1
- package/frontend/src/lib/analysis/case-response-packet.js +7 -7
- package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
- package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
- package/frontend/src/lib/analysis/external-findings-import.js +320 -0
- package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
- package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +487 -0
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
- package/frontend/src/lib/analysis/lookup-presentation.js +64 -0
- package/frontend/src/lib/analysis/lookup-response.js +2 -0
- package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
- package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
- package/frontend/src/lib/analysis/page-similarity.js +1 -1
- package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
- package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
- package/frontend/src/lib/analysis/scoring.js +9 -89
- package/frontend/src/lib/analysis/shortlist-model.js +8 -4
- package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
- package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
- package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
- package/frontend/src/lib/analysis/visualization-models.js +369 -0
- package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
- package/frontend/src/lib/analysis/workspace-archive.js +4 -2
- package/lib/abusech-auth.mjs +1 -1
- package/lib/analyst-taxonomy.mjs +40 -0
- package/lib/availability.mjs +2 -2
- package/lib/bounded-contract-normalizers.mjs +33 -0
- package/lib/browser-library-profile.mjs +15 -2
- package/lib/certificate-extension-profile.mjs +71 -0
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/dns-convergence-review.mjs +183 -0
- package/lib/dns-delegation-health.mjs +196 -3
- package/lib/domain-assurance.mjs +368 -0
- package/lib/domain-change-packet.mjs +133 -0
- package/lib/domain-change-review.mjs +307 -0
- package/lib/domain-control-flight-recorder.mjs +268 -0
- package/lib/domain-control-manifest.mjs +50 -7
- package/lib/domain-portfolio-review.mjs +151 -0
- package/lib/domain-posture.mjs +9 -9
- package/lib/encrypted-dns-contract.mjs +1 -1
- package/lib/evidence-export.mjs +1 -1
- package/lib/evidence-report-markdown.mjs +1 -1
- package/lib/evidence-report.mjs +1 -1
- package/lib/generated/cisa-kev-catalog.mjs +1668 -0
- package/lib/html-signals.mjs +5 -11
- package/lib/ip-prefix.mjs +34 -1
- package/lib/local-geoip-evidence.mjs +2 -2
- package/lib/lookup-response-contract.mjs +346 -0
- package/lib/nameserver-preflight-review.mjs +159 -0
- package/lib/observed-network-context.mjs +1 -1
- package/lib/opportunity-scoring.mjs +107 -0
- package/lib/page-fingerprints.mjs +1 -1
- package/lib/page-language-signals.mjs +124 -0
- package/lib/rdap-capabilities.mjs +6 -6
- package/lib/rdap-search-workbench.mjs +96 -6
- package/lib/registry-access.mjs +1 -1
- package/lib/registry-capability-catalogue.mjs +4 -4
- package/lib/registry-insights.mjs +6 -6
- package/lib/risk-scoring.mjs +247 -83
- package/lib/scoring-evidence-quality.mjs +107 -0
- package/lib/security-txt.mjs +3 -3
- package/lib/static-html-analysis.mjs +29 -1
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threatfox-intelligence.mjs +1 -1
- package/lib/tls-intelligence.mjs +13 -3
- package/lib/trust-store-comparison.mjs +146 -0
- package/lib/urlhaus-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +5 -5
- package/lib/website-technology.mjs +25 -11
- package/lib/whois-chain.mjs +1 -1
- package/lib/zone-intent-review.mjs +534 -0
- package/package.json +5 -1
- package/third-party-notices.txt +911 -19
package/lib/tls-intelligence.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
'
|
|
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
|
|
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
|
|
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
|
|
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
|
|
227
|
-
: finding('certificate_authorization_unavailable', 'certificate', 'unavailable', 'neutral', 'Certificate
|
|
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
|
|
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 =
|
|
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
|
|
43
|
+
const PASSIVE_TECHNOLOGY_HEADER_NAMES = Object.freeze([
|
|
44
44
|
'cf-ray',
|
|
45
45
|
'x-drupal-cache',
|
|
46
|
-
'x-
|
|
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: [
|
|
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
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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-
|
|
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, };
|
package/lib/whois-chain.mjs
CHANGED
|
@@ -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
|
|
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
|
}
|