diff --git a/lib/config/presets/npm/index.ts b/lib/config/presets/npm/index.ts index 9c9fa9b8b5ceb8170068590ef4e34d9ed4207c82..ab79ac34b84b31df1856539e6cc37745df205680 100644 --- a/lib/config/presets/npm/index.ts +++ b/lib/config/presets/npm/index.ts @@ -19,9 +19,8 @@ export async function getPreset({ }: PresetConfig): Promise<Preset> { let dep; try { - const { headers, packageUrl } = resolvePackage(packageName); - const body = (await http.getJson<NpmResponse>(packageUrl, { headers })) - .body; + const { packageUrl } = resolvePackage(packageName); + const body = (await http.getJson<NpmResponse>(packageUrl)).body; dep = body.versions[body['dist-tags'].latest]; } catch (err) { throw new Error(PRESET_DEP_NOT_FOUND); diff --git a/lib/datasource/npm/__snapshots__/get.spec.ts.snap b/lib/datasource/npm/__snapshots__/get.spec.ts.snap index 829071366665515f10971879155b2e35658e14c9..492541626254dad01d7b4f0ec1b27f8003acd77e 100644 --- a/lib/datasource/npm/__snapshots__/get.spec.ts.snap +++ b/lib/datasource/npm/__snapshots__/get.spec.ts.snap @@ -83,6 +83,7 @@ Array [ "headers": Object { "accept": "application/json", "accept-encoding": "gzip, deflate, br", + "authorization": "Bearer XXX", "host": "registry.npmjs.org", "user-agent": "RenovateBot/0.0.0-semantic-release (https://github.com/renovatebot/renovate)", }, @@ -93,6 +94,7 @@ Array [ "headers": Object { "accept": "application/json", "accept-encoding": "gzip, deflate, br", + "authorization": "Bearer XXX", "host": "registry.npmjs.org", "user-agent": "RenovateBot/0.0.0-semantic-release (https://github.com/renovatebot/renovate)", }, @@ -407,22 +409,6 @@ Array [ ] `; -exports[`datasource/npm/get no auth "@myco:registry=https://test.org -_authToken=XXX" 1`] = ` -Array [ - Object { - "headers": Object { - "accept": "application/json", - "accept-encoding": "gzip, deflate, br", - "host": "test.org", - "user-agent": "RenovateBot/0.0.0-semantic-release (https://github.com/renovatebot/renovate)", - }, - "method": "GET", - "url": "https://test.org/@myco%2Ftest", - }, -] -`; - exports[`datasource/npm/get no auth "@myco:registry=https://test.org" 1`] = ` Array [ Object { diff --git a/lib/datasource/npm/__snapshots__/index.spec.ts.snap b/lib/datasource/npm/__snapshots__/index.spec.ts.snap index 4aac8cf03d2daf7c69b4f0b4a0969b605990ce27..ae5b688b281f1e5eddc03176c78570230af668c9 100644 --- a/lib/datasource/npm/__snapshots__/index.spec.ts.snap +++ b/lib/datasource/npm/__snapshots__/index.spec.ts.snap @@ -6,6 +6,7 @@ Array [ "headers": Object { "accept": "application/json", "accept-encoding": "gzip, deflate, br", + "authorization": "Bearer abcdefghijklmnopqrstuvwxyz", "host": "registry.npmjs.org", "user-agent": "RenovateBot/0.0.0-semantic-release (https://github.com/renovatebot/renovate)", }, diff --git a/lib/datasource/npm/get.spec.ts b/lib/datasource/npm/get.spec.ts index d4f9524b09873b585f37363a838bb9917e4f2f77..94b2e6b685af9735307506081e7c867a00eeac26 100644 --- a/lib/datasource/npm/get.spec.ts +++ b/lib/datasource/npm/get.spec.ts @@ -18,6 +18,7 @@ describe('datasource/npm/get', () => { jest.clearAllMocks(); resetMemCache(); hostRules.clear(); + setNpmrc(); }); describe('has bearer auth', () => { @@ -85,7 +86,6 @@ describe('datasource/npm/get', () => { describe('no auth', () => { const configs = [ - `@myco:registry=https://test.org\n_authToken=XXX`, `@myco:registry=https://test.org\n//test.org/sub/:_authToken=XXX`, `@myco:registry=https://test.org\n//test.org/sub/:_auth=dGVzdDp0ZXN0`, `@myco:registry=https://test.org`, diff --git a/lib/datasource/npm/get.ts b/lib/datasource/npm/get.ts index 417dd3e7bf4f9f3030c62201d414a3e928ed1e7d..7356b7de3a81808de7a110bcc9e00148baee7d49 100644 --- a/lib/datasource/npm/get.ts +++ b/lib/datasource/npm/get.ts @@ -4,7 +4,6 @@ import { logger } from '../../logger'; import { ExternalHostError } from '../../types/errors/external-host-error'; import * as packageCache from '../../util/cache/package'; import type { Http } from '../../util/http'; -import type { HttpOptions } from '../../util/http/types'; import { id } from './common'; import { resolvePackage } from './npmrc'; import type { NpmDependency, NpmRelease, NpmResponse } from './types'; @@ -63,7 +62,7 @@ export async function getDependency( return JSON.parse(memcache[packageName]) as NpmDependency; } - const { headers, packageUrl, registryUrl } = resolvePackage(packageName); + const { packageUrl, registryUrl } = resolvePackage(packageName); // Now check the persistent cache const cacheNamespace = 'datasource-npm'; @@ -78,18 +77,8 @@ export async function getDependency( const uri = url.parse(packageUrl); - if (uri.host === 'registry.npmjs.org' && !uri.pathname.startsWith('/@')) { - // Delete the authorization header for non-scoped public packages to improve http caching - // Otherwise, authenticated requests are not cacheable until the registry adds "public" to Cache-Control - // Ref: https://greenbytes.de/tech/webdav/rfc7234.html#caching.authenticated.responses - delete headers.authorization; - } - try { - const opts: HttpOptions = { - headers, - }; - const raw = await http.getJson<NpmResponse>(packageUrl, opts); + const raw = await http.getJson<NpmResponse>(packageUrl); const res = raw.body; if (!res.versions || !Object.keys(res.versions).length) { // Registry returned a 200 OK but with no versions diff --git a/lib/datasource/npm/index.spec.ts b/lib/datasource/npm/index.spec.ts index f373ac6f51575b0e46fad72aadf257aa77a99584..ec457904a471dd8115cdac9998a8bca3447562a2 100644 --- a/lib/datasource/npm/index.spec.ts +++ b/lib/datasource/npm/index.spec.ts @@ -1,5 +1,4 @@ import mockDate from 'mockdate'; -import _registryAuthToken from 'registry-auth-token'; import { getPkgReleases } from '..'; import * as httpMock from '../../../test/http-mock'; import { GlobalConfig } from '../../config/global'; @@ -9,11 +8,8 @@ import { NpmDatasource, getNpmrc, resetCache, setNpmrc } from '.'; const datasource = NpmDatasource.id; -jest.mock('registry-auth-token'); jest.mock('delay'); -const registryAuthToken: jest.Mock<_registryAuthToken.NpmCredentials> = - _registryAuthToken as never; let npmResponse: any; describe('datasource/npm/index', () => { @@ -237,10 +233,6 @@ describe('datasource/npm/index', () => { }); it('should not send an authorization header if public package', async () => { - registryAuthToken.mockReturnValueOnce({ - type: 'Basic', - token: '1234', - }); httpMock .scope('https://registry.npmjs.org', { badheaders: ['authorization'], @@ -253,17 +245,17 @@ describe('datasource/npm/index', () => { }); it('should send an authorization header if provided', async () => { - registryAuthToken.mockReturnValueOnce({ - type: 'Basic', - token: '1234', - }); httpMock .scope('https://registry.npmjs.org', { reqheaders: { authorization: 'Basic 1234' }, }) .get('/@foobar%2Fcore') .reply(200, { ...npmResponse, name: '@foobar/core' }); - const res = await getPkgReleases({ datasource, depName: '@foobar/core' }); + const res = await getPkgReleases({ + datasource, + depName: '@foobar/core', + npmrc: '_auth = 1234', + }); expect(res).toMatchSnapshot(); expect(httpMock.getTrace()).toMatchSnapshot(); }); diff --git a/lib/datasource/npm/npmrc.spec.ts b/lib/datasource/npm/npmrc.spec.ts index de5f5000f5611fa017e75ff06d5cdcd4ff40ef8b..78f337551bce74dfb1d6e9a5f3540868f343d115 100644 --- a/lib/datasource/npm/npmrc.spec.ts +++ b/lib/datasource/npm/npmrc.spec.ts @@ -1,7 +1,13 @@ +import ini from 'ini'; import { mocked } from '../../../test/util'; import { GlobalConfig } from '../../config/global'; import * as _sanitize from '../../util/sanitize'; -import { getNpmrc, setNpmrc } from './npmrc'; +import { + convertNpmrcToRules, + getMatchHostFromNpmrcHost, + getNpmrc, + setNpmrc, +} from './npmrc'; jest.mock('../../util/sanitize'); @@ -13,7 +19,124 @@ describe('datasource/npm/npmrc', () => { GlobalConfig.reset(); jest.resetAllMocks(); }); - + describe('getMatchHostFromNpmrcHost()', () => { + it('parses //host', () => { + expect(getMatchHostFromNpmrcHost('//registry.npmjs.org')).toBe( + 'registry.npmjs.org' + ); + }); + it('parses //host/path', () => { + expect( + getMatchHostFromNpmrcHost('//registry.company.com/some/path') + ).toBe('https://registry.company.com/some/path'); + }); + it('parses https://host', () => { + expect(getMatchHostFromNpmrcHost('https://registry.npmjs.org')).toBe( + 'https://registry.npmjs.org' + ); + }); + }); + describe('convertNpmrcToRules()', () => { + it('handles naked auth', () => { + expect(convertNpmrcToRules(ini.parse('_auth=abc123\n'))) + .toMatchInlineSnapshot(` + Object { + "hostRules": Array [ + Object { + "authType": "Basic", + "hostType": "npm", + "token": "abc123", + }, + ], + } + `); + }); + it('handles host, path and auth', () => { + expect( + convertNpmrcToRules(ini.parse('//some.test/with/path:_auth=abc123')) + ).toMatchInlineSnapshot(` + Object { + "hostRules": Array [ + Object { + "authType": "Basic", + "hostType": "npm", + "matchHost": "https://some.test/with/path", + "token": "abc123", + }, + ], + } + `); + }); + it('handles host, path, port and auth', () => { + expect( + convertNpmrcToRules( + ini.parse('//some.test:8080/with/path:_authToken=abc123') + ) + ).toMatchInlineSnapshot(` + Object { + "hostRules": Array [ + Object { + "hostType": "npm", + "matchHost": "https://some.test:8080/with/path", + "token": "abc123", + }, + ], + } + `); + }); + it('handles naked authToken', () => { + expect(convertNpmrcToRules(ini.parse('_authToken=abc123\n'))) + .toMatchInlineSnapshot(` + Object { + "hostRules": Array [ + Object { + "hostType": "npm", + "token": "abc123", + }, + ], + } + `); + }); + it('handles host authToken', () => { + expect( + convertNpmrcToRules( + ini.parse( + '@fontawesome:registry=https://npm.fontawesome.com/\n//npm.fontawesome.com/:_authToken=abc123' + ) + ) + ).toMatchInlineSnapshot(` + Object { + "hostRules": Array [ + Object { + "hostType": "npm", + "matchHost": "https://npm.fontawesome.com/", + "token": "abc123", + }, + ], + } + `); + }); + it('handles username and _password', () => { + expect( + convertNpmrcToRules( + ini.parse( + `//my-registry.example.com/npm-private/:_password=dGVzdA==\n//my-registry.example.com/npm-private/:username=bot\n//my-registry.example.com/npm-private/:always-auth=true` + ) + ) + ).toMatchInlineSnapshot(` + Object { + "hostRules": Array [ + Object { + "hostType": "npm", + "matchHost": "https://my-registry.example.com/npm-private/", + "password": "test", + "username": "bot", + }, + ], + } + `); + }); + }); it('sanitize _auth', () => { setNpmrc('_auth=test'); expect(sanitize.addSecretForSanitizing).toHaveBeenCalledWith('test'); @@ -23,23 +146,19 @@ describe('datasource/npm/npmrc', () => { it('sanitize _authtoken', () => { setNpmrc('//registry.test.com:_authToken=test\n_authToken=${NPM_TOKEN}'); expect(sanitize.addSecretForSanitizing).toHaveBeenCalledWith('test'); - expect(sanitize.addSecretForSanitizing).toHaveBeenCalledTimes(1); + expect(sanitize.addSecretForSanitizing).toHaveBeenCalledTimes(2); }); it('sanitize _password', () => { setNpmrc( `registry=https://test.org\n//test.org/:username=test\n//test.org/:_password=dGVzdA==` ); + expect(sanitize.addSecretForSanitizing).toHaveBeenNthCalledWith(1, 'test'); expect(sanitize.addSecretForSanitizing).toHaveBeenNthCalledWith( - 1, - 'dGVzdA==' - ); - expect(sanitize.addSecretForSanitizing).toHaveBeenNthCalledWith(2, 'test'); - expect(sanitize.addSecretForSanitizing).toHaveBeenNthCalledWith( - 3, + 2, 'dGVzdDp0ZXN0' ); - expect(sanitize.addSecretForSanitizing).toHaveBeenCalledTimes(3); + expect(sanitize.addSecretForSanitizing).toHaveBeenCalledTimes(2); }); it('sanitize _authtoken with high trust', () => { diff --git a/lib/datasource/npm/npmrc.ts b/lib/datasource/npm/npmrc.ts index 31591f35e0989aa735ab7eefa7b132fe9a3fef7a..712ceb87351107d0b2d0817133618b89cd694200 100644 --- a/lib/datasource/npm/npmrc.ts +++ b/lib/datasource/npm/npmrc.ts @@ -1,17 +1,14 @@ import url from 'url'; import is from '@sindresorhus/is'; import ini from 'ini'; -import registryAuthToken from 'registry-auth-token'; import getRegistryUrl from 'registry-auth-token/registry-url.js'; import { GlobalConfig } from '../../config/global'; import { logger } from '../../logger'; -import type { OutgoingHttpHeaders } from '../../util/http/types'; -import { maskToken } from '../../util/mask'; +import type { HostRule } from '../../types'; +import * as hostRules from '../../util/host-rules'; import { regEx } from '../../util/regex'; -import { addSecretForSanitizing } from '../../util/sanitize'; -import { fromBase64, toBase64 } from '../../util/string'; -import { ensureTrailingSlash } from '../../util/url'; -import type { Npmrc, PackageResolution } from './types'; +import { fromBase64 } from '../../util/string'; +import type { Npmrc, NpmrcRules, PackageResolution } from './types'; let npmrc: Record<string, any> = {}; let npmrcRaw = ''; @@ -37,21 +34,56 @@ function envReplace(value: any, env = process.env): any { }); } -const envRe = regEx(/(\\*)\$\{([^}]+)\}/); -// TODO: better add to host rules (#9588) -function sanitize(key: string, val: string): void { - if (!val || envRe.test(val)) { - return; +export function getMatchHostFromNpmrcHost(input: string): string { + if (input.startsWith('//')) { + const matchHost = input.replace('//', ''); + if (matchHost.includes('/')) { + return 'https://' + matchHost; + } + return matchHost; } - if (key.endsWith('_authToken') || key.endsWith('_auth')) { - addSecretForSanitizing(val); - } else if (key.endsWith(':_password')) { - addSecretForSanitizing(val); - const password = fromBase64(val); - addSecretForSanitizing(password); - const username: string = npmrc[key.replace(':_password', ':username')]; - addSecretForSanitizing(toBase64(`${username}:${password}`)); + return input; +} + +export function convertNpmrcToRules(npmrc: Record<string, any>): NpmrcRules { + const rules: NpmrcRules = { + hostRules: [], + }; + const hostType = 'npm'; + const hosts: Record<string, HostRule> = {}; + for (const [key, value] of Object.entries(npmrc)) { + if (!is.nonEmptyString(value)) { + continue; + } + const keyParts = key.split(':'); + const keyType = keyParts.pop(); + let matchHost = ''; + if (keyParts.length) { + matchHost = getMatchHostFromNpmrcHost(keyParts.join(':')); + } + const rule: HostRule = hosts[matchHost] || {}; + if (keyType === '_authToken' || keyType === '_auth') { + rule.token = value; + if (keyType === '_auth') { + rule.authType = 'Basic'; + } + } else if (keyType === 'username') { + rule.username = value; + } else if (keyType === '_password') { + rule.password = fromBase64(value); + } else { + continue; // don't add the rule + } + hosts[matchHost] = rule; } + for (const [matchHost, rule] of Object.entries(hosts)) { + const hostRule = { ...rule, hostType }; + if (matchHost) { + hostRule.matchHost = matchHost; + } + rules.hostRules.push(hostRule); + } + return rules; } export function setNpmrc(input?: string): void { @@ -65,9 +97,6 @@ export function setNpmrc(input?: string): void { npmrc = ini.parse(input.replace(regEx(/\\n/g), '\n')); const { exposeAllEnv } = GlobalConfig.get(); for (const [key, val] of Object.entries(npmrc)) { - if (!exposeAllEnv) { - sanitize(key, val); - } if ( !exposeAllEnv && key.endsWith('registry') && @@ -82,12 +111,14 @@ export function setNpmrc(input?: string): void { return; } } - if (!exposeAllEnv) { - return; + if (exposeAllEnv) { + for (const key of Object.keys(npmrc)) { + npmrc[key] = envReplace(npmrc[key]); + } } - for (const key of Object.keys(npmrc)) { - npmrc[key] = envReplace(npmrc[key]); - sanitize(key, npmrc[key]); + const npmrcRules = convertNpmrcToRules(npmrc); + if (npmrcRules.hostRules.length) { + npmrcRules.hostRules.forEach((hostRule) => hostRules.add(hostRule)); } } else if (npmrc) { logger.debug('Resetting npmrc'); @@ -108,24 +139,5 @@ export function resolvePackage(packageName: string): PackageResolution { registryUrl, encodeURIComponent(packageName).replace(regEx(/^%40/), '@') ); - const headers: OutgoingHttpHeaders = {}; - let authInfo = registryAuthToken(registryUrl, { npmrc, recursive: true }); - if ( - !authInfo && - npmrc && - npmrc._authToken && - ensureTrailingSlash(registryUrl) === - ensureTrailingSlash(npmrc?.registry || '') - ) { - authInfo = { type: 'Bearer', token: npmrc._authToken }; - } - - if (authInfo?.type && authInfo.token) { - headers.authorization = `${authInfo.type} ${authInfo.token}`; - logger.trace( - { token: maskToken(authInfo.token), npmName: packageName }, - 'Using auth (via npmrc) for npm lookup' - ); - } - return { headers, packageUrl, registryUrl }; + return { packageUrl, registryUrl }; } diff --git a/lib/datasource/npm/types.ts b/lib/datasource/npm/types.ts index aa4ff75f9a6797595bc75716d275157ca09df55f..f6153bcfa0c81d60177ee10c827a4bd943620047 100644 --- a/lib/datasource/npm/types.ts +++ b/lib/datasource/npm/types.ts @@ -1,6 +1,10 @@ -import type { OutgoingHttpHeaders } from '../../util/http/types'; +import type { HostRule } from '../../types'; import type { Release, ReleaseResult } from '../types'; +export interface NpmrcRules { + hostRules?: HostRule[]; +} + export interface NpmResponse { _id: string; name?: string; @@ -43,7 +47,6 @@ export interface NpmDependency extends ReleaseResult { export type Npmrc = Record<string, any>; export interface PackageResolution { - headers: OutgoingHttpHeaders; packageUrl: string; registryUrl: string; }