@pnpm/deps.compliance.audit 1002.0.14 → 1101.0.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/lib/index.d.ts CHANGED
@@ -1,22 +1,23 @@
1
1
  import { PnpmError } from '@pnpm/error';
2
2
  import type { GetAuthHeader } from '@pnpm/fetching.types';
3
3
  import type { EnvLockfile, LockfileObject } from '@pnpm/lockfile.types';
4
- import { type AgentOptions, type RetryTimeoutOptions } from '@pnpm/network.fetch';
4
+ import { type DispatcherOptions, type RetryTimeoutOptions } from '@pnpm/network.fetch';
5
5
  import type { DependenciesField } from '@pnpm/types';
6
6
  import type { AuditReport } from './types.js';
7
+ export type { AuditIndexRequest, AuditPathIndex, PathInfo } from './lockfileToAuditIndex.js';
8
+ export { buildAuditPathIndex, lockfileToAuditRequest } from './lockfileToAuditIndex.js';
7
9
  export * from './types.js';
8
10
  export declare function audit(lockfile: LockfileObject, getAuthHeader: GetAuthHeader, opts: {
9
- agentOptions?: AgentOptions;
11
+ dispatcherOptions?: DispatcherOptions;
10
12
  envLockfile?: EnvLockfile | null;
11
13
  include?: {
12
14
  [dependenciesField in DependenciesField]: boolean;
13
15
  };
14
- lockfileDir: string;
15
16
  registry: string;
16
17
  retry?: RetryTimeoutOptions;
17
18
  timeout?: number;
18
- virtualStoreDirMaxLength: number;
19
19
  }): Promise<AuditReport>;
20
+ export declare function normalizeGhsaId(ghsaId: string): string;
20
21
  export declare class AuditEndpointNotExistsError extends PnpmError {
21
22
  constructor(endpoint: string);
22
23
  }
package/lib/index.js CHANGED
@@ -1,38 +1,174 @@
1
1
  import { PnpmError } from '@pnpm/error';
2
- import { fetchWithAgent } from '@pnpm/network.fetch';
3
- import { lockfileToAuditTree } from './lockfileToAuditTree.js';
2
+ import { detectDepTypes } from '@pnpm/lockfile.detect-dep-types';
3
+ import { fetchWithDispatcher } from '@pnpm/network.fetch';
4
+ import semver from 'semver';
5
+ import { buildAuditPathIndex, collectOptionalOnlyDepPaths, lockfileToAuditRequest, } from './lockfileToAuditIndex.js';
6
+ export { buildAuditPathIndex, lockfileToAuditRequest } from './lockfileToAuditIndex.js';
4
7
  export * from './types.js';
5
8
  export async function audit(lockfile, getAuthHeader, opts) {
6
- const auditTree = await lockfileToAuditTree(lockfile, { envLockfile: opts.envLockfile, include: opts.include, lockfileDir: opts.lockfileDir });
9
+ const depTypes = detectDepTypes(lockfile);
10
+ const optionalOnly = collectOptionalOnlyDepPaths(lockfile, opts.include);
11
+ const auditRequest = lockfileToAuditRequest(lockfile, { envLockfile: opts.envLockfile, include: opts.include, depTypes, optionalOnly });
7
12
  const registry = opts.registry.endsWith('/') ? opts.registry : `${opts.registry}/`;
8
- const auditUrl = `${registry}-/npm/v1/security/audits`;
9
- const quickAuditUrl = `${registry}-/npm/v1/security/audits/quick`;
13
+ const auditUrl = `${registry}-/npm/v1/security/advisories/bulk`;
10
14
  const authHeaderValue = getAuthHeader(registry);
11
- const requestBody = JSON.stringify(auditTree);
12
15
  const requestHeaders = {
13
16
  'Content-Type': 'application/json',
14
17
  ...getAuthHeaders(authHeaderValue),
15
18
  };
16
- const requestOptions = {
17
- agentOptions: opts.agentOptions ?? {},
18
- body: requestBody,
19
+ const res = await fetchWithDispatcher(auditUrl, {
20
+ dispatcherOptions: opts.dispatcherOptions ?? {},
21
+ body: JSON.stringify(auditRequest.request),
19
22
  headers: requestHeaders,
20
23
  method: 'POST',
21
24
  retry: opts.retry,
22
25
  timeout: opts.timeout,
26
+ });
27
+ if (res.status === 200) {
28
+ const rawBody = await res.text();
29
+ let body;
30
+ try {
31
+ body = JSON.parse(rawBody);
32
+ }
33
+ catch (err) {
34
+ const reason = err instanceof Error ? err.message : String(err);
35
+ throw new PnpmError('AUDIT_BAD_RESPONSE', `The audit endpoint (at ${auditUrl}) returned invalid JSON: ${reason}. Response body: ${rawBody.slice(0, 500)}`);
36
+ }
37
+ if (!isBulkResponseShape(body)) {
38
+ throw new PnpmError('AUDIT_BAD_RESPONSE', `The audit endpoint (at ${auditUrl}) returned an unexpected body. Expected an object keyed by package name; got: ${JSON.stringify(body)?.slice(0, 500) ?? String(body)}`);
39
+ }
40
+ const vulnerableNames = new Set(Object.keys(body));
41
+ let auditPathIndex = {};
42
+ if (vulnerableNames.size > 0) {
43
+ auditPathIndex = buildAuditPathIndex(lockfile, vulnerableNames, { envLockfile: opts.envLockfile, include: opts.include, depTypes, optionalOnly });
44
+ }
45
+ return bulkResponseToAuditReport(body, auditRequest, auditPathIndex);
46
+ }
47
+ if (res.status === 404) {
48
+ throw new AuditEndpointNotExistsError(auditUrl);
49
+ }
50
+ throw new PnpmError('AUDIT_BAD_RESPONSE', `The audit endpoint (at ${auditUrl}) responded with ${res.status}: ${await res.text()}`);
51
+ }
52
+ function bulkResponseToAuditReport(bulk, auditRequest, auditPathIndex) {
53
+ // Null-prototype map — the id comes from the registry and could be anything.
54
+ const advisories = Object.create(null);
55
+ const vulnerabilities = { info: 0, low: 0, moderate: 0, high: 0, critical: 0 };
56
+ for (const [moduleName, packageAdvisories] of Object.entries(bulk)) {
57
+ const byVersion = auditPathIndex[moduleName];
58
+ for (const adv of packageAdvisories) {
59
+ // Guard against registry-supplied values that could corrupt the report:
60
+ // only accept finite numeric ids and severities from the known set.
61
+ if (typeof adv.id !== 'number' || !Number.isFinite(adv.id))
62
+ continue;
63
+ if (!isKnownSeverity(adv.severity))
64
+ continue;
65
+ const findings = buildFindings(adv, byVersion);
66
+ // If no installed version is vulnerable, skip the advisory entirely so
67
+ // we don't report false positives for packages the lockfile doesn't use.
68
+ if (findings.length === 0)
69
+ continue;
70
+ advisories[String(adv.id)] = normalizeAdvisory(adv, moduleName, findings);
71
+ // npm's audit report counts one vulnerability per advisory in the metadata summary
72
+ // when using the bulk endpoint format pnpm expects.
73
+ vulnerabilities[adv.severity] += 1;
74
+ }
75
+ }
76
+ return {
77
+ advisories,
78
+ metadata: {
79
+ vulnerabilities,
80
+ dependencies: auditRequest.dependencies,
81
+ devDependencies: auditRequest.devDependencies,
82
+ optionalDependencies: auditRequest.optionalDependencies,
83
+ totalDependencies: auditRequest.totalDependencies,
84
+ },
23
85
  };
24
- const quickRes = await fetchWithAgent(quickAuditUrl, requestOptions);
25
- if (quickRes.status === 200) {
26
- return quickRes.json();
86
+ }
87
+ function buildFindings(adv, byVersion) {
88
+ if (byVersion == null)
89
+ return [];
90
+ const findings = [];
91
+ for (const [version, info] of byVersion) {
92
+ if (satisfiesSafe(version, adv.vulnerable_versions)) {
93
+ findings.push({
94
+ version,
95
+ paths: info.paths,
96
+ dev: info.dev,
97
+ optional: info.optional,
98
+ bundled: false,
99
+ });
100
+ }
27
101
  }
28
- const res = await fetchWithAgent(auditUrl, requestOptions);
29
- if (res.status === 200) {
30
- return res.json();
102
+ return findings;
103
+ }
104
+ const KNOWN_SEVERITIES = new Set(['info', 'low', 'moderate', 'high', 'critical']);
105
+ function isKnownSeverity(severity) {
106
+ return typeof severity === 'string' && KNOWN_SEVERITIES.has(severity);
107
+ }
108
+ function isBulkResponseShape(body) {
109
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
110
+ return false;
111
+ // Every value must be an array of advisory objects; a null or scalar value
112
+ // would crash `for (const adv of packageAdvisories)` downstream.
113
+ return Object.values(body).every((packageAdvisories) => Array.isArray(packageAdvisories) && packageAdvisories.every((advisory) => typeof advisory === 'object' && advisory !== null && !Array.isArray(advisory) &&
114
+ typeof advisory.vulnerable_versions === 'string'));
115
+ }
116
+ function satisfiesSafe(version, range) {
117
+ try {
118
+ return semver.satisfies(version, range, { includePrerelease: true, loose: true });
31
119
  }
32
- if (quickRes.status === 404 && res.status === 404) {
33
- throw new AuditEndpointNotExistsError(quickAuditUrl);
120
+ catch {
121
+ return false;
122
+ }
123
+ }
124
+ function normalizeAdvisory(adv, moduleName, findings) {
125
+ const cwe = Array.isArray(adv.cwe) ? adv.cwe.join(', ') : adv.cwe;
126
+ return {
127
+ findings,
128
+ id: adv.id,
129
+ title: adv.title ?? '',
130
+ module_name: moduleName,
131
+ vulnerable_versions: adv.vulnerable_versions,
132
+ patched_versions: inferPatchedVersions(adv.vulnerable_versions),
133
+ severity: adv.severity,
134
+ cwe: cwe ?? '',
135
+ github_advisory_id: deriveGithubAdvisoryId(adv.url),
136
+ url: adv.url ?? '',
137
+ };
138
+ }
139
+ function inferPatchedVersions(vulnerableRange) {
140
+ // Matches `<X.Y.Z` or `<= X.Y.Z` (with optional whitespace after the operator)
141
+ // at the end of the range, optionally preceded by other comparators like
142
+ // `>=0.8.1 <0.28.0`. Returns undefined if the range doesn't have a
143
+ // recognizable upper bound — callers must not confuse that with "no fix".
144
+ const trimmed = vulnerableRange.trim();
145
+ const ltMatch = trimmed.match(/(?:^|\s)<\s*(\d+\.\d+\.\d[\w\-.+]*)\s*$/);
146
+ if (ltMatch)
147
+ return `>=${ltMatch[1]}`;
148
+ const lteMatch = trimmed.match(/(?:^|\s)<=\s*(\d+\.\d+\.\d[\w\-.+]*)\s*$/);
149
+ if (lteMatch) {
150
+ const next = semver.inc(lteMatch[1], 'patch');
151
+ if (next)
152
+ return `>=${next}`;
34
153
  }
35
- throw new PnpmError('AUDIT_BAD_RESPONSE', `The audit endpoint (at ${quickAuditUrl}) responded with ${quickRes.status}: ${await quickRes.text()}. Fallback endpoint (at ${auditUrl}) responded with ${res.status}: ${await res.text()}`);
154
+ return undefined;
155
+ }
156
+ function deriveGithubAdvisoryId(url) {
157
+ if (!url)
158
+ return '';
159
+ const match = url.match(/\/(GHSA-[\w-]+)/i);
160
+ return match ? normalizeGhsaId(match[1]) : '';
161
+ }
162
+ // GHSA identifiers are canonically written with an uppercase `GHSA-` prefix
163
+ // and a lowercase hexadecimal-style suffix (e.g. `GHSA-cph5-m8f7-6c5x`).
164
+ // Normalize both halves so ignore-list comparisons don't depend on how the
165
+ // user (or the advisory url) happens to case the id.
166
+ export function normalizeGhsaId(ghsaId) {
167
+ const trimmed = ghsaId.trim();
168
+ const dash = trimmed.indexOf('-');
169
+ if (dash < 0)
170
+ return trimmed.toUpperCase();
171
+ return trimmed.slice(0, dash).toUpperCase() + trimmed.slice(dash).toLowerCase();
36
172
  }
37
173
  function getAuthHeaders(authHeaderValue) {
38
174
  const headers = {};
@@ -43,7 +179,7 @@ function getAuthHeaders(authHeaderValue) {
43
179
  }
44
180
  export class AuditEndpointNotExistsError extends PnpmError {
45
181
  constructor(endpoint) {
46
- const message = `The audit endpoint (at ${endpoint}) is doesn't exist.`;
182
+ const message = `The audit endpoint (at ${endpoint}) doesn't exist.`;
47
183
  super('AUDIT_ENDPOINT_NOT_EXISTS', message, {
48
184
  hint: 'This issue is probably because you are using a private npm registry and that endpoint doesn\'t have an implementation of audit.',
49
185
  });
@@ -0,0 +1,27 @@
1
+ import { type DepTypes } from '@pnpm/lockfile.detect-dep-types';
2
+ import type { EnvLockfile, LockfileObject } from '@pnpm/lockfile.types';
3
+ import type { DependenciesField, DepPath } from '@pnpm/types';
4
+ export interface PathInfo {
5
+ paths: string[];
6
+ dev: boolean;
7
+ optional: boolean;
8
+ }
9
+ export type AuditPathIndex = Record<string, Map<string, PathInfo>>;
10
+ export interface AuditIndexRequest {
11
+ request: Record<string, string[]>;
12
+ totalDependencies: number;
13
+ dependencies: number;
14
+ devDependencies: number;
15
+ optionalDependencies: number;
16
+ }
17
+ export interface AuditIndexOptions {
18
+ envLockfile?: EnvLockfile | null;
19
+ include?: {
20
+ [dependenciesField in DependenciesField]: boolean;
21
+ };
22
+ depTypes?: DepTypes;
23
+ optionalOnly?: Set<DepPath>;
24
+ }
25
+ export declare function lockfileToAuditRequest(lockfile: LockfileObject, opts: AuditIndexOptions): AuditIndexRequest;
26
+ export declare function buildAuditPathIndex(lockfile: LockfileObject, vulnerableNames: Set<string>, opts: AuditIndexOptions): AuditPathIndex;
27
+ export declare function collectOptionalOnlyDepPaths(lockfile: LockfileObject, include?: AuditIndexOptions['include']): Set<DepPath>;
@@ -0,0 +1,281 @@
1
+ import * as dp from '@pnpm/deps.path';
2
+ import { DepType, detectDepTypes } from '@pnpm/lockfile.detect-dep-types';
3
+ import { convertToLockfileObject } from '@pnpm/lockfile.fs';
4
+ import { nameVerFromPkgSnapshot } from '@pnpm/lockfile.utils';
5
+ import { lockfileWalkerGroupImporterSteps } from '@pnpm/lockfile.walker';
6
+ export function lockfileToAuditRequest(lockfile, opts) {
7
+ const importerIds = Object.keys(lockfile.importers);
8
+ const importerWalkers = lockfileWalkerGroupImporterSteps(lockfile, importerIds, { include: opts.include });
9
+ const depTypes = opts.depTypes ?? detectDepTypes(lockfile);
10
+ const optionalOnly = opts.optionalOnly ?? collectOptionalOnlyDepPaths(lockfile, opts.include);
11
+ // Use null-prototype objects for records keyed by package names so a
12
+ // hostile or unusual package name (e.g. "__proto__") cannot pollute the
13
+ // prototype or overwrite inherited properties.
14
+ const request = Object.create(null);
15
+ // Per (name, version) classification. Counted as dev/optional only while
16
+ // every observed occurrence is dev-only / optional-only; once a non-dev or
17
+ // non-optional occurrence is seen, the flag is cleared and the counter
18
+ // decremented.
19
+ const versionStatesByName = Object.create(null);
20
+ let totalDependencies = 0;
21
+ let dependencies = 0;
22
+ let devDependencies = 0;
23
+ let optionalDependencies = 0;
24
+ const registerOccurrence = (o) => {
25
+ let versionStates = versionStatesByName[o.name];
26
+ if (!versionStates) {
27
+ versionStates = new Map();
28
+ versionStatesByName[o.name] = versionStates;
29
+ request[o.name] = [];
30
+ }
31
+ const state = versionStates.get(o.version);
32
+ if (!state) {
33
+ versionStates.set(o.version, { devOnly: o.devOnly, optionalOnly: o.optionalOnly });
34
+ request[o.name].push(o.version);
35
+ totalDependencies++;
36
+ if (o.devOnly)
37
+ devDependencies++;
38
+ if (o.optionalOnly)
39
+ optionalDependencies++;
40
+ if (!o.devOnly && !o.optionalOnly)
41
+ dependencies++;
42
+ return;
43
+ }
44
+ const wasProduction = !state.devOnly && !state.optionalOnly;
45
+ if (state.devOnly && !o.devOnly) {
46
+ state.devOnly = false;
47
+ devDependencies--;
48
+ }
49
+ if (state.optionalOnly && !o.optionalOnly) {
50
+ state.optionalOnly = false;
51
+ optionalDependencies--;
52
+ }
53
+ if (!wasProduction && !state.devOnly && !state.optionalOnly) {
54
+ dependencies++;
55
+ }
56
+ };
57
+ // Build a visitor for one lockfile graph. The walker already de-duplicates
58
+ // by depPath internally, so we don't need a second visited set here.
59
+ const makeVisitor = (graphDepTypes, graphOptionalOnly) => {
60
+ const visit = (step) => {
61
+ for (const { depPath, pkgSnapshot, next } of step.dependencies) {
62
+ const { name, version } = nameVerFromPkgSnapshot(depPath, pkgSnapshot);
63
+ if (version) {
64
+ registerOccurrence({
65
+ name,
66
+ version,
67
+ devOnly: graphDepTypes[depPath] === DepType.DevOnly,
68
+ optionalOnly: graphOptionalOnly.has(depPath),
69
+ });
70
+ }
71
+ visit(next());
72
+ }
73
+ };
74
+ return visit;
75
+ };
76
+ const visitMain = makeVisitor(depTypes, optionalOnly);
77
+ for (const importerWalker of importerWalkers) {
78
+ visitMain(importerWalker.step);
79
+ }
80
+ if (opts.envLockfile) {
81
+ const envLockfileObject = envLockfileToLockfileObject(opts.envLockfile);
82
+ const envDepTypes = detectDepTypes(envLockfileObject);
83
+ const envOptionalOnly = collectOptionalOnlyDepPaths(envLockfileObject, opts.include);
84
+ const visitEnv = makeVisitor(envDepTypes, envOptionalOnly);
85
+ for (const { step } of lockfileWalkerGroupImporterSteps(envLockfileObject, Object.keys(envLockfileObject.importers), { include: opts.include })) {
86
+ visitEnv(step);
87
+ }
88
+ }
89
+ return { request, totalDependencies, dependencies, devDependencies, optionalDependencies };
90
+ }
91
+ export function buildAuditPathIndex(lockfile, vulnerableNames, opts) {
92
+ // Null-prototype record keyed by package name to avoid prototype pollution
93
+ // from registry-supplied or lockfile-supplied names.
94
+ const paths = Object.create(null);
95
+ const depTypes = opts.depTypes ?? detectDepTypes(lockfile);
96
+ const optionalOnly = opts.optionalOnly ?? collectOptionalOnlyDepPaths(lockfile, opts.include);
97
+ walkForPaths({
98
+ lockfile,
99
+ vulnerableNames,
100
+ paths,
101
+ depTypes,
102
+ optionalOnly,
103
+ include: opts.include,
104
+ importerSegmentOf: (importerId) => importerId.replace(/\//g, '__'),
105
+ });
106
+ if (opts.envLockfile) {
107
+ const envLockfileObject = envLockfileToLockfileObject(opts.envLockfile);
108
+ walkForPaths({
109
+ lockfile: envLockfileObject,
110
+ vulnerableNames,
111
+ paths,
112
+ depTypes: detectDepTypes(envLockfileObject),
113
+ optionalOnly: collectOptionalOnlyDepPaths(envLockfileObject, opts.include),
114
+ include: opts.include,
115
+ importerSegmentOf: (importerId) => importerId,
116
+ });
117
+ }
118
+ return paths;
119
+ }
120
+ function walkForPaths(ctx) {
121
+ const { lockfile, vulnerableNames, paths, depTypes, optionalOnly, include, importerSegmentOf } = ctx;
122
+ const includeDeps = include?.dependencies !== false;
123
+ const includeDevDeps = include?.devDependencies !== false;
124
+ const includeOptDeps = include?.optionalDependencies !== false;
125
+ const packages = lockfile.packages ?? {};
126
+ // Reused across every root to avoid per-node Set cloning. visit adds the
127
+ // current depPath before recursing and removes it on the way back, so the
128
+ // set always reflects the current trail.
129
+ const inTrail = new Set();
130
+ const visit = (edge, trail) => {
131
+ if (inTrail.has(edge.depPath))
132
+ return;
133
+ const pkgSnapshot = packages[edge.depPath];
134
+ if (pkgSnapshot == null)
135
+ return;
136
+ const { name, version } = nameVerFromPkgSnapshot(edge.depPath, pkgSnapshot);
137
+ const resolvedName = name ?? edge.name;
138
+ const fullPath = [...trail, resolvedName];
139
+ if (version && vulnerableNames.has(resolvedName)) {
140
+ recordPath(paths, resolvedName, version, fullPath.join('>'), depTypes[edge.depPath] === DepType.DevOnly, optionalOnly.has(edge.depPath));
141
+ }
142
+ inTrail.add(edge.depPath);
143
+ try {
144
+ for (const child of resolvedDepsToNamedDepPaths(pkgSnapshot.dependencies ?? {})) {
145
+ visit(child, fullPath);
146
+ }
147
+ if (includeOptDeps) {
148
+ for (const child of resolvedDepsToNamedDepPaths(pkgSnapshot.optionalDependencies ?? {})) {
149
+ visit(child, fullPath);
150
+ }
151
+ }
152
+ }
153
+ finally {
154
+ inTrail.delete(edge.depPath);
155
+ }
156
+ };
157
+ for (const [importerId, importer] of Object.entries(lockfile.importers)) {
158
+ const trail = [importerSegmentOf(importerId)];
159
+ const roots = [];
160
+ if (includeDeps)
161
+ roots.push(...resolvedDepsToNamedDepPaths(importer.dependencies ?? {}));
162
+ if (includeDevDeps)
163
+ roots.push(...resolvedDepsToNamedDepPaths(importer.devDependencies ?? {}));
164
+ if (includeOptDeps)
165
+ roots.push(...resolvedDepsToNamedDepPaths(importer.optionalDependencies ?? {}));
166
+ for (const root of roots) {
167
+ visit(root, trail);
168
+ }
169
+ }
170
+ }
171
+ // Per-(name, version) cap on recorded paths. The CLI only ever displays the
172
+ // first few and follows with a "run pnpm why" hint, so keeping tens of
173
+ // thousands of equivalent chains is wasted memory/CPU for projects with
174
+ // heavy sharing (e.g. diamond dependencies deep in the graph).
175
+ const MAX_PATHS_PER_FINDING = 100;
176
+ function recordPath(paths, name, version, joined, isDev, isOptional) {
177
+ let byVersion = paths[name];
178
+ if (!byVersion) {
179
+ byVersion = new Map();
180
+ paths[name] = byVersion;
181
+ }
182
+ const info = byVersion.get(version);
183
+ if (!info) {
184
+ byVersion.set(version, { paths: [joined], dev: isDev, optional: isOptional });
185
+ return;
186
+ }
187
+ if (!isDev)
188
+ info.dev = false;
189
+ if (!isOptional)
190
+ info.optional = false;
191
+ if (info.paths.length >= MAX_PATHS_PER_FINDING)
192
+ return;
193
+ // Dedupe — the same joined trail can be produced when a package appears in
194
+ // both `dependencies` and `optionalDependencies` of the same parent, or via
195
+ // equivalent peer-suffix variants.
196
+ if (info.paths.includes(joined))
197
+ return;
198
+ info.paths.push(joined);
199
+ }
200
+ function resolvedDepsToNamedDepPaths(deps) {
201
+ const result = [];
202
+ for (const [alias, ref] of Object.entries(deps)) {
203
+ const depPath = dp.refToRelative(ref, alias);
204
+ if (depPath != null)
205
+ result.push({ name: alias, depPath });
206
+ }
207
+ return result;
208
+ }
209
+ // Returns the set of depPaths that are reachable only through optional edges
210
+ // (i.e. they would be absent from the install set if optionalDependencies were
211
+ // not included). Matches the AuditMetadata.optionalDependencies semantic.
212
+ //
213
+ // Implemented as (reachableWithOptional − reachableWithoutOptional) so that
214
+ // optionalDependencies nested inside a required chain are also accounted for,
215
+ // not just the ones declared directly on importer.optionalDependencies.
216
+ //
217
+ // Root selection honours the caller's `include` flags, so running
218
+ // `pnpm audit --prod` doesn't let dev-only subgraphs flip a package out of
219
+ // "optional-only" classification.
220
+ export function collectOptionalOnlyDepPaths(lockfile, include) {
221
+ const includeDeps = include?.dependencies !== false;
222
+ const includeDevDeps = include?.devDependencies !== false;
223
+ const includeOptDeps = include?.optionalDependencies !== false;
224
+ const withoutOptional = new Set();
225
+ const withOptional = new Set();
226
+ for (const importer of Object.values(lockfile.importers)) {
227
+ const nonOptionalRoots = [
228
+ ...(includeDeps ? resolvedDepsToDepPaths(importer.dependencies ?? {}) : []),
229
+ ...(includeDevDeps ? resolvedDepsToDepPaths(importer.devDependencies ?? {}) : []),
230
+ ];
231
+ const allRoots = [
232
+ ...nonOptionalRoots,
233
+ ...(includeOptDeps ? resolvedDepsToDepPaths(importer.optionalDependencies ?? {}) : []),
234
+ ];
235
+ walkReachable(lockfile, nonOptionalRoots, withoutOptional, false);
236
+ walkReachable(lockfile, allRoots, withOptional, includeOptDeps);
237
+ }
238
+ const result = new Set();
239
+ for (const depPath of withOptional) {
240
+ if (!withoutOptional.has(depPath))
241
+ result.add(depPath);
242
+ }
243
+ return result;
244
+ }
245
+ function walkReachable(lockfile, depPaths, seen, includeOptionalEdges) {
246
+ const packages = lockfile.packages ?? {};
247
+ for (const depPath of depPaths) {
248
+ if (seen.has(depPath))
249
+ continue;
250
+ seen.add(depPath);
251
+ const snapshot = packages[depPath];
252
+ if (!snapshot)
253
+ continue;
254
+ walkReachable(lockfile, resolvedDepsToDepPaths(snapshot.dependencies ?? {}), seen, includeOptionalEdges);
255
+ if (includeOptionalEdges) {
256
+ walkReachable(lockfile, resolvedDepsToDepPaths(snapshot.optionalDependencies ?? {}), seen, includeOptionalEdges);
257
+ }
258
+ }
259
+ }
260
+ function resolvedDepsToDepPaths(deps) {
261
+ return Object.entries(deps)
262
+ .map(([alias, ref]) => dp.refToRelative(ref, alias))
263
+ .filter((depPath) => depPath !== null);
264
+ }
265
+ function envLockfileToLockfileObject(envLockfile) {
266
+ const envImporter = envLockfile.importers['.'];
267
+ const importers = {};
268
+ if (Object.keys(envImporter.configDependencies).length > 0) {
269
+ importers['configDependencies'] = { dependencies: envImporter.configDependencies };
270
+ }
271
+ if (envImporter.packageManagerDependencies) {
272
+ importers['packageManagerDependencies'] = { dependencies: envImporter.packageManagerDependencies };
273
+ }
274
+ return convertToLockfileObject({
275
+ lockfileVersion: envLockfile.lockfileVersion,
276
+ importers,
277
+ packages: envLockfile.packages,
278
+ snapshots: envLockfile.snapshots,
279
+ });
280
+ }
281
+ //# sourceMappingURL=lockfileToAuditIndex.js.map
package/lib/types.d.ts CHANGED
@@ -6,64 +6,31 @@ export interface AuditVulnerabilityCounts {
6
6
  critical: number;
7
7
  }
8
8
  export interface IgnoredAuditVulnerabilityCounts {
9
+ info: number;
9
10
  low: number;
10
11
  moderate: number;
11
12
  high: number;
12
13
  critical: number;
13
14
  }
14
- export interface AuditResolution {
15
- id: number;
16
- path: string;
15
+ export type AuditLevelString = 'info' | 'low' | 'moderate' | 'high' | 'critical';
16
+ export type AuditLevelNumber = 0 | 1 | 2 | 3 | 4;
17
+ export interface AuditFinding {
18
+ version: string;
19
+ paths: string[];
17
20
  dev: boolean;
18
21
  optional: boolean;
19
22
  bundled: boolean;
20
23
  }
21
- export interface AuditAction {
22
- action: string;
23
- module: string;
24
- target: string;
25
- isMajor: boolean;
26
- resolves: AuditResolution[];
27
- }
28
- export type AuditLevelString = 'low' | 'moderate' | 'high' | 'critical';
29
- export type AuditLevelNumber = 0 | 1 | 2 | 3;
30
24
  export interface AuditAdvisory {
31
- findings: [
32
- {
33
- version: string;
34
- paths: string[];
35
- dev: boolean;
36
- optional: boolean;
37
- bundled: boolean;
38
- }
39
- ];
25
+ findings: AuditFinding[];
40
26
  id: number;
41
- created: string;
42
- updated: string;
43
- deleted?: boolean;
44
27
  title: string;
45
- found_by: {
46
- name: string;
47
- };
48
- reported_by: {
49
- name: string;
50
- };
51
28
  module_name: string;
52
- cves: string[];
53
29
  vulnerable_versions: string;
54
- patched_versions: string;
55
- overview: string;
56
- recommendation: string;
57
- references: string;
58
- access: string;
30
+ patched_versions?: string;
59
31
  severity: AuditLevelString;
60
32
  cwe: string;
61
33
  github_advisory_id: string;
62
- metadata: {
63
- module_type: string;
64
- exploitability: number;
65
- affected_components: string;
66
- };
67
34
  url: string;
68
35
  }
69
36
  export interface AuditMetadata {
@@ -74,15 +41,8 @@ export interface AuditMetadata {
74
41
  totalDependencies: number;
75
42
  }
76
43
  export interface AuditReport {
77
- actions: AuditAction[];
78
44
  advisories: {
79
45
  [id: string]: AuditAdvisory;
80
46
  };
81
- muted: unknown[];
82
47
  metadata: AuditMetadata;
83
48
  }
84
- export interface AuditActionRecommendation {
85
- cmd: string;
86
- isBreaking: boolean;
87
- action: AuditAction;
88
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnpm/deps.compliance.audit",
3
- "version": "1002.0.14",
3
+ "version": "1101.0.0",
4
4
  "description": "Audit a lockfile",
5
5
  "keywords": [
6
6
  "pnpm",
@@ -25,28 +25,28 @@
25
25
  "!*.map"
26
26
  ],
27
27
  "dependencies": {
28
- "ramda": "npm:@pnpm/ramda@0.28.1",
29
- "@pnpm/error": "1000.0.5",
30
- "@pnpm/fetching.types": "1000.2.0",
31
- "@pnpm/lockfile.fs": "1001.1.21",
32
- "@pnpm/lockfile.detect-dep-types": "1001.0.16",
33
- "@pnpm/lockfile.types": "1002.0.2",
34
- "@pnpm/lockfile.walker": "1001.0.16",
35
- "@pnpm/network.fetch": "1000.2.6",
36
- "@pnpm/types": "1000.9.0",
37
- "@pnpm/lockfile.utils": "1003.0.3",
38
- "@pnpm/workspace.project-manifest-reader": "1001.1.4"
28
+ "semver": "^7.7.2",
29
+ "@pnpm/error": "1100.0.0",
30
+ "@pnpm/fetching.types": "1100.0.0",
31
+ "@pnpm/deps.path": "1100.0.1",
32
+ "@pnpm/lockfile.detect-dep-types": "1100.0.1",
33
+ "@pnpm/lockfile.fs": "1100.0.1",
34
+ "@pnpm/lockfile.utils": "1100.0.1",
35
+ "@pnpm/lockfile.types": "1100.0.1",
36
+ "@pnpm/lockfile.walker": "1100.0.1",
37
+ "@pnpm/network.fetch": "1100.0.1",
38
+ "@pnpm/types": "1101.0.0"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@pnpm/logger": ">=1001.0.0 <1002.0.0"
42
42
  },
43
43
  "devDependencies": {
44
- "@types/ramda": "0.29.12",
45
- "nock": "13.3.4",
46
- "@pnpm/logger": "1001.0.1",
47
- "@pnpm/test-fixtures": "1000.0.0",
48
- "@pnpm/deps.compliance.audit": "1002.0.14",
49
- "@pnpm/constants": "1001.3.1"
44
+ "@types/semver": "7.7.1",
45
+ "@pnpm/deps.compliance.audit": "1101.0.0",
46
+ "@pnpm/constants": "1100.0.0",
47
+ "@pnpm/logger": "1100.0.0",
48
+ "@pnpm/test-fixtures": "1100.0.0",
49
+ "@pnpm/testing.mock-agent": "1100.0.1"
50
50
  },
51
51
  "engines": {
52
52
  "node": ">=22.13"
@@ -56,8 +56,8 @@
56
56
  },
57
57
  "scripts": {
58
58
  "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
59
- "_test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest",
60
- "test": "pnpm run compile && pnpm run _test",
61
- "compile": "tsgo --build && pnpm run lint --fix"
59
+ "test": "pn compile && pn .test",
60
+ "compile": "tsgo --build && pn lint --fix",
61
+ ".test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest"
62
62
  }
63
63
  }
@@ -1,24 +0,0 @@
1
- import type { EnvLockfile, LockfileObject } from '@pnpm/lockfile.types';
2
- import type { DependenciesField } from '@pnpm/types';
3
- export interface AuditNode {
4
- version?: string;
5
- integrity?: string;
6
- requires?: Record<string, string>;
7
- dependencies?: {
8
- [name: string]: AuditNode;
9
- };
10
- dev: boolean;
11
- }
12
- export interface AuditTree extends AuditNode {
13
- name?: string;
14
- install: string[];
15
- remove: string[];
16
- metadata: unknown;
17
- }
18
- export declare function lockfileToAuditTree(lockfile: LockfileObject, opts: {
19
- envLockfile?: EnvLockfile | null;
20
- include?: {
21
- [dependenciesField in DependenciesField]: boolean;
22
- };
23
- lockfileDir: string;
24
- }): Promise<AuditTree>;
@@ -1,93 +0,0 @@
1
- import path from 'node:path';
2
- import { DepType, detectDepTypes } from '@pnpm/lockfile.detect-dep-types';
3
- import { convertToLockfileObject } from '@pnpm/lockfile.fs';
4
- import { nameVerFromPkgSnapshot } from '@pnpm/lockfile.utils';
5
- import { lockfileWalkerGroupImporterSteps } from '@pnpm/lockfile.walker';
6
- import { safeReadProjectManifestOnly } from '@pnpm/workspace.project-manifest-reader';
7
- import { map as mapValues } from 'ramda';
8
- export async function lockfileToAuditTree(lockfile, opts) {
9
- const importerWalkers = lockfileWalkerGroupImporterSteps(lockfile, Object.keys(lockfile.importers), { include: opts?.include });
10
- const dependencies = {};
11
- const depTypes = detectDepTypes(lockfile);
12
- await Promise.all(importerWalkers.map(async (importerWalker) => {
13
- const importerDeps = lockfileToAuditNode(depTypes, importerWalker.step);
14
- // For some reason the registry responds with 500 if the keys in dependencies have slashes
15
- // see issue: https://github.com/pnpm/pnpm/issues/2848
16
- const depName = importerWalker.importerId.replace(/\//g, '__');
17
- const manifest = await safeReadProjectManifestOnly(path.join(opts.lockfileDir, importerWalker.importerId));
18
- dependencies[depName] = {
19
- dependencies: importerDeps,
20
- dev: false,
21
- requires: toRequires(importerDeps),
22
- version: manifest?.version ?? '0.0.0',
23
- };
24
- }));
25
- if (opts.envLockfile) {
26
- const envLockfileObject = envLockfileToLockfileObject(opts.envLockfile);
27
- const envDepTypes = detectDepTypes(envLockfileObject);
28
- for (const { importerId, step } of lockfileWalkerGroupImporterSteps(envLockfileObject, Object.keys(envLockfileObject.importers), { include: opts.include })) {
29
- const deps = lockfileToAuditNode(envDepTypes, step);
30
- if (Object.keys(deps).length > 0) {
31
- dependencies[importerId] = wrapDepsGroup(deps);
32
- }
33
- }
34
- }
35
- const auditTree = {
36
- name: undefined,
37
- version: undefined,
38
- dependencies,
39
- dev: false,
40
- install: [],
41
- integrity: undefined,
42
- metadata: {},
43
- remove: [],
44
- requires: toRequires(dependencies),
45
- };
46
- return auditTree;
47
- }
48
- function lockfileToAuditNode(depTypes, step) {
49
- const dependencies = {};
50
- for (const { depPath, pkgSnapshot, next } of step.dependencies) {
51
- const { name, version } = nameVerFromPkgSnapshot(depPath, pkgSnapshot);
52
- const subdeps = lockfileToAuditNode(depTypes, next());
53
- const dep = {
54
- dev: depTypes[depPath] === DepType.DevOnly,
55
- integrity: pkgSnapshot.resolution.integrity,
56
- version,
57
- };
58
- if (Object.keys(subdeps).length > 0) {
59
- dep.dependencies = subdeps;
60
- dep.requires = toRequires(subdeps);
61
- }
62
- dependencies[name] = dep;
63
- }
64
- return dependencies;
65
- }
66
- function toRequires(auditNodesByDepName) {
67
- return mapValues((auditNode) => auditNode.version, auditNodesByDepName);
68
- }
69
- function wrapDepsGroup(deps) {
70
- return {
71
- dependencies: deps,
72
- dev: false,
73
- requires: toRequires(deps),
74
- version: '0.0.0',
75
- };
76
- }
77
- function envLockfileToLockfileObject(envLockfile) {
78
- const envImporter = envLockfile.importers['.'];
79
- const importers = {};
80
- if (Object.keys(envImporter.configDependencies).length > 0) {
81
- importers['configDependencies'] = { dependencies: envImporter.configDependencies };
82
- }
83
- if (envImporter.packageManagerDependencies) {
84
- importers['packageManagerDependencies'] = { dependencies: envImporter.packageManagerDependencies };
85
- }
86
- return convertToLockfileObject({
87
- lockfileVersion: envLockfile.lockfileVersion,
88
- importers,
89
- packages: envLockfile.packages,
90
- snapshots: envLockfile.snapshots,
91
- });
92
- }
93
- //# sourceMappingURL=lockfileToAuditTree.js.map