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 +47 -31
- package/build/index.d.ts +1 -0
- package/build/index.js +53 -24
- package/build/lib/logger.d.ts +1 -1
- package/build/lib/logger.js +2 -2
- package/build/lib/metrics.d.ts +2 -1
- package/build/lib/metrics.js +3 -2
- package/build/types.d.ts +2 -0
- package/package.json +1 -1
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
|
-
- [
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
1.
|
|
158
|
-
2.
|
|
159
|
-
3.
|
|
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
|
-
|
|
161
|
+
npm then resolves ranges against the versions that are left.
|
|
163
162
|
|
|
164
|
-
### Layer 2: Middleware
|
|
165
|
-
|
|
166
|
-
1.
|
|
167
|
-
2.
|
|
168
|
-
3.
|
|
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
|
-
|
|
169
|
+
### What a client sees
|
|
172
170
|
|
|
173
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
272
|
-
|
|
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;
|
package/build/lib/logger.d.ts
CHANGED
|
@@ -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;
|
package/build/lib/logger.js
CHANGED
|
@@ -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',
|
package/build/lib/metrics.d.ts
CHANGED
|
@@ -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;
|
package/build/lib/metrics.js
CHANGED
|
@@ -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