@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,117 @@
|
|
|
1
|
+
import { decode, convertIndexedToRgb, hasPngSignature } from 'fast-png';
|
|
2
|
+
import { Unzlib } from 'fflate';
|
|
3
|
+
import { updateCrc32 } from "../interchange/crc32.mjs";
|
|
4
|
+
import { readEvidenceImageDimensions } from "./image-regions.mjs";
|
|
5
|
+
/** Admit PNG structure and inflated work before the image library allocates it. */
|
|
6
|
+
export function decodeEvidencePng(bytes) {
|
|
7
|
+
if (!(bytes instanceof Uint8Array) || !(bytes.buffer instanceof ArrayBuffer) || !hasPngSignature(bytes) || bytes.byteLength < 33)
|
|
8
|
+
throw new TypeError('Select a valid PNG image.');
|
|
9
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
10
|
+
if (view.getUint32(8) !== 13 || String.fromCharCode(...bytes.subarray(12, 16)) !== 'IHDR')
|
|
11
|
+
throw new TypeError('The PNG header is malformed.');
|
|
12
|
+
const { width, height } = readEvidenceImageDimensions(view.getUint32(16), view.getUint32(20));
|
|
13
|
+
if (bytes[24] < 8 && bytes[28] !== 0)
|
|
14
|
+
throw new TypeError('Convert this interlaced low-bit-depth PNG to a non-interlaced image before review.');
|
|
15
|
+
const maximumInflatedBytes = width * height * 8 + height * 8 + 64;
|
|
16
|
+
let inflatedBytes = 0;
|
|
17
|
+
const inflater = new Unzlib(chunk => {
|
|
18
|
+
inflatedBytes += chunk.byteLength;
|
|
19
|
+
if (inflatedBytes > maximumInflatedBytes)
|
|
20
|
+
throw new TypeError('The PNG exceeds its dimension-derived decompression bound.');
|
|
21
|
+
});
|
|
22
|
+
const selected = [bytes.subarray(0, 8)];
|
|
23
|
+
let offset = 8, total = 8, ended = false, imageData = false, dataEnded = false, header = false, palette = false, transparency = false;
|
|
24
|
+
while (offset < bytes.byteLength) {
|
|
25
|
+
if (bytes.byteLength - offset < 12)
|
|
26
|
+
throw new TypeError('The PNG contains a truncated chunk.');
|
|
27
|
+
const length = view.getUint32(offset), end = offset + length + 12;
|
|
28
|
+
if (end > bytes.byteLength)
|
|
29
|
+
throw new TypeError('The PNG contains a truncated chunk.');
|
|
30
|
+
const kind = String.fromCharCode(...bytes.subarray(offset + 4, offset + 8));
|
|
31
|
+
if (!/^[A-Za-z]{4}$/u.test(kind))
|
|
32
|
+
throw new TypeError('The PNG contains an invalid chunk type.');
|
|
33
|
+
const crc = (updateCrc32(0xffff_ffff, bytes.subarray(offset + 4, end - 4)) ^ 0xffff_ffff) >>> 0;
|
|
34
|
+
if (crc !== view.getUint32(end - 4))
|
|
35
|
+
throw new TypeError('The PNG chunk checksum does not match.');
|
|
36
|
+
if (kind === 'acTL' || kind === 'fcTL' || kind === 'fdAT')
|
|
37
|
+
throw new TypeError('Select a still PNG image; animated frames are not silently omitted.');
|
|
38
|
+
if (kind === 'IHDR') {
|
|
39
|
+
if (header || offset !== 8 || length !== 13)
|
|
40
|
+
throw new TypeError('The PNG has conflicting headers.');
|
|
41
|
+
header = true;
|
|
42
|
+
}
|
|
43
|
+
else if (kind === 'PLTE') {
|
|
44
|
+
if (palette || imageData || !length || length > 768 || length % 3)
|
|
45
|
+
throw new TypeError('The PNG palette is malformed.');
|
|
46
|
+
palette = true;
|
|
47
|
+
}
|
|
48
|
+
else if (kind === 'tRNS') {
|
|
49
|
+
if (transparency || imageData || length > 256)
|
|
50
|
+
throw new TypeError('The PNG transparency data is malformed.');
|
|
51
|
+
transparency = true;
|
|
52
|
+
}
|
|
53
|
+
else if (kind === 'IDAT') {
|
|
54
|
+
if (dataEnded)
|
|
55
|
+
throw new TypeError('The PNG image-data chunks are not consecutive.');
|
|
56
|
+
imageData = true;
|
|
57
|
+
// A small compressed slice bounds transient inflation before the callback.
|
|
58
|
+
for (let position = offset + 8; position < end - 4; position += 1_024)
|
|
59
|
+
inflater.push(bytes.subarray(position, Math.min(position + 1_024, end - 4)), false);
|
|
60
|
+
}
|
|
61
|
+
else if (kind === 'IEND') {
|
|
62
|
+
if (length || !imageData || end !== bytes.byteLength)
|
|
63
|
+
throw new TypeError('The PNG trailer is malformed.');
|
|
64
|
+
ended = true;
|
|
65
|
+
}
|
|
66
|
+
else if (kind[0] === kind[0].toUpperCase())
|
|
67
|
+
throw new TypeError('The PNG has an unsupported critical chunk.');
|
|
68
|
+
if (imageData && kind !== 'IDAT')
|
|
69
|
+
dataEnded = true;
|
|
70
|
+
// Colour-profile and text metadata are irrelevant to QR pixel analysis and
|
|
71
|
+
// may contain independent compressed payloads. Never decode those payloads.
|
|
72
|
+
if (['IHDR', 'PLTE', 'tRNS', 'IDAT', 'IEND'].includes(kind)) {
|
|
73
|
+
const part = bytes.subarray(offset, end);
|
|
74
|
+
selected.push(part);
|
|
75
|
+
total += part.byteLength;
|
|
76
|
+
}
|
|
77
|
+
offset = end;
|
|
78
|
+
}
|
|
79
|
+
if (!ended)
|
|
80
|
+
throw new TypeError('The PNG trailer is missing.');
|
|
81
|
+
inflater.push(new Uint8Array(), true);
|
|
82
|
+
const admitted = new Uint8Array(total);
|
|
83
|
+
let position = 0;
|
|
84
|
+
for (const part of selected) {
|
|
85
|
+
admitted.set(part, position);
|
|
86
|
+
position += part.byteLength;
|
|
87
|
+
}
|
|
88
|
+
let image = decode(admitted, { checkCrc: true });
|
|
89
|
+
if (image.width !== width || image.height !== height)
|
|
90
|
+
throw new TypeError('The decoded PNG dimensions changed.');
|
|
91
|
+
if (image.depth < 8 && !image.palette && image.channels === 1) {
|
|
92
|
+
const maximum = (1 << image.depth) - 1;
|
|
93
|
+
// Use the library's packed-sample conversion for grayscale as well as
|
|
94
|
+
// indexed images; a generated grayscale palette does not parse new bytes.
|
|
95
|
+
image = { ...image, palette: Array.from({ length: maximum + 1 }, (_, value) => {
|
|
96
|
+
const grey = Math.round(value * 255 / maximum);
|
|
97
|
+
return [grey, grey, grey, image.transparency?.[0] === value ? 0 : 255];
|
|
98
|
+
}) };
|
|
99
|
+
}
|
|
100
|
+
const indexed = Boolean(image.palette), samples = indexed ? convertIndexedToRgb(image) : image.data;
|
|
101
|
+
const channels = indexed ? image.palette[0].length : image.channels;
|
|
102
|
+
if (![1, 2, 3, 4].includes(channels) || samples.length !== width * height * channels)
|
|
103
|
+
throw new TypeError('The PNG pixel layout is unsupported.');
|
|
104
|
+
const pixels = new Uint8ClampedArray(width * height * 4);
|
|
105
|
+
const sample = (index) => image.depth === 16 && !indexed ? Math.round(samples[index] / 257) : samples[index];
|
|
106
|
+
for (let index = 0; index < width * height; index++) {
|
|
107
|
+
const source = index * channels, target = index * 4;
|
|
108
|
+
pixels[target] = sample(source);
|
|
109
|
+
pixels[target + 1] = channels <= 2 ? sample(source) : sample(source + 1);
|
|
110
|
+
pixels[target + 2] = channels <= 2 ? sample(source) : sample(source + 2);
|
|
111
|
+
pixels[target + 3] = channels === 2 || channels === 4 ? sample(source + channels - 1) : 255;
|
|
112
|
+
if (!indexed && image.transparency && (channels === 1 ? samples[source] === image.transparency[0]
|
|
113
|
+
: channels === 3 && [0, 1, 2].every(channel => samples[source + channel] === image.transparency[channel])))
|
|
114
|
+
pixels[target + 3] = 0;
|
|
115
|
+
}
|
|
116
|
+
return { width, height, pixels };
|
|
117
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Browser-safe canonical public-address policy for analyst-supplied address
|
|
2
|
+
// evidence. This performs no resolution or network work; transport owners must
|
|
3
|
+
// still revalidate and pin addresses before making a request.
|
|
4
|
+
import { ipv6Groups } from "../contracts/ip-address.mjs";
|
|
5
|
+
const IPV4_RE = /^\d{1,3}(?:\.\d{1,3}){3}$/u;
|
|
6
|
+
const IPV6_RE = /^[0-9a-f:]+$/iu;
|
|
7
|
+
function canonicalIpv4(value) {
|
|
8
|
+
if (typeof value !== 'string' || !IPV4_RE.test(value.trim()))
|
|
9
|
+
return null;
|
|
10
|
+
const octets = value.trim().split('.').map(Number);
|
|
11
|
+
if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255))
|
|
12
|
+
return null;
|
|
13
|
+
return octets.join('.');
|
|
14
|
+
}
|
|
15
|
+
function isPublicIpv4(value) {
|
|
16
|
+
const octets = value.split('.').map(Number);
|
|
17
|
+
const [first = -1, second = -1, third = -1] = octets;
|
|
18
|
+
return octets.length === 4
|
|
19
|
+
&& first !== 0
|
|
20
|
+
&& first !== 10
|
|
21
|
+
&& first !== 127
|
|
22
|
+
&& first < 224
|
|
23
|
+
&& !(first === 100 && second >= 64 && second <= 127)
|
|
24
|
+
&& !(first === 169 && second === 254)
|
|
25
|
+
&& !(first === 172 && second >= 16 && second <= 31)
|
|
26
|
+
&& !(first === 192 && second === 168)
|
|
27
|
+
&& !(first === 198 && (second === 18 || second === 19))
|
|
28
|
+
&& !(first === 192 && second === 0 && third === 0)
|
|
29
|
+
&& !(first === 192 && second === 0 && third === 2)
|
|
30
|
+
&& !(first === 192 && second === 88 && third === 99)
|
|
31
|
+
&& !(first === 198 && second === 51 && third === 100)
|
|
32
|
+
&& !(first === 203 && second === 0 && third === 113);
|
|
33
|
+
}
|
|
34
|
+
function canonicalIpv6(value) {
|
|
35
|
+
if (typeof value !== 'string')
|
|
36
|
+
return null;
|
|
37
|
+
const candidate = value.trim().toLowerCase();
|
|
38
|
+
if (!candidate.includes(':') || candidate.includes('%') || candidate.includes('.') || !IPV6_RE.test(candidate)) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const host = new URL(`https://[${candidate}]/`).hostname;
|
|
43
|
+
return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : null;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function isPublicIpv6(value) {
|
|
50
|
+
const groups = ipv6Groups(value);
|
|
51
|
+
if (groups.length !== 8)
|
|
52
|
+
return false;
|
|
53
|
+
const [first = '', second = '', third = ''] = groups;
|
|
54
|
+
if (groups.every((group) => group === '0000'))
|
|
55
|
+
return false;
|
|
56
|
+
if (groups.slice(0, 7).every((group) => group === '0000') && groups[7] === '0001')
|
|
57
|
+
return false;
|
|
58
|
+
if (first.startsWith('ff') || /^fe[89ab]/u.test(first) || /^fe[cdef]/u.test(first) || /^f[cd]/u.test(first))
|
|
59
|
+
return false;
|
|
60
|
+
if (first === '0100' && groups.slice(1, 4).every((group) => group === '0000'))
|
|
61
|
+
return false;
|
|
62
|
+
if (first === '2001' && second === '0db8')
|
|
63
|
+
return false;
|
|
64
|
+
if (first === '3fff' && Number.parseInt(second, 16) <= 0x0fff)
|
|
65
|
+
return false;
|
|
66
|
+
if (first === '2001' && second === '0002' && third === '0000')
|
|
67
|
+
return false;
|
|
68
|
+
if (first === '2001' && /^00[1-2][0-9a-f]$/u.test(second))
|
|
69
|
+
return false;
|
|
70
|
+
if (first === '2001' && second === '0000')
|
|
71
|
+
return false;
|
|
72
|
+
if (first === '2002')
|
|
73
|
+
return false;
|
|
74
|
+
const firstValue = Number.parseInt(first, 16);
|
|
75
|
+
return Number.isInteger(firstValue) && firstValue >= 0x2000 && firstValue <= 0x3fff;
|
|
76
|
+
}
|
|
77
|
+
export function canonicalPublicIpAddress(value) {
|
|
78
|
+
const ipv4 = canonicalIpv4(value);
|
|
79
|
+
if (ipv4)
|
|
80
|
+
return isPublicIpv4(ipv4) ? ipv4 : null;
|
|
81
|
+
const ipv6 = canonicalIpv6(value);
|
|
82
|
+
return ipv6 && isPublicIpv6(ipv6) ? ipv6 : null;
|
|
83
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// Pure defensive-domain export formatting for filtered Bulk findings. These
|
|
2
|
+
// files are generated locally and never submitted or applied automatically.
|
|
3
|
+
import { caseDispositionSupportsDefensiveResponse } from "../cases/case-record-decisions.mjs";
|
|
4
|
+
import { normalizeDomain } from "../evidence/domain-name.mjs";
|
|
5
|
+
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
6
|
+
import { DEFENSIVE_INDICATOR_EXPORT_VERSION, DEFENSIVE_INDICATOR_MANIFEST_SCHEMA, DEFENSIVE_INDICATOR_ROLLBACK_SCHEMA, } from "../contracts/analyst-interchange.mjs";
|
|
7
|
+
export { DEFENSIVE_INDICATOR_EXPORT_VERSION, DEFENSIVE_INDICATOR_MANIFEST_SCHEMA, DEFENSIVE_INDICATOR_ROLLBACK_SCHEMA, } from "../contracts/analyst-interchange.mjs";
|
|
8
|
+
export const MAX_DEFENSIVE_INDICATORS = 2000;
|
|
9
|
+
export const MAX_DEFENSIVE_INDICATOR_INPUTS = MAX_DEFENSIVE_INDICATORS * 4;
|
|
10
|
+
export const DEFENSIVE_INDICATOR_FORMATS = Object.freeze(['domains', 'hosts', 'dnsmasq', 'rpz']);
|
|
11
|
+
const REGISTERED_STATES = new Set(['registered', 'for_sale', 'expiring']);
|
|
12
|
+
const MINIMUM_RISK_SCORE = 70;
|
|
13
|
+
const CONTROL_RE = /[\x00-\x1f\x7f]/;
|
|
14
|
+
function plainRecord(value) {
|
|
15
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
16
|
+
? value
|
|
17
|
+
: null;
|
|
18
|
+
}
|
|
19
|
+
function riskScore(record) {
|
|
20
|
+
const value = record.risk ?? record.riskScore;
|
|
21
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
22
|
+
}
|
|
23
|
+
/** Source metadata shared by interchange writers; export time is not evidence time. */
|
|
24
|
+
export function defensiveIndicatorProvenance(value) {
|
|
25
|
+
const source = plainRecord(value) ?? {};
|
|
26
|
+
const saved = plainRecord(source.saved) ?? {};
|
|
27
|
+
const score = riskScore(source);
|
|
28
|
+
const version = source.riskModelVersion ?? saved.riskModelVersion;
|
|
29
|
+
const depth = source.scanDepth ?? saved.scanDepth;
|
|
30
|
+
return Object.freeze({
|
|
31
|
+
riskScore: score === null ? null : Math.max(0, Math.min(100, Math.round(score))),
|
|
32
|
+
riskModelVersion: typeof version === 'number' && Number.isSafeInteger(version) && version > 0 && version <= 1_000 ? version : null,
|
|
33
|
+
scanDepth: depth === 'fast' || depth === 'deep' ? depth : 'unknown',
|
|
34
|
+
observedAt: normalizeExplicitIsoTimestamp(source.observedAt) || normalizeExplicitIsoTimestamp(saved.observedAt),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function normalizedDomainSet(value) {
|
|
38
|
+
if (!Array.isArray(value))
|
|
39
|
+
return new Set();
|
|
40
|
+
return new Set(value.slice(0, MAX_DEFENSIVE_INDICATOR_INPUTS)
|
|
41
|
+
.map((item) => normalizeDomain(item))
|
|
42
|
+
.filter((item) => Boolean(item)));
|
|
43
|
+
}
|
|
44
|
+
function analystDisposition(record) {
|
|
45
|
+
return typeof record.analystDisposition === 'string'
|
|
46
|
+
? record.analystDisposition
|
|
47
|
+
: typeof record.disposition === 'string'
|
|
48
|
+
? record.disposition
|
|
49
|
+
: '';
|
|
50
|
+
}
|
|
51
|
+
function hasReadyProfileContext(record) {
|
|
52
|
+
const saved = plainRecord(record.saved);
|
|
53
|
+
const profileContext = plainRecord(saved?.profileContext ?? record.profileContext);
|
|
54
|
+
return profileContext?.sourceState === 'ready';
|
|
55
|
+
}
|
|
56
|
+
export function isDefensiveIndicatorCandidate(value) {
|
|
57
|
+
const record = plainRecord(value);
|
|
58
|
+
if (!record
|
|
59
|
+
|| !hasReadyProfileContext(record)
|
|
60
|
+
|| record.trusted
|
|
61
|
+
|| record.status === 'error'
|
|
62
|
+
|| typeof record.availability !== 'string'
|
|
63
|
+
|| !REGISTERED_STATES.has(record.availability))
|
|
64
|
+
return false;
|
|
65
|
+
return Boolean(normalizeDomain(record.domain)) && (riskScore(record) ?? -1) >= MINIMUM_RISK_SCORE;
|
|
66
|
+
}
|
|
67
|
+
export function collectDefensiveIndicatorCandidates(records, limit = MAX_DEFENSIVE_INDICATORS) {
|
|
68
|
+
if (!Array.isArray(records))
|
|
69
|
+
throw new TypeError('Defensive indicator export requires an array of Bulk results.');
|
|
70
|
+
const retainedLimit = Number.isSafeInteger(limit) && limit > 0
|
|
71
|
+
? Math.min(limit, MAX_DEFENSIVE_INDICATORS)
|
|
72
|
+
: MAX_DEFENSIVE_INDICATORS;
|
|
73
|
+
const byDomain = new Map();
|
|
74
|
+
for (const item of records.slice(0, MAX_DEFENSIVE_INDICATOR_INPUTS)) {
|
|
75
|
+
if (!isDefensiveIndicatorCandidate(item))
|
|
76
|
+
continue;
|
|
77
|
+
const record = plainRecord(item);
|
|
78
|
+
const domain = normalizeDomain(record?.domain);
|
|
79
|
+
if (record && domain && !byDomain.has(domain))
|
|
80
|
+
byDomain.set(domain, record);
|
|
81
|
+
}
|
|
82
|
+
const candidates = [...byDomain.entries()]
|
|
83
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
84
|
+
.map(([domain, source]) => ({ domain, source }));
|
|
85
|
+
const entries = candidates.slice(0, retainedLimit);
|
|
86
|
+
return {
|
|
87
|
+
entries,
|
|
88
|
+
domains: entries.map((entry) => entry.domain),
|
|
89
|
+
truncated: records.length > MAX_DEFENSIVE_INDICATOR_INPUTS || candidates.length > retainedLimit,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export function prepareDefensiveIndicatorExport(records, options = {}, limit = MAX_DEFENSIVE_INDICATORS) {
|
|
93
|
+
if (!Array.isArray(records))
|
|
94
|
+
throw new TypeError('Defensive indicator export requires an array of Bulk results.');
|
|
95
|
+
const explicitSelection = Array.isArray(options.selectedDomains);
|
|
96
|
+
const selected = normalizedDomainSet(options.selectedDomains);
|
|
97
|
+
const official = normalizedDomainSet(options.officialDomains);
|
|
98
|
+
const allowlisted = normalizedDomainSet(options.allowlistedDomains);
|
|
99
|
+
const common = normalizedDomainSet(options.commonInfrastructureDomains);
|
|
100
|
+
const retainedLimit = Number.isSafeInteger(limit) && limit > 0
|
|
101
|
+
? Math.min(limit, MAX_DEFENSIVE_INDICATORS)
|
|
102
|
+
: MAX_DEFENSIVE_INDICATORS;
|
|
103
|
+
const exclusions = [];
|
|
104
|
+
const seenExclusions = new Set();
|
|
105
|
+
const accepted = new Map();
|
|
106
|
+
const exclude = (domain, reason) => {
|
|
107
|
+
const key = `${domain}\u0000${reason}`;
|
|
108
|
+
if (!seenExclusions.has(key)) {
|
|
109
|
+
exclusions.push({ domain, reason });
|
|
110
|
+
seenExclusions.add(key);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
for (const item of records.slice(0, MAX_DEFENSIVE_INDICATOR_INPUTS)) {
|
|
114
|
+
const source = plainRecord(item);
|
|
115
|
+
const domain = normalizeDomain(source?.domain);
|
|
116
|
+
if (!source || !domain)
|
|
117
|
+
continue;
|
|
118
|
+
if (explicitSelection && !selected.has(domain)) {
|
|
119
|
+
exclude(domain, 'not_selected');
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (!hasReadyProfileContext(source)) {
|
|
123
|
+
exclude(domain, 'profile_context_unavailable');
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (official.has(domain)) {
|
|
127
|
+
exclude(domain, 'official_domain');
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (allowlisted.has(domain)) {
|
|
131
|
+
exclude(domain, 'allowlisted_domain');
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (common.has(domain) || source.commonInfrastructure === true) {
|
|
135
|
+
exclude(domain, 'common_infrastructure');
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (!isDefensiveIndicatorCandidate(source)) {
|
|
139
|
+
exclude(domain, 'not_eligible');
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const disposition = analystDisposition(source);
|
|
143
|
+
if (explicitSelection && !caseDispositionSupportsDefensiveResponse(disposition)) {
|
|
144
|
+
exclude(domain, 'unreviewed_disposition');
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (!accepted.has(domain))
|
|
148
|
+
accepted.set(domain, source);
|
|
149
|
+
}
|
|
150
|
+
const candidates = [...accepted.entries()]
|
|
151
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
152
|
+
.map(([domain, source]) => ({ domain, source }));
|
|
153
|
+
const entries = candidates.slice(0, retainedLimit);
|
|
154
|
+
return {
|
|
155
|
+
entries,
|
|
156
|
+
domains: entries.map((entry) => entry.domain),
|
|
157
|
+
exclusions: exclusions.sort((left, right) => left.domain.localeCompare(right.domain) || left.reason.localeCompare(right.reason)),
|
|
158
|
+
truncated: records.length > MAX_DEFENSIVE_INDICATOR_INPUTS || candidates.length > retainedLimit,
|
|
159
|
+
explicitSelection,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function timestamp(value) {
|
|
163
|
+
if (typeof value !== 'string' || value.length > 64 || CONTROL_RE.test(value))
|
|
164
|
+
return null;
|
|
165
|
+
const parsed = Date.parse(value);
|
|
166
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
167
|
+
}
|
|
168
|
+
function header(prefix, generatedAt, count) {
|
|
169
|
+
return [
|
|
170
|
+
`${prefix} WHOISleuth defensive domain indicators v${DEFENSIVE_INDICATOR_EXPORT_VERSION}`,
|
|
171
|
+
`${prefix} Generated ${generatedAt}`,
|
|
172
|
+
`${prefix} ${count} high-risk registered domain${count === 1 ? '' : 's'}`,
|
|
173
|
+
`${prefix} Review before use. Heuristic findings can include false positives.`,
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
function formatDomains(domains, generatedAt) {
|
|
177
|
+
return [...header('#', generatedAt, domains.length), ...domains].join('\n');
|
|
178
|
+
}
|
|
179
|
+
function formatHosts(domains, generatedAt) {
|
|
180
|
+
return [...header('#', generatedAt, domains.length), ...domains.map((domain) => `0.0.0.0 ${domain}`)].join('\n');
|
|
181
|
+
}
|
|
182
|
+
function formatDnsmasq(domains, generatedAt) {
|
|
183
|
+
return [...header('#', generatedAt, domains.length), ...domains.map((domain) => `address=/${domain}/0.0.0.0`)].join('\n');
|
|
184
|
+
}
|
|
185
|
+
function formatRpz(domains, generatedAt, includeWildcards) {
|
|
186
|
+
const serial = Math.floor(Date.parse(generatedAt) / 1000) >>> 0;
|
|
187
|
+
return [
|
|
188
|
+
...header(';', generatedAt, domains.length),
|
|
189
|
+
'$TTL 60',
|
|
190
|
+
`@ IN SOA localhost. root.localhost. (${serial} 60 60 60 60)`,
|
|
191
|
+
'@ IN NS localhost.',
|
|
192
|
+
...domains.flatMap((domain) => [
|
|
193
|
+
`${domain} CNAME .`,
|
|
194
|
+
...(includeWildcards ? [`*.${domain} CNAME .`] : []),
|
|
195
|
+
]),
|
|
196
|
+
].join('\n');
|
|
197
|
+
}
|
|
198
|
+
function contentFor(format, domains, generatedAt, includeWildcards) {
|
|
199
|
+
if (format === 'hosts')
|
|
200
|
+
return formatHosts(domains, generatedAt);
|
|
201
|
+
if (format === 'dnsmasq')
|
|
202
|
+
return formatDnsmasq(domains, generatedAt);
|
|
203
|
+
if (format === 'rpz')
|
|
204
|
+
return formatRpz(domains, generatedAt, includeWildcards);
|
|
205
|
+
return formatDomains(domains, generatedAt);
|
|
206
|
+
}
|
|
207
|
+
export function buildDefensiveIndicatorExport(records, options = {}) {
|
|
208
|
+
const format = (typeof options.format === 'string'
|
|
209
|
+
&& DEFENSIVE_INDICATOR_FORMATS.includes(options.format)) ? options.format : 'domains';
|
|
210
|
+
const generatedAt = timestamp(options.generatedAt) || new Date().toISOString();
|
|
211
|
+
const includeWildcards = options.includeWildcards === true;
|
|
212
|
+
const expiryDays = typeof options.expiryDays === 'number' && Number.isSafeInteger(options.expiryDays)
|
|
213
|
+
? Math.max(1, Math.min(365, options.expiryDays))
|
|
214
|
+
: 30;
|
|
215
|
+
const collected = prepareDefensiveIndicatorExport(records, options);
|
|
216
|
+
const expiresAt = new Date(Date.parse(generatedAt) + expiryDays * 86_400_000).toISOString();
|
|
217
|
+
const manifest = {
|
|
218
|
+
schema: DEFENSIVE_INDICATOR_MANIFEST_SCHEMA,
|
|
219
|
+
version: DEFENSIVE_INDICATOR_EXPORT_VERSION,
|
|
220
|
+
generatedAt,
|
|
221
|
+
expiresAt,
|
|
222
|
+
reviewRequired: true,
|
|
223
|
+
explicitSelection: collected.explicitSelection,
|
|
224
|
+
includeWildcards,
|
|
225
|
+
entries: collected.entries.map(({ domain, source }) => ({
|
|
226
|
+
domain,
|
|
227
|
+
disposition: analystDisposition(source) || 'not_recorded',
|
|
228
|
+
reason: `High-risk registered Bulk finding (risk ${riskScore(source) ?? 'unknown'}).`,
|
|
229
|
+
source: 'bulk',
|
|
230
|
+
generatedAt,
|
|
231
|
+
expiresAt,
|
|
232
|
+
})),
|
|
233
|
+
exclusions: collected.exclusions,
|
|
234
|
+
};
|
|
235
|
+
const rollback = {
|
|
236
|
+
schema: DEFENSIVE_INDICATOR_ROLLBACK_SCHEMA,
|
|
237
|
+
version: DEFENSIVE_INDICATOR_EXPORT_VERSION,
|
|
238
|
+
generatedAt,
|
|
239
|
+
removes: collected.domains.map((domain) => ({
|
|
240
|
+
domain,
|
|
241
|
+
includeWildcard: includeWildcards,
|
|
242
|
+
reason: 'Remove the indicator generated by the paired reviewed manifest.',
|
|
243
|
+
})),
|
|
244
|
+
};
|
|
245
|
+
return {
|
|
246
|
+
version: DEFENSIVE_INDICATOR_EXPORT_VERSION,
|
|
247
|
+
format,
|
|
248
|
+
generatedAt,
|
|
249
|
+
expiresAt,
|
|
250
|
+
domains: collected.domains,
|
|
251
|
+
entries: collected.entries,
|
|
252
|
+
exclusions: collected.exclusions,
|
|
253
|
+
explicitSelection: collected.explicitSelection,
|
|
254
|
+
includeWildcards,
|
|
255
|
+
truncated: collected.truncated,
|
|
256
|
+
filename: `whoisleuth-defensive-domains-${generatedAt.slice(0, 10)}.${format === 'rpz' ? 'zone' : 'txt'}`,
|
|
257
|
+
manifestFilename: `whoisleuth-defensive-domains-${generatedAt.slice(0, 10)}.manifest.json`,
|
|
258
|
+
rollbackFilename: `whoisleuth-defensive-domains-${generatedAt.slice(0, 10)}.rollback.json`,
|
|
259
|
+
mimeType: 'text/plain;charset=utf-8',
|
|
260
|
+
content: `${contentFor(format, collected.domains, generatedAt, includeWildcards)}\n`,
|
|
261
|
+
manifestContent: `${JSON.stringify(manifest, null, 2)}\n`,
|
|
262
|
+
rollbackContent: `${JSON.stringify(rollback, null, 2)}\n`,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { normalizeDomain
|
|
2
|
-
import {
|
|
1
|
+
import { normalizeDomain } from "../evidence/domain-name.mjs";
|
|
2
|
+
import { openOrCreateCase, updateCase } from "../cases/case-record-operations.mjs";
|
|
3
|
+
import { canonicalRegistrableDomain } from "../analysis/registrable-domain.mjs";
|
|
3
4
|
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
4
5
|
import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
|
|
5
6
|
import { sha256IdentityHex } from "../evidence/record-identity.mjs";
|
|
@@ -182,16 +183,7 @@ function structuredObservation(value, index) {
|
|
|
182
183
|
};
|
|
183
184
|
}
|
|
184
185
|
function findingKey(finding, sourceName) {
|
|
185
|
-
return
|
|
186
|
-
finding.domain,
|
|
187
|
-
finding.category,
|
|
188
|
-
finding.evidenceClass,
|
|
189
|
-
finding.summary,
|
|
190
|
-
finding.observedAt,
|
|
191
|
-
finding.completeness,
|
|
192
|
-
sourceName,
|
|
193
|
-
finding.structuredObservation?.eventId ?? '',
|
|
194
|
-
].join('\u0000');
|
|
186
|
+
return canonicalArtifactJsonV2({ sourceName, finding });
|
|
195
187
|
}
|
|
196
188
|
export function parseExternalFindingsDocument(value) {
|
|
197
189
|
const root = record(value);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readManagedIndicatorSet } from "./managed-indicator-set.mjs";
|
|
2
|
+
import { stixIndicatorObjects, stixIndicatorProducer } from "./stix-indicator-export.mjs";
|
|
3
|
+
import { mispIndicatorAttribute, mispIndicatorEvent } from "./misp-indicator-export.mjs";
|
|
4
|
+
/** Re-exporting a revision is deterministic; revoked object versions never advance. */
|
|
5
|
+
export async function exportManagedIndicators(input, format) {
|
|
6
|
+
const manifest = await readManagedIndicatorSet(input);
|
|
7
|
+
let document = manifest;
|
|
8
|
+
if (format === 'stix') {
|
|
9
|
+
const producer = `identity--${manifest.producerId}`;
|
|
10
|
+
document = { type: 'bundle', id: `bundle--${manifest.revisionId}`, objects: [
|
|
11
|
+
stixIndicatorProducer(producer, manifest.createdAt),
|
|
12
|
+
...manifest.entries.flatMap(entry => stixIndicatorObjects(entry.domain, entry.observation.availability, entry.observation, entry.createdAt, producer, type => `${type}--${entry.id}`)
|
|
13
|
+
.map(object => object.type !== 'indicator' ? object : { ...object,
|
|
14
|
+
modified: entry.modifiedAt, valid_until: entry.expiresAt, revoked: entry.withdrawal !== null,
|
|
15
|
+
x_whoisleuth_managed_set: manifest.id, x_whoisleuth_original_review_basis: entry.basis,
|
|
16
|
+
x_whoisleuth_review_basis: entry.reviewBasis,
|
|
17
|
+
...(entry.withdrawal ? { x_whoisleuth_withdrawal_reason: entry.withdrawal.reason } : {}),
|
|
18
|
+
})),
|
|
19
|
+
] };
|
|
20
|
+
}
|
|
21
|
+
else if (format === 'misp') {
|
|
22
|
+
const attributes = manifest.entries.map(entry => {
|
|
23
|
+
const attribute = mispIndicatorAttribute(entry.domain, entry.observation.availability, entry.observation, entry.id, entry.modifiedAt);
|
|
24
|
+
return { ...attribute, deleted: entry.withdrawal !== null,
|
|
25
|
+
comment: `${attribute.comment}; review-expires=${entry.expiresAt}; expiry requires recipient review, not automatic deletion; original-basis=${entry.basis}; latest-review=${entry.reviewBasis}${entry.withdrawal ? `; withdrawn=${entry.withdrawal.reason}` : ''}` };
|
|
26
|
+
});
|
|
27
|
+
document = { Event: { ...mispIndicatorEvent(manifest.id, manifest.createdAt, attributes),
|
|
28
|
+
info: `${manifest.name} · reviewed domain indicators`, timestamp: String(Math.floor(Date.parse(manifest.modifiedAt) / 1000)) } };
|
|
29
|
+
}
|
|
30
|
+
else if (format !== 'manifest')
|
|
31
|
+
throw new TypeError('Unsupported managed indicator export format.');
|
|
32
|
+
return { document, content: `${JSON.stringify(document, null, 2)}\n`,
|
|
33
|
+
filename: `whoisleuth-indicators-${manifest.id}-r${manifest.revision}.${format}.json`,
|
|
34
|
+
mimeType: format === 'stix' ? 'application/stix+json;charset=utf-8' : 'application/json;charset=utf-8' };
|
|
35
|
+
}
|