diff --git a/packages/cli/src/ai-context/references/configure-playwright-checks.md b/packages/cli/src/ai-context/references/configure-playwright-checks.md index e2d16599..fb28a3c5 100644 --- a/packages/cli/src/ai-context/references/configure-playwright-checks.md +++ b/packages/cli/src/ai-context/references/configure-playwright-checks.md @@ -16,7 +16,11 @@ - In a workspace (monorepo) whose code bundle covers only part of the workspace, the bundled lockfile is pruned automatically: the CLI regenerates it (via `pnpm install --lockfile-only` / `npm install --package-lock-only` / `bun install --lockfile-only` / `yarn install --mode=update-lockfile` in a temp dir) so it only references the packages actually in the bundle — otherwise the remote install would try to fetch dependencies of workspace members that were omitted or shipped as dependency-free placeholder manifests, which fails outright for private packages. Supported for `pnpm-lock.yaml` versions 6/9, `package-lock.json` versions 2/3, the text `bun.lock` version 1 and Yarn Berry `yarn.lock` files (for bun projects, keep registry configuration in `.npmrc`, which bun reads: `bunfig.toml` is not carried into the regeneration — recorded resolutions keep their URLs, but whenever bun declines to reuse the lockfile — it is out of date with a manifest, or a workspace member's name collides with a registry dependency — bun re-resolves those entries against the wrong registry, disclosing the package names to it (typically the public registry), and pruning rejects the result with a warning; for yarn projects, `.yarnrc.yml` is likewise not carried into the regeneration, which is safe because Berry lockfiles are registry-agnostic and the regeneration reuses recorded resolutions without the network — settings like `approvedGitRepositories` and `npmScopes` only affect new resolutions, which pruning never performs — and the regeneration runs with yarn's network access disabled outright, since it never needs it: a lockfile that is out of date with a manifest then fails fast with a warning instead of resolving the missing package against the wrong registry and disclosing its name; yarn's hardened mode is disabled for the same reason; `yarn patch` files under `.yarn/patches` are bundled automatically because the regeneration reads them; a `yarn` binary that resolves to Yarn Classic on a Berry project is refused before it can run, because Classic would silently perform a full install); when a bundled lockfile over-describes a partial-workspace bundle but pruning cannot run — other lockfile formats, Yarn Classic v1 lockfiles, a `yarn` binary that resolves to Yarn Classic on a Berry project (set the `packageManager` field so Corepack provisions Yarn 2+), bun's binary `bun.lockb` (regenerate a text lockfile with `bun install --save-text-lockfile`), the package manager binary not being installed on the machine running the CLI, `excludeLinksFromLockfile`, a recorded pnpmfile checksum without a bundled pnpmfile, a workspace member whose version cannot be determined, among others — the original lockfile ships unchanged and the CLI prints a note saying so. Other skips are silent: nothing to prune (the bundle contains the full workspace, or regeneration produced identical bytes), no bundled lockfile to prune, or pruning disabled via `CHECKLY_LOCKFILE_PRUNE=0`; silent skip reasons are visible via `DEBUG='checkly:cli:services:check-parser:*'`. When pruning runs but cannot produce a provably pruned copy of the original — the lockfile is out of date with a `package.json`, the package manager could not run or timed out, the lockfile could not be read or written, or verification failed, among others — the original ships unchanged with a warning. Set `CHECKLY_LOCKFILE_PRUNE=0` to disable pruning. - Checkly caches installed dependencies between runs, keyed off the workspace's lock file, every workspace member's `package.json` and `.npmrc` (whether or not the member is in the bundle), bundled pnpmfile contents, and the resolved `bundle.packages.embed` tarball set (filtered to what the pruned lockfile still references when pruning applied) — plus, as additional inputs, any synthesized placeholder manifests shipped in the bundle and the pruned lockfile when pruning applied. Because the bundle-specific inputs follow the bundle, the key can change without a file edit — e.g. when a different set of workspace members ends up in the bundle. To force a reinstall declaratively, set `caching.dependencyCache.version` (a string or a safe integer) at the top level of `checkly.config.ts` (not per check — one code bundle serves all Playwright Check Suites) and change its value whenever the cache should be invalidated; scheduled checks pick up the change on the next `checkly deploy`. Unset or empty-string values leave the cache key unchanged, so a dynamic value such as `version: process.env.DEPENDENCY_CACHE_VERSION` is safe when the variable is not always set. For a one-off reinstall during an ad-hoc run, use the `--refresh-cache` flag available on the run/test commands (`checkly test`, `checkly pw-test`, `checkly trigger`, `checkly checks run`) instead; the config value is the persistent knob that also applies to deployed, scheduled checks. - In Checkly CLI v8.0.0 and later, `include` patterns resolve relative to the Playwright config directory, not the project root. If `playwrightConfigPath` points to a subdirectory, adjust `include` globs. Example: `playwrightConfigPath: "./e2e/playwright.config.ts"` with a root fixture at `fixtures/data.json` needs `include: ["../fixtures/data.json"]`. -- If dependencies come from a private registry that Checkly's infrastructure cannot reach (for example an intranet-only Nexus mirror), list them in `bundle.packages.embed` in `checkly.config.ts` — a top-level section: `bundle: { packages: { embed: ['@acme/private-utils', 'legacy-private-pkg@2.1.0'] } }`. Each entry is a package name (embeds every version found in the lockfile) or an exact `name@version` pin; names may contain `*` wildcards (`@acme/*`, `acme-*`, `@acme/*-utils`) where each `*` matches any run of characters except `/` (never crossing the scope separator); a `!` prefix (`!@acme/legacy`, `!@acme/*`, `!legacy@2.1.0`) turns an entry into an exclusion that removes the packages it matches from what the entries *before* it selected, so entries apply in order — `['@acme/*', '!@acme/legacy']` embeds the whole scope except `@acme/legacy`, while the reverse order embeds the whole scope because the exclusion runs before anything has been selected; as long as a spec matches at least one registry package, matches that cannot be embedded are skipped (workspace members silently, git/file/URL dependencies and integrity-less entries with a warning since the runner must fetch those itself), while a spec whose only matches cannot be embedded — or that matches nothing at all — is an error, except that exclusions never error (one that removes nothing is a no-op) and removing every package an earlier entry selected also silences that entry — no error, and no skip warning even for packages it matched but did not exclude, so use `DEBUG='checkly:cli:services:embedded-packages'` to see what such an entry reached; because exclusions only subtract, a list of nothing but `!` entries selects nothing, and a configuration whose entries select no packages at all is reported as a warning (packages dropped later by lockfile pruning are covered by the pruning note above); a pattern embeds every lockfile version of every package it matches, so scope it to the packages the runner genuinely cannot fetch. List every unreachable package by name, including private packages that only appear as transitive dependencies of other private packages — dependencies of listed packages are not embedded automatically. The CLI resolves entries against the workspace-root lockfile (`pnpm-lock.yaml`, `package-lock.json`, the text `bun.lock` or a Yarn Berry `yarn.lock` — Yarn Classic v1 lockfiles are not supported), reuses tarballs from local caches (its own, then npm's) or downloads them from the registry configured in `.npmrc` (only `.npmrc` — bun or yarn users whose registry credentials live solely in `bunfig.toml` or `.yarnrc.yml` must duplicate them into `.npmrc`, or downloads fail with an auth error), verifies each against the lockfile's integrity hash, and ships them inside the code bundle at `.checkly/embedded-packages/*.tgz`, where the runner serves them through a local registry during install. Yarn Berry lockfiles record no npm tarball integrity (Berry checksums cover yarn's own cache format), so the CLI resolves the tarball integrity from the registry's package metadata instead — one small metadata request per embedded package on every deploy (the per-version route, falling back to the full packument), even when the tarballs themselves come from a warm cache, so a yarn embed needs registry reachability at deploy time even on a warm cache. When the bundled lockfile is pruned to the code bundle's contents (see the pruning bullet above), the embedded set follows it: packages the pruned lockfile no longer references — dependencies of workspace members that are not part of the bundle — are neither embedded nor downloaded, even if an entry matches them. If a package unexpectedly stops being embedded, the usual cause is that only a workspace member outside the bundle depends on it, in which case the runner never installs it and nothing is wrong; if the checks genuinely need it, make the depending member part of the bundle (import it from check code) rather than disabling pruning — `CHECKLY_LOCKFILE_PRUNE=0` restores the unfiltered set but reintroduces the over-describing lockfile that pruning exists to prevent, so treat it as a last resort. Downloads are cached under the workspace root's `node_modules/.cache/checkly` (in a monorepo that is the repo root, not the member package; override with `CHECKLY_CACHE_DIR`; a per-user cache dir is the fallback when the project location isn't writable), so nothing lands in the project outside `node_modules`. CI setups that cache `node_modules` — or platforms that preserve `node_modules/.cache` — persist the tarballs automatically; otherwise persist `CHECKLY_CACHE_DIR` in CI to avoid re-downloading (note `npm ci` deletes `node_modules` wholesale, unlike incremental pnpm installs). The machine running `checkly deploy`/`test` needs registry access on a cold cache — but only for the tarballs actually shipped, not for pruned-away ones. Changing the resolved set of embedded packages invalidates the runner's dependency cache, so the next run reinstalls with the new tarballs. Applies to Playwright Check Suites only, not browser or multistep checks. +<<<<<<< HEAD +- If dependencies come from a private registry that Checkly's infrastructure cannot reach (for example an intranet-only Nexus mirror), list them in `bundle.packages.embed` in `checkly.config.ts` — a top-level section: `bundle: { packages: { embed: ['@acme/private-utils', 'legacy-private-pkg@2.1.0'] } }`. Each entry is a package name (embeds every version found in the lockfile) or an exact `name@version` pin; names may contain `*` wildcards (`@acme/*`, `acme-*`, `@acme/*-utils`) where each `*` matches any run of characters except `/` (never crossing the scope separator); a `!` prefix (`!@acme/legacy`, `!@acme/*`, `!legacy@2.1.0`) turns an entry into an exclusion that removes the packages it matches from what the entries *before* it selected, so entries apply in order — `['@acme/*', '!@acme/legacy']` embeds the whole scope except `@acme/legacy`, while the reverse order embeds the whole scope because the exclusion runs before anything has been selected; as long as a spec matches at least one registry package, matches that cannot be embedded are skipped (workspace members silently, git/file/URL dependencies and integrity-less entries with a warning since the runner must fetch those itself), while a spec whose only matches cannot be embedded — or that matches nothing at all — is an error, except that exclusions never error (one that removes nothing is a no-op) and removing every package an earlier entry selected also silences that entry — no error, and no skip warning even for packages it matched but did not exclude, so use `DEBUG='checkly:cli:services:embedded-packages'` to see what such an entry reached; because exclusions only subtract, a list of nothing but `!` entries selects nothing, and a configuration whose entries select no packages at all is reported as a warning (packages dropped later by lockfile pruning are covered by the pruning note above); a pattern embeds every lockfile version of every package it matches, so scope it to the packages the runner genuinely cannot fetch. List every unreachable package by name, including private packages that only appear as transitive dependencies of other private packages — dependencies of listed packages are not embedded automatically. The CLI resolves entries against the workspace-root lockfile (`pnpm-lock.yaml`, `package-lock.json`, the text `bun.lock` or a Yarn Berry `yarn.lock` — Yarn Classic v1 lockfiles are not supported), reuses tarballs from local caches (its own, then npm's) or downloads them from the registry configured in `.npmrc` (credentials come from `npm_config_*` environment variables, the project, workspace-root and user `.npmrc` files, and pnpm's global `auth.ini` — where `pnpm login` writes tokens on pnpm 11+, outranking the user `.npmrc` for pnpm projects and acting as a fallback for others; bun or yarn users whose registry credentials live solely in `bunfig.toml` or `.yarnrc.yml` must duplicate them into `.npmrc` or set `npm_config_*`, or downloads fail with an auth error), verifies each against the lockfile's integrity hash, and ships them inside the code bundle at `.checkly/embedded-packages/*.tgz`, where the runner serves them through a local registry during install. Yarn Berry lockfiles record no npm tarball integrity (Berry checksums cover yarn's own cache format), so the CLI resolves the tarball integrity from the registry's package metadata instead — one small metadata request per embedded package on every deploy (the per-version route, falling back to the full packument), even when the tarballs themselves come from a warm cache, so a yarn embed needs registry reachability at deploy time even on a warm cache. When the bundled lockfile is pruned to the code bundle's contents (see the pruning bullet above), the embedded set follows it: packages the pruned lockfile no longer references — dependencies of workspace members that are not part of the bundle — are neither embedded nor downloaded, even if an entry matches them. If a package unexpectedly stops being embedded, the usual cause is that only a workspace member outside the bundle depends on it, in which case the runner never installs it and nothing is wrong; if the checks genuinely need it, make the depending member part of the bundle (import it from check code) rather than disabling pruning — `CHECKLY_LOCKFILE_PRUNE=0` restores the unfiltered set but reintroduces the over-describing lockfile that pruning exists to prevent, so treat it as a last resort. Downloads are cached under the workspace root's `node_modules/.cache/checkly` (in a monorepo that is the repo root, not the member package; override with `CHECKLY_CACHE_DIR`; a per-user cache dir is the fallback when the project location isn't writable), so nothing lands in the project outside `node_modules`. CI setups that cache `node_modules` — or platforms that preserve `node_modules/.cache` — persist the tarballs automatically; otherwise persist `CHECKLY_CACHE_DIR` in CI to avoid re-downloading (note `npm ci` deletes `node_modules` wholesale, unlike incremental pnpm installs). The machine running `checkly deploy`/`test` needs registry access on a cold cache — but only for the tarballs actually shipped, not for pruned-away ones. Changing the resolved set of embedded packages invalidates the runner's dependency cache, so the next run reinstalls with the new tarballs. Applies to Playwright Check Suites only, not browser or multistep checks. +======= +- If dependencies come from a private registry that Checkly's infrastructure cannot reach (for example an intranet-only Nexus mirror), list them in `bundle.packages.embed` in `checkly.config.ts` — a top-level section: `bundle: { packages: { embed: ['@acme/private-utils', 'legacy-private-pkg@2.1.0'] } }`. Each entry is a package name (embeds every version found in the lockfile) or an exact `name@version` pin; names may contain `*` wildcards (`@acme/*`, `acme-*`, `@acme/*-utils`) where each `*` matches any run of characters except `/` (never crossing the scope separator); as long as a spec matches at least one registry package, matches that cannot be embedded are skipped (workspace members silently, git/file/URL dependencies and integrity-less entries with a warning since the runner must fetch those itself), while a spec whose only matches cannot be embedded — or that matches nothing at all — is an error; a pattern embeds every lockfile version of every package it matches, so scope it to the packages the runner genuinely cannot fetch. List every unreachable package by name, including private packages that only appear as transitive dependencies of other private packages — dependencies of listed packages are not embedded automatically. The CLI resolves entries against the workspace-root lockfile (`pnpm-lock.yaml` or `package-lock.json`), reuses tarballs from local caches (its own, then npm's) or downloads them from the registry configured in `.npmrc` (credentials come from `npm_config_*` environment variables, the project, workspace-root and user `.npmrc` files, and pnpm's global `auth.ini` — where `pnpm login` writes tokens on pnpm 11+, outranking the user `.npmrc` for pnpm projects and acting as a fallback for others. A download that fails to authenticate names where the credentials came from — a config key and its file, an environment variable, or the lockfile that recorded the URL — so tell the user to read the error rather than guessing which source to edit), verifies each against the lockfile's integrity hash, and ships them inside the code bundle at `.checkly/embedded-packages/*.tgz`, where the runner serves them through a local registry during install. Downloads are cached under the workspace root's `node_modules/.cache/checkly` (in a monorepo that is the repo root, not the member package; override with `CHECKLY_CACHE_DIR`; a per-user cache dir is the fallback when the project location isn't writable), so nothing lands in the project outside `node_modules`. CI setups that cache `node_modules` — or platforms that preserve `node_modules/.cache` — persist the tarballs automatically; otherwise persist `CHECKLY_CACHE_DIR` in CI to avoid re-downloading (note `npm ci` deletes `node_modules` wholesale, unlike incremental pnpm installs). The machine running `checkly deploy`/`test` needs registry access on a cold cache. Changing the resolved set of embedded packages invalidates the runner's dependency cache, so the next run reinstalls with the new tarballs. Applies to Playwright Check Suites only, not browser or multistep checks. +>>>>>>> cb05fe04 (fix(cli): never echo a URL that cannot be parsed safely [RED-891]) ## Install troubleshooting diff --git a/packages/cli/src/services/embedded-packages/__tests__/diagnostics.spec.ts b/packages/cli/src/services/embedded-packages/__tests__/diagnostics.spec.ts new file mode 100644 index 00000000..99c491d5 --- /dev/null +++ b/packages/cli/src/services/embedded-packages/__tests__/diagnostics.spec.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from 'vitest' + +import { UNPRINTABLE_URL, downloadFailureHint, redactUrl } from '../diagnostics.js' +import { ConfigOrigin, LoadedNpmrcConfig } from '../npmrc.js' + +function npmrc (overrides: Partial = {}): LoadedNpmrcConfig { + return { + config: new Map(), + files: ['/ws/.npmrc', '/home/u/.npmrc'], + unreadable: [], + origins: new Map(), + ...overrides, + } +} + +describe('redactUrl()', () => { + // Only scheme and host survive. The path goes too: some registries take + // a token as a path segment, and the package name and version the path + // encodes are already stated separately in every message that shows a URL. + it.each([ + ['userinfo', 'https://user:tok@nexus.local/npm/foo.tgz', 'https://nexus.local'], + ['a port with userinfo', 'https://u:p@nexus.local:8443/npm/f.tgz', 'https://nexus.local:8443'], + ['a query, which may hold a pre-signed signature', + 'https://cdn.example.com/f.tgz?X-Amz-Signature=deadbeef', 'https://cdn.example.com'], + ['a fragment', 'https://cdn.example.com/f.tgz#tok=deadbeef', 'https://cdn.example.com'], + ['a token in a path segment', 'https://nexus.local/s3cret-token/npm/f.tgz', 'https://nexus.local'], + // The WHATWG parser strips surrounding whitespace, so this is a normal + // parse rather than one of the malformed shapes below. + ['userinfo, ignoring leading whitespace', ' https://user:tok@nexus.local/x', 'https://nexus.local'], + ])('keeps only scheme and host, dropping %s', (_label, input, expected) => { + expect(redactUrl(input)).toBe(expected) + }) + + // Each of these leaked a credential through an earlier string-surgery + // implementation. None can be redacted reliably — telling userinfo from a + // path in a malformed string needs a parser — so none is echoed at all. + it.each([ + ['no scheme and no slashes', 'admin:s3cret@nexus.local/npm/'], + ['an @ inside the password', '//user:p@ss@nexus.local/npm/'], + ['a scheme with an out-of-range port', 'https://user:tok@nexus.local:99999/x'], + ['whitespace inside the credential', 'https://user:pa ss@nexus.local:99999/x'], + ['an extra leading slash', '///user:tok@nexus.local/x'], + ['an unencoded slash in the password', '//user:pa/ss@nexus.local/x'], + ['a scheme-less host and port', 'nexus.local:8443/npm/@acme/foo.tgz'], + ])('withholds an unusable URL with %s', (_label, input) => { + const redacted = redactUrl(input) + expect(redacted).toBe(UNPRINTABLE_URL) + for (const secret of ['s3cret', 'tok', 'p@ss', 'pa ss', 'pa/ss']) { + expect(redacted).not.toContain(secret) + } + }) +}) + +describe('downloadFailureHint()', () => { + it('says nothing for a status authentication cannot explain', () => { + expect(downloadFailureHint(500, undefined, npmrc())).toBe('') + }) + + // The URL in the message is the one that was requested, so a hop has to + // be reported whatever the status — otherwise the message names a host + // that produced nothing. + it('reports a redirect for a status authentication cannot explain', () => { + expect(downloadFailureHint(500, undefined, npmrc(), { host: 'cdn.example.com' })) + .toContain(`redirected to 'cdn.example.com', which is what answered`) + }) + + it('does not claim the redirect target answered when nothing did', () => { + // No status means no response at all — a reset or a timeout. + const hint = downloadFailureHint(undefined, undefined, npmrc(), { host: 'cdn.example.com' }) + expect(hint).toContain(`redirected to 'cdn.example.com' before it failed`) + expect(hint).not.toContain('is what answered') + }) + + it('names every source consulted when no credentials matched', () => { + const hint = downloadFailureHint(401, undefined, npmrc()) + expect(hint).toContain('npm_config_* environment variables') + expect(hint).toContain('/ws/.npmrc') + expect(hint).toContain('/home/u/.npmrc') + }) + + it('hedges a 404 as a possible authorization failure', () => { + expect(downloadFailureHint(404, undefined, npmrc())) + .toMatch(/may answer 404 for a package you are not authorized to see/) + }) + + it('names the file a rejected credential came from', () => { + const origins = new Map([ + ['//nexus.local/:_authToken', { kind: 'file', path: '/home/u/.npmrc' }], + ]) + const hint = downloadFailureHint(401, { from: 'config', keys: ['//nexus.local/:_authToken'] }, npmrc({ origins })) + expect(hint).toContain(`'//nexus.local/:_authToken' in '/home/u/.npmrc'`) + expect(hint).toMatch(/were rejected/) + }) + + it('names an environment variable by its verbatim spelling', () => { + const origins = new Map([ + ['registry', { kind: 'env', variable: 'NPM_CONFIG_REGISTRY' }], + ]) + const hint = downloadFailureHint(401, { from: 'config', keys: ['registry'] }, npmrc({ origins })) + expect(hint).toContain(`the 'NPM_CONFIG_REGISTRY' environment variable`) + expect(hint).not.toContain('npm_config_registry') + }) + + it('reports both halves of a username/password pair separately', () => { + const origins = new Map([ + ['//h/:username', { kind: 'file', path: '/ws/.npmrc' }], + ['//h/:_password', { kind: 'env', variable: 'npm_config_//h/:_password' }], + ]) + const hint = downloadFailureHint(401, { from: 'config', keys: ['//h/:username', '//h/:_password'] }, npmrc({ origins })) + expect(hint).toContain(`'//h/:username' in '/ws/.npmrc'`) + expect(hint).toContain(`the 'npm_config_//h/:_password' environment variable`) + }) + + it('blames a redirect that dropped the credentials, not the credentials', () => { + const hint = downloadFailureHint( + 404, + { from: 'config', keys: ['//h/:_authToken'] }, + npmrc(), + { host: 'cdn.example.com', credentialsDropped: true }, + ) + expect(hint).toContain(`redirected to 'cdn.example.com'`) + expect(hint).not.toMatch(/were rejected/) + }) + + // Without this the reader is told to configure credentials for a host + // that never asked for any. + it('mentions a redirect even when no credentials were configured', () => { + const hint = downloadFailureHint(404, undefined, npmrc(), { host: 'cdn.example.com' }) + expect(hint).toContain(`redirected to 'cdn.example.com'`) + expect(hint).toContain('which is what answered') + }) + + it('mentions a redirect that carried the credentials through', () => { + const hint = downloadFailureHint( + 401, + { from: 'config', keys: ['//h/:_authToken'] }, + npmrc(), + { host: 'other.example.com' }, + ) + expect(hint).toMatch(/were rejected/) + expect(hint).toContain(`redirect to 'other.example.com'`) + }) + + it('reports a config file that could not be read', () => { + const hint = downloadFailureHint(401, undefined, npmrc({ unreadable: ['/home/u/pnpm/auth.ini'] })) + expect(hint).toContain('/home/u/pnpm/auth.ini') + expect(hint).toMatch(/could not be read/) + }) + + it('never repeats a credential value', () => { + // The hint is assembled from keys and paths only; nothing in its inputs + // carries a value, and this pins that the config map is not consulted. + const config = new Map([['//h/:_authToken', 'super-secret']]) + const hint = downloadFailureHint(401, { from: 'config', keys: ['//h/:_authToken'] }, npmrc({ config })) + expect(hint).not.toContain('super-secret') + }) +}) diff --git a/packages/cli/src/services/embedded-packages/__tests__/lockfile-packages.spec.ts b/packages/cli/src/services/embedded-packages/__tests__/lockfile-packages.spec.ts index d3c88463..319820b2 100644 --- a/packages/cli/src/services/embedded-packages/__tests__/lockfile-packages.spec.ts +++ b/packages/cli/src/services/embedded-packages/__tests__/lockfile-packages.spec.ts @@ -6,6 +6,7 @@ import { describe, it, expect } from 'vitest' import { UnsupportedLockfileError, + isPnpmLockfile, loadLockfilePackages, parseBunLockfilePackages, parseLockfilePackagesContent, @@ -14,6 +15,23 @@ import { parseYarnLockfilePackages, } from '../lockfile-packages.js' +describe('isPnpmLockfile()', () => { + it('recognizes a pnpm lockfile by basename', () => { + expect(isPnpmLockfile('pnpm-lock.yaml')).toBe(true) + expect(isPnpmLockfile(path.join('/ws', 'pnpm-lock.yaml'))).toBe(true) + }) + + it('rejects other lockfiles', () => { + expect(isPnpmLockfile(path.join('/ws', 'package-lock.json'))).toBe(false) + expect(isPnpmLockfile(path.join('/ws', 'yarn.lock'))).toBe(false) + expect(isPnpmLockfile(path.join('/ws', 'bun.lock'))).toBe(false) + }) + + it('does not match a directory that merely contains the name', () => { + expect(isPnpmLockfile(path.join('/ws', 'pnpm-lock.yaml', 'nested.json'))).toBe(false) + }) +}) + describe('parsePnpmLockfilePackages()', () => { it('parses v9 registry entries', () => { const { registry, excluded } = parsePnpmLockfilePackages(` diff --git a/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts b/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts index 821d774c..780f1e40 100644 --- a/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts +++ b/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts @@ -677,6 +677,44 @@ packages: {} expect(requests[0].authorization).toBe('Bearer secret') }) + // Both cases deliberately put a *different* token in each file: with a + // token in only one of them, either ordering resolves the same + // credential and the test could not detect inverted precedence. + // XDG_CONFIG_HOME pins pnpm's config dir on every platform, so neither + // test has to branch on the real process.platform. + const writeCompetingTokens = async () => { + const nerfDart = `//127.0.0.1:${(server.address() as AddressInfo).port}/` + await fs.mkdir(path.join(homedir, 'pnpm'), { recursive: true }) + await fs.writeFile(path.join(homedir, 'pnpm', 'auth.ini'), `${nerfDart}:_authToken=pnpm-token\n`) + await fs.writeFile(path.join(homedir, '.npmrc'), `${nerfDart}:_authToken=npmrc-token\n`) + return { CHECKLY_CACHE_DIR: cacheDir, XDG_CONFIG_HOME: homedir } + } + + it('prefers the pnpm auth file over the user .npmrc for a pnpm lockfile', async () => { + const env = await writeCompetingTokens() + + await materializeAll(makeMaterializer(['bar@2.0.0'], { env })) + expect(requests[0].authorization).toBe('Bearer pnpm-token') + }) + + it('prefers the user .npmrc over the pnpm auth file for an npm lockfile', async () => { + const env = await writeCompetingTokens() + + // No `resolved` field: npm lockfiles normally carry one, and it would + // be used verbatim, sending the request to the real registry instead + // of this test's server. + const npmLockfilePath = path.join(workspaceRoot, 'package-lock.json') + await fs.writeFile(npmLockfilePath, JSON.stringify({ + lockfileVersion: 3, + packages: { + 'node_modules/bar': { version: '2.0.0', integrity: barIntegrity }, + }, + })) + + await materializeAll(makeMaterializer(['bar@2.0.0'], { lockfilePath: npmLockfilePath, env })) + expect(requests[0].authorization).toBe('Bearer npmrc-token') + }) + it('prefers a lockfile-recorded tarball URL over the derived one', async () => { await fs.writeFile(lockfilePath, ` lockfileVersion: '9.0' @@ -713,6 +751,288 @@ packages: .rejects.toThrow(/Failed to download embedded package 'secured@1\.0\.0'.*HTTP 401.*credentials/s) }) + describe('authentication hints', () => { + const securedLockfile = ` +lockfileVersion: '9.0' +packages: + secured@1.0.0: + resolution: {integrity: ${barIntegrity}} +` + + it('names every consulted config file when no credentials matched', async () => { + await fs.writeFile(lockfilePath, securedLockfile) + + // XDG_CONFIG_HOME pins auth.ini's location: the production code + // uses the real process.platform, whose default differs per OS. + const error = await materializeAll(makeMaterializer(['secured'], { + env: { CHECKLY_CACHE_DIR: cacheDir, XDG_CONFIG_HOME: homedir }, + })).catch(err => err) + expect(error.message).toMatch(/No credentials for this registry were found in/) + expect(error.message).toContain(path.join(workspaceRoot, '.npmrc')) + expect(error.message).toContain(path.join(homedir, '.npmrc')) + // pnpm's auth.ini is named alongside the .npmrc files, so a pnpm + // user is not told to edit a file their credentials do not live in. + expect(error.message).toContain(path.join(homedir, 'pnpm', 'auth.ini')) + }) + + it('names the file a rejected credential came from', async () => { + await fs.writeFile(lockfilePath, securedLockfile) + const workspaceNpmrc = path.join(workspaceRoot, '.npmrc') + const nerfDart = `//127.0.0.1:${(server.address() as AddressInfo).port}/` + await fs.writeFile(workspaceNpmrc, [ + `registry=${serverUrl}`, + `${nerfDart}:_authToken=wrong-token`, + ].join('\n')) + + const error = await materializeAll(makeMaterializer(['secured'])).catch(err => err) + expect(error.message).toMatch(/credentials sent for this registry were rejected/) + expect(error.message).toMatch(/expired token/) + // Naming the exact key and file is the whole point: several files + // can supply a credential, and "yours was rejected" without saying + // which one leaves the reader as stuck as a bare status code. + expect(error.message).toContain(`'${nerfDart}:_authToken' in '${workspaceNpmrc}'`) + // Never the credential itself. + expect(error.message).not.toContain('wrong-token') + }) + + it('names the environment variable when the credential came from one', async () => { + await fs.writeFile(lockfilePath, securedLockfile) + const { port } = server.address() as AddressInfo + const envKey = `npm_config_//127.0.0.1:${port}/:_authToken` + + const error = await materializeAll(makeMaterializer(['secured'], { + env: { CHECKLY_CACHE_DIR: cacheDir, [envKey]: 'env-token' }, + })).catch(err => err) + // The stored key has the npm_config_ prefix stripped, so the hint + // must name the variable itself or it names nothing searchable. + expect(error.message).toContain(`the '${envKey}' environment variable`) + expect(error.message).not.toContain('env-token') + }) + + it('names an uppercase environment variable by its real spelling', async () => { + await fs.writeFile(lockfilePath, securedLockfile) + // Shells and CI systems routinely uppercase these. The config map + // case-folds the key, so echoing the key would print a name that + // does not exist in the environment. + const envKey = 'NPM_CONFIG_REGISTRY' + + const error = await materializeAll(makeMaterializer(['secured'], { + env: { CHECKLY_CACHE_DIR: cacheDir, [envKey]: `http://user:pass@127.0.0.1:${ + (server.address() as AddressInfo).port}/` }, + })).catch(err => err) + expect(error.message).toContain(`the '${envKey}' environment variable`) + expect(error.message).not.toContain('npm_config_registry') + expect(error.message).not.toContain('pass@') + }) + + it('attributes credentials in a lockfile-recorded URL to the lockfile', async () => { + const { port } = server.address() as AddressInfo + // npm lockfiles record a `resolved` URL verbatim, and it can carry + // userinfo — in which case no config key is to blame for it. + await fs.writeFile(lockfilePath, ` +lockfileVersion: '9.0' +packages: + secured@1.0.0: + resolution: {integrity: ${barIntegrity}, tarball: http://user:pass@127.0.0.1:${port}/secured/-/secured-1.0.0.tgz} +`) + + const error = await materializeAll(makeMaterializer(['secured'])).catch(err => err) + expect(error.message).toContain(`came from the tarball URL recorded in '${lockfilePath}'`) + expect(error.message).not.toContain('pass@') + }) + + it('blames a cross-host redirect rather than the credentials', async () => { + await fs.writeFile(lockfilePath, securedLockfile) + const { port } = server.address() as AddressInfo + const workspaceNpmrc = path.join(workspaceRoot, '.npmrc') + await fs.writeFile(workspaceNpmrc, [ + `registry=${serverUrl}`, + `//127.0.0.1:${port}/:_authToken=good-token`, + ].join('\n')) + // A second port on the same address: follow-redirects compares the + // host INCLUDING the port, so this is a different host to it and + // the header is stripped — deterministic, with no DNS involved. + const cdn = http.createServer((req, res) => { + requests.push({ url: req.url!, authorization: req.headers.authorization }) + res.statusCode = 404 + res.end('not found') + }) + await new Promise(resolve => cdn.listen(0, '127.0.0.1', resolve)) + const cdnPort = (cdn.address() as AddressInfo).port + + server.removeAllListeners('request') + server.on('request', (req, res) => { + requests.push({ url: req.url!, authorization: req.headers.authorization }) + res.statusCode = 302 + res.setHeader('location', `http://127.0.0.1:${cdnPort}${req.url!}`) + res.end() + }) + + try { + const error = await materializeAll(makeMaterializer(['secured'])).catch(err => err) + // The redirect target never received the token, so saying it was + // rejected would send the reader to rotate a working credential. + expect(error.message).toContain(`redirected to '127.0.0.1:${cdnPort}'`) + expect(error.message).toMatch(/dropped rather than forwarded/) + expect(error.message).not.toMatch(/were rejected/) + // The attribution survives, so the reader still learns which + // source the original host was given. + expect(error.message).toContain(`'//127.0.0.1:${port}/:_authToken' in '${workspaceNpmrc}'`) + expect(requests[0].authorization).toBe('Bearer good-token') + expect(requests[1]?.authorization).toBeUndefined() + } finally { + await new Promise((resolve, reject) => + cdn.close(err => err ? reject(err) : resolve())) + } + }) + + it('still blames the credentials when a redirect keeps them', async () => { + await fs.writeFile(lockfilePath, securedLockfile) + const { port } = server.address() as AddressInfo + const workspaceNpmrc = path.join(workspaceRoot, '.npmrc') + await fs.writeFile(workspaceNpmrc, [ + `registry=${serverUrl}`, + `//127.0.0.1:${port}/:_authToken=good-token`, + ].join('\n')) + // A same-host redirect keeps the Authorization header, so the + // credentials really were seen and rejected. Deriving the drop from + // host comparison rather than observing it would misreport this. + server.removeAllListeners('request') + server.on('request', (req, res) => { + requests.push({ url: req.url!, authorization: req.headers.authorization }) + if (req.url === '/secured/-/secured-1.0.0.tgz') { + res.statusCode = 302 + res.setHeader('location', `http://127.0.0.1:${port}/moved/secured.tgz`) + res.end() + return + } + res.statusCode = 401 + res.end('unauthorized') + }) + + const error = await materializeAll(makeMaterializer(['secured'])).catch(err => err) + expect(error.message).toMatch(/were rejected/) + // Asserted positively: the credentials survived the hop, so the + // message must say they were carried through it rather than + // dropped. A negative assertion here passed on the coincidence + // that the two sentences differ by one word. + expect(error.message).toMatch(/carried through a redirect to/) + expect(error.message).not.toMatch(/dropped rather than forwarded/) + expect(requests[1]?.authorization).toBe('Bearer good-token') + }) + + it('lists the environment channel among the places it looked', async () => { + await fs.writeFile(lockfilePath, securedLockfile) + + const error = await materializeAll(makeMaterializer(['secured'])).catch(err => err) + // npm_config_* outranks every file, so omitting it would send the + // reader to edit files that a set variable would override anyway. + expect(error.message).toContain(`'npm_config_* environment variables'`) + }) + + it('names both files when a username/password pair is split across them', async () => { + await fs.writeFile(lockfilePath, securedLockfile) + const workspaceNpmrc = path.join(workspaceRoot, '.npmrc') + const userNpmrc = path.join(homedir, '.npmrc') + const nerfDart = `//127.0.0.1:${(server.address() as AddressInfo).port}/` + await fs.writeFile(workspaceNpmrc, [ + `registry=${serverUrl}`, + `${nerfDart}:username=alice`, + ].join('\n')) + // The password — the half that actually expires — lives elsewhere. + await fs.writeFile(userNpmrc, `${nerfDart}:_password=${Buffer.from('secret').toString('base64')}\n`) + + const error = await materializeAll(makeMaterializer(['secured'])).catch(err => err) + expect(error.message).toContain(`'${nerfDart}:username' in '${workspaceNpmrc}'`) + expect(error.message).toContain(`'${nerfDart}:_password' in '${userNpmrc}'`) + expect(error.message).not.toContain('secret') + }) + + it('explains a 404 that credentials did not unlock', async () => { + await fs.writeFile(lockfilePath, securedLockfile) + const workspaceNpmrc = path.join(workspaceRoot, '.npmrc') + const nerfDart = `//127.0.0.1:${(server.address() as AddressInfo).port}/` + await fs.writeFile(workspaceNpmrc, [ + `registry=${serverUrl}`, + `${nerfDart}:_authToken=insufficient-token`, + ].join('\n')) + // A registry that hides packages the caller may not see answers 404 + // even once credentials are presented. + server.removeAllListeners('request') + server.on('request', (req, res) => { + requests.push({ url: req.url!, authorization: req.headers.authorization }) + res.statusCode = 404 + res.end('not found') + }) + + const error = await materializeAll(makeMaterializer(['secured'])).catch(err => err) + expect(error.message).toMatch(/HTTP 404/) + expect(error.message).toMatch(/Credentials were sent but did not grant access/) + expect(error.message).toContain(`'${nerfDart}:_authToken' in '${workspaceNpmrc}'`) + expect(error.message).not.toContain('insufficient-token') + }) + + // A registry that hides unauthorized packages behind a 404 is the + // case that reads as "package does not exist" without this hint. + it('explains a 404 as a possible authorization failure', async () => { + await fs.writeFile(lockfilePath, ` +lockfileVersion: '9.0' +packages: + missing@1.0.0: + resolution: {integrity: ${barIntegrity}} +`) + + const error = await materializeAll(makeMaterializer(['missing'])).catch(err => err) + expect(error.message).toMatch(/HTTP 404/) + expect(error.message).toMatch(/may answer 404 for a package you are not authorized to see/) + }) + + it('reports a config file that exists but could not be read', async () => { + await fs.writeFile(lockfilePath, securedLockfile) + const authIni = path.join(homedir, 'pnpm', 'auth.ini') + await fs.mkdir(path.dirname(authIni), { recursive: true }) + await fs.writeFile(authIni, 'registry=https://unreadable.example.com/\n') + await fs.chmod(authIni, 0o000) + try { + await fs.readFile(authIni, 'utf8') + return // Running as root: permission bits do not apply. + } catch { + // Expected: the file is genuinely unreadable. + } + + const error = await materializeAll(makeMaterializer(['secured'], { + env: { CHECKLY_CACHE_DIR: cacheDir, XDG_CONFIG_HOME: homedir }, + })).catch(err => err) + expect(error.message).toContain(authIni) + expect(error.message).toMatch(/could not be read/) + }) + + // axios sends userinfo credentials itself and drops the Authorization + // header when it does, so reporting the config entry would name a + // credential that never left the process. + it('attributes credentials embedded in the registry URL to the key that configured it', async () => { + await fs.writeFile(lockfilePath, securedLockfile) + const workspaceNpmrc = path.join(workspaceRoot, '.npmrc') + const { port } = server.address() as AddressInfo + await fs.writeFile(workspaceNpmrc, [ + `registry=http://user:pass@127.0.0.1:${port}/`, + `//127.0.0.1:${port}/:_authToken=unused-token`, + ].join('\n')) + + const error = await materializeAll(makeMaterializer(['secured'])).catch(err => err) + expect(error.message).toMatch(/came from the registry URL configured by/) + expect(error.message).toContain(`'registry' in '${workspaceNpmrc}'`) + expect(error.message).not.toContain('unused-token') + expect(error.message).not.toContain('pass@') + + // The precedence rule rests on axios sending the URL's credentials + // and dropping the Authorization header. Assert the wire, not just + // the wording, so a change in that behaviour fails here. + expect(requests[0].authorization) + .toBe(`Basic ${Buffer.from('user:pass').toString('base64')}`) + }) + }) + it('refuses to materialize when the plan has issues', async () => { await expect(materializeAll(makeMaterializer(['no-such-package']))) .rejects.toThrow(EmbeddedPackageError) @@ -747,6 +1067,24 @@ packages: .rejects.toThrow(/is not a valid URL.*registry/s) }) + // Each of these registry values produces a URL the parser cannot make + // sense of, so redaction falls back to string surgery. Every one of + // them leaked a credential at some point during development. + it.each([ + ['an @ in the password', '//user:p@ss@nexus.local/npm/', ['ss@', 'user:']], + ['no protocol and no leading slashes', 'admin:s3cret@nexus.local/npm/', ['s3cret', 'admin:']], + ['a scheme with an out-of-range port', 'https://user:tok@nexus.local:99999/npm/', ['tok@', 'user:']], + ['whitespace inside the credential', 'https://user:pa ss@nexus.local:99999/npm/', ['pa ss', 'user:']], + ])('redacts credentials from an unparseable registry URL with %s', async (_label, registry, forbidden) => { + await fs.writeFile(path.join(workspaceRoot, '.npmrc'), `registry=${registry}\n`) + + const error = await materializeAll(makeMaterializer(['bar@2.0.0'])).catch(err => err) + expect(error.message).toMatch(/is not a valid URL/) + for (const secret of forbidden) { + expect(error.message).not.toContain(secret) + } + }) + it('redacts registry credentials from download error messages', async () => { const { port } = server.address() as AddressInfo await fs.writeFile( diff --git a/packages/cli/src/services/embedded-packages/__tests__/npmrc.spec.ts b/packages/cli/src/services/embedded-packages/__tests__/npmrc.spec.ts index 8370a4ef..81802801 100644 --- a/packages/cli/src/services/embedded-packages/__tests__/npmrc.spec.ts +++ b/packages/cli/src/services/embedded-packages/__tests__/npmrc.spec.ts @@ -11,8 +11,9 @@ import { loadNpmrcConfig, npmrcConfigFromEnv, parseNpmrc, + pnpmAuthIniPath, resolveAuthHeader, - resolveRegistryUrl, + resolveRegistry, } from '../npmrc.js' describe('parseNpmrc()', () => { @@ -53,25 +54,112 @@ describe('loadNpmrcConfig()', () => { }) it('gives earlier files precedence and merges the rest', async () => { - const config = await loadNpmrcConfig([ - path.join(dir, 'project.npmrc'), - path.join(dir, 'user.npmrc'), + const { config } = await loadNpmrcConfig([ + { path: path.join(dir, 'project.npmrc') }, + { path: path.join(dir, 'user.npmrc') }, ], {}) expect(config.get('registry')).toBe('https://project.example.com/') expect(config.get('//user.example.com/:_authToken')).toBe('user-token') }) it('skips missing files', async () => { - const config = await loadNpmrcConfig([ - path.join(dir, 'does-not-exist.npmrc'), - path.join(dir, 'project.npmrc'), + const { config } = await loadNpmrcConfig([ + { path: path.join(dir, 'does-not-exist.npmrc') }, + { path: path.join(dir, 'project.npmrc') }, ], {}) expect(config.get('registry')).toBe('https://project.example.com/') }) + it('lets the pnpm auth file win over the user .npmrc when it ranks higher', async () => { + const authIni = path.join(dir, 'auth.ini') + const userNpmrc = path.join(dir, 'competing-user.npmrc') + await fs.writeFile(authIni, '//registry.example.com/:_authToken=pnpm-token\n') + await fs.writeFile(userNpmrc, '//registry.example.com/:_authToken=npmrc-token\n') + + const { config: preferred } = await loadNpmrcConfig([{ path: authIni, optional: true }, { path: userNpmrc }], {}) + expect(preferred.get('//registry.example.com/:_authToken')).toBe('pnpm-token') + + const { config: notPreferred } = await loadNpmrcConfig([{ path: userNpmrc }, { path: authIni, optional: true }], {}) + expect(notPreferred.get('//registry.example.com/:_authToken')).toBe('npmrc-token') + }) + + it('fails on an unreadable required file but skips an unreadable optional one', async () => { + const unreadable = path.join(dir, 'unreadable.npmrc') + await fs.writeFile(unreadable, 'registry=https://unreadable.example.com/\n') + await fs.chmod(unreadable, 0o000) + try { + // Running as root defeats permission bits entirely, so only assert + // when the mode actually denies this process. + await fs.readFile(unreadable, 'utf8') + return + } catch { + // Expected: the file is genuinely unreadable. + } + + await expect(loadNpmrcConfig([{ path: unreadable }], {})).rejects.toThrow(/Unable to read npm configuration/) + + const { config, unreadable: skipped } = await loadNpmrcConfig([ + { path: unreadable, optional: true }, + { path: path.join(dir, 'project.npmrc') }, + ], {}) + expect(config.get('registry')).toBe('https://project.example.com/') + // Reported rather than merely skipped, so a later authentication + // failure can say the file was found but not used. + expect(skipped).toEqual([unreadable]) + }) + + it('records which source supplied each key', async () => { + const projectNpmrc = path.join(dir, 'project.npmrc') + const userNpmrc = path.join(dir, 'user.npmrc') + const { origins } = await loadNpmrcConfig( + [{ path: projectNpmrc }, { path: userNpmrc }], + { 'npm_config_//env.example.com/:_authToken': 'env-token' }, + ) + + expect(origins.get('registry')).toEqual({ kind: 'file', path: projectNpmrc }) + expect(origins.get('//user.example.com/:_authToken')).toEqual({ kind: 'file', path: userNpmrc }) + expect(origins.get('//env.example.com/:_authToken')) + .toEqual({ kind: 'env', variable: 'npm_config_//env.example.com/:_authToken' }) + }) + + it('names the environment variable verbatim, whatever its case', async () => { + // The stored key is case-folded, so only the verbatim variable name is + // something the user can search their environment for. + const { origins } = await loadNpmrcConfig([], { NPM_CONFIG_REGISTRY: 'https://env.example.com/' }) + expect(origins.get('registry')).toEqual({ kind: 'env', variable: 'NPM_CONFIG_REGISTRY' }) + expect(origins.get('REGISTRY')).toEqual({ kind: 'env', variable: 'NPM_CONFIG_REGISTRY' }) + }) + + it('reports the config files consulted', async () => { + const projectNpmrc = path.join(dir, 'project.npmrc') + const { files } = await loadNpmrcConfig([{ path: projectNpmrc }], {}) + expect(files).toEqual([projectNpmrc]) + }) + + it('records the origin under the key spelling that actually matched', async () => { + const lowercased = path.join(dir, 'lowercased.npmrc') + await fs.writeFile(lowercased, '//nexus.local/:_authtoken=lower-token\n') + + const { config, origins } = await loadNpmrcConfig([{ path: lowercased }], {}) + const auth = resolveAuthHeader(config, 'https://nexus.local/foo', {}) + + // resolveAuthHeader asks for the canonical `_authToken` spelling but + // matches the lowercase one; the reported key has to be the spelling + // present in origins, or the source cannot be named. + expect(auth?.header).toBe('Bearer lower-token') + expect(origins.get(auth!.keys[0])).toEqual({ kind: 'file', path: lowercased }) + }) + + it('does not report missing files as unreadable', async () => { + const { unreadable } = await loadNpmrcConfig([ + { path: path.join(dir, 'does-not-exist.npmrc'), optional: true }, + ], {}) + expect(unreadable).toEqual([]) + }) + it('gives npm_config_* environment variables precedence over files', async () => { - const config = await loadNpmrcConfig( - [path.join(dir, 'project.npmrc')], + const { config } = await loadNpmrcConfig( + [{ path: path.join(dir, 'project.npmrc') }], { npm_config_registry: 'https://env.example.com/' }, ) expect(config.get('registry')).toBe('https://env.example.com/') @@ -94,35 +182,124 @@ describe('npmrcConfigFromEnv()', () => { const config = npmrcConfigFromEnv({ 'npm_config_//nexus.local/:_authToken': 'env-secret', }) - expect(resolveAuthHeader(config, 'https://nexus.local/foo', {})).toBe('Bearer env-secret') + expect(resolveAuthHeader(config, 'https://nexus.local/foo', {})?.header).toBe('Bearer env-secret') }) }) describe('defaultNpmrcPaths()', () => { it('orders context dir before workspace root before home', () => { - expect(defaultNpmrcPaths('/ws', '/home/user', '/ws/packages/a')).toEqual([ - path.join('/ws/packages/a', '.npmrc'), - path.join('/ws', '.npmrc'), - path.join('/home/user', '.npmrc'), + expect(defaultNpmrcPaths({ + workspaceRoot: '/ws', + homedir: '/home/user', + contextDir: '/ws/packages/a', + })).toEqual([ + { path: path.join('/ws/packages/a', '.npmrc') }, + { path: path.join('/ws', '.npmrc') }, + { path: path.join('/home/user', '.npmrc') }, ]) }) it('deduplicates when the context dir is the workspace root', () => { - expect(defaultNpmrcPaths('/ws', '/home/user', '/ws')).toEqual([ - path.join('/ws', '.npmrc'), - path.join('/home/user', '.npmrc'), + expect(defaultNpmrcPaths({ + workspaceRoot: '/ws', + homedir: '/home/user', + contextDir: '/ws', + })).toEqual([ + { path: path.join('/ws', '.npmrc') }, + { path: path.join('/home/user', '.npmrc') }, + ]) + }) + + it('ranks the pnpm auth file above the user .npmrc when preferred', () => { + expect(defaultNpmrcPaths({ + workspaceRoot: '/ws', + homedir: '/home/user', + pnpmAuthFile: '/cfg/pnpm/auth.ini', + pnpmAuthFilePreferred: true, + })).toEqual([ + { path: path.join('/ws', '.npmrc') }, + { path: '/cfg/pnpm/auth.ini', optional: true }, + { path: path.join('/home/user', '.npmrc') }, + ]) + }) + + it('ranks the pnpm auth file below the user .npmrc when not preferred', () => { + expect(defaultNpmrcPaths({ + workspaceRoot: '/ws', + homedir: '/home/user', + pnpmAuthFile: '/cfg/pnpm/auth.ini', + pnpmAuthFilePreferred: false, + })).toEqual([ + { path: path.join('/ws', '.npmrc') }, + { path: path.join('/home/user', '.npmrc') }, + { path: '/cfg/pnpm/auth.ini', optional: true }, + ]) + }) + + it('omits the pnpm auth file when none is given', () => { + expect(defaultNpmrcPaths({ workspaceRoot: '/ws', homedir: '/home/user' })).toEqual([ + { path: path.join('/ws', '.npmrc') }, + { path: path.join('/home/user', '.npmrc') }, ]) }) }) -describe('resolveRegistryUrl()', () => { +describe('pnpmAuthIniPath()', () => { + const home = path.sep === '/' ? '/home/user' : 'C:\\Users\\user' + + it('uses macOS preferences on darwin', () => { + expect(pnpmAuthIniPath({}, 'darwin', home)) + .toBe(path.join(home, 'Library', 'Preferences', 'pnpm', 'auth.ini')) + }) + + it('uses ~/.config on linux', () => { + expect(pnpmAuthIniPath({}, 'linux', home)).toBe(path.join(home, '.config', 'pnpm', 'auth.ini')) + }) + + it('uses LOCALAPPDATA on win32', () => { + expect(pnpmAuthIniPath({ LOCALAPPDATA: 'C:\\LocalAppData' }, 'win32', home)) + .toBe(path.join('C:\\LocalAppData', 'pnpm', 'config', 'auth.ini')) + }) + + it('falls back to ~/.config on win32 without LOCALAPPDATA', () => { + expect(pnpmAuthIniPath({}, 'win32', home)).toBe(path.join(home, '.config', 'pnpm', 'auth.ini')) + }) + + it('prefers XDG_CONFIG_HOME on every platform', () => { + for (const platform of ['darwin', 'linux', 'win32'] as NodeJS.Platform[]) { + expect(pnpmAuthIniPath({ XDG_CONFIG_HOME: '/xdg' }, platform, home)) + .toBe(path.join('/xdg', 'pnpm', 'auth.ini')) + } + }) + + it('ignores an empty XDG_CONFIG_HOME', () => { + expect(pnpmAuthIniPath({ XDG_CONFIG_HOME: '' }, 'linux', home)) + .toBe(path.join(home, '.config', 'pnpm', 'auth.ini')) + }) + + // An empty value must not be joined as-is: that would yield a relative + // path and read credentials from the current working directory. + it('ignores an empty LOCALAPPDATA', () => { + expect(pnpmAuthIniPath({ LOCALAPPDATA: '' }, 'win32', home)) + .toBe(path.join(home, '.config', 'pnpm', 'auth.ini')) + }) + + // pnpm consults PNPM_HOME for its data and state directories, never for + // the config directory that holds auth.ini. + it('ignores PNPM_HOME', () => { + expect(pnpmAuthIniPath({ PNPM_HOME: '/pnpm-home' }, 'linux', home)) + .toBe(path.join(home, '.config', 'pnpm', 'auth.ini')) + }) +}) + +describe('resolveRegistry()', () => { it('defaults to the public registry', () => { - expect(resolveRegistryUrl(new Map(), 'some-package')).toBe(DEFAULT_REGISTRY_URL) + expect(resolveRegistry(new Map(), 'some-package').url).toBe(DEFAULT_REGISTRY_URL) }) it('uses the registry entry and appends a trailing slash', () => { const config = parseNpmrc('registry=https://nexus.local/repository/npm') - expect(resolveRegistryUrl(config, 'some-package')).toBe('https://nexus.local/repository/npm/') + expect(resolveRegistry(config, 'some-package').url).toBe('https://nexus.local/repository/npm/') }) it('prefers a scoped registry for scoped packages', () => { @@ -130,19 +307,19 @@ describe('resolveRegistryUrl()', () => { 'registry=https://nexus.local/repository/npm/', '@acme:registry=https://nexus.local/repository/npm-private/', ].join('\n')) - expect(resolveRegistryUrl(config, '@acme/private-utils')).toBe('https://nexus.local/repository/npm-private/') - expect(resolveRegistryUrl(config, 'some-package')).toBe('https://nexus.local/repository/npm/') + expect(resolveRegistry(config, '@acme/private-utils').url).toBe('https://nexus.local/repository/npm-private/') + expect(resolveRegistry(config, 'some-package').url).toBe('https://nexus.local/repository/npm/') }) it('expands ${VAR} references from the environment', () => { const config = parseNpmrc('registry=${MY_REGISTRY}') - expect(resolveRegistryUrl(config, 'some-package', { MY_REGISTRY: 'https://example.com' })) + expect(resolveRegistry(config, 'some-package', { MY_REGISTRY: 'https://example.com' }).url) .toBe('https://example.com/') }) it('throws a clear error for unset ${VAR} references', () => { const config = parseNpmrc('registry=${MY_UNSET_REGISTRY}') - expect(() => resolveRegistryUrl(config, 'some-package', {})).toThrow(NpmrcEnvVarError) + expect(() => resolveRegistry(config, 'some-package', {})).toThrow(NpmrcEnvVarError) }) it('ignores unset ${VAR} references in entries that are not used', () => { @@ -150,7 +327,7 @@ describe('resolveRegistryUrl()', () => { 'registry=https://nexus.local/repository/npm/', '//unrelated.example.com/:_authToken=${SOME_UNSET_TOKEN}', ].join('\n')) - expect(resolveRegistryUrl(config, 'some-package', {})).toBe('https://nexus.local/repository/npm/') + expect(resolveRegistry(config, 'some-package', {}).url).toBe('https://nexus.local/repository/npm/') expect(resolveAuthHeader(config, 'https://nexus.local/repository/npm/foo', {})).toBeUndefined() }) }) @@ -158,29 +335,45 @@ describe('resolveRegistryUrl()', () => { describe('resolveAuthHeader()', () => { it('matches an _authToken by nerf dart', () => { const config = parseNpmrc('//nexus.local/repository/npm-private/:_authToken=secret') - const header = resolveAuthHeader( + const auth = resolveAuthHeader( config, 'https://nexus.local/repository/npm-private/@acme/foo/-/foo-1.0.0.tgz', {}, ) - expect(header).toBe('Bearer secret') + expect(auth?.header).toBe('Bearer secret') + // The matched key is reported so a rejected credential can be traced + // back to the file that supplied it. + expect(auth?.keys).toEqual(['//nexus.local/repository/npm-private/:_authToken']) + }) + + it('reports both halves of a username/_password pair', () => { + const config = parseNpmrc([ + '//nexus.local/:username=user', + `//nexus.local/:_password=${Buffer.from('pass').toString('base64')}`, + ].join('\n')) + // Precedence is per key, so the two halves can come from different + // files; naming only the username would point at the half that is not + // secret and cannot expire. + expect(resolveAuthHeader(config, 'https://nexus.local/foo', {})?.keys) + .toEqual(['//nexus.local/:username', '//nexus.local/:_password']) }) it('walks the URL path upward to find host-level credentials', () => { const config = parseNpmrc('//nexus.local/:_authToken=host-secret') - const header = resolveAuthHeader(config, 'https://nexus.local/repository/npm/foo/-/foo-1.0.0.tgz', {}) - expect(header).toBe('Bearer host-secret') + const auth = resolveAuthHeader(config, 'https://nexus.local/repository/npm/foo/-/foo-1.0.0.tgz', {}) + expect(auth?.header).toBe('Bearer host-secret') }) it('includes the port in the nerf dart', () => { const config = parseNpmrc('//nexus.local:8443/:_authToken=port-secret') - expect(resolveAuthHeader(config, 'https://nexus.local:8443/foo/-/foo-1.0.0.tgz', {})).toBe('Bearer port-secret') + expect(resolveAuthHeader(config, 'https://nexus.local:8443/foo/-/foo-1.0.0.tgz', {})?.header) + .toBe('Bearer port-secret') expect(resolveAuthHeader(config, 'https://nexus.local/foo/-/foo-1.0.0.tgz', {})).toBeUndefined() }) it('supports pre-encoded _auth as Basic', () => { const config = parseNpmrc('//nexus.local/:_auth=dXNlcjpwYXNz') - expect(resolveAuthHeader(config, 'https://nexus.local/foo', {})).toBe('Basic dXNlcjpwYXNz') + expect(resolveAuthHeader(config, 'https://nexus.local/foo', {})?.header).toBe('Basic dXNlcjpwYXNz') }) it('supports username and base64 _password as Basic', () => { @@ -188,13 +381,13 @@ describe('resolveAuthHeader()', () => { '//nexus.local/:username=user', `//nexus.local/:_password=${Buffer.from('pass').toString('base64')}`, ].join('\n')) - expect(resolveAuthHeader(config, 'https://nexus.local/foo', {})) + expect(resolveAuthHeader(config, 'https://nexus.local/foo', {})?.header) .toBe(`Basic ${Buffer.from('user:pass').toString('base64')}`) }) it('expands ${VAR} tokens from the environment', () => { const config = parseNpmrc('//nexus.local/:_authToken=${NPM_TOKEN}') - expect(resolveAuthHeader(config, 'https://nexus.local/foo', { NPM_TOKEN: 'env-secret' })) + expect(resolveAuthHeader(config, 'https://nexus.local/foo', { NPM_TOKEN: 'env-secret' })?.header) .toBe('Bearer env-secret') }) diff --git a/packages/cli/src/services/embedded-packages/diagnostics.ts b/packages/cli/src/services/embedded-packages/diagnostics.ts new file mode 100644 index 00000000..722a49fe --- /dev/null +++ b/packages/cli/src/services/embedded-packages/diagnostics.ts @@ -0,0 +1,226 @@ +import { LoadedNpmrcConfig, NPM_CONFIG_ENV_PREFIX } from './npmrc.js' + +/** + * Joins up to 8 items, appending `N more` for the rest — the + * uniform truncation for user-facing lists of packages, versions and + * reasons. + */ +export function capList (items: string[], separator: string, overflow: string): string { + const shown = items.slice(0, 8).join(separator) + return items.length > 8 ? `${shown}${overflow}${items.length - 8} more` : shown +} + +/** Quotes and joins a list of names for a message. */ +export function quotedList (names: string[]): string { + return capList(names.map(name => `'${name}'`), ', ', ' and ') +} + +/** Stand-in for a URL that cannot be shown without risking a credential. */ +export const UNPRINTABLE_URL = '(withheld: unparseable and may contain credentials)' + +/** + * Rebuilds a URL from the parts that cannot carry a credential, so it can + * safely appear in error messages and logs. + * + * Only scheme and host survive. Userinfo, path, query and fragment are + * dropped by construction rather than stripped: a registry URL may embed a + * token in its userinfo, a pre-signed CDN URL puts its signature in the + * query, and some registries take a token as a PATH segment + * (`https://host//npm/`). The path is the component least worth + * keeping anyway — every message that shows a URL already names the + * package and version separately, which is what the path encodes. + * + * A string that does not parse, or parses without a host, yields the + * placeholder instead. Earlier revisions tried to redact such strings with + * a regex and leaked a credential four times over as many review rounds — + * through the first `@` only, through an empty userinfo, through a + * host-less parse, and through an authority that did not start at offset + * zero. Nothing short of a parser can tell userinfo from a path in a + * malformed string, so this follows the precedent already set for proxy + * URLs in `rest/errors.ts` and declines to echo it at all. Callers name the + * configuration key and file that produced the value instead, which is + * where the reader would go to look anyway. + */ +export function redactUrl (url: string): string { + let parsed: URL + try { + parsed = new URL(url) + } catch { + return UNPRINTABLE_URL + } + + // `admin:s3cret@nexus.local/x` parses — as the opaque scheme `admin:` + // with an EMPTY host — leaving the credential in the path, so a host is + // required before anything is echoed. `origin` is not used to rebuild it + // because it is the string "null" for non-special schemes. + if (parsed.host === '') { + return UNPRINTABLE_URL + } + + return `${parsed.protocol}//${parsed.host}` +} + +/** + * Where the credentials sent with a request came from. `config` covers a + * nerf-darted entry; `url` covers userinfo embedded in the registry URL, + * which is configured elsewhere and so carries its own provenance. + */ +export type SentCredentials = + | { from: 'config', keys: string[] } + | { from: 'url', registryKey: string } + | { from: 'url', lockfile: string } + +/** + * Where a tarball URL came from: the lockfile recorded it verbatim, or it + * was built from a registry (`registryKey` absent when nothing configured + * one and the public npm registry was assumed). Needed only to attribute + * credentials the URL itself carries. + */ +export type UrlOrigin = + | { lockfile: string } + | { registryKey?: string } + +/** What a redirect did to the credentials on a request. */ +export interface RedirectOutcome { + /** The host the request was last redirected to. */ + host?: string + /** True when credentials were sent but did not survive the hop. */ + credentialsDropped?: boolean +} + +/** + * Names config keys and where they came from, for a hint sentence. Keys are + * listed individually because precedence is per key: the halves of a + * `username`/`_password` pair can come from different sources. + */ +export function describeConfigKeys (keys: string[], npmrc: LoadedNpmrcConfig): string { + const described = keys.map(key => { + const origin = npmrc.origins.get(key) + switch (origin?.kind) { + // Name the variable verbatim: the stored key has the `npm_config_` + // prefix stripped and may be case-folded, so echoing it would name + // nothing the user can search their environment for. + case 'env': + return `the '${origin.variable}' environment variable` + case 'file': + return `'${key}' in '${origin.path}'` + default: + return `'${key}'` + } + }) + return capList(described, ', ', ' and ') +} + +/** + * The places a credential could have been configured, highest precedence + * first. The environment channel is always consulted and outranks every + * file, so a list that omits it sends people to edit files that cannot win. + */ +function describeConfigSources (npmrc: LoadedNpmrcConfig): string { + return quotedList([ + `${NPM_CONFIG_ENV_PREFIX}* environment variables`, + ...npmrc.files, + ]) +} + +/** + * Names where the credentials on a request came from, as a sentence tail + * ending in a full stop. + */ +function describeSentCredentials (sent: SentCredentials, npmrc: LoadedNpmrcConfig): string { + if (sent.from === 'config') { + return `${describeConfigKeys(sent.keys, npmrc)}.` + } + if ('registryKey' in sent) { + return `the registry URL configured by ${describeConfigKeys([sent.registryKey], npmrc)}.` + } + return `the tarball URL recorded in '${sent.lockfile}'.` +} + +/** + * Explains an HTTP failure that authentication could account for. + * + * 404 gets the same treatment as 401/403 because registries routinely hide + * packages the caller is not authorized to see behind a 404 — npmjs does — + * which otherwise reads as "this package does not exist" and sends people + * looking in entirely the wrong place. The wording stays hedged for 404, + * where a genuinely missing package is equally likely. + * + * Whenever credentials were sent, the message names where they came from. + * They can come from any of several files or an environment variable, so + * "your credentials were rejected" without saying which source supplied + * them leaves the reader exactly as stuck as a bare 404. + */ +export function downloadFailureHint ( + status: number | undefined, + sent: SentCredentials | undefined, + npmrc: LoadedNpmrcConfig, + redirect: RedirectOutcome = {}, +): string { + if (status !== 401 && status !== 403 && status !== 404) { + // The URL in the message is the one that was requested; whatever the + // status, say so when something else handled it. Without a status + // nothing answered at all, so the hop is reported without claiming it + // produced the failure. + if (redirect.host === undefined) { + return '' + } + return status === undefined + ? ` The request was redirected to '${redirect.host}' before it failed.` + : ` The request was redirected to '${redirect.host}', which is what answered.` + } + + const sentences: string[] = [] + + if (redirect.credentialsDropped === true) { + // Never say the credentials were rejected: the host that answered never + // saw them, and blaming them sends the reader to rotate a working + // token. A redirect to an unauthenticated CDN is normal for GitHub + // Packages and for Artifactory or Nexus fronted by object storage, so + // this is often not the fault at all — hence the pointer to the + // redirect target rather than a verdict about it. + sentences.push( + `The request was redirected to '${redirect.host}', and the credentials were dropped rather` + + ` than forwarded there, so that host answered without them. This is normal when a registry` + + ` redirects to a pre-signed URL; look at what that host returned.`, + ) + if (sent !== undefined) { + sentences.push(`The credentials the original host received came from ${describeSentCredentials(sent, npmrc)}`) + } + } else if (sent === undefined) { + sentences.push(`No credentials for this registry were found in ${describeConfigSources(npmrc)}.`) + if (status === 404) { + sentences.push( + `A registry may answer 404 for a package you are not authorized to see, so the package` + + ` may exist but be invisible without credentials.`, + ) + } + if (redirect.host !== undefined) { + // Without this the reader is told to configure credentials for a host + // that never asked for any, when the status came from elsewhere. + sentences.push(`Note that the request was redirected to '${redirect.host}', which is what answered.`) + } + } else { + if (status === 404) { + sentences.push( + `Credentials were sent but did not grant access, so either the package does not exist` + + ` or the credentials do not cover it.`, + ) + } else { + sentences.push(`The credentials sent for this registry were rejected — an expired token fails this way.`) + } + sentences.push(`They came from ${describeSentCredentials(sent, npmrc)}`) + if (redirect.host !== undefined) { + sentences.push(`They were carried through a redirect to '${redirect.host}', which is what answered.`) + } + } + + if (npmrc.unreadable.length > 0) { + sentences.push( + `Note that ${quotedList(npmrc.unreadable)} could not be read, so any credentials it holds` + + ` were not used.`, + ) + } + + return ` ${sentences.join(' ')}` +} diff --git a/packages/cli/src/services/embedded-packages/lockfile-packages.ts b/packages/cli/src/services/embedded-packages/lockfile-packages.ts index 2a0475d1..2a63e37e 100644 --- a/packages/cli/src/services/embedded-packages/lockfile-packages.ts +++ b/packages/cli/src/services/embedded-packages/lockfile-packages.ts @@ -67,6 +67,16 @@ export class UnsupportedLockfileError extends Error { } } +const PNPM_LOCKFILE = 'pnpm-lock.yaml' + +/** + * Whether a lockfile is pnpm's. Callers use this to decide which package + * manager's credential conventions apply to the project. + */ +export function isPnpmLockfile (lockfilePath: string): boolean { + return path.basename(lockfilePath) === PNPM_LOCKFILE +} + /** * Enumerates every package entry in a lockfile, classified into embeddable * registry packages and excluded (git/file/link/integrity-less) entries. @@ -86,7 +96,7 @@ export async function loadLockfilePackages (lockfilePath: string): PromiseN more` for the rest — the - * uniform truncation for user-facing lists of packages, versions and - * reasons. - */ -function capList (items: string[], separator: string, overflow: string): string { - const shown = items.slice(0, 8).join(separator) - return items.length > 8 ? `${shown}${overflow}${items.length - 8} more` : shown -} - -/** - * Removes userinfo credentials from a URL so it can be safely included in - * error messages and logs (a registry URL may embed a token). + * Wraps an axios error from a registry request in an EmbeddedPackageError, + * appending the HTTP status and whatever the caller's hint makes of it. + * `message` is the action-specific prefix (e.g. "Failed to download …"). */ -function redactUrl (url: string): string { - try { - const parsed = new URL(url) - parsed.username = '' - parsed.password = '' - return parsed.toString() - } catch { - // Not parseable as a URL (e.g. a scheme-less registry entry) — strip - // anything that looks like a userinfo segment before displaying it. - return url.replace(/(^|\/\/)[^/@\s]+@/, '$1') - } +function registryHttpError ( + err: any, + message: string, + hint: (status: number | undefined) => string = () => '', +): EmbeddedPackageError { + const status = err?.response?.status + const statusHint = status !== undefined ? ` (HTTP ${status})` : '' + return new EmbeddedPackageError(`${message}${statusHint}.${hint(status)}`, { cause: err }) } /** - * Wraps an axios error from a registry request in an EmbeddedPackageError, - * appending the HTTP status and, for 401/403, a credentials hint. `message` - * is the action-specific prefix (e.g. "Failed to download …"). + * Whether a URL carries credentials in its userinfo component. axios sends + * those itself — and drops any `Authorization` header when it does — so a + * failure hint that only consulted the npm config would contradict what was + * actually on the wire. + * + * Only called with a URL the caller has already parsed successfully. */ -function registryHttpError (err: any, message: string): EmbeddedPackageError { - const status = err?.response?.status - const statusHint = status !== undefined ? ` (HTTP ${status})` : '' - const authHint = status === 401 || status === 403 - ? ` Check that your .npmrc contains valid credentials for this registry.` - : '' - return new EmbeddedPackageError(`${message}${statusHint}.${authHint}`, { cause: err }) +function hasUrlCredentials (url: string): boolean { + const parsed = new URL(url) + return parsed.username !== '' || parsed.password !== '' } /** @@ -221,17 +225,24 @@ export class EmbeddedPackagesMaterializer { return [] } - // Safe to assert: a missing lockfile is a plan issue, and issues abort - // above. - const npmrcConfig = await loadNpmrcConfig(defaultNpmrcPaths( - this.#projectRoot!, - this.#homedir, - this.#options.contextDir, - ), this.#env) + // Safe to assert both: a missing lockfile is a plan issue, and issues + // abort above. + const lockfilePath = this.#options.lockfilePath! + const pnpmAuthFile = pnpmAuthIniPath(this.#env, process.platform, this.#homedir) + const pnpmAuthFilePreferred = isPnpmLockfile(lockfilePath) + debug('pnpm auth file %s (preferred: %s)', pnpmAuthFile, pnpmAuthFilePreferred) + + const npmrc = await loadNpmrcConfig(defaultNpmrcPaths({ + workspaceRoot: this.#projectRoot!, + homedir: this.#homedir, + contextDir: this.#options.contextDir, + pnpmAuthFile, + pnpmAuthFilePreferred, + }), this.#env) const queue = new PQueue({ concurrency: DOWNLOAD_CONCURRENCY }) return await queue.addAll(tarballs.map(tarball => async (): Promise => { - const { filePath, integrity } = await this.#obtainTarball(tarball, npmrcConfig) + const { filePath, integrity } = await this.#obtainTarball(tarball, npmrc) return { ...tarball, integrity, @@ -478,14 +489,14 @@ export class EmbeddedPackagesMaterializer { async #obtainTarball ( tarball: PlannedTarball, - npmrcConfig: NpmrcConfig, + npmrc: LoadedNpmrcConfig, ): Promise<{ filePath: string, integrity: string }> { let { integrity, tarballUrl } = tarball if (integrity === undefined) { // yarn.lock plans carry no SRI tarball integrity (Berry checksums // hash yarn's own cache archive); resolve it from the registry's // per-version metadata before the caches can be consulted. - const dist = await this.#resolveDistFromRegistry(tarball, npmrcConfig) + const dist = await this.#resolveDistFromRegistry(tarball, npmrc) integrity = dist.integrity tarballUrl ??= dist.tarballUrl } @@ -502,16 +513,53 @@ export class EmbeddedPackagesMaterializer { return { filePath: await this.#cache.put(integrity, fromNpmCacache), integrity } } - const url = tarballUrl ?? this.#deriveTarballUrl(tarball, npmrcConfig) - if (!URL.canParse(url)) { + // Where the URL came from decides who to blame for credentials embedded + // in it: a lockfile-recorded URL is the lockfile's, a derived one + // belongs to whichever config key configured the registry. + let url: string + let urlOrigin: UrlOrigin + if (tarballUrl !== undefined) { + url = tarballUrl + // Safe to assert: a missing lockfile is a plan issue, and materialize + // aborts on issues before any tarball is obtained. + urlOrigin = { lockfile: this.#options.lockfilePath! } + } else { + const registry = resolveRegistry(npmrc.config, tarball.name, this.#env) + const basename = tarball.name.split('/').pop() + url = `${registry.url}${tarball.name}/-/${basename}-${tarball.version}.tgz` + urlOrigin = { registryKey: registry.key } + } + + // `URL.canParse` accepts host-less forms: `admin:s3cret@nexus.local/x` + // parses as the opaque scheme `admin:` with no host. Nothing can be + // fetched from one, so require a host and give the configuration error + // rather than letting the request fail obscurely further down. + if (!URL.canParse(url) || new URL(url).host === '') { + // The offending value is deliberately not echoed: it is unparseable + // by definition here, so nothing can reliably tell a credential in it + // from a path. Naming the source is both safe and more useful — that + // is where the reader goes to fix it. + // The advice has to match the branch: telling someone to fix their + // registry setting when the bad value came out of the lockfile sends + // them to a setting that is already correct. + let source: string + if ('lockfile' in urlOrigin) { + source = `It was recorded in '${urlOrigin.lockfile}', whose tarball URL for this package` + + ` is not absolute or has no host.` + } else if (urlOrigin.registryKey !== undefined) { + source = `It was built from the registry configured by` + + ` ${describeConfigKeys([urlOrigin.registryKey], npmrc)}, which must be an absolute URL` + + ` including the protocol, and must have a host.` + } else { + source = `It was built from the default registry, which suggests a malformed package name.` + } throw new EmbeddedPackageError( `The tarball URL for embedded package '${tarball.name}@${tarball.version}'` - + ` is not a valid URL: '${redactUrl(url)}'. Check the 'registry' configuration` - + ` in your .npmrc (it must be an absolute URL including the protocol).`, + + ` is not a valid URL. ${source}`, ) } debug('%s@%s: downloading from %s', tarball.name, tarball.version, redactUrl(url)) - const content = await this.#download(tarball, url, npmrcConfig) + const content = await this.#download(tarball, url, npmrc, urlOrigin) if (!verifyIntegrity(content, integrity)) { // For yarn.lock plans the integrity came from the registry's own @@ -545,18 +593,18 @@ export class EmbeddedPackagesMaterializer { */ async #resolveDistFromRegistry ( tarball: PlannedTarball, - npmrcConfig: NpmrcConfig, + npmrc: LoadedNpmrcConfig, ): Promise<{ integrity: string, tarballUrl?: string }> { - const registryUrl = resolveRegistryUrl(npmrcConfig, tarball.name, this.#env) + const registryUrl = resolveRegistry(npmrc.config, tarball.name, this.#env).url const versionUrl = `${registryUrl}${tarball.name}/${tarball.version}` const packumentUrl = `${registryUrl}${tarball.name}` // Per-version route: dist is at the document root. - const perVersion = await this.#fetchMetadataDist(tarball, npmrcConfig, versionUrl, data => data?.dist) + const perVersion = await this.#fetchMetadataDist(tarball, npmrc, versionUrl, data => data?.dist) // Packument fallback (only when the per-version route was absent, not // when it answered with unusable data): dist is nested per version. const dist = perVersion ?? await this.#fetchMetadataDist( - tarball, npmrcConfig, packumentUrl, data => data?.versions?.[tarball.version]?.dist, + tarball, npmrc, packumentUrl, data => data?.versions?.[tarball.version]?.dist, ) // Modern publishes carry an SRI `integrity`; very old ones only a hex @@ -592,7 +640,7 @@ export class EmbeddedPackagesMaterializer { */ async #fetchMetadataDist ( tarball: PlannedTarball, - npmrcConfig: NpmrcConfig, + npmrc: LoadedNpmrcConfig, url: string, select: (data: any) => any, ): Promise { @@ -603,12 +651,12 @@ export class EmbeddedPackagesMaterializer { + ` in your .npmrc (it must be an absolute URL including the protocol).`, ) } - const authHeader = resolveAuthHeader(npmrcConfig, url, this.#env) + const auth = resolveAuthHeader(npmrc.config, url, this.#env) debug('%s@%s: resolving integrity from %s', tarball.name, tarball.version, redactUrl(url)) try { const response = await axios.get(url, assignProxy(url, { headers: { - ...(authHeader !== undefined ? { authorization: authHeader } : {}), + ...(auth !== undefined ? { authorization: auth.header } : {}), }, timeout: DOWNLOAD_TIMEOUT_MS, })) @@ -621,18 +669,57 @@ export class EmbeddedPackagesMaterializer { err, `Failed to fetch registry metadata for embedded package` + ` '${tarball.name}@${tarball.version}' from '${redactUrl(url)}'`, + status => downloadFailureHint( + status, + auth !== undefined ? { from: 'config', keys: auth.keys } : undefined, + npmrc, + ), ) } } - #deriveTarballUrl (tarball: PlannedTarball, npmrcConfig: NpmrcConfig): string { - const registryUrl = resolveRegistryUrl(npmrcConfig, tarball.name, this.#env) - const basename = tarball.name.split('/').pop() - return `${registryUrl}${tarball.name}/-/${basename}-${tarball.version}.tgz` - } + async #download ( + tarball: PlannedTarball, + url: string, + npmrc: LoadedNpmrcConfig, + urlOrigin: UrlOrigin, + ): Promise { + const auth = resolveAuthHeader(npmrc.config, url, this.#env) + + // A URL carrying userinfo wins: axios sends those itself and drops the + // Authorization header when it does, so the hint must report them + // rather than the config entry that never reached the wire. + let sent: SentCredentials | undefined + if (hasUrlCredentials(url)) { + if ('lockfile' in urlOrigin) { + sent = { from: 'url', lockfile: urlOrigin.lockfile } + } else if (urlOrigin.registryKey !== undefined) { + sent = { from: 'url', registryKey: urlOrigin.registryKey } + } + // Credentials in a URL built from the default registry are not + // reachable: the default carries none. Leaving `sent` unset would + // misreport, so fall through to the config attribution below. + } + if (sent === undefined && auth !== undefined) { + sent = { from: 'config', keys: auth.keys } + } - async #download (tarball: PlannedTarball, url: string, npmrcConfig: NpmrcConfig): Promise { - const authHeader = resolveAuthHeader(npmrcConfig, url, this.#env) + // A redirect can make the credentials moot: follow-redirects drops + // confidential headers rather than hand them to another host, so + // whatever answered never saw them and "they were rejected" would be + // wrong. Tarball downloads redirect to CDNs routinely. + // + // Observed, not predicted: the drop happens before `beforeRedirect` + // runs and mutates the very options handed to it, so the hook can see + // what actually survived. Re-deriving the library's rule would get + // subdomain redirects (which keep the header) and protocol downgrades + // (which drop it regardless of host) wrong, and would rot silently if + // the policy ever changed. + // + // The hop itself is recorded even when nothing was sent: whatever + // answered is then not the host the reader configured, and telling them + // to add credentials for a host that never asked is its own dead end. + const redirect: RedirectOutcome = {} try { const response = await axios.get(url, assignProxy(url, { @@ -643,7 +730,23 @@ export class EmbeddedPackagesMaterializer { // otherwise make axios gunzip it, breaking integrity verification // with a misleading "different artifact" error. 'accept-encoding': 'identity', - ...(authHeader !== undefined ? { authorization: authHeader } : {}), + ...(auth !== undefined ? { authorization: auth.header } : {}), + }, + beforeRedirect: (options: { host?: string, auth?: string | null, headers?: Record }) => { + redirect.host = options.host + if (sent === undefined) { + return + } + const keptHeader = Object.keys(options.headers ?? {}) + .some(header => header.toLowerCase() === 'authorization') + // `!= null` rather than `!== undefined`: the legacy URL path + // yields `null` here, and treating that as "credentials survived" + // would fail open on the very check meant to catch a drop. + const keptUrlAuth = options.auth != null && options.auth !== '' + // Assigned rather than latched: a later hop back to the original + // origin restores URL credentials, and reporting them as dropped + // would send the reader to inspect the wrong host. + redirect.credentialsDropped = !keptHeader && !keptUrlAuth }, timeout: DOWNLOAD_TIMEOUT_MS, maxContentLength: MAX_TARBALL_BYTES, @@ -654,6 +757,7 @@ export class EmbeddedPackagesMaterializer { err, `Failed to download embedded package '${tarball.name}@${tarball.version}'` + ` from '${redactUrl(url)}'`, + status => downloadFailureHint(status, sent, npmrc, redirect), ) } } diff --git a/packages/cli/src/services/embedded-packages/npmrc.ts b/packages/cli/src/services/embedded-packages/npmrc.ts index b74574d0..f1ade999 100644 --- a/packages/cli/src/services/embedded-packages/npmrc.ts +++ b/packages/cli/src/services/embedded-packages/npmrc.ts @@ -2,8 +2,53 @@ import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' +import Debug from 'debug' + +const debug = Debug('checkly:cli:services:embedded-packages') + export const DEFAULT_REGISTRY_URL = 'https://registry.npmjs.org/' +/** + * A configuration file to merge, and how hard to insist on reading it. + * `optional` files are skipped with a log line when they exist but cannot + * be read, for files the user did not choose to put there themselves. + */ +export interface NpmrcFile { + path: string + optional?: boolean +} + +/** The prefix that marks an environment variable as npm configuration. */ +export const NPM_CONFIG_ENV_PREFIX = 'npm_config_' + +/** + * Where a config value came from. Structured rather than a display string: + * an environment variable is named by its verbatim spelling, which is what + * the user can actually search for — the key stored in the config map has + * the prefix stripped and may be case-folded. + */ +export type ConfigOrigin = + | { kind: 'file', path: string } + | { kind: 'env', variable: string } + +export interface LoadedNpmrcConfig { + config: NpmrcConfig + /** The config files consulted, highest precedence first. */ + files: string[] + /** + * Optional files that could not be read, and were therefore skipped. Any + * credentials they hold went unused, which is worth saying out loud when + * a download later fails to authenticate. + */ + unreadable: string[] + /** + * Which source each key came from. A credential that a registry rejects + * is far easier to fix when the error can name where it came from, which + * the merged map alone cannot say. + */ + origins: Map +} + /** * Merged `.npmrc` configuration: a flat key → raw value map. Values keep * any `${VAR}` references unexpanded until they're actually used, so an @@ -14,8 +59,10 @@ export type NpmrcConfig = Map export class NpmrcEnvVarError extends Error { constructor (key: string, varName: string) { super( - `The .npmrc value for '${key}' references the environment variable` - + ` '${varName}', which is not set`, + // Not necessarily an .npmrc: the value may equally have come from + // pnpm's auth.ini or an npm_config_* environment variable. + `The npm configuration value for '${key}' references the environment` + + ` variable '${varName}', which is not set`, ) this.name = 'NpmrcEnvVarError' } @@ -53,32 +100,55 @@ export function parseNpmrc (content: string): NpmrcConfig { } /** - * Extracts npm configuration from `npm_config_*` environment variables - * (e.g. `npm_config_registry`, commonly set in CI and by package managers - * running lifecycle scripts). In npm's precedence order these sit above - * every `.npmrc` file. The prefix is matched case-insensitively; the key - * is stored both verbatim and lowercased, because plain keys are written - * in any case (`NPM_CONFIG_REGISTRY`) while nerf-darted auth keys carry a - * case-sensitive spelling (`npm_config_//host/:_authToken`). + * Every `npm_config_*` variable in an environment, as the config key it + * carries plus the variable's verbatim name. The prefix is matched + * case-insensitively; the name is kept because only it is something the + * user can search their environment for. */ -export function npmrcConfigFromEnv (env: NodeJS.ProcessEnv): NpmrcConfig { - const config: NpmrcConfig = new Map() - - const prefix = 'npm_config_' - for (const [name, value] of Object.entries(env)) { - if (value === undefined || !name.toLowerCase().startsWith(prefix)) { +function* npmConfigEnvEntries ( + env: NodeJS.ProcessEnv, +): Generator<{ key: string, value: string, variable: string }> { + for (const [variable, value] of Object.entries(env)) { + if (value === undefined || !variable.toLowerCase().startsWith(NPM_CONFIG_ENV_PREFIX)) { continue } - const key = name.slice(prefix.length) + const key = variable.slice(NPM_CONFIG_ENV_PREFIX.length) // npm drops env config entries with empty values rather than treating // them as set-to-empty. if (key === '' || value === '') { continue } - config.set(key, value) - if (!config.has(key.toLowerCase())) { - config.set(key.toLowerCase(), value) - } + yield { key, value, variable } + } +} + +/** + * Records an env-derived entry under both the verbatim key and, unless one + * is already present, its lowercased alias. Shared so that the config map + * and the origins map cannot drift apart: they must key identically, or a + * value resolves while its origin does not. + */ +function setEnvEntry (map: Map, key: string, value: T): void { + map.set(key, value) + if (!map.has(key.toLowerCase())) { + map.set(key.toLowerCase(), value) + } +} + +/** + * Extracts npm configuration from `npm_config_*` environment variables + * (e.g. `npm_config_registry`, commonly set in CI and by package managers + * running lifecycle scripts). In npm's precedence order these sit above + * every `.npmrc` file. The key is stored both verbatim and lowercased, + * because plain keys are written in any case (`NPM_CONFIG_REGISTRY`) while + * nerf-darted auth keys carry a case-sensitive spelling + * (`npm_config_//host/:_authToken`). + */ +export function npmrcConfigFromEnv (env: NodeJS.ProcessEnv): NpmrcConfig { + const config: NpmrcConfig = new Map() + + for (const { key, value } of npmConfigEnvEntries(env)) { + setEnvEntry(config, key, value) } return config @@ -91,12 +161,20 @@ export function npmrcConfigFromEnv (env: NodeJS.ProcessEnv): NpmrcConfig { * Missing files are skipped. */ export async function loadNpmrcConfig ( - filePaths: string[], + files: NpmrcFile[], env: NodeJS.ProcessEnv = process.env, -): Promise { +): Promise { const merged: NpmrcConfig = npmrcConfigFromEnv(env) + const unreadable: string[] = [] + const origins = new Map() - for (const filePath of filePaths) { + // Keyed exactly as the config map above, so every spelling that resolves + // a value can also name the variable the user actually set. + for (const { key, variable } of npmConfigEnvEntries(env)) { + setEnvEntry(origins, key, { kind: 'env', variable }) + } + + for (const { path: filePath, optional = false } of files) { let content: string try { content = await fs.readFile(filePath, 'utf8') @@ -106,35 +184,117 @@ export async function loadNpmrcConfig ( } // An unreadable .npmrc (e.g. bad permissions) must not silently drop // registry credentials — that would surface later as a baffling 401. - throw new Error(`Unable to read npm configuration from '${filePath}'`, { cause: err }) + // Optional files belong to another tool rather than to this project, + // so an unreadable one must not take the whole command down with it. + // It is still recorded so an authentication failure can say the file + // was skipped. Note this covers more than bad permissions on the file + // itself — an unsearchable parent directory lands here too — so the + // reported wording must not claim the file exists. + if (!optional) { + throw new Error(`Unable to read npm configuration from '${filePath}'`, { cause: err }) + } + debug('skipping unreadable optional config %s: %s', filePath, (err as Error).message) + unreadable.push(filePath) + continue } for (const [key, value] of parseNpmrc(content)) { if (!merged.has(key)) { merged.set(key, value) + origins.set(key, { kind: 'file', path: filePath }) + } + } + } + + return { config: merged, files: files.map(file => file.path), unreadable, origins } +} + +/** + * The file pnpm keeps its global registry credentials in. pnpm 11 stopped + * writing them to `.npmrc`: `pnpm login` writes `auth.ini` in pnpm's global + * config directory instead, so a logged-in pnpm user looks unauthenticated + * to anything that only reads `.npmrc`. + * + * The directory resolution mirrors pnpm's own `getConfigDir` branch for + * branch. Note that `PNPM_HOME` is deliberately NOT consulted: pnpm uses it + * for the data and state directories, never for the config directory. + */ +export function pnpmAuthIniPath ( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform, + homedir: string, +): string { + const xdgConfigHome = env.XDG_CONFIG_HOME + if (xdgConfigHome !== undefined && xdgConfigHome !== '') { + return path.join(xdgConfigHome, 'pnpm', 'auth.ini') + } + switch (platform) { + case 'darwin': + return path.join(homedir, 'Library', 'Preferences', 'pnpm', 'auth.ini') + case 'win32': { + const localAppData = env.LOCALAPPDATA + if (localAppData !== undefined && localAppData !== '') { + return path.join(localAppData, 'pnpm', 'config', 'auth.ini') } + return path.join(homedir, '.config', 'pnpm', 'auth.ini') } + default: + return path.join(homedir, '.config', 'pnpm', 'auth.ini') } +} - return merged +export interface NpmrcPathsOptions { + /** Workspace root, whose `.npmrc` is consulted. */ + workspaceRoot: string + /** + * The directory the Checkly project lives in (a workspace member in a + * monorepo), whose `.npmrc` takes precedence over the workspace root's. + */ + contextDir?: string + homedir?: string + /** pnpm's global `auth.ini`. Consulted whenever it is provided. */ + pnpmAuthFile?: string + /** + * True when the project's lockfile is pnpm's, which is when `auth.ini` + * outranks the user `.npmrc` — matching pnpm's own precedence. For any + * other package manager it ranks below. + * + * Note that precedence applies per key, as it does in npm and pnpm, not + * per registry: a lower-ranked file's `_authToken` still wins over a + * higher-ranked file's `username`/`_password` for the same registry, + * because the credential kinds are distinct keys and `resolveAuthHeader` + * prefers a token over basic auth. npm's own config cascade behaves the + * same way. + */ + pnpmAuthFilePreferred?: boolean } /** - * The `.npmrc` locations relevant to a project, in npm's precedence order: + * The configuration files relevant to a project, highest precedence first: * the directory the Checkly project lives in (the nearest project config, - * which may be a workspace member), the workspace root, then the - * user-level file. (npm's global and builtin configs are not consulted.) + * which may be a workspace member), the workspace root, then pnpm's global + * `auth.ini` and the user-level `.npmrc` in whichever order the project's + * package manager implies. (npm's global and builtin configs are not + * consulted.) */ -export function defaultNpmrcPaths ( - workspaceRoot: string, - homedir = os.homedir(), - contextDir?: string, -): string[] { - const paths = [ - ...(contextDir !== undefined ? [path.join(contextDir, '.npmrc')] : []), - path.join(workspaceRoot, '.npmrc'), - path.join(homedir, '.npmrc'), +export function defaultNpmrcPaths (options: NpmrcPathsOptions): NpmrcFile[] { + const { workspaceRoot, contextDir, homedir = os.homedir(), pnpmAuthFile, pnpmAuthFilePreferred } = options + + const userNpmrc: NpmrcFile = { path: path.join(homedir, '.npmrc') } + // Not a file this project chose to have, so an unreadable one is skipped + // rather than failing the command. + const authIni: NpmrcFile[] = pnpmAuthFile !== undefined + ? [{ path: pnpmAuthFile, optional: true }] + : [] + + const files: NpmrcFile[] = [ + ...(contextDir !== undefined ? [{ path: path.join(contextDir, '.npmrc') }] : []), + { path: path.join(workspaceRoot, '.npmrc') }, + ...(pnpmAuthFilePreferred === true ? [...authIni, userNpmrc] : [userNpmrc, ...authIni]), ] - return [...new Set(paths)] + + // Dedupe by path, keeping the highest-precedence occurrence: `new Set` on + // the records themselves would compare by identity and never match. + return files.filter((file, index) => files.findIndex(other => other.path === file.path) === index) } function expandValue (key: string, value: string, env: NodeJS.ProcessEnv): string { @@ -147,41 +307,99 @@ function expandValue (key: string, value: string, env: NodeJS.ProcessEnv): strin }) } -function getExpanded (config: NpmrcConfig, key: string, env: NodeJS.ProcessEnv): string | undefined { - const value = config.get(key) ?? config.get(key.toLowerCase()) - if (value === undefined) { +/** + * Looks a key up, falling back to its lowercased spelling, and reports + * which spelling actually matched. Callers that trace a value back to the + * file it came from need the matched key, not the one they asked for: only + * the former is a key in `LoadedNpmrcConfig`'s `origins`. + */ +function getExpandedEntry ( + config: NpmrcConfig, + key: string, + env: NodeJS.ProcessEnv, +): { value: string, key: string } | undefined { + for (const candidate of key === key.toLowerCase() ? [key] : [key, key.toLowerCase()]) { + const value = config.get(candidate) + if (value !== undefined) { + return { value: expandValue(candidate, value, env), key: candidate } + } + } + return undefined +} + +/** + * The scope of a package name (`@acme/foo` → `@acme`), or undefined when + * the name is unscoped. A leading `@` with no slash is not a scope: it is a + * malformed name, and treating it as one would silently truncate it. + */ +function packageScope (packageName: string): string | undefined { + if (!packageName.startsWith('@')) { return undefined } - return expandValue(key, value, env) + const separator = packageName.indexOf('/') + return separator > 1 ? packageName.slice(0, separator) : undefined +} + +export interface ResolvedRegistry { + /** The registry URL, always ending in a slash. */ + url: string + /** + * The config key that supplied it, absent when nothing configured one and + * the public npm registry was assumed. Reported for the same reason as + * `ResolvedAuth.key`: a registry URL can itself carry credentials, and a + * failure needs to name where that URL was configured. + */ + key?: string } /** - * Resolves the registry URL for a package name: the `@scope:registry` entry - * if the package is scoped and one exists, the `registry` entry otherwise, - * falling back to the public npm registry. Always ends with a slash. + * Resolves the registry for a package name: the `@scope:registry` entry if + * the package is scoped and one exists, the `registry` entry otherwise, + * falling back to the public npm registry. */ -export function resolveRegistryUrl ( +export function resolveRegistry ( config: NpmrcConfig, packageName: string, env: NodeJS.ProcessEnv = process.env, -): string { - let registry: string | undefined +): ResolvedRegistry { + let registry: { value: string, key: string } | undefined + + const scope = packageScope(packageName) + if (scope !== undefined) { + registry = getExpandedEntry(config, `${scope}:registry`, env) + } + + registry ??= getExpandedEntry(config, 'registry', env) - if (packageName.startsWith('@')) { - const scope = packageName.slice(0, packageName.indexOf('/')) - registry = getExpanded(config, `${scope}:registry`, env) + if (registry === undefined) { + return { url: DEFAULT_REGISTRY_URL } } - registry ??= getExpanded(config, 'registry', env) - registry ??= DEFAULT_REGISTRY_URL + return { + url: registry.value.endsWith('/') ? registry.value : `${registry.value}/`, + key: registry.key, + } +} - return registry.endsWith('/') ? registry : `${registry}/` +export interface ResolvedAuth { + /** The `Authorization` header value to send. */ + header: string + /** + * Every config key that contributed, in the spelling that matched. + * Paired with `LoadedNpmrcConfig`'s `origins`, these name the file or + * environment variable a rejected credential came from — indispensable + * once several sources can supply one. `username` + `_password` yields + * two keys rather than one: because precedence is + * per key, the halves routinely come from different files, and the + * password (the half that actually expires) is the one worth naming. + */ + keys: string[] } /** - * Resolves the `Authorization` header value applicable to a URL, matching - * npm's "nerf dart" scheme: credentials are keyed by the registry URL minus - * its protocol (`//host/path/:_authToken=...`). The URL's path is walked + * Resolves the `Authorization` header applicable to a URL, matching npm's + * "nerf dart" scheme: credentials are keyed by the registry URL minus its + * protocol (`//host/path/:_authToken=...`). The URL's path is walked * upward so credentials configured for a registry root also apply to * tarball URLs beneath it. Supports `_authToken` (Bearer), `_auth` * (pre-encoded Basic), and `username` + `_password` (base64-encoded, per @@ -191,28 +409,29 @@ export function resolveAuthHeader ( config: NpmrcConfig, url: string, env: NodeJS.ProcessEnv = process.env, -): string | undefined { +): ResolvedAuth | undefined { const parsed = new URL(url) const segments = parsed.pathname.split('/').filter(segment => segment !== '') for (let depth = segments.length; depth >= 0; depth--) { const nerfDart = `//${parsed.host}/${segments.slice(0, depth).map(segment => `${segment}/`).join('')}` - const authToken = getExpanded(config, `${nerfDart}:_authToken`, env) + const authToken = getExpandedEntry(config, `${nerfDart}:_authToken`, env) if (authToken !== undefined) { - return `Bearer ${authToken}` + return { header: `Bearer ${authToken.value}`, keys: [authToken.key] } } - const auth = getExpanded(config, `${nerfDart}:_auth`, env) + const auth = getExpandedEntry(config, `${nerfDart}:_auth`, env) if (auth !== undefined) { - return `Basic ${auth}` + return { header: `Basic ${auth.value}`, keys: [auth.key] } } - const username = getExpanded(config, `${nerfDart}:username`, env) - const password = getExpanded(config, `${nerfDart}:_password`, env) + const username = getExpandedEntry(config, `${nerfDart}:username`, env) + const password = getExpandedEntry(config, `${nerfDart}:_password`, env) if (username !== undefined && password !== undefined) { - const decodedPassword = Buffer.from(password, 'base64').toString('utf8') - return `Basic ${Buffer.from(`${username}:${decodedPassword}`, 'utf8').toString('base64')}` + const decodedPassword = Buffer.from(password.value, 'base64').toString('utf8') + const encoded = Buffer.from(`${username.value}:${decodedPassword}`, 'utf8').toString('base64') + return { header: `Basic ${encoded}`, keys: [username.key, password.key] } } }