verdaccio-security-filter 1.3.11 → 1.4.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/README.md CHANGED
@@ -117,7 +117,7 @@ This plugin implements **two independent security layers** for complete protecti
117
117
  - [ ] **ML-based Anomaly Detection** - Machine learning for threat detection
118
118
 
119
119
  #### Phase 5: Developer Experience
120
- - [ ] **Dry Run Mode** - Test rules without actually blocking
120
+ - [x] **Dry Run Mode** - Test rules without actually blocking (`dryRun: true`)
121
121
  - [ ] **Auto-approve Criteria** - Automatic approval based on npm stats (downloads, stars)
122
122
  - [ ] **CLI Tool** - Command-line interface for rule management
123
123
  - [ ] **Visual Studio Code Extension** - IDE integration
@@ -406,11 +406,30 @@ middlewares:
406
406
  | Option | Type | Default | Description |
407
407
  |--------|------|---------|-------------|
408
408
  | `mode` | `string` | `blacklist` | Filter mode: `blacklist` or `whitelist` |
409
+ | `dryRun` | `boolean` | `false` | Log and record every decision, but block nothing. See [Dry-run mode](#dry-run-mode) |
409
410
  | `blockedVersions` | `string[]` | `[]` | List of `package@version` to block |
410
411
  | `blockedPatterns` | `string[]` | `[]` | Regex patterns for package names |
411
412
  | `allowedScopes` | `string[]` | `[]` | Allowed package scopes |
412
413
  | `blockedScopes` | `string[]` | `[]` | Blocked package scopes |
413
414
 
415
+ ### Dry-run mode
416
+
417
+ Set `dryRun: true` to see what a rule set would do before enforcing it. Put it in both the `filters` and the `middlewares` block, like the rest of the configuration.
418
+
419
+ ```yaml
420
+ dryRun: true
421
+ ```
422
+
423
+ While it is on:
424
+
425
+ - every check runs as usual, including CVE lookups
426
+ - nothing is blocked: metadata is served unchanged and every tarball can be downloaded
427
+ - each decision is logged at `warn` with a `[DRY RUN]` marker, followed by a summary such as `lodash: would remove 115 of 117 version(s)`
428
+ - metrics events carry `"dryRun": true`
429
+ - one warning at start-up says that dry-run is active
430
+
431
+ Recommended before turning on `cveCheck.autoBlock`, a license policy or the author filter on a registry that is already in use.
432
+
414
433
  ### CVE Check Options
415
434
 
416
435
  | Option | Type | Default | Description |
@@ -422,7 +441,7 @@ middlewares:
422
441
  | `cveCheck.updateInterval` | `number` | `24` | How long a package's vulnerability data is cached (hours) |
423
442
  | `cveCheck.cacheDir` | `string` | `./.security-cache` | Cache directory path |
424
443
 
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`).
444
+ > **Note on removed versions and dist-tags:** whenever a check removes versions (CVE `autoBlock`, license, author, age, version rules, `whitelist.versions`), the other versions stay installable. The plugin drops every dist-tag that points to a removed version. Verdaccio then points `latest` at the highest version that is left (verified with Verdaccio 6.2), so `npm install <pkg>` keeps working. Other dist-tags that pointed to a removed version are gone.
426
445
 
427
446
  > **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
447
 
@@ -432,7 +451,9 @@ middlewares:
432
451
  |--------|------|---------|-------------|
433
452
  | `licenses.allowed` | `string[]` | `[]` | Allowed license list |
434
453
  | `licenses.blocked` | `string[]` | `[]` | Blocked license list |
435
- | `licenses.requireLicense` | `boolean` | `false` | Require license field |
454
+ | `licenses.requireLicense` | `boolean` | `true` | Reject versions without a license field |
455
+
456
+ The license check runs when `licenses.allowed` is not empty. Every version is checked: versions with a rejected license are removed from the metadata, and the package is blocked as a whole only when no version is left.
436
457
 
437
458
  ### Whitelist Options
438
459
 
@@ -481,6 +502,8 @@ middlewares:
481
502
  | `authorFilter.requireVerifiedEmail` | `boolean` | `false` | Require author email information |
482
503
  | `authorFilter.warnOnly` | `boolean` | `false` | Only warn, don't block |
483
504
 
505
+ Every version is checked against the author rules: versions published by a blocked author are removed from the metadata, and the package is blocked as a whole only when no version is left.
506
+
484
507
  ### Error Handling Options
485
508
 
486
509
  | Option | Type | Default | Description |
package/build/index.d.ts CHANGED
@@ -23,7 +23,9 @@ export default class SecurityFilterPlugin implements IPluginMiddleware<SecurityC
23
23
  register_middlewares(app: Application, _auth: IBasicAuth<SecurityConfig>, _storage: IStorageManager<SecurityConfig>): void;
24
24
  private _blockedPackage;
25
25
  private _removeVersions;
26
+ private _rejectedVersions;
26
27
  filter_metadata(packageInfo: Package): Promise<Package>;
28
+ private _filterMetadata;
27
29
  private _versionBlockReason;
28
30
  private _checkPackageBlock;
29
31
  private _getAppliedRules;
package/build/index.js CHANGED
@@ -66,8 +66,8 @@ class SecurityFilterPlugin {
66
66
  onCveCheckError: 'fail-open',
67
67
  };
68
68
  }
69
- this.logger = new logger_1.SecurityLogger(options.logger, this.config.logger);
70
- this.metrics = new metrics_1.MetricsCollector(this.config.metrics);
69
+ this.logger = new logger_1.SecurityLogger(options.logger, this.config.logger, !!this.config.dryRun);
70
+ this.metrics = new metrics_1.MetricsCollector(this.config.metrics, !!this.config.dryRun);
71
71
  this.whitelistChecker = new whitelist_checker_1.WhitelistChecker(this.config.whitelist);
72
72
  this.cveChecker = new cve_checker_1.CVEChecker(this.config.cveCheck);
73
73
  this.licenseChecker = new license_checker_1.LicenseChecker(this.config.licenses);
@@ -90,6 +90,9 @@ class SecurityFilterPlugin {
90
90
  }
91
91
  });
92
92
  this._logInitialization();
93
+ if (this.config.dryRun) {
94
+ this.logger.warn('Dry-run mode is on: decisions are logged and recorded in metrics, but nothing is blocked');
95
+ }
93
96
  }
94
97
  _parseVersionRangeRules(rules) {
95
98
  return rules.map((rule, index) => {
@@ -140,6 +143,8 @@ class SecurityFilterPlugin {
140
143
  _getEnabledFeatures() {
141
144
  var _a, _b, _c, _d, _e;
142
145
  const features = [];
146
+ if (this.config.dryRun)
147
+ features.push('dry-run');
143
148
  if (this.config.mode === 'whitelist')
144
149
  features.push('whitelist-mode');
145
150
  if ((_a = this.config.metrics) === null || _a === void 0 ? void 0 : _a.enabled)
@@ -225,6 +230,9 @@ class SecurityFilterPlugin {
225
230
  if (blockResult.blocked) {
226
231
  this.logger.warn(`[Middleware] [X] BLOCKED TARBALL: ${packageName}@${version || '*'} - ${blockResult.reason}`);
227
232
  this.metrics.recordBlock(packageName, version || '*', blockResult.reason);
233
+ if (this.config.dryRun) {
234
+ return next();
235
+ }
228
236
  res.status(403).json({
229
237
  error: 'Package blocked by security filter',
230
238
  package: packageName,
@@ -241,6 +249,9 @@ class SecurityFilterPlugin {
241
249
  const blockResult = yield this._checkPackageBlock(packageName);
242
250
  if (blockResult.blocked) {
243
251
  this.logger.warn(`[Middleware] [X] BLOCKED METADATA: ${packageName} - ${blockResult.reason}`);
252
+ if (this.config.dryRun) {
253
+ return next();
254
+ }
244
255
  const blockedResponse = {
245
256
  name: packageName,
246
257
  versions: {},
@@ -306,9 +317,35 @@ class SecurityFilterPlugin {
306
317
  }
307
318
  return Object.assign(Object.assign({}, pkg), { versions, 'dist-tags': distTags });
308
319
  }
320
+ _rejectedVersions(pkg, check) {
321
+ var _a;
322
+ const all = Object.entries(pkg.versions || {});
323
+ const results = all
324
+ .map(([version, data]) => [version, check(data)])
325
+ .filter(([, result]) => !result.allowed);
326
+ const latest = results.find(([version]) => { var _a; return version === ((_a = pkg['dist-tags']) === null || _a === void 0 ? void 0 : _a.latest); });
327
+ return {
328
+ results,
329
+ versions: results.map(([version]) => version),
330
+ first: (_a = (latest || results[0])) === null || _a === void 0 ? void 0 : _a[1],
331
+ all: all.length > 0 && results.length === all.length,
332
+ };
333
+ }
309
334
  filter_metadata(packageInfo) {
310
335
  return __awaiter(this, void 0, void 0, function* () {
311
- var _a, _b, _c, _d, _e, _f, _g, _h, _j;
336
+ const filtered = yield this._filterMetadata(packageInfo);
337
+ if (!this.config.dryRun || filtered === packageInfo) {
338
+ return filtered;
339
+ }
340
+ const total = Object.keys(packageInfo.versions || {}).length;
341
+ const left = Object.keys(filtered.versions || {}).length;
342
+ this.logger.warn(`[filter_metadata] ${packageInfo.name}: would remove ${total - left} of ${total} version(s)`);
343
+ return packageInfo;
344
+ });
345
+ }
346
+ _filterMetadata(packageInfo) {
347
+ return __awaiter(this, void 0, void 0, function* () {
348
+ var _a, _b, _c, _d, _e;
312
349
  let pkg = packageInfo;
313
350
  const packageName = pkg.name;
314
351
  this.logger.debug(`[filter_metadata] --> Processing: ${packageName}`);
@@ -361,19 +398,22 @@ class SecurityFilterPlugin {
361
398
  }
362
399
  }
363
400
  if (this.config.licenses && this.config.licenses.allowed && this.config.licenses.allowed.length > 0) {
364
- const latestVersion = (_c = pkg['dist-tags']) === null || _c === void 0 ? void 0 : _c.latest;
365
- if (latestVersion && ((_d = pkg.versions) === null || _d === void 0 ? void 0 : _d[latestVersion])) {
366
- const versionData = pkg.versions[latestVersion];
367
- const licenseResult = this.licenseChecker.checkLicense(versionData);
368
- if (!licenseResult.allowed) {
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 });
401
+ const rejected = this._rejectedVersions(pkg, data => this.licenseChecker.checkLicense(data));
402
+ if (rejected.all) {
403
+ const reason = rejected.first.reason || 'License not allowed';
404
+ this.logger.warn(`[filter_metadata] LICENSE BLOCKED: ${packageName} - ${reason}`);
405
+ this.metrics.recordBlock(packageName, '*', reason);
406
+ return this._blockedPackage(pkg, reason, { license: rejected.first.license });
407
+ }
408
+ if (rejected.versions.length > 0) {
409
+ this.logger.warn(`[filter_metadata] LICENSE FILTERED: ${packageName}: ${rejected.versions.join(', ')}`);
410
+ for (const [version, result] of rejected.results) {
411
+ this.metrics.recordBlock(packageName, version, result.reason || 'License not allowed');
373
412
  }
413
+ pkg = this._removeVersions(pkg, rejected.versions);
374
414
  }
375
415
  }
376
- if ((_e = this.config.packageAge) === null || _e === void 0 ? void 0 : _e.enabled) {
416
+ if ((_c = this.config.packageAge) === null || _c === void 0 ? void 0 : _c.enabled) {
377
417
  const ageResult = this.packageAgeChecker.checkPackageAge(pkg);
378
418
  if (!ageResult.allowed && !ageResult.warnOnly) {
379
419
  const reason = ageResult.reason || 'Package too new';
@@ -400,33 +440,38 @@ class SecurityFilterPlugin {
400
440
  pkg = this._removeVersions(pkg, removedVersions);
401
441
  }
402
442
  }
403
- if ((_f = this.config.authorFilter) === null || _f === void 0 ? void 0 : _f.enabled) {
404
- const latestVersion = (_g = pkg['dist-tags']) === null || _g === void 0 ? void 0 : _g.latest;
405
- if (latestVersion && ((_h = pkg.versions) === null || _h === void 0 ? void 0 : _h[latestVersion])) {
406
- const versionData = pkg.versions[latestVersion];
407
- const authorResult = this.authorChecker.checkAuthor(versionData);
408
- if (!authorResult.allowed && !this.config.authorFilter.warnOnly) {
409
- const reason = authorResult.reason || 'Author not allowed';
410
- this.logger.warn(`[filter_metadata] AUTHOR BLOCKED: ${packageName} - ${reason}`);
443
+ if ((_d = this.config.authorFilter) === null || _d === void 0 ? void 0 : _d.enabled) {
444
+ const rejected = this._rejectedVersions(pkg, data => this.authorChecker.checkAuthor(data));
445
+ if (rejected.versions.length > 0 && this.config.authorFilter.warnOnly) {
446
+ this.logger.warn(`[filter_metadata] AUTHOR WARNING: ${packageName}: ${rejected.versions.join(', ')} - ${rejected.first.reason} (warn-only mode)`);
447
+ }
448
+ else if (rejected.versions.length > 0) {
449
+ for (const [version, result] of rejected.results) {
411
450
  this.metrics.record({
412
451
  timestamp: new Date().toISOString(),
413
452
  event: 'author_blocked',
414
453
  packageName,
415
- version: latestVersion,
416
- reason,
454
+ version: rejected.all ? '*' : version,
455
+ reason: result.reason || 'Author not allowed',
417
456
  metadata: {
418
- blockedBy: authorResult.blockedBy,
419
- authorInfo: authorResult.authorInfo,
457
+ blockedBy: result.blockedBy,
458
+ authorInfo: result.authorInfo,
420
459
  },
421
460
  });
461
+ if (rejected.all) {
462
+ break;
463
+ }
464
+ }
465
+ if (rejected.all) {
466
+ const reason = rejected.first.reason || 'Author not allowed';
467
+ this.logger.warn(`[filter_metadata] AUTHOR BLOCKED: ${packageName} - ${reason}`);
422
468
  return this._blockedPackage(pkg, reason, {
423
- blockedBy: authorResult.blockedBy,
424
- authorInfo: authorResult.authorInfo,
469
+ blockedBy: rejected.first.blockedBy,
470
+ authorInfo: rejected.first.authorInfo,
425
471
  });
426
472
  }
427
- else if (!authorResult.allowed && this.config.authorFilter.warnOnly) {
428
- this.logger.warn(`[filter_metadata] AUTHOR WARNING: ${packageName} - ${authorResult.reason} (warn-only mode)`);
429
- }
473
+ this.logger.warn(`[filter_metadata] AUTHOR FILTERED: ${packageName}: ${rejected.versions.join(', ')}`);
474
+ pkg = this._removeVersions(pkg, rejected.versions);
430
475
  }
431
476
  }
432
477
  this.logger.debug(`[filter_metadata] [OK] ALLOWED: ${packageName}`);
@@ -434,7 +479,7 @@ class SecurityFilterPlugin {
434
479
  }
435
480
  catch (error) {
436
481
  this.logger.error(`[filter_metadata] Error processing ${packageName}: ${error.message}`);
437
- const errorStrategy = ((_j = this.config.errorHandling) === null || _j === void 0 ? void 0 : _j.onFilterError) || 'fail-open';
482
+ const errorStrategy = ((_e = this.config.errorHandling) === null || _e === void 0 ? void 0 : _e.onFilterError) || 'fail-open';
438
483
  if (errorStrategy === 'fail-closed') {
439
484
  this.logger.warn(`[filter_metadata] Error handling: fail-closed - BLOCKING ${packageName}`);
440
485
  this.metrics.recordBlock(packageName, '*', `Error during processing: ${error.message}`);
@@ -4,7 +4,7 @@ export declare class SecurityLogger {
4
4
  private verdaccioLogger;
5
5
  private config;
6
6
  private readonly prefix;
7
- constructor(verdaccioLogger: Logger, config?: LoggerConfig);
7
+ constructor(verdaccioLogger: Logger, config?: LoggerConfig, dryRun?: boolean);
8
8
  private shouldLog;
9
9
  private formatMessage;
10
10
  trace(message: string, ...args: any[]): void;
@@ -2,8 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SecurityLogger = void 0;
4
4
  class SecurityLogger {
5
- constructor(verdaccioLogger, config) {
6
- this.prefix = '[Security Filter]';
5
+ constructor(verdaccioLogger, config, dryRun = false) {
6
+ this.prefix = dryRun ? '[Security Filter] [DRY RUN]' : '[Security Filter]';
7
7
  this.verdaccioLogger = verdaccioLogger;
8
8
  this.config = {
9
9
  level: (config === null || config === void 0 ? void 0 : config.level) || 'info',
@@ -1,12 +1,13 @@
1
1
  import { MetricsConfig, MetricsData } from '../types';
2
2
  export declare class MetricsCollector {
3
+ private readonly dryRun;
3
4
  private config;
4
5
  private metricsBuffer;
5
6
  private readonly bufferSize;
6
7
  private readonly maxBufferSize;
7
8
  private readonly flushIntervalMs;
8
9
  private flushTimer;
9
- constructor(config?: MetricsConfig);
10
+ constructor(config?: MetricsConfig, dryRun?: boolean);
10
11
  record(event: MetricsData): void;
11
12
  recordBlock(packageName: string, version: string, reason: string, metadata?: Record<string, any>): void;
12
13
  recordPublishRejected(packageName: string, version: string, reason: string): void;
@@ -37,7 +37,8 @@ exports.MetricsCollector = void 0;
37
37
  const fs = __importStar(require("fs"));
38
38
  const path = __importStar(require("path"));
39
39
  class MetricsCollector {
40
- constructor(config) {
40
+ constructor(config, dryRun = false) {
41
+ this.dryRun = dryRun;
41
42
  this.metricsBuffer = [];
42
43
  this.bufferSize = 100;
43
44
  this.maxBufferSize = 500;
@@ -60,7 +61,7 @@ class MetricsCollector {
60
61
  if (this.metricsBuffer.length >= this.maxBufferSize) {
61
62
  this.flush();
62
63
  }
63
- this.metricsBuffer.push(event);
64
+ this.metricsBuffer.push(this.dryRun ? Object.assign(Object.assign({}, event), { dryRun: true }) : event);
64
65
  if (this.metricsBuffer.length >= this.bufferSize) {
65
66
  this.flush();
66
67
  }
package/build/types.d.ts CHANGED
@@ -58,6 +58,7 @@ export interface SecurityConfig extends Config {
58
58
  blockedScopes?: string[];
59
59
  versionRangeRules?: VersionRangeRule[];
60
60
  mode?: 'blacklist' | 'whitelist';
61
+ dryRun?: boolean;
61
62
  whitelist?: WhitelistConfig;
62
63
  licenses?: LicenseConfig;
63
64
  cveCheck?: CVECheckConfig;
@@ -100,6 +101,7 @@ export interface MetricsData {
100
101
  version?: string;
101
102
  reason: string;
102
103
  metadata?: Record<string, any>;
104
+ dryRun?: boolean;
103
105
  }
104
106
  export interface AuthorInfo {
105
107
  name?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verdaccio-security-filter",
3
- "version": "1.3.11",
3
+ "version": "1.4.0",
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",