@slicedearth/whoisleuth-cli 2.5.1 → 2.7.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/DISCLOSURE +9 -6
- package/NOTICE +3 -2
- package/README.md +60 -67
- package/cli/artifact-validation/case-response.mjs +6 -814
- package/cli/artifact-validation/lookup-evidence.mjs +30 -3
- package/cli/artifact-validation/signed-review.mjs +2 -51
- package/cli/artifact-verify.mjs +41 -3
- package/cli/bulk-command-runner.mjs +4 -6
- package/cli/case-command.mjs +92 -16
- package/cli/case-pack.mjs +14 -3
- package/cli/ci-report.mjs +17 -5
- package/cli/command-definition.mjs +7 -6
- package/cli/discovery-scan-command-runner.mjs +4 -6
- package/cli/doctor.mjs +37 -3
- package/cli/domain-feed.mjs +61 -0
- package/cli/formatters/html.mjs +3 -0
- package/cli/formatters/json.mjs +9 -14
- package/cli/formatters/terminal-lookup.mjs +24 -1
- package/cli/history-command-definitions.mjs +1 -1
- package/cli/indicator-set-command.mjs +1 -2
- package/cli/intake-command.mjs +43 -3
- package/cli/investigation-artifacts.mjs +2 -3
- package/cli/investigation-package-review.mjs +2 -0
- package/cli/investigation-plan.mjs +15 -0
- package/cli/local-mmdb-review.mjs +27 -4
- package/cli/lookup-brief.mjs +11 -5
- package/cli/lookup-command-runner.mjs +21 -13
- package/cli/offline-evidence-review.mjs +51 -5
- package/cli/review-arguments.mjs +3 -2
- package/cli/review-command-definitions.mjs +23 -17
- package/cli/review-command-runner.mjs +6 -3
- package/cli/risk-calibration.mjs +6 -4
- package/cli/saved-lookup.mjs +22 -7
- package/cli/selected-input-worker.mjs +12 -2
- package/cli/source-reliability.mjs +12 -10
- package/cli/support-arguments.mjs +1 -1
- package/cli/support-command-definitions.mjs +5 -4
- package/cli/support-command-runner.mjs +9 -7
- package/cli/watchlist-review.mjs +43 -0
- package/cli/workflow-arguments.mjs +14 -0
- package/cli/workflow-command-definitions.mjs +27 -1
- package/cli/workflow-command-runner.mjs +32 -0
- package/docs/cli-reference.md +49 -11
- package/docs/cli.md +200 -26
- package/docs/contextual-reviews.md +38 -5
- package/docs/infrastructure-observations.md +135 -0
- package/docs/offline-intake-context.md +213 -0
- package/lib/credential-form-attribution.mjs +136 -0
- package/lib/credential-surface-profile.mjs +6 -2
- package/lib/dmarc-discovery.mjs +4 -2
- package/lib/dns-delegation-health.mjs +23 -7
- package/lib/dns-inheritance-review.mjs +14 -1
- package/lib/dns-intelligence.mjs +18 -4
- package/lib/domain-posture-analysis.mjs +53 -2
- package/lib/domain-posture-parsers.mjs +9 -0
- package/lib/domain-posture.mjs +29 -10
- package/lib/evidence-export-privacy.mjs +5 -2
- package/lib/evidence-export.mjs +10 -4
- package/lib/evidence-report.mjs +3 -1
- package/lib/favicon.mjs +13 -4
- package/lib/generated/cisa-kev-catalog.mjs +17 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +178 -5
- package/lib/html-signals.mjs +6 -0
- package/lib/idn-confusables.mjs +5 -1
- package/lib/lookup-child-profile-contract.mjs +28 -11
- package/lib/lookup-collection-receipt.mjs +52 -0
- package/lib/lookup-diagnostics.mjs +2 -12
- package/lib/lookup-progress.mjs +29 -31
- package/lib/lookup-response-contract.mjs +10 -0
- package/lib/lookup-response.mjs +15 -14
- package/lib/lookup-source-progress.mjs +4 -2
- package/lib/lookup.mjs +33 -2
- package/lib/operation-budget.mjs +4 -0
- package/lib/rdap-capabilities.mjs +43 -9
- package/lib/registry-insights.mjs +17 -6
- package/lib/service-binding-dns.mjs +19 -2
- package/lib/sslbl-certificates.generated.mjs +142 -140
- package/lib/static-html-analysis.mjs +32 -4
- package/lib/technology-evidence-role.mjs +1 -51
- package/lib/tls-intelligence.mjs +30 -5
- package/lib/website-enrichment.mjs +39 -23
- package/lib/whois-authority.mjs +56 -1
- package/lib/whois-chain.mjs +14 -4
- package/lib/whois-common-formats.mjs +3 -1
- package/lib/whois-contacts.mjs +13 -2
- package/lib/whois-contracts.mjs +2 -0
- package/lib/whois-normalization.mjs +4 -4
- package/package.json +9 -7
- package/packages/analysis/bounded-json.mjs +12 -2
- package/packages/analysis/risk-scoring.mjs +12 -9
- package/packages/cases/case-attachment-model.mjs +36 -1
- package/packages/cases/case-checkpoint-receipts.mjs +68 -0
- package/packages/cases/case-containment-handoff.mjs +195 -0
- package/packages/cases/case-evidence-links.mjs +44 -0
- package/packages/cases/case-evidence-model.mjs +51 -7
- package/packages/cases/case-migration-model.mjs +9 -1
- package/packages/cases/case-object-coverage.mjs +119 -0
- package/packages/cases/case-packet-correction.mjs +106 -0
- package/packages/cases/case-recheck-model.mjs +35 -5
- package/packages/cases/case-record-operations.mjs +87 -9
- package/packages/cases/case-record-projection.mjs +43 -5
- package/packages/cases/case-record-version-input.mjs +23 -1
- package/packages/cases/case-report.mjs +35 -4
- package/packages/cases/case-requested-evidence.mjs +1 -1
- package/packages/cases/case-response-actions.mjs +57 -1
- package/packages/cases/case-response-model.mjs +7 -0
- package/packages/cases/case-response-object.mjs +119 -0
- package/packages/cases/case-response-outcomes.mjs +81 -14
- package/packages/cases/case-response-packet-render.mjs +22 -3
- package/packages/cases/case-response-packet-validation.mjs +880 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
- package/packages/cases/case-response-packet.mjs +32 -17
- package/packages/cases/case-response-review-inputs.mjs +66 -8
- package/packages/cases/case-type-presentation.mjs +10 -0
- package/packages/cases/case-workflow-metadata.mjs +1 -17
- package/packages/comparison/capture-context.mjs +3 -2
- package/packages/contracts/analyst-interchange.mjs +7 -5
- package/packages/contracts/analyst-review-state-contract.mjs +1 -1
- package/packages/contracts/candidate-watch-review.mjs +54 -0
- package/packages/contracts/case-portability.mjs +292 -43
- package/packages/contracts/cli-command-semantics.mjs +3 -0
- package/packages/contracts/cli-doctor.mjs +4 -0
- package/packages/contracts/cli-lookup.mjs +57 -46
- package/packages/contracts/context-review.mjs +2 -1
- package/packages/contracts/external-observation-interchange.mjs +96 -28
- package/packages/contracts/lookup-evidence.mjs +13 -2
- package/packages/contracts/message-intake.mjs +7 -1
- package/packages/contracts/registry-insights.mjs +6 -0
- package/packages/contracts/risk-calibration.mjs +44 -29
- package/packages/contracts/schema-lifecycle.mjs +3 -1
- package/packages/contracts/web-capture.mjs +1 -1
- package/packages/contracts/workspace-portability.mjs +86 -16
- package/packages/evidence/collection-quality.mjs +1 -1
- package/packages/evidence/credential-form-attribution.mjs +60 -0
- package/packages/evidence/dmarc-policy-context.mjs +80 -0
- package/packages/evidence/image-regions.mjs +15 -0
- package/packages/evidence/lookup-collection-receipt.mjs +140 -0
- package/packages/evidence/lookup-collection-review.mjs +74 -0
- package/packages/evidence/record-identity.mjs +60 -16
- package/packages/evidence/technology-evidence-role.mjs +51 -0
- package/packages/interchange/external-findings-import.mjs +40 -21
- package/packages/interchange/json-output.mjs +14 -0
- package/packages/interchange/managed-indicator-export.mjs +3 -2
- package/packages/interchange/managed-indicator-set.mjs +13 -0
- package/packages/investigation/acquisition-decision-packet.mjs +78 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -2
- package/packages/investigation/context-review.mjs +3 -3
- package/packages/investigation/document-intake.mjs +9 -3
- package/packages/investigation/domain-history-review.mjs +50 -12
- package/packages/investigation/domain-transition-review.mjs +2 -0
- package/packages/investigation/infrastructure-observation.mjs +234 -0
- package/packages/investigation/intake-context.mjs +284 -0
- package/packages/investigation/intake-indicators.mjs +116 -0
- package/packages/investigation/intake-phones.mjs +155 -0
- package/packages/investigation/intake-report.mjs +2 -1
- package/packages/investigation/investigation-manifest.mjs +33 -7
- package/packages/investigation/link-intake.mjs +38 -8
- package/packages/investigation/message-intake.mjs +55 -15
- package/packages/monitoring/domain-feed.mjs +321 -0
- package/packages/workspace/brand-candidate-workflow.mjs +351 -0
- package/packages/workspace/brand-keyword-campaign.mjs +241 -0
- package/packages/workspace/brand-profile-model.mjs +46 -2
- package/packages/workspace/bulk-session-model.mjs +12 -0
- package/packages/workspace/candidate-watch-handoff.mjs +195 -0
- package/packages/workspace/detection-rule-model.mjs +10 -2
- package/packages/workspace/shortlist-model.mjs +10 -2
- package/packages/workspace/watchlist-history.mjs +31 -4
- package/packages/workspace/watchlist-store.mjs +136 -7
- package/third-party-notices.txt +30 -30
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { isValidAsciiHostname } from "../contracts/domain-name.mjs";
|
|
2
|
+
import { normalizeExplicitIsoTimestamp } from "./observation.mjs";
|
|
3
|
+
export const MAX_DMARC_DISCOVERY_OWNERS = 8;
|
|
4
|
+
function closedRecord(value, keys) {
|
|
5
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
6
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(value))
|
|
7
|
+
|| Reflect.ownKeys(value).length !== keys.length)
|
|
8
|
+
throw new TypeError('DMARC policy context has an invalid structure.');
|
|
9
|
+
const result = {};
|
|
10
|
+
for (const key of keys) {
|
|
11
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
12
|
+
if (!descriptor?.enumerable || !('value' in descriptor))
|
|
13
|
+
throw new TypeError('DMARC policy context requires ordinary data fields.');
|
|
14
|
+
result[key] = descriptor.value;
|
|
15
|
+
}
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
function owner(value) {
|
|
19
|
+
return typeof value === 'string' && value.length <= 253
|
|
20
|
+
&& isValidAsciiHostname(value, { requireDot: false, requireLowercase: true });
|
|
21
|
+
}
|
|
22
|
+
function time(value) {
|
|
23
|
+
if (value === null)
|
|
24
|
+
return null;
|
|
25
|
+
const normalized = typeof value === 'string' && value.length <= 64 ? normalizeExplicitIsoTimestamp(value) : null;
|
|
26
|
+
if (normalized === null)
|
|
27
|
+
throw new TypeError('DMARC policy context has an invalid observation time.');
|
|
28
|
+
return normalized;
|
|
29
|
+
}
|
|
30
|
+
/** Current opted-in review only; absent historical context is never reconstructed. */
|
|
31
|
+
export function normalizeDmarcPolicyContext(value, expectedDomain) {
|
|
32
|
+
if (value === undefined)
|
|
33
|
+
return undefined;
|
|
34
|
+
const input = closedRecord(value, ['version', 'source', 'relation', 'complete', 'owner', 'observedAt', 'policy', 'subdomainPolicy', 'nonexistentPolicy', 'psd', 'testMode', 'discovery']);
|
|
35
|
+
if (input.version !== 1 || input.source !== 'recursive_dns_txt' || typeof input.complete !== 'boolean'
|
|
36
|
+
|| typeof input.relation !== 'string' || !['exact', 'inherited', 'undetermined'].includes(input.relation)
|
|
37
|
+
|| !Array.isArray(input.discovery) || input.discovery.length < 1 || input.discovery.length > MAX_DMARC_DISCOVERY_OWNERS) {
|
|
38
|
+
throw new TypeError('DMARC policy context has an unsupported version or discovery state.');
|
|
39
|
+
}
|
|
40
|
+
const discovery = [];
|
|
41
|
+
for (let index = 0; index < input.discovery.length; index++) {
|
|
42
|
+
const entry = Object.getOwnPropertyDescriptor(input.discovery, String(index));
|
|
43
|
+
if (!entry?.enumerable || !('value' in entry))
|
|
44
|
+
throw new TypeError('DMARC discovery requires ordinary entries.');
|
|
45
|
+
const row = closedRecord(entry.value, ['owner', 'state', 'observedAt']);
|
|
46
|
+
if (!owner(row.owner) || typeof row.state !== 'string' || !['observed', 'not_found', 'invalid', 'unavailable'].includes(row.state)) {
|
|
47
|
+
throw new TypeError('DMARC discovery has an invalid owner or state.');
|
|
48
|
+
}
|
|
49
|
+
discovery.push(Object.freeze({ owner: row.owner, state: row.state, observedAt: time(row.observedAt) }));
|
|
50
|
+
}
|
|
51
|
+
if (new Set(discovery.map(row => row.owner)).size !== discovery.length
|
|
52
|
+
|| (expectedDomain !== undefined && discovery[0].owner !== expectedDomain)
|
|
53
|
+
|| discovery.some((row, index) => index > 0 && !discovery[index - 1].owner.endsWith(`.${row.owner}`))
|
|
54
|
+
|| (input.complete && discovery.some(row => row.state === 'unavailable'))) {
|
|
55
|
+
throw new TypeError('DMARC discovery does not match one bounded owner walk.');
|
|
56
|
+
}
|
|
57
|
+
const observedAt = time(input.observedAt);
|
|
58
|
+
if (input.relation === 'undetermined') {
|
|
59
|
+
if ([input.owner, observedAt, input.policy, input.subdomainPolicy, input.nonexistentPolicy, input.psd, input.testMode].some(field => field !== null)) {
|
|
60
|
+
throw new TypeError('Undetermined DMARC policy must not declare a selected policy.');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
const selected = discovery.find(row => row.owner === input.owner);
|
|
65
|
+
if (!input.complete || !owner(input.owner) || !selected || selected.state !== 'observed' || selected.observedAt !== observedAt
|
|
66
|
+
|| (input.relation === 'exact') !== (input.owner === discovery[0].owner)
|
|
67
|
+
|| [input.policy, input.subdomainPolicy, input.nonexistentPolicy].some(field => typeof field !== 'string' || !['none', 'quarantine', 'reject'].includes(field))
|
|
68
|
+
|| typeof input.psd !== 'string' || !['u', 'n', 'y'].includes(input.psd) || typeof input.testMode !== 'boolean') {
|
|
69
|
+
throw new TypeError('DMARC policy does not match a complete selected observation.');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return Object.freeze({
|
|
73
|
+
version: 1, source: 'recursive_dns_txt', relation: input.relation,
|
|
74
|
+
complete: input.complete, owner: input.owner, observedAt,
|
|
75
|
+
policy: input.policy, subdomainPolicy: input.subdomainPolicy,
|
|
76
|
+
nonexistentPolicy: input.nonexistentPolicy,
|
|
77
|
+
psd: input.psd, testMode: input.testMode,
|
|
78
|
+
discovery: Object.freeze(discovery),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -5,6 +5,21 @@ export const MAX_EVIDENCE_IMAGE_PIXELS = 16 * 1024 * 1024;
|
|
|
5
5
|
export const MAX_EVIDENCE_IMAGE_DIMENSION = 10_000;
|
|
6
6
|
// Each selected region emits at most four solid rectangles; no nested effects.
|
|
7
7
|
export const MAX_EVIDENCE_IMAGE_REGIONS = 64;
|
|
8
|
+
export function readImageDerivationDeclaration(raw) {
|
|
9
|
+
const value = exact(raw, ['method', 'source', 'operations'], 'Image derivation declaration');
|
|
10
|
+
if (value.method !== 'png-regions-v1')
|
|
11
|
+
throw new TypeError('This image derivation method is unsupported.');
|
|
12
|
+
const operations = Array.from(array(value.operations, 'Image operations', 2, 1), operation => enumeration(operation, ['redact', 'outline'], 'Image operation'));
|
|
13
|
+
if (new Set(operations).size !== operations.length)
|
|
14
|
+
throw new TypeError('Image operations must be unique.');
|
|
15
|
+
return Object.freeze({ method: value.method, source: readRetainedFileReference(value.source), operations: Object.freeze(operations) });
|
|
16
|
+
}
|
|
17
|
+
/** Retain the immediate parent's fingerprint, without identifiers or region coordinates. */
|
|
18
|
+
export function declareImageDerivation(raw) {
|
|
19
|
+
const derivation = readImageDerivation(raw);
|
|
20
|
+
const operations = ['redact', 'outline'].filter(kind => derivation.plan.regions.some(region => region.kind === kind));
|
|
21
|
+
return readImageDerivationDeclaration({ method: derivation.method, source: derivation.source, operations });
|
|
22
|
+
}
|
|
8
23
|
export function readEvidenceImageDimensions(width, height) {
|
|
9
24
|
const result = {
|
|
10
25
|
width: integer(width, 'Image width', 1, MAX_EVIDENCE_IMAGE_DIMENSION),
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// A bounded account of one completed Lookup operation. This records collector
|
|
2
|
+
// execution, not proof of network delivery, independent verification or history.
|
|
3
|
+
export const LOOKUP_SOURCE_IDS = Object.freeze([
|
|
4
|
+
'rdap', 'whois', 'domain_evidence', 'reverse_dns', 'registrar_rdap',
|
|
5
|
+
'network_context', 'security_txt', 'external_intelligence',
|
|
6
|
+
'malware_host_intelligence', 'malware_ioc_intelligence',
|
|
7
|
+
]);
|
|
8
|
+
export const LOOKUP_SOURCE_STATES = Object.freeze([
|
|
9
|
+
'success', 'partial', 'not_found', 'skipped', 'error', 'unsupported',
|
|
10
|
+
'not_applicable', 'unavailable', 'rate_limited', 'complete', 'disabled', 'stale',
|
|
11
|
+
]);
|
|
12
|
+
export const LOOKUP_COLLECTION_RECEIPT_VERSION = 1;
|
|
13
|
+
export const LOOKUP_CHOICE_KEYS = Object.freeze([
|
|
14
|
+
'securityTxt', 'externalIntelligence', 'malwareHostIntelligence', 'malwareIocIntelligence',
|
|
15
|
+
]);
|
|
16
|
+
export const LOOKUP_CHOICE_SOURCES = Object.freeze({
|
|
17
|
+
securityTxt: 'security_txt', externalIntelligence: 'external_intelligence',
|
|
18
|
+
malwareHostIntelligence: 'malware_host_intelligence', malwareIocIntelligence: 'malware_ioc_intelligence',
|
|
19
|
+
});
|
|
20
|
+
const ADMISSIONS = ['admitted', 'disabled', 'unsupported', 'not_applicable', 'prerequisite_unavailable', 'unknown'];
|
|
21
|
+
function fields(value, keys) {
|
|
22
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
23
|
+
throw new TypeError('Collection receipt must contain ordinary objects.');
|
|
24
|
+
const prototype = Object.getPrototypeOf(value);
|
|
25
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
26
|
+
throw new TypeError('Collection receipt must contain ordinary objects.');
|
|
27
|
+
const own = Reflect.ownKeys(value);
|
|
28
|
+
if (own.length !== keys.length || own.some(key => typeof key !== 'string' || !keys.includes(key))) {
|
|
29
|
+
throw new TypeError('Collection receipt contains an unsupported field set.');
|
|
30
|
+
}
|
|
31
|
+
const output = {};
|
|
32
|
+
for (const key of keys) {
|
|
33
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
34
|
+
if (!descriptor?.enumerable || !('value' in descriptor))
|
|
35
|
+
throw new TypeError('Collection receipt contains an invalid field.');
|
|
36
|
+
output[key] = descriptor.value;
|
|
37
|
+
}
|
|
38
|
+
return output;
|
|
39
|
+
}
|
|
40
|
+
function oneOf(value, options) {
|
|
41
|
+
if (typeof value !== 'string' || !options.includes(value))
|
|
42
|
+
throw new TypeError('Collection receipt contains an invalid state.');
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
function quality(value) {
|
|
46
|
+
if (value !== null && typeof value !== 'boolean')
|
|
47
|
+
throw new TypeError('Collection receipt quality must be explicit or unknown.');
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
export function lookupCollectionChoices(input) {
|
|
51
|
+
return Object.freeze({
|
|
52
|
+
securityTxt: input.securityTxt === true,
|
|
53
|
+
externalIntelligence: input.externalIntelligence === true,
|
|
54
|
+
malwareHostIntelligence: input.malwareHostIntelligence === true,
|
|
55
|
+
malwareIocIntelligence: input.malwareIocIntelligence === true,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** Absence is historical unknown, never reconstructed as a negative choice. */
|
|
59
|
+
export function parseLookupCollectionReceipt(raw) {
|
|
60
|
+
if (raw === undefined)
|
|
61
|
+
return null;
|
|
62
|
+
const value = fields(raw, ['version', 'requestedDepth', 'targetClass', 'webSelection', 'originalChoices', 'sources']);
|
|
63
|
+
if (value.version !== LOOKUP_COLLECTION_RECEIPT_VERSION)
|
|
64
|
+
throw new TypeError('Collection receipt version is unsupported.');
|
|
65
|
+
const choices = fields(value.originalChoices, LOOKUP_CHOICE_KEYS);
|
|
66
|
+
if (Object.values(choices).some(choice => typeof choice !== 'boolean'))
|
|
67
|
+
throw new TypeError('Collection receipt choices must be explicit.');
|
|
68
|
+
const requestedDepth = oneOf(value.requestedDepth, ['fast', 'deep']);
|
|
69
|
+
const targetClass = oneOf(value.targetClass, ['domain', 'ipv4', 'ipv6', 'asn']);
|
|
70
|
+
const webSelection = oneOf(value.webSelection, ['hostname', 'selected_url', 'not_applicable']);
|
|
71
|
+
if ((targetClass !== 'domain' && webSelection !== 'not_applicable') || (webSelection === 'selected_url' && requestedDepth !== 'deep')) {
|
|
72
|
+
throw new TypeError('Collection receipt target and web selection disagree.');
|
|
73
|
+
}
|
|
74
|
+
if (!Array.isArray(value.sources) || Object.getPrototypeOf(value.sources) !== Array.prototype
|
|
75
|
+
|| value.sources.length !== LOOKUP_SOURCE_IDS.length
|
|
76
|
+
|| Reflect.ownKeys(value.sources).length !== LOOKUP_SOURCE_IDS.length + 1)
|
|
77
|
+
throw new TypeError('Collection receipt source inventory is invalid.');
|
|
78
|
+
const bySource = new Map();
|
|
79
|
+
for (let index = 0; index < LOOKUP_SOURCE_IDS.length; index++) {
|
|
80
|
+
const descriptor = Object.getOwnPropertyDescriptor(value.sources, String(index));
|
|
81
|
+
if (!descriptor?.enumerable || !('value' in descriptor))
|
|
82
|
+
throw new TypeError('Collection receipt source entry is invalid.');
|
|
83
|
+
const row = fields(descriptor.value, ['source', 'intent', 'admission', 'attempt', 'settlement', 'complete', 'truncated']);
|
|
84
|
+
const source = oneOf(row.source, LOOKUP_SOURCE_IDS);
|
|
85
|
+
if (bySource.has(source))
|
|
86
|
+
throw new TypeError('Collection receipt source is duplicated.');
|
|
87
|
+
const attempt = oneOf(row.attempt, ['started', 'not_started', 'unknown']);
|
|
88
|
+
const admission = oneOf(row.admission, ADMISSIONS);
|
|
89
|
+
const complete = quality(row.complete), truncated = quality(row.truncated);
|
|
90
|
+
const intent = oneOf(row.intent, ['requested', 'not_requested']);
|
|
91
|
+
const settlement = oneOf(row.settlement, [...LOOKUP_SOURCE_STATES, 'unknown']);
|
|
92
|
+
if (attempt === 'started' && admission !== 'admitted'
|
|
93
|
+
|| attempt === 'started' && intent !== 'requested'
|
|
94
|
+
|| attempt !== 'started' && (complete !== null || truncated !== null)
|
|
95
|
+
|| attempt === 'not_started' && settlement !== 'unknown'
|
|
96
|
+
|| complete === true && !['success', 'complete', 'not_found'].includes(settlement)
|
|
97
|
+
|| complete === true && truncated === true)
|
|
98
|
+
throw new TypeError('Collection receipt execution and quality disagree.');
|
|
99
|
+
bySource.set(source, Object.freeze({
|
|
100
|
+
source, intent, admission, attempt, settlement, complete, truncated,
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
for (const choice of LOOKUP_CHOICE_KEYS) {
|
|
104
|
+
if ((bySource.get(LOOKUP_CHOICE_SOURCES[choice]).intent === 'requested') !== choices[choice]) {
|
|
105
|
+
throw new TypeError('Collection receipt choice and source intent disagree.');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return Object.freeze({ version: LOOKUP_COLLECTION_RECEIPT_VERSION, requestedDepth, targetClass, webSelection,
|
|
109
|
+
originalChoices: lookupCollectionChoices(choices),
|
|
110
|
+
sources: Object.freeze(LOOKUP_SOURCE_IDS.map(source => bySource.get(source))),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/** Bind a validated execution account to this submission's pre-filter choices.
|
|
114
|
+
* A browser may omit an ineligible optional request; the server cannot know
|
|
115
|
+
* which unchecked/disabled control led to that omission. Do not invent why. */
|
|
116
|
+
export function bindLookupCollectionChoices(raw, originalChoices) {
|
|
117
|
+
const receipt = parseLookupCollectionReceipt(raw);
|
|
118
|
+
if (!receipt)
|
|
119
|
+
return null;
|
|
120
|
+
const choices = fields(originalChoices, LOOKUP_CHOICE_KEYS);
|
|
121
|
+
if (Object.values(choices).some(choice => typeof choice !== 'boolean'))
|
|
122
|
+
throw new TypeError('Collection receipt choices must be explicit.');
|
|
123
|
+
const sources = receipt.sources.map(row => {
|
|
124
|
+
const choice = LOOKUP_CHOICE_KEYS.find(key => LOOKUP_CHOICE_SOURCES[key] === row.source);
|
|
125
|
+
if (!choice || choices[choice] === receipt.originalChoices[choice])
|
|
126
|
+
return { ...row };
|
|
127
|
+
if (receipt.originalChoices[choice] || choices[choice] !== true || row.attempt !== 'not_started') {
|
|
128
|
+
throw new TypeError('Collection receipt does not match the submitted choices.');
|
|
129
|
+
}
|
|
130
|
+
return { ...row, intent: 'requested', admission: 'unknown' };
|
|
131
|
+
});
|
|
132
|
+
const bound = { ...receipt, originalChoices: lookupCollectionChoices(choices), sources };
|
|
133
|
+
parseLookupCollectionReceipt(bound);
|
|
134
|
+
return bound;
|
|
135
|
+
}
|
|
136
|
+
/** Stable, payload-free material comparison. Parsing also removes key order as a difference. */
|
|
137
|
+
export function lookupCollectionReceiptIdentity(raw) {
|
|
138
|
+
const receipt = parseLookupCollectionReceipt(raw);
|
|
139
|
+
return receipt ? JSON.stringify(receipt) : null;
|
|
140
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { LOOKUP_CHOICE_KEYS, LOOKUP_CHOICE_SOURCES, parseLookupCollectionReceipt } from "./lookup-collection-receipt.mjs";
|
|
2
|
+
export const LOOKUP_SOURCE_LABELS = Object.freeze({
|
|
3
|
+
rdap: 'Registry RDAP', whois: 'WHOIS chain', domain_evidence: 'Domain evidence',
|
|
4
|
+
reverse_dns: 'Reverse DNS', registrar_rdap: 'Registrar RDAP', network_context: 'Network context',
|
|
5
|
+
security_txt: 'security.txt', external_intelligence: 'Archived web intelligence',
|
|
6
|
+
malware_host_intelligence: 'Malware host intelligence', malware_ioc_intelligence: 'Malware infrastructure intelligence',
|
|
7
|
+
});
|
|
8
|
+
const ADMISSION_LABELS = Object.freeze({
|
|
9
|
+
admitted: 'Admitted', disabled: 'Disabled by policy', unsupported: 'Unsupported', not_applicable: 'Not applicable',
|
|
10
|
+
prerequisite_unavailable: 'Required earlier evidence unavailable', unknown: 'Admission not recorded',
|
|
11
|
+
});
|
|
12
|
+
const ATTEMPT_LABELS = Object.freeze({ started: 'Collector started', not_started: 'Collector not started', unknown: 'Collector start unknown' });
|
|
13
|
+
/** Inert guidance from recorded states, never an admission or retry decision. */
|
|
14
|
+
export function lookupSourceStateExplanation(state, source, receipt) {
|
|
15
|
+
if (receipt && receipt.source === source && receipt.attempt === 'not_started') {
|
|
16
|
+
if (receipt.intent === 'not_requested')
|
|
17
|
+
return 'Not requested in this Lookup. Select it explicitly in a new Lookup if needed.';
|
|
18
|
+
if (receipt.admission === 'disabled')
|
|
19
|
+
return 'Collection was disabled by policy. Retrying does not change that policy.';
|
|
20
|
+
if (receipt.admission === 'prerequisite_unavailable')
|
|
21
|
+
return 'Required earlier evidence was unavailable, so this collector did not start. Review that source first.';
|
|
22
|
+
if (receipt.admission === 'not_applicable')
|
|
23
|
+
return 'This collector did not apply to the submitted target or mode.';
|
|
24
|
+
if (receipt.admission === 'unsupported')
|
|
25
|
+
return 'Collection was not supported for this target; retrying unchanged inputs will not add evidence.';
|
|
26
|
+
}
|
|
27
|
+
switch (state) {
|
|
28
|
+
case 'success':
|
|
29
|
+
case 'complete':
|
|
30
|
+
return 'Collection completed within this source’s scope. Review its observations and time below.';
|
|
31
|
+
case 'not_found':
|
|
32
|
+
return 'This source returned no matching record. Its authority and search scope determine what that means.';
|
|
33
|
+
case 'partial':
|
|
34
|
+
case 'inconclusive':
|
|
35
|
+
return 'Evidence is incomplete or inconclusive. Missing fields remain unknown; a later retry may help if source conditions change.';
|
|
36
|
+
case 'error':
|
|
37
|
+
case 'unavailable':
|
|
38
|
+
return 'Usable evidence was not obtained. This is not an absence finding; retry may help after the reported problem changes.';
|
|
39
|
+
case 'rate_limited':
|
|
40
|
+
return 'The source reported a rate limit. Wait for its permitted retry window; no reset time is assumed.';
|
|
41
|
+
case 'unsupported':
|
|
42
|
+
return source === 'rdap' || source === 'whois'
|
|
43
|
+
? 'No applicable service was recorded by registry policy or discovery. Retrying unchanged discovery may not add evidence.'
|
|
44
|
+
: 'This source does not support the target or request. Use an applicable source rather than repeating the same request.';
|
|
45
|
+
case 'skipped':
|
|
46
|
+
return 'This source was not queried under the selected mode or access policy. Review its source details before requesting it again.';
|
|
47
|
+
case 'disabled':
|
|
48
|
+
return 'Collection was disabled by policy. Retrying does not change that policy.';
|
|
49
|
+
case 'not_applicable':
|
|
50
|
+
return 'This source does not apply to the submitted target or mode.';
|
|
51
|
+
case 'stale':
|
|
52
|
+
return 'Retained evidence is out of date for this review. A deliberate refresh may provide a newer observation.';
|
|
53
|
+
default:
|
|
54
|
+
return 'The source outcome was not retained. Its request history and retry usefulness are unknown.';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** One presentation of the closed receipt vocabulary for browser and terminal. */
|
|
58
|
+
export function lookupCollectionReceiptReview(raw) {
|
|
59
|
+
const receipt = parseLookupCollectionReceipt(raw);
|
|
60
|
+
if (!receipt)
|
|
61
|
+
return null;
|
|
62
|
+
return {
|
|
63
|
+
receipt,
|
|
64
|
+
selection: `${receipt.requestedDepth === 'deep' ? 'Deep' : 'Fast'} Lookup · ${receipt.webSelection === 'selected_url'
|
|
65
|
+
? 'selected URL' : receipt.webSelection === 'hostname' ? 'hostname' : 'no web target'}`,
|
|
66
|
+
choices: LOOKUP_CHOICE_KEYS.map(choice => ({ label: LOOKUP_SOURCE_LABELS[LOOKUP_CHOICE_SOURCES[choice]], selected: receipt.originalChoices[choice] })),
|
|
67
|
+
sources: receipt.sources.map(row => ({
|
|
68
|
+
id: row.source, label: LOOKUP_SOURCE_LABELS[row.source],
|
|
69
|
+
summary: `${row.intent === 'requested' ? 'Requested' : 'Not requested'} · ${ADMISSION_LABELS[row.admission]} · ${ATTEMPT_LABELS[row.attempt]}`,
|
|
70
|
+
outcome: `Source result: ${row.settlement.replaceAll('_', ' ')} · ${row.complete === null ? 'completeness unknown' : row.complete ? 'complete' : 'not complete'} · ${row.truncated === null ? 'truncation unknown' : row.truncated ? 'truncated' : 'not truncated'}`,
|
|
71
|
+
explanation: lookupSourceStateExplanation(row.settlement, row.source, row),
|
|
72
|
+
})),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Shared collision-resistant identities for bounded, already normalised records.
|
|
2
2
|
// Platform cryptography remains the owner of signatures and authentication.
|
|
3
3
|
export const MAX_IDENTITY_DIGEST_BYTES = 1024 * 1024;
|
|
4
|
+
export const MAX_INCREMENTAL_DIGEST_BYTES = 256 * 1024 * 1024;
|
|
4
5
|
const SHA256_CONSTANTS = Object.freeze([
|
|
5
6
|
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
6
7
|
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
@@ -14,27 +15,21 @@ const SHA256_CONSTANTS = Object.freeze([
|
|
|
14
15
|
function rotateRight(value, bits) {
|
|
15
16
|
return (value >>> bits) | (value << (32 - bits));
|
|
16
17
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
// signature, MAC, or substitute for the platform cryptography used there.
|
|
22
|
-
const paddedLength = Math.ceil((bytes.byteLength + 9) / 64) * 64;
|
|
23
|
-
const padded = new Uint8Array(paddedLength);
|
|
24
|
-
padded.set(bytes);
|
|
25
|
-
padded[bytes.byteLength] = 0x80;
|
|
26
|
-
const view = new DataView(padded.buffer);
|
|
27
|
-
const bitLength = bytes.byteLength * 8;
|
|
28
|
-
view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x1_0000_0000), false);
|
|
29
|
-
view.setUint32(paddedLength - 4, bitLength >>> 0, false);
|
|
18
|
+
/** Incremental content identity only; never authentication or publisher proof. */
|
|
19
|
+
export function createIncrementalSha256(maximumBytes = MAX_IDENTITY_DIGEST_BYTES) {
|
|
20
|
+
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0 || maximumBytes > MAX_INCREMENTAL_DIGEST_BYTES)
|
|
21
|
+
throw new RangeError('Content identity has an invalid byte limit.');
|
|
30
22
|
const state = new Uint32Array([
|
|
31
23
|
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
|
32
24
|
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
|
33
25
|
]);
|
|
34
26
|
const words = new Uint32Array(64);
|
|
35
|
-
|
|
27
|
+
const pending = new Uint8Array(64);
|
|
28
|
+
let pendingBytes = 0, totalBytes = 0, finished = false;
|
|
29
|
+
function block(bytes, offset) {
|
|
30
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset + offset, 64);
|
|
36
31
|
for (let index = 0; index < 16; index += 1)
|
|
37
|
-
words[index] = view.getUint32(
|
|
32
|
+
words[index] = view.getUint32(index * 4, false);
|
|
38
33
|
for (let index = 16; index < 64; index += 1) {
|
|
39
34
|
const left = words[index - 15];
|
|
40
35
|
const right = words[index - 2];
|
|
@@ -68,5 +63,54 @@ export function sha256IdentityHex(bytes) {
|
|
|
68
63
|
state[6] = (state[6] + g) >>> 0;
|
|
69
64
|
state[7] = (state[7] + h) >>> 0;
|
|
70
65
|
}
|
|
71
|
-
return
|
|
66
|
+
return Object.freeze({
|
|
67
|
+
update(bytes) {
|
|
68
|
+
if (finished)
|
|
69
|
+
throw new Error('Content identity is already finalised.');
|
|
70
|
+
if (!(bytes instanceof Uint8Array) || bytes.byteLength > maximumBytes - totalBytes)
|
|
71
|
+
throw new RangeError('Content identity exceeds its byte limit.');
|
|
72
|
+
totalBytes += bytes.byteLength;
|
|
73
|
+
let offset = 0;
|
|
74
|
+
if (pendingBytes) {
|
|
75
|
+
const length = Math.min(64 - pendingBytes, bytes.byteLength);
|
|
76
|
+
pending.set(bytes.subarray(0, length), pendingBytes);
|
|
77
|
+
pendingBytes += length;
|
|
78
|
+
offset += length;
|
|
79
|
+
if (pendingBytes === 64) {
|
|
80
|
+
block(pending, 0);
|
|
81
|
+
pendingBytes = 0;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
while (offset + 64 <= bytes.byteLength) {
|
|
85
|
+
block(bytes, offset);
|
|
86
|
+
offset += 64;
|
|
87
|
+
}
|
|
88
|
+
if (offset < bytes.byteLength) {
|
|
89
|
+
pending.set(bytes.subarray(offset), 0);
|
|
90
|
+
pendingBytes = bytes.byteLength - offset;
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
digestHex() {
|
|
94
|
+
if (finished)
|
|
95
|
+
throw new Error('Content identity is already finalised.');
|
|
96
|
+
finished = true;
|
|
97
|
+
const padding = new Uint8Array(pendingBytes < 56 ? 64 : 128);
|
|
98
|
+
padding.set(pending.subarray(0, pendingBytes));
|
|
99
|
+
padding[pendingBytes] = 0x80;
|
|
100
|
+
const view = new DataView(padding.buffer), bits = totalBytes * 8;
|
|
101
|
+
view.setUint32(padding.length - 8, Math.floor(bits / 0x1_0000_0000), false);
|
|
102
|
+
view.setUint32(padding.length - 4, bits >>> 0, false);
|
|
103
|
+
block(padding, 0);
|
|
104
|
+
if (padding.length === 128)
|
|
105
|
+
block(padding, 64);
|
|
106
|
+
return [...state].map(value => value.toString(16).padStart(8, '0')).join('');
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
export function sha256IdentityHex(bytes) {
|
|
111
|
+
if (bytes.byteLength > MAX_IDENTITY_DIGEST_BYTES)
|
|
112
|
+
throw new RangeError('Record identity exceeds its byte limit.');
|
|
113
|
+
const digest = createIncrementalSha256();
|
|
114
|
+
digest.update(bytes);
|
|
115
|
+
return digest.digestHex();
|
|
72
116
|
}
|
|
@@ -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, };
|
|
@@ -7,6 +7,7 @@ import { sha256IdentityHex } from "../evidence/record-identity.mjs";
|
|
|
7
7
|
import { MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_VALUE_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, normalizeCaseEvidencePins, normalizeCaseSightings, } from "../cases/case-response-model.mjs";
|
|
8
8
|
import { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, MAX_EXTERNAL_FINDING_DOMAINS, MAX_EXTERNAL_FINDING_LIMITATIONS, MAX_EXTERNAL_FINDING_LIMITATION_LENGTH, MAX_EXTERNAL_FINDINGS, MAX_EXTERNAL_FINDINGS_IMPORT_BYTES, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, } from "../contracts/external-observation-interchange.mjs";
|
|
9
9
|
import { hasUnsafeRetainedText } from "./retained-text.mjs";
|
|
10
|
+
import { readInfrastructureObservation } from "../investigation/infrastructure-observation.mjs";
|
|
10
11
|
export { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, MAX_EXTERNAL_FINDING_DOMAINS, MAX_EXTERNAL_FINDING_LIMITATIONS, MAX_EXTERNAL_FINDING_LIMITATION_LENGTH, MAX_EXTERNAL_FINDINGS, MAX_EXTERNAL_FINDINGS_IMPORT_BYTES, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, };
|
|
11
12
|
export const EXTERNAL_FINDING_CATEGORIES = [
|
|
12
13
|
'certificate',
|
|
@@ -33,6 +34,7 @@ const FINDING_KEYS = new Set([
|
|
|
33
34
|
'limitations',
|
|
34
35
|
'reference',
|
|
35
36
|
'structuredObservation',
|
|
37
|
+
'infrastructureObservation',
|
|
36
38
|
]);
|
|
37
39
|
const STRUCTURED_OBSERVATION_KEYS = new Set([
|
|
38
40
|
'sourceSchema',
|
|
@@ -185,14 +187,14 @@ function structuredObservation(value, index) {
|
|
|
185
187
|
function findingKey(finding, sourceName) {
|
|
186
188
|
return canonicalArtifactJsonV2({ sourceName, finding });
|
|
187
189
|
}
|
|
188
|
-
export function parseExternalFindingsDocument(value) {
|
|
190
|
+
export function parseExternalFindingsDocument(value, observeOrigin) {
|
|
189
191
|
const root = record(value);
|
|
190
192
|
if (!root || !hasOnlyKeys(root, ROOT_KEYS)) {
|
|
191
193
|
throw new Error('External findings must use the documented object shape without additional top-level fields.');
|
|
192
194
|
}
|
|
193
195
|
if (root.schema !== EXTERNAL_FINDINGS_SCHEMA
|
|
194
|
-
|| root.schemaVersion !==
|
|
195
|
-
throw new Error(`External findings must use ${EXTERNAL_FINDINGS_SCHEMA} schema version
|
|
196
|
+
|| (root.schemaVersion !== 4 && root.schemaVersion !== 5)) {
|
|
197
|
+
throw new Error(`External findings must use ${EXTERNAL_FINDINGS_SCHEMA} schema version 4 or 5.`);
|
|
196
198
|
}
|
|
197
199
|
const sourceValue = record(root.source);
|
|
198
200
|
if (!sourceValue || !hasOnlyKeys(sourceValue, SOURCE_KEYS)) {
|
|
@@ -207,7 +209,8 @@ export function parseExternalFindingsDocument(value) {
|
|
|
207
209
|
throw new Error(`External findings must contain between 1 and ${MAX_EXTERNAL_FINDINGS} findings.`);
|
|
208
210
|
}
|
|
209
211
|
const normalized = [];
|
|
210
|
-
|
|
212
|
+
let retainedBytes = new TextEncoder().encode(JSON.stringify({ schema: root.schema, schemaVersion: root.schemaVersion, source, findings: [] })).byteLength + 1;
|
|
213
|
+
const seen = new Map();
|
|
211
214
|
const domainCounts = new Map();
|
|
212
215
|
for (const [index, raw] of root.findings.entries()) {
|
|
213
216
|
const item = record(raw);
|
|
@@ -228,7 +231,7 @@ export function parseExternalFindingsDocument(value) {
|
|
|
228
231
|
if (evidenceClass !== 'deployment_observation' && evidenceClass !== 'provider_report') {
|
|
229
232
|
throw new Error(`Finding ${index + 1} evidence class is unsupported.`);
|
|
230
233
|
}
|
|
231
|
-
|
|
234
|
+
let finding = {
|
|
232
235
|
domain,
|
|
233
236
|
category: item.category,
|
|
234
237
|
evidenceClass,
|
|
@@ -239,10 +242,28 @@ export function parseExternalFindingsDocument(value) {
|
|
|
239
242
|
reference: optionalText(item.reference, 500, `Finding ${index + 1} reference`),
|
|
240
243
|
structuredObservation: structuredObservation(item.structuredObservation, index),
|
|
241
244
|
};
|
|
245
|
+
if (Object.hasOwn(item, 'infrastructureObservation')) {
|
|
246
|
+
if (root.schemaVersion !== 5)
|
|
247
|
+
throw new TypeError('Infrastructure observations require external-findings version 5.');
|
|
248
|
+
const infrastructureObservation = readInfrastructureObservation(item.infrastructureObservation);
|
|
249
|
+
if (infrastructureObservation.target !== domain || infrastructureObservation.observedAt !== finding.observedAt)
|
|
250
|
+
throw new TypeError('Infrastructure snapshot must match its finding target and observation time.');
|
|
251
|
+
if (finding.structuredObservation)
|
|
252
|
+
throw new TypeError('Infrastructure snapshots cannot also declare a single structured observation.');
|
|
253
|
+
finding = { ...finding, infrastructureObservation };
|
|
254
|
+
}
|
|
255
|
+
if (root.schemaVersion === 5) {
|
|
256
|
+
retainedBytes += new TextEncoder().encode(JSON.stringify(finding)).byteLength + 1;
|
|
257
|
+
if (retainedBytes > MAX_EXTERNAL_FINDINGS_IMPORT_BYTES)
|
|
258
|
+
throw new TypeError('External findings exceed the retained aggregate byte bound.');
|
|
259
|
+
}
|
|
242
260
|
const key = findingKey(finding, source.name);
|
|
243
|
-
|
|
261
|
+
const previous = seen.get(key);
|
|
262
|
+
if (previous) {
|
|
263
|
+
observeOrigin?.(previous, index);
|
|
244
264
|
continue;
|
|
245
|
-
|
|
265
|
+
}
|
|
266
|
+
seen.set(key, finding);
|
|
246
267
|
const count = (domainCounts.get(domain) ?? 0) + 1;
|
|
247
268
|
if (count > MAX_EXTERNAL_FINDINGS_PER_DOMAIN) {
|
|
248
269
|
throw new Error(`External findings exceed the ${MAX_EXTERNAL_FINDINGS_PER_DOMAIN}-finding per-domain limit.`);
|
|
@@ -252,17 +273,20 @@ export function parseExternalFindingsDocument(value) {
|
|
|
252
273
|
throw new Error(`External findings exceed the ${MAX_EXTERNAL_FINDING_DOMAINS}-domain limit.`);
|
|
253
274
|
}
|
|
254
275
|
normalized.push(finding);
|
|
276
|
+
observeOrigin?.(finding, index);
|
|
255
277
|
}
|
|
256
278
|
if (!normalized.length)
|
|
257
279
|
throw new Error('External findings did not contain a unique valid finding.');
|
|
258
280
|
return {
|
|
259
281
|
schema: EXTERNAL_FINDINGS_SCHEMA,
|
|
260
|
-
schemaVersion:
|
|
282
|
+
schemaVersion: root.schemaVersion,
|
|
261
283
|
source,
|
|
262
284
|
findings: normalized,
|
|
263
285
|
};
|
|
264
286
|
}
|
|
265
287
|
export function serializeExternalFindingsDocument(value) {
|
|
288
|
+
if (record(value)?.schemaVersion === 5)
|
|
289
|
+
return `${JSON.stringify(parseExternalFindingsDocument(value))}\n`;
|
|
266
290
|
const serialised = JSON.stringify(value, null, 2);
|
|
267
291
|
if (typeof serialised !== 'string') {
|
|
268
292
|
throw new TypeError('External findings must be JSON-serialisable.');
|
|
@@ -294,6 +318,12 @@ function alreadyRetained(recordValue, projection) {
|
|
|
294
318
|
});
|
|
295
319
|
}
|
|
296
320
|
function structuredPinFields(finding) {
|
|
321
|
+
if (finding.infrastructureObservation)
|
|
322
|
+
return {
|
|
323
|
+
field: 'infrastructure.snapshot', category: 'infrastructure',
|
|
324
|
+
sourceSchema: { collection: 'external_observations', schema: finding.infrastructureObservation.schema, version: finding.infrastructureObservation.version },
|
|
325
|
+
infrastructureObservation: finding.infrastructureObservation,
|
|
326
|
+
};
|
|
297
327
|
const observation = finding.structuredObservation;
|
|
298
328
|
if (!observation)
|
|
299
329
|
return {};
|
|
@@ -348,6 +378,7 @@ export function externalFindingCaseProjection(finding, source) {
|
|
|
348
378
|
const omittedLimitations = finding.limitations.filter((item) => !sourceLimitations.includes(item)).length;
|
|
349
379
|
const pin = normalizeCaseEvidencePins([{
|
|
350
380
|
...structuredPinFields(finding),
|
|
381
|
+
observationHostname: finding.domain,
|
|
351
382
|
importContentSha256: identitySha256,
|
|
352
383
|
label: `External ${finding.category} finding`,
|
|
353
384
|
value,
|
|
@@ -428,17 +459,6 @@ export function mergeExternalFindingsIntoCases(current, document, now = new Date
|
|
|
428
459
|
duplicatesSkipped,
|
|
429
460
|
};
|
|
430
461
|
}
|
|
431
|
-
function targetedFinding(finding, caseDomain) {
|
|
432
|
-
if (finding.domain === caseDomain)
|
|
433
|
-
return finding;
|
|
434
|
-
const prefix = `Captured hostname ${finding.domain}. `;
|
|
435
|
-
const maximumSummaryLength = MAX_EXTERNAL_FINDING_SUMMARY_LENGTH;
|
|
436
|
-
const remaining = maximumSummaryLength - prefix.length;
|
|
437
|
-
const summary = remaining > 1 && finding.summary.length > remaining
|
|
438
|
-
? `${prefix}${finding.summary.slice(0, remaining - 1).trimEnd()}…`
|
|
439
|
-
: `${prefix}${finding.summary}`;
|
|
440
|
-
return { ...finding, domain: caseDomain, summary };
|
|
441
|
-
}
|
|
442
462
|
export function externalFindingsCaseTargets(document, caseDomain) {
|
|
443
463
|
if (canonicalRegistrableDomain(caseDomain) !== caseDomain) {
|
|
444
464
|
throw new Error('The selected Case does not have a canonical registrable-domain identity.');
|
|
@@ -468,8 +488,7 @@ export function mergeExternalFindingsIntoCase(current, caseId, document, now = n
|
|
|
468
488
|
let record = selected;
|
|
469
489
|
let findingsAdded = 0;
|
|
470
490
|
let duplicatesSkipped = 0;
|
|
471
|
-
for (const
|
|
472
|
-
const finding = targetedFinding(original, selected.domain);
|
|
491
|
+
for (const finding of document.findings) {
|
|
473
492
|
const merged = mergeExternalFindingIntoCase(cases, selected.id, finding, document.source, now);
|
|
474
493
|
cases = merged.cases;
|
|
475
494
|
record = merged.record;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const TERMINAL_UNSAFE_JSON_RE = /[\u007f-\u009f\u2028\u2029]|\p{Default_Ignorable_Code_Point}/gu;
|
|
2
|
+
function jsonUnicodeEscape(value) {
|
|
3
|
+
const codePoint = value.codePointAt(0);
|
|
4
|
+
if (codePoint <= 0xffff)
|
|
5
|
+
return `\\u${codePoint.toString(16).padStart(4, '0')}`;
|
|
6
|
+
const offset = codePoint - 0x10000;
|
|
7
|
+
const high = 0xd800 + (offset >> 10);
|
|
8
|
+
const low = 0xdc00 + (offset & 0x3ff);
|
|
9
|
+
return `\\u${high.toString(16).padStart(4, '0')}\\u${low.toString(16).padStart(4, '0')}`;
|
|
10
|
+
}
|
|
11
|
+
/** Escape display controls without changing the parsed JSON values. */
|
|
12
|
+
export function terminalSafeJson(value, space) {
|
|
13
|
+
return JSON.stringify(value, null, space).replace(TERMINAL_UNSAFE_JSON_RE, jsonUnicodeEscape);
|
|
14
|
+
}
|