@slicedearth/whoisleuth-cli 2.4.0 → 2.5.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/NOTICE +2 -2
- package/cli/argument-values.mjs +55 -0
- package/cli/arguments.mjs +24 -470
- package/cli/artifact-validation/case-response.mjs +21 -6
- package/cli/artifact-validation/lookup-evidence.mjs +3 -3
- package/cli/artifact-validation/signed-review.mjs +59 -13
- package/cli/artifact-verify.mjs +2 -0
- package/cli/assurance-arguments.mjs +56 -0
- package/cli/assurance-command-definitions.mjs +179 -0
- package/cli/assurance-command-runner.mjs +1 -1
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +1 -1
- package/cli/bulk-command-runner.mjs +2 -2
- package/cli/bulk.mjs +2 -3
- package/cli/case-command.mjs +20 -3
- package/cli/case-pack.mjs +12 -2
- package/cli/ci-report.mjs +6 -3
- package/cli/collection-arguments.mjs +123 -0
- package/cli/collection-command-definitions.mjs +330 -0
- package/cli/collection-preflight.mjs +8 -1
- package/cli/command-argument-grammar.mjs +8 -210
- package/cli/command-definition.mjs +329 -0
- package/cli/command-reference.mjs +55 -1388
- package/cli/command-seeds.mjs +18 -0
- package/cli/compare.mjs +1 -1
- package/cli/config-profile.mjs +1 -1
- package/cli/ct-event-intake.mjs +4 -4
- package/cli/discover.mjs +2 -2
- package/cli/discovery-observation-snapshot.mjs +2 -2
- package/cli/discovery-scan-command-runner.mjs +3 -3
- package/cli/discovery-scan.mjs +1 -1
- package/cli/discovery-snapshot.mjs +2 -2
- package/cli/discovery-workflow.mjs +4 -4
- package/cli/domain-control-monitor.mjs +6 -6
- package/cli/domain-control-observations.mjs +2 -1
- package/cli/errors.mjs +5 -6
- package/cli/evidence-arguments.mjs +46 -0
- package/cli/evidence-command-definitions.mjs +69 -0
- package/cli/evidence-command-runner.mjs +2 -3
- package/cli/evidence-signing.mjs +3 -2
- package/cli/external-observation-mapping.mjs +2 -2
- package/cli/formatters/terminal-command-formats.mjs +6 -0
- package/cli/formatters/terminal-lookup.mjs +6 -0
- package/cli/history-arguments.mjs +39 -0
- package/cli/history-command-definitions.mjs +88 -0
- package/cli/indicator-set-command.mjs +33 -0
- package/cli/inline-command-families.mjs +6 -47
- package/cli/inline-command-runner.mjs +4 -31
- package/cli/input.mjs +2 -2
- package/cli/intake-command.mjs +107 -0
- package/cli/interchange-report.mjs +4 -2
- package/cli/investigation-artifacts.mjs +1 -1
- package/cli/investigation-plan.mjs +32 -154
- package/cli/investigation-recipes.mjs +177 -0
- package/cli/investigation-run.mjs +78 -12
- package/cli/local-mmdb-review.mjs +197 -17
- package/cli/lookalike-calibration.mjs +1 -1
- package/cli/lookup-brief.mjs +2 -2
- package/cli/lookup-command-runner.mjs +18 -35
- package/cli/lookup-diff.mjs +10 -9
- package/cli/lookup-plan.mjs +10 -14
- package/cli/lookup-reconcile.mjs +6 -5
- package/cli/lookup-timeline.mjs +7 -6
- package/cli/mail-header-review.mjs +24 -247
- package/cli/mail-review.mjs +9 -7
- package/cli/network-arguments.mjs +51 -0
- package/cli/network-command-definitions.mjs +130 -0
- package/cli/network-command-runner.mjs +43 -31
- package/cli/offline-evidence-review.mjs +39 -6
- package/cli/posture.mjs +1 -1
- package/cli/registry-fixture-scaffold.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +8 -7
- package/cli/review-arguments.mjs +131 -0
- package/cli/review-command-definitions.mjs +273 -0
- package/cli/review-command-runner.mjs +68 -158
- package/cli/risk-calibration.mjs +8 -8
- package/cli/runner.mjs +20 -32
- package/cli/saved-lookup.mjs +2 -2
- package/cli/selected-input-worker.mjs +63 -0
- package/cli/sharing-review.mjs +5 -64
- package/cli/source-reliability.mjs +2 -2
- package/cli/support-arguments.mjs +49 -0
- package/cli/support-command-definitions.mjs +209 -0
- package/cli/support-command-runner.mjs +2 -2
- package/cli/workflow-arguments.mjs +86 -0
- package/cli/workflow-command-definitions.mjs +78 -0
- package/cli/workflow-command-runner.mjs +8 -1
- package/docs/cli-reference.md +27 -0
- package/docs/cli.md +223 -2
- package/docs/contextual-reviews.md +155 -0
- package/lib/analyst-taxonomy.mjs +2 -40
- package/lib/application-version.mjs +1 -1
- package/lib/availability.mjs +13 -789
- package/lib/bounded-contract-normalizers.mjs +4 -0
- package/lib/bounded-json.mjs +2 -330
- package/lib/browser-library-profile.mjs +62 -84
- package/lib/candidate-provenance-bounds.mjs +2 -0
- package/lib/classify.mjs +1 -1
- package/lib/ct-query.mjs +2 -27
- package/lib/ct-response-bounds.mjs +2 -9
- package/lib/ct-search.mjs +83 -47
- package/lib/dmarc-discovery.mjs +62 -0
- package/lib/dns-convergence-review.mjs +21 -8
- package/lib/dns-delegation-health.mjs +12 -8
- package/lib/dns-inheritance-review.mjs +53 -63
- package/lib/dns-intelligence.mjs +9 -7
- package/lib/dns-query-coverage.mjs +40 -0
- package/lib/domain-assurance.mjs +1 -1
- package/lib/domain-change-packet.mjs +12 -13
- package/lib/domain-change-review.mjs +32 -14
- package/lib/domain-control-flight-recorder.mjs +103 -27
- package/lib/domain-control-manifest.mjs +25 -17
- package/lib/domain-posture-analysis.mjs +22 -5
- package/lib/domain-posture.mjs +16 -15
- package/lib/domain-registration.mjs +392 -0
- package/lib/evidence-export-privacy.mjs +22 -15
- package/lib/evidence-export.mjs +3 -3
- package/lib/evidence-report.mjs +4 -1
- package/lib/external-intelligence-risk.mjs +2 -124
- package/lib/generated/cisa-kev-catalog.mjs +41 -3
- package/lib/generated/registrar-standing-catalogue.mjs +18 -10
- package/lib/generated/retire-browser-catalog.mjs +332 -19
- package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
- package/lib/html-signals.mjs +7 -2
- package/lib/http-evidence-bounds.mjs +2 -8
- package/lib/http-intelligence.mjs +13 -1
- package/lib/idn-confusable-policy.mjs +6 -4
- package/lib/idn-confusables.mjs +1 -1
- package/lib/interchange-fidelity-registry.mjs +10 -0
- package/lib/lookup-contract-primitives.mjs +1 -1
- package/lib/lookup-diagnostics.mjs +1 -23
- package/lib/lookup-network-evidence-bounds.mjs +1 -10
- package/lib/lookup-progress.mjs +50 -1
- package/lib/lookup-response-contract.mjs +69 -40
- package/lib/lookup-response.mjs +5 -15
- package/lib/lookup-source-progress.mjs +8 -22
- package/lib/lookup.mjs +1 -1
- package/lib/opportunity-scoring.mjs +2 -107
- package/lib/outbound-identity.mjs +1 -1
- package/lib/page-fingerprints.mjs +18 -1
- package/lib/perceptual-hash-comparison.mjs +2 -34
- package/lib/perceptual-hash.mjs +36 -10
- package/lib/portable-generator.mjs +2 -29
- package/lib/project-metadata.mjs +2 -8
- package/lib/rdap-client.mjs +11 -7
- package/lib/registrable-domain.mjs +2 -56
- package/lib/registrar-standing-contract.mjs +1 -1
- package/lib/registrar-standing.mjs +9 -13
- package/lib/registry-capabilities.mjs +2 -2
- package/lib/registry-capability-catalogue.mjs +27 -19
- package/lib/risk-calibration-summary.mjs +2 -352
- package/lib/risk-scoring.mjs +2 -345
- package/lib/scoring-evidence-quality.mjs +2 -107
- package/lib/semantic-version.mjs +2 -54
- package/lib/service-binding-dns.mjs +27 -0
- package/lib/smtp-transport-review.mjs +48 -19
- package/lib/sslbl-certificates.generated.mjs +141 -139
- package/lib/static-html-analysis.mjs +1 -1
- package/lib/structured-data-identity.mjs +1 -1
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threat-intelligence-runtime.mjs +1 -1
- package/lib/threat-intelligence-targets.mjs +1 -1
- package/lib/threat-intelligence-types.mjs +2 -75
- package/lib/typosquat-generator.mjs +11 -2
- package/lib/website-enrichment.mjs +388 -0
- package/lib/whois-authority.mjs +43 -5
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-parser.mjs +5 -3
- package/package.json +13 -14
- package/packages/analysis/analyst-taxonomy.mjs +40 -0
- package/packages/analysis/bounded-json.mjs +330 -0
- package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
- package/packages/analysis/cli-argument-grammar.mjs +216 -0
- package/packages/analysis/ct-query.mjs +27 -0
- package/packages/analysis/ct-response-bounds.mjs +9 -0
- package/packages/analysis/external-intelligence-risk.mjs +124 -0
- package/packages/analysis/http-evidence-bounds.mjs +8 -0
- package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
- package/packages/analysis/opportunity-scoring.mjs +107 -0
- package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
- package/packages/analysis/portable-generator.mjs +29 -0
- package/packages/analysis/project-metadata.mjs +8 -0
- package/packages/analysis/registrable-domain.mjs +56 -0
- package/packages/analysis/risk-calibration-summary.mjs +352 -0
- package/packages/analysis/risk-scoring.mjs +345 -0
- package/packages/analysis/scoring-evidence-quality.mjs +107 -0
- package/packages/analysis/semantic-version.mjs +54 -0
- package/packages/analysis/threat-intelligence-types.mjs +75 -0
- package/packages/cases/case-evidence-links.mjs +135 -0
- package/packages/cases/case-evidence-model.mjs +78 -12
- package/packages/cases/case-evidence-quality.mjs +5 -0
- package/packages/cases/case-export-input.mjs +7 -5
- package/packages/cases/case-incident-context.mjs +52 -0
- package/packages/cases/case-markdown.mjs +11 -0
- package/packages/cases/case-migration-model.mjs +40 -24
- package/packages/cases/case-recheck-model.mjs +90 -32
- package/packages/cases/case-record-contracts.mjs +1 -1
- package/packages/cases/case-record-core.mjs +1 -1
- package/packages/cases/case-record-operations.mjs +40 -112
- package/packages/cases/case-record-projection.mjs +8 -1
- package/packages/cases/case-record-version-input.mjs +19 -0
- package/packages/cases/case-report.mjs +63 -27
- package/packages/cases/case-requested-evidence.mjs +146 -0
- package/packages/cases/case-response-actions.mjs +103 -1
- package/packages/cases/case-response-model.mjs +9 -1
- package/packages/cases/case-response-outcomes.mjs +29 -8
- package/packages/cases/case-response-packet-render.mjs +13 -9
- package/packages/cases/case-response-packet.mjs +25 -11
- package/packages/cases/case-response-records.mjs +0 -1
- package/packages/cases/case-response-review-inputs.mjs +14 -3
- package/packages/cases/case-storage-model.mjs +1 -1
- package/packages/cases/case-workflow-metadata.mjs +225 -0
- package/packages/cases/case-workflow-migration.mjs +43 -0
- package/packages/cases/http-summary.mjs +6 -1
- package/packages/comparison/favicon-similarity.mjs +1 -1
- package/packages/comparison/page-similarity.mjs +1 -1
- package/packages/comparison/relationship-evidence.mjs +21 -9
- package/packages/comparison/relationship-provenance.mjs +7 -1
- package/packages/contracts/analyst-interchange.mjs +28 -10
- package/packages/contracts/case-portability.mjs +118 -25
- package/packages/contracts/cli-command-semantics.mjs +2 -0
- package/packages/contracts/cli-grammar.mjs +6 -0
- package/packages/contracts/cli-lookup-brief.mjs +2 -0
- package/packages/contracts/context-review.mjs +24 -0
- package/packages/contracts/document-intake.mjs +11 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
- package/packages/contracts/domain-control-manifest.mjs +1 -1
- package/packages/contracts/domain-control-monitor.mjs +13 -2
- package/packages/contracts/domain-control-review.mjs +36 -15
- package/packages/contracts/har-review.mjs +2 -0
- package/packages/contracts/identity-events.mjs +4 -0
- package/packages/contracts/investigation-run.mjs +2 -0
- package/packages/contracts/ip-address.mjs +10 -0
- package/packages/contracts/mail-authentication.mjs +5 -0
- package/packages/contracts/message-intake.mjs +31 -0
- package/packages/contracts/offline-comparison.mjs +4 -1
- package/packages/contracts/passive-mail.mjs +10 -0
- package/packages/contracts/web-capture.mjs +2 -2
- package/packages/contracts/workspace-portability.mjs +63 -17
- package/packages/evidence/collection-quality.mjs +67 -0
- package/packages/evidence/dns-query-outcome.mjs +72 -0
- package/packages/evidence/email-recipient.mjs +18 -0
- package/packages/evidence/lookup-target.mjs +5 -1
- package/packages/evidence/observation.mjs +2 -0
- package/packages/evidence/png-pixels.mjs +117 -0
- package/packages/evidence/public-address-policy.mjs +83 -0
- package/packages/interchange/defensive-indicator-export.mjs +264 -0
- package/packages/interchange/external-findings-import.mjs +4 -12
- package/packages/interchange/managed-indicator-export.mjs +35 -0
- package/packages/interchange/managed-indicator-set.mjs +210 -0
- package/packages/interchange/misp-indicator-export.mjs +93 -0
- package/packages/interchange/sharing-policy.mjs +56 -0
- package/packages/interchange/stix-indicator-export.mjs +129 -0
- package/packages/interchange/web-capture-import.mjs +22 -15
- package/packages/investigation/acquisition-decision-packet.mjs +1 -1
- package/packages/investigation/bulk-mail-exposure.mjs +7 -13
- package/packages/investigation/capture-coverage.mjs +57 -0
- package/packages/investigation/connector-provenance-review.mjs +100 -0
- package/packages/investigation/context-review.mjs +35 -0
- package/packages/investigation/document-intake.mjs +81 -0
- package/packages/investigation/docx-intake.mjs +133 -0
- package/packages/investigation/domain-history-review.mjs +100 -0
- package/packages/investigation/domain-transition-review.mjs +47 -0
- package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
- package/packages/investigation/har-intake.mjs +68 -0
- package/packages/investigation/identity-event-intake.mjs +131 -0
- package/packages/investigation/identity-incident-review.mjs +39 -0
- package/packages/investigation/incident-sequence-review.mjs +58 -0
- package/packages/investigation/intake-report.mjs +19 -0
- package/packages/investigation/investigation-manifest.mjs +2 -2
- package/packages/investigation/link-intake.mjs +179 -0
- package/packages/investigation/lookup-asset-graph.mjs +1 -1
- package/packages/investigation/mail-authentication-review.mjs +222 -0
- package/packages/investigation/mail-header-identity.mjs +209 -0
- package/packages/investigation/message-intake.mjs +195 -0
- package/packages/investigation/page-behaviour.mjs +113 -0
- package/packages/investigation/pdf-intake.mjs +155 -0
- package/packages/investigation/platform-continuity-review.mjs +60 -0
- package/packages/investigation/qr-intake.mjs +56 -0
- package/packages/investigation/requested-action-hints.mjs +11 -0
- package/packages/investigation/selected-input-review.mjs +17 -0
- package/packages/investigation/storefront-review.mjs +70 -0
- package/packages/workspace/brand-profile-model.mjs +2 -2
- package/packages/workspace/bulk-review-model.mjs +28 -26
- package/packages/workspace/bulk-session-model.mjs +23 -13
- package/packages/workspace/campaign-model.mjs +1 -1
- package/packages/workspace/detection-rule-model.mjs +1 -1
- package/packages/workspace/hostile-input.mjs +1 -1
- package/packages/workspace/investigation-guide.mjs +2 -2
- package/packages/workspace/investigation-template-model.mjs +26 -2
- package/packages/workspace/page-baseline.mjs +2 -2
- package/packages/workspace/relationship-observation-model.mjs +17 -13
- package/packages/workspace/shortlist-model.mjs +14 -5
- package/packages/workspace/watchlist-history.mjs +28 -12
- package/packages/workspace/watchlist-store.mjs +17 -5
- package/packages/workspace/website-snapshot-model.mjs +28 -3
- package/packages/workspace/workspace-archive-crypto.mjs +1 -1
- package/packages/workspace/workspace-archive.mjs +5 -4
- package/third-party-notices.txt +549 -16
- package/lib/hostname.mjs +0 -2
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { array, digest, domain, enumeration, exact, exactOptional, integer, iso, text, validateIntegrity } from "../evidence/artifact-structure.mjs";
|
|
2
|
+
import { canonicalArtifactJsonV2, sha256ArtifactDigestV2, SORTED_JSON_V2 } from "../evidence/artifact-integrity.mjs";
|
|
3
|
+
import { assertWorkspaceInputGraph } from "../workspace/hostile-input.mjs";
|
|
4
|
+
import { parseBoundedJson, boundedJsonLimitsForBytes } from "../analysis/bounded-json.mjs";
|
|
5
|
+
import { MANAGED_INDICATOR_SET_SCHEMA, MANAGED_INDICATOR_SET_VERSION, MAX_MANAGED_INDICATOR_SET_BYTES, MAX_MANAGED_INDICATOR_PLAN_BYTES } from "../contracts/analyst-interchange.mjs";
|
|
6
|
+
import { defensiveIndicatorProvenance, prepareDefensiveIndicatorExport, MAX_DEFENSIVE_INDICATOR_INPUTS } from "./defensive-indicator-export.mjs";
|
|
7
|
+
import { MAX_STIX_INDICATORS } from "./stix-indicator-export.mjs";
|
|
8
|
+
export const MAX_MANAGED_INDICATORS = MAX_STIX_INDICATORS;
|
|
9
|
+
export const MAX_INDICATOR_REVIEW_BASIS = 1_000;
|
|
10
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
|
11
|
+
const AVAILABILITY = ['registered', 'for_sale', 'expiring'];
|
|
12
|
+
function uuid(value) {
|
|
13
|
+
if (typeof value !== 'string' || !UUID.test(value))
|
|
14
|
+
throw new TypeError('Indicator identities must be lowercase version-4 UUIDs.');
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
function timestamp(value, label) { iso(value, label); return value; }
|
|
18
|
+
function basis(value) {
|
|
19
|
+
const result = text(value, 'Indicator review basis', MAX_INDICATOR_REVIEW_BASIS).trim();
|
|
20
|
+
if (!result)
|
|
21
|
+
throw new TypeError('Explain the indicator revision.');
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
function bounded(value, maximumBytes) {
|
|
25
|
+
assertWorkspaceInputGraph(value, 'Indicator input', { maximumBytes });
|
|
26
|
+
if (new TextEncoder().encode(JSON.stringify(value)).byteLength > maximumBytes)
|
|
27
|
+
throw new TypeError('Indicator input exceeds its byte limit.');
|
|
28
|
+
}
|
|
29
|
+
/** Structural validation is independent of digest verification and author trust. */
|
|
30
|
+
export function validateManagedIndicatorSet(value) {
|
|
31
|
+
bounded(value, MAX_MANAGED_INDICATOR_SET_BYTES);
|
|
32
|
+
const root = exact(value, ['schema', 'version', 'id', 'producerId', 'revisionId', 'name', 'revision', 'createdAt', 'modifiedAt', 'previous', 'entries', 'integrity'], 'Managed indicator set');
|
|
33
|
+
if (root.schema !== MANAGED_INDICATOR_SET_SCHEMA || root.version !== MANAGED_INDICATOR_SET_VERSION)
|
|
34
|
+
throw new TypeError('Unsupported managed indicator set schema or version.');
|
|
35
|
+
const identities = [uuid(root.id), uuid(root.producerId), uuid(root.revisionId)];
|
|
36
|
+
if (!text(root.name, 'Indicator set name', 120).trim())
|
|
37
|
+
throw new TypeError('Name the indicator set.');
|
|
38
|
+
const revision = integer(root.revision, 'Indicator revision', 1);
|
|
39
|
+
const created = Date.parse(timestamp(root.createdAt, 'Set creation time')), modified = Date.parse(timestamp(root.modifiedAt, 'Set modification time'));
|
|
40
|
+
if (modified < created || (revision === 1 && modified !== created))
|
|
41
|
+
throw new TypeError('Indicator revision times are inconsistent.');
|
|
42
|
+
if (revision === 1) {
|
|
43
|
+
if (root.previous !== null)
|
|
44
|
+
throw new TypeError('An initial indicator revision has no predecessor.');
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
const previous = exact(root.previous, ['revisionId', 'digestSha256'], 'Preceding indicator revision');
|
|
48
|
+
identities.push(uuid(previous.revisionId));
|
|
49
|
+
digest(previous.digestSha256, 'Preceding revision digest');
|
|
50
|
+
}
|
|
51
|
+
const activeDomains = new Set();
|
|
52
|
+
for (const item of array(root.entries, 'Managed indicators', MAX_MANAGED_INDICATORS, 1)) {
|
|
53
|
+
const entry = exact(item, ['id', 'domain', 'createdAt', 'modifiedAt', 'expiresAt', 'basis', 'reviewBasis', 'observation', 'withdrawal'], 'Managed indicator');
|
|
54
|
+
identities.push(uuid(entry.id));
|
|
55
|
+
domain(entry.domain, 'Managed indicator domain');
|
|
56
|
+
const first = Date.parse(timestamp(entry.createdAt, 'Indicator creation time'));
|
|
57
|
+
const last = Date.parse(timestamp(entry.modifiedAt, 'Indicator modification time'));
|
|
58
|
+
const expires = Date.parse(timestamp(entry.expiresAt, 'Indicator review expiry'));
|
|
59
|
+
if (first < created || last < first || last > modified || expires <= first || (entry.withdrawal === null && expires <= last))
|
|
60
|
+
throw new TypeError('Indicator entry times are inconsistent.');
|
|
61
|
+
basis(entry.basis);
|
|
62
|
+
basis(entry.reviewBasis);
|
|
63
|
+
const observation = exact(entry.observation, ['availability', 'riskScore', 'riskModelVersion', 'scanDepth', 'observedAt'], 'Indicator observation');
|
|
64
|
+
enumeration(observation.availability, AVAILABILITY, 'Indicator registration observation');
|
|
65
|
+
if (observation.riskScore !== null)
|
|
66
|
+
integer(observation.riskScore, 'Retained risk score', 0, 100);
|
|
67
|
+
if (observation.riskModelVersion !== null)
|
|
68
|
+
integer(observation.riskModelVersion, 'Retained risk model', 1, 1_000);
|
|
69
|
+
enumeration(observation.scanDepth, ['fast', 'deep', 'unknown'], 'Retained scan depth');
|
|
70
|
+
iso(observation.observedAt, 'Retained observation time', true);
|
|
71
|
+
if (entry.withdrawal === null) {
|
|
72
|
+
if (activeDomains.has(entry.domain))
|
|
73
|
+
throw new TypeError('Only one active identity per domain is allowed in a managed set.');
|
|
74
|
+
activeDomains.add(entry.domain);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
const withdrawal = exact(entry.withdrawal, ['at', 'reason'], 'Indicator withdrawal');
|
|
78
|
+
if (timestamp(withdrawal.at, 'Withdrawal time') !== entry.modifiedAt)
|
|
79
|
+
throw new TypeError('Withdrawal must be the final indicator modification.');
|
|
80
|
+
basis(withdrawal.reason);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (new Set(identities).size !== identities.length)
|
|
84
|
+
throw new TypeError('Managed indicator identities must be distinct.');
|
|
85
|
+
validateIntegrity(root.integrity, 'Managed indicator integrity', root.version, MANAGED_INDICATOR_SET_VERSION);
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
export async function readManagedIndicatorSet(value) {
|
|
89
|
+
const checked = structuredClone(validateManagedIndicatorSet(value));
|
|
90
|
+
const { integrity, ...unsigned } = checked;
|
|
91
|
+
if (await sha256ArtifactDigestV2(unsigned) !== integrity.digestSha256)
|
|
92
|
+
throw new TypeError('Managed indicator content does not match its recorded digest.');
|
|
93
|
+
return checked;
|
|
94
|
+
}
|
|
95
|
+
export function parseManagedIndicatorJson(raw, plan = false) {
|
|
96
|
+
const maximumBytes = plan ? MAX_MANAGED_INDICATOR_PLAN_BYTES : MAX_MANAGED_INDICATOR_SET_BYTES;
|
|
97
|
+
return parseBoundedJson(raw, { maximumBytes, label: 'Managed indicator input', limits: boundedJsonLimitsForBytes(maximumBytes) });
|
|
98
|
+
}
|
|
99
|
+
export function managedIndicatorState(entry, at) {
|
|
100
|
+
iso(at, 'Review time');
|
|
101
|
+
return entry.withdrawal ? 'withdrawn' : Date.parse(entry.expiresAt) <= Date.parse(at) ? 'expired' : 'active';
|
|
102
|
+
}
|
|
103
|
+
/** A known predecessor detects removal, rollback and edits to original observations. */
|
|
104
|
+
export function compareManagedIndicatorRevisions(previous, next) {
|
|
105
|
+
validateManagedIndicatorSet(previous);
|
|
106
|
+
validateManagedIndicatorSet(next);
|
|
107
|
+
if (previous.id !== next.id || previous.producerId !== next.producerId || previous.name !== next.name || previous.createdAt !== next.createdAt
|
|
108
|
+
|| next.revision !== previous.revision + 1 || next.previous?.revisionId !== previous.revisionId || next.previous.digestSha256 !== previous.integrity.digestSha256
|
|
109
|
+
|| Math.floor(Date.parse(next.modifiedAt) / 1000) <= Math.floor(Date.parse(previous.modifiedAt) / 1000)) {
|
|
110
|
+
throw new TypeError('This is not the next revision of the selected indicator set. Keep both files and review the predecessor.');
|
|
111
|
+
}
|
|
112
|
+
const old = new Map(previous.entries.map(entry => [entry.id, entry]));
|
|
113
|
+
const changes = [];
|
|
114
|
+
for (const entry of next.entries) {
|
|
115
|
+
const retained = old.get(entry.id);
|
|
116
|
+
if (!retained) {
|
|
117
|
+
if (entry.createdAt !== next.modifiedAt || entry.modifiedAt !== next.modifiedAt || entry.withdrawal)
|
|
118
|
+
throw new TypeError('A new indicator must start in the new revision.');
|
|
119
|
+
changes.push({ id: entry.id, domain: entry.domain, kind: 'added' });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
old.delete(entry.id);
|
|
123
|
+
if (canonicalArtifactJsonV2(retained) === canonicalArtifactJsonV2(entry))
|
|
124
|
+
continue;
|
|
125
|
+
if (retained.withdrawal || entry.modifiedAt !== next.modifiedAt || retained.domain !== entry.domain || retained.createdAt !== entry.createdAt
|
|
126
|
+
|| retained.basis !== entry.basis || canonicalArtifactJsonV2(retained.observation) !== canonicalArtifactJsonV2(entry.observation)
|
|
127
|
+
|| (entry.withdrawal && retained.expiresAt !== entry.expiresAt))
|
|
128
|
+
throw new TypeError('A revision cannot rewrite observations or modify a withdrawn identity.');
|
|
129
|
+
changes.push({ id: entry.id, domain: entry.domain, kind: entry.withdrawal ? 'withdrawn' : 'renewed' });
|
|
130
|
+
}
|
|
131
|
+
if (old.size)
|
|
132
|
+
throw new TypeError('A revision cannot remove retained indicator history.');
|
|
133
|
+
if (!changes.length)
|
|
134
|
+
throw new TypeError('No indicator change was selected.');
|
|
135
|
+
return changes;
|
|
136
|
+
}
|
|
137
|
+
export async function buildManagedIndicatorRevision(input, now = new Date().toISOString()) {
|
|
138
|
+
bounded(input, MAX_MANAGED_INDICATOR_PLAN_BYTES);
|
|
139
|
+
const plan = exactOptional(structuredClone(input), ['basis'], ['name', 'previous', 'rows', 'selectedDomains', 'officialDomains', 'allowlistedDomains', 'commonInfrastructureDomains', 'renewIds', 'withdrawIds', 'reintroduceDomains', 'expiresAt'], 'Indicator revision plan');
|
|
140
|
+
const previous = plan.previous === undefined ? null : await readManagedIndicatorSet(plan.previous);
|
|
141
|
+
const generatedAt = timestamp(now, 'Revision time');
|
|
142
|
+
const reviewBasis = basis(plan.basis);
|
|
143
|
+
const name = plan.name === undefined && previous ? previous.name : text(plan.name, 'Indicator set name', 120).trim();
|
|
144
|
+
if (!name || (previous && name !== previous.name))
|
|
145
|
+
throw new TypeError('A revision retains the original set name.');
|
|
146
|
+
function values(key, maximum, read) {
|
|
147
|
+
const result = array(plan[key] ?? [], key, maximum).map(read);
|
|
148
|
+
if (new Set(result).size !== result.length)
|
|
149
|
+
throw new TypeError('Revision selections must not contain duplicates.');
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
const domains = (key) => values(key, MAX_DEFENSIVE_INDICATOR_INPUTS, item => { domain(item, 'Selected domain'); return item; });
|
|
153
|
+
const selectedDomains = domains('selectedDomains');
|
|
154
|
+
const preflight = prepareDefensiveIndicatorExport(array(plan.rows ?? [], 'Reviewed rows', MAX_DEFENSIVE_INDICATOR_INPUTS), {
|
|
155
|
+
selectedDomains, officialDomains: domains('officialDomains'), allowlistedDomains: domains('allowlistedDomains'), commonInfrastructureDomains: domains('commonInfrastructureDomains'),
|
|
156
|
+
}, MAX_MANAGED_INDICATORS);
|
|
157
|
+
if (preflight.truncated)
|
|
158
|
+
throw new TypeError('The selected revision exceeds the managed-set entry limit; no partial revision was created.');
|
|
159
|
+
const renew = new Set(values('renewIds', MAX_MANAGED_INDICATORS, uuid));
|
|
160
|
+
const withdraw = new Set(values('withdrawIds', MAX_MANAGED_INDICATORS, uuid));
|
|
161
|
+
const reintroduce = new Set(domains('reintroduceDomains'));
|
|
162
|
+
if ([...renew].some(id => withdraw.has(id)))
|
|
163
|
+
throw new TypeError('An indicator cannot be renewed and withdrawn in one revision.');
|
|
164
|
+
const entries = structuredClone(previous?.entries ?? []);
|
|
165
|
+
const known = new Map(entries.map(entry => [entry.id, entry]));
|
|
166
|
+
for (const id of [...renew, ...withdraw])
|
|
167
|
+
if (!known.has(id) || known.get(id).withdrawal)
|
|
168
|
+
throw new TypeError('Select an existing, non-withdrawn indicator identity.');
|
|
169
|
+
const active = new Set(entries.filter(entry => !entry.withdrawal).map(entry => entry.domain));
|
|
170
|
+
const withdrawn = new Set(entries.filter(entry => entry.withdrawal).map(entry => entry.domain));
|
|
171
|
+
const additions = preflight.entries.filter(entry => !active.has(entry.domain));
|
|
172
|
+
if ([...reintroduce].some(value => !withdrawn.has(value) || !additions.some(entry => entry.domain === value)))
|
|
173
|
+
throw new TypeError('Reintroduction must select a withdrawn domain being added with a new identity.');
|
|
174
|
+
for (const entry of additions)
|
|
175
|
+
if (withdrawn.has(entry.domain) && !reintroduce.has(entry.domain))
|
|
176
|
+
throw new TypeError('Explicitly approve a new identity before reintroducing a withdrawn domain.');
|
|
177
|
+
let expiresAt = null;
|
|
178
|
+
if (additions.length || renew.size) {
|
|
179
|
+
expiresAt = timestamp(plan.expiresAt, 'New review expiry');
|
|
180
|
+
if (Date.parse(expiresAt) <= Date.parse(generatedAt))
|
|
181
|
+
throw new TypeError('The new review expiry must be in the future.');
|
|
182
|
+
}
|
|
183
|
+
else if (plan.expiresAt !== undefined)
|
|
184
|
+
timestamp(plan.expiresAt, 'New review expiry');
|
|
185
|
+
for (const entry of entries) {
|
|
186
|
+
if (renew.has(entry.id)) {
|
|
187
|
+
entry.expiresAt = expiresAt;
|
|
188
|
+
entry.modifiedAt = generatedAt;
|
|
189
|
+
entry.reviewBasis = reviewBasis;
|
|
190
|
+
}
|
|
191
|
+
if (withdraw.has(entry.id)) {
|
|
192
|
+
entry.withdrawal = { at: generatedAt, reason: reviewBasis };
|
|
193
|
+
entry.modifiedAt = generatedAt;
|
|
194
|
+
entry.reviewBasis = reviewBasis;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const randomId = () => globalThis.crypto.randomUUID();
|
|
198
|
+
for (const entry of additions)
|
|
199
|
+
entries.push({ id: randomId(), domain: entry.domain, createdAt: generatedAt, modifiedAt: generatedAt, expiresAt: expiresAt, basis: reviewBasis, reviewBasis,
|
|
200
|
+
observation: { availability: entry.source.availability, ...defensiveIndicatorProvenance(entry.source) }, withdrawal: null });
|
|
201
|
+
if (!additions.length && !renew.size && !withdraw.size)
|
|
202
|
+
throw new TypeError('No eligible addition, renewal or withdrawal was selected.');
|
|
203
|
+
const unsigned = { schema: MANAGED_INDICATOR_SET_SCHEMA, version: MANAGED_INDICATOR_SET_VERSION,
|
|
204
|
+
id: previous?.id ?? randomId(), producerId: previous?.producerId ?? randomId(), revisionId: randomId(), name,
|
|
205
|
+
revision: (previous?.revision ?? 0) + 1, createdAt: previous?.createdAt ?? generatedAt, modifiedAt: generatedAt,
|
|
206
|
+
previous: previous ? { revisionId: previous.revisionId, digestSha256: previous.integrity.digestSha256 } : null, entries };
|
|
207
|
+
const manifest = validateManagedIndicatorSet({ ...unsigned, integrity: { algorithm: 'SHA-256', canonicalization: SORTED_JSON_V2, digestSha256: await sha256ArtifactDigestV2(unsigned) } });
|
|
208
|
+
const changes = previous ? compareManagedIndicatorRevisions(previous, manifest) : manifest.entries.map(entry => ({ id: entry.id, domain: entry.domain, kind: 'added' }));
|
|
209
|
+
return { manifest, changes, unchanged: manifest.entries.length - changes.length, exclusions: preflight.exclusions };
|
|
210
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Pure MISP event JSON for local analyst import. The event stays unpublished,
|
|
2
|
+
// organization-only, non-IDS, and non-correlating until a MISP user reviews it.
|
|
3
|
+
import { collectDefensiveIndicatorCandidates, defensiveIndicatorProvenance, MAX_DEFENSIVE_INDICATORS, } from "./defensive-indicator-export.mjs";
|
|
4
|
+
import { MISP_INDICATOR_EXPORT_VERSION } from "../contracts/analyst-interchange.mjs";
|
|
5
|
+
export { MISP_INDICATOR_EXPORT_VERSION } from "../contracts/analyst-interchange.mjs";
|
|
6
|
+
export const MAX_MISP_ATTRIBUTES = MAX_DEFENSIVE_INDICATORS;
|
|
7
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
8
|
+
const CONTROL_RE = /[\x00-\x1f\x7f]/;
|
|
9
|
+
const WARNING = 'Review before operational use; false positives are possible.';
|
|
10
|
+
function record(value) {
|
|
11
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
12
|
+
? value
|
|
13
|
+
: {};
|
|
14
|
+
}
|
|
15
|
+
function isoTimestamp(value) {
|
|
16
|
+
if (typeof value !== 'string' || value.length > 64 || CONTROL_RE.test(value))
|
|
17
|
+
return null;
|
|
18
|
+
const parsed = Date.parse(value);
|
|
19
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
20
|
+
}
|
|
21
|
+
function defaultUuidFactory() {
|
|
22
|
+
const uuid = globalThis.crypto?.randomUUID?.();
|
|
23
|
+
if (!uuid)
|
|
24
|
+
throw new Error('Secure random identifiers are unavailable for the MISP export.');
|
|
25
|
+
return uuid;
|
|
26
|
+
}
|
|
27
|
+
function nextUuid(uuidFactory, used) {
|
|
28
|
+
const value = uuidFactory();
|
|
29
|
+
if (typeof value !== 'string' || !UUID_RE.test(value)) {
|
|
30
|
+
throw new Error('The MISP identifier factory returned an invalid UUID.');
|
|
31
|
+
}
|
|
32
|
+
const uuid = value.toLowerCase();
|
|
33
|
+
if (used.has(uuid))
|
|
34
|
+
throw new Error('The MISP identifier factory returned a duplicate UUID.');
|
|
35
|
+
used.add(uuid);
|
|
36
|
+
return uuid;
|
|
37
|
+
}
|
|
38
|
+
function attributeComment(availability, provenance) {
|
|
39
|
+
const { riskScore: score, riskModelVersion: modelVersion, scanDepth, observedAt } = provenance;
|
|
40
|
+
return [
|
|
41
|
+
'Heuristic Bulk finding',
|
|
42
|
+
`availability=${availability}`,
|
|
43
|
+
`risk=${score ?? 'unknown'}`,
|
|
44
|
+
...(modelVersion === null ? [] : [`risk-model=v${modelVersion}`]),
|
|
45
|
+
`scan-depth=${scanDepth}`,
|
|
46
|
+
`observed-at=${observedAt ?? 'unknown'}`,
|
|
47
|
+
`timestamp-basis=${observedAt ? 'scan' : 'unknown'}`,
|
|
48
|
+
WARNING,
|
|
49
|
+
].join('; ');
|
|
50
|
+
}
|
|
51
|
+
export function mispIndicatorAttribute(domain, availability, provenance, uuid, modifiedAt) {
|
|
52
|
+
return {
|
|
53
|
+
uuid, type: 'domain', category: 'Network activity', value: domain,
|
|
54
|
+
to_ids: false, distribution: '5', timestamp: String(Math.floor(Date.parse(modifiedAt) / 1000)),
|
|
55
|
+
...(provenance.observedAt ? { first_seen: provenance.observedAt, last_seen: provenance.observedAt } : {}),
|
|
56
|
+
comment: attributeComment(availability, provenance), disable_correlation: true, deleted: false,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export function mispIndicatorEvent(uuid, generatedAt, attributes) {
|
|
60
|
+
return { uuid, date: generatedAt.slice(0, 10),
|
|
61
|
+
info: `WHOISleuth heuristic defensive-domain candidates for analyst review (export v${MISP_INDICATOR_EXPORT_VERSION})`,
|
|
62
|
+
threat_level_id: '4', analysis: '0', distribution: '0', published: false,
|
|
63
|
+
timestamp: String(Math.floor(Date.parse(generatedAt) / 1000)), publish_timestamp: '0', disable_correlation: true, Attribute: attributes };
|
|
64
|
+
}
|
|
65
|
+
export function buildMispIndicatorExport(records, options = {}) {
|
|
66
|
+
const generatedAt = isoTimestamp(options.generatedAt) || new Date().toISOString();
|
|
67
|
+
const uuidFactory = typeof options.uuidFactory === 'function'
|
|
68
|
+
? options.uuidFactory
|
|
69
|
+
: defaultUuidFactory;
|
|
70
|
+
let collected;
|
|
71
|
+
try {
|
|
72
|
+
collected = collectDefensiveIndicatorCandidates(records, MAX_MISP_ATTRIBUTES);
|
|
73
|
+
}
|
|
74
|
+
catch (cause) {
|
|
75
|
+
if (cause instanceof TypeError)
|
|
76
|
+
throw new TypeError('MISP indicator export requires an array of Bulk results.');
|
|
77
|
+
throw cause;
|
|
78
|
+
}
|
|
79
|
+
const used = new Set();
|
|
80
|
+
const eventUuid = nextUuid(uuidFactory, used);
|
|
81
|
+
const attributes = collected.entries.map(({ domain, source }) => mispIndicatorAttribute(domain, record(source).availability, defensiveIndicatorProvenance(source), nextUuid(uuidFactory, used), generatedAt));
|
|
82
|
+
const payload = { Event: mispIndicatorEvent(eventUuid, generatedAt, attributes) };
|
|
83
|
+
return {
|
|
84
|
+
version: MISP_INDICATOR_EXPORT_VERSION,
|
|
85
|
+
format: 'misp',
|
|
86
|
+
generatedAt,
|
|
87
|
+
domains: collected.domains,
|
|
88
|
+
truncated: collected.truncated,
|
|
89
|
+
filename: `whoisleuth-defensive-domains-${generatedAt.slice(0, 10)}.misp.json`,
|
|
90
|
+
mimeType: 'application/json;charset=utf-8',
|
|
91
|
+
content: `${JSON.stringify(payload, null, 2)}\n`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Sharing declarations are not encryption, redaction or an authority to disclose. */
|
|
2
|
+
export const TLP_MARKINGS = ['clear', 'green', 'amber', 'amber-strict', 'red'];
|
|
3
|
+
export const RECIPIENT_SCOPES = ['public', 'community', 'organization', 'named-recipients'];
|
|
4
|
+
const RISKY_KEYS = new Set([
|
|
5
|
+
'authorization', 'cookie', 'cookies', 'credential', 'credentials', 'email', 'emails',
|
|
6
|
+
'entities', 'password', 'phone', 'raw', 'rawrdap', 'rawwhois', 'registrant', 'session', 'token',
|
|
7
|
+
]);
|
|
8
|
+
const MARKING_KEYS = new Set([
|
|
9
|
+
'informationmarking', 'marking', 'markings', 'sharingmarking',
|
|
10
|
+
'tlp', 'tlplabel', 'tlpmarking', 'trafficlightprotocol',
|
|
11
|
+
]);
|
|
12
|
+
export function markingFromText(value) {
|
|
13
|
+
const normalized = value.toUpperCase().replace(/\s+/gu, '').replace('TLP:', '').replace('+STRICT', '-STRICT').toLowerCase();
|
|
14
|
+
return TLP_MARKINGS.includes(normalized) ? normalized : null;
|
|
15
|
+
}
|
|
16
|
+
export function expectedScope(marking) {
|
|
17
|
+
if (marking === 'clear')
|
|
18
|
+
return 'public';
|
|
19
|
+
if (marking === 'green')
|
|
20
|
+
return 'community';
|
|
21
|
+
if (marking === 'red')
|
|
22
|
+
return 'named-recipients';
|
|
23
|
+
return 'organization';
|
|
24
|
+
}
|
|
25
|
+
export function tlpLabel(marking) {
|
|
26
|
+
return `TLP:${marking === 'amber-strict' ? 'AMBER+STRICT' : marking.toUpperCase()}`;
|
|
27
|
+
}
|
|
28
|
+
/** Scan an already admitted, byte/node/depth-bounded JSON tree, not arbitrary objects. */
|
|
29
|
+
export function scanSharingMetadata(root) {
|
|
30
|
+
const stack = [{ value: root, marking: false }];
|
|
31
|
+
const markings = new Set();
|
|
32
|
+
let riskyKeyCount = 0;
|
|
33
|
+
while (stack.length) {
|
|
34
|
+
const next = stack.pop();
|
|
35
|
+
if (typeof next.value === 'string') {
|
|
36
|
+
if (next.marking) {
|
|
37
|
+
const marking = markingFromText(next.value);
|
|
38
|
+
if (marking)
|
|
39
|
+
markings.add(marking);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
else if (Array.isArray(next.value)) {
|
|
43
|
+
for (const value of next.value)
|
|
44
|
+
stack.push({ value, marking: next.marking });
|
|
45
|
+
}
|
|
46
|
+
else if (next.value && typeof next.value === 'object') {
|
|
47
|
+
for (const [key, value] of Object.entries(next.value)) {
|
|
48
|
+
const normalized = key.toLowerCase().replace(/[^a-z0-9]/gu, '');
|
|
49
|
+
if (RISKY_KEYS.has(normalized))
|
|
50
|
+
riskyKeyCount++;
|
|
51
|
+
stack.push({ value, marking: MARKING_KEYS.has(normalized) });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { importedMarkings: [...markings], riskyKeyCount };
|
|
56
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Pure STIX 2.1 interchange for locally reviewed Bulk candidates. Direct
|
|
2
|
+
// observations and heuristic inferences remain separate objects so consumers
|
|
3
|
+
// do not mistake a Risk score for confirmed maliciousness.
|
|
4
|
+
import { collectDefensiveIndicatorCandidates, defensiveIndicatorProvenance, } from "./defensive-indicator-export.mjs";
|
|
5
|
+
import { STIX_INDICATOR_EXPORT_VERSION } from "../contracts/analyst-interchange.mjs";
|
|
6
|
+
export { STIX_INDICATOR_EXPORT_VERSION } from "../contracts/analyst-interchange.mjs";
|
|
7
|
+
export const MAX_STIX_INDICATORS = 1000;
|
|
8
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
9
|
+
const CONTROL_RE = /[\x00-\x1f\x7f]/;
|
|
10
|
+
const WARNING = 'Heuristic finding; review before operational use because false positives are possible.';
|
|
11
|
+
function record(value) {
|
|
12
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
13
|
+
? value
|
|
14
|
+
: {};
|
|
15
|
+
}
|
|
16
|
+
function isoTimestamp(value) {
|
|
17
|
+
if (typeof value !== 'string' || value.length > 64 || CONTROL_RE.test(value))
|
|
18
|
+
return null;
|
|
19
|
+
const parsed = Date.parse(value);
|
|
20
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
21
|
+
}
|
|
22
|
+
function defaultIdFactory(type) {
|
|
23
|
+
const uuid = globalThis.crypto?.randomUUID?.();
|
|
24
|
+
if (!uuid)
|
|
25
|
+
throw new Error('Secure random identifiers are unavailable for the STIX export.');
|
|
26
|
+
return `${type}--${uuid}`;
|
|
27
|
+
}
|
|
28
|
+
function stixId(type, idFactory) {
|
|
29
|
+
const value = idFactory(type);
|
|
30
|
+
const prefix = `${type}--`;
|
|
31
|
+
if (typeof value !== 'string' || !value.startsWith(prefix) || !UUID_RE.test(value.slice(prefix.length))) {
|
|
32
|
+
throw new Error(`The STIX identifier factory returned an invalid ${type} identifier.`);
|
|
33
|
+
}
|
|
34
|
+
return value.toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
export function stixIndicatorProducer(producerId, generatedAt) {
|
|
37
|
+
return {
|
|
38
|
+
type: 'identity', spec_version: '2.1', id: producerId,
|
|
39
|
+
created: generatedAt, modified: generatedAt,
|
|
40
|
+
name: 'WHOISleuth', identity_class: 'system',
|
|
41
|
+
description: 'Producer of locally generated defensive-domain observations and heuristic inferences.',
|
|
42
|
+
x_whoisleuth_export_version: STIX_INDICATOR_EXPORT_VERSION,
|
|
43
|
+
x_whoisleuth_generated_at: generatedAt,
|
|
44
|
+
x_whoisleuth_false_positive_warning: WARNING,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** The caller owns validated candidates and either fresh or retained identities. */
|
|
48
|
+
export function stixIndicatorObjects(domain, availability, provenance, generatedAt, producerId, nextId) {
|
|
49
|
+
const { observedAt, riskScore: score, riskModelVersion: modelVersion } = provenance;
|
|
50
|
+
const domainId = nextId('domain-name');
|
|
51
|
+
const observationId = nextId(observedAt ? 'observed-data' : 'note');
|
|
52
|
+
const indicatorId = nextId('indicator');
|
|
53
|
+
const observationContext = {
|
|
54
|
+
created_by_ref: producerId, created: generatedAt, modified: generatedAt,
|
|
55
|
+
x_whoisleuth_observed_at_basis: observedAt ? 'scan' : 'unknown',
|
|
56
|
+
x_whoisleuth_source: 'bulk', x_whoisleuth_availability: availability,
|
|
57
|
+
x_whoisleuth_scan_depth: provenance.scanDepth,
|
|
58
|
+
};
|
|
59
|
+
const objects = [
|
|
60
|
+
{ type: 'domain-name', spec_version: '2.1', id: domainId, value: domain },
|
|
61
|
+
observedAt ? {
|
|
62
|
+
type: 'observed-data', spec_version: '2.1', id: observationId, ...observationContext,
|
|
63
|
+
first_observed: observedAt, last_observed: observedAt, number_observed: 1, object_refs: [domainId],
|
|
64
|
+
x_whoisleuth_evidence_kind: 'direct-observation',
|
|
65
|
+
} : {
|
|
66
|
+
type: 'note', spec_version: '2.1', id: observationId, ...observationContext,
|
|
67
|
+
content: 'Observation time was not retained. This candidate is supplied for heuristic review; its export time is not a sighting time.',
|
|
68
|
+
object_refs: [domainId, indicatorId], x_whoisleuth_evidence_kind: 'observation-context',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
type: 'indicator', spec_version: '2.1', id: indicatorId,
|
|
72
|
+
created_by_ref: producerId, created: generatedAt, modified: generatedAt,
|
|
73
|
+
name: `Heuristic domain candidate: ${domain}`, description: WARNING,
|
|
74
|
+
pattern: `[domain-name:value = '${domain}']`, pattern_type: 'stix', pattern_version: '2.1',
|
|
75
|
+
valid_from: generatedAt, x_whoisleuth_validity_basis: 'export',
|
|
76
|
+
labels: ['heuristic', 'defensive-review'], x_whoisleuth_evidence_kind: 'heuristic-inference',
|
|
77
|
+
x_whoisleuth_risk_score: score,
|
|
78
|
+
...(modelVersion === null ? {} : { x_whoisleuth_risk_model_version: modelVersion }),
|
|
79
|
+
x_whoisleuth_false_positive_warning: WARNING,
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
if (observedAt)
|
|
83
|
+
objects.push({
|
|
84
|
+
type: 'relationship', spec_version: '2.1', id: nextId('relationship'),
|
|
85
|
+
created_by_ref: producerId, created: generatedAt, modified: generatedAt,
|
|
86
|
+
relationship_type: 'based-on', source_ref: indicatorId, target_ref: observationId,
|
|
87
|
+
description: 'The heuristic Indicator is based on the separately represented domain observation.',
|
|
88
|
+
});
|
|
89
|
+
return objects;
|
|
90
|
+
}
|
|
91
|
+
export function buildStixIndicatorExport(records, options = {}) {
|
|
92
|
+
const generatedAt = isoTimestamp(options.generatedAt) || new Date().toISOString();
|
|
93
|
+
const idFactory = typeof options.idFactory === 'function'
|
|
94
|
+
? options.idFactory
|
|
95
|
+
: defaultIdFactory;
|
|
96
|
+
let collected;
|
|
97
|
+
try {
|
|
98
|
+
collected = collectDefensiveIndicatorCandidates(records, MAX_STIX_INDICATORS);
|
|
99
|
+
}
|
|
100
|
+
catch (cause) {
|
|
101
|
+
if (cause instanceof TypeError)
|
|
102
|
+
throw new TypeError('STIX indicator export requires an array of Bulk results.');
|
|
103
|
+
throw cause;
|
|
104
|
+
}
|
|
105
|
+
const usedIds = new Set();
|
|
106
|
+
const nextId = (type) => {
|
|
107
|
+
const id = stixId(type, idFactory);
|
|
108
|
+
if (usedIds.has(id))
|
|
109
|
+
throw new Error('The STIX identifier factory returned a duplicate identifier.');
|
|
110
|
+
usedIds.add(id);
|
|
111
|
+
return id;
|
|
112
|
+
};
|
|
113
|
+
const producerId = nextId('identity');
|
|
114
|
+
const objects = [stixIndicatorProducer(producerId, generatedAt)];
|
|
115
|
+
for (const { domain, source } of collected.entries) {
|
|
116
|
+
objects.push(...stixIndicatorObjects(domain, record(source).availability, defensiveIndicatorProvenance(source), generatedAt, producerId, nextId));
|
|
117
|
+
}
|
|
118
|
+
const bundle = { type: 'bundle', id: nextId('bundle'), objects };
|
|
119
|
+
return {
|
|
120
|
+
version: STIX_INDICATOR_EXPORT_VERSION,
|
|
121
|
+
format: 'stix',
|
|
122
|
+
generatedAt,
|
|
123
|
+
domains: collected.domains,
|
|
124
|
+
truncated: collected.truncated,
|
|
125
|
+
filename: `whoisleuth-defensive-domains-${generatedAt.slice(0, 10)}.stix.json`,
|
|
126
|
+
mimeType: 'application/stix+json;charset=utf-8',
|
|
127
|
+
content: `${JSON.stringify(bundle, null, 2)}\n`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { normalizeDomain } from "../
|
|
2
|
-
import { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, MAX_EXTERNAL_FINDING_DOMAINS, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, MAX_EXTERNAL_FINDING_SUMMARY_LENGTH, parseExternalFindingsDocument, } from "./external-findings-import.mjs";
|
|
3
|
-
import { MAX_WEB_CAPTURE_DOM_DIGEST_BYTES, MAX_WEB_CAPTURE_SCREENSHOT_BYTES, WEB_CAPTURE_MANIFEST_SCHEMA, WEB_CAPTURE_MANIFEST_VERSION, WEB_CAPTURE_SUMMARY_SCHEMA, WEB_CAPTURE_SUMMARY_VERSION, } from "../contracts/web-capture.mjs";
|
|
1
|
+
import { normalizeDomain } from "../evidence/domain-name.mjs";
|
|
2
|
+
import { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, MAX_EXTERNAL_FINDING_DOMAINS, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, MAX_EXTERNAL_FINDING_SUMMARY_LENGTH, parseExternalFindingsDocument, retainExternalFindingLimitations, } from "./external-findings-import.mjs";
|
|
3
|
+
import { MAX_WEB_CAPTURE_DOM_DIGEST_BYTES, MAX_WEB_CAPTURE_SCREENSHOT_BYTES, WEB_CAPTURE_MANIFEST_SCHEMA, WEB_CAPTURE_MANIFEST_VERSION, WEB_CAPTURE_MANIFEST_SUPPORTED_VERSIONS, WEB_CAPTURE_SUMMARY_SCHEMA, WEB_CAPTURE_SUMMARY_VERSION, } from "../contracts/web-capture.mjs";
|
|
4
4
|
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
5
|
+
import { readManifestPageBehaviour } from "../investigation/page-behaviour.mjs";
|
|
5
6
|
import { readCaptureConditions, readObservationLabel } from "../comparison/capture-context.mjs";
|
|
6
7
|
export { WEB_CAPTURE_MANIFEST_SCHEMA, WEB_CAPTURE_MANIFEST_VERSION, WEB_CAPTURE_SUMMARY_SCHEMA, WEB_CAPTURE_SUMMARY_VERSION, } from "../contracts/web-capture.mjs";
|
|
7
8
|
export const MAX_WEB_CAPTURE_SUMMARIES = 50;
|
|
@@ -25,6 +26,7 @@ const CAPTURE_KEYS = new Set([
|
|
|
25
26
|
'networkOrigins',
|
|
26
27
|
]);
|
|
27
28
|
const MANIFEST_CAPTURE_KEYS = new Set([
|
|
29
|
+
'pageBehaviour',
|
|
28
30
|
'conditions', 'observerLabel', 'vantageLabel',
|
|
29
31
|
'domain',
|
|
30
32
|
'capturedAt',
|
|
@@ -151,17 +153,17 @@ export function parseWebCaptureSummary(value) {
|
|
|
151
153
|
].filter(Boolean);
|
|
152
154
|
if (!summaries.length)
|
|
153
155
|
throw new Error(`Web capture ${index + 1} contains no supported summary evidence.`);
|
|
156
|
+
const retainedLimitations = retainExternalFindingLimitations(limitations, [
|
|
157
|
+
'Imported sanitised capture summary; WHOISleuth did not collect or independently verify this observation.',
|
|
158
|
+
]);
|
|
154
159
|
findings.push({
|
|
155
160
|
domain,
|
|
156
161
|
category: technologies.length && summaries.length === 1 ? 'http' : 'page',
|
|
157
162
|
evidenceClass: 'deployment_observation',
|
|
158
163
|
summary: summaries.join(' '),
|
|
159
164
|
observedAt,
|
|
160
|
-
completeness,
|
|
161
|
-
limitations:
|
|
162
|
-
'Imported sanitised capture summary; WHOISleuth did not collect or independently verify this observation.',
|
|
163
|
-
...limitations,
|
|
164
|
-
].slice(0, 8),
|
|
165
|
+
completeness: limitations.every((item) => retainedLimitations.includes(item)) ? completeness : 'partial',
|
|
166
|
+
limitations: retainedLimitations,
|
|
165
167
|
reference: sourceReference,
|
|
166
168
|
});
|
|
167
169
|
}
|
|
@@ -230,7 +232,7 @@ export function readWebCaptureManifest(value) {
|
|
|
230
232
|
if (!root
|
|
231
233
|
|| !onlyKeys(root, ROOT_KEYS)
|
|
232
234
|
|| root.schema !== WEB_CAPTURE_MANIFEST_SCHEMA
|
|
233
|
-
|| root.schemaVersion
|
|
235
|
+
|| !WEB_CAPTURE_MANIFEST_SUPPORTED_VERSIONS.includes(root.schemaVersion)) {
|
|
234
236
|
throw new Error(`Web capture manifests must use ${WEB_CAPTURE_MANIFEST_SCHEMA} schema version ${WEB_CAPTURE_MANIFEST_VERSION}.`);
|
|
235
237
|
}
|
|
236
238
|
const source = record(root.source);
|
|
@@ -270,7 +272,10 @@ export function readWebCaptureManifest(value) {
|
|
|
270
272
|
? capture.completeness
|
|
271
273
|
: 'unknown';
|
|
272
274
|
const limitations = stringList(capture.limitations, 8, 240, `Web capture manifest ${index + 1} limitations`);
|
|
273
|
-
|
|
275
|
+
const pageBehaviour = readManifestPageBehaviour(capture.pageBehaviour, root.schemaVersion);
|
|
276
|
+
if (pageBehaviour?.state === 'partial' && completeness === 'complete')
|
|
277
|
+
throw new Error('Partial page observations cannot declare a complete capture.');
|
|
278
|
+
contexts.push({ domain, observedAt, completeness: String(completeness), conditions, observerLabel, vantageLabel, pageBehaviour });
|
|
274
279
|
const page = record(capture.page);
|
|
275
280
|
if (page && !onlyKeys(page, PAGE_KEYS))
|
|
276
281
|
throw new Error(`Web capture manifest ${index + 1} page metadata contains unsupported fields.`);
|
|
@@ -334,6 +339,8 @@ export function readWebCaptureManifest(value) {
|
|
|
334
339
|
: '',
|
|
335
340
|
...listSummaryFragments('Observed technology labels', technologies),
|
|
336
341
|
...listSummaryFragments('Observed request domains', requestDomains),
|
|
342
|
+
...(pageBehaviour ? [`Page observations: ${pageBehaviour.requests.length} navigation/script/frame responses; ${pageBehaviour.elements.length} script/frame/form elements; ${pageBehaviour.clipboardWriteAttempts} blocked Clipboard API write attempts. State: ${pageBehaviour.state}. Full observations remain in the selected manifest.`,
|
|
343
|
+
...listSummaryFragments('Requested-action wording in body text', pageBehaviour.actionHints)] : []),
|
|
337
344
|
...artifactSummaries,
|
|
338
345
|
].filter(Boolean);
|
|
339
346
|
const summaries = partitionSummary(summaryFragments);
|
|
@@ -342,6 +349,9 @@ export function readWebCaptureManifest(value) {
|
|
|
342
349
|
throw new Error(`Web capture manifests exceed the ${MAX_EXTERNAL_FINDINGS_PER_DOMAIN}-finding per-domain import limit after preserving bounded metadata.`);
|
|
343
350
|
}
|
|
344
351
|
findingCounts.set(domain, priorFindingCount + summaries.length);
|
|
352
|
+
const retainedLimitations = retainExternalFindingLimitations(limitations, [
|
|
353
|
+
'Imported capture metadata, not independently collected website evidence; artefact bytes and separate byte checks are not retained in these findings.',
|
|
354
|
+
]);
|
|
345
355
|
for (const summary of summaries) {
|
|
346
356
|
findings.push({
|
|
347
357
|
domain,
|
|
@@ -349,11 +359,8 @@ export function readWebCaptureManifest(value) {
|
|
|
349
359
|
evidenceClass: 'deployment_observation',
|
|
350
360
|
summary,
|
|
351
361
|
observedAt,
|
|
352
|
-
completeness,
|
|
353
|
-
limitations:
|
|
354
|
-
'Imported capture metadata, not independently collected website evidence; artefact bytes and separate byte checks are not retained in these findings.',
|
|
355
|
-
...limitations,
|
|
356
|
-
].slice(0, 8),
|
|
362
|
+
completeness: limitations.every((item) => retainedLimitations.includes(item)) ? completeness : 'partial',
|
|
363
|
+
limitations: retainedLimitations,
|
|
357
364
|
reference: sourceReference,
|
|
358
365
|
});
|
|
359
366
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SORTED_JSON_V2, sha256ArtifactDigestV2 } from "../evidence/artifact-integrity.mjs";
|
|
2
2
|
import { ACQUISITION_DECISION_PACKET_SCHEMA, ACQUISITION_DECISION_PACKET_VERSION, } from "../contracts/investigation-portability.mjs";
|
|
3
|
-
import { isValidAsciiDomainName } from "
|
|
3
|
+
import { isValidAsciiDomainName } from "../contracts/domain-name.mjs";
|
|
4
4
|
export { ACQUISITION_DECISION_PACKET_SCHEMA, ACQUISITION_DECISION_PACKET_VERSION };
|
|
5
5
|
export const ACQUISITION_DECISIONS = [
|
|
6
6
|
'unresolved',
|