feat(plugin-commands-audit): show path info in audit output (#5917)

close #3073
This commit is contained in:
await-ovo
2023-01-18 10:04:17 +08:00
committed by GitHub
parent 6c7ac6320f
commit 94ef3299ed
11 changed files with 2284 additions and 1009 deletions

View File

@@ -0,0 +1,9 @@
---
"@pnpm/reviewing.dependencies-hierarchy": minor
"@pnpm/plugin-commands-audit": minor
"@pnpm/audit": minor
"@pnpm/list": minor
"pnpm": minor
---
Show dependency paths info in `pnpm audit` output [#3073](https://github.com/pnpm/pnpm/issues/3073)

View File

@@ -41,6 +41,7 @@
"@pnpm/error": "workspace:*",
"@pnpm/fetch": "workspace:*",
"@pnpm/fetching-types": "workspace:*",
"@pnpm/list": "workspace:*",
"@pnpm/lockfile-types": "workspace:*",
"@pnpm/lockfile-utils": "workspace:*",
"@pnpm/lockfile-walker": "workspace:*",

View File

@@ -1,3 +1,4 @@
import path from 'path'
import { PnpmError } from '@pnpm/error'
import { AgentOptions, fetchWithAgent, RetryTimeoutOptions } from '@pnpm/fetch'
import { GetAuthHeader } from '@pnpm/fetching-types'
@@ -5,6 +6,7 @@ import { Lockfile } from '@pnpm/lockfile-types'
import { DependenciesField } from '@pnpm/types'
import { lockfileToAuditTree } from './lockfileToAuditTree'
import { AuditReport } from './types'
import { searchForPackages, PackageNode } from '@pnpm/list'
export * from './types'
@@ -44,7 +46,12 @@ export async function audit (
if (res.status !== 200) {
throw new PnpmError('AUDIT_BAD_RESPONSE', `The audit endpoint (at ${auditUrl}) responded with ${res.status}: ${await res.text()}`)
}
return res.json() as Promise<AuditReport>
const auditReport = await (res.json() as Promise<AuditReport>)
return extendWithDependencyPaths(auditReport, {
lockfile,
lockfileDir: opts.lockfileDir,
include: opts.include,
})
}
function getAuthHeaders (authHeaderValue: string | undefined) {
@@ -55,6 +62,63 @@ function getAuthHeaders (authHeaderValue: string | undefined) {
return headers
}
async function extendWithDependencyPaths (auditReport: AuditReport, opts: {
lockfile: Lockfile
lockfileDir: string
include?: { [dependenciesField in DependenciesField]: boolean }
}): Promise<AuditReport> {
const { advisories } = auditReport
if (!Object.keys(advisories).length) return auditReport
const projectDirs = Object.keys(opts.lockfile.importers)
.map((importerId) => path.join(opts.lockfileDir, importerId))
const searchOpts = {
lockfileDir: opts.lockfileDir,
depth: Infinity,
include: opts.include,
}
const _searchPackagePaths = searchPackagePaths.bind(null, searchOpts, projectDirs)
for (const { findings, module_name: moduleName } of Object.values(advisories)) {
for (const finding of findings) {
finding.paths = await _searchPackagePaths(`${moduleName}@${finding.version}`)
}
}
return auditReport
}
async function searchPackagePaths (
searchOpts: {
lockfileDir: string
depth: number
include?: { [dependenciesField in DependenciesField]: boolean }
},
projectDirs: string[],
pkg: string
) {
const pkgs = await searchForPackages([pkg], projectDirs, searchOpts)
const paths: string[] = []
for (const pkg of pkgs) {
_walker([
...(pkg.optionalDependencies ?? []),
...(pkg.dependencies ?? []),
...(pkg.devDependencies ?? []),
...(pkg.unsavedDependencies ?? []),
], path.relative(searchOpts.lockfileDir, pkg.path) || '.')
}
return paths
function _walker (packages: PackageNode[], depPath: string) {
for (const pkg of packages) {
const nextDepPath = `${depPath}>${pkg.name}@${pkg.version}`
if (pkg.dependencies?.length) {
_walker(pkg.dependencies, nextDepPath)
} else {
paths.push(nextDepPath)
}
}
}
}
export class AuditEndpointNotExistsError extends PnpmError {
constructor (endpoint: string) {
const message = `The audit endpoint (at ${endpoint}) is doesn't exist.`

View File

@@ -30,6 +30,9 @@
{
"path": "../../pkg-manifest/read-project-manifest"
},
{
"path": "../../reviewing/list"
},
{
"path": "../lockfile-file"
},

View File

@@ -32,6 +32,7 @@
"homepage": "https://github.com/pnpm/pnpm/blob/main/lockfile/plugin-commands-audit#readme",
"devDependencies": {
"@pnpm/plugin-commands-audit": "workspace:*",
"@pnpm/plugin-commands-installation": "workspace:*",
"@pnpm/test-fixtures": "workspace:*",
"@types/ramda": "0.28.20",
"@types/zkochan__table": "npm:@types/table@6.0.0",

View File

@@ -221,6 +221,7 @@ ${JSON.stringify(newOverrides, null, 2)}`,
['Package', advisory.module_name],
['Vulnerable versions', advisory.vulnerable_versions],
['Patched versions', advisory.patched_versions],
['Paths', advisory.findings.map(({ paths }) => paths).flat().join('\n\n')],
['More info', advisory.url],
], TABLE_OPTIONS)
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import path from 'path'
import { fixtures } from '@pnpm/test-fixtures'
import { audit } from '@pnpm/plugin-commands-audit'
import { install } from '@pnpm/plugin-commands-installation'
import { AuditEndpointNotExistsError } from '@pnpm/audit'
import nock from 'nock'
import stripAnsi from 'strip-ansi'
@@ -13,226 +14,281 @@ const registries = {
const rawConfig = {
registry: registries.default,
}
export const DEFAULT_OPTS = {
argv: {
original: [],
},
bail: true,
bin: 'node_modules/.bin',
ca: undefined,
cacheDir: '../cache',
cert: undefined,
extraEnv: {},
cliOptions: {},
fetchRetries: 2,
fetchRetryFactor: 90,
fetchRetryMaxtimeout: 90,
fetchRetryMintimeout: 10,
filter: [] as string[],
httpsProxy: undefined,
include: {
dependencies: true,
devDependencies: true,
optionalDependencies: true,
},
key: undefined,
linkWorkspacePackages: true,
localAddress: undefined,
lock: false,
lockStaleDuration: 90,
networkConcurrency: 16,
offline: false,
pending: false,
pnpmfile: './.pnpmfile.cjs',
pnpmHomeDir: '',
proxy: undefined,
rawConfig,
rawLocalConfig: {},
registries,
// registry: REGISTRY,
sort: true,
storeDir: '../store',
strictSsl: false,
userAgent: 'pnpm',
userConfig: {},
useRunningStoreServer: false,
useStoreServer: false,
workspaceConcurrency: 4,
}
test('audit', async () => {
nock(registries.default)
.post('/-/npm/v1/security/audits')
.reply(200, responses.ALL_VULN_RESP)
const { output, exitCode } = await audit.handler({
dir: f.find('has-vulnerabilities'),
userConfig: {},
rawConfig,
registries,
describe('plugin-commands-audit', () => {
beforeAll(async () => {
await install.handler({
...DEFAULT_OPTS,
frozenLockfile: true,
dir: f.find('has-vulnerabilities'),
})
})
expect(exitCode).toBe(1)
expect(stripAnsi(output)).toMatchSnapshot()
})
test('audit', async () => {
nock(registries.default)
.post('/-/npm/v1/security/audits')
.reply(200, responses.ALL_VULN_RESP)
test('audit --dev', async () => {
nock(registries.default)
.post('/-/npm/v1/security/audits')
.reply(200, responses.DEV_VULN_ONLY_RESP)
const { output, exitCode } = await audit.handler({
dir: f.find('has-vulnerabilities'),
dev: true,
production: false,
userConfig: {},
rawConfig,
registries,
const { output, exitCode } = await audit.handler({
dir: f.find('has-vulnerabilities'),
userConfig: {},
rawConfig,
registries,
})
expect(exitCode).toBe(1)
expect(stripAnsi(output)).toMatchSnapshot()
})
expect(exitCode).toBe(1)
expect(stripAnsi(output)).toMatchSnapshot()
})
test('audit --dev', async () => {
nock(registries.default)
.post('/-/npm/v1/security/audits')
.reply(200, responses.DEV_VULN_ONLY_RESP)
test('audit --audit-level', async () => {
nock(registries.default)
.post('/-/npm/v1/security/audits')
.reply(200, responses.ALL_VULN_RESP)
const { output, exitCode } = await audit.handler({
dir: f.find('has-vulnerabilities'),
dev: true,
production: false,
userConfig: {},
rawConfig,
registries,
})
const { output, exitCode } = await audit.handler({
auditLevel: 'moderate',
dir: f.find('has-vulnerabilities'),
userConfig: {},
rawConfig,
registries,
expect(exitCode).toBe(1)
expect(stripAnsi(output)).toMatchSnapshot()
})
expect(exitCode).toBe(1)
expect(stripAnsi(output)).toMatchSnapshot()
})
test('audit --audit-level', async () => {
nock(registries.default)
.post('/-/npm/v1/security/audits')
.reply(200, responses.ALL_VULN_RESP)
test('audit: no vulnerabilities', async () => {
nock(registries.default)
.post('/-/npm/v1/security/audits')
.reply(200, responses.NO_VULN_RESP)
const { output, exitCode } = await audit.handler({
auditLevel: 'moderate',
dir: f.find('has-vulnerabilities'),
userConfig: {},
rawConfig,
registries,
})
const { output, exitCode } = await audit.handler({
dir: f.find('has-outdated-deps'),
userConfig: {},
rawConfig,
registries,
expect(exitCode).toBe(1)
expect(stripAnsi(output)).toMatchSnapshot()
})
expect(stripAnsi(output)).toBe('No known vulnerabilities found\n')
expect(exitCode).toBe(0)
})
test('audit: no vulnerabilities', async () => {
nock(registries.default)
.post('/-/npm/v1/security/audits')
.reply(200, responses.NO_VULN_RESP)
test('audit --json', async () => {
nock(registries.default)
.post('/-/npm/v1/security/audits')
.reply(200, responses.ALL_VULN_RESP)
const { output, exitCode } = await audit.handler({
dir: f.find('has-outdated-deps'),
userConfig: {},
rawConfig,
registries,
})
const { output, exitCode } = await audit.handler({
dir: f.find('has-vulnerabilities'),
json: true,
userConfig: {},
rawConfig,
registries,
expect(stripAnsi(output)).toBe('No known vulnerabilities found\n')
expect(exitCode).toBe(0)
})
const json = JSON.parse(output)
expect(json.metadata).toBeTruthy()
expect(exitCode).toBe(1)
})
test('audit --json', async () => {
nock(registries.default)
.post('/-/npm/v1/security/audits')
.reply(200, responses.ALL_VULN_RESP)
test.skip('audit does not exit with code 1 if the found vulnerabilities are having lower severity then what we asked for', async () => {
nock(registries.default)
.post('/-/npm/v1/security/audits')
.reply(200, responses.DEV_VULN_ONLY_RESP)
const { output, exitCode } = await audit.handler({
dir: f.find('has-vulnerabilities'),
json: true,
userConfig: {},
rawConfig,
registries,
})
const { output, exitCode } = await audit.handler({
auditLevel: 'high',
dir: f.find('has-vulnerabilities'),
userConfig: {},
rawConfig,
dev: true,
registries,
const json = JSON.parse(output)
expect(json.metadata).toBeTruthy()
expect(exitCode).toBe(1)
})
expect(exitCode).toBe(0)
expect(stripAnsi(output)).toBe(`1 vulnerabilities found
Severity: 1 moderate`)
})
test.skip('audit does not exit with code 1 if the found vulnerabilities are having lower severity then what we asked for', async () => {
nock(registries.default)
.post('/-/npm/v1/security/audits')
.reply(200, responses.DEV_VULN_ONLY_RESP)
test('audit does not exit with code 1 if the registry responds with a non-200 response and ignoreRegistryErrors is used', async () => {
nock(registries.default)
.post('/-/npm/v1/security/audits')
.reply(500, { message: 'Something bad happened' })
const { output, exitCode } = await audit.handler({
dir: f.find('has-vulnerabilities'),
dev: true,
fetchRetries: 0,
ignoreRegistryErrors: true,
production: false,
userConfig: {},
rawConfig,
registries,
const { output, exitCode } = await audit.handler({
auditLevel: 'high',
dir: f.find('has-vulnerabilities'),
userConfig: {},
rawConfig,
dev: true,
registries,
})
expect(exitCode).toBe(0)
expect(stripAnsi(output)).toBe(`1 vulnerabilities found
Severity: 1 moderate`)
})
expect(exitCode).toBe(0)
expect(stripAnsi(output)).toBe(`The audit endpoint (at ${registries.default}-/npm/v1/security/audits) responded with 500: {"message":"Something bad happened"}`)
})
test('audit does not exit with code 1 if the registry responds with a non-200 response and ignoreRegistryErrors is used', async () => {
nock(registries.default)
.post('/-/npm/v1/security/audits')
.reply(500, { message: 'Something bad happened' })
const { output, exitCode } = await audit.handler({
dir: f.find('has-vulnerabilities'),
dev: true,
fetchRetries: 0,
ignoreRegistryErrors: true,
production: false,
userConfig: {},
rawConfig,
registries,
})
test('audit sends authToken', async () => {
nock(registries.default, {
reqheaders: { authorization: 'Bearer 123' },
})
.post('/-/npm/v1/security/audits')
.reply(200, responses.NO_VULN_RESP)
const { output, exitCode } = await audit.handler({
dir: f.find('has-outdated-deps'),
userConfig: {},
rawConfig: {
registry: registries.default,
[`${registries.default.replace(/^https?:/, '')}:_authToken`]: '123',
},
registries,
expect(exitCode).toBe(0)
expect(stripAnsi(output)).toBe(`The audit endpoint (at ${registries.default}-/npm/v1/security/audits) responded with 500: {"message":"Something bad happened"}`)
})
expect(stripAnsi(output)).toBe('No known vulnerabilities found\n')
expect(exitCode).toBe(0)
})
test('audit sends authToken', async () => {
nock(registries.default, {
reqheaders: { authorization: 'Bearer 123' },
})
.post('/-/npm/v1/security/audits')
.reply(200, responses.NO_VULN_RESP)
test('audit endpoint does not exist', async () => {
nock(registries.default)
.post('/-/npm/v1/security/audits')
.reply(404, {})
const { output, exitCode } = await audit.handler({
dir: f.find('has-outdated-deps'),
userConfig: {},
rawConfig: {
registry: registries.default,
[`${registries.default.replace(/^https?:/, '')}:_authToken`]: '123',
},
registries,
})
await expect(audit.handler({
dir: f.find('has-vulnerabilities'),
dev: true,
fetchRetries: 0,
ignoreRegistryErrors: false,
production: false,
userConfig: {},
rawConfig,
registries,
})).rejects.toThrow(AuditEndpointNotExistsError)
})
expect(stripAnsi(output)).toBe('No known vulnerabilities found\n')
expect(exitCode).toBe(0)
})
test('audit: CVEs in ignoreCves do not show up', async () => {
const tmp = f.prepare('has-vulnerabilities')
test('audit endpoint does not exist', async () => {
nock(registries.default)
.post('/-/npm/v1/security/audits')
.reply(404, {})
nock(registries.default)
.post('/-/npm/v1/security/audits')
.reply(200, responses.ALL_VULN_RESP)
await expect(audit.handler({
dir: f.find('has-vulnerabilities'),
dev: true,
fetchRetries: 0,
ignoreRegistryErrors: false,
production: false,
userConfig: {},
rawConfig,
registries,
})).rejects.toThrow(AuditEndpointNotExistsError)
})
const { exitCode, output } = await audit.handler({
auditLevel: 'moderate',
dir: tmp,
userConfig: {},
rawConfig,
registries,
rootProjectManifest: {
pnpm: {
auditConfig: {
ignoreCves: [
'CVE-2019-10742',
'CVE-2020-28168',
'CVE-2021-3749',
'CVE-2020-7598',
],
test('audit: CVEs in ignoreCves do not show up', async () => {
const tmp = f.prepare('has-vulnerabilities')
nock(registries.default)
.post('/-/npm/v1/security/audits')
.reply(200, responses.ALL_VULN_RESP)
const { exitCode, output } = await audit.handler({
auditLevel: 'moderate',
dir: tmp,
userConfig: {},
rawConfig,
registries,
rootProjectManifest: {
pnpm: {
auditConfig: {
ignoreCves: [
'CVE-2019-10742',
'CVE-2020-28168',
'CVE-2021-3749',
'CVE-2020-7598',
],
},
},
},
},
})
expect(exitCode).toBe(1)
expect(stripAnsi(output)).toMatchSnapshot()
})
expect(exitCode).toBe(1)
expect(stripAnsi(output)).toMatchSnapshot()
})
test('audit: CVEs in ignoreCves do not show up when JSON output is used', async () => {
const tmp = f.prepare('has-vulnerabilities')
test('audit: CVEs in ignoreCves do not show up when JSON output is used', async () => {
const tmp = f.prepare('has-vulnerabilities')
nock(registries.default)
.post('/-/npm/v1/security/audits')
.reply(200, responses.ALL_VULN_RESP)
nock(registries.default)
.post('/-/npm/v1/security/audits')
.reply(200, responses.ALL_VULN_RESP)
const { exitCode, output } = await audit.handler({
auditLevel: 'moderate',
dir: tmp,
json: true,
userConfig: {},
rawConfig,
registries,
rootProjectManifest: {
pnpm: {
auditConfig: {
ignoreCves: [
'CVE-2019-10742',
'CVE-2020-28168',
'CVE-2021-3749',
'CVE-2020-7598',
],
const { exitCode, output } = await audit.handler({
auditLevel: 'moderate',
dir: tmp,
json: true,
userConfig: {},
rawConfig,
registries,
rootProjectManifest: {
pnpm: {
auditConfig: {
ignoreCves: [
'CVE-2019-10742',
'CVE-2020-28168',
'CVE-2021-3749',
'CVE-2020-7598',
],
},
},
},
},
})
})
expect(exitCode).toBe(1)
expect(stripAnsi(output)).toMatchSnapshot()
expect(exitCode).toBe(1)
expect(stripAnsi(output)).toMatchSnapshot()
})
})

View File

@@ -30,6 +30,9 @@
{
"path": "../../packages/types"
},
{
"path": "../../pkg-manager/plugin-commands-installation"
},
{
"path": "../../pkg-manifest/read-project-manifest"
},

6
pnpm-lock.yaml generated
View File

@@ -1652,6 +1652,9 @@ importers:
'@pnpm/fetching-types':
specifier: workspace:*
version: link:../../network/fetching-types
'@pnpm/list':
specifier: workspace:*
version: link:../../reviewing/list
'@pnpm/lockfile-types':
specifier: workspace:*
version: link:../lockfile-types
@@ -2015,6 +2018,9 @@ importers:
'@pnpm/plugin-commands-audit':
specifier: workspace:*
version: 'link:'
'@pnpm/plugin-commands-installation':
specifier: workspace:*
version: link:../../pkg-manager/plugin-commands-installation
'@pnpm/test-fixtures':
specifier: workspace:*
version: link:../../__utils__/test-fixtures

View File

@@ -7,6 +7,8 @@ import { renderParseable } from './renderParseable'
import { renderTree } from './renderTree'
import { PackageDependencyHierarchy } from './types'
export { PackageNode } from '@pnpm/reviewing.dependencies-hierarchy'
const DEFAULTS = {
alwaysPrintRootPackage: true,
depth: 0,
@@ -16,6 +18,41 @@ const DEFAULTS = {
showExtraneous: true,
}
export async function searchForPackages (
packages: string[],
projectPaths: string[],
opts: {
depth: number
lockfileDir: string
include?: { [dependenciesField in DependenciesField]: boolean }
onlyProjects?: boolean
registries?: Registries
}
) {
const search = createPackagesSearcher(packages)
return Promise.all(
Object.entries(await buildDependenciesHierarchy(projectPaths, {
depth: opts.depth,
include: opts.include,
lockfileDir: opts.lockfileDir,
onlyProjects: opts.onlyProjects,
registries: opts.registries,
search,
}))
.map(async ([projectPath, buildDependenciesHierarchy]) => {
const entryPkg = await readProjectManifestOnly(projectPath)
return {
name: entryPkg.name,
version: entryPkg.version,
path: projectPath,
...buildDependenciesHierarchy,
} as PackageDependencyHierarchy
})
)
}
export async function listForPackages (
packages: string[],
projectPaths: string[],
@@ -32,28 +69,7 @@ export async function listForPackages (
) {
const opts = { ...DEFAULTS, ...maybeOpts }
const search = createPackagesSearcher(packages)
const pkgs = await Promise.all(
Object.entries(await buildDependenciesHierarchy(projectPaths, {
depth: opts.depth,
include: maybeOpts?.include,
lockfileDir: maybeOpts?.lockfileDir,
onlyProjects: maybeOpts?.onlyProjects,
registries: opts.registries,
search,
}))
.map(async ([projectPath, buildDependenciesHierarchy]) => {
const entryPkg = await readProjectManifestOnly(projectPath)
return {
name: entryPkg.name,
version: entryPkg.version,
path: projectPath,
...buildDependenciesHierarchy,
} as PackageDependencyHierarchy
})
)
const pkgs = await searchForPackages(packages, projectPaths, opts)
const print = getPrinter(opts.reportAs)
return print(pkgs, {