From 2c695096b14593cdd9908aefe405fc601c2aa884 Mon Sep 17 00:00:00 2001 From: Andy Edwards Date: Tue, 27 Aug 2024 11:18:37 -0500 Subject: [PATCH] fix: check that npm token has write access on package fix #4 --- index.js | 52 +++++------ lib/npm.js | 30 +++++++ lib/verify-auth.js | 20 +++-- lib/verify-release.js | 55 ++++++++++++ test/integration.test.js | 1 + test/verify-auth.test.js | 44 ++++++++++ test/verify-release.test.js | 167 ++++++++++++++++++++++++++++++++++++ 7 files changed, 334 insertions(+), 35 deletions(-) create mode 100644 lib/npm.js create mode 100644 lib/verify-release.js create mode 100644 test/verify-auth.test.js create mode 100644 test/verify-release.test.js diff --git a/index.js b/index.js index 590eace3..b9c763ef 100644 --- a/index.js +++ b/index.js @@ -4,11 +4,13 @@ import { temporaryFile } from "tempy"; import getPkg from "./lib/get-pkg.js"; import verifyNpmConfig from "./lib/verify-config.js"; import verifyNpmAuth from "./lib/verify-auth.js"; +import verifyNpmRelease from "./lib/verify-release.js"; import addChannelNpm from "./lib/add-channel.js"; import prepareNpm from "./lib/prepare.js"; import publishNpm from "./lib/publish.js"; let verified; +let releaseVerified; let prepared; const npmrc = temporaryFile({ name: ".npmrc" }); @@ -43,12 +45,13 @@ export async function verifyConditions(pluginConfig, context) { verified = true; } -export async function prepare(pluginConfig, context) { - const errors = verified ? [] : verifyNpmConfig(pluginConfig); +export async function verifyRelease(pluginConfig, context) { + const errors = verifyNpmConfig(pluginConfig); try { - // Reload package.json in case a previous external step updated it const pkg = await getPkg(pluginConfig, context); + + // Verify the npm authentication only if `npmPublish` is not false and `pkg.private` is not `true` if (!verified && pluginConfig.npmPublish !== false && pkg.private !== true) { await verifyNpmAuth(npmrc, pkg, context); } @@ -60,19 +63,20 @@ export async function prepare(pluginConfig, context) { throw new AggregateError(errors); } - await prepareNpm(npmrc, pluginConfig, context); - prepared = true; + await verifyNpmRelease(npmrc, pkg, context); + releaseVerified = true; } -export async function publish(pluginConfig, context) { - let pkg; +async function verifyIfNecessary(pluginConfig, context) { const errors = verified ? [] : verifyNpmConfig(pluginConfig); + let pkg; try { // Reload package.json in case a previous external step updated it pkg = await getPkg(pluginConfig, context); - if (!verified && pluginConfig.npmPublish !== false && pkg.private !== true) { - await verifyNpmAuth(npmrc, pkg, context); + if (pluginConfig.npmPublish !== false && pkg.private !== true) { + if (!verified) await verifyNpmAuth(npmrc, pkg, context); + if (!releaseVerified) await verifyNpmRelease(npmrc, pkg, context); } } catch (error) { errors.push(...error.errors); @@ -82,6 +86,18 @@ export async function publish(pluginConfig, context) { throw new AggregateError(errors); } + return pkg; +} + +export async function prepare(pluginConfig, context) { + await verifyIfNecessary(pluginConfig, context); + await prepareNpm(npmrc, pluginConfig, context); + prepared = true; +} + +export async function publish(pluginConfig, context) { + const pkg = await verifyIfNecessary(pluginConfig, context); + if (!prepared) { await prepareNpm(npmrc, pluginConfig, context); } @@ -90,22 +106,6 @@ export async function publish(pluginConfig, context) { } export async function addChannel(pluginConfig, context) { - let pkg; - const errors = verified ? [] : verifyNpmConfig(pluginConfig); - - try { - // Reload package.json in case a previous external step updated it - pkg = await getPkg(pluginConfig, context); - if (!verified && pluginConfig.npmPublish !== false && pkg.private !== true) { - await verifyNpmAuth(npmrc, pkg, context); - } - } catch (error) { - errors.push(...error.errors); - } - - if (errors.length > 0) { - throw new AggregateError(errors); - } - + const pkg = await verifyIfNecessary(pluginConfig, context); return addChannelNpm(npmrc, pluginConfig, pkg, context); } diff --git a/lib/npm.js b/lib/npm.js new file mode 100644 index 00000000..073a0906 --- /dev/null +++ b/lib/npm.js @@ -0,0 +1,30 @@ +export default { + whoami: async ({ userconfig, registry }, execaOpts) => { + const whoamiResult = execa( + "npm", + ["whoami", ...(userconfig ? ["--userconfig", userconfig] : []), ...(registry ? ["--registry", registry] : [])], + execaOpts + ); + whoamiResult.stdout.pipe(stdout, { end: false }); + whoamiResult.stderr.pipe(stderr, { end: false }); + return (await whoamiResult).stdout.split("\n").pop(); + }, + accessListPackages: async ({ principal, pkg, userconfig, registry }, execaOpts) => { + const accessResult = execa( + "npm", + [ + "access", + "list", + "packages", + principal, + pkg, + ...(userconfig ? ["--userconfig", userconfig] : []), + ...(registry ? ["--registry", registry] : []), + ], + execaOpts + ); + accessResult.stdout.pipe(stdout, { end: false }); + accessResult.stderr.pipe(stderr, { end: false }); + return Object.fromEntries((await accessResult).stdout.split("\n").map((line) => line.split(/\s*:\s*/))); + }, +}; diff --git a/lib/verify-auth.js b/lib/verify-auth.js index 99e138e9..c09c256b 100644 --- a/lib/verify-auth.js +++ b/lib/verify-auth.js @@ -4,8 +4,9 @@ import AggregateError from "aggregate-error"; import getError from "./get-error.js"; import getRegistry from "./get-registry.js"; import setNpmrcAuth from "./set-npmrc-auth.js"; +import defaultNpm from "./npm.js"; -export default async function (npmrc, pkg, context) { +export default async function (npmrc, pkg, context, npm = defaultNpm) { const { cwd, env: { DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org/", ...env }, @@ -17,15 +18,16 @@ export default async function (npmrc, pkg, context) { await setNpmrcAuth(npmrc, registry, context); if (normalizeUrl(registry) === normalizeUrl(DEFAULT_NPM_REGISTRY)) { + let user; try { - const whoamiResult = execa("npm", ["whoami", "--userconfig", npmrc, "--registry", registry], { - cwd, - env, - preferLocal: true, - }); - whoamiResult.stdout.pipe(stdout, { end: false }); - whoamiResult.stderr.pipe(stderr, { end: false }); - await whoamiResult; + user = await npm.whoami( + { userconfig: npmrc, registry }, + { + cwd, + env, + preferLocal: true, + } + ); } catch { throw new AggregateError([getError("EINVALIDNPMTOKEN", { registry })]); } diff --git a/lib/verify-release.js b/lib/verify-release.js new file mode 100644 index 00000000..022d190e --- /dev/null +++ b/lib/verify-release.js @@ -0,0 +1,55 @@ +import { execa } from "execa"; +import normalizeUrl from "normalize-url"; +import AggregateError from "aggregate-error"; +import getError from "./get-error.js"; +import getRegistry from "./get-registry.js"; +import setNpmrcAuth from "./set-npmrc-auth.js"; +import defaultNpm from "./npm.js"; + +export default async function (npmrc, pkg, context, npm = defaultNpm) { + const { + cwd, + env: { DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org/", ...env }, + stdout, + stderr, + } = context; + const registry = getRegistry(pkg, context); + + if (context.lastRelease?.version && normalizeUrl(registry) === normalizeUrl(DEFAULT_NPM_REGISTRY)) { + let user; + try { + user = await npm.whoami( + { userconfig: npmrc, registry }, + { + cwd, + env, + preferLocal: true, + } + ); + } catch { + throw new AggregateError([getError("EINVALIDNPMTOKEN", { registry })]); + } + + let packages; + try { + packages = await npm.accessListPackages( + { + principal: user, + pkg: pkg.name, + userconfig: npmrc, + registry, + }, + { + cwd, + env, + preferLocal: true, + } + ); + } catch { + throw new AggregateError([getError("EINVALIDNPMTOKEN", { registry })]); + } + if (packages[pkg.name] !== "read-write") { + throw new AggregateError([getError("EINVALIDNPMTOKEN", { registry })]); + } + } +} diff --git a/test/integration.test.js b/test/integration.test.js index 8c12ae7a..ed75fc74 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -258,6 +258,7 @@ test("Publish the package on a dist-tag", async (t) => { stdout: t.context.stdout, stderr: t.context.stderr, logger: t.context.logger, + lastRelease: {}, nextRelease: { channel: "next", version: "1.0.0" }, } ); diff --git a/test/verify-auth.test.js b/test/verify-auth.test.js new file mode 100644 index 00000000..b435f38c --- /dev/null +++ b/test/verify-auth.test.js @@ -0,0 +1,44 @@ +import path from "path"; +import test from "ava"; +import fs from "fs-extra"; +import { temporaryDirectory, temporaryFile } from "tempy"; +import { execa } from "execa"; +import { stub } from "sinon"; +import { WritableStreamBuffer } from "stream-buffers"; +import verifyNpmAuth from "../lib/verify-auth.js"; + +test.beforeEach((t) => { + t.context.log = stub(); + t.context.logger = { log: t.context.log }; + t.context.stdout = new WritableStreamBuffer(); + t.context.stderr = new WritableStreamBuffer(); +}); + +test("Verify valid token and access", async (t) => { + const cwd = temporaryDirectory(); + const npmrc = temporaryFile({ name: ".npmrc" }); + const packagePath = path.resolve(cwd, "package.json"); + const pkg = { name: "foo", version: "0.0.0-dev" }; + await fs.outputJson(packagePath, pkg); + + const npm = { + whoami: async () => "bob", + accessListPackages: async () => ({}), + }; + + await t.notThrowsAsync(() => + verifyNpmAuth( + npmrc, + pkg, + { + cwd, + env: {}, + stdout: t.context.stdout, + stderr: t.context.stderr, + nextRelease: { version: "1.0.0" }, + logger: t.context.logger, + }, + npm + ) + ); +}); diff --git a/test/verify-release.test.js b/test/verify-release.test.js new file mode 100644 index 00000000..f0b9d375 --- /dev/null +++ b/test/verify-release.test.js @@ -0,0 +1,167 @@ +import path from "path"; +import test from "ava"; +import fs from "fs-extra"; +import { temporaryDirectory, temporaryFile } from "tempy"; +import { execa } from "execa"; +import { stub } from "sinon"; +import { WritableStreamBuffer } from "stream-buffers"; +import verifyNpmRelease from "../lib/verify-release.js"; + +test.beforeEach((t) => { + t.context.log = stub(); + t.context.logger = { log: t.context.log }; + t.context.stdout = new WritableStreamBuffer(); + t.context.stderr = new WritableStreamBuffer(); +}); + +test("Verify valid token and access", async (t) => { + const cwd = temporaryDirectory(); + const npmrc = temporaryFile({ name: ".npmrc" }); + const packagePath = path.resolve(cwd, "package.json"); + const pkg = { name: "foo", version: "0.0.0-dev" }; + await fs.outputJson(packagePath, pkg); + + const npm = { + whoami: async () => "bob", + accessListPackages: async ({ principal, pkg }) => { + t.is(principal, "bob"); + t.is(pkg, "foo"); + return Object.fromEntries( + [ + ["foo", "read-write"], + ["bar", "read"], + ].filter((entry) => !pkg || entry[0] === pkg) + ); + }, + }; + + await verifyNpmRelease( + npmrc, + pkg, + { + cwd, + env: {}, + stdout: t.context.stdout, + stderr: t.context.stderr, + lastRelease: { version: "1.0.0" }, + nextRelease: { version: "1.0.1" }, + logger: t.context.logger, + }, + npm + ); +}); + +test("Rejects when user only has read access", async (t) => { + const cwd = temporaryDirectory(); + const npmrc = temporaryFile({ name: ".npmrc" }); + const packagePath = path.resolve(cwd, "package.json"); + const pkg = { name: "foo", version: "0.0.0-dev" }; + await fs.outputJson(packagePath, pkg); + + const npm = { + whoami: async () => "bob", + accessListPackages: async ({ principal, pkg }) => { + t.is(principal, "bob"); + t.is(pkg, "foo"); + return Object.fromEntries( + [ + ["foo", "read"], + ["bar", "read"], + ].filter((entry) => !pkg || entry[0] === pkg) + ); + }, + }; + + const { + errors: [error], + } = await t.throwsAsync( + verifyNpmRelease( + npmrc, + pkg, + { + cwd, + env: {}, + stdout: t.context.stdout, + stderr: t.context.stderr, + lastRelease: { version: "1.0.0" }, + nextRelease: { version: "1.0.1" }, + logger: t.context.logger, + }, + npm + ) + ); + + t.is(error.code, "EINVALIDNPMTOKEN"); +}); + +test("Rejects when package isn't found", async (t) => { + const cwd = temporaryDirectory(); + const npmrc = temporaryFile({ name: ".npmrc" }); + const packagePath = path.resolve(cwd, "package.json"); + const pkg = { name: "foo", version: "0.0.0-dev" }; + await fs.outputJson(packagePath, pkg); + + const npm = { + whoami: async () => "bob", + accessListPackages: async ({ principal, pkg }) => { + t.is(principal, "bob"); + t.is(pkg, "foo"); + return Object.fromEntries([["bar", "read"]].filter((entry) => !pkg || entry[0] === pkg)); + }, + }; + + const { + errors: [error], + } = await t.throwsAsync( + verifyNpmRelease( + npmrc, + pkg, + { + cwd, + env: {}, + stdout: t.context.stdout, + stderr: t.context.stderr, + lastRelease: { version: "1.0.0" }, + nextRelease: { version: "1.0.1" }, + logger: t.context.logger, + }, + npm + ) + ); + + t.is(error.code, "EINVALIDNPMTOKEN"); +}); + +test("Doesn't check for package if there is no last release", async (t) => { + const cwd = temporaryDirectory(); + const npmrc = temporaryFile({ name: ".npmrc" }); + const packagePath = path.resolve(cwd, "package.json"); + const pkg = { name: "foo", version: "0.0.0-dev" }; + await fs.outputJson(packagePath, pkg); + + const npm = { + whoami: async () => "bob", + accessListPackages: async ({ principal, pkg }) => { + t.is(principal, "bob"); + t.is(pkg, "foo"); + return Object.fromEntries([["bar", "read"]].filter((entry) => !pkg || entry[0] === pkg)); + }, + }; + + await t.notThrowsAsync( + verifyNpmRelease( + npmrc, + pkg, + { + cwd, + env: {}, + stdout: t.context.stdout, + stderr: t.context.stderr, + lastRelease: {}, + nextRelease: { version: "1.0.0" }, + logger: t.context.logger, + }, + npm + ) + ); +});