@slicedearth/whoisleuth-cli 1.34.0 → 1.39.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -2
- package/bin/whoisleuth.mjs +41 -24
- package/cli/arguments.mjs +326 -3
- package/cli/artifact-verify.mjs +60 -14
- package/cli/bulk.mjs +1 -2
- package/cli/compare.mjs +1 -1
- package/cli/completion.mjs +36 -2
- package/cli/doctor.mjs +11 -5
- package/cli/evidence-command-arguments.mjs +1 -1
- package/cli/evidence-command-runner.mjs +2 -2
- package/cli/evidence-signing.mjs +4 -4
- package/cli/export-evidence.mjs +9 -2
- package/cli/formatters/html.mjs +1 -1
- package/cli/formatters/json.mjs +13 -1
- package/cli/formatters/terminal.mjs +14 -4
- package/cli/investigation-plan.mjs +112 -0
- package/cli/local-mmdb-review.mjs +76 -0
- package/cli/lookalike-calibration.mjs +121 -0
- package/cli/lookup-command-runner.mjs +4 -1
- 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 +48 -3
- package/cli/offline-evidence-review.mjs +159 -0
- package/cli/registry-doctor.mjs +125 -0
- package/cli/registry-support.mjs +1 -4
- package/cli/risk-calibration.mjs +117 -9
- package/cli/runner.mjs +342 -15
- 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 +1110 -0
- package/docs/cli.md +224 -847
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +6 -6
- 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-retry-plan.js +3 -10
- package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
- package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
- 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-response-packet.js +7 -7
- package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
- package/frontend/src/lib/analysis/http-summary.js +2 -1
- 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 +480 -0
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
- package/frontend/src/lib/analysis/lookup-presentation.js +74 -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 +8 -88
- package/frontend/src/lib/analysis/shortlist-model.js +8 -4
- package/frontend/src/lib/analysis/utils.js +7 -40
- 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 +370 -0
- package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
- package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
- package/frontend/src/lib/analysis/workspace-archive.js +2 -2
- package/lib/analyst-taxonomy.mjs +40 -0
- package/lib/availability.mjs +30 -10
- package/lib/browser-library-profile.mjs +15 -2
- package/lib/bulk-limits.mjs +13 -0
- package/lib/certificate-extension-profile.mjs +71 -0
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/ct-response-bounds.mjs +9 -0
- package/lib/ct-search.mjs +66 -12
- package/lib/dns-delegation-health.mjs +56 -5
- package/lib/dnssec-evidence-validation.mjs +235 -0
- package/lib/domain-assurance.mjs +368 -0
- package/lib/domain-control-manifest.mjs +382 -0
- package/lib/domain-posture.mjs +11 -10
- package/lib/encrypted-dns-contract.mjs +81 -0
- package/lib/evidence-export.mjs +17 -3
- package/lib/evidence-report-markdown.mjs +1 -1
- package/lib/evidence-report.mjs +4 -1
- package/lib/favicon.mjs +3 -8
- package/lib/generated/cisa-kev-catalog.mjs +1668 -0
- package/lib/html-signals.mjs +4 -0
- package/lib/http-evidence-bounds.mjs +4 -0
- package/lib/http-intelligence.mjs +2 -1
- package/lib/ip-prefix.mjs +87 -0
- package/lib/local-geoip-evidence.mjs +89 -0
- package/lib/lookup-response-contract.mjs +346 -0
- package/lib/observed-network-context.mjs +1 -1
- package/lib/opportunity-scoring.mjs +107 -0
- package/lib/outbound-identity.mjs +9 -0
- package/lib/outbound-request-bounds.mjs +9 -0
- package/lib/page-fingerprints.mjs +1 -1
- package/lib/perceptual-hash-comparison.mjs +34 -0
- package/lib/perceptual-hash.mjs +3 -31
- package/lib/project-metadata.mjs +8 -0
- package/lib/provider-policy-admission.mjs +49 -0
- package/lib/rdap-capabilities.mjs +6 -6
- package/lib/rdap-search-workbench.mjs +247 -0
- package/lib/rdap.mjs +7 -2
- package/lib/registry-access.mjs +1 -1
- package/lib/registry-capabilities.mjs +31 -1
- package/lib/registry-capability-catalogue.mjs +15 -14
- package/lib/registry-insights.mjs +6 -6
- package/lib/response-policy.mjs +81 -6
- package/lib/risk-scoring.mjs +206 -83
- package/lib/rpki-evidence.mjs +105 -0
- package/lib/safe-fetch.mjs +2 -1
- package/lib/scoring-evidence-quality.mjs +107 -0
- package/lib/security-txt.mjs +3 -3
- package/lib/semantic-version.mjs +54 -0
- package/lib/static-html-analysis.mjs +27 -0
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threatfox-intelligence.mjs +11 -6
- package/lib/tls-intelligence.mjs +13 -3
- package/lib/tlsa-evidence.mjs +194 -0
- package/lib/urlhaus-intelligence.mjs +11 -6
- package/lib/urlscan-intelligence.mjs +10 -5
- package/lib/website-security-posture.mjs +16 -9
- package/lib/website-technology.mjs +1 -1
- package/lib/whois-chain.mjs +14 -152
- package/lib/whois.mjs +2 -2
- package/package.json +6 -2
- package/third-party-notices.txt +911 -19
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/gu;
|
|
2
|
+
const MAX_NODES = 72;
|
|
3
|
+
const MAX_EDGES = 120;
|
|
4
|
+
const MAX_VALUES = 16;
|
|
5
|
+
const MAX_LIMITATIONS = 5;
|
|
6
|
+
const MAX_VISUAL_EDGES_PER_HUB = 10;
|
|
7
|
+
function projectedNodeGroup(node, edges) {
|
|
8
|
+
if (node.kind === 'target')
|
|
9
|
+
return { id: 'focus', label: 'Lookup target' };
|
|
10
|
+
if (node.kind === 'address' || node.kind === 'network' || node.kind === 'prefix') {
|
|
11
|
+
return { id: 'network', label: 'Network' };
|
|
12
|
+
}
|
|
13
|
+
if (node.kind === 'registrar')
|
|
14
|
+
return { id: 'registration', label: 'Registration' };
|
|
15
|
+
if (node.kind === 'certificate' || node.kind === 'issuer' || node.kind === 'key') {
|
|
16
|
+
return { id: 'certificate', label: 'Certificates' };
|
|
17
|
+
}
|
|
18
|
+
if (node.kind === 'observation')
|
|
19
|
+
return { id: 'delegation', label: 'Delegation' };
|
|
20
|
+
if (node.kind === 'identity' || node.kind === 'origin' || node.kind === 'tracker') {
|
|
21
|
+
return { id: 'identity', label: 'Web identity' };
|
|
22
|
+
}
|
|
23
|
+
if (node.kind === 'hostname') {
|
|
24
|
+
const incident = edges.filter((edge) => edge.source === node.id || edge.target === node.id);
|
|
25
|
+
if (incident.some((edge) => edge.kind === 'authorizes-name')) {
|
|
26
|
+
return { id: 'certificate', label: 'Certificates' };
|
|
27
|
+
}
|
|
28
|
+
if (incident.some((edge) => edge.lenses.includes('identity') && !edge.lenses.includes('delegation'))) {
|
|
29
|
+
return { id: 'identity', label: 'Web identity' };
|
|
30
|
+
}
|
|
31
|
+
return { id: 'dns', label: 'DNS and routing' };
|
|
32
|
+
}
|
|
33
|
+
if (node.kind === 'summary')
|
|
34
|
+
return { id: 'summary', label: 'Grouped evidence' };
|
|
35
|
+
return { id: 'evidence', label: 'Other evidence' };
|
|
36
|
+
}
|
|
37
|
+
function interleaveVisualEdgesByFamily(edges, nodesById) {
|
|
38
|
+
const buckets = new Map();
|
|
39
|
+
for (const edge of edges) {
|
|
40
|
+
const endpointGroups = [nodesById.get(edge.source), nodesById.get(edge.target)]
|
|
41
|
+
.filter((node) => Boolean(node))
|
|
42
|
+
.map((node) => projectedNodeGroup(node, edges).id)
|
|
43
|
+
.filter((group) => group !== 'focus')
|
|
44
|
+
.sort();
|
|
45
|
+
const family = [...new Set(endpointGroups)].join('+') || 'evidence';
|
|
46
|
+
const bucket = buckets.get(family) ?? [];
|
|
47
|
+
bucket.push(edge);
|
|
48
|
+
buckets.set(family, bucket);
|
|
49
|
+
}
|
|
50
|
+
const families = [...buckets.keys()].sort();
|
|
51
|
+
const ordered = [];
|
|
52
|
+
for (let offset = 0; ordered.length < edges.length; offset += 1) {
|
|
53
|
+
let appended = false;
|
|
54
|
+
for (const family of families) {
|
|
55
|
+
const edge = buckets.get(family)?.[offset];
|
|
56
|
+
if (!edge)
|
|
57
|
+
continue;
|
|
58
|
+
ordered.push(edge);
|
|
59
|
+
appended = true;
|
|
60
|
+
}
|
|
61
|
+
if (!appended)
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
return ordered;
|
|
65
|
+
}
|
|
66
|
+
function edgeMatchesLens(edge, lens) {
|
|
67
|
+
return lens === 'all'
|
|
68
|
+
? edge.lenses.includes('all') || edge.lenses.length > 0
|
|
69
|
+
: edge.lenses.includes(lens);
|
|
70
|
+
}
|
|
71
|
+
function record(value) {
|
|
72
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
73
|
+
? value
|
|
74
|
+
: {};
|
|
75
|
+
}
|
|
76
|
+
function records(value, maximum = MAX_VALUES) {
|
|
77
|
+
return Array.isArray(value)
|
|
78
|
+
? value.slice(0, maximum).map(record).filter((item) => Object.keys(item).length > 0)
|
|
79
|
+
: [];
|
|
80
|
+
}
|
|
81
|
+
function text(value, maximum = 240) {
|
|
82
|
+
return String(value ?? '')
|
|
83
|
+
.replace(CONTROL_CHARACTERS, ' ')
|
|
84
|
+
.replace(/\s+/gu, ' ')
|
|
85
|
+
.trim()
|
|
86
|
+
.slice(0, maximum);
|
|
87
|
+
}
|
|
88
|
+
function textList(value, maximum = MAX_VALUES) {
|
|
89
|
+
if (!Array.isArray(value))
|
|
90
|
+
return [];
|
|
91
|
+
const output = [];
|
|
92
|
+
const seen = new Set();
|
|
93
|
+
for (const item of value.slice(0, maximum * 2)) {
|
|
94
|
+
const normalized = text(item, 320);
|
|
95
|
+
if (!normalized || seen.has(normalized))
|
|
96
|
+
continue;
|
|
97
|
+
seen.add(normalized);
|
|
98
|
+
output.push(normalized);
|
|
99
|
+
if (output.length >= maximum)
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
return output;
|
|
103
|
+
}
|
|
104
|
+
function limitations(value) {
|
|
105
|
+
return textList(value, MAX_LIMITATIONS).map((item) => text(item, 280));
|
|
106
|
+
}
|
|
107
|
+
function hash(value) {
|
|
108
|
+
let state = 2_166_136_261;
|
|
109
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
110
|
+
state ^= value.charCodeAt(index);
|
|
111
|
+
state = Math.imul(state, 16_777_619);
|
|
112
|
+
}
|
|
113
|
+
return (state >>> 0).toString(36);
|
|
114
|
+
}
|
|
115
|
+
function nodeId(kind, value) {
|
|
116
|
+
const slug = value.toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-|-$/gu, '').slice(0, 28);
|
|
117
|
+
return `${kind}-${slug || 'value'}-${hash(value)}`;
|
|
118
|
+
}
|
|
119
|
+
function isoDate(value) {
|
|
120
|
+
if (typeof value !== 'string')
|
|
121
|
+
return null;
|
|
122
|
+
const timestamp = Date.parse(value);
|
|
123
|
+
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
|
|
124
|
+
}
|
|
125
|
+
function hostname(value) {
|
|
126
|
+
const candidate = text(value, 320).toLowerCase().replace(/\.$/u, '');
|
|
127
|
+
if (!candidate || candidate.includes(' ') || !candidate.includes('.'))
|
|
128
|
+
return null;
|
|
129
|
+
return candidate;
|
|
130
|
+
}
|
|
131
|
+
function urlHost(value) {
|
|
132
|
+
const candidate = text(value, 2_048);
|
|
133
|
+
if (!candidate)
|
|
134
|
+
return null;
|
|
135
|
+
try {
|
|
136
|
+
return new URL(/^[a-z][a-z\d+.-]*:\/\//iu.test(candidate) ? candidate : `https://${candidate}`)
|
|
137
|
+
.hostname.toLowerCase().replace(/\.$/u, '');
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function mxHostname(value) {
|
|
144
|
+
if (typeof value === 'string')
|
|
145
|
+
return hostname(value);
|
|
146
|
+
const item = record(value);
|
|
147
|
+
return hostname(item.exchange || item.host || item.hostname);
|
|
148
|
+
}
|
|
149
|
+
function sourceCompleteness(value) {
|
|
150
|
+
const source = record(value);
|
|
151
|
+
if (source.complete === true && source.truncated !== true)
|
|
152
|
+
return 'complete';
|
|
153
|
+
if (source.complete === false || source.truncated === true || text(source.status, 32) === 'partial')
|
|
154
|
+
return 'partial';
|
|
155
|
+
return 'unknown';
|
|
156
|
+
}
|
|
157
|
+
export function buildLookupAssetGraph(input) {
|
|
158
|
+
const target = hostname(input.target);
|
|
159
|
+
if (!target) {
|
|
160
|
+
return {
|
|
161
|
+
version: 2,
|
|
162
|
+
targetId: '',
|
|
163
|
+
nodes: [],
|
|
164
|
+
edges: [],
|
|
165
|
+
sources: [],
|
|
166
|
+
truncated: false,
|
|
167
|
+
limitations: ['A normalised domain target is required before an asset graph can be projected.'],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
const observedAt = isoDate(input.observedAt);
|
|
171
|
+
const nodes = new Map();
|
|
172
|
+
const edges = new Map();
|
|
173
|
+
const sources = new Map();
|
|
174
|
+
let truncated = false;
|
|
175
|
+
const targetId = nodeId('target', target);
|
|
176
|
+
nodes.set(targetId, { id: targetId, label: target, kind: 'target', detail: 'Lookup target' });
|
|
177
|
+
const addNode = (kind, rawLabel, detail = '') => {
|
|
178
|
+
const label = text(rawLabel, 320);
|
|
179
|
+
if (!label)
|
|
180
|
+
return null;
|
|
181
|
+
const id = nodeId(kind, label);
|
|
182
|
+
if (!nodes.has(id)) {
|
|
183
|
+
if (nodes.size >= MAX_NODES) {
|
|
184
|
+
truncated = true;
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
nodes.set(id, { id, label, kind, detail: text(detail, 180) });
|
|
188
|
+
}
|
|
189
|
+
return id;
|
|
190
|
+
};
|
|
191
|
+
const addEdge = (edge) => {
|
|
192
|
+
if (!nodes.has(edge.source) || !nodes.has(edge.target) || edge.source === edge.target)
|
|
193
|
+
return;
|
|
194
|
+
const id = `${edge.kind}-${hash(`${edge.source}|${edge.target}|${edge.sourceLabel}`)}`;
|
|
195
|
+
if (edges.has(id))
|
|
196
|
+
return;
|
|
197
|
+
if (edges.size >= MAX_EDGES) {
|
|
198
|
+
truncated = true;
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const sourceId = `source-${hash(`${edge.sourceLabel}|${edge.href}`)}`;
|
|
202
|
+
const existingSource = sources.get(sourceId);
|
|
203
|
+
const sourceLimitations = [...new Set([
|
|
204
|
+
...(existingSource?.limitations ?? []),
|
|
205
|
+
...edge.limitations,
|
|
206
|
+
])].slice(0, MAX_LIMITATIONS);
|
|
207
|
+
const sourceCompleteness = existingSource?.completeness === 'partial' || edge.completeness === 'partial'
|
|
208
|
+
? 'partial'
|
|
209
|
+
: existingSource?.completeness === 'unknown' || edge.completeness === 'unknown'
|
|
210
|
+
? 'unknown'
|
|
211
|
+
: 'complete';
|
|
212
|
+
sources.set(sourceId, {
|
|
213
|
+
id: sourceId,
|
|
214
|
+
label: edge.sourceLabel,
|
|
215
|
+
href: edge.href,
|
|
216
|
+
observedAt: existingSource?.observedAt ?? edge.observedAt,
|
|
217
|
+
completeness: sourceCompleteness,
|
|
218
|
+
limitations: sourceLimitations,
|
|
219
|
+
});
|
|
220
|
+
edges.set(id, { id, sourceId, ...edge });
|
|
221
|
+
};
|
|
222
|
+
const connect = (kind, label, edge, detail = '', source = targetId) => {
|
|
223
|
+
const targetNode = addNode(kind, label, detail);
|
|
224
|
+
if (!targetNode)
|
|
225
|
+
return null;
|
|
226
|
+
addEdge({ ...edge, source, target: targetNode });
|
|
227
|
+
return targetNode;
|
|
228
|
+
};
|
|
229
|
+
const reviewedProfileDomains = new Set([
|
|
230
|
+
...textList(input.profileDomains?.official, 32),
|
|
231
|
+
...textList(input.profileDomains?.partner, 32),
|
|
232
|
+
...textList(input.profileDomains?.allowlisted, 32),
|
|
233
|
+
].map((value) => value.toLowerCase().replace(/\.$/u, '')));
|
|
234
|
+
const trustBoundary = (candidate, sourceHost = target) => {
|
|
235
|
+
if (!candidate)
|
|
236
|
+
return 'unresolved';
|
|
237
|
+
if (candidate === sourceHost)
|
|
238
|
+
return 'same_origin';
|
|
239
|
+
if (candidate === target || candidate.endsWith(`.${target}`))
|
|
240
|
+
return 'same_registrable_domain';
|
|
241
|
+
if (reviewedProfileDomains.has(candidate))
|
|
242
|
+
return 'reviewed_profile';
|
|
243
|
+
return 'external';
|
|
244
|
+
};
|
|
245
|
+
const dnsEvidence = record(input.dnsEvidence);
|
|
246
|
+
const dnsRecords = record(input.dnsRecords);
|
|
247
|
+
const dnsCompleteness = sourceCompleteness(dnsEvidence);
|
|
248
|
+
const dnsLimits = limitations(dnsEvidence.limitations);
|
|
249
|
+
const dnsEdge = (kind, label, lenses, href = '#evidence-dns') => ({
|
|
250
|
+
kind,
|
|
251
|
+
label,
|
|
252
|
+
sourceLabel: 'DNS',
|
|
253
|
+
observedAt: isoDate(dnsEvidence.observedAt) || observedAt,
|
|
254
|
+
completeness: dnsCompleteness,
|
|
255
|
+
limitations: dnsLimits,
|
|
256
|
+
lenses,
|
|
257
|
+
href,
|
|
258
|
+
});
|
|
259
|
+
for (const address of [...textList(dnsRecords.a), ...textList(dnsRecords.aaaa)]) {
|
|
260
|
+
connect('address', address, dnsEdge('resolves-to', 'resolves to', ['all']));
|
|
261
|
+
}
|
|
262
|
+
for (const alias of textList(dnsRecords.cname)) {
|
|
263
|
+
connect('hostname', alias, dnsEdge('aliases-to', 'aliases to', ['all']));
|
|
264
|
+
}
|
|
265
|
+
for (const nameserver of textList(dnsRecords.ns)) {
|
|
266
|
+
connect('hostname', nameserver, dnsEdge('uses-nameserver', 'uses nameserver', ['all', 'delegation']));
|
|
267
|
+
}
|
|
268
|
+
for (const mx of Array.isArray(dnsRecords.mx) ? dnsRecords.mx.slice(0, MAX_VALUES) : []) {
|
|
269
|
+
const host = mxHostname(mx);
|
|
270
|
+
if (host)
|
|
271
|
+
connect('hostname', host, dnsEdge('routes-mail-to', 'routes mail to', ['all']));
|
|
272
|
+
}
|
|
273
|
+
for (const binding of records(dnsRecords.https)) {
|
|
274
|
+
const serviceTarget = hostname(binding.target);
|
|
275
|
+
if (serviceTarget && serviceTarget !== '.') {
|
|
276
|
+
connect('hostname', serviceTarget, dnsEdge('service-binding', 'publishes HTTPS service target', ['all']), `Priority ${text(binding.priority, 12) || 'unknown'}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const rdapEvidence = record(input.rdapEvidence);
|
|
280
|
+
const rdapParsed = record(input.rdapParsed);
|
|
281
|
+
const registrarEntity = record(rdapParsed.registrar);
|
|
282
|
+
const registrar = text(registrarEntity.name
|
|
283
|
+
?? registrarEntity.fn
|
|
284
|
+
?? registrarEntity.handle
|
|
285
|
+
?? (typeof rdapParsed.registrar === 'string' ? rdapParsed.registrar : ''), 180);
|
|
286
|
+
if (registrar) {
|
|
287
|
+
connect('registrar', registrar, {
|
|
288
|
+
kind: 'registered-via',
|
|
289
|
+
label: 'registered via',
|
|
290
|
+
sourceLabel: 'Registry RDAP',
|
|
291
|
+
observedAt,
|
|
292
|
+
completeness: sourceCompleteness(rdapEvidence),
|
|
293
|
+
limitations: limitations(rdapEvidence.limitations),
|
|
294
|
+
lenses: ['all'],
|
|
295
|
+
href: '#evidence-registry',
|
|
296
|
+
}, 'Published registrar of record');
|
|
297
|
+
}
|
|
298
|
+
const delegation = record(dnsEvidence.delegation);
|
|
299
|
+
const delegationLimits = limitations(delegation.limitations);
|
|
300
|
+
const registryObservationId = addNode('observation', 'Registry publication', 'Registry nameserver and glue evidence');
|
|
301
|
+
const parentObservationId = addNode('observation', 'Recursive parent view', 'Point-in-time parent delegation observation');
|
|
302
|
+
for (const nameserver of textList(record(delegation.registry).nameservers)) {
|
|
303
|
+
const nameserverId = addNode('hostname', nameserver, 'Authoritative nameserver');
|
|
304
|
+
if (registryObservationId && nameserverId) {
|
|
305
|
+
addEdge({
|
|
306
|
+
source: registryObservationId,
|
|
307
|
+
target: nameserverId,
|
|
308
|
+
...dnsEdge('registry-publishes', 'registry publishes', ['delegation']),
|
|
309
|
+
sourceLabel: 'Registry delegation',
|
|
310
|
+
completeness: sourceCompleteness(delegation),
|
|
311
|
+
limitations: delegationLimits,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
for (const nameserver of textList(record(delegation.parent).nameservers)) {
|
|
316
|
+
const nameserverId = addNode('hostname', nameserver, 'Authoritative nameserver');
|
|
317
|
+
if (parentObservationId && nameserverId) {
|
|
318
|
+
addEdge({
|
|
319
|
+
source: parentObservationId,
|
|
320
|
+
target: nameserverId,
|
|
321
|
+
...dnsEdge('parent-observes', 'parent view observes', ['delegation']),
|
|
322
|
+
sourceLabel: 'Recursive parent view',
|
|
323
|
+
completeness: sourceCompleteness(delegation),
|
|
324
|
+
limitations: delegationLimits,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
for (const authority of records(delegation.authorities)) {
|
|
329
|
+
const nameserver = hostname(authority.nameserver);
|
|
330
|
+
if (!nameserver)
|
|
331
|
+
continue;
|
|
332
|
+
const nameserverId = addNode('hostname', nameserver, `Direct authority state: ${text(authority.state, 40) || 'unknown'}`);
|
|
333
|
+
if (nameserverId) {
|
|
334
|
+
addEdge({
|
|
335
|
+
source: targetId,
|
|
336
|
+
target: nameserverId,
|
|
337
|
+
...dnsEdge('direct-authority', 'queried directly', ['delegation']),
|
|
338
|
+
sourceLabel: 'Direct authoritative DNS',
|
|
339
|
+
completeness: authority.state === 'success' ? 'complete' : authority.state ? 'partial' : 'unknown',
|
|
340
|
+
limitations: delegationLimits,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
for (const address of textList(authority.addresses, 4)) {
|
|
344
|
+
const addressId = addNode('address', address, text(authority.addressSource, 80));
|
|
345
|
+
if (nameserverId && addressId) {
|
|
346
|
+
addEdge({
|
|
347
|
+
source: nameserverId,
|
|
348
|
+
target: addressId,
|
|
349
|
+
...dnsEdge('nameserver-address', 'answered at', ['delegation']),
|
|
350
|
+
sourceLabel: text(authority.addressSource, 80) || 'Nameserver address',
|
|
351
|
+
completeness: authority.state === 'success' ? 'complete' : 'partial',
|
|
352
|
+
limitations: delegationLimits,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const networkContext = record(input.observedNetworkContext);
|
|
358
|
+
const networkEndpoint = record(input.observedNetworkEndpoint);
|
|
359
|
+
const network = record(input.observedNetwork);
|
|
360
|
+
const endpointAddress = text(networkEndpoint.address, 80);
|
|
361
|
+
const endpointId = endpointAddress
|
|
362
|
+
? addNode('address', endpointAddress, `Selected from ${text(networkEndpoint.selectedFrom, 80) || 'observed endpoint'}`)
|
|
363
|
+
: null;
|
|
364
|
+
if (endpointId) {
|
|
365
|
+
addEdge({
|
|
366
|
+
source: targetId,
|
|
367
|
+
target: endpointId,
|
|
368
|
+
kind: 'observed-endpoint',
|
|
369
|
+
label: 'was observed at',
|
|
370
|
+
sourceLabel: 'Observed network context',
|
|
371
|
+
observedAt: isoDate(networkContext.observedAt) || observedAt,
|
|
372
|
+
completeness: sourceCompleteness(networkContext),
|
|
373
|
+
limitations: limitations(networkContext.limitations),
|
|
374
|
+
lenses: ['all'],
|
|
375
|
+
href: '#evidence-network-context',
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
const networkLabel = text(network.name || network.holder || network.handle, 180);
|
|
379
|
+
const networkId = networkLabel
|
|
380
|
+
? addNode('network', networkLabel, textList(network.cidrs, 4).join(', '))
|
|
381
|
+
: null;
|
|
382
|
+
if (endpointId && networkId) {
|
|
383
|
+
addEdge({
|
|
384
|
+
source: endpointId,
|
|
385
|
+
target: networkId,
|
|
386
|
+
kind: 'registered-with',
|
|
387
|
+
label: 'registered within',
|
|
388
|
+
sourceLabel: 'IP RDAP',
|
|
389
|
+
observedAt: isoDate(networkContext.observedAt) || observedAt,
|
|
390
|
+
completeness: sourceCompleteness(networkContext),
|
|
391
|
+
limitations: limitations(networkContext.limitations),
|
|
392
|
+
lenses: ['all'],
|
|
393
|
+
href: '#evidence-network-context',
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
for (const cidr of textList(network.cidrs, 8)) {
|
|
397
|
+
const prefixId = addNode('prefix', cidr, 'Published IP RDAP network prefix');
|
|
398
|
+
const prefixSource = networkId || endpointId;
|
|
399
|
+
if (prefixId && prefixSource) {
|
|
400
|
+
addEdge({
|
|
401
|
+
source: prefixSource,
|
|
402
|
+
target: prefixId,
|
|
403
|
+
kind: 'publishes-prefix',
|
|
404
|
+
label: 'publishes prefix',
|
|
405
|
+
sourceLabel: 'IP RDAP',
|
|
406
|
+
observedAt: isoDate(networkContext.observedAt) || observedAt,
|
|
407
|
+
completeness: sourceCompleteness(networkContext),
|
|
408
|
+
limitations: limitations(networkContext.limitations),
|
|
409
|
+
lenses: ['all'],
|
|
410
|
+
href: '#evidence-network-context',
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const httpEvidence = record(input.httpEvidence);
|
|
415
|
+
const finalHost = urlHost(httpEvidence.finalUrl);
|
|
416
|
+
const finalHostId = finalHost
|
|
417
|
+
? connect('hostname', finalHost, {
|
|
418
|
+
kind: 'redirects-to',
|
|
419
|
+
label: finalHost === target ? 'served from' : 'redirects to',
|
|
420
|
+
sourceLabel: 'HTTP',
|
|
421
|
+
observedAt: isoDate(httpEvidence.observedAt) || observedAt,
|
|
422
|
+
completeness: sourceCompleteness(httpEvidence),
|
|
423
|
+
limitations: limitations(httpEvidence.limitations),
|
|
424
|
+
lenses: ['all', 'identity'],
|
|
425
|
+
href: '#evidence-http',
|
|
426
|
+
boundary: trustBoundary(finalHost),
|
|
427
|
+
}, 'Observed final website origin')
|
|
428
|
+
: null;
|
|
429
|
+
const identitySource = finalHostId || targetId;
|
|
430
|
+
const canonicalHost = urlHost(record(input.pageCanonical).url);
|
|
431
|
+
if (canonicalHost) {
|
|
432
|
+
connect('origin', canonicalHost, {
|
|
433
|
+
kind: 'declares-canonical',
|
|
434
|
+
label: 'declares canonical origin',
|
|
435
|
+
sourceLabel: 'HTML canonical metadata',
|
|
436
|
+
observedAt,
|
|
437
|
+
completeness: sourceCompleteness(input.pageIdentity),
|
|
438
|
+
limitations: limitations(record(input.pageIdentity).limitations),
|
|
439
|
+
lenses: ['identity'],
|
|
440
|
+
href: '#evidence-page-identity',
|
|
441
|
+
boundary: trustBoundary(canonicalHost, finalHost || target),
|
|
442
|
+
}, 'Publisher-declared canonical origin', identitySource);
|
|
443
|
+
}
|
|
444
|
+
const openGraphHost = urlHost(record(input.pageOpenGraphUrl).url);
|
|
445
|
+
if (openGraphHost) {
|
|
446
|
+
connect('origin', openGraphHost, {
|
|
447
|
+
kind: 'declares-open-graph',
|
|
448
|
+
label: 'declares Open Graph origin',
|
|
449
|
+
sourceLabel: 'Open Graph metadata',
|
|
450
|
+
observedAt,
|
|
451
|
+
completeness: sourceCompleteness(input.pageIdentity),
|
|
452
|
+
limitations: limitations(record(input.pageIdentity).limitations),
|
|
453
|
+
lenses: ['identity'],
|
|
454
|
+
href: '#evidence-page-identity',
|
|
455
|
+
boundary: trustBoundary(openGraphHost, finalHost || target),
|
|
456
|
+
}, 'Publisher-declared Open Graph origin', identitySource);
|
|
457
|
+
}
|
|
458
|
+
for (const origin of textList(record(input.pageForms).externalActionOrigins)) {
|
|
459
|
+
const host = urlHost(origin);
|
|
460
|
+
if (host) {
|
|
461
|
+
connect('origin', host, {
|
|
462
|
+
kind: 'form-destination',
|
|
463
|
+
label: 'form may submit to',
|
|
464
|
+
sourceLabel: 'Static HTML form',
|
|
465
|
+
observedAt,
|
|
466
|
+
completeness: sourceCompleteness(input.pageIdentity),
|
|
467
|
+
limitations: ['A declared form action does not prove that a user submitted data or that the endpoint received it.'],
|
|
468
|
+
lenses: ['identity'],
|
|
469
|
+
href: '#evidence-page-identity',
|
|
470
|
+
boundary: trustBoundary(host, finalHost || target),
|
|
471
|
+
}, 'External form-action origin', identitySource);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
for (const origin of textList(record(input.pageResources).externalOrigins)) {
|
|
475
|
+
const host = urlHost(origin);
|
|
476
|
+
if (host) {
|
|
477
|
+
connect('hostname', host, {
|
|
478
|
+
kind: 'loads-from',
|
|
479
|
+
label: 'references resources from',
|
|
480
|
+
sourceLabel: 'Static HTML resources',
|
|
481
|
+
observedAt,
|
|
482
|
+
completeness: sourceCompleteness(input.pageIdentity),
|
|
483
|
+
limitations: ['A static resource reference does not prove that a browser loaded the resource or disclosed data to it.'],
|
|
484
|
+
lenses: ['identity'],
|
|
485
|
+
href: '#evidence-page-identity',
|
|
486
|
+
boundary: trustBoundary(host, finalHost || target),
|
|
487
|
+
}, 'External resource origin', identitySource);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
for (const identifier of records(record(input.pageIdentity).trackingIdentifiers, 12)) {
|
|
491
|
+
const value = text(identifier.value, 160);
|
|
492
|
+
if (value) {
|
|
493
|
+
connect('tracker', value, {
|
|
494
|
+
kind: 'declares-tracker',
|
|
495
|
+
label: 'declares tracker identifier',
|
|
496
|
+
sourceLabel: 'Static HTML identity',
|
|
497
|
+
observedAt,
|
|
498
|
+
completeness: sourceCompleteness(input.pageIdentity),
|
|
499
|
+
limitations: ['A shared identifier is a relationship lead and does not establish common ownership or control.'],
|
|
500
|
+
lenses: ['identity'],
|
|
501
|
+
href: '#evidence-page-identity',
|
|
502
|
+
}, text(identifier.type, 80), identitySource);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
const structuredIdentity = record(input.structuredDataIdentity);
|
|
506
|
+
for (const entity of records(structuredIdentity.entities, 12)) {
|
|
507
|
+
const entityLabel = text(entity.name, 180);
|
|
508
|
+
const declaredOrigin = urlHost(entity.declaredOrigin);
|
|
509
|
+
const entityId = entityLabel
|
|
510
|
+
? connect('identity', entityLabel, {
|
|
511
|
+
kind: 'declares-publisher',
|
|
512
|
+
label: 'declares publisher',
|
|
513
|
+
sourceLabel: 'Structured identity metadata',
|
|
514
|
+
observedAt,
|
|
515
|
+
completeness: sourceCompleteness(structuredIdentity),
|
|
516
|
+
limitations: [
|
|
517
|
+
...limitations(structuredIdentity.limitations),
|
|
518
|
+
'Publisher-declared structured metadata is not independent identity verification.',
|
|
519
|
+
].slice(0, MAX_LIMITATIONS),
|
|
520
|
+
lenses: ['identity'],
|
|
521
|
+
href: '#evidence-structured-identity',
|
|
522
|
+
}, textList(entity.types, 5).join(', ') || 'Publisher-declared entity', identitySource)
|
|
523
|
+
: identitySource;
|
|
524
|
+
if (declaredOrigin) {
|
|
525
|
+
connect('origin', declaredOrigin, {
|
|
526
|
+
kind: 'declares-origin',
|
|
527
|
+
label: 'declares origin',
|
|
528
|
+
sourceLabel: 'Structured identity metadata',
|
|
529
|
+
observedAt,
|
|
530
|
+
completeness: sourceCompleteness(structuredIdentity),
|
|
531
|
+
limitations: limitations(structuredIdentity.limitations),
|
|
532
|
+
lenses: ['identity'],
|
|
533
|
+
href: '#evidence-structured-identity',
|
|
534
|
+
boundary: trustBoundary(declaredOrigin, finalHost || target),
|
|
535
|
+
}, 'Publisher-declared structured-data origin', entityId || identitySource);
|
|
536
|
+
}
|
|
537
|
+
for (const sameAsHost of textList(entity.sameAsHosts, 12)) {
|
|
538
|
+
connect('origin', sameAsHost, {
|
|
539
|
+
kind: 'declares-same-as',
|
|
540
|
+
label: 'declares sameAs',
|
|
541
|
+
sourceLabel: 'Structured identity metadata',
|
|
542
|
+
observedAt,
|
|
543
|
+
completeness: sourceCompleteness(structuredIdentity),
|
|
544
|
+
limitations: [
|
|
545
|
+
...limitations(structuredIdentity.limitations),
|
|
546
|
+
'A sameAs declaration is a site-authored claim and does not establish ownership or control.',
|
|
547
|
+
].slice(0, MAX_LIMITATIONS),
|
|
548
|
+
lenses: ['identity'],
|
|
549
|
+
href: '#evidence-structured-identity',
|
|
550
|
+
boundary: trustBoundary(hostname(sameAsHost), finalHost || target),
|
|
551
|
+
}, 'Publisher-declared sameAs host', entityId || identitySource);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const tlsEvidence = record(input.tlsEvidence);
|
|
555
|
+
const certificate = record(input.tlsCertificate);
|
|
556
|
+
const fingerprint = text(certificate.fingerprintSha256, 80);
|
|
557
|
+
const validFrom = isoDate(certificate.validFrom);
|
|
558
|
+
const validTo = isoDate(certificate.validTo);
|
|
559
|
+
const certificateDetail = [
|
|
560
|
+
fingerprint,
|
|
561
|
+
validFrom ? `Valid from ${validFrom}` : '',
|
|
562
|
+
validTo ? `Valid to ${validTo}` : '',
|
|
563
|
+
].filter(Boolean).join(' · ');
|
|
564
|
+
const certificateId = fingerprint
|
|
565
|
+
? connect('certificate', `Certificate ${fingerprint.slice(0, 12)}`, {
|
|
566
|
+
kind: 'presents-certificate',
|
|
567
|
+
label: 'presented certificate',
|
|
568
|
+
sourceLabel: 'TLS',
|
|
569
|
+
observedAt: isoDate(tlsEvidence.observedAt) || observedAt,
|
|
570
|
+
completeness: sourceCompleteness(tlsEvidence),
|
|
571
|
+
limitations: limitations(tlsEvidence.limitations),
|
|
572
|
+
lenses: ['all', 'certificate'],
|
|
573
|
+
href: '#evidence-tls',
|
|
574
|
+
}, certificateDetail)
|
|
575
|
+
: null;
|
|
576
|
+
if (certificateId) {
|
|
577
|
+
const hostnameReview = record(input.tlsHostname);
|
|
578
|
+
if (typeof hostnameReview.matches === 'boolean') {
|
|
579
|
+
const matchId = addNode('identity', hostnameReview.matches ? 'Hostname match confirmed' : 'Hostname match not confirmed', hostnameReview.matches
|
|
580
|
+
? `The certificate matched ${target} during this TLS observation.`
|
|
581
|
+
: text(hostnameReview.error, 160) || `The certificate did not match ${target} during this TLS observation.`);
|
|
582
|
+
if (matchId) {
|
|
583
|
+
addEdge({
|
|
584
|
+
source: targetId,
|
|
585
|
+
target: matchId,
|
|
586
|
+
kind: 'reviewed-hostname-match',
|
|
587
|
+
label: 'reviewed hostname match',
|
|
588
|
+
sourceLabel: 'TLS hostname verification',
|
|
589
|
+
observedAt: isoDate(tlsEvidence.observedAt) || observedAt,
|
|
590
|
+
completeness: sourceCompleteness(tlsEvidence),
|
|
591
|
+
limitations: limitations(tlsEvidence.limitations),
|
|
592
|
+
lenses: ['certificate', 'identity'],
|
|
593
|
+
href: '#evidence-tls',
|
|
594
|
+
});
|
|
595
|
+
addEdge({
|
|
596
|
+
source: matchId,
|
|
597
|
+
target: certificateId,
|
|
598
|
+
kind: 'evaluated-certificate',
|
|
599
|
+
label: 'evaluated certificate',
|
|
600
|
+
sourceLabel: 'TLS hostname verification',
|
|
601
|
+
observedAt: isoDate(tlsEvidence.observedAt) || observedAt,
|
|
602
|
+
completeness: sourceCompleteness(tlsEvidence),
|
|
603
|
+
limitations: limitations(tlsEvidence.limitations),
|
|
604
|
+
lenses: ['certificate'],
|
|
605
|
+
href: '#evidence-tls',
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
const authorizationReview = record(input.tlsAuthorization);
|
|
610
|
+
if (typeof authorizationReview.authorized === 'boolean') {
|
|
611
|
+
const authorizationId = addNode('identity', authorizationReview.authorized ? 'Runtime trust confirmed' : 'Runtime trust not confirmed', authorizationReview.authorized
|
|
612
|
+
? 'The runtime trust store authorised the observed certificate chain.'
|
|
613
|
+
: text(authorizationReview.error, 160) || 'The runtime trust store did not authorise the observed certificate chain.');
|
|
614
|
+
if (authorizationId) {
|
|
615
|
+
addEdge({
|
|
616
|
+
source: certificateId,
|
|
617
|
+
target: authorizationId,
|
|
618
|
+
kind: 'reviewed-runtime-trust',
|
|
619
|
+
label: 'reviewed runtime trust',
|
|
620
|
+
sourceLabel: 'TLS chain verification',
|
|
621
|
+
observedAt: isoDate(tlsEvidence.observedAt) || observedAt,
|
|
622
|
+
completeness: sourceCompleteness(tlsEvidence),
|
|
623
|
+
limitations: limitations(tlsEvidence.limitations),
|
|
624
|
+
lenses: ['certificate'],
|
|
625
|
+
href: '#evidence-tls',
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
for (const name of textList(record(input.tlsAltNames).dnsNames, 16)) {
|
|
630
|
+
const sanId = addNode('hostname', name, 'Certificate DNS subject alternative name');
|
|
631
|
+
if (sanId) {
|
|
632
|
+
addEdge({
|
|
633
|
+
source: certificateId,
|
|
634
|
+
target: sanId,
|
|
635
|
+
kind: 'authorizes-name',
|
|
636
|
+
label: 'contains SAN',
|
|
637
|
+
sourceLabel: 'TLS certificate',
|
|
638
|
+
observedAt: isoDate(tlsEvidence.observedAt) || observedAt,
|
|
639
|
+
completeness: sourceCompleteness(tlsEvidence),
|
|
640
|
+
limitations: limitations(tlsEvidence.limitations),
|
|
641
|
+
lenses: ['certificate', 'identity'],
|
|
642
|
+
href: '#evidence-tls',
|
|
643
|
+
boundary: trustBoundary(hostname(name)),
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
const publicKey = record(input.tlsPublicKey);
|
|
648
|
+
const keyFingerprint = text(publicKey.fingerprintSha256, 80);
|
|
649
|
+
if (keyFingerprint) {
|
|
650
|
+
const keyId = addNode('key', `SPKI ${keyFingerprint.slice(0, 12)}`, `${text(publicKey.type, 40)} ${text(publicKey.bits, 20)} bits`);
|
|
651
|
+
if (keyId) {
|
|
652
|
+
addEdge({
|
|
653
|
+
source: certificateId,
|
|
654
|
+
target: keyId,
|
|
655
|
+
kind: 'uses-key',
|
|
656
|
+
label: 'uses public key',
|
|
657
|
+
sourceLabel: 'TLS certificate',
|
|
658
|
+
observedAt: isoDate(tlsEvidence.observedAt) || observedAt,
|
|
659
|
+
completeness: sourceCompleteness(tlsEvidence),
|
|
660
|
+
limitations: limitations(tlsEvidence.limitations),
|
|
661
|
+
lenses: ['certificate'],
|
|
662
|
+
href: '#evidence-tls',
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
const issuer = record(input.tlsIssuer);
|
|
667
|
+
const issuerLabel = text(issuer.organization || issuer.commonName || issuer.CN, 180);
|
|
668
|
+
if (issuerLabel) {
|
|
669
|
+
const issuerId = addNode('issuer', issuerLabel, 'Certificate issuer');
|
|
670
|
+
if (issuerId) {
|
|
671
|
+
addEdge({
|
|
672
|
+
source: certificateId,
|
|
673
|
+
target: issuerId,
|
|
674
|
+
kind: 'issued-by',
|
|
675
|
+
label: 'issued by',
|
|
676
|
+
sourceLabel: 'TLS certificate',
|
|
677
|
+
observedAt: isoDate(tlsEvidence.observedAt) || observedAt,
|
|
678
|
+
completeness: sourceCompleteness(tlsEvidence),
|
|
679
|
+
limitations: limitations(tlsEvidence.limitations),
|
|
680
|
+
lenses: ['certificate'],
|
|
681
|
+
href: '#evidence-tls',
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
for (const finding of records(record(input.certificatePolicyReview).findings, 8)) {
|
|
686
|
+
const findingId = text(finding.id, 80);
|
|
687
|
+
const state = text(finding.state, 80);
|
|
688
|
+
if (!findingId || state === 'not_configured')
|
|
689
|
+
continue;
|
|
690
|
+
const policyId = addNode('identity', text(finding.label, 160) || 'Certificate policy', state.replaceAll('_', ' '));
|
|
691
|
+
if (policyId) {
|
|
692
|
+
addEdge({
|
|
693
|
+
source: certificateId,
|
|
694
|
+
target: policyId,
|
|
695
|
+
kind: 'reviewed-against-policy',
|
|
696
|
+
label: 'reviewed against',
|
|
697
|
+
sourceLabel: 'DNS / TLS / Brand Profile',
|
|
698
|
+
observedAt: isoDate(record(input.certificatePolicyReview).observedAt) || observedAt,
|
|
699
|
+
completeness: state === 'indeterminate' || state === 'no_target_policy_observed' ? 'partial' : 'complete',
|
|
700
|
+
limitations: limitations(finding.limitations),
|
|
701
|
+
lenses: ['certificate'],
|
|
702
|
+
href: '#evidence-certificate-policy',
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return {
|
|
708
|
+
version: 2,
|
|
709
|
+
targetId,
|
|
710
|
+
nodes: [...nodes.values()],
|
|
711
|
+
edges: [...edges.values()],
|
|
712
|
+
sources: [...sources.values()].sort((left, right) => left.label.localeCompare(right.label)),
|
|
713
|
+
truncated,
|
|
714
|
+
limitations: [
|
|
715
|
+
'The graph contains only settled evidence from this Lookup and does not start additional requests.',
|
|
716
|
+
'Shared infrastructure, certificates, resources, or identifiers do not establish ownership, control, intent, or maliciousness.',
|
|
717
|
+
'An observed address can represent a CDN, proxy, load balancer, or shared service rather than an origin host.',
|
|
718
|
+
'Missing and incomplete sources remain explicit and are not represented as absent relationships.',
|
|
719
|
+
],
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
export function countLookupAssetGraphEdgesByLens(graph) {
|
|
723
|
+
return Object.freeze({
|
|
724
|
+
all: graph.edges.filter((edge) => edgeMatchesLens(edge, 'all')).length,
|
|
725
|
+
identity: graph.edges.filter((edge) => edgeMatchesLens(edge, 'identity')).length,
|
|
726
|
+
delegation: graph.edges.filter((edge) => edgeMatchesLens(edge, 'delegation')).length,
|
|
727
|
+
certificate: graph.edges.filter((edge) => edgeMatchesLens(edge, 'certificate')).length,
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
export function projectLookupAssetGraph(graph, lens) {
|
|
731
|
+
const acceptedEdges = graph.edges.filter((edge) => edgeMatchesLens(edge, lens));
|
|
732
|
+
const graphNodeById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
733
|
+
const visualEdges = [];
|
|
734
|
+
const visualDegree = new Map();
|
|
735
|
+
const omittedByHub = new Map();
|
|
736
|
+
for (const edge of interleaveVisualEdgesByFamily(acceptedEdges, graphNodeById)) {
|
|
737
|
+
const sourceDegree = visualDegree.get(edge.source) ?? 0;
|
|
738
|
+
const targetDegree = visualDegree.get(edge.target) ?? 0;
|
|
739
|
+
const saturatedHub = sourceDegree >= MAX_VISUAL_EDGES_PER_HUB
|
|
740
|
+
? edge.source
|
|
741
|
+
: targetDegree >= MAX_VISUAL_EDGES_PER_HUB
|
|
742
|
+
? edge.target
|
|
743
|
+
: null;
|
|
744
|
+
if (saturatedHub) {
|
|
745
|
+
omittedByHub.set(saturatedHub, (omittedByHub.get(saturatedHub) ?? 0) + 1);
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
visualEdges.push(edge);
|
|
749
|
+
visualDegree.set(edge.source, sourceDegree + 1);
|
|
750
|
+
visualDegree.set(edge.target, targetDegree + 1);
|
|
751
|
+
}
|
|
752
|
+
const nodeIds = new Set([graph.targetId]);
|
|
753
|
+
for (const edge of visualEdges) {
|
|
754
|
+
nodeIds.add(edge.source);
|
|
755
|
+
nodeIds.add(edge.target);
|
|
756
|
+
}
|
|
757
|
+
const collapsedGroups = [...omittedByHub.entries()].map(([hubId, omittedEdges]) => ({
|
|
758
|
+
hubId,
|
|
759
|
+
hubLabel: graphNodeById.get(hubId)?.label ?? hubId,
|
|
760
|
+
omittedEdges,
|
|
761
|
+
}));
|
|
762
|
+
const nodes = graph.nodes
|
|
763
|
+
.filter((node) => nodeIds.has(node.id))
|
|
764
|
+
.map((node) => {
|
|
765
|
+
const group = projectedNodeGroup(node, visualEdges);
|
|
766
|
+
return {
|
|
767
|
+
id: node.id,
|
|
768
|
+
label: node.label,
|
|
769
|
+
kind: node.kind,
|
|
770
|
+
detail: node.detail,
|
|
771
|
+
group: group.id,
|
|
772
|
+
groupLabel: group.label,
|
|
773
|
+
};
|
|
774
|
+
});
|
|
775
|
+
for (const group of collapsedGroups) {
|
|
776
|
+
nodes.push({
|
|
777
|
+
id: `collapsed-${group.hubId}`,
|
|
778
|
+
label: `+${group.omittedEdges} more`,
|
|
779
|
+
kind: 'summary',
|
|
780
|
+
detail: `The visual graph groups ${group.omittedEdges} additional relationship${group.omittedEdges === 1 ? '' : 's'} connected to ${group.hubLabel}. The accessible relationship list retains every edge.`,
|
|
781
|
+
group: 'summary',
|
|
782
|
+
groupLabel: 'Grouped evidence',
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
const links = visualEdges.map((edge) => ({
|
|
786
|
+
id: edge.id,
|
|
787
|
+
source: edge.source,
|
|
788
|
+
target: edge.target,
|
|
789
|
+
kind: edge.completeness === 'complete'
|
|
790
|
+
? 'observed'
|
|
791
|
+
: edge.completeness === 'partial'
|
|
792
|
+
? 'partial'
|
|
793
|
+
: 'unknown',
|
|
794
|
+
detail: `${edge.label} · ${edge.sourceLabel}${edge.boundary ? ` · ${edge.boundary.replaceAll('_', ' ')}` : ''}`,
|
|
795
|
+
}));
|
|
796
|
+
for (const group of collapsedGroups) {
|
|
797
|
+
links.push({
|
|
798
|
+
id: `collapsed-link-${group.hubId}`,
|
|
799
|
+
source: group.hubId,
|
|
800
|
+
target: `collapsed-${group.hubId}`,
|
|
801
|
+
kind: 'summary',
|
|
802
|
+
detail: `${group.omittedEdges} additional bounded relationships are listed below`,
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
return { nodes, links, edges: acceptedEdges, collapsedGroups };
|
|
806
|
+
}
|