@slicedearth/whoisleuth-cli 1.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
- package/NOTICE +76 -0
- package/README.md +84 -0
- package/TRADEMARKS.md +41 -0
- package/bin/whoisleuth.mjs +35 -0
- package/cli/archive-inspect.mjs +198 -0
- package/cli/arguments.mjs +1022 -0
- package/cli/artifact-verify.mjs +210 -0
- package/cli/bulk-checkpoint.mjs +218 -0
- package/cli/bulk-command-runner.mjs +109 -0
- package/cli/bulk-output.mjs +112 -0
- package/cli/bulk.mjs +133 -0
- package/cli/compare.mjs +238 -0
- package/cli/completion.mjs +175 -0
- package/cli/csv.mjs +1 -0
- package/cli/discover.mjs +50 -0
- package/cli/discovery-command-runner.mjs +36 -0
- package/cli/discovery-observation-snapshot.mjs +315 -0
- package/cli/discovery-scan-command-runner.mjs +141 -0
- package/cli/discovery-scan.mjs +325 -0
- package/cli/discovery-snapshot.mjs +133 -0
- package/cli/discovery-workflow.mjs +73 -0
- package/cli/doctor.mjs +137 -0
- package/cli/errors.mjs +21 -0
- package/cli/evidence-command-arguments.mjs +145 -0
- package/cli/evidence-command-runner.mjs +106 -0
- package/cli/evidence-signing.mjs +215 -0
- package/cli/exit-codes.mjs +9 -0
- package/cli/export-evidence.mjs +28 -0
- package/cli/formatters/evidence-report.mjs +3 -0
- package/cli/formatters/html.mjs +69 -0
- package/cli/formatters/json.mjs +135 -0
- package/cli/formatters/markdown.mjs +3 -0
- package/cli/formatters/terminal.mjs +658 -0
- package/cli/http.mjs +27 -0
- package/cli/lookup-command-runner.mjs +87 -0
- package/cli/lookup-diff.mjs +171 -0
- package/cli/mail-review.mjs +204 -0
- package/cli/manual.mjs +34 -0
- package/cli/network-command-runner.mjs +88 -0
- package/cli/output-file.mjs +97 -0
- package/cli/page-compare.mjs +123 -0
- package/cli/posture.mjs +20 -0
- package/cli/progress-events.mjs +53 -0
- package/cli/progress.mjs +56 -0
- package/cli/registry-access.mjs +3 -0
- package/cli/registry-support.mjs +120 -0
- package/cli/risk-calibration.mjs +294 -0
- package/cli/runner-types.mjs +1 -0
- package/cli/runner.mjs +726 -0
- package/cli/saved-lookup.mjs +87 -0
- package/cli/source-reliability.mjs +727 -0
- package/cli/strict-exit.mjs +55 -0
- package/cli/terminal-presentation.mjs +147 -0
- package/docs/cli.md +827 -0
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
- package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
- package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
- package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
- package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
- package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
- package/frontend/src/lib/analysis/bulk-sort.js +49 -0
- package/frontend/src/lib/analysis/campaign-model.js +265 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
- package/frontend/src/lib/analysis/case-migration-model.js +248 -0
- package/frontend/src/lib/analysis/case-model.js +5 -0
- package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
- package/frontend/src/lib/analysis/case-record-core.js +232 -0
- package/frontend/src/lib/analysis/case-record-model.js +7 -0
- package/frontend/src/lib/analysis/case-record-operations.js +224 -0
- package/frontend/src/lib/analysis/case-response-model.js +640 -0
- package/frontend/src/lib/analysis/case-response-packet.js +573 -0
- package/frontend/src/lib/analysis/case-storage-model.js +93 -0
- package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
- package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
- package/frontend/src/lib/analysis/http-summary.js +165 -0
- package/frontend/src/lib/analysis/investigation-guide.js +500 -0
- package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
- package/frontend/src/lib/analysis/page-baseline.js +272 -0
- package/frontend/src/lib/analysis/page-similarity.js +179 -0
- package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
- package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
- package/frontend/src/lib/analysis/scoring.js +135 -0
- package/frontend/src/lib/analysis/shortlist-model.js +209 -0
- package/frontend/src/lib/analysis/utils.js +265 -0
- package/frontend/src/lib/analysis/watchlist-history.js +643 -0
- package/frontend/src/lib/analysis/watchlist-store.js +127 -0
- package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
- package/frontend/src/lib/analysis/workspace-archive.js +472 -0
- package/lib/abort.mjs +26 -0
- package/lib/abusech-auth.mjs +19 -0
- package/lib/availability.mjs +716 -0
- package/lib/bounded-contract-normalizers.mjs +61 -0
- package/lib/browser-library-profile.mjs +310 -0
- package/lib/bulk-comparison-evidence.mjs +83 -0
- package/lib/classify.mjs +93 -0
- package/lib/client-behavior-profile.mjs +83 -0
- package/lib/credential-surface-profile.mjs +66 -0
- package/lib/csv.mjs +23 -0
- package/lib/ct-query.mjs +27 -0
- package/lib/ct-search.mjs +393 -0
- package/lib/distributed-operation-budget.mjs +461 -0
- package/lib/dns-delegation-health.mjs +344 -0
- package/lib/dns-intelligence.mjs +739 -0
- package/lib/dns-mx.mjs +24 -0
- package/lib/dns-resolver-selection.mjs +33 -0
- package/lib/domain-posture-analysis.mjs +254 -0
- package/lib/domain-posture-parsers.mjs +358 -0
- package/lib/domain-posture.mjs +745 -0
- package/lib/error-detail.mjs +10 -0
- package/lib/evidence-export.mjs +431 -0
- package/lib/evidence-report-markdown.mjs +71 -0
- package/lib/evidence-report.mjs +391 -0
- package/lib/external-intelligence-risk.mjs +108 -0
- package/lib/favicon.mjs +146 -0
- package/lib/feature-policy.mjs +70 -0
- package/lib/generated/retire-browser-catalog.mjs +8724 -0
- package/lib/generated/unicode-confusables-17.mjs +93 -0
- package/lib/hostname.mjs +38 -0
- package/lib/html-signals.mjs +680 -0
- package/lib/http-intelligence.mjs +232 -0
- package/lib/idn-confusable-policy.mjs +86 -0
- package/lib/idn-confusables.mjs +465 -0
- package/lib/lookup-cache.mjs +130 -0
- package/lib/lookup-progress.mjs +291 -0
- package/lib/lookup-source-progress.mjs +109 -0
- package/lib/lookup-threat-provider-inventory.mjs +13 -0
- package/lib/lookup.mjs +592 -0
- package/lib/observation.mjs +114 -0
- package/lib/observed-network-context.mjs +355 -0
- package/lib/operation-budget.mjs +489 -0
- package/lib/page-fingerprints.mjs +481 -0
- package/lib/page-role-profile.mjs +117 -0
- package/lib/perceptual-hash.mjs +451 -0
- package/lib/rdap-attempts.mjs +34 -0
- package/lib/rdap-bootstrap.mjs +173 -0
- package/lib/rdap-capabilities.mjs +292 -0
- package/lib/rdap-client.mjs +125 -0
- package/lib/rdap-normalization.mjs +691 -0
- package/lib/rdap-registrar.mjs +196 -0
- package/lib/rdap-transport.mjs +39 -0
- package/lib/rdap-types.mjs +1 -0
- package/lib/rdap-validation.mjs +94 -0
- package/lib/rdap.mjs +16 -0
- package/lib/registry-access.mjs +32 -0
- package/lib/registry-capabilities.mjs +117 -0
- package/lib/registry-capability-catalogue.mjs +1206 -0
- package/lib/registry-comparison.mjs +318 -0
- package/lib/registry-dates.mjs +190 -0
- package/lib/registry-insights.mjs +325 -0
- package/lib/response-policy.mjs +256 -0
- package/lib/risk-scoring.mjs +164 -0
- package/lib/safe-fetch.mjs +446 -0
- package/lib/security-txt.mjs +359 -0
- package/lib/service-binding-dns.mjs +608 -0
- package/lib/sslbl-certificates.generated.mjs +143 -0
- package/lib/sslbl-intelligence.mjs +241 -0
- package/lib/static-html-analysis.mjs +340 -0
- package/lib/structured-data-identity.mjs +323 -0
- package/lib/threat-intelligence-contract.mjs +5 -0
- package/lib/threat-intelligence-definition-registry.mjs +34 -0
- package/lib/threat-intelligence-fixtures.mjs +82 -0
- package/lib/threat-intelligence-manifests.mjs +512 -0
- package/lib/threat-intelligence-runtime.mjs +662 -0
- package/lib/threat-intelligence-targets.mjs +168 -0
- package/lib/threat-intelligence-types.mjs +3 -0
- package/lib/threatfox-intelligence.mjs +307 -0
- package/lib/tls-intelligence.mjs +814 -0
- package/lib/typosquat-generator.mjs +884 -0
- package/lib/urlhaus-intelligence.mjs +361 -0
- package/lib/urlscan-intelligence.mjs +349 -0
- package/lib/website-security-posture.mjs +325 -0
- package/lib/website-technology.mjs +437 -0
- package/lib/whois-authority.mjs +98 -0
- package/lib/whois-chain.mjs +261 -0
- package/lib/whois-contacts.mjs +163 -0
- package/lib/whois-contracts.mjs +3 -0
- package/lib/whois-normalization.mjs +27 -0
- package/lib/whois-transport.mjs +100 -0
- package/lib/whois-values.mjs +92 -0
- package/lib/whois.mjs +1187 -0
- package/package.json +55 -0
- package/third-party-notices.txt +5571 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// Pure browser-local custom detection-rule model. Rules are deliberately a
|
|
2
|
+
// small structured language: field names and operators come from allowlists,
|
|
3
|
+
// so imported rules cannot execute code or reach outside bounded case evidence.
|
|
4
|
+
import { latestCaseEvidence } from "./case-model.js";
|
|
5
|
+
export const DETECTION_RULE_SCHEMA = 'whoisleuth.detection-rules';
|
|
6
|
+
export const DETECTION_RULE_SCHEMA_VERSION = 1;
|
|
7
|
+
export const MAX_DETECTION_RULES = 50;
|
|
8
|
+
export const MAX_RULE_CONDITIONS = 8;
|
|
9
|
+
export const MAX_RULE_INPUT_RECORDS = 250;
|
|
10
|
+
export const MAX_RULE_IMPORT_BYTES = 2 * 1024 * 1024;
|
|
11
|
+
export const MAX_RULE_STORE_BYTES = 256 * 1024;
|
|
12
|
+
export const MAX_RULE_NAME_LENGTH = 100;
|
|
13
|
+
export const MAX_RULE_TAG_LENGTH = 40;
|
|
14
|
+
export const MAX_CONDITION_VALUE_LENGTH = 200;
|
|
15
|
+
export const MAX_RULE_RISK_DELTA = 25;
|
|
16
|
+
export const MAX_CUSTOM_RISK_TOTAL = 50;
|
|
17
|
+
const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
18
|
+
const TEXT_CONTROL_RE = /[\x00-\x1f\x7f]/;
|
|
19
|
+
export const RULE_FIELD_DEFINITIONS = Object.freeze([
|
|
20
|
+
{ value: 'availability', label: 'Availability state', kind: 'enum', values: ['registered', 'for_sale', 'expiring', 'available', 'unknown', 'error'] },
|
|
21
|
+
{ value: 'activityStatus', label: 'Website state', kind: 'enum', values: ['active', 'parked', 'unreachable', 'no_site'] },
|
|
22
|
+
{ value: 'riskScore', label: 'Built-in risk score', kind: 'number', min: 0, max: 100 },
|
|
23
|
+
{ value: 'registrar', label: 'Registrar', kind: 'text' },
|
|
24
|
+
{ value: 'pageTitle', label: 'Page title', kind: 'text' },
|
|
25
|
+
{ value: 'httpResponseStatus', label: 'HTTP response status', kind: 'number', min: 100, max: 599 },
|
|
26
|
+
{ value: 'httpTransportSecurity', label: 'HTTP transport', kind: 'enum', values: ['https', 'http'] },
|
|
27
|
+
{ value: 'hasMx', label: 'MX present', kind: 'boolean' },
|
|
28
|
+
{ value: 'hasDmarc', label: 'DMARC present', kind: 'boolean' },
|
|
29
|
+
{ value: 'hasPasswordField', label: 'Password field detected', kind: 'boolean' },
|
|
30
|
+
{ value: 'faviconMatch', label: 'Exact favicon match', kind: 'boolean' },
|
|
31
|
+
{ value: 'faviconNearMatch', label: 'Similar favicon', kind: 'boolean' },
|
|
32
|
+
{ value: 'reusesOfficialAssets', label: 'Official assets reused', kind: 'boolean' },
|
|
33
|
+
{ value: 'phishingLanguageMatch', label: 'Phishing-language signal', kind: 'text' },
|
|
34
|
+
{ value: 'hasExternalFormAction', label: 'External form action observed', kind: 'boolean' },
|
|
35
|
+
{ value: 'mutationTypes', label: 'Mutation type', kind: 'list' },
|
|
36
|
+
{ value: 'nameservers', label: 'Nameserver', kind: 'list' },
|
|
37
|
+
{ value: 'httpSecurityHeaders', label: 'HTTP security header', kind: 'list' },
|
|
38
|
+
{ value: 'status', label: 'Case status', kind: 'enum', values: ['new', 'investigating', 'monitoring', 'escalated', 'closed'] },
|
|
39
|
+
{ value: 'disposition', label: 'Case disposition', kind: 'enum', values: ['unreviewed', 'benign', 'suspicious', 'confirmed_abuse', 'false_positive'] },
|
|
40
|
+
{ value: 'tags', label: 'Case tag', kind: 'list' },
|
|
41
|
+
]);
|
|
42
|
+
const FIELD_BY_VALUE = new Map(RULE_FIELD_DEFINITIONS.map((field) => [field.value, field]));
|
|
43
|
+
function record(value) {
|
|
44
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
45
|
+
? value
|
|
46
|
+
: {};
|
|
47
|
+
}
|
|
48
|
+
export function operatorsForRuleField(field) {
|
|
49
|
+
const definition = typeof field === 'string' ? FIELD_BY_VALUE.get(field) : undefined;
|
|
50
|
+
if (!definition)
|
|
51
|
+
return [];
|
|
52
|
+
if (definition.kind === 'number')
|
|
53
|
+
return ['equals', 'at_least', 'at_most'];
|
|
54
|
+
if (definition.kind === 'text')
|
|
55
|
+
return ['contains', 'equals', 'present'];
|
|
56
|
+
if (definition.kind === 'list')
|
|
57
|
+
return ['contains', 'present'];
|
|
58
|
+
return ['equals'];
|
|
59
|
+
}
|
|
60
|
+
function normalizedText(value, maxLength) {
|
|
61
|
+
if (typeof value !== 'string' || TEXT_CONTROL_RE.test(value))
|
|
62
|
+
return '';
|
|
63
|
+
return value.replace(/\s+/g, ' ').trim().slice(0, maxLength).trim();
|
|
64
|
+
}
|
|
65
|
+
function safeId(value) {
|
|
66
|
+
return typeof value === 'string' && SAFE_ID_RE.test(value) ? value : null;
|
|
67
|
+
}
|
|
68
|
+
function makeId() {
|
|
69
|
+
if (typeof globalThis.crypto?.randomUUID === 'function')
|
|
70
|
+
return globalThis.crypto.randomUUID();
|
|
71
|
+
return `rule-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
72
|
+
}
|
|
73
|
+
function normalizeInteger(value, min, max) {
|
|
74
|
+
const parsed = typeof value === 'number' ? value : Number(value);
|
|
75
|
+
return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : null;
|
|
76
|
+
}
|
|
77
|
+
export function normalizeRuleCondition(raw) {
|
|
78
|
+
const condition = record(raw);
|
|
79
|
+
const definition = typeof condition.field === 'string' ? FIELD_BY_VALUE.get(condition.field) : undefined;
|
|
80
|
+
if (!definition)
|
|
81
|
+
return null;
|
|
82
|
+
const operator = typeof condition.operator === 'string'
|
|
83
|
+
&& operatorsForRuleField(definition.value).includes(condition.operator)
|
|
84
|
+
? condition.operator
|
|
85
|
+
: null;
|
|
86
|
+
if (!operator)
|
|
87
|
+
return null;
|
|
88
|
+
let value;
|
|
89
|
+
if (operator === 'present')
|
|
90
|
+
value = true;
|
|
91
|
+
else if (definition.kind === 'number') {
|
|
92
|
+
value = normalizeInteger(condition.value, definition.min ?? 0, definition.max ?? 100);
|
|
93
|
+
if (value === null)
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
else if (definition.kind === 'boolean') {
|
|
97
|
+
if (condition.value !== true && condition.value !== false && condition.value !== 'true' && condition.value !== 'false')
|
|
98
|
+
return null;
|
|
99
|
+
value = condition.value === true || condition.value === 'true';
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
value = normalizedText(condition.value, MAX_CONDITION_VALUE_LENGTH).toLowerCase();
|
|
103
|
+
if (!value)
|
|
104
|
+
return null;
|
|
105
|
+
if (definition.kind === 'enum' && (!Array.isArray(definition.values) || !definition.values.includes(value)))
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
return { field: definition.value, operator, value };
|
|
109
|
+
}
|
|
110
|
+
export function normalizeDetectionRule(raw, { generateId = false } = {}) {
|
|
111
|
+
const item = record(raw);
|
|
112
|
+
const name = normalizedText(item.name, MAX_RULE_NAME_LENGTH);
|
|
113
|
+
if (!name)
|
|
114
|
+
return null;
|
|
115
|
+
const conditions = [];
|
|
116
|
+
const rawConditions = Array.isArray(item.conditions) ? item.conditions : [];
|
|
117
|
+
for (const item of rawConditions.slice(0, MAX_RULE_CONDITIONS)) {
|
|
118
|
+
const condition = normalizeRuleCondition(item);
|
|
119
|
+
if (condition)
|
|
120
|
+
conditions.push(condition);
|
|
121
|
+
}
|
|
122
|
+
if (!conditions.length)
|
|
123
|
+
return null;
|
|
124
|
+
const riskDelta = normalizeInteger(item.riskDelta, 0, MAX_RULE_RISK_DELTA);
|
|
125
|
+
return {
|
|
126
|
+
id: safeId(item.id) || (generateId ? makeId() : null),
|
|
127
|
+
name,
|
|
128
|
+
enabled: item.enabled !== false,
|
|
129
|
+
match: item.match === 'any' ? 'any' : 'all',
|
|
130
|
+
conditions,
|
|
131
|
+
riskDelta: riskDelta ?? 0,
|
|
132
|
+
tag: normalizedText(item.tag, MAX_RULE_TAG_LENGTH).toLowerCase(),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function ruleList(raw) {
|
|
136
|
+
if (Array.isArray(raw))
|
|
137
|
+
return raw;
|
|
138
|
+
const item = record(raw);
|
|
139
|
+
if (Array.isArray(item.rules))
|
|
140
|
+
return item.rules;
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
export function detectionRuleStoreVersion(raw) {
|
|
144
|
+
const item = record(raw);
|
|
145
|
+
return typeof item.version === 'number' && Number.isFinite(item.version) ? item.version : null;
|
|
146
|
+
}
|
|
147
|
+
export function normalizeDetectionRuleStore(raw) {
|
|
148
|
+
const byId = new Map();
|
|
149
|
+
for (const item of ruleList(raw).slice(0, MAX_RULE_INPUT_RECORDS)) {
|
|
150
|
+
const rule = normalizeDetectionRule(item);
|
|
151
|
+
if (!rule?.id || byId.has(rule.id))
|
|
152
|
+
continue;
|
|
153
|
+
byId.set(rule.id, { ...rule, id: rule.id });
|
|
154
|
+
if (byId.size >= MAX_DETECTION_RULES)
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
return { version: DETECTION_RULE_SCHEMA_VERSION, rules: [...byId.values()] };
|
|
158
|
+
}
|
|
159
|
+
export function createDetectionRule(rules, input) {
|
|
160
|
+
const normalized = normalizeDetectionRule(input, { generateId: true });
|
|
161
|
+
if (!normalized?.id)
|
|
162
|
+
throw new Error('Enter a rule name and one valid condition.');
|
|
163
|
+
const existing = normalizeDetectionRuleStore(rules).rules;
|
|
164
|
+
if (existing.length >= MAX_DETECTION_RULES)
|
|
165
|
+
throw new Error(`Custom rules are limited to ${MAX_DETECTION_RULES}. Delete or export one first.`);
|
|
166
|
+
const created = { ...normalized, id: normalized.id };
|
|
167
|
+
return { rules: [created, ...existing], record: created };
|
|
168
|
+
}
|
|
169
|
+
export function updateDetectionRule(rules, id, patch) {
|
|
170
|
+
const normalizedRules = normalizeDetectionRuleStore(rules).rules;
|
|
171
|
+
const current = normalizedRules.find((rule) => rule.id === id);
|
|
172
|
+
if (!current)
|
|
173
|
+
throw new Error('That custom rule no longer exists.');
|
|
174
|
+
const updated = normalizeDetectionRule({ ...current, ...record(patch), id });
|
|
175
|
+
if (!updated?.id)
|
|
176
|
+
throw new Error('A custom rule needs a name and at least one valid condition.');
|
|
177
|
+
return normalizeDetectionRuleStore(normalizedRules.map((rule) => rule.id === id ? updated : rule)).rules;
|
|
178
|
+
}
|
|
179
|
+
function comparableValue(caseRecord, snapshot, field) {
|
|
180
|
+
if (field === 'status' || field === 'disposition' || field === 'tags')
|
|
181
|
+
return record(caseRecord)[field];
|
|
182
|
+
return record(snapshot)[field];
|
|
183
|
+
}
|
|
184
|
+
export function conditionMatchesCase(conditionRaw, caseRecord) {
|
|
185
|
+
const condition = normalizeRuleCondition(conditionRaw);
|
|
186
|
+
if (!condition)
|
|
187
|
+
return false;
|
|
188
|
+
const snapshot = latestCaseEvidence(caseRecord);
|
|
189
|
+
const actual = comparableValue(caseRecord, snapshot, condition.field);
|
|
190
|
+
if (condition.operator === 'present') {
|
|
191
|
+
return Array.isArray(actual) ? actual.length > 0 : actual !== null && actual !== undefined && actual !== '';
|
|
192
|
+
}
|
|
193
|
+
if (condition.operator === 'at_least')
|
|
194
|
+
return typeof actual === 'number' && typeof condition.value === 'number' && actual >= condition.value;
|
|
195
|
+
if (condition.operator === 'at_most')
|
|
196
|
+
return typeof actual === 'number' && typeof condition.value === 'number' && actual <= condition.value;
|
|
197
|
+
if (condition.operator === 'equals') {
|
|
198
|
+
if (typeof condition.value === 'boolean' || typeof condition.value === 'number')
|
|
199
|
+
return actual === condition.value;
|
|
200
|
+
return typeof actual === 'string' && actual.toLowerCase() === condition.value;
|
|
201
|
+
}
|
|
202
|
+
if (condition.operator === 'contains') {
|
|
203
|
+
if (typeof condition.value !== 'string')
|
|
204
|
+
return false;
|
|
205
|
+
const needle = condition.value;
|
|
206
|
+
if (Array.isArray(actual))
|
|
207
|
+
return actual.some((value) => typeof value === 'string' && value.toLowerCase().includes(needle));
|
|
208
|
+
return typeof actual === 'string' && actual.toLowerCase().includes(needle);
|
|
209
|
+
}
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
export function evaluateDetectionRules(caseRecord, rawRules) {
|
|
213
|
+
const rules = normalizeDetectionRuleStore(rawRules).rules;
|
|
214
|
+
const matchedRules = [];
|
|
215
|
+
let customRiskDelta = 0;
|
|
216
|
+
const suggestedTags = new Set();
|
|
217
|
+
for (const rule of rules) {
|
|
218
|
+
if (!rule.enabled)
|
|
219
|
+
continue;
|
|
220
|
+
const results = rule.conditions.map((condition) => conditionMatchesCase(condition, caseRecord));
|
|
221
|
+
const matched = rule.match === 'any' ? results.some(Boolean) : results.every(Boolean);
|
|
222
|
+
if (!matched)
|
|
223
|
+
continue;
|
|
224
|
+
const appliedDelta = Math.min(rule.riskDelta, Math.max(0, MAX_CUSTOM_RISK_TOTAL - customRiskDelta));
|
|
225
|
+
customRiskDelta += appliedDelta;
|
|
226
|
+
if (rule.tag)
|
|
227
|
+
suggestedTags.add(rule.tag);
|
|
228
|
+
matchedRules.push({ id: rule.id, name: rule.name, riskDelta: rule.riskDelta, appliedDelta, tag: rule.tag });
|
|
229
|
+
}
|
|
230
|
+
const snapshot = latestCaseEvidence(caseRecord);
|
|
231
|
+
const builtInRiskScore = typeof snapshot?.riskScore === 'number' ? snapshot.riskScore : null;
|
|
232
|
+
const item = record(caseRecord);
|
|
233
|
+
return {
|
|
234
|
+
caseId: typeof item.id === 'string' ? item.id : '',
|
|
235
|
+
domain: typeof item.domain === 'string' ? item.domain : '',
|
|
236
|
+
builtInRiskScore,
|
|
237
|
+
customRiskDelta,
|
|
238
|
+
contextualRiskScore: builtInRiskScore === null ? null : Math.min(100, builtInRiskScore + customRiskDelta),
|
|
239
|
+
matchedRules,
|
|
240
|
+
suggestedTags: [...suggestedTags].sort(),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
export function evaluateRuleSet(records, rawRules) {
|
|
244
|
+
if (!Array.isArray(records))
|
|
245
|
+
return [];
|
|
246
|
+
return records.slice(0, 500).map((record) => evaluateDetectionRules(record, rawRules));
|
|
247
|
+
}
|
|
248
|
+
export function mergeDetectionRules(localRaw, importedRaw) {
|
|
249
|
+
const importedRecord = record(importedRaw);
|
|
250
|
+
if (typeof importedRecord.schema === 'string' && importedRecord.schema !== DETECTION_RULE_SCHEMA) {
|
|
251
|
+
throw new Error('This JSON file is not a WHOISleuth custom-rule export.');
|
|
252
|
+
}
|
|
253
|
+
const version = detectionRuleStoreVersion(importedRaw);
|
|
254
|
+
if (version !== null && version > DETECTION_RULE_SCHEMA_VERSION) {
|
|
255
|
+
throw new Error(`This custom-rule file uses newer schema ${version}. Update the app before importing it.`);
|
|
256
|
+
}
|
|
257
|
+
const local = normalizeDetectionRuleStore(localRaw).rules;
|
|
258
|
+
const byId = new Map(local.map((rule) => [rule.id, rule]));
|
|
259
|
+
const importedList = ruleList(importedRaw);
|
|
260
|
+
let added = 0;
|
|
261
|
+
let updated = 0;
|
|
262
|
+
let skipped = Math.max(0, importedList.length - MAX_RULE_INPUT_RECORDS);
|
|
263
|
+
for (const item of importedList.slice(0, MAX_RULE_INPUT_RECORDS)) {
|
|
264
|
+
const rule = normalizeDetectionRule(item);
|
|
265
|
+
if (!rule?.id) {
|
|
266
|
+
skipped++;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const storedRule = { ...rule, id: rule.id };
|
|
270
|
+
if (byId.has(rule.id)) {
|
|
271
|
+
byId.set(rule.id, storedRule);
|
|
272
|
+
updated++;
|
|
273
|
+
}
|
|
274
|
+
else if (byId.size < MAX_DETECTION_RULES) {
|
|
275
|
+
byId.set(rule.id, storedRule);
|
|
276
|
+
added++;
|
|
277
|
+
}
|
|
278
|
+
else
|
|
279
|
+
skipped++;
|
|
280
|
+
}
|
|
281
|
+
return { rules: [...byId.values()], added, updated, skipped };
|
|
282
|
+
}
|
|
283
|
+
function byteLength(value) {
|
|
284
|
+
return new TextEncoder().encode(value).byteLength;
|
|
285
|
+
}
|
|
286
|
+
export function assertDetectionRuleStoreBudget(rules) {
|
|
287
|
+
const store = normalizeDetectionRuleStore(rules);
|
|
288
|
+
if (byteLength(JSON.stringify(store)) > MAX_RULE_STORE_BYTES) {
|
|
289
|
+
throw new Error('Custom-rule storage is full. Remove or export rules before saving more.');
|
|
290
|
+
}
|
|
291
|
+
return store;
|
|
292
|
+
}
|
|
293
|
+
export function serializeDetectionRuleStore(rules) {
|
|
294
|
+
return JSON.stringify(assertDetectionRuleStoreBudget(rules));
|
|
295
|
+
}
|
|
296
|
+
export function buildDetectionRuleExport(rules, nowIso = new Date().toISOString()) {
|
|
297
|
+
const timestamp = typeof nowIso === 'string' ? nowIso : '';
|
|
298
|
+
const parsed = Date.parse(timestamp);
|
|
299
|
+
return {
|
|
300
|
+
schema: DETECTION_RULE_SCHEMA,
|
|
301
|
+
version: DETECTION_RULE_SCHEMA_VERSION,
|
|
302
|
+
exportedAt: Number.isFinite(parsed) ? new Date(parsed).toISOString() : new Date().toISOString(),
|
|
303
|
+
rules: normalizeDetectionRuleStore(rules).rules,
|
|
304
|
+
limitations: 'Custom rules are browser-local analyst heuristics. Matches and score contributions are not proof of maliciousness and do not alter built-in risk scores.',
|
|
305
|
+
};
|
|
306
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Protocol-level DNS scalar normalization shared by transient response and
|
|
2
|
+
// browser-local persistence projections. Keep it independent of either model.
|
|
3
|
+
export function normalizeCaaCritical(value) {
|
|
4
|
+
if (typeof value === 'number') {
|
|
5
|
+
return Number.isSafeInteger(value) && value >= 0 && value <= 255 ? value : null;
|
|
6
|
+
}
|
|
7
|
+
if (typeof value !== 'string' || !/^\d{1,3}$/u.test(value))
|
|
8
|
+
return null;
|
|
9
|
+
const parsed = Number(value);
|
|
10
|
+
return parsed <= 255 ? parsed : null;
|
|
11
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Pure, browser-storage-safe HTTP summary normalization. Rich Lookup HTTP
|
|
2
|
+
// observations retain bounded URLs, selected header values, redirect hops, and
|
|
3
|
+
// connection attempts. Bulk/watchlist/case records deliberately keep only this
|
|
4
|
+
// smaller derived shape: no paths, queries, header values, attempt errors, or
|
|
5
|
+
// redirect inventories.
|
|
6
|
+
export const HTTP_SUMMARY_VERSION = 1;
|
|
7
|
+
export const MAX_HTTP_SUMMARY_ORIGIN_LENGTH = 300;
|
|
8
|
+
export const MAX_HTTP_SUMMARY_CONTENT_TYPE_LENGTH = 100;
|
|
9
|
+
export const MAX_HTTP_SUMMARY_REDIRECTS = 5;
|
|
10
|
+
export const MAX_HTTP_SECURITY_HEADER_INPUTS = 20;
|
|
11
|
+
export const HTTP_SECURITY_HEADER_TOKENS = Object.freeze([
|
|
12
|
+
'content-security-policy',
|
|
13
|
+
'content-type-protection',
|
|
14
|
+
'frame-protection',
|
|
15
|
+
'hsts',
|
|
16
|
+
'referrer-policy',
|
|
17
|
+
]);
|
|
18
|
+
const EVIDENCE_STATUSES = new Set(['success', 'partial']);
|
|
19
|
+
const SECURITY_HEADER_TOKEN_SET = new Set(HTTP_SECURITY_HEADER_TOKENS);
|
|
20
|
+
const MIME_TOKEN_RE = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/;
|
|
21
|
+
function plainRecord(value) {
|
|
22
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
23
|
+
? value
|
|
24
|
+
: null;
|
|
25
|
+
}
|
|
26
|
+
function status(value) {
|
|
27
|
+
return typeof value === 'string' && EVIDENCE_STATUSES.has(value)
|
|
28
|
+
? value
|
|
29
|
+
: null;
|
|
30
|
+
}
|
|
31
|
+
function httpStatus(value) {
|
|
32
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 100 && value <= 599 ? value : null;
|
|
33
|
+
}
|
|
34
|
+
function redirectCount(value) {
|
|
35
|
+
return typeof value === 'number'
|
|
36
|
+
&& Number.isInteger(value)
|
|
37
|
+
&& value >= 0
|
|
38
|
+
&& value <= MAX_HTTP_SUMMARY_REDIRECTS
|
|
39
|
+
? value
|
|
40
|
+
: null;
|
|
41
|
+
}
|
|
42
|
+
function boolOrNull(value) {
|
|
43
|
+
return typeof value === 'boolean' ? value : null;
|
|
44
|
+
}
|
|
45
|
+
function transport(value) {
|
|
46
|
+
return value === 'https' || value === 'http' ? value : null;
|
|
47
|
+
}
|
|
48
|
+
function finalOrigin(value) {
|
|
49
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 4096 || /[\u0000-\u001f\u007f]/.test(value))
|
|
50
|
+
return null;
|
|
51
|
+
try {
|
|
52
|
+
const parsed = new URL(value);
|
|
53
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || !parsed.hostname)
|
|
54
|
+
return null;
|
|
55
|
+
return parsed.origin.length <= MAX_HTTP_SUMMARY_ORIGIN_LENGTH ? parsed.origin : null;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function contentType(value) {
|
|
62
|
+
if (typeof value !== 'string' || value.length > 300 || /[\u0000-\u001f\u007f]/.test(value))
|
|
63
|
+
return null;
|
|
64
|
+
const mime = (value.split(';', 1)[0] ?? '').trim().toLowerCase();
|
|
65
|
+
return mime.length <= MAX_HTTP_SUMMARY_CONTENT_TYPE_LENGTH && MIME_TOKEN_RE.test(mime) ? mime : null;
|
|
66
|
+
}
|
|
67
|
+
function securityHeaderTokens(value) {
|
|
68
|
+
if (!Array.isArray(value))
|
|
69
|
+
return null;
|
|
70
|
+
return [...new Set(value.slice(0, MAX_HTTP_SECURITY_HEADER_INPUTS)
|
|
71
|
+
.filter((token) => (typeof token === 'string' && SECURITY_HEADER_TOKEN_SET.has(token))))]
|
|
72
|
+
.sort();
|
|
73
|
+
}
|
|
74
|
+
function presentHeaderValue(value) {
|
|
75
|
+
return typeof value === 'string'
|
|
76
|
+
&& value.length <= 300
|
|
77
|
+
&& !/[\u0000-\u001f\u007f]/.test(value)
|
|
78
|
+
&& value.trim().length > 0;
|
|
79
|
+
}
|
|
80
|
+
function tokensFromRichHeaders(value) {
|
|
81
|
+
const headers = plainRecord(value);
|
|
82
|
+
if (!headers)
|
|
83
|
+
return null;
|
|
84
|
+
const tokens = [];
|
|
85
|
+
if (presentHeaderValue(headers.strictTransportSecurity))
|
|
86
|
+
tokens.push('hsts');
|
|
87
|
+
if (presentHeaderValue(headers.contentSecurityPolicy))
|
|
88
|
+
tokens.push('content-security-policy');
|
|
89
|
+
if (presentHeaderValue(headers.xFrameOptions))
|
|
90
|
+
tokens.push('frame-protection');
|
|
91
|
+
if (presentHeaderValue(headers.xContentTypeOptions))
|
|
92
|
+
tokens.push('content-type-protection');
|
|
93
|
+
if (presentHeaderValue(headers.referrerPolicy))
|
|
94
|
+
tokens.push('referrer-policy');
|
|
95
|
+
return tokens.sort();
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Derives the compact browser-local form from a rich HTTP observation.
|
|
99
|
+
* Failed/skipped observations and observations without a terminal response do
|
|
100
|
+
* not produce a summary: they cannot prove the domain's HTTP state.
|
|
101
|
+
*/
|
|
102
|
+
export function compactHttpObservation(value) {
|
|
103
|
+
const observation = plainRecord(value);
|
|
104
|
+
const response = plainRecord(observation?.response);
|
|
105
|
+
const evidenceStatus = status(observation?.status);
|
|
106
|
+
const responseStatus = httpStatus(response?.status);
|
|
107
|
+
if (!observation || !response || !evidenceStatus || responseStatus === null)
|
|
108
|
+
return null;
|
|
109
|
+
const origin = finalOrigin(observation.finalUrl) || finalOrigin(observation.requestUrl);
|
|
110
|
+
// The retained origin is the canonical transport source. This prevents a
|
|
111
|
+
// malformed rich observation from storing internally contradictory facts.
|
|
112
|
+
const observedTransport = origin
|
|
113
|
+
? transport(new URL(origin).protocol.slice(0, -1))
|
|
114
|
+
: transport(observation.transportSecurity);
|
|
115
|
+
return {
|
|
116
|
+
httpSummaryVersion: HTTP_SUMMARY_VERSION,
|
|
117
|
+
httpEvidenceStatus: evidenceStatus,
|
|
118
|
+
httpFinalOrigin: origin,
|
|
119
|
+
httpResponseStatus: responseStatus,
|
|
120
|
+
httpTransportSecurity: observedTransport,
|
|
121
|
+
httpRedirectCount: redirectCount(observation.redirectCount),
|
|
122
|
+
httpCrossOriginRedirect: boolOrNull(observation.crossOriginRedirect),
|
|
123
|
+
httpHttpsDowngrade: boolOrNull(observation.httpsDowngrade),
|
|
124
|
+
httpContentType: contentType(response.contentType),
|
|
125
|
+
httpSecurityHeaders: tokensFromRichHeaders(response.securityHeaders),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Revalidates an already-compact summary (or a record containing its fields)
|
|
130
|
+
* at every local-storage/import boundary. Unknown keys are discarded.
|
|
131
|
+
*/
|
|
132
|
+
export function normalizeHttpSummary(value) {
|
|
133
|
+
const record = plainRecord(value);
|
|
134
|
+
if (record?.httpSummaryVersion !== HTTP_SUMMARY_VERSION)
|
|
135
|
+
return null;
|
|
136
|
+
const evidenceStatus = status(record?.httpEvidenceStatus);
|
|
137
|
+
const responseStatus = httpStatus(record?.httpResponseStatus);
|
|
138
|
+
if (!record || !evidenceStatus || responseStatus === null)
|
|
139
|
+
return null;
|
|
140
|
+
const origin = finalOrigin(record.httpFinalOrigin);
|
|
141
|
+
return {
|
|
142
|
+
httpSummaryVersion: HTTP_SUMMARY_VERSION,
|
|
143
|
+
httpEvidenceStatus: evidenceStatus,
|
|
144
|
+
httpFinalOrigin: origin,
|
|
145
|
+
httpResponseStatus: responseStatus,
|
|
146
|
+
httpTransportSecurity: origin
|
|
147
|
+
? transport(new URL(origin).protocol.slice(0, -1))
|
|
148
|
+
: transport(record.httpTransportSecurity),
|
|
149
|
+
httpRedirectCount: redirectCount(record.httpRedirectCount),
|
|
150
|
+
httpCrossOriginRedirect: boolOrNull(record.httpCrossOriginRedirect),
|
|
151
|
+
httpHttpsDowngrade: boolOrNull(record.httpHttpsDowngrade),
|
|
152
|
+
httpContentType: contentType(record.httpContentType),
|
|
153
|
+
httpSecurityHeaders: securityHeaderTokens(record.httpSecurityHeaders),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
export function httpSecurityHeaderLabel(token) {
|
|
157
|
+
const labels = {
|
|
158
|
+
'content-security-policy': 'Content Security Policy',
|
|
159
|
+
'content-type-protection': 'Content-type protection',
|
|
160
|
+
'frame-protection': 'Frame protection',
|
|
161
|
+
hsts: 'HSTS',
|
|
162
|
+
'referrer-policy': 'Referrer policy',
|
|
163
|
+
};
|
|
164
|
+
return labels[token] || token;
|
|
165
|
+
}
|