verdaccio-security-filter 1.3.9 → 1.3.11

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/README.md CHANGED
@@ -36,17 +36,15 @@ This plugin implements **two independent security layers** for complete protecti
36
36
 
37
37
  #### 🔒 Version Management
38
38
  - **Version Range Blocking** - Block vulnerable versions using semver ranges
39
- - **Block Strategy**: Completely remove versions from registry
40
- - **Fallback Strategy**: Transparently redirect to safe versions
39
+ - Matching versions are hidden from metadata and their tarballs return 403, so npm resolves to the highest allowed version
41
40
  - **Exact Version Blocking** - Block specific package@version combinations
42
41
  - **Semver Support** - Full semver syntax (`^`, `~`, `>`, `>=`, `<`, `<=`, `x`, `*`)
43
42
 
44
43
  #### 🛡️ CVE & Vulnerability Scanning
45
44
  - **OSV Database Integration** - Automatic vulnerability scanning via [OSV API](https://osv.dev/)
46
45
  - **Severity Filtering** - Filter by severity: `low`, `medium`, `high`, `critical`
47
- - **Auto-Block Vulnerable Packages** - Automatically block packages with known CVEs
48
- - **Caching System** - Configurable cache with update intervals
49
- - **Multiple Database Support** - Ready for OSV, Snyk, GitHub Advisory
46
+ - **Auto-Block Vulnerable Versions** - Remove versions with known CVEs from metadata
47
+ - **Caching System** - One OSV lookup per package, cached per package with a configurable refresh interval
50
48
 
51
49
  #### ⚖️ License Compliance
52
50
  - **License Filtering** - Enforce license policies with allowed/blocked lists
@@ -62,7 +60,7 @@ This plugin implements **two independent security layers** for complete protecti
62
60
 
63
61
  #### 📊 Monitoring & Observability
64
62
  - **Enhanced Logging** - Configurable log levels (`debug`, `info`, `warn`, `error`)
65
- - **Metrics Collection** - Track security events (blocks, fallbacks, CVEs, license violations)
63
+ - **Metrics Collection** - Track security events (blocks with their reason, author blocks)
66
64
  - **Security Audit Trail** - Detailed logging of all security decisions
67
65
  - **Customizable Output** - Log to stdout or file in JSON format
68
66
 
@@ -83,9 +81,7 @@ This plugin implements **two independent security layers** for complete protecti
83
81
  - Prevents blocked packages from being installed as dependencies
84
82
  - Intercepts requests to `/:package/-/:filename.tgz` routes
85
83
  - Returns 403 Forbidden with detailed error messages
86
- - **Size Limits** - Enforce minimum and maximum package sizes
87
84
  - **Metadata Validation** - Verify package integrity and detect dangerous characters
88
- - **Checksum Enforcement** - Ensure package integrity
89
85
 
90
86
  #### ⚙️ Reliability & Error Handling
91
87
  - **Fail-Safe/Fail-Closed Configuration** - Choose error handling strategy
@@ -274,7 +270,6 @@ filters:
274
270
  enabled: true
275
271
  databases:
276
272
  - osv
277
- - github
278
273
  severity:
279
274
  - high
280
275
  - critical
@@ -304,8 +299,7 @@ filters:
304
299
  versionRangeRules:
305
300
  - package: lodash
306
301
  range: "<4.17.21"
307
- strategy: fallback
308
- fallbackVersion: "4.17.21"
302
+ strategy: block
309
303
  reason: "CVE-2021-23337: Command injection"
310
304
 
311
305
  - package: minimist
@@ -379,7 +373,7 @@ filters:
379
373
 
380
374
  cveCheck:
381
375
  enabled: true
382
- databases: [osv, github, snyk]
376
+ databases: [osv]
383
377
  severity: [low, medium, high, critical] # Block ALL
384
378
  autoBlock: true
385
379
  updateInterval: 6 # Check every 6 hours
@@ -389,9 +383,6 @@ filters:
389
383
  blocked: [GPL-3.0, AGPL-3.0, LGPL-3.0]
390
384
  requireLicense: true
391
385
 
392
- minPackageSize: 10000 # 10KB minimum
393
- maxPackageSize: 10485760 # 10MB maximum
394
-
395
386
  logger:
396
387
  level: debug # Log everything
397
388
  enabled: true
@@ -419,23 +410,22 @@ middlewares:
419
410
  | `blockedPatterns` | `string[]` | `[]` | Regex patterns for package names |
420
411
  | `allowedScopes` | `string[]` | `[]` | Allowed package scopes |
421
412
  | `blockedScopes` | `string[]` | `[]` | Blocked package scopes |
422
- | `minPackageSize` | `number` | `0` | Minimum package size in bytes |
423
- | `maxPackageSize` | `number` | `104857600` | Maximum package size (100MB) |
424
- | `enforceChecksum` | `boolean` | `true` | Enforce checksum validation |
425
413
 
426
414
  ### CVE Check Options
427
415
 
428
416
  | Option | Type | Default | Description |
429
417
  |--------|------|---------|-------------|
430
418
  | `cveCheck.enabled` | `boolean` | `false` | Enable CVE scanning |
431
- | `cveCheck.databases` | `string[]` | `['osv']` | Databases: `osv`, `snyk`, `github` |
419
+ | `cveCheck.databases` | `string[]` | `['osv']` | Vulnerability databases. Only `osv` is implemented; it already includes GitHub Security Advisories |
432
420
  | `cveCheck.severity` | `string[]` | `['high', 'critical']` | Severity levels to check |
433
421
  | `cveCheck.autoBlock` | `boolean` | `false` | Remove vulnerable versions from metadata (whole package blocked if every version is vulnerable) |
434
- | `cveCheck.updateInterval` | `number` | `24` | Cache update interval (hours) |
422
+ | `cveCheck.updateInterval` | `number` | `24` | How long a package's vulnerability data is cached (hours) |
435
423
  | `cveCheck.cacheDir` | `string` | `./.security-cache` | Cache directory path |
436
424
 
437
425
  > **Note on `autoBlock` and dist-tags:** only the vulnerable versions are removed; unaffected versions stay installable. Any dist-tag (including `latest`) that points to a removed version is dropped and **not** re-pointed to another version. If `latest` is vulnerable, `npm install <pkg>` without a version or range will fail - install an explicit safe version or range instead (e.g. `npm install <pkg>@^1.2.0`).
438
426
 
427
+ > **When OSV is unreachable:** a lookup gets one retry with a 5 second timeout each. After a failure the same package is not looked up again for 60 seconds. Previously cached data, even if older than `updateInterval`, keeps being used. With no cached data, `errorHandling.onCveCheckError` decides: `fail-open` serves the package unchecked, `fail-closed` blocks it.
428
+
439
429
  ### License Options
440
430
 
441
431
  | Option | Type | Default | Description |
@@ -496,8 +486,7 @@ middlewares:
496
486
  | Option | Type | Default | Description |
497
487
  |--------|------|---------|-------------|
498
488
  | `errorHandling.onFilterError` | `'fail-open' \| 'fail-closed'` | `'fail-open'` | Strategy when filter errors occur |
499
- | `errorHandling.onCveCheckError` | `'fail-open' \| 'fail-closed'` | `'fail-open'` | Strategy when CVE check fails |
500
- | `errorHandling.onLicenseCheckError` | `'fail-open' \| 'fail-closed'` | `'fail-open'` | Strategy when license check fails |
489
+ | `errorHandling.onCveCheckError` | `'fail-open' \| 'fail-closed'` | `'fail-open'` | Strategy when the vulnerability lookup fails and no cached data exists |
501
490
 
502
491
  **Error Handling Strategies:**
503
492
  - **fail-open** (Default): Allow packages through on errors (prioritizes availability)
@@ -509,19 +498,16 @@ middlewares:
509
498
  errorHandling:
510
499
  onFilterError: fail-closed
511
500
  onCveCheckError: fail-closed
512
- onLicenseCheckError: fail-closed
513
501
 
514
502
  # Development: Availability-first approach
515
503
  errorHandling:
516
504
  onFilterError: fail-open
517
505
  onCveCheckError: fail-open
518
- onLicenseCheckError: fail-open
519
506
 
520
- # Balanced: Block only on license failures
507
+ # Balanced: block only when vulnerability data is unavailable
521
508
  errorHandling:
522
509
  onFilterError: fail-open
523
- onCveCheckError: fail-open
524
- onLicenseCheckError: fail-closed
510
+ onCveCheckError: fail-closed
525
511
  ```
526
512
 
527
513
  ### Version Range Rules
@@ -530,8 +516,7 @@ errorHandling:
530
516
  {
531
517
  package: string; // Package name
532
518
  range: string; // Semver range
533
- strategy: 'block' | 'fallback';
534
- fallbackVersion?: string; // Required for fallback strategy
519
+ strategy: 'block'; // 'fallback' is deprecated and behaves like 'block'
535
520
  reason?: string; // Explanation for blocking
536
521
  }
537
522
  ```
@@ -586,12 +571,7 @@ npm run build -- --watch
586
571
 
587
572
  When metrics are enabled, the plugin tracks:
588
573
 
589
- - `block` - Packages blocked by version/pattern/scope rules
590
- - `fallback` - Versions redirected to safe alternatives
591
- - `publish_rejected` - Publish attempts rejected
592
- - `cve_detected` - CVE vulnerabilities found
593
- - `license_blocked` - Packages blocked by license rules
594
- - `package_too_new` - Packages/versions blocked due to age restrictions
574
+ - `block` - Packages or versions blocked by rules, CVE, license or age checks; the `reason` field says which
595
575
  - `author_blocked` - Packages blocked due to author/region filtering
596
576
 
597
577
  Example metrics output:
@@ -621,17 +601,18 @@ versionRangeRules:
621
601
  reason: "CVE-2021-23337: Command injection vulnerability"
622
602
  ```
623
603
 
624
- ### 2. Transparent Security Patches (Fallback)
604
+ ### 2. Steer Installs to a Patched Version
625
605
 
626
606
  ```yaml
627
607
  versionRangeRules:
628
608
  - package: axios
629
609
  range: "0.21.1"
630
- strategy: fallback
631
- fallbackVersion: "0.21.4"
610
+ strategy: block
632
611
  reason: "SSRF vulnerability fix"
633
612
  ```
634
613
 
614
+ The blocked version disappears from the package metadata, so a range such as `axios@^0.21.0` resolves to the newest allowed version. A lockfile pinned to the blocked version fails with 403 until it is updated.
615
+
635
616
  ### 3. Enforce Corporate License Policy
636
617
 
637
618
  ```yaml
@@ -665,7 +646,7 @@ whitelist:
665
646
  ```yaml
666
647
  cveCheck:
667
648
  enabled: true
668
- databases: [osv, github]
649
+ databases: [osv]
669
650
  severity: [high, critical]
670
651
  autoBlock: true
671
652
  updateInterval: 12
package/build/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export default class SecurityFilterPlugin implements IPluginMiddleware<SecurityC
6
6
  logger: SecurityLogger;
7
7
  config: SecurityConfig;
8
8
  private readonly securityRules;
9
+ private readonly blockedPatternRegexes;
9
10
  private readonly metrics;
10
11
  private readonly whitelistChecker;
11
12
  private readonly cveChecker;
@@ -20,7 +21,10 @@ export default class SecurityFilterPlugin implements IPluginMiddleware<SecurityC
20
21
  private _isScopeAllowed;
21
22
  private _getVersionRangeRule;
22
23
  register_middlewares(app: Application, _auth: IBasicAuth<SecurityConfig>, _storage: IStorageManager<SecurityConfig>): void;
24
+ private _blockedPackage;
25
+ private _removeVersions;
23
26
  filter_metadata(packageInfo: Package): Promise<Package>;
27
+ private _versionBlockReason;
24
28
  private _checkPackageBlock;
25
29
  private _getAppliedRules;
26
30
  destroy(): void;
package/build/index.js CHANGED
@@ -47,6 +47,8 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
47
47
  Object.defineProperty(exports, "__esModule", { value: true });
48
48
  exports.AuthorChecker = exports.PackageAgeChecker = exports.LicenseChecker = exports.CVEChecker = exports.WhitelistChecker = exports.MetricsCollector = exports.SecurityLogger = void 0;
49
49
  const semver = __importStar(require("semver"));
50
+ const fs_1 = require("fs");
51
+ const path_1 = require("path");
50
52
  const logger_1 = require("./lib/logger");
51
53
  const metrics_1 = require("./lib/metrics");
52
54
  const whitelist_checker_1 = require("./lib/whitelist-checker");
@@ -54,6 +56,7 @@ const cve_checker_1 = require("./lib/cve-checker");
54
56
  const license_checker_1 = require("./lib/license-checker");
55
57
  const package_age_checker_1 = require("./lib/package-age-checker");
56
58
  const author_checker_1 = require("./lib/author-checker");
59
+ const PLUGIN_VERSION = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(__dirname, '..', 'package.json'), 'utf8')).version;
57
60
  class SecurityFilterPlugin {
58
61
  constructor(config, options) {
59
62
  this.config = config || {};
@@ -61,7 +64,6 @@ class SecurityFilterPlugin {
61
64
  this.config.errorHandling = {
62
65
  onFilterError: 'fail-open',
63
66
  onCveCheckError: 'fail-open',
64
- onLicenseCheckError: 'fail-open',
65
67
  };
66
68
  }
67
69
  this.logger = new logger_1.SecurityLogger(options.logger, this.config.logger);
@@ -74,13 +76,19 @@ class SecurityFilterPlugin {
74
76
  this.securityRules = {
75
77
  blockedVersions: this.config.blockedVersions || [],
76
78
  blockedPatterns: this.config.blockedPatterns || [],
77
- minPackageSize: this.config.minPackageSize || 0,
78
- maxPackageSize: this.config.maxPackageSize || 100 * 1024 * 1024,
79
79
  allowedScopes: this.config.allowedScopes || [],
80
80
  blockedScopes: this.config.blockedScopes || [],
81
- enforceChecksum: this.config.enforceChecksum !== false,
82
81
  versionRangeRules: this._parseVersionRangeRules(this.config.versionRangeRules || [])
83
82
  };
83
+ this.blockedPatternRegexes = this.securityRules.blockedPatterns.flatMap(pattern => {
84
+ try {
85
+ return [new RegExp(pattern)];
86
+ }
87
+ catch (error) {
88
+ this.logger.warn(`Invalid blocked pattern "${pattern}", skipping: ${error.message}`);
89
+ return [];
90
+ }
91
+ });
84
92
  this._logInitialization();
85
93
  }
86
94
  _parseVersionRangeRules(rules) {
@@ -89,9 +97,8 @@ class SecurityFilterPlugin {
89
97
  this.logger.warn(`Invalid version range rule at index ${index}, skipping`);
90
98
  return null;
91
99
  }
92
- if (rule.strategy === 'fallback' && !rule.fallbackVersion) {
93
- this.logger.warn(`Fallback strategy requires fallbackVersion for ${rule.package}, skipping`);
94
- return null;
100
+ if (rule.strategy === 'fallback') {
101
+ this.logger.warn(`Strategy "fallback" for ${rule.package} is deprecated and works like "block": matching versions are hidden, so npm resolves to the highest allowed one`);
95
102
  }
96
103
  try {
97
104
  const validRange = semver.validRange(rule.range);
@@ -156,10 +163,7 @@ class SecurityFilterPlugin {
156
163
  return features;
157
164
  }
158
165
  _isBlockedByPattern(packageName) {
159
- return this.securityRules.blockedPatterns.some(pattern => {
160
- const regex = new RegExp(pattern);
161
- return regex.test(packageName);
162
- });
166
+ return this.blockedPatternRegexes.some(regex => regex.test(packageName));
163
167
  }
164
168
  _isScopeAllowed(packageName) {
165
169
  if (!packageName.startsWith('@')) {
@@ -212,9 +216,11 @@ class SecurityFilterPlugin {
212
216
  const tarballFile = urlMatch[3];
213
217
  let version;
214
218
  if (tarballFile) {
215
- const versionMatch = tarballFile.match(/-([\d.]+(?:-[a-zA-Z0-9.]+)?)\.tgz$/);
216
- version = versionMatch ? versionMatch[1] : undefined;
217
- this.logger.info(`[Middleware] TARBALL REQUEST: ${packageName}@${version || 'unknown'}`);
219
+ const filePrefix = `${urlMatch[2]}-`;
220
+ version = tarballFile.startsWith(filePrefix)
221
+ ? tarballFile.slice(filePrefix.length, -'.tgz'.length)
222
+ : undefined;
223
+ this.logger.debug(`[Middleware] TARBALL REQUEST: ${packageName}@${version || 'unknown'}`);
218
224
  const blockResult = yield this._checkPackageBlock(packageName, version);
219
225
  if (blockResult.blocked) {
220
226
  this.logger.warn(`[Middleware] [X] BLOCKED TARBALL: ${packageName}@${version || '*'} - ${blockResult.reason}`);
@@ -228,10 +234,10 @@ class SecurityFilterPlugin {
228
234
  });
229
235
  return;
230
236
  }
231
- this.logger.info(`[Middleware] [OK] ALLOWED TARBALL: ${packageName}@${version || 'unknown'}`);
237
+ this.logger.debug(`[Middleware] [OK] ALLOWED TARBALL: ${packageName}@${version || 'unknown'}`);
232
238
  }
233
239
  else {
234
- this.logger.info(`[Middleware] METADATA REQUEST: ${packageName} - will intercept response`);
240
+ this.logger.debug(`[Middleware] METADATA REQUEST: ${packageName} - will intercept response`);
235
241
  const blockResult = yield this._checkPackageBlock(packageName);
236
242
  if (blockResult.blocked) {
237
243
  this.logger.warn(`[Middleware] [X] BLOCKED METADATA: ${packageName} - ${blockResult.reason}`);
@@ -244,7 +250,7 @@ class SecurityFilterPlugin {
244
250
  reason: blockResult.reason,
245
251
  plugin: {
246
252
  name: 'verdaccio-security-filter',
247
- version: '1.0.0',
253
+ version: PLUGIN_VERSION,
248
254
  mode: this.config.mode || 'blacklist',
249
255
  },
250
256
  blockedAt: new Date().toISOString(),
@@ -263,7 +269,7 @@ class SecurityFilterPlugin {
263
269
  return;
264
270
  }
265
271
  else {
266
- this.logger.info(`[Middleware] [OK] ALLOWED METADATA: ${packageName}`);
272
+ this.logger.debug(`[Middleware] [OK] ALLOWED METADATA: ${packageName}`);
267
273
  }
268
274
  }
269
275
  next();
@@ -275,28 +281,52 @@ class SecurityFilterPlugin {
275
281
  }));
276
282
  this.logger.info('[Middleware] Security filter middleware registered successfully');
277
283
  }
284
+ _blockedPackage(pkg, reason, extra = {}) {
285
+ return Object.assign(Object.assign({}, pkg), { versions: {}, 'dist-tags': {}, security: Object.assign(Object.assign({ blocked: true, reason }, extra), { plugin: {
286
+ name: 'verdaccio-security-filter',
287
+ version: PLUGIN_VERSION,
288
+ mode: this.config.mode || 'blacklist',
289
+ }, blockedAt: new Date().toISOString() }) });
290
+ }
291
+ _removeVersions(pkg, removed) {
292
+ if (removed.length === 0) {
293
+ return pkg;
294
+ }
295
+ const gone = new Set(removed);
296
+ const versions = Object.assign({}, pkg.versions);
297
+ for (const version of gone) {
298
+ delete versions[version];
299
+ }
300
+ const distTags = Object.assign({}, pkg['dist-tags']);
301
+ for (const [tag, tagVersion] of Object.entries(distTags)) {
302
+ if (gone.has(tagVersion)) {
303
+ delete distTags[tag];
304
+ this.logger.debug(`[filter_metadata] Removed dist-tag "${tag}" pointing to filtered version ${tagVersion}`);
305
+ }
306
+ }
307
+ return Object.assign(Object.assign({}, pkg), { versions, 'dist-tags': distTags });
308
+ }
278
309
  filter_metadata(packageInfo) {
279
310
  return __awaiter(this, void 0, void 0, function* () {
280
311
  var _a, _b, _c, _d, _e, _f, _g, _h, _j;
281
312
  let pkg = packageInfo;
282
313
  const packageName = pkg.name;
283
- this.logger.info(`[filter_metadata] --> Processing: ${packageName}`);
314
+ this.logger.debug(`[filter_metadata] --> Processing: ${packageName}`);
284
315
  try {
285
316
  const blockResult = yield this._checkPackageBlock(packageName);
286
317
  if (blockResult.blocked) {
287
318
  this.logger.warn(`[filter_metadata] BLOCKED: ${packageName}@* - ${blockResult.reason}`);
288
319
  this.metrics.recordBlock(packageName, '*', blockResult.reason);
289
- return Object.assign(Object.assign({}, pkg), { versions: {}, 'dist-tags': {}, security: {
290
- blocked: true,
291
- reason: blockResult.reason,
292
- plugin: {
293
- name: 'verdaccio-security-filter',
294
- version: '2.0.0',
295
- mode: this.config.mode || 'blacklist',
296
- },
297
- blockedAt: new Date().toISOString(),
298
- rules: this._getAppliedRules(packageName),
299
- } });
320
+ return this._blockedPackage(pkg, blockResult.reason, { rules: this._getAppliedRules(packageName) });
321
+ }
322
+ const whitelistMode = this.config.mode === 'whitelist';
323
+ if (whitelistMode || this.securityRules.blockedVersions.length > 0 || this.securityRules.versionRangeRules.length > 0) {
324
+ const ruleBlocked = Object.keys(pkg.versions || {}).filter(version => this._versionBlockReason(packageName, version) !== null ||
325
+ (whitelistMode && !this.whitelistChecker.isWhitelisted(packageName, version).allowed));
326
+ if (ruleBlocked.length > 0) {
327
+ this.logger.warn(`[filter_metadata] RULE FILTERED: ${packageName}: ${ruleBlocked.join(', ')}`);
328
+ pkg = this._removeVersions(pkg, ruleBlocked);
329
+ }
300
330
  }
301
331
  if ((_a = this.config.cveCheck) === null || _a === void 0 ? void 0 : _a.enabled) {
302
332
  const versions = Object.keys(pkg.versions || {});
@@ -310,15 +340,7 @@ class SecurityFilterPlugin {
310
340
  if (((_b = this.config.errorHandling) === null || _b === void 0 ? void 0 : _b.onCveCheckError) === 'fail-closed') {
311
341
  const reason = `CVE check failed (fail-closed mode): ${error.message}`;
312
342
  this.metrics.recordBlock(packageName, '*', reason);
313
- return Object.assign(Object.assign({}, pkg), { versions: {}, 'dist-tags': {}, security: {
314
- blocked: true,
315
- reason,
316
- plugin: {
317
- name: 'verdaccio-security-filter',
318
- version: '2.0.0',
319
- },
320
- blockedAt: new Date().toISOString(),
321
- } });
343
+ return this._blockedPackage(pkg, reason);
322
344
  }
323
345
  }
324
346
  if (vulnerableVersions.length > 0 && !this.config.cveCheck.autoBlock) {
@@ -329,31 +351,13 @@ class SecurityFilterPlugin {
329
351
  if (vulnerableVersions.length === versions.length) {
330
352
  this.logger.warn(`[filter_metadata] CVE BLOCKED: ${packageName} - ${reason}`);
331
353
  this.metrics.recordBlock(packageName, '*', reason);
332
- return Object.assign(Object.assign({}, pkg), { versions: {}, 'dist-tags': {}, security: {
333
- blocked: true,
334
- reason,
335
- vulnerableVersions,
336
- plugin: {
337
- name: 'verdaccio-security-filter',
338
- version: '2.0.0',
339
- },
340
- blockedAt: new Date().toISOString(),
341
- } });
354
+ return this._blockedPackage(pkg, reason, { vulnerableVersions });
342
355
  }
343
356
  this.logger.warn(`[filter_metadata] CVE FILTERED: ${packageName} - ${reason}: ${vulnerableVersions.join(', ')}`);
344
- const filteredVersions = Object.assign({}, pkg.versions);
345
357
  for (const version of vulnerableVersions) {
346
- delete filteredVersions[version];
347
358
  this.metrics.recordBlock(packageName, version, reason);
348
359
  }
349
- const filteredDistTags = Object.assign({}, pkg['dist-tags']);
350
- for (const [tag, tagVersion] of Object.entries(filteredDistTags)) {
351
- if (vulnerableVersions.includes(tagVersion)) {
352
- delete filteredDistTags[tag];
353
- this.logger.debug(`[filter_metadata] Removed dist-tag "${tag}" pointing to vulnerable version ${tagVersion}`);
354
- }
355
- }
356
- pkg = Object.assign(Object.assign({}, pkg), { versions: filteredVersions, 'dist-tags': filteredDistTags });
360
+ pkg = this._removeVersions(pkg, vulnerableVersions);
357
361
  }
358
362
  }
359
363
  if (this.config.licenses && this.config.licenses.allowed && this.config.licenses.allowed.length > 0) {
@@ -362,66 +366,38 @@ class SecurityFilterPlugin {
362
366
  const versionData = pkg.versions[latestVersion];
363
367
  const licenseResult = this.licenseChecker.checkLicense(versionData);
364
368
  if (!licenseResult.allowed) {
365
- this.logger.warn(`[filter_metadata] LICENSE BLOCKED: ${packageName} - ${licenseResult.reason}`);
366
- this.metrics.recordBlock(packageName, '*', licenseResult.reason || 'License not allowed');
367
- return Object.assign(Object.assign({}, pkg), { versions: {}, 'dist-tags': {}, security: {
368
- blocked: true,
369
- reason: licenseResult.reason || 'License not allowed',
370
- license: licenseResult.license,
371
- plugin: {
372
- name: 'verdaccio-security-filter',
373
- version: '2.0.0',
374
- },
375
- blockedAt: new Date().toISOString(),
376
- } });
369
+ const reason = licenseResult.reason || 'License not allowed';
370
+ this.logger.warn(`[filter_metadata] LICENSE BLOCKED: ${packageName} - ${reason}`);
371
+ this.metrics.recordBlock(packageName, '*', reason);
372
+ return this._blockedPackage(pkg, reason, { license: licenseResult.license });
377
373
  }
378
374
  }
379
375
  }
380
376
  if ((_e = this.config.packageAge) === null || _e === void 0 ? void 0 : _e.enabled) {
381
377
  const ageResult = this.packageAgeChecker.checkPackageAge(pkg);
382
378
  if (!ageResult.allowed && !ageResult.warnOnly) {
383
- this.logger.warn(`[filter_metadata] AGE BLOCKED: ${packageName} - ${ageResult.reason}`);
384
- this.metrics.recordBlock(packageName, '*', ageResult.reason || 'Package too new');
385
- return Object.assign(Object.assign({}, pkg), { versions: {}, 'dist-tags': {}, security: {
386
- blocked: true,
387
- reason: ageResult.reason || 'Package too new',
388
- ageDays: ageResult.ageDays,
389
- plugin: {
390
- name: 'verdaccio-security-filter',
391
- version: '2.0.0',
392
- },
393
- blockedAt: new Date().toISOString(),
394
- } });
379
+ const reason = ageResult.reason || 'Package too new';
380
+ this.logger.warn(`[filter_metadata] AGE BLOCKED: ${packageName} - ${reason}`);
381
+ this.metrics.recordBlock(packageName, '*', reason);
382
+ return this._blockedPackage(pkg, reason, { ageDays: ageResult.ageDays });
395
383
  }
396
384
  else if (!ageResult.allowed && ageResult.warnOnly) {
397
385
  this.logger.warn(`[filter_metadata] AGE WARNING: ${packageName} - ${ageResult.reason} (warn-only mode)`);
398
386
  }
399
387
  if (this.config.packageAge.minVersionAgeDays) {
400
- const filteredVersions = Object.assign({}, pkg.versions);
401
388
  const removedVersions = [];
402
- for (const version of Object.keys(filteredVersions)) {
389
+ for (const version of Object.keys(pkg.versions || {})) {
403
390
  const versionAgeResult = this.packageAgeChecker.checkVersionAge(pkg, version);
404
391
  if (!versionAgeResult.allowed && !versionAgeResult.warnOnly) {
405
392
  this.logger.warn(`[filter_metadata] VERSION AGE FILTERED: ${packageName}@${version} - ${versionAgeResult.reason}`);
406
393
  this.metrics.recordBlock(packageName, version, versionAgeResult.reason || 'Version too new');
407
- delete filteredVersions[version];
408
394
  removedVersions.push(version);
409
395
  }
410
396
  else if (!versionAgeResult.allowed && versionAgeResult.warnOnly) {
411
397
  this.logger.warn(`[filter_metadata] VERSION AGE WARNING: ${packageName}@${version} - ${versionAgeResult.reason} (warn-only mode)`);
412
398
  }
413
399
  }
414
- if (removedVersions.length > 0) {
415
- this.logger.info(`[filter_metadata] Removed ${removedVersions.length} too-new version(s) from ${packageName}: ${removedVersions.join(', ')}`);
416
- const filteredDistTags = Object.assign({}, pkg['dist-tags']);
417
- for (const [tag, tagVersion] of Object.entries(filteredDistTags)) {
418
- if (removedVersions.includes(tagVersion)) {
419
- delete filteredDistTags[tag];
420
- this.logger.debug(`[filter_metadata] Removed dist-tag "${tag}" pointing to filtered version ${tagVersion}`);
421
- }
422
- }
423
- pkg = Object.assign(Object.assign({}, pkg), { versions: filteredVersions, 'dist-tags': filteredDistTags });
424
- }
400
+ pkg = this._removeVersions(pkg, removedVersions);
425
401
  }
426
402
  }
427
403
  if ((_f = this.config.authorFilter) === null || _f === void 0 ? void 0 : _f.enabled) {
@@ -430,36 +406,30 @@ class SecurityFilterPlugin {
430
406
  const versionData = pkg.versions[latestVersion];
431
407
  const authorResult = this.authorChecker.checkAuthor(versionData);
432
408
  if (!authorResult.allowed && !this.config.authorFilter.warnOnly) {
433
- this.logger.warn(`[filter_metadata] AUTHOR BLOCKED: ${packageName} - ${authorResult.reason}`);
409
+ const reason = authorResult.reason || 'Author not allowed';
410
+ this.logger.warn(`[filter_metadata] AUTHOR BLOCKED: ${packageName} - ${reason}`);
434
411
  this.metrics.record({
435
412
  timestamp: new Date().toISOString(),
436
413
  event: 'author_blocked',
437
414
  packageName,
438
415
  version: latestVersion,
439
- reason: authorResult.reason || 'Author not allowed',
416
+ reason,
440
417
  metadata: {
441
418
  blockedBy: authorResult.blockedBy,
442
419
  authorInfo: authorResult.authorInfo,
443
420
  },
444
421
  });
445
- return Object.assign(Object.assign({}, pkg), { versions: {}, 'dist-tags': {}, security: {
446
- blocked: true,
447
- reason: authorResult.reason || 'Author not allowed',
448
- blockedBy: authorResult.blockedBy,
449
- authorInfo: authorResult.authorInfo,
450
- plugin: {
451
- name: 'verdaccio-security-filter',
452
- version: '2.0.0',
453
- },
454
- blockedAt: new Date().toISOString(),
455
- } });
422
+ return this._blockedPackage(pkg, reason, {
423
+ blockedBy: authorResult.blockedBy,
424
+ authorInfo: authorResult.authorInfo,
425
+ });
456
426
  }
457
427
  else if (!authorResult.allowed && this.config.authorFilter.warnOnly) {
458
428
  this.logger.warn(`[filter_metadata] AUTHOR WARNING: ${packageName} - ${authorResult.reason} (warn-only mode)`);
459
429
  }
460
430
  }
461
431
  }
462
- this.logger.info(`[filter_metadata] [OK] ALLOWED: ${packageName}`);
432
+ this.logger.debug(`[filter_metadata] [OK] ALLOWED: ${packageName}`);
463
433
  return pkg;
464
434
  }
465
435
  catch (error) {
@@ -468,21 +438,20 @@ class SecurityFilterPlugin {
468
438
  if (errorStrategy === 'fail-closed') {
469
439
  this.logger.warn(`[filter_metadata] Error handling: fail-closed - BLOCKING ${packageName}`);
470
440
  this.metrics.recordBlock(packageName, '*', `Error during processing: ${error.message}`);
471
- return Object.assign(Object.assign({}, packageInfo), { versions: {}, 'dist-tags': {}, security: {
472
- blocked: true,
473
- reason: `Error during security processing (fail-closed mode): ${error.message}`,
474
- plugin: {
475
- name: 'verdaccio-security-filter',
476
- version: '2.0.0',
477
- },
478
- blockedAt: new Date().toISOString(),
479
- } });
441
+ return this._blockedPackage(packageInfo, `Error during security processing (fail-closed mode): ${error.message}`);
480
442
  }
481
443
  this.logger.warn(`[filter_metadata] Error handling: fail-open - ALLOWING ${packageName}`);
482
444
  return packageInfo;
483
445
  }
484
446
  });
485
447
  }
448
+ _versionBlockReason(packageName, version) {
449
+ if (this.securityRules.blockedVersions.includes(`${packageName}@${version}`)) {
450
+ return 'Exact version match in blocklist';
451
+ }
452
+ const rangeRule = this._getVersionRangeRule(packageName, version);
453
+ return rangeRule ? `Version matches blocked range: ${rangeRule.range}` : null;
454
+ }
486
455
  _checkPackageBlock(packageName, version) {
487
456
  return __awaiter(this, void 0, void 0, function* () {
488
457
  if (this.config.mode === 'whitelist') {
@@ -497,15 +466,9 @@ class SecurityFilterPlugin {
497
466
  if (!this._isScopeAllowed(packageName)) {
498
467
  return { blocked: true, reason: 'Package scope not allowed' };
499
468
  }
500
- if (version) {
501
- const versionKey = `${packageName}@${version}`;
502
- if (this.securityRules.blockedVersions.includes(versionKey)) {
503
- return { blocked: true, reason: 'Exact version match in blocklist' };
504
- }
505
- const rangeRule = this._getVersionRangeRule(packageName, version);
506
- if (rangeRule && rangeRule.strategy === 'block') {
507
- return { blocked: true, reason: `Version matches blocked range: ${rangeRule.range}` };
508
- }
469
+ const versionReason = version ? this._versionBlockReason(packageName, version) : null;
470
+ if (versionReason) {
471
+ return { blocked: true, reason: versionReason };
509
472
  }
510
473
  return { blocked: false, reason: '' };
511
474
  });
@@ -516,11 +479,9 @@ class SecurityFilterPlugin {
516
479
  rules.mode = 'whitelist';
517
480
  rules.whitelisted = false;
518
481
  }
519
- if (this._isBlockedByPattern(packageName)) {
520
- rules.blockedPattern = this.securityRules.blockedPatterns.find(pattern => {
521
- const regex = new RegExp(pattern);
522
- return regex.test(packageName);
523
- });
482
+ const blockedPattern = this.blockedPatternRegexes.find(regex => regex.test(packageName));
483
+ if (blockedPattern) {
484
+ rules.blockedPattern = blockedPattern.source;
524
485
  }
525
486
  if (packageName.startsWith('@')) {
526
487
  const scope = packageName.split('/')[0];
@@ -4,12 +4,12 @@ export declare class MetricsCollector {
4
4
  private metricsBuffer;
5
5
  private readonly bufferSize;
6
6
  private readonly maxBufferSize;
7
+ private readonly flushIntervalMs;
8
+ private flushTimer;
7
9
  constructor(config?: MetricsConfig);
8
10
  record(event: MetricsData): void;
9
11
  recordBlock(packageName: string, version: string, reason: string, metadata?: Record<string, any>): void;
10
- recordFallback(packageName: string, fromVersion: string, toVersion: string, reason: string): void;
11
12
  recordPublishRejected(packageName: string, version: string, reason: string): void;
12
- recordCVE(packageName: string, version: string, cveId: string, severity: string): void;
13
13
  recordLicenseBlock(packageName: string, version: string, license: string, reason: string): void;
14
14
  recordPackageTooNew(packageName: string, version: string, reason: string, metadata?: Record<string, any>): void;
15
15
  flush(): void;
@@ -41,11 +41,17 @@ class MetricsCollector {
41
41
  this.metricsBuffer = [];
42
42
  this.bufferSize = 100;
43
43
  this.maxBufferSize = 500;
44
+ this.flushIntervalMs = 10000;
45
+ this.flushTimer = null;
44
46
  this.config = {
45
47
  enabled: (config === null || config === void 0 ? void 0 : config.enabled) || false,
46
48
  output: (config === null || config === void 0 ? void 0 : config.output) || 'stdout',
47
49
  filePath: (config === null || config === void 0 ? void 0 : config.filePath) || './security-metrics.json',
48
50
  };
51
+ if (this.config.enabled) {
52
+ this.flushTimer = setInterval(() => this.flush(), this.flushIntervalMs);
53
+ this.flushTimer.unref();
54
+ }
49
55
  }
50
56
  record(event) {
51
57
  if (!this.config.enabled) {
@@ -69,16 +75,6 @@ class MetricsCollector {
69
75
  metadata,
70
76
  });
71
77
  }
72
- recordFallback(packageName, fromVersion, toVersion, reason) {
73
- this.record({
74
- timestamp: new Date().toISOString(),
75
- event: 'fallback',
76
- packageName,
77
- version: fromVersion,
78
- reason,
79
- metadata: { toVersion },
80
- });
81
- }
82
78
  recordPublishRejected(packageName, version, reason) {
83
79
  this.record({
84
80
  timestamp: new Date().toISOString(),
@@ -88,16 +84,6 @@ class MetricsCollector {
88
84
  reason,
89
85
  });
90
86
  }
91
- recordCVE(packageName, version, cveId, severity) {
92
- this.record({
93
- timestamp: new Date().toISOString(),
94
- event: 'cve_detected',
95
- packageName,
96
- version,
97
- reason: `CVE ${cveId} (${severity})`,
98
- metadata: { cveId, severity },
99
- });
100
- }
101
87
  recordLicenseBlock(packageName, version, license, reason) {
102
88
  this.record({
103
89
  timestamp: new Date().toISOString(),
@@ -163,6 +149,10 @@ class MetricsCollector {
163
149
  return summary;
164
150
  }
165
151
  cleanup() {
152
+ if (this.flushTimer) {
153
+ clearInterval(this.flushTimer);
154
+ this.flushTimer = null;
155
+ }
166
156
  this.flush();
167
157
  }
168
158
  }
@@ -1,22 +1,19 @@
1
1
  import { WhitelistConfig } from '../types';
2
2
  export declare class WhitelistChecker {
3
3
  private config;
4
+ private regexes;
4
5
  constructor(config?: WhitelistConfig);
5
6
  isWhitelisted(packageName: string, version?: string): {
6
7
  allowed: boolean;
7
8
  reason?: string;
8
9
  };
9
10
  private matchesPattern;
11
+ private compile;
10
12
  addPackage(packageName: string, versionRange?: string): void;
11
13
  removePackage(packageName: string): void;
12
14
  addPattern(pattern: string): void;
13
- meetsAutoApproveCriteria(_packageName: string): Promise<{
14
- approved: boolean;
15
- reason?: string;
16
- }>;
17
15
  getSummary(): {
18
16
  totalPackages: number;
19
17
  totalPatterns: number;
20
- hasAutoApprove: boolean;
21
18
  };
22
19
  }
@@ -32,25 +32,16 @@ var __importStar = (this && this.__importStar) || (function () {
32
32
  return result;
33
33
  };
34
34
  })();
35
- var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
36
- function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
37
- return new (P || (P = Promise))(function (resolve, reject) {
38
- function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
39
- function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
40
- function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
41
- step((generator = generator.apply(thisArg, _arguments || [])).next());
42
- });
43
- };
44
35
  Object.defineProperty(exports, "__esModule", { value: true });
45
36
  exports.WhitelistChecker = void 0;
46
37
  const semver = __importStar(require("semver"));
47
38
  class WhitelistChecker {
48
39
  constructor(config) {
40
+ this.regexes = new Map();
49
41
  this.config = {
50
42
  packages: (config === null || config === void 0 ? void 0 : config.packages) || [],
51
43
  patterns: (config === null || config === void 0 ? void 0 : config.patterns) || [],
52
44
  versions: (config === null || config === void 0 ? void 0 : config.versions) || {},
53
- autoApprove: config === null || config === void 0 ? void 0 : config.autoApprove,
54
45
  };
55
46
  }
56
47
  isWhitelisted(packageName, version) {
@@ -75,16 +66,21 @@ class WhitelistChecker {
75
66
  };
76
67
  }
77
68
  matchesPattern(packageName) {
78
- return this.config.patterns.some(pattern => {
69
+ return this.config.patterns.some(pattern => { var _a; return (_a = this.compile(pattern)) === null || _a === void 0 ? void 0 : _a.test(packageName); });
70
+ }
71
+ compile(pattern) {
72
+ let regex = this.regexes.get(pattern);
73
+ if (regex === undefined) {
79
74
  try {
80
- const regex = new RegExp(pattern);
81
- return regex.test(packageName);
75
+ regex = new RegExp(pattern);
82
76
  }
83
77
  catch (_a) {
84
78
  console.error(`[Whitelist] Invalid pattern: ${pattern}`);
85
- return false;
79
+ regex = null;
86
80
  }
87
- });
81
+ this.regexes.set(pattern, regex);
82
+ }
83
+ return regex;
88
84
  }
89
85
  addPackage(packageName, versionRange) {
90
86
  if (!this.config.packages.includes(packageName)) {
@@ -109,22 +105,10 @@ class WhitelistChecker {
109
105
  this.config.patterns.push(pattern);
110
106
  }
111
107
  }
112
- meetsAutoApproveCriteria(_packageName) {
113
- return __awaiter(this, void 0, void 0, function* () {
114
- if (!this.config.autoApprove) {
115
- return { approved: false, reason: 'Auto-approve not configured' };
116
- }
117
- return {
118
- approved: false,
119
- reason: 'Auto-approve requires npm registry API integration (not implemented)',
120
- };
121
- });
122
- }
123
108
  getSummary() {
124
109
  return {
125
110
  totalPackages: this.config.packages.length,
126
111
  totalPatterns: this.config.patterns.length,
127
- hasAutoApprove: !!this.config.autoApprove,
128
112
  };
129
113
  }
130
114
  }
package/build/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Config, Version } from '@verdaccio/types';
1
+ import { Config } from '@verdaccio/types';
2
2
  export interface VersionRangeRule {
3
3
  package: string;
4
4
  range: string;
@@ -8,7 +8,7 @@ export interface VersionRangeRule {
8
8
  }
9
9
  export interface CVECheckConfig {
10
10
  enabled: boolean;
11
- databases: ('osv' | 'snyk' | 'github')[];
11
+ databases: 'osv'[];
12
12
  severity: ('low' | 'medium' | 'high' | 'critical')[];
13
13
  autoBlock: boolean;
14
14
  updateInterval?: number;
@@ -18,11 +18,6 @@ export interface WhitelistConfig {
18
18
  packages: string[];
19
19
  patterns: string[];
20
20
  versions?: Record<string, string>;
21
- autoApprove?: {
22
- minDownloads?: number;
23
- minStars?: number;
24
- verifiedPublisher?: boolean;
25
- };
26
21
  }
27
22
  export interface LicenseConfig {
28
23
  allowed: string[];
@@ -59,11 +54,8 @@ export interface AuthorFilterConfig {
59
54
  export interface SecurityConfig extends Config {
60
55
  blockedVersions?: string[];
61
56
  blockedPatterns?: string[];
62
- minPackageSize?: number;
63
- maxPackageSize?: number;
64
57
  allowedScopes?: string[];
65
58
  blockedScopes?: string[];
66
- enforceChecksum?: boolean;
67
59
  versionRangeRules?: VersionRangeRule[];
68
60
  mode?: 'blacklist' | 'whitelist';
69
61
  whitelist?: WhitelistConfig;
@@ -76,32 +68,15 @@ export interface SecurityConfig extends Config {
76
68
  errorHandling?: {
77
69
  onFilterError?: 'fail-open' | 'fail-closed';
78
70
  onCveCheckError?: 'fail-open' | 'fail-closed';
79
- onLicenseCheckError?: 'fail-open' | 'fail-closed';
80
71
  };
81
72
  }
82
73
  export interface SecurityRules {
83
74
  blockedVersions: string[];
84
75
  blockedPatterns: string[];
85
- minPackageSize: number;
86
- maxPackageSize: number;
87
76
  allowedScopes: string[];
88
77
  blockedScopes: string[];
89
- enforceChecksum: boolean;
90
78
  versionRangeRules: VersionRangeRule[];
91
79
  }
92
- export interface VersionData extends Version {
93
- _fallback?: boolean;
94
- _fallbackFrom?: string;
95
- _fallbackReason?: string;
96
- }
97
- export interface FilterResult {
98
- versions: Record<string, VersionData>;
99
- blockedVersions: string[];
100
- fallbacksApplied: Array<{
101
- original: string;
102
- fallback: string;
103
- }>;
104
- }
105
80
  export interface CVEVulnerability {
106
81
  id: string;
107
82
  severity: 'low' | 'medium' | 'high' | 'critical';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verdaccio-security-filter",
3
- "version": "1.3.9",
3
+ "version": "1.3.11",
4
4
  "description": "Advanced security filter plugin for Verdaccio with version range blocking and fallback strategies",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -28,6 +28,7 @@
28
28
  "test": "jest",
29
29
  "test:watch": "jest --watch",
30
30
  "test:coverage": "jest --coverage",
31
+ "test:e2e": "npm run build && jest --testMatch \"**/*.e2e.ts\" --coverage=false",
31
32
  "debug:verdaccio": "node --inspect-brk $(which verdaccio)",
32
33
  "debug:build": "npm run build && npm link",
33
34
  "prepack": "npm run build",
@@ -55,7 +56,7 @@
55
56
  },
56
57
  "overrides": {
57
58
  "qs": "6.16.0",
58
- "@cypress/request": "latest"
59
+ "@cypress/request": "3.0.10"
59
60
  },
60
61
  "author": {
61
62
  "name": "Vitaliy Ponomarenko",