diff --git a/lib/modules/datasource/docker/artifactory.ts b/lib/modules/datasource/docker/artifactory.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5c09df01cdecfafa981411ce1b185072fae9c7e --- /dev/null +++ b/lib/modules/datasource/docker/artifactory.ts @@ -0,0 +1,10 @@ +import is from '@sindresorhus/is'; +import type { HttpResponse } from '../../../util/http/types'; + +const JFROG_ARTIFACTORY_RES_HEADER = 'x-jfrog-version'; + +export function isArtifactoryServer<T = unknown>( + res: HttpResponse<T> | undefined +): boolean { + return is.string(res?.headers[JFROG_ARTIFACTORY_RES_HEADER]); +} diff --git a/lib/modules/datasource/docker/common.spec.ts b/lib/modules/datasource/docker/common.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..c851267d10fd4ec610b7e66db87dfb15823e7017 --- /dev/null +++ b/lib/modules/datasource/docker/common.spec.ts @@ -0,0 +1,189 @@ +import * as httpMock from '../../../../test/http-mock'; +import { mocked } from '../../../../test/util'; +import { PAGE_NOT_FOUND_ERROR } from '../../../constants/error-messages'; +import * as _hostRules from '../../../util/host-rules'; +import { Http } from '../../../util/http'; +import { + dockerDatasourceId, + getAuthHeaders, + getRegistryRepository, +} from './common'; + +const hostRules = mocked(_hostRules); + +const http = new Http(dockerDatasourceId); + +jest.mock('../../../util/host-rules'); + +describe('modules/datasource/docker/common', () => { + beforeEach(() => { + hostRules.find.mockReturnValue({ + username: 'some-username', + password: 'some-password', + }); + hostRules.hosts.mockReturnValue([]); + }); + + describe('getRegistryRepository', () => { + it('handles local registries', () => { + const res = getRegistryRepository( + 'registry:5000/org/package', + 'https://index.docker.io' + ); + expect(res).toStrictEqual({ + dockerRepository: 'org/package', + registryHost: 'https://registry:5000', + }); + }); + + it('supports registryUrls', () => { + const res = getRegistryRepository( + 'my.local.registry/prefix/image', + 'https://my.local.registry/prefix' + ); + expect(res).toStrictEqual({ + dockerRepository: 'prefix/image', + registryHost: 'https://my.local.registry', + }); + }); + + it('supports http registryUrls', () => { + const res = getRegistryRepository( + 'my.local.registry/prefix/image', + 'http://my.local.registry/prefix' + ); + expect(res).toStrictEqual({ + dockerRepository: 'prefix/image', + registryHost: 'http://my.local.registry', + }); + }); + + it('supports schemeless registryUrls', () => { + const res = getRegistryRepository( + 'my.local.registry/prefix/image', + 'my.local.registry/prefix' + ); + expect(res).toStrictEqual({ + dockerRepository: 'prefix/image', + registryHost: 'https://my.local.registry', + }); + }); + }); + + describe('getAuthHeaders', () => { + it('throw page not found exception', async () => { + httpMock + .scope('https://my.local.registry') + .get('/v2/repo/tags/list?n=1000') + .reply(404, {}); + + await expect( + getAuthHeaders( + http, + 'https://my.local.registry', + 'repo', + 'https://my.local.registry/v2/repo/tags/list?n=1000' + ) + ).rejects.toThrow(PAGE_NOT_FOUND_ERROR); + }); + + it('returns "authType token" if both provided', async () => { + httpMock + .scope('https://my.local.registry') + .get('/v2/', undefined, { badheaders: ['authorization'] }) + .reply(401, '', { 'www-authenticate': 'Authenticate you must' }); + hostRules.hosts.mockReturnValue([]); + hostRules.find.mockReturnValue({ + authType: 'some-authType', + token: 'some-token', + }); + + const headers = await getAuthHeaders( + http, + 'https://my.local.registry', + 'https://my.local.registry/prefix' + ); + + // do not inline, otherwise we get false positive from codeql + expect(headers).toMatchInlineSnapshot(` + { + "authorization": "some-authType some-token", + } + `); + }); + + it('returns "Bearer token" if only token provided', async () => { + httpMock + .scope('https://my.local.registry') + .get('/v2/', undefined, { badheaders: ['authorization'] }) + .reply(401, '', { 'www-authenticate': 'Authenticate you must' }); + hostRules.hosts.mockReturnValue([]); + hostRules.find.mockReturnValue({ + token: 'some-token', + }); + + const headers = await getAuthHeaders( + http, + 'https://my.local.registry', + 'https://my.local.registry/prefix' + ); + + // do not inline, otherwise we get false positive from codeql + expect(headers).toMatchInlineSnapshot(` + { + "authorization": "Bearer some-token", + } + `); + }); + + it('fails', async () => { + httpMock + .scope('https://my.local.registry') + .get('/v2/', undefined, { badheaders: ['authorization'] }) + .reply(401, '', { 'www-authenticate': 'Authenticate you must' }); + hostRules.hosts.mockReturnValue([]); + httpMock.clear(false); + + httpMock + .scope('https://my.local.registry') + .get('/v2/', undefined, { badheaders: ['authorization'] }) + .reply(401, '', {}); + + const headers = await getAuthHeaders( + http, + 'https://my.local.registry', + 'https://my.local.registry/prefix' + ); + + expect(headers).toBeNull(); + }); + + it('use resources URL and resolve scope in www-authenticate header', async () => { + httpMock + .scope('https://my.local.registry') + .get('/v2/my/node/resource') + .reply(401, '', { + 'www-authenticate': + 'Bearer realm="https://my.local.registry/oauth2/token",service="my.local.registry",scope="repository:my/node:whatever"', + }) + .get( + '/oauth2/token?service=my.local.registry&scope=repository:my/node:whatever' + ) + .reply(200, { token: 'some-token' }); + + const headers = await getAuthHeaders( + http, + 'https://my.local.registry', + 'my/node/prefix', + 'https://my.local.registry/v2/my/node/resource' + ); + + // do not inline, otherwise we get false positive from codeql + expect(headers).toMatchInlineSnapshot(` + { + "authorization": "Bearer some-token", + } + `); + }); + }); +}); diff --git a/lib/modules/datasource/docker/common.ts b/lib/modules/datasource/docker/common.ts index 467755d9779f6b74775586a3b0b7292fd2d6c1a1..6f6b6cdb8fcdb1fbf7ee55c16c1339a9c1c478d1 100644 --- a/lib/modules/datasource/docker/common.ts +++ b/lib/modules/datasource/docker/common.ts @@ -1,5 +1,32 @@ import is from '@sindresorhus/is'; -import type { HttpResponse } from '../../../util/http/types'; +import { parse } from 'auth-header'; +import hasha from 'hasha'; +import { + HOST_DISABLED, + PAGE_NOT_FOUND_ERROR, +} from '../../../constants/error-messages'; +import { logger } from '../../../logger'; +import type { HostRule } from '../../../types'; +import { ExternalHostError } from '../../../types/errors/external-host-error'; +import * as hostRules from '../../../util/host-rules'; +import type { Http } from '../../../util/http'; +import type { + HttpOptions, + HttpResponse, + OutgoingHttpHeaders, +} from '../../../util/http/types'; +import { regEx } from '../../../util/regex'; +import { addSecretForSanitizing } from '../../../util/sanitize'; +import { + ensureTrailingSlash, + parseUrl, + trimTrailingSlash, +} from '../../../util/url'; +import { api as dockerVersioning } from '../../versioning/docker'; +import { ecrRegex, getECRAuthToken } from './ecr'; +import type { RegistryRepository } from './types'; + +export const dockerDatasourceId = 'docker' as const; export const sourceLabels: string[] = [ 'org.opencontainers.image.source', @@ -8,10 +35,261 @@ export const sourceLabels: string[] = [ export const gitRefLabel = 'org.opencontainers.image.revision'; -const JFROG_ARTIFACTORY_RES_HEADER = 'x-jfrog-version'; +export const DOCKER_HUB = 'https://index.docker.io'; + +export function isDockerHost(host: string): boolean { + const regex = regEx(/(?:^|\.)docker\.io$/); + return regex.test(host); +} + +export async function getAuthHeaders( + http: Http, + registryHost: string, + dockerRepository: string, + apiCheckUrl = `${registryHost}/v2/` +): Promise<OutgoingHttpHeaders | null> { + try { + const options = { + throwHttpErrors: false, + noAuth: true, + }; + const apiCheckResponse = apiCheckUrl.endsWith('/v2/') + ? await http.get(apiCheckUrl, options) + : // use json request, as this will be cached for tags, so it returns json + // TODO: add cache test + await http.getJson(apiCheckUrl, options); + + if (apiCheckResponse.statusCode === 200) { + logger.debug(`No registry auth required for ${apiCheckUrl}`); + return {}; + } + if (apiCheckResponse.statusCode === 404) { + logger.debug(`Page Not Found ${apiCheckUrl}`); + // throw error up to be caught and potentially retried with library/ prefix + throw new Error(PAGE_NOT_FOUND_ERROR); + } + if ( + apiCheckResponse.statusCode !== 401 || + !is.nonEmptyString(apiCheckResponse.headers['www-authenticate']) + ) { + logger.warn( + { apiCheckUrl, res: apiCheckResponse }, + 'Invalid registry response' + ); + return null; + } + + const authenticateHeader = parse( + apiCheckResponse.headers['www-authenticate'] + ); + + const opts: HostRule & HttpOptions = hostRules.find({ + hostType: dockerDatasourceId, + url: apiCheckUrl, + }); + if (ecrRegex.test(registryHost)) { + logger.trace( + { registryHost, dockerRepository }, + `Using ecr auth for Docker registry` + ); + const [, region] = ecrRegex.exec(registryHost) ?? []; + const auth = await getECRAuthToken(region, opts); + if (auth) { + opts.headers = { authorization: `Basic ${auth}` }; + } + } else if (opts.username && opts.password) { + logger.trace( + { registryHost, dockerRepository }, + `Using basic auth for Docker registry` + ); + const auth = Buffer.from(`${opts.username}:${opts.password}`).toString( + 'base64' + ); + opts.headers = { authorization: `Basic ${auth}` }; + } else if (opts.token) { + const authType = opts.authType ?? 'Bearer'; + logger.trace( + { registryHost, dockerRepository }, + `Using ${authType} token for Docker registry` + ); + opts.headers = { authorization: `${authType} ${opts.token}` }; + } + delete opts.username; + delete opts.password; + delete opts.token; + + // If realm isn't an url, we should directly use auth header + // Can happen when we get a Basic auth or some other auth type + // * WWW-Authenticate: Basic realm="Artifactory Realm" + // * Www-Authenticate: Basic realm="https://123456789.dkr.ecr.eu-central-1.amazonaws.com/",service="ecr.amazonaws.com" + // * www-authenticate: Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull" + // * www-authenticate: Bearer realm="https://auth.docker.io/token",service="registry.docker.io" + if ( + authenticateHeader.scheme.toUpperCase() !== 'BEARER' || + !is.string(authenticateHeader.params.realm) || + parseUrl(authenticateHeader.params.realm) === null + ) { + logger.trace( + { registryHost, dockerRepository, authenticateHeader }, + `Invalid realm, testing direct auth` + ); + return opts.headers ?? null; + } + + let scope = `repository:${dockerRepository}:pull`; + // repo isn't known to server yet, so causing wrong scope `repository:user/image:pull` + if ( + is.string(authenticateHeader.params.scope) && + !apiCheckUrl.endsWith('/v2/') + ) { + scope = authenticateHeader.params.scope; + } + + let service = authenticateHeader.params.service; + if (is.string(service)) { + service = `service=${service}&`; + } else { + service = ``; + } + + const authUrl = `${authenticateHeader.params.realm}?${service}scope=${scope}`; + logger.trace( + { registryHost, dockerRepository, authUrl }, + `Obtaining docker registry token` + ); + opts.noAuth = true; + const authResponse = ( + await http.getJson<{ token?: string; access_token?: string }>( + authUrl, + opts + ) + ).body; + + const token = authResponse.token ?? authResponse.access_token; + // istanbul ignore if + if (!token) { + logger.warn('Failed to obtain docker registry token'); + return null; + } + // sanitize token + addSecretForSanitizing(token); + return { + authorization: `Bearer ${token}`, + }; + } catch (err) /* istanbul ignore next */ { + if (err.host === 'quay.io') { + // TODO: debug why quay throws errors (#9604) + return null; + } + if (err.statusCode === 401) { + logger.debug( + { registryHost, dockerRepository }, + 'Unauthorized docker lookup' + ); + logger.debug({ err }); + return null; + } + if (err.statusCode === 403) { + logger.debug( + { registryHost, dockerRepository }, + 'Not allowed to access docker registry' + ); + logger.debug({ err }); + return null; + } + if (err.name === 'RequestError' && isDockerHost(registryHost)) { + throw new ExternalHostError(err); + } + if (err.statusCode === 429 && isDockerHost(registryHost)) { + throw new ExternalHostError(err); + } + if (err.statusCode >= 500 && err.statusCode < 600) { + throw new ExternalHostError(err); + } + if (err.message === PAGE_NOT_FOUND_ERROR) { + throw err; + } + if (err.message === HOST_DISABLED) { + logger.trace({ registryHost, dockerRepository, err }, 'Host disabled'); + return null; + } + logger.warn( + { registryHost, dockerRepository, err }, + 'Error obtaining docker token' + ); + return null; + } +} + +export function getRegistryRepository( + packageName: string, + registryUrl: string +): RegistryRepository { + if (registryUrl !== DOCKER_HUB) { + const registryEndingWithSlash = ensureTrailingSlash( + registryUrl.replace(regEx(/^https?:\/\//), '') + ); + if (packageName.startsWith(registryEndingWithSlash)) { + let registryHost = trimTrailingSlash(registryUrl); + if (!regEx(/^https?:\/\//).test(registryHost)) { + registryHost = `https://${registryHost}`; + } + let dockerRepository = packageName.replace(registryEndingWithSlash, ''); + const fullUrl = `${registryHost}/${dockerRepository}`; + const { origin, pathname } = parseUrl(fullUrl)!; + registryHost = origin; + dockerRepository = pathname.substring(1); + return { + registryHost, + dockerRepository, + }; + } + } + let registryHost: string | undefined; + const split = packageName.split('/'); + if (split.length > 1 && (split[0].includes('.') || split[0].includes(':'))) { + [registryHost] = split; + split.shift(); + } + let dockerRepository = split.join('/'); + if (!registryHost) { + registryHost = registryUrl.replace( + 'https://docker.io', + 'https://index.docker.io' + ); + } + if (registryHost === 'docker.io') { + registryHost = 'index.docker.io'; + } + if (!regEx(/^https?:\/\//).exec(registryHost)) { + registryHost = `https://${registryHost}`; + } + const opts = hostRules.find({ + hostType: dockerDatasourceId, + url: registryHost, + }); + if (opts?.insecureRegistry) { + registryHost = registryHost.replace('https', 'http'); + } + if (registryHost.endsWith('.docker.io') && !dockerRepository.includes('/')) { + dockerRepository = 'library/' + dockerRepository; + } + return { + registryHost, + dockerRepository, + }; +} + +export function extractDigestFromResponseBody( + manifestResponse: HttpResponse +): string { + return 'sha256:' + hasha(manifestResponse.body, { algorithm: 'sha256' }); +} + +export function findLatestStable(tags: string[]): string | null { + const versions = tags + .filter((v) => dockerVersioning.isValid(v) && dockerVersioning.isStable(v)) + .sort((a, b) => dockerVersioning.sortVersions(a, b)); -export function isArtifactoryServer<T = unknown>( - res: HttpResponse<T> | undefined -): boolean { - return is.string(res?.headers[JFROG_ARTIFACTORY_RES_HEADER]); + return versions.pop() ?? tags.slice(-1).pop() ?? null; } diff --git a/lib/modules/datasource/docker/ecr.ts b/lib/modules/datasource/docker/ecr.ts new file mode 100644 index 0000000000000000000000000000000000000000..08e244f7da809fc65b2df69230e99022f080dd78 --- /dev/null +++ b/lib/modules/datasource/docker/ecr.ts @@ -0,0 +1,54 @@ +import { ECR, ECRClientConfig } from '@aws-sdk/client-ecr'; +import { logger } from '../../../logger'; +import type { HostRule } from '../../../types'; +import type { HttpError } from '../../../util/http'; +import type { HttpResponse } from '../../../util/http/types'; +import { regEx } from '../../../util/regex'; +import { addSecretForSanitizing } from '../../../util/sanitize'; + +export const ecrRegex = regEx(/\d+\.dkr\.ecr\.([-a-z0-9]+)\.amazonaws\.com/); +export const ecrPublicRegex = regEx(/public\.ecr\.aws/); + +export async function getECRAuthToken( + region: string, + opts: HostRule +): Promise<string | null> { + const config: ECRClientConfig = { region }; + if (opts.username && opts.password) { + config.credentials = { + accessKeyId: opts.username, + secretAccessKey: opts.password, + ...(opts.token && { sessionToken: opts.token }), + }; + } + + const ecr = new ECR(config); + try { + const data = await ecr.getAuthorizationToken({}); + const authorizationToken = data?.authorizationData?.[0]?.authorizationToken; + if (authorizationToken) { + // sanitize token + addSecretForSanitizing(authorizationToken); + return authorizationToken; + } + logger.warn( + 'Could not extract authorizationToken from ECR getAuthorizationToken response' + ); + } catch (err) { + logger.trace({ err }, 'err'); + logger.debug('ECR getAuthorizationToken error'); + } + return null; +} + +export function isECRMaxResultsError(err: HttpError): boolean { + const resp = err.response as HttpResponse<any> | undefined; + return !!( + resp?.statusCode === 405 && + resp.headers?.['docker-distribution-api-version'] && + // https://docs.aws.amazon.com/AmazonECR/latest/APIReference/API_DescribeRepositories.html#ECR-DescribeRepositories-request-maxResults + resp.body?.['errors']?.[0]?.message?.includes( + 'Member must have value less than or equal to 1000' + ) + ); +} diff --git a/lib/modules/datasource/docker/index.spec.ts b/lib/modules/datasource/docker/index.spec.ts index fe3979b58e57cced4f6b14cb6afd49a2dd7db8cf..6d1ffb0558b793d2912e03f37d7ec06c6dea0274 100644 --- a/lib/modules/datasource/docker/index.spec.ts +++ b/lib/modules/datasource/docker/index.spec.ts @@ -8,18 +8,12 @@ import { getDigest, getPkgReleases } from '..'; import { range } from '../../../../lib/util/range'; import * as httpMock from '../../../../test/http-mock'; import { logger, mocked } from '../../../../test/util'; -import { - EXTERNAL_HOST_ERROR, - PAGE_NOT_FOUND_ERROR, -} from '../../../constants/error-messages'; +import { EXTERNAL_HOST_ERROR } from '../../../constants/error-messages'; import * as _hostRules from '../../../util/host-rules'; -import { Http } from '../../../util/http'; -import { DockerDatasource, getAuthHeaders, getRegistryRepository } from '.'; +import { DockerDatasource } from '.'; const hostRules = mocked(_hostRules); -const http = new Http(DockerDatasource.id); - jest.mock('../../../util/host-rules'); const ecrMock = mockClient(ECRClient); @@ -48,169 +42,6 @@ describe('modules/datasource/docker/index', () => { hostRules.hosts.mockReturnValue([]); }); - describe('getRegistryRepository', () => { - it('handles local registries', () => { - const res = getRegistryRepository( - 'registry:5000/org/package', - 'https://index.docker.io' - ); - expect(res).toStrictEqual({ - dockerRepository: 'org/package', - registryHost: 'https://registry:5000', - }); - }); - - it('supports registryUrls', () => { - const res = getRegistryRepository( - 'my.local.registry/prefix/image', - 'https://my.local.registry/prefix' - ); - expect(res).toStrictEqual({ - dockerRepository: 'prefix/image', - registryHost: 'https://my.local.registry', - }); - }); - - it('supports http registryUrls', () => { - const res = getRegistryRepository( - 'my.local.registry/prefix/image', - 'http://my.local.registry/prefix' - ); - expect(res).toStrictEqual({ - dockerRepository: 'prefix/image', - registryHost: 'http://my.local.registry', - }); - }); - - it('supports schemeless registryUrls', () => { - const res = getRegistryRepository( - 'my.local.registry/prefix/image', - 'my.local.registry/prefix' - ); - expect(res).toStrictEqual({ - dockerRepository: 'prefix/image', - registryHost: 'https://my.local.registry', - }); - }); - }); - - describe('getAuthHeaders', () => { - it('throw page not found exception', async () => { - httpMock - .scope('https://my.local.registry') - .get('/v2/repo/tags/list?n=1000') - .reply(404, {}); - - await expect( - getAuthHeaders( - http, - 'https://my.local.registry', - 'repo', - 'https://my.local.registry/v2/repo/tags/list?n=1000' - ) - ).rejects.toThrow(PAGE_NOT_FOUND_ERROR); - }); - - it('returns "authType token" if both provided', async () => { - httpMock - .scope('https://my.local.registry') - .get('/v2/', undefined, { badheaders: ['authorization'] }) - .reply(401, '', { 'www-authenticate': 'Authenticate you must' }); - hostRules.hosts.mockReturnValue([]); - hostRules.find.mockReturnValue({ - authType: 'some-authType', - token: 'some-token', - }); - - const headers = await getAuthHeaders( - http, - 'https://my.local.registry', - 'https://my.local.registry/prefix' - ); - - // do not inline, otherwise we get false positive from codeql - expect(headers).toMatchInlineSnapshot(` - { - "authorization": "some-authType some-token", - } - `); - }); - - it('returns "Bearer token" if only token provided', async () => { - httpMock - .scope('https://my.local.registry') - .get('/v2/', undefined, { badheaders: ['authorization'] }) - .reply(401, '', { 'www-authenticate': 'Authenticate you must' }); - hostRules.hosts.mockReturnValue([]); - hostRules.find.mockReturnValue({ - token: 'some-token', - }); - - const headers = await getAuthHeaders( - http, - 'https://my.local.registry', - 'https://my.local.registry/prefix' - ); - - // do not inline, otherwise we get false positive from codeql - expect(headers).toMatchInlineSnapshot(` - { - "authorization": "Bearer some-token", - } - `); - }); - - it('fails', async () => { - httpMock - .scope('https://my.local.registry') - .get('/v2/', undefined, { badheaders: ['authorization'] }) - .reply(401, '', { 'www-authenticate': 'Authenticate you must' }); - hostRules.hosts.mockReturnValue([]); - httpMock.clear(false); - - httpMock - .scope('https://my.local.registry') - .get('/v2/', undefined, { badheaders: ['authorization'] }) - .reply(401, '', {}); - - const headers = await getAuthHeaders( - http, - 'https://my.local.registry', - 'https://my.local.registry/prefix' - ); - - expect(headers).toBeNull(); - }); - - it('use resources URL and resolve scope in www-authenticate header', async () => { - httpMock - .scope('https://my.local.registry') - .get('/v2/my/node/resource') - .reply(401, '', { - 'www-authenticate': - 'Bearer realm="https://my.local.registry/oauth2/token",service="my.local.registry",scope="repository:my/node:whatever"', - }) - .get( - '/oauth2/token?service=my.local.registry&scope=repository:my/node:whatever' - ) - .reply(200, { token: 'some-token' }); - - const headers = await getAuthHeaders( - http, - 'https://my.local.registry', - 'my/node/prefix', - 'https://my.local.registry/v2/my/node/resource' - ); - - // do not inline, otherwise we get false positive from codeql - expect(headers).toMatchInlineSnapshot(` - { - "authorization": "Bearer some-token", - } - `); - }); - }); - describe('getDigest', () => { it('returns null if no token', async () => { httpMock diff --git a/lib/modules/datasource/docker/index.ts b/lib/modules/datasource/docker/index.ts index 88ab4354ffcea126e7d422400ce47d335ba37f9c..ca5f5d728aa9dd723f101b4b06a70dee003defd8 100644 --- a/lib/modules/datasource/docker/index.ts +++ b/lib/modules/datasource/docker/index.ts @@ -1,353 +1,42 @@ -import { ECR } from '@aws-sdk/client-ecr'; -import type { ECRClientConfig } from '@aws-sdk/client-ecr'; import is from '@sindresorhus/is'; -import { parse } from 'auth-header'; -import hasha from 'hasha'; -import { - HOST_DISABLED, - PAGE_NOT_FOUND_ERROR, -} from '../../../constants/error-messages'; +import { PAGE_NOT_FOUND_ERROR } from '../../../constants/error-messages'; import { logger } from '../../../logger'; -import type { HostRule } from '../../../types'; import { ExternalHostError } from '../../../types/errors/external-host-error'; import { cache } from '../../../util/cache/package/decorator'; -import * as hostRules from '../../../util/host-rules'; -import { Http, HttpError } from '../../../util/http'; -import type { - HttpOptions, - HttpResponse, - OutgoingHttpHeaders, -} from '../../../util/http/types'; +import { HttpError } from '../../../util/http'; +import type { HttpResponse } from '../../../util/http/types'; import { hasKey } from '../../../util/object'; import { regEx } from '../../../util/regex'; -import { addSecretForSanitizing } from '../../../util/sanitize'; import { isDockerDigest } from '../../../util/string'; import { ensurePathPrefix, - ensureTrailingSlash, joinUrlParts, parseLinkHeader, - parseUrl, - trimTrailingSlash, } from '../../../util/url'; -import { - api as dockerVersioning, - id as dockerVersioningId, -} from '../../versioning/docker'; +import { id as dockerVersioningId } from '../../versioning/docker'; import { Datasource } from '../datasource'; import type { DigestConfig, GetReleasesConfig, ReleaseResult } from '../types'; -import { gitRefLabel, isArtifactoryServer, sourceLabels } from './common'; +import { isArtifactoryServer } from './artifactory'; +import { + DOCKER_HUB, + dockerDatasourceId, + extractDigestFromResponseBody, + findLatestStable, + getAuthHeaders, + getRegistryRepository, + gitRefLabel, + isDockerHost, + sourceLabels, +} from './common'; +import { ecrPublicRegex, ecrRegex, isECRMaxResultsError } from './ecr'; import type { Image, ImageConfig, ImageList, OciImage, OciImageList, - RegistryRepository, } from './types'; -export const DOCKER_HUB = 'https://index.docker.io'; - -export const ecrRegex = regEx(/\d+\.dkr\.ecr\.([-a-z0-9]+)\.amazonaws\.com/); -export const ecrPublicRegex = regEx(/public\.ecr\.aws/); - -function isDockerHost(host: string): boolean { - const regex = regEx(/(?:^|\.)docker\.io$/); - return regex.test(host); -} - -export async function getAuthHeaders( - http: Http, - registryHost: string, - dockerRepository: string, - apiCheckUrl = `${registryHost}/v2/` -): Promise<OutgoingHttpHeaders | null> { - try { - const options = { - throwHttpErrors: false, - noAuth: true, - }; - const apiCheckResponse = apiCheckUrl.endsWith('/v2/') - ? await http.get(apiCheckUrl, options) - : // use json request, as this will be cached for tags, so it returns json - // TODO: add cache test - await http.getJson(apiCheckUrl, options); - - if (apiCheckResponse.statusCode === 200) { - logger.debug(`No registry auth required for ${apiCheckUrl}`); - return {}; - } - if (apiCheckResponse.statusCode === 404) { - logger.debug(`Page Not Found ${apiCheckUrl}`); - // throw error up to be caught and potentially retried with library/ prefix - throw new Error(PAGE_NOT_FOUND_ERROR); - } - if ( - apiCheckResponse.statusCode !== 401 || - !is.nonEmptyString(apiCheckResponse.headers['www-authenticate']) - ) { - logger.warn( - { apiCheckUrl, res: apiCheckResponse }, - 'Invalid registry response' - ); - return null; - } - - const authenticateHeader = parse( - apiCheckResponse.headers['www-authenticate'] - ); - - const opts: HostRule & HttpOptions = hostRules.find({ - hostType: DockerDatasource.id, - url: apiCheckUrl, - }); - if (ecrRegex.test(registryHost)) { - logger.trace( - { registryHost, dockerRepository }, - `Using ecr auth for Docker registry` - ); - const [, region] = ecrRegex.exec(registryHost) ?? []; - const auth = await getECRAuthToken(region, opts); - if (auth) { - opts.headers = { authorization: `Basic ${auth}` }; - } - } else if (opts.username && opts.password) { - logger.trace( - { registryHost, dockerRepository }, - `Using basic auth for Docker registry` - ); - const auth = Buffer.from(`${opts.username}:${opts.password}`).toString( - 'base64' - ); - opts.headers = { authorization: `Basic ${auth}` }; - } else if (opts.token) { - const authType = opts.authType ?? 'Bearer'; - logger.trace( - { registryHost, dockerRepository }, - `Using ${authType} token for Docker registry` - ); - opts.headers = { authorization: `${authType} ${opts.token}` }; - } - delete opts.username; - delete opts.password; - delete opts.token; - - // If realm isn't an url, we should directly use auth header - // Can happen when we get a Basic auth or some other auth type - // * WWW-Authenticate: Basic realm="Artifactory Realm" - // * Www-Authenticate: Basic realm="https://123456789.dkr.ecr.eu-central-1.amazonaws.com/",service="ecr.amazonaws.com" - // * www-authenticate: Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull" - // * www-authenticate: Bearer realm="https://auth.docker.io/token",service="registry.docker.io" - if ( - authenticateHeader.scheme.toUpperCase() !== 'BEARER' || - !is.string(authenticateHeader.params.realm) || - parseUrl(authenticateHeader.params.realm) === null - ) { - logger.trace( - { registryHost, dockerRepository, authenticateHeader }, - `Invalid realm, testing direct auth` - ); - return opts.headers ?? null; - } - - let scope = `repository:${dockerRepository}:pull`; - // repo isn't known to server yet, so causing wrong scope `repository:user/image:pull` - if ( - is.string(authenticateHeader.params.scope) && - !apiCheckUrl.endsWith('/v2/') - ) { - scope = authenticateHeader.params.scope; - } - - let service = authenticateHeader.params.service; - if (is.string(service)) { - service = `service=${service}&`; - } else { - service = ``; - } - - const authUrl = `${authenticateHeader.params.realm}?${service}scope=${scope}`; - logger.trace( - { registryHost, dockerRepository, authUrl }, - `Obtaining docker registry token` - ); - opts.noAuth = true; - const authResponse = ( - await http.getJson<{ token?: string; access_token?: string }>( - authUrl, - opts - ) - ).body; - - const token = authResponse.token ?? authResponse.access_token; - // istanbul ignore if - if (!token) { - logger.warn('Failed to obtain docker registry token'); - return null; - } - // sanitize token - addSecretForSanitizing(token); - return { - authorization: `Bearer ${token}`, - }; - } catch (err) /* istanbul ignore next */ { - if (err.host === 'quay.io') { - // TODO: debug why quay throws errors (#9604) - return null; - } - if (err.statusCode === 401) { - logger.debug( - { registryHost, dockerRepository }, - 'Unauthorized docker lookup' - ); - logger.debug({ err }); - return null; - } - if (err.statusCode === 403) { - logger.debug( - { registryHost, dockerRepository }, - 'Not allowed to access docker registry' - ); - logger.debug({ err }); - return null; - } - if (err.name === 'RequestError' && isDockerHost(registryHost)) { - throw new ExternalHostError(err); - } - if (err.statusCode === 429 && isDockerHost(registryHost)) { - throw new ExternalHostError(err); - } - if (err.statusCode >= 500 && err.statusCode < 600) { - throw new ExternalHostError(err); - } - if (err.message === PAGE_NOT_FOUND_ERROR) { - throw err; - } - if (err.message === HOST_DISABLED) { - logger.trace({ registryHost, dockerRepository, err }, 'Host disabled'); - return null; - } - logger.warn( - { registryHost, dockerRepository, err }, - 'Error obtaining docker token' - ); - return null; - } -} - -async function getECRAuthToken( - region: string, - opts: HostRule -): Promise<string | null> { - const config: ECRClientConfig = { region }; - if (opts.username && opts.password) { - config.credentials = { - accessKeyId: opts.username, - secretAccessKey: opts.password, - ...(opts.token && { sessionToken: opts.token }), - }; - } - - const ecr = new ECR(config); - try { - const data = await ecr.getAuthorizationToken({}); - const authorizationToken = data?.authorizationData?.[0]?.authorizationToken; - if (authorizationToken) { - // sanitize token - addSecretForSanitizing(authorizationToken); - return authorizationToken; - } - logger.warn( - 'Could not extract authorizationToken from ECR getAuthorizationToken response' - ); - } catch (err) { - logger.trace({ err }, 'err'); - logger.debug('ECR getAuthorizationToken error'); - } - return null; -} - -export function getRegistryRepository( - packageName: string, - registryUrl: string -): RegistryRepository { - if (registryUrl !== DOCKER_HUB) { - const registryEndingWithSlash = ensureTrailingSlash( - registryUrl.replace(regEx(/^https?:\/\//), '') - ); - if (packageName.startsWith(registryEndingWithSlash)) { - let registryHost = trimTrailingSlash(registryUrl); - if (!regEx(/^https?:\/\//).test(registryHost)) { - registryHost = `https://${registryHost}`; - } - let dockerRepository = packageName.replace(registryEndingWithSlash, ''); - const fullUrl = `${registryHost}/${dockerRepository}`; - const { origin, pathname } = parseUrl(fullUrl)!; - registryHost = origin; - dockerRepository = pathname.substring(1); - return { - registryHost, - dockerRepository, - }; - } - } - let registryHost: string | undefined; - const split = packageName.split('/'); - if (split.length > 1 && (split[0].includes('.') || split[0].includes(':'))) { - [registryHost] = split; - split.shift(); - } - let dockerRepository = split.join('/'); - if (!registryHost) { - registryHost = registryUrl.replace( - 'https://docker.io', - 'https://index.docker.io' - ); - } - if (registryHost === 'docker.io') { - registryHost = 'index.docker.io'; - } - if (!regEx(/^https?:\/\//).exec(registryHost)) { - registryHost = `https://${registryHost}`; - } - const opts = hostRules.find({ - hostType: DockerDatasource.id, - url: registryHost, - }); - if (opts?.insecureRegistry) { - registryHost = registryHost.replace('https', 'http'); - } - if (registryHost.endsWith('.docker.io') && !dockerRepository.includes('/')) { - dockerRepository = 'library/' + dockerRepository; - } - return { - registryHost, - dockerRepository, - }; -} - -function digestFromManifestStr(str: hasha.HashaInput): string { - return 'sha256:' + hasha(str, { algorithm: 'sha256' }); -} - -export function extractDigestFromResponseBody( - manifestResponse: HttpResponse -): string { - return digestFromManifestStr(manifestResponse.body); -} - -export function isECRMaxResultsError(err: HttpError): boolean { - const resp = err.response as HttpResponse<any> | undefined; - return !!( - resp?.statusCode === 405 && - resp.headers?.['docker-distribution-api-version'] && - // https://docs.aws.amazon.com/AmazonECR/latest/APIReference/API_DescribeRepositories.html#ECR-DescribeRepositories-request-maxResults - resp.body?.['errors']?.[0]?.message?.includes( - 'Member must have value less than or equal to 1000' - ) - ); -} - const defaultConfig = { commitMessageTopic: '{{{depName}}} Docker tag', commitMessageExtra: @@ -372,16 +61,8 @@ const defaultConfig = { }, }; -function findLatestStable(tags: string[]): string | null { - const versions = tags - .filter((v) => dockerVersioning.isValid(v) && dockerVersioning.isStable(v)) - .sort((a, b) => dockerVersioning.sortVersions(a, b)); - - return versions.pop() ?? tags.slice(-1).pop() ?? null; -} - export class DockerDatasource extends Datasource { - static readonly id = 'docker'; + static readonly id = dockerDatasourceId; override readonly defaultVersioning = dockerVersioningId;