@vltpkg/security-archive 1.3.8 → 1.5.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
@@ -23,7 +23,9 @@ const graph = actual.load({
23
23
  projectRoot: process.cwd(),
24
24
  })
25
25
 
26
- const archive = await SecurityArchive.start({ graph, specOptions })
26
+ const archive = await SecurityArchive.start({
27
+ nodes: [...graph.nodes.values()],
28
+ })
27
29
 
28
30
  if (archive.ok) {
29
31
  for (const node of graph.nodes.values()) {
@@ -36,3 +38,14 @@ if (archive.ok) {
36
38
  console.warn('Failed to start the SecurityArchive')
37
39
  }
38
40
  ```
41
+
42
+ ## Options
43
+
44
+ - `path`: sqlite db location. Defaults to the vlt XDG cache dir.
45
+ - `retries`: retry attempts on failed API requests. Defaults to `3`.
46
+ - `timeout`: max ms to wait for the API, retries included. Defaults to
47
+ `VLT_SECURITY_ARCHIVE_TIMEOUT` env or `30000`. Must be a positive
48
+ integer, other values are ignored. On timeout a warning is printed,
49
+ missing packages stay unscanned, `ok` is `false`, `timedOut` is
50
+ `true` and nothing is cached.
51
+ - `ttl`: ms to cache newly fetched entries for. Defaults to 3 hours.
package/dist/index.d.ts CHANGED
@@ -3,19 +3,6 @@ import type { DepID } from '@vltpkg/dep-id';
3
3
  import type { PackageReportData, SecurityArchiveLike, SecurityArchiveRefreshOptions } from './types.ts';
4
4
  export * from './types.ts';
5
5
  export { npmRegistryURL, usesNpmRegistry } from './browser.ts';
6
- export type JSONItemResponse = {
7
- namespace?: `@{string}`;
8
- name: string;
9
- version: string;
10
- score: {
11
- overall: number;
12
- license: number;
13
- maintenance: number;
14
- quality: number;
15
- supplyChain: number;
16
- vulnerability: number;
17
- };
18
- };
19
6
  export type DBReadEntry = {
20
7
  depID: string;
21
8
  now: number;
@@ -37,6 +24,14 @@ export type SecurityArchiveOptions = LRUCache.OptionsBase<DepID, PackageReportDa
37
24
  * Number of retries attempts to reach the remote security API.
38
25
  */
39
26
  retries?: number;
27
+ /**
28
+ * Max ms to wait for the remote security API, incl. retries.
29
+ * Defaults to `VLT_SECURITY_ARCHIVE_TIMEOUT` env or
30
+ * {@link SecurityArchive.defaultTimeout}. Positive integers only,
31
+ * other values are ignored. On timeout, missing packages are left
32
+ * unscanned and a warning is printed.
33
+ */
34
+ timeout?: number;
40
35
  };
41
36
  /**
42
37
  * A database of security information for given packages from a list of nodes.
@@ -44,7 +39,8 @@ export type SecurityArchiveOptions = LRUCache.OptionsBase<DepID, PackageReportDa
44
39
  * Using the SecurityArchive.refresh() method will update the local cache
45
40
  * with information from the socket.dev APIs or load from the local storage
46
41
  * if available. Information about package security is then available
47
- * using the SecurityArchive.get() method.
42
+ * using the SecurityArchive.get() method. Remote requests are bounded
43
+ * by the `timeout` option.
48
44
  */
49
45
  export declare class SecurityArchive extends LRUCache<DepID, PackageReportData> implements SecurityArchiveLike {
50
46
  #private;
@@ -53,6 +49,10 @@ export declare class SecurityArchive extends LRUCache<DepID, PackageReportData>
53
49
  * for all public registry packages from the initial list of nodes.
54
50
  */
55
51
  ok: boolean;
52
+ /**
53
+ * True if the last refresh() timed out waiting for the remote API.
54
+ */
55
+ timedOut: boolean;
56
56
  /**
57
57
  * Creates a new security archive instance and starts the refresh process.
58
58
  */
@@ -65,6 +65,10 @@ export declare class SecurityArchive extends LRUCache<DepID, PackageReportData>
65
65
  * By default, entries are cached for 3 hours.
66
66
  */
67
67
  static get defaultTtl(): number;
68
+ /**
69
+ * By default, remote requests time out after 30 seconds.
70
+ */
71
+ static get defaultTimeout(): number;
68
72
  constructor(options?: SecurityArchiveOptions);
69
73
  /**
70
74
  * Get the report data for a node, keyed by its base {@link DepID}.
package/dist/index.js CHANGED
@@ -9,25 +9,34 @@ import { asDepID, baseDepID } from '@vltpkg/dep-id';
9
9
  import { error } from '@vltpkg/error-cause';
10
10
  import { userAgent } from '@vltpkg/user-agent';
11
11
  import { XDG } from '@vltpkg/xdg';
12
- import { asPackageReportData } from "./types.js";
12
+ import { asPackageReportData, getItemScore } from "./types.js";
13
13
  import { __CODE_SPLIT_SCRIPT_NAME } from "./update-expired.js";
14
14
  import { usesNpmRegistry } from "./browser.js";
15
15
  export * from "./types.js";
16
16
  export { npmRegistryURL, usesNpmRegistry } from "./browser.js";
17
17
  const SOCKET_API_V0_URL = 'https://api.socket.dev/v0/purl?alerts=true';
18
18
  const SOCKET_PUBLIC_API_TOKEN = 'sktsec_t_--RAN5U4ivauy4w37-6aoKyYPDt5ZbaT5JBVMqiwKo_api';
19
+ // AbortSignal.timeout throws on <0/NaN and overflows to 1ms above 2^31-1
20
+ const asTimeout = (raw) => {
21
+ const n = Number(raw);
22
+ return Number.isInteger(n) && n > 0 && n <= 2_147_483_647 ?
23
+ n
24
+ : undefined;
25
+ };
19
26
  /**
20
27
  * A database of security information for given packages from a list of nodes.
21
28
  *
22
29
  * Using the SecurityArchive.refresh() method will update the local cache
23
30
  * with information from the socket.dev APIs or load from the local storage
24
31
  * if available. Information about package security is then available
25
- * using the SecurityArchive.get() method.
32
+ * using the SecurityArchive.get() method. Remote requests are bounded
33
+ * by the `timeout` option.
26
34
  */
27
35
  export class SecurityArchive extends LRUCache {
28
36
  #expired = new Set();
29
37
  #path;
30
38
  #retries;
39
+ #timeout;
31
40
  #nodesByName = new Map();
32
41
  #nodesByID = new Map();
33
42
  /**
@@ -35,6 +44,10 @@ export class SecurityArchive extends LRUCache {
35
44
  * for all public registry packages from the initial list of nodes.
36
45
  */
37
46
  ok = false;
47
+ /**
48
+ * True if the last refresh() timed out waiting for the remote API.
49
+ */
50
+ timedOut = false;
38
51
  /**
39
52
  * Creates a new security archive instance and starts the refresh process.
40
53
  */
@@ -55,6 +68,12 @@ export class SecurityArchive extends LRUCache {
55
68
  static get defaultTtl() {
56
69
  return 1000 * 60 * 60 * 3;
57
70
  }
71
+ /**
72
+ * By default, remote requests time out after 30 seconds.
73
+ */
74
+ static get defaultTimeout() {
75
+ return 30_000;
76
+ }
58
77
  constructor(options = {}) {
59
78
  super({
60
79
  max: _a.defaultMax,
@@ -65,6 +84,10 @@ export class SecurityArchive extends LRUCache {
65
84
  this.#path =
66
85
  options.path ?? new XDG('vlt').cache('security-archive.db');
67
86
  this.#retries = options.retries ?? 3;
87
+ this.#timeout =
88
+ asTimeout(options.timeout) ??
89
+ asTimeout(process.env.VLT_SECURITY_ARCHIVE_TIMEOUT) ??
90
+ _a.defaultTimeout;
68
91
  }
69
92
  /**
70
93
  * Get the report data for a node, keyed by its base {@link DepID}.
@@ -187,6 +210,7 @@ export class SecurityArchive extends LRUCache {
187
210
  const payload = {
188
211
  dbPath: this.#path,
189
212
  retries: this.#retries,
213
+ timeout: this.#timeout,
190
214
  ttl: _a.defaultTtl,
191
215
  expired,
192
216
  };
@@ -235,7 +259,7 @@ export class SecurityArchive extends LRUCache {
235
259
  * Fetches security information from the socket.dev API.
236
260
  * Returns a string of NDJSON data.
237
261
  */
238
- async #retrieveRemoteData(queue) {
262
+ async #retrieveRemoteData(queue, signal) {
239
263
  // fetch information from the socket.dev API
240
264
  const req = await fetch(SOCKET_API_V0_URL, {
241
265
  method: 'POST',
@@ -246,6 +270,7 @@ export class SecurityArchive extends LRUCache {
246
270
  body: JSON.stringify({
247
271
  components: Array.from(queue),
248
272
  }),
273
+ signal,
249
274
  });
250
275
  // on missing valid auth or API, it should abort the retry logic
251
276
  if (req.status === 404) {
@@ -258,6 +283,23 @@ export class SecurityArchive extends LRUCache {
258
283
  const str = await req.text();
259
284
  return str.trim() + '\n';
260
285
  }
286
+ /**
287
+ * Fetches remote data within the configured timeout, retries included.
288
+ * Returns `undefined` on timeout so that queued packages stay unscanned.
289
+ */
290
+ async #fetchRemoteData(queue) {
291
+ const signal = AbortSignal.timeout(this.#timeout);
292
+ try {
293
+ return await pRetry(() => this.#retrieveRemoteData(queue, signal), { retries: this.#retries, signal });
294
+ }
295
+ catch (err) {
296
+ if (!signal.aborted)
297
+ throw err;
298
+ this.timedOut = true;
299
+ // eslint-disable-next-line no-console
300
+ console.warn(`security-archive: timed out after ${this.#timeout}ms fetching security data; ${queue.size} package(s) left unscanned (use :scanned to exclude them). Set VLT_SECURITY_ARCHIVE_TIMEOUT=<ms> to wait longer.`);
301
+ }
302
+ }
261
303
  /**
262
304
  * Parses and load NDJSON data into the in-memory cache.
263
305
  * Returns a set of dep ids that were successfully loaded.
@@ -270,30 +312,25 @@ export class SecurityArchive extends LRUCache {
270
312
  if (!line.trim())
271
313
  continue;
272
314
  const data = JSON.parse(line + '}');
315
+ // skip the purlError and summary wrapper items the API may
316
+ // include in the stream, they do not describe a package
317
+ if (data._type)
318
+ continue;
273
319
  const scope = data.namespace ? `${data.namespace}/` : '';
274
320
  const name = `${scope}${data.name}`;
275
- const node = this.#retrieveNodeByNameVersion(name, data.version);
321
+ const node = this.#retrieveNodeByNameVersion(name, String(data.version));
276
322
  if (node) {
323
+ const score = getItemScore(data);
324
+ // a package that socket.dev has not finished analyzing yet
325
+ // comes back without a score, leave it out of the archive so
326
+ // that it gets requested again on the next run
327
+ if (!score)
328
+ continue;
277
329
  const normalizedId = baseDepID(node.id);
278
- fetchedDepIDs.add(baseDepID(node.id));
279
- // Calculate average score from all score components
280
- const scoreComponents = [
281
- data.score.license,
282
- data.score.maintenance,
283
- data.score.quality,
284
- data.score.supplyChain,
285
- data.score.vulnerability,
286
- ];
287
- const newAverageScore = Number((scoreComponents.reduce((sum, score) => sum + score, 0) /
288
- scoreComponents.length).toFixed(2));
289
- // Add average score to the score object
290
- const scoreWithNewAverage = {
291
- ...data.score,
292
- overall: newAverageScore,
293
- };
330
+ fetchedDepIDs.add(normalizedId);
294
331
  this.set(normalizedId, asPackageReportData({
295
332
  ...data,
296
- score: scoreWithNewAverage,
333
+ score,
297
334
  }));
298
335
  }
299
336
  else {
@@ -350,6 +387,7 @@ export class SecurityArchive extends LRUCache {
350
387
  async refresh({ nodes }) {
351
388
  // should start by clearing the current in-memory cache
352
389
  this.clear();
390
+ this.timedOut = false;
353
391
  const db = this.#openDatabase();
354
392
  // indexes nodes by name and id for quick lookup
355
393
  this.#nodesByName.clear();
@@ -367,10 +405,12 @@ export class SecurityArchive extends LRUCache {
367
405
  const queue = this.#queueUpRequiredPackages();
368
406
  // only reach for the remote API if there are packages queued up
369
407
  if (queue.size > 0) {
370
- // Parse the response data
371
- const res = await pRetry(() => this.#retrieveRemoteData(queue), { retries: this.#retries });
372
- const ids = this.#loadFromNDJSON(res);
373
- this.#storeNewItemsToDatabase(db, ids);
408
+ const res = await this.#fetchRemoteData(queue);
409
+ // on timeout nothing is loaded nor cached
410
+ if (res !== undefined) {
411
+ const ids = this.#loadFromNDJSON(res);
412
+ this.#storeNewItemsToDatabase(db, ids);
413
+ }
374
414
  }
375
415
  // validates the refresh process was successful
376
416
  this.#validateReportData();
package/dist/types.d.ts CHANGED
@@ -89,3 +89,30 @@ export type PackageReportData = {
89
89
  };
90
90
  export declare const isPackageReportData: (o: unknown) => o is PackageReportData;
91
91
  export declare const asPackageReportData: (o: unknown) => PackageReportData;
92
+ /**
93
+ * A single line of the NDJSON stream returned by the socket.dev
94
+ * batch purl API. Besides package artifacts, the stream can carry
95
+ * `purlError` and `summary` wrapper items, and a package that was
96
+ * published recently enough to not have been analyzed yet comes back
97
+ * as an artifact with no `score` object at all.
98
+ */
99
+ export type JSONItemResponse = {
100
+ _type?: 'purlError' | 'summary';
101
+ namespace?: `@${string}`;
102
+ name?: string;
103
+ version?: string;
104
+ score?: {
105
+ overall?: number;
106
+ license: number;
107
+ maintenance: number;
108
+ quality: number;
109
+ supplyChain: number;
110
+ vulnerability: number;
111
+ };
112
+ };
113
+ /**
114
+ * Returns the score of a response item with its `overall` value
115
+ * recalculated as the average of the individual components, or
116
+ * `undefined` when the item is not a scored package artifact.
117
+ */
118
+ export declare const getItemScore: (data: JSONItemResponse) => PackageScore | undefined;
package/dist/types.js CHANGED
@@ -27,3 +27,23 @@ export const asPackageReportData = (o) => {
27
27
  }
28
28
  return o;
29
29
  };
30
+ /**
31
+ * Returns the score of a response item with its `overall` value
32
+ * recalculated as the average of the individual components, or
33
+ * `undefined` when the item is not a scored package artifact.
34
+ */
35
+ export const getItemScore = (data) => {
36
+ if (data._type || !data.score)
37
+ return undefined;
38
+ const { license, maintenance, quality, supplyChain, vulnerability, } = data.score;
39
+ const scoreComponents = [
40
+ license,
41
+ maintenance,
42
+ quality,
43
+ supplyChain,
44
+ vulnerability,
45
+ ];
46
+ const overall = Number((scoreComponents.reduce((sum, score) => sum + score, 0) /
47
+ scoreComponents.length).toFixed(2));
48
+ return { ...data.score, overall };
49
+ };
@@ -9,6 +9,8 @@ export type UpdateExpiredPayload = {
9
9
  dbPath: string;
10
10
  /** Number of retries for fetching remote data */
11
11
  retries: number;
12
+ /** Max ms for the remote request, incl. retries */
13
+ timeout: number;
12
14
  /** TTL in ms to use for new entries */
13
15
  ttl: number;
14
16
  /** Expired entries to revalidate */
@@ -6,7 +6,7 @@ import pRetry, { AbortError } from 'p-retry';
6
6
  import { asDepID, baseDepID } from '@vltpkg/dep-id';
7
7
  import { error } from '@vltpkg/error-cause';
8
8
  import { userAgent } from '@vltpkg/user-agent';
9
- import { asPackageReportData } from "./types.js";
9
+ import { asPackageReportData, getItemScore } from "./types.js";
10
10
  export const __CODE_SPLIT_SCRIPT_NAME = import.meta.filename;
11
11
  const SOCKET_API_V0_URL = 'https://api.socket.dev/v0/purl?alerts=true';
12
12
  const SOCKET_PUBLIC_API_TOKEN = 'sktsec_t_--RAN5U4ivauy4w37-6aoKyYPDt5ZbaT5JBVMqiwKo_api';
@@ -17,7 +17,8 @@ const isMain = (path) => path === __CODE_SPLIT_SCRIPT_NAME ||
17
17
  /**
18
18
  * Fetches security information from the socket.dev API.
19
19
  */
20
- const retrieveRemoteData = async (queue, retries) => {
20
+ const retrieveRemoteData = async (queue, retries, timeout) => {
21
+ const signal = AbortSignal.timeout(timeout);
21
22
  return pRetry(async () => {
22
23
  const req = await fetch(SOCKET_API_V0_URL, {
23
24
  method: 'POST',
@@ -28,6 +29,7 @@ const retrieveRemoteData = async (queue, retries) => {
28
29
  body: JSON.stringify({
29
30
  components: Array.from(queue),
30
31
  }),
32
+ signal,
31
33
  });
32
34
  if (req.status === 404) {
33
35
  throw new AbortError('Missing API');
@@ -39,7 +41,7 @@ const retrieveRemoteData = async (queue, retries) => {
39
41
  }
40
42
  const str = await req.text();
41
43
  return str.trim() + '\n';
42
- }, { retries });
44
+ }, { retries, signal });
43
45
  };
44
46
  /**
45
47
  * Main entrypoint for the detached process.
@@ -68,7 +70,7 @@ export const main = async (input = process.stdin) => {
68
70
  /* c8 ignore next */
69
71
  input.on('error', reject);
70
72
  });
71
- const { dbPath, retries, ttl, expired } = payload;
73
+ const { dbPath, retries, timeout, ttl, expired } = payload;
72
74
  if (!expired.length) {
73
75
  return false;
74
76
  }
@@ -82,7 +84,7 @@ export const main = async (input = process.stdin) => {
82
84
  entryByNameVersion.set(`${entry.name}@${entry.version}`, entry);
83
85
  }
84
86
  // Fetch updated data
85
- const res = await retrieveRemoteData(expiredQueue, retries);
87
+ const res = await retrieveRemoteData(expiredQueue, retries, timeout);
86
88
  // Parse NDJSON response and collect results
87
89
  const now = Date.now();
88
90
  const results = [];
@@ -91,6 +93,10 @@ export const main = async (input = process.stdin) => {
91
93
  if (!line.trim())
92
94
  continue;
93
95
  const data = JSON.parse(line + '}');
96
+ // skip the purlError and summary wrapper items the API may
97
+ // include in the stream, they do not describe a package
98
+ if (data._type)
99
+ continue;
94
100
  const scope = data.namespace ? `${data.namespace}/` : '';
95
101
  const name = `${scope}${data.name}`;
96
102
  const key = `${name}@${data.version}`;
@@ -100,19 +106,15 @@ export const main = async (input = process.stdin) => {
100
106
  console.warn(`security-archive: failed to find entry for ${key} found in the response.`);
101
107
  continue;
102
108
  }
103
- // Calculate average score from all score components
104
- const scoreComponents = [
105
- data.score.license,
106
- data.score.maintenance,
107
- data.score.quality,
108
- data.score.supplyChain,
109
- data.score.vulnerability,
110
- ];
111
- const newAverageScore = Number((scoreComponents.reduce((sum, score) => sum + score, 0) /
112
- scoreComponents.length).toFixed(2));
109
+ const score = getItemScore(data);
110
+ // a package that socket.dev has not finished analyzing yet comes
111
+ // back without a score, keep the stale entry rather than replacing
112
+ // it with an invalid one
113
+ if (!score)
114
+ continue;
113
115
  const reportData = asPackageReportData({
114
116
  ...data,
115
- score: { ...data.score, overall: newAverageScore },
117
+ score,
116
118
  });
117
119
  results.push({
118
120
  depID: baseDepID(asDepID(entry.depID)),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vltpkg/security-archive",
3
3
  "description": "A database of security information of packages",
4
- "version": "1.3.8",
4
+ "version": "1.5.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/vltpkg/vltpkg.git",
@@ -13,12 +13,12 @@
13
13
  "url": "http://vlt.sh"
14
14
  },
15
15
  "dependencies": {
16
- "@vltpkg/dep-id": "1.3.8",
17
- "@vltpkg/error-cause": "1.3.8",
18
- "@vltpkg/spec": "1.3.8",
19
- "@vltpkg/types": "1.3.8",
20
- "@vltpkg/user-agent": "1.3.8",
21
- "@vltpkg/xdg": "1.3.8",
16
+ "@vltpkg/dep-id": "1.5.0",
17
+ "@vltpkg/error-cause": "1.5.0",
18
+ "@vltpkg/spec": "1.5.0",
19
+ "@vltpkg/types": "1.5.0",
20
+ "@vltpkg/user-agent": "1.5.0",
21
+ "@vltpkg/xdg": "1.5.0",
22
22
  "lru-cache": "^11.2.4",
23
23
  "p-retry": "^7.1.1"
24
24
  },