@loomhelix/deptrail 0.1.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.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +421 -0
  3. package/dist/actions/action-error.js +11 -0
  4. package/dist/actions/action-jobs.js +199 -0
  5. package/dist/actions/action-service.js +268 -0
  6. package/dist/actions/apply.js +120 -0
  7. package/dist/actions/availability.js +79 -0
  8. package/dist/actions/change-sets.js +117 -0
  9. package/dist/actions/dry-run.js +287 -0
  10. package/dist/actions/edge-moves.js +108 -0
  11. package/dist/actions/edit-plan.js +179 -0
  12. package/dist/actions/file-scope.js +118 -0
  13. package/dist/actions/fix-options.js +141 -0
  14. package/dist/actions/goal-check.js +80 -0
  15. package/dist/actions/install.js +27 -0
  16. package/dist/actions/job-registry.js +104 -0
  17. package/dist/actions/json-edit.js +189 -0
  18. package/dist/actions/labels.js +35 -0
  19. package/dist/actions/node-actions.js +102 -0
  20. package/dist/actions/overrides-location.js +59 -0
  21. package/dist/actions/parent-chain.js +210 -0
  22. package/dist/actions/range-style.js +38 -0
  23. package/dist/actions/recipes.js +31 -0
  24. package/dist/actions/root-files.js +30 -0
  25. package/dist/actions/sandbox.js +175 -0
  26. package/dist/actions/set-range.js +59 -0
  27. package/dist/actions/shared-packages.js +50 -0
  28. package/dist/actions/text-diff.js +203 -0
  29. package/dist/actions/types.js +1 -0
  30. package/dist/actions/update-options.js +232 -0
  31. package/dist/actions/workspace-edit.js +33 -0
  32. package/dist/adapters/command-runner.js +127 -0
  33. package/dist/adapters/manifest-only.js +20 -0
  34. package/dist/adapters/node-modules.js +148 -0
  35. package/dist/adapters/npm-lock.js +149 -0
  36. package/dist/adapters/package-manifest.js +57 -0
  37. package/dist/adapters/pnpm-lock.js +177 -0
  38. package/dist/adapters/resolve-today.js +93 -0
  39. package/dist/cli/cli.js +227 -0
  40. package/dist/cli/open-browser.js +14 -0
  41. package/dist/cli/package-version.js +6 -0
  42. package/dist/cli/usage-text.js +27 -0
  43. package/dist/config/glob.js +119 -0
  44. package/dist/config/npmrc.js +116 -0
  45. package/dist/config/root-config.js +103 -0
  46. package/dist/config/secrets.js +18 -0
  47. package/dist/discovery/find-roots.js +107 -0
  48. package/dist/enrich/credentials.js +143 -0
  49. package/dist/enrich/enrichment-cache.js +42 -0
  50. package/dist/enrich/enrichment-keys.js +71 -0
  51. package/dist/enrich/enrichment-service.js +399 -0
  52. package/dist/enrich/enrichment-status.js +86 -0
  53. package/dist/enrich/enrichment-store.js +143 -0
  54. package/dist/enrich/http.js +201 -0
  55. package/dist/enrich/on-demand.js +220 -0
  56. package/dist/enrich/osv-client.js +260 -0
  57. package/dist/enrich/registry-client.js +123 -0
  58. package/dist/enrich/types.js +1 -0
  59. package/dist/git/blob-reader.js +180 -0
  60. package/dist/git/git-commands.js +32 -0
  61. package/dist/git/git-process.js +71 -0
  62. package/dist/git/git-repository.js +136 -0
  63. package/dist/git/ref-list.js +78 -0
  64. package/dist/git/tree-index.js +87 -0
  65. package/dist/graph/global-index.js +19 -0
  66. package/dist/graph/root-graph-builder.js +114 -0
  67. package/dist/graph/serialize.js +19 -0
  68. package/dist/model.js +2 -0
  69. package/dist/query/children.js +38 -0
  70. package/dist/query/compare-shape.js +13 -0
  71. package/dist/query/compare.js +245 -0
  72. package/dist/query/decorations.js +121 -0
  73. package/dist/query/dependents.js +135 -0
  74. package/dist/query/duplicates.js +23 -0
  75. package/dist/query/enrichment-lookup.js +78 -0
  76. package/dist/query/fixes.js +60 -0
  77. package/dist/query/graph-diff.js +42 -0
  78. package/dist/query/issues.js +235 -0
  79. package/dist/query/lookup.js +49 -0
  80. package/dist/query/node-fix.js +46 -0
  81. package/dist/query/package-insight.js +112 -0
  82. package/dist/query/search.js +61 -0
  83. package/dist/query/subgraph.js +107 -0
  84. package/dist/query/versions.js +103 -0
  85. package/dist/scan/build-root.js +137 -0
  86. package/dist/scan/cache-shape.js +52 -0
  87. package/dist/scan/scan-cache.js +32 -0
  88. package/dist/scan/scan-session.js +127 -0
  89. package/dist/scan/source-registry.js +55 -0
  90. package/dist/server/auth.js +45 -0
  91. package/dist/server/branches.js +105 -0
  92. package/dist/server/error-log.js +9 -0
  93. package/dist/server/root-segment.js +13 -0
  94. package/dist/server/router.js +474 -0
  95. package/dist/server/server.js +166 -0
  96. package/dist/server/source-key.js +1 -0
  97. package/dist/server/static.js +59 -0
  98. package/dist/sources/file-reader.js +55 -0
  99. package/dist/sources/git-reader.js +45 -0
  100. package/dist/ui/assets/index-C4uyB5v3.css +1 -0
  101. package/dist/ui/assets/index-DYAG2LM-.js +51 -0
  102. package/dist/ui/index.html +15 -0
  103. package/package.json +72 -0
@@ -0,0 +1,201 @@
1
+ export class HttpError extends Error {
2
+ kind;
3
+ constructor(kind) {
4
+ super(`http request failed: ${kind}`);
5
+ this.name = 'HttpError';
6
+ this.kind = kind;
7
+ }
8
+ }
9
+ export const HTTP_DEFAULTS = {
10
+ timeoutMs: 30_000,
11
+ retryDelaysMs: [1000, 4000],
12
+ maxRetryAfterMs: 30_000,
13
+ maxBytes: 52_428_800,
14
+ concurrencyPerOrigin: 16,
15
+ maxRedirects: 5,
16
+ };
17
+ const REDIRECT_STATUSES = [301, 302, 303, 307, 308];
18
+ const RETRYABLE_STATUS = (status) => status === 429 || status >= 500;
19
+ const AUTHORIZATION_HEADER = /^authorization$/i;
20
+ function retryDelayMs(headers, attempt) {
21
+ const retryAfterSeconds = Number(headers.get('retry-after'));
22
+ const headerValid = headers.get('retry-after') !== null && Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0;
23
+ return headerValid
24
+ ? Math.min(retryAfterSeconds * 1000, HTTP_DEFAULTS.maxRetryAfterMs)
25
+ : HTTP_DEFAULTS.retryDelaysMs[attempt];
26
+ }
27
+ function nextHop(current, response) {
28
+ const location = response.headers.get('location');
29
+ if (!REDIRECT_STATUSES.includes(response.status) || location === null)
30
+ return undefined;
31
+ const url = new URL(location, current.url).href;
32
+ if (response.status === 303)
33
+ return { url, method: 'GET', body: undefined };
34
+ return { ...current, url };
35
+ }
36
+ export function createHttpClient(options) {
37
+ const fetchImpl = options.fetchImpl ?? fetch;
38
+ const sleep = options.sleep ?? sleepUnlessAborted;
39
+ const timeoutMs = options.timeoutMs ?? HTTP_DEFAULTS.timeoutMs;
40
+ const maxBytes = options.maxBytes ?? HTTP_DEFAULTS.maxBytes;
41
+ const concurrencyPerOrigin = options.concurrencyPerOrigin ?? HTTP_DEFAULTS.concurrencyPerOrigin;
42
+ const gates = new Map();
43
+ function throwIfAborted() {
44
+ if (options.signal?.aborted)
45
+ throw new HttpError('aborted');
46
+ }
47
+ function sleepUnlessAborted(ms) {
48
+ return new Promise((resolve) => {
49
+ const finish = () => {
50
+ clearTimeout(timer);
51
+ options.signal?.removeEventListener('abort', finish);
52
+ resolve();
53
+ };
54
+ const timer = setTimeout(finish, ms);
55
+ options.signal?.addEventListener('abort', finish);
56
+ if (options.signal?.aborted)
57
+ finish();
58
+ });
59
+ }
60
+ async function acquire(origin) {
61
+ throwIfAborted();
62
+ const gate = gates.get(origin) ?? { active: 0, waiting: [] };
63
+ gates.set(origin, gate);
64
+ if (gate.active >= concurrencyPerOrigin) {
65
+ await new Promise((resolve, reject) => {
66
+ const grant = () => {
67
+ options.signal?.removeEventListener('abort', giveUp);
68
+ resolve();
69
+ };
70
+ const giveUp = () => {
71
+ gate.waiting.splice(gate.waiting.indexOf(grant), 1);
72
+ reject(new HttpError('aborted'));
73
+ };
74
+ gate.waiting.push(grant);
75
+ options.signal?.addEventListener('abort', giveUp);
76
+ });
77
+ }
78
+ else
79
+ gate.active++;
80
+ return () => {
81
+ const next = gate.waiting.shift();
82
+ if (next)
83
+ next();
84
+ else
85
+ gate.active--;
86
+ };
87
+ }
88
+ async function readBodyWithin(response) {
89
+ const reader = response.body?.getReader();
90
+ if (!reader)
91
+ return '';
92
+ const chunks = [];
93
+ let total = 0;
94
+ for (;;) {
95
+ const { done, value } = await reader.read();
96
+ if (done)
97
+ break;
98
+ total += value.byteLength;
99
+ if (total > maxBytes) {
100
+ await reader.cancel();
101
+ throw new HttpError('too-large');
102
+ }
103
+ chunks.push(value);
104
+ }
105
+ return Buffer.concat(chunks).toString('utf8');
106
+ }
107
+ function headersFor(request, hop, withholdAuthorization) {
108
+ const headers = {};
109
+ for (const [name, value] of Object.entries(request.headers ?? {})) {
110
+ if (!AUTHORIZATION_HEADER.test(name))
111
+ headers[name] = value;
112
+ }
113
+ headers['User-Agent'] = options.userAgent;
114
+ const authorization = withholdAuthorization ? undefined : request.authorizationFor?.(hop.url);
115
+ if (authorization !== undefined)
116
+ headers['Authorization'] = authorization;
117
+ return headers;
118
+ }
119
+ async function attempt(request, hop, withholdAuthorization, lastAttempt) {
120
+ const release = await acquire(new URL(hop.url).origin);
121
+ const controller = new AbortController();
122
+ let timedOut = false;
123
+ const timer = setTimeout(() => {
124
+ timedOut = true;
125
+ controller.abort();
126
+ }, timeoutMs);
127
+ const abortFromOutside = () => controller.abort();
128
+ options.signal?.addEventListener('abort', abortFromOutside);
129
+ try {
130
+ throwIfAborted();
131
+ const response = await fetchImpl(hop.url, {
132
+ method: hop.method,
133
+ headers: headersFor(request, hop, withholdAuthorization),
134
+ body: hop.body,
135
+ redirect: 'manual',
136
+ signal: controller.signal,
137
+ });
138
+ const bodyIsIrrelevant = nextHop(hop, response) !== undefined || (RETRYABLE_STATUS(response.status) && !lastAttempt);
139
+ if (bodyIsIrrelevant) {
140
+ await response.body?.cancel();
141
+ return { response, text: '' };
142
+ }
143
+ return { response, text: await readBodyWithin(response) };
144
+ }
145
+ catch (error) {
146
+ if (error instanceof HttpError)
147
+ throw error;
148
+ if (options.signal?.aborted)
149
+ throw new HttpError('aborted');
150
+ throw new HttpError(timedOut ? 'timeout' : 'network');
151
+ }
152
+ finally {
153
+ release();
154
+ clearTimeout(timer);
155
+ options.signal?.removeEventListener('abort', abortFromOutside);
156
+ }
157
+ }
158
+ async function fetchHop(request, hop, withholdAuthorization) {
159
+ const retries = HTTP_DEFAULTS.retryDelaysMs.length;
160
+ for (let attemptIndex = 0;; attemptIndex++) {
161
+ const lastAttempt = attemptIndex === retries;
162
+ let outcome;
163
+ try {
164
+ outcome = await attempt(request, hop, withholdAuthorization, lastAttempt);
165
+ }
166
+ catch (error) {
167
+ if (!(error instanceof HttpError) || error.kind !== 'network' || lastAttempt)
168
+ throw error;
169
+ await sleep(HTTP_DEFAULTS.retryDelaysMs[attemptIndex]);
170
+ throwIfAborted();
171
+ continue;
172
+ }
173
+ const { response, text } = outcome;
174
+ if (RETRYABLE_STATUS(response.status) && !lastAttempt) {
175
+ await sleep(retryDelayMs(response.headers, attemptIndex));
176
+ throwIfAborted();
177
+ continue;
178
+ }
179
+ const redirectTo = nextHop(hop, response);
180
+ if (redirectTo !== undefined)
181
+ return { redirectTo };
182
+ return { response: { status: response.status, headers: response.headers, text } };
183
+ }
184
+ }
185
+ async function request(httpRequest) {
186
+ throwIfAborted();
187
+ let hop = { url: httpRequest.url, method: httpRequest.method ?? 'GET', body: httpRequest.body };
188
+ let httpsSeen = false;
189
+ for (let redirects = 0;; redirects++) {
190
+ const hopProtocol = new URL(hop.url).protocol;
191
+ const result = await fetchHop(httpRequest, hop, httpsSeen && hopProtocol === 'http:');
192
+ httpsSeen = httpsSeen || hopProtocol === 'https:';
193
+ if ('response' in result)
194
+ return result.response;
195
+ if (redirects === HTTP_DEFAULTS.maxRedirects)
196
+ throw new HttpError('redirects');
197
+ hop = result.redirectTo;
198
+ }
199
+ }
200
+ return { request };
201
+ }
@@ -0,0 +1,220 @@
1
+ import { advisoriesOfMatch, worstSeverity } from "../query/enrichment-lookup.js";
2
+ import { isFresh } from "./enrichment-cache.js";
3
+ import { hasKnownVersion, inPrivateScope, registryKeyOf, registryUrlOf } from "./enrichment-keys.js";
4
+ import { ADVISORIES_VIEWS, advisoriesCover, isCachedAdvisories, isCachedMatch, isCachedRegistry, registryOk, } from "./enrichment-store.js";
5
+ import { queryPackageAdvisories, queryVersions, versionKey } from "./osv-client.js";
6
+ import { failureReason, fetchPackageMeta } from "./registry-client.js";
7
+ const OFFLINE_REASON = 'deptrail was started with --offline';
8
+ const NOT_SCANNED_REASON = 'the root is not scanned';
9
+ const NOT_FOUND_REASON = 'not found in the registry';
10
+ const NOT_MODIFIED_WITHOUT_VALIDATOR = 'responded with HTTP 304';
11
+ const MISSING_OSV_RESULT = 'returned a malformed response';
12
+ const PRIVATE_SCOPE_NOTE = 'private scope';
13
+ const UNKNOWN_VERSION_NOTE = 'unknown version';
14
+ const OSV_LABEL = 'OSV';
15
+ const PENDING = { state: 'pending' };
16
+ function isConfigured(root) {
17
+ return root.config !== undefined;
18
+ }
19
+ function isMatched(state) {
20
+ return state?.state === 'ok' || state?.state === 'stale';
21
+ }
22
+ function publishedOf(record) {
23
+ return record.state === 'ok' ? { state: 'ok', meta: record.meta } : { state: 'failed', reason: NOT_FOUND_REASON };
24
+ }
25
+ function failedOrExpired(reason, expired) {
26
+ return expired?.state === 'ok' ? { state: 'ok', meta: expired.meta } : { state: 'failed', reason };
27
+ }
28
+ function distinctQueries(packages) {
29
+ const byKey = new Map();
30
+ for (const { name, version } of packages) {
31
+ const key = versionKey(name, version);
32
+ if (!byKey.has(key))
33
+ byKey.set(key, { name, version });
34
+ }
35
+ return [...byKey.values()];
36
+ }
37
+ function skipNoteOf(target, privateScopes) {
38
+ if (inPrivateScope(target.name, privateScopes))
39
+ return PRIVATE_SCOPE_NOTE;
40
+ return hasKnownVersion(target.version) ? undefined : UNKNOWN_VERSION_NOTE;
41
+ }
42
+ function failedBatch(queries, reason) {
43
+ return { matches: new Map(), failed: new Map(queries.map(({ name, version }) => [versionKey(name, version), reason])) };
44
+ }
45
+ function reportedByName(targets, matches) {
46
+ const reported = new Map();
47
+ for (const { name, version } of targets) {
48
+ const match = matches.get(versionKey(name, version));
49
+ if (!isMatched(match) || match.vulns.length === 0)
50
+ continue;
51
+ reported.set(name, [...(reported.get(name) ?? []), ...match.vulns]);
52
+ }
53
+ return reported;
54
+ }
55
+ function unmatchedNote(match) {
56
+ return `${OSV_LABEL} ${match?.state === 'failed' ? match.reason : MISSING_OSV_RESULT}`;
57
+ }
58
+ function vulnerabilityFields(target, findings) {
59
+ const match = findings.matches.get(versionKey(target.name, target.version));
60
+ if (!isMatched(match))
61
+ return { vulnIds: [], notChecked: unmatchedNote(match) };
62
+ const advisories = advisoriesOfMatch(findings.view, target.name, match);
63
+ const severity = worstSeverity(advisories.map((advisory) => advisory.severity));
64
+ return { vulnIds: advisories.map((advisory) => advisory.id), ...(severity === undefined ? {} : { severity }) };
65
+ }
66
+ export function createOnDemandLookups(dependencies) {
67
+ const { http, osvUrl, offline, cache, now } = dependencies;
68
+ const versionsRecords = new Map();
69
+ const versionsLoads = new Map();
70
+ const nowIso = () => new Date(now()).toISOString();
71
+ const isFreshNow = (fetchedAt) => isFresh(fetchedAt, now());
72
+ async function fetchVersionsRecord(target, known) {
73
+ const validator = known?.state === 'ok' ? known : undefined;
74
+ const { authorizationFor } = await dependencies.credentialsOf(target.root);
75
+ const result = await dependencies.registryLimiterOf(target.registryUrl)(() => fetchPackageMeta(http, {
76
+ registry: target.registryUrl,
77
+ name: target.name,
78
+ etag: validator?.etag,
79
+ versionsInUse: [],
80
+ allManifests: true,
81
+ authorizationFor,
82
+ }));
83
+ const fetchedAt = nowIso();
84
+ if (result.state === 'ok')
85
+ return { record: registryOk(result.meta, fetchedAt, result.etag) };
86
+ if (result.state === 'not-found')
87
+ return { record: { state: 'not-found', fetchedAt } };
88
+ if (result.state === 'not-modified' && validator !== undefined) {
89
+ return { record: registryOk(validator.meta, fetchedAt, validator.etag) };
90
+ }
91
+ return { failure: result.state === 'failed' ? result.reason : NOT_MODIFIED_WITHOUT_VALIDATOR };
92
+ }
93
+ async function loadVersions(target) {
94
+ const keyParts = [target.registryUrl, target.name];
95
+ let known = versionsRecords.get(target.key);
96
+ try {
97
+ const cached = await cache.read('registry-versions', keyParts, isCachedRegistry);
98
+ if (cached !== undefined && isFreshNow(cached.fetchedAt)) {
99
+ versionsRecords.set(target.key, cached);
100
+ return publishedOf(cached);
101
+ }
102
+ known = cached ?? known;
103
+ const fetched = await fetchVersionsRecord(target, known);
104
+ if ('failure' in fetched)
105
+ return failedOrExpired(fetched.failure, known);
106
+ versionsRecords.set(target.key, fetched.record);
107
+ await cache.write('registry-versions', keyParts, fetched.record);
108
+ return publishedOf(fetched.record);
109
+ }
110
+ catch (error) {
111
+ return failedOrExpired(failureReason(error), known);
112
+ }
113
+ }
114
+ async function publishedVersions(root, name) {
115
+ if (offline)
116
+ return { state: 'failed', reason: OFFLINE_REASON };
117
+ if (!isConfigured(root))
118
+ return { state: 'failed', reason: NOT_SCANNED_REASON };
119
+ const registryUrl = registryUrlOf(root.config, name);
120
+ const key = registryKeyOf(registryUrl, name);
121
+ const remembered = versionsRecords.get(key);
122
+ if (remembered !== undefined && isFreshNow(remembered.fetchedAt))
123
+ return publishedOf(remembered);
124
+ const loading = versionsLoads.get(key);
125
+ if (loading !== undefined)
126
+ return loading;
127
+ const load = loadVersions({ key, registryUrl, name, root }).finally(() => versionsLoads.delete(key));
128
+ versionsLoads.set(key, load);
129
+ return load;
130
+ }
131
+ async function queryMatches(unresolved, matches) {
132
+ if (unresolved.length === 0)
133
+ return;
134
+ const outcome = await queryVersions(http, osvUrl, unresolved).catch((error) => failedBatch(unresolved, failureReason(error)));
135
+ const fetchedAt = nowIso();
136
+ const writes = unresolved.map(({ name, version }) => {
137
+ const key = versionKey(name, version);
138
+ const vulns = outcome.matches.get(key);
139
+ if (vulns === undefined) {
140
+ matches.set(key, { state: 'failed', reason: outcome.failed.get(key) ?? MISSING_OSV_RESULT });
141
+ return undefined;
142
+ }
143
+ const record = { state: 'ok', vulns, fetchedAt };
144
+ matches.set(key, record);
145
+ return cache.write('osv-matches', [key], record);
146
+ });
147
+ await Promise.all(writes);
148
+ }
149
+ async function matchesOf(targets) {
150
+ const matches = new Map();
151
+ await Promise.all(targets.map(async ({ name, version }) => {
152
+ const key = versionKey(name, version);
153
+ const stored = dependencies.storedMatchOf(key);
154
+ if (isMatched(stored)) {
155
+ matches.set(key, stored);
156
+ return;
157
+ }
158
+ const cached = await cache.read('osv-matches', [key], isCachedMatch);
159
+ if (cached !== undefined && isFreshNow(cached.fetchedAt))
160
+ matches.set(key, cached);
161
+ }));
162
+ await queryMatches(targets.filter(({ name, version }) => !matches.has(versionKey(name, version))), matches);
163
+ return matches;
164
+ }
165
+ async function advisoriesOf(name, reported) {
166
+ const stored = dependencies.storedAdvisoriesOf(name);
167
+ if ((stored?.state === 'ok' || stored?.state === 'stale') && advisoriesCover(stored.advisories, reported))
168
+ return stored;
169
+ const cached = await cache.read('osv-packages', [name], isCachedAdvisories);
170
+ if (cached !== undefined && isFreshNow(cached.fetchedAt) && advisoriesCover(cached.advisories, reported)) {
171
+ return ADVISORIES_VIEWS.ofSuccess(cached);
172
+ }
173
+ const result = await dependencies
174
+ .limitPackageQueries(() => queryPackageAdvisories(http, osvUrl, name))
175
+ .catch((error) => ({ state: 'failed', reason: failureReason(error) }));
176
+ if (result.state === 'failed')
177
+ return ADVISORIES_VIEWS.ofFailure(result.reason, undefined);
178
+ const record = { fetchedAt: nowIso(), advisories: result.advisories };
179
+ await cache.write('osv-packages', [name], record);
180
+ return ADVISORIES_VIEWS.ofSuccess(record);
181
+ }
182
+ async function findingsOf(targets) {
183
+ const matches = await matchesOf(targets);
184
+ const loaded = await Promise.all([...reportedByName(targets, matches)].map(async ([name, reported]) => [name, await advisoriesOf(name, reported)]));
185
+ const advisoriesByName = new Map(loaded);
186
+ return { matches, view: { advisoriesOf: (name) => advisoriesByName.get(name) ?? PENDING } };
187
+ }
188
+ async function deprecationsOf(root, targets) {
189
+ const known = targets.filter(({ version }) => hasKnownVersion(version));
190
+ const names = [...new Set(known.map(({ name }) => name))];
191
+ const published = new Map(await Promise.all(names.map(async (name) => [name, await publishedVersions(root, name)])));
192
+ const deprecations = new Map();
193
+ for (const { name, version } of known) {
194
+ const loaded = published.get(name);
195
+ const message = loaded?.state === 'ok' ? loaded.meta.deprecated[version] : undefined;
196
+ if (message !== undefined)
197
+ deprecations.set(versionKey(name, version), message);
198
+ }
199
+ return deprecations;
200
+ }
201
+ async function assess(root, packages) {
202
+ const targets = distinctQueries(packages);
203
+ const privateScopes = new Set([...dependencies.privateScopes(), ...Object.keys(root.config?.scopedRegistries ?? {})]);
204
+ if (offline) {
205
+ return targets.map((target) => ({ ...target, vulnIds: [], notChecked: skipNoteOf(target, privateScopes) ?? OFFLINE_REASON }));
206
+ }
207
+ const checked = targets.filter((target) => skipNoteOf(target, privateScopes) === undefined);
208
+ const [findings, deprecations] = await Promise.all([findingsOf(checked), deprecationsOf(root, targets)]);
209
+ return targets.map((target) => {
210
+ const skipNote = skipNoteOf(target, privateScopes);
211
+ const deprecated = deprecations.get(versionKey(target.name, target.version));
212
+ return {
213
+ ...target,
214
+ ...(skipNote === undefined ? vulnerabilityFields(target, findings) : { vulnIds: [], notChecked: skipNote }),
215
+ ...(deprecated === undefined ? {} : { deprecated }),
216
+ };
217
+ });
218
+ }
219
+ return { publishedVersions, assess };
220
+ }
@@ -0,0 +1,260 @@
1
+ import semver from 'semver';
2
+ import { failureReason } from "./registry-client.js";
3
+ export const OSV_DEFAULT_URL = 'https://api.osv.dev';
4
+ export const OSV_BATCH_SIZE = 1000;
5
+ export const OSV_PARALLEL_BATCHES = 2;
6
+ export const OSV_MAX_PAGES = 50;
7
+ const ECOSYSTEM = 'npm';
8
+ const MALFORMED_REASON = 'returned a malformed response';
9
+ const TOO_MANY_PAGES_REASON = 'returned too many pages';
10
+ const ADVISORY_URL_PREFIX = 'https://osv.dev/vulnerability/';
11
+ const MALICIOUS_ID_PREFIX = 'MAL-';
12
+ const GITHUB_ID_PREFIX = 'GHSA-';
13
+ const SUPPORTED_RANGE_TYPES = ['SEMVER', 'ECOSYSTEM'];
14
+ const CVSS_TYPES_BY_PREFERENCE = ['CVSS_V4', 'CVSS_V3', 'CVSS_V2'];
15
+ const SEVERITY_BY_LABEL = new Map([
16
+ ['LOW', 'low'],
17
+ ['MODERATE', 'moderate'],
18
+ ['MEDIUM', 'moderate'],
19
+ ['HIGH', 'high'],
20
+ ['CRITICAL', 'critical'],
21
+ ]);
22
+ export function versionKey(name, version) {
23
+ return `${name}@${version}`;
24
+ }
25
+ function isPlainObject(value) {
26
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
27
+ }
28
+ function stringsOf(value) {
29
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === 'string') : [];
30
+ }
31
+ function parseJson(text) {
32
+ try {
33
+ return JSON.parse(text);
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ function chunk(items, size) {
40
+ const chunks = [];
41
+ for (let start = 0; start < items.length; start += size)
42
+ chunks.push(items.slice(start, start + size));
43
+ return chunks;
44
+ }
45
+ async function postJson(http, osvUrl, path, payload) {
46
+ let response;
47
+ try {
48
+ response = await http.request({
49
+ url: `${osvUrl.replace(/\/+$/, '')}${path}`,
50
+ method: 'POST',
51
+ headers: { 'Content-Type': 'application/json' },
52
+ body: JSON.stringify(payload),
53
+ });
54
+ }
55
+ catch (error) {
56
+ return { ok: false, reason: failureReason(error) };
57
+ }
58
+ if (response.status < 200 || response.status >= 300) {
59
+ return { ok: false, reason: `responded with HTTP ${response.status}` };
60
+ }
61
+ const body = parseJson(response.text);
62
+ return isPlainObject(body) ? { ok: true, body } : { ok: false, reason: MALFORMED_REASON };
63
+ }
64
+ function toVulnRef(value) {
65
+ if (!isPlainObject(value) || typeof value.id !== 'string')
66
+ return undefined;
67
+ return { id: value.id, modified: typeof value.modified === 'string' ? value.modified : '' };
68
+ }
69
+ function nextPageTokenOf(value) {
70
+ return isPlainObject(value) && typeof value.next_page_token === 'string' && value.next_page_token !== ''
71
+ ? value.next_page_token
72
+ : undefined;
73
+ }
74
+ function batchPayload(pending) {
75
+ return {
76
+ queries: pending.map(({ query, pageToken }) => ({
77
+ package: { name: query.name, ecosystem: ECOSYSTEM },
78
+ version: query.version,
79
+ ...(pageToken === undefined ? {} : { page_token: pageToken }),
80
+ })),
81
+ };
82
+ }
83
+ async function resolveChunk(http, osvUrl, chunkQueries) {
84
+ const matches = new Map(chunkQueries.map(({ key }) => [key, []]));
85
+ const sentTokens = new Map();
86
+ let pending = chunkQueries;
87
+ let followUpPages = 0;
88
+ while (pending.length > 0) {
89
+ const page = await postJson(http, osvUrl, '/v1/querybatch', batchPayload(pending));
90
+ const results = page.ok && isPlainObject(page.body) ? page.body.results : undefined;
91
+ if (!page.ok || !Array.isArray(results) || results.length !== pending.length) {
92
+ const reason = page.ok ? MALFORMED_REASON : page.reason;
93
+ return { matches: new Map(), failed: new Map(chunkQueries.map(({ key }) => [key, reason])) };
94
+ }
95
+ const following = [];
96
+ let repeatedToken = false;
97
+ pending.forEach((entry, index) => {
98
+ const result = results[index];
99
+ const vulns = isPlainObject(result) && Array.isArray(result.vulns) ? result.vulns : [];
100
+ matches.get(entry.key)?.push(...vulns.map(toVulnRef).filter((ref) => ref !== undefined));
101
+ const pageToken = nextPageTokenOf(result);
102
+ if (pageToken === undefined)
103
+ return;
104
+ const tokens = sentTokens.get(entry.key) ?? new Set();
105
+ sentTokens.set(entry.key, tokens);
106
+ if (tokens.has(pageToken))
107
+ repeatedToken = true;
108
+ tokens.add(pageToken);
109
+ following.push({ ...entry, pageToken });
110
+ });
111
+ followUpPages += following.length > 0 ? 1 : 0;
112
+ if (repeatedToken || followUpPages > OSV_MAX_PAGES) {
113
+ return { matches: new Map(), failed: new Map(chunkQueries.map(({ key }) => [key, TOO_MANY_PAGES_REASON])) };
114
+ }
115
+ pending = following;
116
+ }
117
+ return { matches, failed: new Map() };
118
+ }
119
+ export async function queryVersions(http, osvUrl, queries) {
120
+ const uniqueQueries = new Map();
121
+ for (const query of queries) {
122
+ const key = versionKey(query.name, query.version);
123
+ if (!uniqueQueries.has(key))
124
+ uniqueQueries.set(key, { key, query });
125
+ }
126
+ const chunks = chunk([...uniqueQueries.values()], OSV_BATCH_SIZE);
127
+ const outcome = { matches: new Map(), failed: new Map() };
128
+ let nextChunk = 0;
129
+ async function worker() {
130
+ while (nextChunk < chunks.length) {
131
+ const chunkOutcome = await resolveChunk(http, osvUrl, chunks[nextChunk++]);
132
+ for (const [key, refs] of chunkOutcome.matches)
133
+ outcome.matches.set(key, refs);
134
+ for (const [key, reason] of chunkOutcome.failed)
135
+ outcome.failed.set(key, reason);
136
+ }
137
+ }
138
+ await Promise.all(Array.from({ length: Math.min(OSV_PARALLEL_BATCHES, chunks.length) }, worker));
139
+ return outcome;
140
+ }
141
+ export function severityOf(record) {
142
+ if (typeof record.id === 'string' && record.id.startsWith(MALICIOUS_ID_PREFIX)) {
143
+ return { severity: 'critical', malicious: true };
144
+ }
145
+ const label = isPlainObject(record.database_specific) ? record.database_specific.severity : undefined;
146
+ const severity = typeof label === 'string' ? SEVERITY_BY_LABEL.get(label) : undefined;
147
+ return { severity: severity ?? 'unknown', malicious: false };
148
+ }
149
+ function cvssOf(record) {
150
+ const entries = Array.isArray(record.severity) ? record.severity.filter(isPlainObject) : [];
151
+ for (const type of CVSS_TYPES_BY_PREFERENCE) {
152
+ const match = entries.find((entry) => entry.type === type && typeof entry.score === 'string');
153
+ if (match !== undefined)
154
+ return match.score;
155
+ }
156
+ return undefined;
157
+ }
158
+ function toRangeEvent(value) {
159
+ if (!isPlainObject(value))
160
+ return undefined;
161
+ if (typeof value.introduced === 'string')
162
+ return { introduced: value.introduced };
163
+ if (typeof value.fixed === 'string')
164
+ return { fixed: value.fixed };
165
+ if (typeof value.last_affected === 'string')
166
+ return { last_affected: value.last_affected };
167
+ return undefined;
168
+ }
169
+ function toAffectedEntry(value, name) {
170
+ if (!isPlainObject(value) || !isPlainObject(value.package))
171
+ return undefined;
172
+ if (value.package.ecosystem !== ECOSYSTEM || value.package.name !== name)
173
+ return undefined;
174
+ const ranges = (Array.isArray(value.ranges) ? value.ranges : [])
175
+ .filter(isPlainObject)
176
+ .filter((range) => typeof range.type === 'string' && SUPPORTED_RANGE_TYPES.includes(range.type))
177
+ .map((range) => ({
178
+ events: (Array.isArray(range.events) ? range.events : [])
179
+ .map(toRangeEvent)
180
+ .filter((event) => event !== undefined),
181
+ }));
182
+ return { versions: stringsOf(value.versions), ranges };
183
+ }
184
+ function fixedVersionsOf(affected) {
185
+ const fixed = new Set();
186
+ for (const entry of affected) {
187
+ for (const range of entry.ranges) {
188
+ for (const event of range.events) {
189
+ if ('fixed' in event && semver.valid(event.fixed) !== null)
190
+ fixed.add(event.fixed);
191
+ }
192
+ }
193
+ }
194
+ return [...fixed].sort(semver.compare);
195
+ }
196
+ export function toAdvisory(record, name) {
197
+ if (!isPlainObject(record) || typeof record.id !== 'string' || record.id === '')
198
+ return null;
199
+ if (record.withdrawn !== undefined && record.withdrawn !== null)
200
+ return null;
201
+ const affected = (Array.isArray(record.affected) ? record.affected : [])
202
+ .map((entry) => toAffectedEntry(entry, name))
203
+ .filter((entry) => entry !== undefined);
204
+ const cvss = cvssOf(record);
205
+ return {
206
+ id: record.id,
207
+ aliases: stringsOf(record.aliases),
208
+ summary: typeof record.summary === 'string' ? record.summary : '',
209
+ ...severityOf(record),
210
+ url: `${ADVISORY_URL_PREFIX}${record.id}`,
211
+ fixedIn: fixedVersionsOf(affected),
212
+ ...(cvss === undefined ? {} : { cvss }),
213
+ modified: typeof record.modified === 'string' ? record.modified : '',
214
+ affected,
215
+ };
216
+ }
217
+ function areSameVulnerability(first, second) {
218
+ return first.aliases.includes(second.id) || second.aliases.includes(first.id);
219
+ }
220
+ export function dedupeAdvisories(advisories) {
221
+ const kept = [];
222
+ for (const candidate of advisories) {
223
+ const duplicateIndex = kept.findIndex((existing) => areSameVulnerability(existing, candidate));
224
+ if (duplicateIndex === -1) {
225
+ kept.push(candidate);
226
+ continue;
227
+ }
228
+ const candidateIsGithub = candidate.id.startsWith(GITHUB_ID_PREFIX);
229
+ const existingIsGithub = kept[duplicateIndex].id.startsWith(GITHUB_ID_PREFIX);
230
+ if (candidateIsGithub && !existingIsGithub)
231
+ kept[duplicateIndex] = candidate;
232
+ }
233
+ return kept;
234
+ }
235
+ export async function queryPackageAdvisories(http, osvUrl, name) {
236
+ const advisories = [];
237
+ const sentTokens = new Set();
238
+ let pageToken;
239
+ let pagesFetched = 0;
240
+ do {
241
+ const payload = { package: { name, ecosystem: ECOSYSTEM }, ...(pageToken === undefined ? {} : { page_token: pageToken }) };
242
+ const page = await postJson(http, osvUrl, '/v1/query', payload);
243
+ if (!page.ok)
244
+ return { state: 'failed', reason: page.reason };
245
+ const body = page.body;
246
+ for (const record of Array.isArray(body.vulns) ? body.vulns : []) {
247
+ const converted = toAdvisory(record, name);
248
+ if (converted !== null)
249
+ advisories.push(converted);
250
+ }
251
+ pageToken = nextPageTokenOf(body);
252
+ pagesFetched += 1;
253
+ if (pageToken !== undefined && (sentTokens.has(pageToken) || pagesFetched > OSV_MAX_PAGES)) {
254
+ return { state: 'failed', reason: TOO_MANY_PAGES_REASON };
255
+ }
256
+ if (pageToken !== undefined)
257
+ sentTokens.add(pageToken);
258
+ } while (pageToken !== undefined);
259
+ return { state: 'ok', advisories: dedupeAdvisories(advisories) };
260
+ }