verdaccio-security-filter 1.3.12 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -150,47 +150,44 @@ npm link
150
150
 
151
151
  ## 🔧 How It Works
152
152
 
153
- This plugin implements **two layers of protection** to block malicious packages:
153
+ The plugin works in two layers. Configure both: the same settings go under `filters` and under `middlewares`.
154
154
 
155
155
  ### Layer 1: Metadata Filtering (`filter_metadata`)
156
- When a client requests package information (e.g., `npm info lodash`), the plugin:
157
- 1. Checks if the package is allowed (whitelist mode)
158
- 2. Validates against blocked patterns, scopes, and age requirements
159
- 3. **Throws HTTP 404 error** for completely blocked packages (patterns, scopes, whitelist violations)
160
- 4. Filters out specific blocked versions from the versions list for partial blocks
156
+ Runs inside Verdaccio when package metadata is read, so it sees every version:
157
+ 1. Removes versions blocked by `blockedVersions`, `versionRangeRules` and `whitelist.versions`
158
+ 2. Removes versions rejected by the CVE, license, age and author checks
159
+ 3. Blocks the package as a whole when no version is left, or when the package itself is not allowed
161
160
 
162
- **Result**: Blocked packages return "404 Not Found" - as if they don't exist in the registry.
161
+ npm then resolves ranges against the versions that are left.
163
162
 
164
- ### Layer 2: Middleware Interception (`register_middlewares`)
165
- When a client tries to download a tarball file (e.g., during `npm install`), the plugin:
166
- 1. Intercepts **all HTTP requests** early in the middleware chain
167
- 2. Extracts package name and version from the request URL
168
- 3. Applies all security checks (whitelist, patterns, scopes, blocked versions, range rules)
169
- 4. Returns **403 Forbidden** for blocked packages with detailed error message
163
+ ### Layer 2: Middleware (`register_middlewares`)
164
+ Runs first on every `GET` request:
165
+ 1. Blocks packages by name: whitelist, patterns, scopes
166
+ 2. Blocks tarball downloads of blocked versions, even if a client has stale metadata or a lockfile
167
+ 3. Turns a whole-package block from layer 1 into an HTTP error that carries the reason
170
168
 
171
- **Result**: Even if metadata is cached, tarball downloads are blocked at the HTTP level.
169
+ ### What a client sees
172
170
 
173
- ### Why Both Layers?
171
+ A removed version is simply missing from the metadata. A blocked package or tarball is answered with **403 Forbidden**, and the reason is in the `error` field, which npm prints:
174
172
 
175
- - **Metadata filtering (404)** - Makes blocked packages invisible to npm/yarn
176
- - **Middleware interception (403)** - Blocks tarball downloads even with cached metadata
177
- - Together, they provide **complete protection** against blocked packages being installed in any way
178
-
179
- Example flow when a blocked package is requested:
180
173
  ```
181
174
  npm install hawk
182
- → GET /hawk (metadata request)
183
- → [Security Filter] Blocked hawk@* - Package is not in whitelist
184
- → HTTP 404 Not Found: Package blocked by security filter: Not in whitelist
185
- → npm ERR! 404 Not Found - GET http://localhost:4873/hawk
186
-
187
- npm install (with hawk as dependency)
188
- → GET /hawk/-/hawk-9.0.1.tgz (tarball request)
189
- → [Middleware] Tarball request: hawk/hawk-9.0.1.tgz
190
- → [Security Filter] Blocked hawk@9.0.1 - Package is not in whitelist
191
- → HTTP 403 Forbidden: {"error": "Package blocked by security filter", "reason": "Package is not in whitelist"}
175
+ npm error code E403
176
+ npm error 403 403 Forbidden - GET http://localhost:4873/hawk - Package blocked by security filter: Package is not in whitelist
177
+ ```
178
+
179
+ ```json
180
+ {
181
+ "error": "Package blocked by security filter: License 'GPL-3.0' is not in allowed list",
182
+ "package": "some-package",
183
+ "reason": "License 'GPL-3.0' is not in allowed list",
184
+ "security": { "blocked": true, "reason": "...", "plugin": { "name": "verdaccio-security-filter", "version": "..." } },
185
+ "timestamp": "2026-01-15T10:30:00.000Z"
186
+ }
192
187
  ```
193
188
 
189
+ The 403 for packages blocked in layer 1 (CVE, license, age, author, fail-closed errors) needs the middleware. With only `filters` configured, such a package is returned with an empty version list and npm reports "No versions available" without a reason.
190
+
194
191
  ## ⚙️ Configuration
195
192
 
196
193
  ### Quick Start (Middleware Only)
@@ -406,11 +403,30 @@ middlewares:
406
403
  | Option | Type | Default | Description |
407
404
  |--------|------|---------|-------------|
408
405
  | `mode` | `string` | `blacklist` | Filter mode: `blacklist` or `whitelist` |
406
+ | `dryRun` | `boolean` | `false` | Log and record every decision, but block nothing. See [Dry-run mode](#dry-run-mode) |
409
407
  | `blockedVersions` | `string[]` | `[]` | List of `package@version` to block |
410
408
  | `blockedPatterns` | `string[]` | `[]` | Regex patterns for package names |
411
409
  | `allowedScopes` | `string[]` | `[]` | Allowed package scopes |
412
410
  | `blockedScopes` | `string[]` | `[]` | Blocked package scopes |
413
411
 
412
+ ### Dry-run mode
413
+
414
+ 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.
415
+
416
+ ```yaml
417
+ dryRun: true
418
+ ```
419
+
420
+ While it is on:
421
+
422
+ - every check runs as usual, including CVE lookups
423
+ - nothing is blocked: metadata is served unchanged and every tarball can be downloaded
424
+ - 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)`
425
+ - metrics events carry `"dryRun": true`
426
+ - one warning at start-up says that dry-run is active
427
+
428
+ Recommended before turning on `cveCheck.autoBlock`, a license policy or the author filter on a registry that is already in use.
429
+
414
430
  ### CVE Check Options
415
431
 
416
432
  | Option | Type | Default | Description |
package/build/index.d.ts CHANGED
@@ -25,6 +25,7 @@ export default class SecurityFilterPlugin implements IPluginMiddleware<SecurityC
25
25
  private _removeVersions;
26
26
  private _rejectedVersions;
27
27
  filter_metadata(packageInfo: Package): Promise<Package>;
28
+ private _filterMetadata;
28
29
  private _versionBlockReason;
29
30
  private _checkPackageBlock;
30
31
  private _getAppliedRules;
package/build/index.js CHANGED
@@ -56,7 +56,12 @@ const cve_checker_1 = require("./lib/cve-checker");
56
56
  const license_checker_1 = require("./lib/license-checker");
57
57
  const package_age_checker_1 = require("./lib/package-age-checker");
58
58
  const author_checker_1 = require("./lib/author-checker");
59
+ const lru_cache_1 = require("./lib/lru-cache");
59
60
  const PLUGIN_VERSION = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(__dirname, '..', 'package.json'), 'utf8')).version;
61
+ const filterBlocks = new lru_cache_1.LRUCache(1000);
62
+ function forbiddenBody(packageName, reason, extra = {}) {
63
+ return Object.assign(Object.assign({ error: `Package blocked by security filter: ${reason}`, package: packageName, reason }, extra), { timestamp: new Date().toISOString() });
64
+ }
60
65
  class SecurityFilterPlugin {
61
66
  constructor(config, options) {
62
67
  this.config = config || {};
@@ -66,8 +71,8 @@ class SecurityFilterPlugin {
66
71
  onCveCheckError: 'fail-open',
67
72
  };
68
73
  }
69
- this.logger = new logger_1.SecurityLogger(options.logger, this.config.logger);
70
- this.metrics = new metrics_1.MetricsCollector(this.config.metrics);
74
+ this.logger = new logger_1.SecurityLogger(options.logger, this.config.logger, !!this.config.dryRun);
75
+ this.metrics = new metrics_1.MetricsCollector(this.config.metrics, !!this.config.dryRun);
71
76
  this.whitelistChecker = new whitelist_checker_1.WhitelistChecker(this.config.whitelist);
72
77
  this.cveChecker = new cve_checker_1.CVEChecker(this.config.cveCheck);
73
78
  this.licenseChecker = new license_checker_1.LicenseChecker(this.config.licenses);
@@ -90,6 +95,9 @@ class SecurityFilterPlugin {
90
95
  }
91
96
  });
92
97
  this._logInitialization();
98
+ if (this.config.dryRun) {
99
+ this.logger.warn('Dry-run mode is on: decisions are logged and recorded in metrics, but nothing is blocked');
100
+ }
93
101
  }
94
102
  _parseVersionRangeRules(rules) {
95
103
  return rules.map((rule, index) => {
@@ -140,6 +148,8 @@ class SecurityFilterPlugin {
140
148
  _getEnabledFeatures() {
141
149
  var _a, _b, _c, _d, _e;
142
150
  const features = [];
151
+ if (this.config.dryRun)
152
+ features.push('dry-run');
143
153
  if (this.config.mode === 'whitelist')
144
154
  features.push('whitelist-mode');
145
155
  if ((_a = this.config.metrics) === null || _a === void 0 ? void 0 : _a.enabled)
@@ -225,13 +235,10 @@ class SecurityFilterPlugin {
225
235
  if (blockResult.blocked) {
226
236
  this.logger.warn(`[Middleware] [X] BLOCKED TARBALL: ${packageName}@${version || '*'} - ${blockResult.reason}`);
227
237
  this.metrics.recordBlock(packageName, version || '*', blockResult.reason);
228
- res.status(403).json({
229
- error: 'Package blocked by security filter',
230
- package: packageName,
231
- version: version || 'all versions',
232
- reason: blockResult.reason,
233
- timestamp: new Date().toISOString()
234
- });
238
+ if (this.config.dryRun) {
239
+ return next();
240
+ }
241
+ res.status(403).json(forbiddenBody(packageName, blockResult.reason, { version: version || 'all versions' }));
235
242
  return;
236
243
  }
237
244
  this.logger.debug(`[Middleware] [OK] ALLOWED TARBALL: ${packageName}@${version || 'unknown'}`);
@@ -241,10 +248,10 @@ class SecurityFilterPlugin {
241
248
  const blockResult = yield this._checkPackageBlock(packageName);
242
249
  if (blockResult.blocked) {
243
250
  this.logger.warn(`[Middleware] [X] BLOCKED METADATA: ${packageName} - ${blockResult.reason}`);
244
- const blockedResponse = {
245
- name: packageName,
246
- versions: {},
247
- 'dist-tags': {},
251
+ if (this.config.dryRun) {
252
+ return next();
253
+ }
254
+ res.status(403).json(forbiddenBody(packageName, blockResult.reason, {
248
255
  security: {
249
256
  blocked: true,
250
257
  reason: blockResult.reason,
@@ -255,21 +262,24 @@ class SecurityFilterPlugin {
255
262
  },
256
263
  blockedAt: new Date().toISOString(),
257
264
  rules: this._getAppliedRules(packageName),
258
- message: `This package has been blocked by security filter: ${blockResult.reason}`,
259
265
  contact: 'Please contact your registry administrator for more information',
260
266
  },
261
- _security: {
262
- blocked: true,
263
- reason: blockResult.reason,
264
- blockedBy: 'verdaccio-security-filter',
265
- blockedAt: new Date().toISOString(),
266
- }
267
- };
268
- res.status(200).json(blockedResponse);
267
+ }));
269
268
  return;
270
269
  }
271
- else {
272
- this.logger.debug(`[Middleware] [OK] ALLOWED METADATA: ${packageName}`);
270
+ this.logger.debug(`[Middleware] [OK] ALLOWED METADATA: ${packageName}`);
271
+ if (!this.config.dryRun) {
272
+ const startedAt = Date.now();
273
+ const send = res.send.bind(res);
274
+ res.send = (body) => {
275
+ const block = filterBlocks.get(packageName);
276
+ if (block && block.at >= startedAt && res.statusCode < 300) {
277
+ res.removeHeader('ETag');
278
+ res.status(403);
279
+ return send(JSON.stringify(forbiddenBody(packageName, block.security.reason, { security: block.security })));
280
+ }
281
+ return send(body);
282
+ };
273
283
  }
274
284
  }
275
285
  next();
@@ -321,6 +331,25 @@ class SecurityFilterPlugin {
321
331
  };
322
332
  }
323
333
  filter_metadata(packageInfo) {
334
+ return __awaiter(this, void 0, void 0, function* () {
335
+ const filtered = yield this._filterMetadata(packageInfo);
336
+ if (!this.config.dryRun) {
337
+ const security = filtered.security;
338
+ if (security === null || security === void 0 ? void 0 : security.blocked) {
339
+ filterBlocks.set(packageInfo.name, { security, at: Date.now() });
340
+ }
341
+ return filtered;
342
+ }
343
+ if (filtered === packageInfo) {
344
+ return filtered;
345
+ }
346
+ const total = Object.keys(packageInfo.versions || {}).length;
347
+ const left = Object.keys(filtered.versions || {}).length;
348
+ this.logger.warn(`[filter_metadata] ${packageInfo.name}: would remove ${total - left} of ${total} version(s)`);
349
+ return packageInfo;
350
+ });
351
+ }
352
+ _filterMetadata(packageInfo) {
324
353
  return __awaiter(this, void 0, void 0, function* () {
325
354
  var _a, _b, _c, _d, _e;
326
355
  let pkg = packageInfo;
@@ -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.12",
3
+ "version": "1.4.1",
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",