muaddib-scanner 2.11.18 → 2.11.20
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/package.json +1 -1
- package/src/response/playbooks.js +5 -0
- package/src/rules/index.js +25 -2
- package/src/scanner/dataflow.js +36 -7
- package/src/scanner/typosquat.js +30 -1
- package/src/scoring.js +45 -17
package/package.json
CHANGED
|
@@ -168,6 +168,11 @@ const PLAYBOOKS = {
|
|
|
168
168
|
'Le code charge cette dep typosquattee via require/import. Si ce n\'est pas intentionnel, supprimer la dep et la reference, puis reinstaller avec --ignore-scripts.',
|
|
169
169
|
dependency_typosquat_require:
|
|
170
170
|
'CRITIQUE — pattern Axios UNC1069 detecte: dep typosquattee declaree ET chargee dans le code. Le wrapper apparent est probablement legitime mais sa dep contient le payload. Bloquer l\'install (--ignore-scripts), supprimer la dep, auditer le history de modifications.',
|
|
171
|
+
// RT-C1-FPR (audit 2026-05)
|
|
172
|
+
typosquat_lifecycle:
|
|
173
|
+
'CRITIQUE — pattern boundary-squat avec hook lifecycle: la dep typosquattee sera executee a l\'install. Bloquer l\'install (--ignore-scripts), supprimer la dep, regenerer les secrets exposes, auditer le history.',
|
|
174
|
+
typosquat_dataflow:
|
|
175
|
+
'IMPORTANT — dep boundary-squat coexistante avec un flux credentials → reseau dans le code. Verifier si la dep est require()d depuis le fichier d\'exfil. Si oui = CRITICAL (compound dependency_typosquat_require complementaire).',
|
|
171
176
|
|
|
172
177
|
dangerous_call_function:
|
|
173
178
|
'Appel new Function() detecte. Equivalent a eval(). Verifier la source des donnees.',
|
package/src/rules/index.js
CHANGED
|
@@ -350,9 +350,13 @@ const RULES = {
|
|
|
350
350
|
dependency_typosquat: {
|
|
351
351
|
id: 'MUADDIB-TYPO-002',
|
|
352
352
|
name: 'Dependency Boundary-Squat',
|
|
353
|
-
|
|
353
|
+
// RT-C1-FPR (audit 2026-05): demoted HIGH -> MEDIUM. Boundary-squat alone is
|
|
354
|
+
// a name-resemblance heuristic without execution proof. Compounds typosquat_lifecycle
|
|
355
|
+
// (CRITICAL), typosquat_dataflow (HIGH), dependency_typosquat_require (CRITICAL)
|
|
356
|
+
// escalate when co-occurring with real execution/exfil signals.
|
|
357
|
+
severity: 'MEDIUM',
|
|
354
358
|
confidence: 'high',
|
|
355
|
-
description: 'Une dependance declaree porte le nom d\'un package populaire prefixe/suffixe d\'un token suspect (Axios UNC1069, mars 2026). Le wrapper innocent declare un sub-dep malveillant.',
|
|
359
|
+
description: 'Une dependance declaree porte le nom d\'un package populaire prefixe/suffixe d\'un token suspect (Axios UNC1069, mars 2026). Le wrapper innocent declare un sub-dep malveillant. Signal solo MEDIUM, escalade CRITICAL via compounds lifecycle/dataflow/require.',
|
|
356
360
|
references: [
|
|
357
361
|
'https://snyk.io/blog/typosquatting-attacks/',
|
|
358
362
|
'https://attack.mitre.org/techniques/T1195/002/'
|
|
@@ -377,6 +381,25 @@ const RULES = {
|
|
|
377
381
|
references: ['https://attack.mitre.org/techniques/T1195/002/'],
|
|
378
382
|
mitre: 'T1195.002'
|
|
379
383
|
},
|
|
384
|
+
// RT-C1-FPR (audit 2026-05): compounds escaladant dependency_typosquat solo (MEDIUM)
|
|
385
|
+
typosquat_lifecycle: {
|
|
386
|
+
id: 'MUADDIB-COMPOUND-014',
|
|
387
|
+
name: 'Boundary-Squat Dep + Lifecycle Hook',
|
|
388
|
+
severity: 'CRITICAL',
|
|
389
|
+
confidence: 'high',
|
|
390
|
+
description: 'Dependance boundary-squat declaree avec script lifecycle (preinstall/postinstall) — install-time payload delivery via typosquat sub-dep.',
|
|
391
|
+
references: ['https://attack.mitre.org/techniques/T1195/002/'],
|
|
392
|
+
mitre: 'T1195.002'
|
|
393
|
+
},
|
|
394
|
+
typosquat_dataflow: {
|
|
395
|
+
id: 'MUADDIB-COMPOUND-015',
|
|
396
|
+
name: 'Boundary-Squat Dep + Suspicious Dataflow',
|
|
397
|
+
severity: 'HIGH',
|
|
398
|
+
confidence: 'high',
|
|
399
|
+
description: 'Dependance boundary-squat declaree avec flux de donnees suspect (lecture credentials + envoi reseau) — typosquat dep co-occurring with exfiltration.',
|
|
400
|
+
references: ['https://attack.mitre.org/techniques/T1195/002/'],
|
|
401
|
+
mitre: 'T1195.002'
|
|
402
|
+
},
|
|
380
403
|
|
|
381
404
|
// Package.json script patterns
|
|
382
405
|
curl_pipe_sh: {
|
package/src/scanner/dataflow.js
CHANGED
|
@@ -831,7 +831,7 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
831
831
|
const envVar = node.property?.name || '';
|
|
832
832
|
if (isSensitiveEnv(envVar)) {
|
|
833
833
|
sources.push({
|
|
834
|
-
type: 'env_read',
|
|
834
|
+
type: isCredentialEnv(envVar) ? 'credential_env_read' : 'env_read',
|
|
835
835
|
name: envVar,
|
|
836
836
|
line: node.loc?.start?.line,
|
|
837
837
|
taint_tracked: true
|
|
@@ -857,7 +857,7 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
857
857
|
const envVar = node.property?.name || '';
|
|
858
858
|
if (isSensitiveEnv(envVar)) {
|
|
859
859
|
sources.push({
|
|
860
|
-
type: 'env_read',
|
|
860
|
+
type: isCredentialEnv(envVar) ? 'credential_env_read' : 'env_read',
|
|
861
861
|
name: envVar,
|
|
862
862
|
line: node.loc?.start?.line
|
|
863
863
|
});
|
|
@@ -948,7 +948,7 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
948
948
|
if (taint && EXFIL_PRONE_MODULES.has(taint.source)) exfilProneInScope.push(taint.source);
|
|
949
949
|
}
|
|
950
950
|
if (exfilProneInScope.length > 0 &&
|
|
951
|
-
sources.some(s => s.type === 'env_read' || s.type === 'credential_read')) {
|
|
951
|
+
sources.some(s => s.type === 'env_read' || s.type === 'credential_env_read' || s.type === 'credential_read')) {
|
|
952
952
|
const moduleList = [...new Set(exfilProneInScope)].join(', ');
|
|
953
953
|
const firstSourceLine = sources.find(s => s.line)?.line || 0;
|
|
954
954
|
threats.push({
|
|
@@ -1028,12 +1028,16 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
1028
1028
|
}
|
|
1029
1029
|
|
|
1030
1030
|
// Graduation: HIGH → MEDIUM for env/telemetry-only sources (no credential file reads,
|
|
1031
|
-
// no fingerprint reads, no command output
|
|
1032
|
-
// is the dominant FP pattern (SDK/API usage, binary
|
|
1033
|
-
// Real credential exfiltration uses credential_read
|
|
1031
|
+
// no fingerprint reads, no command output, no credential-tier env vars). Distant
|
|
1032
|
+
// env/telemetry → network_send is the dominant FP pattern (SDK/API usage, binary
|
|
1033
|
+
// wrappers, config libraries). Real credential exfiltration uses credential_read,
|
|
1034
|
+
// fingerprint_read, or credential_env_read sources (audit 2026-05 DF-C4: NPM_TOKEN,
|
|
1035
|
+
// GITHUB_TOKEN, AWS_SECRET_* now classified as credential_env_read upstream and
|
|
1036
|
+
// retained at HIGH instead of downgrading to MEDIUM with the rest of env_read).
|
|
1034
1037
|
if (severity === 'HIGH') {
|
|
1035
1038
|
const hasHighRiskSource = sources.some(s =>
|
|
1036
|
-
s.type === 'credential_read' || s.type === 'fingerprint_read' ||
|
|
1039
|
+
s.type === 'credential_read' || s.type === 'fingerprint_read' ||
|
|
1040
|
+
s.type === 'command_output' || s.type === 'credential_env_read'
|
|
1037
1041
|
);
|
|
1038
1042
|
if (!hasHighRiskSource) {
|
|
1039
1043
|
severity = 'MEDIUM';
|
|
@@ -1169,4 +1173,29 @@ function isSensitiveEnv(name) {
|
|
|
1169
1173
|
return sensitive.some(s => upper.includes(s));
|
|
1170
1174
|
}
|
|
1171
1175
|
|
|
1176
|
+
// Audit 2026-05 DF-C4: credential-tier env vars distinguished from generic env_read.
|
|
1177
|
+
// These represent authentication material (NPM_TOKEN, GITHUB_TOKEN, AWS_SECRET_ACCESS_KEY,
|
|
1178
|
+
// STRIPE_API_KEY etc.) — strictly narrower than isSensitiveEnv. Sources of this type
|
|
1179
|
+
// participate in hasHighRiskSource so credential exfil patterns are NOT downgraded by the
|
|
1180
|
+
// HIGH→MEDIUM graduation. System identity vars (HOME, USER) remain plain env_read since
|
|
1181
|
+
// they are fingerprinting signals, not credentials.
|
|
1182
|
+
const KNOWN_CREDENTIAL_ENV_VARS = new Set([
|
|
1183
|
+
'NPM_TOKEN', 'GITHUB_TOKEN', 'GH_TOKEN', 'NODE_AUTH_TOKEN',
|
|
1184
|
+
'CIRCLE_TOKEN', 'GITLAB_TOKEN', 'CARGO_REGISTRY_TOKEN', 'PYPI_TOKEN',
|
|
1185
|
+
'GOOGLE_APPLICATION_CREDENTIALS', 'AZURE_CLIENT_SECRET',
|
|
1186
|
+
'SENTRY_AUTH_TOKEN', 'NPM_AUTH_TOKEN', 'NPM_CONFIG_AUTHTOKEN'
|
|
1187
|
+
]);
|
|
1188
|
+
|
|
1189
|
+
const CREDENTIAL_ENV_SUFFIX_RE = /(?:^|_)(?:TOKEN|SECRET|PASSWORD|PASSPHRASE|CREDENTIAL|CREDENTIALS|API_KEY|ACCESS_KEY|ACCESS_KEY_ID|SECRET_KEY|PRIVATE_KEY|SIGNING_KEY|SESSION_TOKEN|REFRESH_TOKEN|AUTH_TOKEN)$/;
|
|
1190
|
+
|
|
1191
|
+
function isCredentialEnv(name) {
|
|
1192
|
+
const upper = name.toUpperCase();
|
|
1193
|
+
// System identity vars are fingerprinting, not credentials
|
|
1194
|
+
if (SYSTEM_IDENTITY_ENVS.has(upper)) return false;
|
|
1195
|
+
// Public keys are not credentials (e.g., SSH_PUBLIC_KEY, GPG_PUBLIC_KEY)
|
|
1196
|
+
if (upper.includes('PUBLIC_KEY') || upper.includes('PUBKEY')) return false;
|
|
1197
|
+
if (KNOWN_CREDENTIAL_ENV_VARS.has(upper)) return true;
|
|
1198
|
+
return CREDENTIAL_ENV_SUFFIX_RE.test(upper);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1172
1201
|
module.exports = { analyzeDataFlow };
|
package/src/scanner/typosquat.js
CHANGED
|
@@ -30,6 +30,23 @@ const POPULAR_PACKAGES = [
|
|
|
30
30
|
'crypto-js'
|
|
31
31
|
];
|
|
32
32
|
|
|
33
|
+
// RT-C1-FPR (audit 2026-05): frameworks dont les packages d'ecosysteme ont la
|
|
34
|
+
// convention <framework>-<role>-<extension> (eslint-plugin-react, babel-preset-env,
|
|
35
|
+
// gatsby-plugin-mdx, eslint-import-resolver-typescript). Un dep dont le PREMIER
|
|
36
|
+
// segment hyphene est dans ce Set est une extension legitime du framework, jamais
|
|
37
|
+
// un squat d'un autre package -- quelles que soient les autres segments.
|
|
38
|
+
// EXCLUS volontairement : react, vue, angular, svelte, typescript. Leurs packages
|
|
39
|
+
// d'ecosysteme ont le framework EN SUFFIXE (eslint-plugin-react), donc la convention
|
|
40
|
+
// prefixe ne s'applique pas et conserver la detection de squats `react-token-exfil`
|
|
41
|
+
// reste prioritaire.
|
|
42
|
+
const ECOSYSTEM_FRAMEWORK_PREFIXES = new Set([
|
|
43
|
+
'eslint', 'babel', 'webpack', 'rollup', 'vite', 'parcel', 'esbuild', 'swc', 'tsup',
|
|
44
|
+
'gatsby', 'next', 'nuxt', 'remix', 'expo', 'metro',
|
|
45
|
+
'jest', 'mocha', 'karma', 'cypress', 'playwright', 'vitest',
|
|
46
|
+
'postcss', 'stylelint', 'prettier', 'typedoc',
|
|
47
|
+
'storybook', 'docusaurus', 'astro'
|
|
48
|
+
]);
|
|
49
|
+
|
|
33
50
|
// RT-C1: Hyphen tokens that legitimately PREFIX or SUFFIX popular package names.
|
|
34
51
|
// `<token>-<popular>` or `<popular>-<token>` is considered benign when the extra
|
|
35
52
|
// token is in this set (ecosystem qualifiers, framework prefixes, official scopes).
|
|
@@ -372,7 +389,11 @@ async function scanTyposquatting(targetPath) {
|
|
|
372
389
|
+ bMatch.original + '" (extra token: "' + bMatch.extra + '"). Axios UNC1069 pattern.';
|
|
373
390
|
threats.push({
|
|
374
391
|
type: 'dependency_typosquat',
|
|
375
|
-
|
|
392
|
+
// RT-C1-FPR (audit 2026-05): demoted HIGH -> MEDIUM. Boundary-squat alone is
|
|
393
|
+
// a heuristic signal (name resemblance, no execution proof). The compounds
|
|
394
|
+
// typosquat_lifecycle (CRITICAL) and typosquat_dataflow (HIGH) escalate when
|
|
395
|
+
// co-occurring with real execution/exfil signals.
|
|
396
|
+
severity: 'MEDIUM',
|
|
376
397
|
message: declMsg,
|
|
377
398
|
file: 'package.json',
|
|
378
399
|
details: {
|
|
@@ -557,6 +578,14 @@ function findTyposquatMatch(name) {
|
|
|
557
578
|
}
|
|
558
579
|
|
|
559
580
|
function isLegitimateVariant(name) {
|
|
581
|
+
// RT-C1-FPR (audit 2026-05): ecosystem framework prefix -> legitimate extension.
|
|
582
|
+
// Ex: eslint-import-resolver-typescript, babel-loader-svelte, gatsby-source-fs.
|
|
583
|
+
const firstHyphen = name.indexOf('-');
|
|
584
|
+
if (firstHyphen > 0) {
|
|
585
|
+
const prefix = name.slice(0, firstHyphen);
|
|
586
|
+
if (ECOSYSTEM_FRAMEWORK_PREFIXES.has(prefix)) return true;
|
|
587
|
+
}
|
|
588
|
+
|
|
560
589
|
// Suffixes legitimes qui ne sont PAS du typosquatting
|
|
561
590
|
const legitimateSuffixes = [
|
|
562
591
|
'-cli', '-core', '-utils', '-plugin', '-loader', '-webpack',
|
package/src/scoring.js
CHANGED
|
@@ -183,13 +183,11 @@ function isPackageLevelThreat(threat) {
|
|
|
183
183
|
* @param {Array} threats - array of threat objects (after FP reductions)
|
|
184
184
|
* @returns {number} score 0-100
|
|
185
185
|
*/
|
|
186
|
-
// Hybrid v3 Phase 3:
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
return _COMPOUND_REPLACE_ENABLED() && t.replacedByCompound;
|
|
192
|
-
}
|
|
186
|
+
// Hybrid v3 Phase 3: threats tagged with replacedByCompound (their compound has
|
|
187
|
+
// fired and represents their score) contribute 0 to the group score. Avoids the
|
|
188
|
+
// additive double-count of compound + constituents. Audit 2026-05 SC-C1: the
|
|
189
|
+
// previous MUADDIB_COMPOUND_REPLACE env-var gate is removed — the tag posed by
|
|
190
|
+
// applyCompoundBoosts is now honored unconditionally.
|
|
193
191
|
|
|
194
192
|
function computeGroupScore(threats) {
|
|
195
193
|
// Score decay default ON since v2.11.9 (FPR plan Chantier 1). Opt-out: MUADDIB_DECAY=0.
|
|
@@ -199,7 +197,7 @@ function computeGroupScore(threats) {
|
|
|
199
197
|
let dataflowMediumPoints = 0;
|
|
200
198
|
|
|
201
199
|
for (const t of threats) {
|
|
202
|
-
if (
|
|
200
|
+
if (t.replacedByCompound) continue;
|
|
203
201
|
const weight = _severityWeights[t.severity] || 0;
|
|
204
202
|
const rule = getRule(t.type);
|
|
205
203
|
const factor = CONFIDENCE_FACTORS[rule.confidence] || 1.0;
|
|
@@ -257,7 +255,7 @@ function computeGroupScoreDecay(threats) {
|
|
|
257
255
|
const typeBuckets = new Map();
|
|
258
256
|
|
|
259
257
|
for (const t of threats) {
|
|
260
|
-
if (
|
|
258
|
+
if (t.replacedByCompound) continue;
|
|
261
259
|
const weight = _severityWeights[t.severity] || 0;
|
|
262
260
|
const rule = getRule(t.type);
|
|
263
261
|
const factor = CONFIDENCE_FACTORS[rule.confidence] || 1.0;
|
|
@@ -303,7 +301,11 @@ const FP_COUNT_THRESHOLDS = {
|
|
|
303
301
|
// Real malware uses 1-2 targeted Function() calls.
|
|
304
302
|
dangerous_call_function: { maxCount: 5, to: 'LOW' },
|
|
305
303
|
require_cache_poison: { maxCount: 3, from: 'CRITICAL', to: 'LOW' },
|
|
306
|
-
|
|
304
|
+
// Audit 2026-05 SC-C2: floorEligible: true opts suspicious_dataflow into the
|
|
305
|
+
// dilution floor without adding a `from` constraint (which would block MEDIUM
|
|
306
|
+
// count-threshold downgrades). Restores 1 instance at original severity so an
|
|
307
|
+
// attacker can't dilute real exfil flows by injecting benign data flows.
|
|
308
|
+
suspicious_dataflow: { maxCount: 3, to: 'LOW', floorEligible: true },
|
|
307
309
|
obfuscation_detected: { maxCount: 3, to: 'LOW' },
|
|
308
310
|
module_compile_dynamic: { maxCount: 3, from: 'HIGH', to: 'LOW' },
|
|
309
311
|
module_compile: { maxCount: 3, from: 'HIGH', to: 'LOW' },
|
|
@@ -322,8 +324,10 @@ const FP_COUNT_THRESHOLDS = {
|
|
|
322
324
|
// P6: HTTP client libraries (undici, aws-sdk, nodemailer, jsdom) parse Authorization/Bearer headers
|
|
323
325
|
// with 3+ credential regexes. Real harvesters use 1-2 targeted regexes.
|
|
324
326
|
// Audit v3: removed `from` constraint — ALL severity levels downgraded when count > 2.
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
+
// Audit 2026-05 SC-C2: floorEligible: true restores 1 instance at original
|
|
328
|
+
// severity. Without it an attacker injects 3+ benign header regexes (Authorization,
|
|
329
|
+
// Cookie, X-Forwarded-For) and downgrades all real exfil regexes to LOW.
|
|
330
|
+
credential_regex_harvest: { maxCount: 2, to: 'LOW', floorEligible: true },
|
|
327
331
|
// P7→Audit v3: Config frameworks (pm2, nx, dotenv, aws-sdk, oclif) read 5+ env vars — not credential theft.
|
|
328
332
|
// Real stealers access 1-4 targeted env vars. Count >4 = config loader pattern.
|
|
329
333
|
// Lowered from 10→4 for better FP reduction. B5 network_sink_immunity protects genuine exfiltration.
|
|
@@ -505,6 +509,30 @@ const SCORING_COMPOUNDS = [
|
|
|
505
509
|
message: 'Boundary-squat dependency declared AND require()d in code — Axios UNC1069 pattern (scoring compound).',
|
|
506
510
|
fileFrom: 'dependency_typosquat_used'
|
|
507
511
|
},
|
|
512
|
+
{
|
|
513
|
+
// RT-C1-FPR (audit 2026-05): boundary-squat dep + lifecycle hook → install-time
|
|
514
|
+
// payload delivery via typosquat sub-dep. Mirror of dependency_typosquat_require
|
|
515
|
+
// but with lifecycle instead of _used: stronger signal — proves install-time
|
|
516
|
+
// execution intent without requiring explicit require() in scanned code.
|
|
517
|
+
type: 'typosquat_lifecycle',
|
|
518
|
+
requires: ['dependency_typosquat', 'lifecycle_script'],
|
|
519
|
+
severity: 'CRITICAL',
|
|
520
|
+
message: 'Boundary-squat dependency + lifecycle hook — install-time payload delivery via typosquat sub-dep (scoring compound).',
|
|
521
|
+
fileFrom: 'dependency_typosquat'
|
|
522
|
+
// No sameFile: both are package.json-level
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
// RT-C1-FPR (audit 2026-05): boundary-squat dep + suspicious dataflow → typosquat
|
|
526
|
+
// dep co-occurring with credential exfil. Mirror of lifecycle_dataflow (HIGH) —
|
|
527
|
+
// co-occurrence without direct causal link, so HIGH not CRITICAL.
|
|
528
|
+
type: 'typosquat_dataflow',
|
|
529
|
+
requires: ['dependency_typosquat', 'suspicious_dataflow'],
|
|
530
|
+
severity: 'HIGH',
|
|
531
|
+
message: 'Boundary-squat dependency + suspicious dataflow — typosquat dep co-occurring with credential exfil (scoring compound).',
|
|
532
|
+
fileFrom: 'suspicious_dataflow',
|
|
533
|
+
// No sameFile: dep is package.json, dataflow is src/*.js
|
|
534
|
+
excludeIfBundled: true
|
|
535
|
+
},
|
|
508
536
|
{
|
|
509
537
|
type: 'lifecycle_inline_exec',
|
|
510
538
|
requires: ['lifecycle_script', 'node_inline_exec'],
|
|
@@ -1015,16 +1043,16 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps, re
|
|
|
1015
1043
|
|
|
1016
1044
|
// Dilution floor: retain at least one instance at original severity per type
|
|
1017
1045
|
// to prevent complete count-threshold dilution by injected benign patterns.
|
|
1018
|
-
//
|
|
1019
|
-
//
|
|
1020
|
-
//
|
|
1021
|
-
//
|
|
1046
|
+
// Applies to types with low maxCount (≤3) AND either a `from` severity
|
|
1047
|
+
// constraint OR an explicit `floorEligible: true` opt-in (audit 2026-05 SC-C2).
|
|
1048
|
+
// High-count types (dynamic_require, env_access) represent legitimate framework
|
|
1049
|
+
// patterns and remain ineligible (no floor → full downgrade allowed).
|
|
1022
1050
|
const restoredTypes = new Set();
|
|
1023
1051
|
for (const t of threats) {
|
|
1024
1052
|
const lastReduction = t.reductions?.find(r => r.rule === 'count_threshold');
|
|
1025
1053
|
if (lastReduction && !restoredTypes.has(t.type)) {
|
|
1026
1054
|
const rule = FP_COUNT_THRESHOLDS[t.type];
|
|
1027
|
-
if (rule && rule.from && rule.maxCount <= 3) {
|
|
1055
|
+
if (rule && (rule.from || rule.floorEligible) && rule.maxCount <= 3) {
|
|
1028
1056
|
t.severity = lastReduction.from;
|
|
1029
1057
|
t.reductions = t.reductions.filter(r => r.rule !== 'count_threshold');
|
|
1030
1058
|
t.reductions.push({ rule: 'count_threshold_floor', note: 'retained one instance at original severity' });
|