@npm-safe/core-dsh 1.0.5
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/LICENSE +204 -0
- package/dist/index.d.ts +513 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +711 -0
- package/dist/index.js.map +1 -0
- package/dist/llm/anthropic.d.ts +47 -0
- package/dist/llm/anthropic.d.ts.map +1 -0
- package/dist/llm/anthropic.js +161 -0
- package/dist/llm/anthropic.js.map +1 -0
- package/dist/llm/gemini.d.ts +47 -0
- package/dist/llm/gemini.d.ts.map +1 -0
- package/dist/llm/gemini.js +165 -0
- package/dist/llm/gemini.js.map +1 -0
- package/dist/llm/llm-config.d.ts +97 -0
- package/dist/llm/llm-config.d.ts.map +1 -0
- package/dist/llm/llm-config.js +188 -0
- package/dist/llm/llm-config.js.map +1 -0
- package/dist/llm/parse.d.ts +95 -0
- package/dist/llm/parse.d.ts.map +1 -0
- package/dist/llm/parse.js +158 -0
- package/dist/llm/parse.js.map +1 -0
- package/dist/llm/provider.d.ts +122 -0
- package/dist/llm/provider.d.ts.map +1 -0
- package/dist/llm/provider.js +206 -0
- package/dist/llm/provider.js.map +1 -0
- package/dist/registry/client.d.ts +164 -0
- package/dist/registry/client.d.ts.map +1 -0
- package/dist/registry/client.js +378 -0
- package/dist/registry/client.js.map +1 -0
- package/dist/registry/types.d.ts +226 -0
- package/dist/registry/types.d.ts.map +1 -0
- package/dist/registry/types.js +32 -0
- package/dist/registry/types.js.map +1 -0
- package/dist/registry/validator.d.ts +87 -0
- package/dist/registry/validator.d.ts.map +1 -0
- package/dist/registry/validator.js +214 -0
- package/dist/registry/validator.js.map +1 -0
- package/dist/scanner/ci-scan.d.ts +82 -0
- package/dist/scanner/ci-scan.d.ts.map +1 -0
- package/dist/scanner/ci-scan.js +130 -0
- package/dist/scanner/ci-scan.js.map +1 -0
- package/dist/scanner/rule-config.d.ts +61 -0
- package/dist/scanner/rule-config.d.ts.map +1 -0
- package/dist/scanner/rule-config.js +103 -0
- package/dist/scanner/rule-config.js.map +1 -0
- package/dist/scanner/rule-loader.d.ts +28 -0
- package/dist/scanner/rule-loader.d.ts.map +1 -0
- package/dist/scanner/rule-loader.js +67 -0
- package/dist/scanner/rule-loader.js.map +1 -0
- package/dist/scanner/static-rules.d.ts +88 -0
- package/dist/scanner/static-rules.d.ts.map +1 -0
- package/dist/scanner/static-rules.js +723 -0
- package/dist/scanner/static-rules.js.map +1 -0
- package/dist/scanner/types.d.ts +177 -0
- package/dist/scanner/types.d.ts.map +1 -0
- package/dist/scanner/types.js +53 -0
- package/dist/scanner/types.js.map +1 -0
- package/dist/scheduler/rate-limiter.d.ts +74 -0
- package/dist/scheduler/rate-limiter.d.ts.map +1 -0
- package/dist/scheduler/rate-limiter.js +182 -0
- package/dist/scheduler/rate-limiter.js.map +1 -0
- package/dist/scheduler/refresh-scheduler.d.ts +201 -0
- package/dist/scheduler/refresh-scheduler.d.ts.map +1 -0
- package/dist/scheduler/refresh-scheduler.js +295 -0
- package/dist/scheduler/refresh-scheduler.js.map +1 -0
- package/dist/store/cache-manager.d.ts +166 -0
- package/dist/store/cache-manager.d.ts.map +1 -0
- package/dist/store/cache-manager.js +356 -0
- package/dist/store/cache-manager.js.map +1 -0
- package/dist/store/database.d.ts +81 -0
- package/dist/store/database.d.ts.map +1 -0
- package/dist/store/database.js +182 -0
- package/dist/store/database.js.map +1 -0
- package/dist/store/schema.d.ts +42 -0
- package/dist/store/schema.d.ts.map +1 -0
- package/dist/store/schema.js +126 -0
- package/dist/store/schema.js.map +1 -0
- package/dist/translator/provider.d.ts +152 -0
- package/dist/translator/provider.d.ts.map +1 -0
- package/dist/translator/provider.js +159 -0
- package/dist/translator/provider.js.map +1 -0
- package/dist/translator/types.d.ts +83 -0
- package/dist/translator/types.d.ts.map +1 -0
- package/dist/translator/types.js +58 -0
- package/dist/translator/types.js.map +1 -0
- package/package.json +42 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified composition layer for @npm-safe/core.
|
|
3
|
+
*
|
|
4
|
+
* {@link NpmSafeEngine} combines the database, cache, registry client, rate
|
|
5
|
+
* limiter, static analyzer, and refresh scheduler into a single facade that
|
|
6
|
+
* exposes the full public API of the engine.
|
|
7
|
+
*
|
|
8
|
+
* @module index
|
|
9
|
+
*/
|
|
10
|
+
import { DatabaseManager } from './store/database.js';
|
|
11
|
+
import { CacheManager } from './store/cache-manager.js';
|
|
12
|
+
import { NpmRegistryClient } from './registry/client.js';
|
|
13
|
+
import { NpmRegistryError } from './registry/types.js';
|
|
14
|
+
import { TokenBucket } from './scheduler/rate-limiter.js';
|
|
15
|
+
import { StaticAnalyzer } from './scanner/static-rules.js';
|
|
16
|
+
import { RefreshScheduler } from './scheduler/refresh-scheduler.js';
|
|
17
|
+
import { SecurityLevel } from './scanner/types.js';
|
|
18
|
+
import { RuleConfigManager } from './scanner/rule-config.js';
|
|
19
|
+
import { loadRulesFromDirectory } from './scanner/rule-loader.js';
|
|
20
|
+
import { LEVEL_RANK, readDependencies, readLockfileDependencies } from './scanner/ci-scan.js';
|
|
21
|
+
import { createLlmProvider } from './llm/provider.js';
|
|
22
|
+
import { LlmConfigManager } from './llm/llm-config.js';
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// Engine
|
|
25
|
+
// ============================================================================
|
|
26
|
+
/**
|
|
27
|
+
* Unified facade that composes every @npm-safe/core module into a single
|
|
28
|
+
* public API surface.
|
|
29
|
+
*
|
|
30
|
+
* Construct an instance with optional {@link NpmSafeEngineOptions}, then use
|
|
31
|
+
* its methods to check, search, watch, refresh, and configure npm packages.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```ts
|
|
35
|
+
* const engine = new NpmSafeEngine({ dbPath: './my-cache.db' });
|
|
36
|
+
* const result = await engine.checkPackage('lodash');
|
|
37
|
+
* console.log(result.security.overallLevel);
|
|
38
|
+
* await engine.close();
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export class NpmSafeEngine {
|
|
42
|
+
/** Database connection manager. */
|
|
43
|
+
database;
|
|
44
|
+
/** Cache read/write layer. */
|
|
45
|
+
cache;
|
|
46
|
+
/** HTTP client for the npm registry. */
|
|
47
|
+
client;
|
|
48
|
+
/** Token bucket rate limiter. */
|
|
49
|
+
limiter;
|
|
50
|
+
/** Static analysis engine. */
|
|
51
|
+
analyzer;
|
|
52
|
+
/** Per-rule configuration (enabled / severity / options). */
|
|
53
|
+
ruleConfig;
|
|
54
|
+
/** Auto-refresh scheduler. */
|
|
55
|
+
scheduler;
|
|
56
|
+
/** LLM provider configuration manager. */
|
|
57
|
+
llmConfig;
|
|
58
|
+
/** Optional semantic security scanner. */
|
|
59
|
+
llmProvider;
|
|
60
|
+
/**
|
|
61
|
+
* @param options - Optional configuration overrides; see
|
|
62
|
+
* {@link NpmSafeEngineOptions} for available options.
|
|
63
|
+
*/
|
|
64
|
+
constructor(options) {
|
|
65
|
+
this.database = new DatabaseManager(options?.dbPath ?? './npm-safe.db');
|
|
66
|
+
this.cache = new CacheManager(this.database, {
|
|
67
|
+
cacheTtlMs: options?.cacheTtlMs,
|
|
68
|
+
});
|
|
69
|
+
this.client = new NpmRegistryClient({
|
|
70
|
+
baseUrl: options?.registryUrl,
|
|
71
|
+
proxy: options?.proxy,
|
|
72
|
+
});
|
|
73
|
+
this.limiter = new TokenBucket(options?.rateLimit ?? 5, options?.rateLimitBurst ?? 10);
|
|
74
|
+
this.ruleConfig = new RuleConfigManager(options?.rulesConfigPath);
|
|
75
|
+
this.analyzer = new StaticAnalyzer(undefined, this.ruleConfig);
|
|
76
|
+
this.llmConfig = new LlmConfigManager(options?.llmConfigPath);
|
|
77
|
+
this.llmProvider = options?.llm
|
|
78
|
+
? createLlmProvider(options.llm)
|
|
79
|
+
: this.llmConfig.createProvider();
|
|
80
|
+
this.scheduler = new RefreshScheduler(this.client, this.cache, this.limiter, this.analyzer, () => this.llmProvider);
|
|
81
|
+
void this.loadRulePlugins(options?.rulesDir);
|
|
82
|
+
}
|
|
83
|
+
// --------------------------------------------------------------------------
|
|
84
|
+
// Package checking & searching
|
|
85
|
+
// --------------------------------------------------------------------------
|
|
86
|
+
/**
|
|
87
|
+
* Check a package by name, returning cached data if still fresh, or fetching
|
|
88
|
+
* from the registry, running static analysis, and caching the result.
|
|
89
|
+
*
|
|
90
|
+
* When the package does not exist on the registry (HTTP 404) the returned
|
|
91
|
+
* {@link CheckResult.exists} is `false` and the security / registry info
|
|
92
|
+
* fields are empty. All other errors (network failure, timeout, …) are
|
|
93
|
+
* rethrown so the caller can handle them appropriately.
|
|
94
|
+
*
|
|
95
|
+
* Pass `{ forceRefresh: true }` to skip the cache-hit fast path entirely
|
|
96
|
+
* and always re-fetch from the registry. Pass `{ signal }` to make the
|
|
97
|
+
* check cooperatively cancellable — an abort settles the promise promptly
|
|
98
|
+
* with `DOMException('The operation was aborted.', 'AbortError')`.
|
|
99
|
+
*
|
|
100
|
+
* @param name - Fully-qualified package name (scope included when scoped).
|
|
101
|
+
* @param options - Optional check options (forceRefresh, signal).
|
|
102
|
+
* @returns A promise that resolves to the check result.
|
|
103
|
+
*/
|
|
104
|
+
async checkPackage(name, options) {
|
|
105
|
+
// 1. Try the cache first — unless forceRefresh bypasses it entirely.
|
|
106
|
+
if (!options?.forceRefresh) {
|
|
107
|
+
const cached = await this.cache.getPackage(name);
|
|
108
|
+
if (cached !== null) {
|
|
109
|
+
const latestVersion = cached['dist-tags'].latest;
|
|
110
|
+
const staticScan = await this.cache.getSecurityReport(name, latestVersion);
|
|
111
|
+
const llmScan = this.llmProvider
|
|
112
|
+
? (await this.cache.getLlmScanReport(name, latestVersion)) ??
|
|
113
|
+
await this.scanWithLlm(cached, latestVersion, options?.signal)
|
|
114
|
+
: undefined;
|
|
115
|
+
return this.buildCheckResult(name, true, latestVersion, staticScan ?? null, llmScan, {
|
|
116
|
+
description: cached.description ?? '',
|
|
117
|
+
homepage: cached.homepage ?? '',
|
|
118
|
+
repository: repositoryToString(cached.repository),
|
|
119
|
+
}, null);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// 2. Cache miss, stale, or forceRefresh — fetch from the registry.
|
|
123
|
+
try {
|
|
124
|
+
const meta = await this.client.getPackageMetadata(name, {
|
|
125
|
+
signal: options?.signal,
|
|
126
|
+
});
|
|
127
|
+
const latestVersion = meta['dist-tags'].latest;
|
|
128
|
+
// Persist the fresh metadata before running analysis so the cache is
|
|
129
|
+
// updated even if analysis fails downstream.
|
|
130
|
+
await this.cache.setPackage(meta);
|
|
131
|
+
// Derive a package.json-like object from the latest version manifest
|
|
132
|
+
// for the static analyzer. The spread + double-cast is needed because
|
|
133
|
+
// AbbreviatedVersion is a readonly interface, not a plain object.
|
|
134
|
+
const manifest = meta.versions[latestVersion];
|
|
135
|
+
const packageJson = manifest
|
|
136
|
+
? { ...manifest }
|
|
137
|
+
: undefined;
|
|
138
|
+
const readme = meta.readme ?? '';
|
|
139
|
+
const report = this.analyzer.analyze(readme, packageJson);
|
|
140
|
+
await this.cache.setSecurityReport(report);
|
|
141
|
+
const llmScan = this.llmProvider
|
|
142
|
+
? await this.scanWithLlm(meta, latestVersion, options?.signal)
|
|
143
|
+
: undefined;
|
|
144
|
+
return this.buildCheckResult(name, true, latestVersion, report, llmScan, {
|
|
145
|
+
description: meta.description ?? '',
|
|
146
|
+
homepage: meta.homepage ?? '',
|
|
147
|
+
repository: repositoryToString(meta.repository),
|
|
148
|
+
}, new Date().toISOString());
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
// A 404 from the registry means the package simply does not exist —
|
|
152
|
+
// return a graceful "not found" result instead of throwing.
|
|
153
|
+
if (err instanceof NpmRegistryError && err.statusCode === 404) {
|
|
154
|
+
return this.buildCheckResult(name, false, '', null, undefined, null, null);
|
|
155
|
+
}
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Search the npm registry for packages matching a text query.
|
|
161
|
+
*
|
|
162
|
+
* @param query - Free-text search query.
|
|
163
|
+
* @param options - Optional search options. `options.size` caps the number
|
|
164
|
+
* of results (defaults to 20); `options.signal` is forwarded to the
|
|
165
|
+
* registry client for cooperative cancellation. Search is not cached, so
|
|
166
|
+
* there is no `forceRefresh` option here.
|
|
167
|
+
* @returns An array of search-result hits, ordered by relevance.
|
|
168
|
+
*/
|
|
169
|
+
async searchPackages(query, options) {
|
|
170
|
+
return this.client.searchPackages(query, {
|
|
171
|
+
size: options?.size,
|
|
172
|
+
signal: options?.signal,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Check many packages in parallel with a shared concurrency cap.
|
|
177
|
+
*
|
|
178
|
+
* Every check consumes one token from the rate limiter, so the batch
|
|
179
|
+
* respects the configured request budget even when running concurrently.
|
|
180
|
+
* Individual failures are isolated: a package that throws (network error,
|
|
181
|
+
* timeout, …) yields a `{ ok: false, error }` entry instead of rejecting
|
|
182
|
+
* the whole batch. Use `checkPackage` when the raw error must propagate.
|
|
183
|
+
*
|
|
184
|
+
* @param names - Package names to check.
|
|
185
|
+
* @param options - Batch options (concurrency, progress callback).
|
|
186
|
+
* @returns One entry per input name, in input order.
|
|
187
|
+
*/
|
|
188
|
+
async checkPackages(names, options) {
|
|
189
|
+
const concurrency = Math.max(1, Math.min(options?.concurrency ?? 5, names.length || 1));
|
|
190
|
+
const results = new Array(names.length);
|
|
191
|
+
let next = 0;
|
|
192
|
+
let done = 0;
|
|
193
|
+
const worker = async () => {
|
|
194
|
+
for (;;) {
|
|
195
|
+
// Short-circuit at the top of each iteration so an aborted batch
|
|
196
|
+
// settles promptly instead of the worker catch swallowing the
|
|
197
|
+
// AbortError as a per-package error and continuing to iterate
|
|
198
|
+
// (cooperative cancellation).
|
|
199
|
+
if (options?.signal?.aborted)
|
|
200
|
+
break;
|
|
201
|
+
const index = next++;
|
|
202
|
+
if (index >= names.length)
|
|
203
|
+
return;
|
|
204
|
+
const name = names[index];
|
|
205
|
+
try {
|
|
206
|
+
await this.limiter.consume(1);
|
|
207
|
+
const result = await this.checkPackage(name, {
|
|
208
|
+
signal: options?.signal,
|
|
209
|
+
});
|
|
210
|
+
const entry = { name, ok: true, result };
|
|
211
|
+
results[index] = entry;
|
|
212
|
+
done++;
|
|
213
|
+
options?.onProgress?.(done, names.length, entry);
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
const entry = {
|
|
217
|
+
name,
|
|
218
|
+
ok: false,
|
|
219
|
+
error: error instanceof Error ? error.message : String(error),
|
|
220
|
+
};
|
|
221
|
+
results[index] = entry;
|
|
222
|
+
done++;
|
|
223
|
+
options?.onProgress?.(done, names.length, entry);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
|
228
|
+
return results;
|
|
229
|
+
}
|
|
230
|
+
// --------------------------------------------------------------------------
|
|
231
|
+
// CI scan
|
|
232
|
+
// --------------------------------------------------------------------------
|
|
233
|
+
/**
|
|
234
|
+
* Scan a project's dependencies and return a CI report with a failure gate.
|
|
235
|
+
*
|
|
236
|
+
* Reads dependencies from `package.json` (or `package-lock.json` when
|
|
237
|
+
* `options.lockfile` is set), checks each one via {@link checkPackage}, and
|
|
238
|
+
* aggregates the results into a {@link CiReport}. The report's `failed` flag
|
|
239
|
+
* is `true` when any dependency reaches `options.failLevel` (inclusive) or
|
|
240
|
+
* when any check throws.
|
|
241
|
+
*
|
|
242
|
+
* This method is a pure computation — it does NOT log, format, or set process
|
|
243
|
+
* exit codes. The caller (CLI or plugin) renders the report and maps `failed`
|
|
244
|
+
* to an exit code.
|
|
245
|
+
*
|
|
246
|
+
* When `options.signal` is supplied, the loop short-circuits at the top of
|
|
247
|
+
* each iteration on abort and rejects with
|
|
248
|
+
* `DOMException('The operation was aborted.', 'AbortError')` so an aborted
|
|
249
|
+
* scan does not keep iterating deps (cooperative cancellation).
|
|
250
|
+
*
|
|
251
|
+
* @param options - Optional scan configuration. Defaults:
|
|
252
|
+
* `dir = process.cwd()`, `failLevel = SecurityLevel.Dangerous`.
|
|
253
|
+
* @returns A {@link CiReport} describing every checked dependency.
|
|
254
|
+
*/
|
|
255
|
+
async ciScan(options) {
|
|
256
|
+
const dir = options?.dir ?? process.cwd();
|
|
257
|
+
const failLevel = options?.failLevel ?? SecurityLevel.Dangerous;
|
|
258
|
+
const deps = options?.lockfile
|
|
259
|
+
? readLockfileDependencies(dir, !options?.prod)
|
|
260
|
+
: readDependencies(dir, !options?.prod);
|
|
261
|
+
const results = [];
|
|
262
|
+
const summary = {
|
|
263
|
+
safe: 0,
|
|
264
|
+
suspicious: 0,
|
|
265
|
+
dangerous: 0,
|
|
266
|
+
unknown: 0,
|
|
267
|
+
errors: 0,
|
|
268
|
+
};
|
|
269
|
+
for (const dep of deps) {
|
|
270
|
+
// Short-circuit at the top of each iteration so an aborted scan does
|
|
271
|
+
// not keep iterating deps (cooperative cancellation). The throw is
|
|
272
|
+
// BEFORE the try/catch so it propagates instead of being swallowed as
|
|
273
|
+
// a per-package error.
|
|
274
|
+
if (options?.signal?.aborted) {
|
|
275
|
+
throw new DOMException('The operation was aborted.', 'AbortError');
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
const result = await this.checkPackage(dep.name, {
|
|
279
|
+
signal: options?.signal,
|
|
280
|
+
});
|
|
281
|
+
const level = result.security.overallLevel;
|
|
282
|
+
results.push({
|
|
283
|
+
name: dep.name,
|
|
284
|
+
exists: result.exists,
|
|
285
|
+
version: result.latestVersion,
|
|
286
|
+
level,
|
|
287
|
+
score: result.security.overallScore,
|
|
288
|
+
findingCount: result.security.staticScan?.findings.length ?? 0,
|
|
289
|
+
});
|
|
290
|
+
summary[level] = (summary[level] ?? 0) + 1;
|
|
291
|
+
if (result.exists) {
|
|
292
|
+
await this.recordCheckHistory(result);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
results.push({
|
|
297
|
+
name: dep.name,
|
|
298
|
+
exists: false,
|
|
299
|
+
version: '',
|
|
300
|
+
level: SecurityLevel.Unknown,
|
|
301
|
+
score: 0,
|
|
302
|
+
findingCount: 0,
|
|
303
|
+
error: err instanceof Error ? err.message : String(err),
|
|
304
|
+
});
|
|
305
|
+
summary.errors++;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const failRank = LEVEL_RANK[failLevel];
|
|
309
|
+
const failed = summary.errors > 0 ||
|
|
310
|
+
results.some((r) => r.exists &&
|
|
311
|
+
LEVEL_RANK[r.level] !== undefined &&
|
|
312
|
+
LEVEL_RANK[r.level] <= failRank);
|
|
313
|
+
return {
|
|
314
|
+
dir,
|
|
315
|
+
scannedAt: new Date().toISOString(),
|
|
316
|
+
dependencyCount: deps.length,
|
|
317
|
+
failLevel,
|
|
318
|
+
failed,
|
|
319
|
+
summary,
|
|
320
|
+
packages: results,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
// --------------------------------------------------------------------------
|
|
324
|
+
// Watchlist
|
|
325
|
+
// --------------------------------------------------------------------------
|
|
326
|
+
/**
|
|
327
|
+
* Returns the list of package names currently on the watchlist.
|
|
328
|
+
*
|
|
329
|
+
* @returns All watched package names, in insertion order.
|
|
330
|
+
*/
|
|
331
|
+
async getWatchlist() {
|
|
332
|
+
return this.cache.getWatchlist();
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Add a package to the watchlist. Idempotent — adding a name that is already
|
|
336
|
+
* watched is a no-op.
|
|
337
|
+
*
|
|
338
|
+
* @param name - Fully-qualified package name to watch.
|
|
339
|
+
*/
|
|
340
|
+
async addToWatchlist(name) {
|
|
341
|
+
return this.cache.addToWatchlist(name);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Remove a package from the watchlist. No-op if the name was not watched.
|
|
345
|
+
*
|
|
346
|
+
* @param name - Fully-qualified package name to stop watching.
|
|
347
|
+
*/
|
|
348
|
+
async removeFromWatchlist(name) {
|
|
349
|
+
return this.cache.removeFromWatchlist(name);
|
|
350
|
+
}
|
|
351
|
+
// --------------------------------------------------------------------------
|
|
352
|
+
// Refresh
|
|
353
|
+
// --------------------------------------------------------------------------
|
|
354
|
+
/**
|
|
355
|
+
* Refresh a single package: fetch its latest metadata from the registry,
|
|
356
|
+
* re-run static analysis, and persist the results.
|
|
357
|
+
*
|
|
358
|
+
* Per-package failures are surfaced via the scheduler's `refresh:error`
|
|
359
|
+
* event and represented by a `false` result rather than thrown, so a
|
|
360
|
+
* failing package does not abort a batch. An external abort
|
|
361
|
+
* (`options.signal` aborted) propagates as
|
|
362
|
+
* `DOMException('The operation was aborted.', 'AbortError')`.
|
|
363
|
+
*
|
|
364
|
+
* @param name - Fully-qualified package name to refresh.
|
|
365
|
+
* @param options - Optional refresh options. `options.signal` is forwarded
|
|
366
|
+
* to the registry fetch and LLM scan.
|
|
367
|
+
*/
|
|
368
|
+
async refreshPackage(name, options) {
|
|
369
|
+
return this.scheduler.refreshPackage(name, options);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Refresh every package whose cached metadata has passed its TTL.
|
|
373
|
+
*
|
|
374
|
+
* Packages are processed sequentially so the rate limiter is respected.
|
|
375
|
+
* When `options.signal` is supplied, the loop short-circuits at the top of
|
|
376
|
+
* each iteration on abort and rejects with
|
|
377
|
+
* `DOMException('The operation was aborted.', 'AbortError')`.
|
|
378
|
+
*
|
|
379
|
+
* @param options - Optional refresh options. `options.signal` is forwarded
|
|
380
|
+
* to each per-package refresh.
|
|
381
|
+
*/
|
|
382
|
+
async refreshAll(options) {
|
|
383
|
+
return this.scheduler.refreshAll(options);
|
|
384
|
+
}
|
|
385
|
+
// --------------------------------------------------------------------------
|
|
386
|
+
// Settings
|
|
387
|
+
// --------------------------------------------------------------------------
|
|
388
|
+
/**
|
|
389
|
+
* Retrieve a setting value by key.
|
|
390
|
+
*
|
|
391
|
+
* @param key - Settings key.
|
|
392
|
+
* @returns The stored value, or `null` if the key is unset.
|
|
393
|
+
*/
|
|
394
|
+
async getSetting(key) {
|
|
395
|
+
return this.cache.getSetting(key);
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Upsert a setting value by key.
|
|
399
|
+
*
|
|
400
|
+
* @param key - Settings key.
|
|
401
|
+
* @param value - Value to persist.
|
|
402
|
+
*/
|
|
403
|
+
async setSetting(key, value) {
|
|
404
|
+
return this.cache.setSetting(key, value);
|
|
405
|
+
}
|
|
406
|
+
// --------------------------------------------------------------------------
|
|
407
|
+
// Check history
|
|
408
|
+
// --------------------------------------------------------------------------
|
|
409
|
+
/**
|
|
410
|
+
* Record a check into the persistent history database. Used by the CLI and
|
|
411
|
+
* the desktop extension so history is shared across both frontends.
|
|
412
|
+
*
|
|
413
|
+
* @param result - A check result for an existing package.
|
|
414
|
+
*/
|
|
415
|
+
async recordCheckHistory(result) {
|
|
416
|
+
if (!result.exists)
|
|
417
|
+
return;
|
|
418
|
+
await this.recordHistoryEntry({
|
|
419
|
+
packageName: result.packageName,
|
|
420
|
+
level: result.security.overallLevel,
|
|
421
|
+
score: result.security.overallScore,
|
|
422
|
+
timestamp: new Date().toISOString(),
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Append a raw history entry (newest-first, capped at 1000).
|
|
427
|
+
*/
|
|
428
|
+
async recordHistoryEntry(entry) {
|
|
429
|
+
await this.cache.addHistoryEntry(entry);
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Return the persistent check history, newest first (capped at 1000).
|
|
433
|
+
*/
|
|
434
|
+
async getCheckHistory(limit) {
|
|
435
|
+
return this.cache.getHistory(limit);
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Clear the persistent check history.
|
|
439
|
+
*/
|
|
440
|
+
async clearCheckHistory() {
|
|
441
|
+
return this.cache.clearHistory();
|
|
442
|
+
}
|
|
443
|
+
// --------------------------------------------------------------------------
|
|
444
|
+
// Rule plugin management
|
|
445
|
+
// --------------------------------------------------------------------------
|
|
446
|
+
/**
|
|
447
|
+
* Register a scan rule at runtime. A rule with the same id replaces the
|
|
448
|
+
* existing one.
|
|
449
|
+
*
|
|
450
|
+
* @param rule - The rule to register.
|
|
451
|
+
*/
|
|
452
|
+
registerRule(rule) {
|
|
453
|
+
this.analyzer.registerRule(rule);
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Remove a scan rule by id.
|
|
457
|
+
*
|
|
458
|
+
* @param ruleId - Id of the rule to remove.
|
|
459
|
+
* @returns `true` if a rule was removed, `false` if no such rule exists.
|
|
460
|
+
*/
|
|
461
|
+
unregisterRule(ruleId) {
|
|
462
|
+
return this.analyzer.unregisterRule(ruleId);
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Describe every registered rule with its effective status.
|
|
466
|
+
*
|
|
467
|
+
* @returns Rule descriptors in registration order.
|
|
468
|
+
*/
|
|
469
|
+
listRules() {
|
|
470
|
+
return this.analyzer.listRules();
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Enable or disable a rule (persisted in the rules config file).
|
|
474
|
+
*
|
|
475
|
+
* @param ruleId - Id of the rule.
|
|
476
|
+
* @param enabled - Whether the rule should run.
|
|
477
|
+
*/
|
|
478
|
+
setRuleEnabled(ruleId, enabled) {
|
|
479
|
+
this.ruleConfig.setEnabled(ruleId, enabled);
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Override a rule's severity (persisted). Pass `undefined` to clear the
|
|
483
|
+
* override and return to the rule's default severity.
|
|
484
|
+
*
|
|
485
|
+
* @param ruleId - Id of the rule.
|
|
486
|
+
* @param severity - Severity override, or `undefined` to clear.
|
|
487
|
+
*/
|
|
488
|
+
setRuleSeverity(ruleId, severity) {
|
|
489
|
+
this.ruleConfig.setSeverity(ruleId, severity);
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Set free-form options for a rule (persisted). Rule implementations can
|
|
493
|
+
* read these via the rule config manager.
|
|
494
|
+
*
|
|
495
|
+
* @param ruleId - Id of the rule.
|
|
496
|
+
* @param options - Free-form options.
|
|
497
|
+
*/
|
|
498
|
+
setRuleOptions(ruleId, options) {
|
|
499
|
+
this.ruleConfig.setOptions(ruleId, options);
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Access the rule configuration manager for low-level inspection.
|
|
503
|
+
*
|
|
504
|
+
* @returns The rule configuration manager backing this engine.
|
|
505
|
+
*/
|
|
506
|
+
getRuleConfig() {
|
|
507
|
+
return this.ruleConfig;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Load third-party rules from a directory of ES module files.
|
|
511
|
+
*
|
|
512
|
+
* Each `*.mjs` / `*.js` file may export a `rule`, `rules`, or `default`
|
|
513
|
+
* binding holding one or more {@link ScanRule}s. Files that fail to load
|
|
514
|
+
* are skipped.
|
|
515
|
+
*
|
|
516
|
+
* @param dir - Directory to scan. Defaults to `~/.npm-safe/rules/`.
|
|
517
|
+
* @returns The number of rules loaded.
|
|
518
|
+
*/
|
|
519
|
+
async loadRulePlugins(dir) {
|
|
520
|
+
const results = await loadRulesFromDirectory(dir);
|
|
521
|
+
let count = 0;
|
|
522
|
+
for (const result of results) {
|
|
523
|
+
for (const rule of result.rules) {
|
|
524
|
+
this.analyzer.registerRule(rule);
|
|
525
|
+
count++;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return count;
|
|
529
|
+
}
|
|
530
|
+
// --------------------------------------------------------------------------
|
|
531
|
+
// LLM configuration
|
|
532
|
+
// --------------------------------------------------------------------------
|
|
533
|
+
/**
|
|
534
|
+
* Get the raw LLM configuration, including the API key.
|
|
535
|
+
*
|
|
536
|
+
* @returns The current persisted LLM config.
|
|
537
|
+
*/
|
|
538
|
+
getLlmConfig() {
|
|
539
|
+
return this.llmConfig.getConfig();
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Get a masked, display-safe view of the LLM status.
|
|
543
|
+
*
|
|
544
|
+
* @returns Status object safe to render in a UI.
|
|
545
|
+
*/
|
|
546
|
+
getLlmStatus() {
|
|
547
|
+
return this.llmConfig.getStatus();
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Update the LLM configuration and recreate the provider.
|
|
551
|
+
*
|
|
552
|
+
* The change is persisted immediately. If LLM scanning is disabled or no
|
|
553
|
+
* API key is available, the provider is set to `undefined` so the rest of the
|
|
554
|
+
* engine continues unaffected.
|
|
555
|
+
*
|
|
556
|
+
* @param update - Partial config update. Pass `{ enabled: false }` to disable.
|
|
557
|
+
*/
|
|
558
|
+
setLlmConfig(update) {
|
|
559
|
+
this.llmConfig.setConfig(update);
|
|
560
|
+
this.llmProvider = this.llmConfig.createProvider();
|
|
561
|
+
this.scheduler.setLlmProvider(this.llmProvider);
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Test whether the current LLM configuration can connect to its provider.
|
|
565
|
+
*
|
|
566
|
+
* @returns `true` if the provider is enabled, configured, and the test call
|
|
567
|
+
* succeeds; `false` otherwise.
|
|
568
|
+
*/
|
|
569
|
+
async testLlmConnection() {
|
|
570
|
+
return this.llmConfig.testConnection();
|
|
571
|
+
}
|
|
572
|
+
// --------------------------------------------------------------------------
|
|
573
|
+
// Auto-refresh lifecycle
|
|
574
|
+
// --------------------------------------------------------------------------
|
|
575
|
+
/**
|
|
576
|
+
* Start the periodic auto-refresh loop.
|
|
577
|
+
*
|
|
578
|
+
* The first refresh cycle kicks off immediately in the background; subsequent
|
|
579
|
+
* cycles repeat at `intervalMs`. Safe to call multiple times — calling while
|
|
580
|
+
* already running resets the interval.
|
|
581
|
+
*
|
|
582
|
+
* @param intervalMs - Milliseconds between refresh cycles.
|
|
583
|
+
* Defaults to 1 hour.
|
|
584
|
+
*/
|
|
585
|
+
startAutoRefresh(intervalMs) {
|
|
586
|
+
this.scheduler.start(intervalMs);
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Stop the periodic auto-refresh loop.
|
|
590
|
+
*
|
|
591
|
+
* Safe to call when the scheduler is not running. Any in-flight refresh
|
|
592
|
+
* continues to completion.
|
|
593
|
+
*/
|
|
594
|
+
stopAutoRefresh() {
|
|
595
|
+
this.scheduler.stop();
|
|
596
|
+
}
|
|
597
|
+
// --------------------------------------------------------------------------
|
|
598
|
+
// Lifecycle
|
|
599
|
+
// --------------------------------------------------------------------------
|
|
600
|
+
/**
|
|
601
|
+
* Release all resources held by the engine.
|
|
602
|
+
*
|
|
603
|
+
* Stops the auto-refresh scheduler, disposes the rate-limiter timer, and
|
|
604
|
+
* closes the database connection. After calling this method the engine
|
|
605
|
+
* instance must not be used for further operations.
|
|
606
|
+
*/
|
|
607
|
+
close() {
|
|
608
|
+
this.scheduler.stop();
|
|
609
|
+
this.limiter.dispose();
|
|
610
|
+
this.database.close();
|
|
611
|
+
}
|
|
612
|
+
// --------------------------------------------------------------------------
|
|
613
|
+
// Internal helpers
|
|
614
|
+
// --------------------------------------------------------------------------
|
|
615
|
+
/**
|
|
616
|
+
* Assemble a {@link CheckResult} from its constituent parts.
|
|
617
|
+
*/
|
|
618
|
+
buildCheckResult(packageName, exists, latestVersion, staticScan, llmScan, registryInfo, cachedAt) {
|
|
619
|
+
const overallScore = combineScores(staticScan, llmScan);
|
|
620
|
+
return {
|
|
621
|
+
packageName,
|
|
622
|
+
exists,
|
|
623
|
+
latestVersion,
|
|
624
|
+
security: {
|
|
625
|
+
overallLevel: scoreToSecurityLevel(overallScore),
|
|
626
|
+
overallScore,
|
|
627
|
+
staticScan,
|
|
628
|
+
llmScan,
|
|
629
|
+
},
|
|
630
|
+
registryInfo,
|
|
631
|
+
cachedAt,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
async scanWithLlm(meta, version, signal) {
|
|
635
|
+
if (!this.llmProvider) {
|
|
636
|
+
return { enabled: false, reason: 'LLM provider is not configured.' };
|
|
637
|
+
}
|
|
638
|
+
const manifest = meta.versions[version];
|
|
639
|
+
try {
|
|
640
|
+
const report = await this.llmProvider.scan({
|
|
641
|
+
packageName: meta.name,
|
|
642
|
+
version,
|
|
643
|
+
description: meta.description ?? '',
|
|
644
|
+
readme: meta.readme ?? '',
|
|
645
|
+
packageJson: manifest ? { ...manifest } : undefined,
|
|
646
|
+
signal,
|
|
647
|
+
});
|
|
648
|
+
await this.cache.setLlmScanReport(meta.name, version, report);
|
|
649
|
+
return report;
|
|
650
|
+
}
|
|
651
|
+
catch (error) {
|
|
652
|
+
// An external abort must propagate as AbortError so the caller
|
|
653
|
+
// settles promptly (cooperative cancellation) instead of receiving
|
|
654
|
+
// a disabled LLM report and returning normally.
|
|
655
|
+
if (signal?.aborted) {
|
|
656
|
+
throw new DOMException('The operation was aborted.', 'AbortError');
|
|
657
|
+
}
|
|
658
|
+
const report = {
|
|
659
|
+
enabled: false,
|
|
660
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
661
|
+
scannedAt: new Date().toISOString(),
|
|
662
|
+
};
|
|
663
|
+
await this.cache.setLlmScanReport(meta.name, version, report);
|
|
664
|
+
return report;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
// ============================================================================
|
|
669
|
+
// Helpers
|
|
670
|
+
// ============================================================================
|
|
671
|
+
/**
|
|
672
|
+
* Normalize a {@link PackageRepository} value to a plain string.
|
|
673
|
+
*
|
|
674
|
+
* Structured descriptors are rendered as `"type:url"`; shorthand strings are
|
|
675
|
+
* returned verbatim; `undefined` produces an empty string.
|
|
676
|
+
*
|
|
677
|
+
* @param repo - Repository descriptor from registry metadata.
|
|
678
|
+
* @returns A string representation suitable for display.
|
|
679
|
+
*/
|
|
680
|
+
function repositoryToString(repo) {
|
|
681
|
+
if (repo === undefined)
|
|
682
|
+
return '';
|
|
683
|
+
if (typeof repo === 'string')
|
|
684
|
+
return repo;
|
|
685
|
+
return `${repo.type}:${repo.url}`;
|
|
686
|
+
}
|
|
687
|
+
function combineScores(staticScan, llmScan) {
|
|
688
|
+
if (!staticScan)
|
|
689
|
+
return 0;
|
|
690
|
+
if (!llmScan?.enabled)
|
|
691
|
+
return staticScan.score;
|
|
692
|
+
return Math.round(staticScan.score * 0.6 + (100 - (llmScan.suspiciousScore ?? 0)) * 0.4);
|
|
693
|
+
}
|
|
694
|
+
function scoreToSecurityLevel(score) {
|
|
695
|
+
if (score >= 80)
|
|
696
|
+
return SecurityLevel.Safe;
|
|
697
|
+
if (score >= 50)
|
|
698
|
+
return SecurityLevel.Suspicious;
|
|
699
|
+
if (score >= 20)
|
|
700
|
+
return SecurityLevel.Dangerous;
|
|
701
|
+
return SecurityLevel.Unknown;
|
|
702
|
+
}
|
|
703
|
+
export { createLlmProvider, LlmProviderError } from './llm/provider.js';
|
|
704
|
+
export { LlmConfigManager, getDefaultLlmConfigPath } from './llm/llm-config.js';
|
|
705
|
+
export { RuleConfigManager } from './scanner/rule-config.js';
|
|
706
|
+
export { loadRulesFromDirectory } from './scanner/rule-loader.js';
|
|
707
|
+
export { DatabaseManager } from './store/database.js';
|
|
708
|
+
export { CacheManager, DEFAULT_CACHE_TTL_MS, MAX_CHECK_HISTORY } from './store/cache-manager.js';
|
|
709
|
+
export { SecurityLevel, Severity } from './scanner/types.js';
|
|
710
|
+
export { LEVEL_RANK, readDependencies, readLockfileDependencies } from './scanner/ci-scan.js';
|
|
711
|
+
//# sourceMappingURL=index.js.map
|