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 +26 -3
- package/build/index.d.ts +2 -0
- package/build/index.js +76 -31
- 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
|
|
@@ -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
|
|
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` | `
|
|
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
|
-
|
|
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
|
|
365
|
-
if (
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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 ((
|
|
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 ((
|
|
404
|
-
const
|
|
405
|
-
if (
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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:
|
|
416
|
-
reason,
|
|
454
|
+
version: rejected.all ? '*' : version,
|
|
455
|
+
reason: result.reason || 'Author not allowed',
|
|
417
456
|
metadata: {
|
|
418
|
-
blockedBy:
|
|
419
|
-
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:
|
|
424
|
-
authorInfo:
|
|
469
|
+
blockedBy: rejected.first.blockedBy,
|
|
470
|
+
authorInfo: rejected.first.authorInfo,
|
|
425
471
|
});
|
|
426
472
|
}
|
|
427
|
-
|
|
428
|
-
|
|
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 = ((
|
|
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}`);
|
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