From 44c67da0e18c4657b2369f594542088dca488fcf Mon Sep 17 00:00:00 2001 From: Johannes Feichtner <Churro@users.noreply.github.com> Date: Mon, 6 Jun 2022 07:47:17 +0200 Subject: [PATCH] feat(dockerfile): add support for escape chars and ARG instructions (#15751) --- .../dockerfile/__fixtures__/3.Dockerfile | 14 + .../dockerfile/__fixtures__/4.Dockerfile | 28 + .../manager/dockerfile/extract.spec.ts | 500 +++++++++++++++++- lib/modules/manager/dockerfile/extract.ts | 276 ++++++++-- 4 files changed, 734 insertions(+), 84 deletions(-) create mode 100644 lib/modules/manager/dockerfile/__fixtures__/3.Dockerfile create mode 100644 lib/modules/manager/dockerfile/__fixtures__/4.Dockerfile diff --git a/lib/modules/manager/dockerfile/__fixtures__/3.Dockerfile b/lib/modules/manager/dockerfile/__fixtures__/3.Dockerfile new file mode 100644 index 0000000000..ef4344c0ec --- /dev/null +++ b/lib/modules/manager/dockerfile/__fixtures__/3.Dockerfile @@ -0,0 +1,14 @@ + ARG \ + # multi-line arg + ALPINE_VERSION=alpine:3.15.4 + +FROM \ +${ALPINE_VERSION} as stage1 + +ARG \ + \ + # multi-line arg + # and multi-line comment + nginx_version="nginx:1.18.0-alpine@sha256:ca9fac83c6c89a09424279de522214e865e322187b22a1a29b12747a4287b7bd" + +FROM $nginx_version as stage2 diff --git a/lib/modules/manager/dockerfile/__fixtures__/4.Dockerfile b/lib/modules/manager/dockerfile/__fixtures__/4.Dockerfile new file mode 100644 index 0000000000..1480cad0cc --- /dev/null +++ b/lib/modules/manager/dockerfile/__fixtures__/4.Dockerfile @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1 + # EsCaPe=` + ARG ` + # multi-line arg + ALPINE_VERSION=alpine:3.15.4 + +FROM ` +${ALPINE_VERSION} as stage1 + +ARG ` + ` + # multi-line arg + # and multi-line comment + nginx_version="nginx:18.04@sha256:abcdef" + +FROM $nginx_version as stage2 + + FROM ` + ` + image5 ` + #comment5 + as name3 + + COPY ` + ` + --from=image12 a ` + #comment5 + b diff --git a/lib/modules/manager/dockerfile/extract.spec.ts b/lib/modules/manager/dockerfile/extract.spec.ts index 81c8a91e4e..38f0487483 100644 --- a/lib/modules/manager/dockerfile/extract.spec.ts +++ b/lib/modules/manager/dockerfile/extract.spec.ts @@ -1,8 +1,10 @@ -import { loadFixture } from '../../../../test/util'; -import { extractPackageFile, getDep } from './extract'; +import { Fixtures } from '../../../../test/fixtures'; +import { extractPackageFile, extractVariables, getDep } from './extract'; -const d1 = loadFixture('1.Dockerfile'); -const d2 = loadFixture('2.Dockerfile'); +const d1 = Fixtures.get('1.Dockerfile'); +const d2 = Fixtures.get('2.Dockerfile'); +const d3 = Fixtures.get('3.Dockerfile'); +const d4 = Fixtures.get('4.Dockerfile'); describe('modules/manager/dockerfile/extract', () => { describe('extractPackageFile()', () => { @@ -647,6 +649,377 @@ describe('modules/manager/dockerfile/extract', () => { ] `); }); + + it('handles implausible line continuation', () => { + const res = extractPackageFile( + 'FROM alpine:3.5\n\nRUN something \\' + ).deps; + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "autoReplaceStringTemplate": "{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}", + "currentDigest": undefined, + "currentValue": "3.5", + "datasource": "docker", + "depName": "alpine", + "depType": "final", + "replaceString": "alpine:3.5", + }, + ] + `); + }); + + it('handles multi-line FROM with space after escape character', () => { + const res = extractPackageFile('FROM \\ \nnginx:1.20\n').deps; + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "autoReplaceStringTemplate": "{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}", + "currentDigest": undefined, + "currentValue": "1.20", + "datasource": "docker", + "depName": "nginx", + "depType": "final", + "replaceString": "nginx:1.20", + }, + ] + `); + }); + + it('handles FROM without ARG default value', () => { + const res = extractPackageFile('ARG img_base\nFROM $img_base\n').deps; + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "autoReplaceStringTemplate": "{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}", + "datasource": "docker", + "depType": "final", + "replaceString": "$img_base", + "skipReason": "contains-variable", + }, + ] + `); + }); + + it('handles FROM with empty ARG default value', () => { + const res = extractPackageFile( + 'ARG patch1=""\nARG patch2=\nFROM nginx:1.20${patch1}$patch2\n' + ).deps; + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "autoReplaceStringTemplate": "FROM nginx:{{#if newValue}}{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}\${patch1}$patch2", + "currentDigest": undefined, + "currentValue": "1.20", + "datasource": "docker", + "depName": "nginx", + "depType": "final", + "replaceString": "FROM nginx:1.20\${patch1}$patch2", + }, + ] + `); + }); + + it('handles FROM with version in ARG value', () => { + const res = extractPackageFile( + 'ARG\tVARIANT="1.60.0-bullseye"\nFROM\trust:${VARIANT}\n' + ).deps; + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "autoReplaceStringTemplate": "ARG\tVARIANT=\\"{{#if newValue}}{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}\\"", + "currentDigest": undefined, + "currentValue": "1.60.0-bullseye", + "datasource": "docker", + "depName": "rust", + "depType": "final", + "replaceString": "ARG\tVARIANT=\\"1.60.0-bullseye\\"", + }, + ] + `); + }); + + it('handles FROM with version in ARG default value', () => { + const res = extractPackageFile( + 'ARG IMAGE_VERSION=${IMAGE_VERSION:-ubuntu:xenial}\nfrom ${IMAGE_VERSION} as base\n' + ).deps; + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "autoReplaceStringTemplate": "ARG IMAGE_VERSION=\${IMAGE_VERSION:-ubuntu:{{#if newValue}}{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}}", + "currentValue": "xenial", + "datasource": "docker", + "depName": "ubuntu", + "depType": "final", + "replaceString": "ARG IMAGE_VERSION=\${IMAGE_VERSION:-ubuntu:xenial}", + "versioning": "ubuntu", + }, + ] + `); + }); + + it('handles FROM with digest in ARG default value', () => { + const res = extractPackageFile( + 'ARG sha_digest=sha256:ab37242e81cbc031b2600eef4440fe87055a05c14b40686df85078cc5086c98f\n' + + ' FROM gcr.io/distroless/java17@$sha_digest' + ).deps; + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "autoReplaceStringTemplate": "ARG sha_digest={{#if newDigest}}{{newDigest}}{{/if}}", + "currentDigest": "sha256:ab37242e81cbc031b2600eef4440fe87055a05c14b40686df85078cc5086c98f", + "currentValue": undefined, + "datasource": "docker", + "depName": "gcr.io/distroless/java17", + "depType": "final", + "replaceString": "ARG sha_digest=sha256:ab37242e81cbc031b2600eef4440fe87055a05c14b40686df85078cc5086c98f", + }, + ] + `); + }); + + it('handles FROM with overwritten ARG value', () => { + const res = extractPackageFile( + 'ARG base=nginx:1.19\nFROM $base as stage1\nARG base=nginx:1.20\nFROM --platform=amd64 $base as stage2\n' + ).deps; + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "autoReplaceStringTemplate": "ARG base=nginx:{{#if newValue}}{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}", + "currentDigest": undefined, + "currentValue": "1.19", + "datasource": "docker", + "depName": "nginx", + "depType": "stage", + "replaceString": "ARG base=nginx:1.19", + }, + Object { + "autoReplaceStringTemplate": "ARG base=nginx:{{#if newValue}}{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}", + "currentDigest": undefined, + "currentValue": "1.20", + "datasource": "docker", + "depName": "nginx", + "depType": "final", + "replaceString": "ARG base=nginx:1.20", + }, + ] + `); + }); + + it('handles FROM with multiple ARG values', () => { + const res = extractPackageFile( + 'ARG CUDA=9.2\nARG LINUX_VERSION ubuntu16.04\nFROM nvidia/cuda:${CUDA}-devel-${LINUX_VERSION}\n' + ).deps; + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "autoReplaceStringTemplate": "{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}", + "currentDigest": undefined, + "currentValue": "9.2-devel-ubuntu16.04", + "datasource": "docker", + "depName": "nvidia/cuda", + "depType": "final", + "replaceString": "nvidia/cuda:9.2-devel-ubuntu16.04", + }, + ] + `); + }); + + it('skips scratch if provided in ARG value', () => { + const res = extractPackageFile('ARG img="scratch"\nFROM $img as base\n'); + expect(res).toBeNull(); + }); + + it('extracts images from multi-line ARG statements', () => { + const res = extractPackageFile(d3).deps; + expect(res).toEqual([ + { + autoReplaceStringTemplate: + ' ARG \\\n' + + '\t# multi-line arg\n' + + ' ALPINE_VERSION=alpine:{{#if newValue}}{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}', + currentDigest: undefined, + currentValue: '3.15.4', + datasource: 'docker', + depName: 'alpine', + depType: 'stage', + replaceString: + ' ARG \\\n' + + '\t# multi-line arg\n' + + ' ALPINE_VERSION=alpine:3.15.4', + }, + { + autoReplaceStringTemplate: + 'ARG \\\n' + + ' \\\n' + + ' # multi-line arg\n' + + ' # and multi-line comment\n' + + ' nginx_version="nginx:{{#if newValue}}{{newValue}}{{/if}}@{{#if newDigest}}{{newDigest}}{{/if}}"', + currentDigest: + 'sha256:ca9fac83c6c89a09424279de522214e865e322187b22a1a29b12747a4287b7bd', + currentValue: '1.18.0-alpine', + datasource: 'docker', + depName: 'nginx', + depType: 'final', + replaceString: + 'ARG \\\n' + + ' \\\n' + + ' # multi-line arg\n' + + ' # and multi-line comment\n' + + ' nginx_version="nginx:1.18.0-alpine@sha256:ca9fac83c6c89a09424279de522214e865e322187b22a1a29b12747a4287b7bd"', + }, + ]); + }); + + it('ignores parser directives in wrong order', () => { + const res = extractPackageFile( + '# dummy\n# escape = `\n\nFROM\\\nnginx:1.20' + ).deps; + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "autoReplaceStringTemplate": "{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}", + "currentDigest": undefined, + "currentValue": "1.20", + "datasource": "docker", + "depName": "nginx", + "depType": "final", + "replaceString": "nginx:1.20", + }, + ] + `); + }); + + it('handles an alternative escape character', () => { + const res = extractPackageFile(d4).deps; + expect(res).toEqual([ + { + autoReplaceStringTemplate: + ' ARG `\n' + + '\t# multi-line arg\n' + + ' ALPINE_VERSION=alpine:{{#if newValue}}{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}', + currentDigest: undefined, + currentValue: '3.15.4', + datasource: 'docker', + depName: 'alpine', + depType: 'stage', + replaceString: + ' ARG `\n' + + '\t# multi-line arg\n' + + ' ALPINE_VERSION=alpine:3.15.4', + }, + { + autoReplaceStringTemplate: + 'ARG `\n' + + ' `\n' + + ' # multi-line arg\n' + + ' # and multi-line comment\n' + + ' nginx_version="nginx:{{#if newValue}}{{newValue}}{{/if}}@{{#if newDigest}}{{newDigest}}{{/if}}"', + currentDigest: 'sha256:abcdef', + currentValue: '18.04', + datasource: 'docker', + depName: 'nginx', + depType: 'stage', + replaceString: + 'ARG `\n' + + ' `\n' + + ' # multi-line arg\n' + + ' # and multi-line comment\n' + + ' nginx_version="nginx:18.04@sha256:abcdef"', + }, + { + autoReplaceStringTemplate: + '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}', + currentDigest: undefined, + currentValue: undefined, + datasource: 'docker', + depName: 'image5', + depType: 'stage', + replaceString: 'image5', + }, + { + autoReplaceStringTemplate: + '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}', + currentDigest: undefined, + currentValue: undefined, + datasource: 'docker', + depName: 'image12', + depType: 'final', + replaceString: 'image12', + }, + ]); + }); + + it('handles FROM with version in ARG default value and quotes', () => { + const res = extractPackageFile( + 'ARG REF_NAME=${REF_NAME:-"gcr.io/distroless/static-debian11:nonroot@sha256:abc"}\nfrom ${REF_NAME}' + ).deps; + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "autoReplaceStringTemplate": "ARG REF_NAME=\${REF_NAME:-\\"gcr.io/distroless/static-debian11:{{#if newValue}}{{newValue}}{{/if}}@{{#if newDigest}}{{newDigest}}{{/if}}\\"}", + "currentDigest": "sha256:abc", + "currentValue": "nonroot", + "datasource": "docker", + "depName": "gcr.io/distroless/static-debian11", + "depType": "final", + "replaceString": "ARG REF_NAME=\${REF_NAME:-\\"gcr.io/distroless/static-debian11:nonroot@sha256:abc\\"}", + }, + ] + `); + }); + + it('handles version in ARG and digest in FROM with CRLF linefeed', () => { + const res = extractPackageFile( + 'ARG IMAGE_TAG=14.04\r\n#something unrelated\r\nFROM ubuntu:$IMAGE_TAG@sha256:abc\r\n' + ).deps; + expect(res).toEqual([ + { + autoReplaceStringTemplate: + 'ARG IMAGE_TAG={{#if newValue}}{{newValue}}{{/if}}\r\n#something unrelated\r\nFROM ubuntu:$IMAGE_TAG@{{#if newDigest}}{{newDigest}}{{/if}}', + currentDigest: 'sha256:abc', + currentValue: '14.04', + datasource: 'docker', + depName: 'ubuntu', + depType: 'final', + replaceString: + 'ARG IMAGE_TAG=14.04\r\n#something unrelated\r\nFROM ubuntu:$IMAGE_TAG@sha256:abc', + versioning: 'ubuntu', + }, + ]); + }); + + it('handles updates of multiple ARG values', () => { + const res = extractPackageFile( + '# random comment\n\n' + + 'ARG NODE_IMAGE_HASH="@sha256:ba9c961513b853210ae0ca1524274eafa5fd94e20b856343887ca7274c8450e4"\n' + + 'ARG NODE_IMAGE_HOST="docker.io/library/"\n' + + 'ARG NODE_IMAGE_NAME=node\n' + + 'ARG NODE_IMAGE_TAG="16.14.2-alpine3.14"\n' + + 'ARG DUMMY_PREFIX=\n' + + 'FROM ${DUMMY_PREFIX}${NODE_IMAGE_HOST}${NODE_IMAGE_NAME}:${NODE_IMAGE_TAG}${NODE_IMAGE_HASH} as yarn\n' + ).deps; + expect(res).toEqual([ + { + autoReplaceStringTemplate: + 'ARG NODE_IMAGE_HASH="@{{#if newDigest}}{{newDigest}}{{/if}}"\n' + + 'ARG NODE_IMAGE_HOST="docker.io/library/"\n' + + 'ARG NODE_IMAGE_NAME=node\n' + + 'ARG NODE_IMAGE_TAG="{{#if newValue}}{{newValue}}{{/if}}"', + currentDigest: + 'sha256:ba9c961513b853210ae0ca1524274eafa5fd94e20b856343887ca7274c8450e4', + currentValue: '16.14.2-alpine3.14', + datasource: 'docker', + depName: 'docker.io/library/node', + depType: 'final', + replaceString: + 'ARG NODE_IMAGE_HASH="@sha256:ba9c961513b853210ae0ca1524274eafa5fd94e20b856343887ca7274c8450e4"\n' + + 'ARG NODE_IMAGE_HOST="docker.io/library/"\n' + + 'ARG NODE_IMAGE_NAME=node\n' + + 'ARG NODE_IMAGE_TAG="16.14.2-alpine3.14"', + }, + ]); + }); }); describe('getDep()', () => { @@ -657,37 +1030,63 @@ describe('modules/manager/dockerfile/extract', () => { it('handles default environment variable values', () => { const res = getDep('${REDIS_IMAGE:-redis:5.0.0@sha256:abcd}'); expect(res).toMatchInlineSnapshot(` -Object { - "autoReplaceStringTemplate": "{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}", - "currentDigest": "sha256:abcd", - "currentValue": "5.0.0", - "datasource": "docker", - "depName": "redis", - "replaceString": "redis:5.0.0@sha256:abcd", -} -`); + Object { + "autoReplaceStringTemplate": "{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}", + "currentDigest": "sha256:abcd", + "currentValue": "5.0.0", + "datasource": "docker", + "depName": "redis", + "replaceString": "redis:5.0.0@sha256:abcd", + } + `); const res2 = getDep('${REDIS_IMAGE:-redis:5.0.0}'); expect(res2).toMatchInlineSnapshot(` -Object { - "autoReplaceStringTemplate": "{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}", - "currentValue": "5.0.0", - "datasource": "docker", - "depName": "redis", - "replaceString": "redis:5.0.0", -} -`); + Object { + "autoReplaceStringTemplate": "{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}", + "currentValue": "5.0.0", + "datasource": "docker", + "depName": "redis", + "replaceString": "redis:5.0.0", + } + `); const res3 = getDep('${REDIS_IMAGE:-redis@sha256:abcd}'); expect(res3).toMatchInlineSnapshot(` -Object { - "autoReplaceStringTemplate": "{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}", - "currentDigest": "sha256:abcd", - "datasource": "docker", - "depName": "redis", - "replaceString": "redis@sha256:abcd", -} -`); + Object { + "autoReplaceStringTemplate": "{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}", + "currentDigest": "sha256:abcd", + "datasource": "docker", + "depName": "redis", + "replaceString": "redis@sha256:abcd", + } + `); + + const res4 = getDep( + '${REF_NAME:-"gcr.io/distroless/static-debian11:nonroot@sha256:abc"}' + ); + expect(res4).toMatchInlineSnapshot(` + Object { + "autoReplaceStringTemplate": "{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}", + "currentDigest": "sha256:abc", + "currentValue": "nonroot", + "datasource": "docker", + "depName": "gcr.io/distroless/static-debian11", + "replaceString": "gcr.io/distroless/static-debian11:nonroot@sha256:abc", + } + `); + + const res5 = getDep( + '${REF_NAME:+-gcr.io/distroless/static-debian11:nonroot@sha256:abc}' + ); + expect(res5).toMatchInlineSnapshot(` + Object { + "autoReplaceStringTemplate": "{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}", + "datasource": "docker", + "replaceString": "\${REF_NAME:+-gcr.io/distroless/static-debian11:nonroot@sha256:abc}", + "skipReason": "contains-variable", + } + `); }); it('skips tag containing a variable', () => { @@ -750,4 +1149,47 @@ Object { `); }); }); + + describe('extractVariables()', () => { + it('handles no variable', () => { + expect(extractVariables('nginx:latest')).toBeEmpty(); + }); + + it('handles simple variable', () => { + expect(extractVariables('nginx:$version')).toMatchObject({ + $version: 'version', + }); + }); + + it('handles escaped variable', () => { + expect(extractVariables('nginx:\\$version')).toMatchObject({ + '\\$version': 'version', + }); + }); + + it('handles complex variable', () => { + expect(extractVariables('ubuntu:${ubuntu_version}')).toMatchObject({ + '${ubuntu_version}': 'ubuntu_version', + }); + }); + + it('handles complex variable with static default value', () => { + expect(extractVariables('${var1:-nginx}:latest')).toMatchObject({ + '${var1:-nginx}': 'var1', + }); + }); + + it('handles complex variable with other variable as default value', () => { + expect(extractVariables('${VAR1:-$var2}:latest')).toMatchObject({ + '${VAR1:-$var2}': 'VAR1', + }); + }); + + it('handles multiple variables', () => { + expect(extractVariables('${var1:-$var2}:$version')).toMatchObject({ + '${var1:-$var2}': 'var1', + $version: 'version', + }); + }); + }); }); diff --git a/lib/modules/manager/dockerfile/extract.ts b/lib/modules/manager/dockerfile/extract.ts index 6dc2b0d869..f27f7461a8 100644 --- a/lib/modules/manager/dockerfile/extract.ts +++ b/lib/modules/manager/dockerfile/extract.ts @@ -1,6 +1,6 @@ import is from '@sindresorhus/is'; import { logger } from '../../../logger'; -import { regEx } from '../../../util/regex'; +import { newlineRegex, regEx } from '../../../util/regex'; import { DockerDatasource } from '../../datasource/docker'; import * as debianVersioning from '../../versioning/debian'; import * as ubuntuVersioning from '../../versioning/ubuntu'; @@ -11,6 +11,90 @@ const variableOpen = '${'; const variableClose = '}'; const variableDefaultValueSplit = ':-'; +export function extractVariables(image: string): Record<string, string> { + const variables: Record<string, string> = {}; + const variableRegex = regEx( + /(?<fullvariable>\\?\$(?<simplearg>\w+)|\\?\${(?<complexarg>\w+)(?::.+?)?}+)/gi + ); + + let match: RegExpExecArray | null; + do { + match = variableRegex.exec(image); + if (match?.groups?.fullvariable) { + variables[match.groups.fullvariable] = + match.groups?.simplearg || match.groups?.complexarg; + } + } while (match); + + return variables; +} + +function getAutoReplaceTemplate(dep: PackageDependency): string | undefined { + let template = dep.replaceString; + + if (dep.currentValue) { + let placeholder = '{{#if newValue}}{{newValue}}{{/if}}'; + if (!dep.currentDigest) { + placeholder += '{{#if newDigest}}@{{newDigest}}{{/if}}'; + } + template = template?.replace(dep.currentValue, placeholder); + } + + if (dep.currentDigest) { + template = template?.replace( + dep.currentDigest, + '{{#if newDigest}}{{newDigest}}{{/if}}' + ); + } + + return template; +} + +function processDepForAutoReplace( + dep: PackageDependency, + lineNumberRanges: number[][], + lines: string[], + linefeed: string +): void { + const lineNumberRangesToReplace: number[][] = []; + for (const lineNumberRange of lineNumberRanges) { + for (const lineNumber of lineNumberRange) { + if ( + (dep.currentValue && lines[lineNumber].includes(dep.currentValue)) || + (dep.currentDigest && lines[lineNumber].includes(dep.currentDigest)) + ) { + lineNumberRangesToReplace.push(lineNumberRange); + } + } + } + + lineNumberRangesToReplace.sort((a, b) => { + return a[0] - b[0]; + }); + + const minLine = lineNumberRangesToReplace[0]?.[0]; + const maxLine = + lineNumberRangesToReplace[lineNumberRangesToReplace.length - 1]?.[1]; + if ( + lineNumberRanges.length === 1 || + minLine === undefined || + maxLine === undefined + ) { + return; + } + + const unfoldedLineNumbers = Array.from( + { length: maxLine - minLine + 1 }, + (_v, k) => k + minLine + ); + + dep.replaceString = unfoldedLineNumbers + .map((lineNumber) => lines[lineNumber]) + .join(linefeed); + + dep.autoReplaceStringTemplate = getAutoReplaceTemplate(dep); +} + export function splitImageParts(currentFrom: string): PackageDependency { // Check if we have a variable in format of "${VARIABLE:-<image>:<defaultVal>@<digest>}" // If so, remove everything except the image, defaultVal and digest. @@ -41,6 +125,7 @@ export function splitImageParts(currentFrom: string): PackageDependency { cleanedCurrentFrom.indexOf(variableDefaultValueSplit) + variableDefaultValueSplit.length ); + cleanedCurrentFrom = cleanedCurrentFrom.replace(regEx(/^"(.*)"$/), '$1'); } const [currentDepTag, currentDigest] = cleanedCurrentFrom.split('@'); @@ -58,7 +143,7 @@ export function splitImageParts(currentFrom: string): PackageDependency { } if (depName?.includes(variableMarker)) { - // If depName contains a variable, after cleaning, e.g. "$REGISTRY/alpine", we currently not support this. + // If depName contains a variable, after cleaning, e.g. "$REGISTRY/alpine", we do not support this. return { skipReason: 'contains-variable', }; @@ -162,69 +247,150 @@ export function getDep( export function extractPackageFile(content: string): PackageFile | null { const deps: PackageDependency[] = []; const stageNames: string[] = []; + const args: Record<string, string> = {}; + const argsLines: Record<string, number[]> = {}; - const fromMatches = content.matchAll( - /^[ \t]*FROM(?:\\\r?\n| |\t|#.*?\r?\n|[ \t]--[a-z]+=\S+?)*[ \t](?<image>\S+)(?:(?:\\\r?\n| |\t|#.*\r?\n)+as[ \t]+(?<name>\S+))?/gim // TODO #12875 complex for re2 has too many not supported groups - ); + let escapeChar = '\\\\'; + let lookForEscapeChar = true; + + const lineFeed = content.indexOf('\r\n') >= 0 ? '\r\n' : '\n'; + const lines = content.split(newlineRegex); + for (let lineNumber = 0; lineNumber < lines.length; ) { + const lineNumberInstrStart = lineNumber; + let instruction = lines[lineNumber]; - for (const fromMatch of fromMatches) { - if (fromMatch.groups?.name) { - logger.debug('Found a multistage build stage name'); - stageNames.push(fromMatch.groups.name); + if (lookForEscapeChar) { + const directivesMatch = regEx( + /^[ \t]*#[ \t]*(?<directive>syntax|escape)[ \t]*=[ \t]*(?<escapeChar>\S)/i + ).exec(instruction); + if (!directivesMatch) { + lookForEscapeChar = false; + } else if (directivesMatch.groups?.directive.toLowerCase() === 'escape') { + if (directivesMatch.groups?.escapeChar === '`') { + escapeChar = '`'; + } + lookForEscapeChar = false; + } } - if (fromMatch.groups?.image === 'scratch') { - logger.debug('Skipping scratch'); - } else if ( - fromMatch.groups?.image && - stageNames.includes(fromMatch.groups.image) + + const lineContinuationRegex = regEx(escapeChar + '[ \\t]*$|^[ \\t]*#', 'm'); + let lineLookahead = instruction; + while ( + !lookForEscapeChar && + !instruction.trimStart().startsWith('#') && + lineContinuationRegex.test(lineLookahead) ) { - logger.debug({ image: fromMatch.groups.image }, 'Skipping alias FROM'); - } else { - const dep = getDep(fromMatch.groups?.image); - logger.trace( - { - depName: dep.depName, - currentValue: dep.currentValue, - currentDigest: dep.currentDigest, - }, - 'Dockerfile FROM' - ); - deps.push(dep); + lineLookahead = lines[++lineNumber] || ''; + instruction += '\n' + lineLookahead; } - } - const copyFromMatches = content.matchAll( - /^[ \t]*COPY(?:\\\r?\n| |\t|#.*\r?\n|[ \t]--[a-z]+=\w+?)*[ \t]--from=(?<image>\S+)/gim // TODO #12875 complex for re2 has too many not supported groups - ); + const argRegex = regEx( + '^[ \\t]*ARG(?:' + + escapeChar + + '[ \\t]*\\r?\\n| |\\t|#.*?\\r?\\n)+(?<name>\\S+)[ =](?<value>.*)', + 'im' + ); + const argMatch = argRegex.exec(instruction); + if (argMatch?.groups?.name) { + argsLines[argMatch.groups.name] = [lineNumberInstrStart, lineNumber]; + let argMatchValue = argMatch.groups?.value; + + if ( + argMatchValue.charAt(0) === '"' && + argMatchValue.charAt(argMatchValue.length - 1) === '"' + ) { + argMatchValue = argMatchValue.slice(1, -1); + } - for (const copyFromMatch of copyFromMatches) { - // istanbul ignore if: will never happen - if (!copyFromMatch.groups?.image) { - continue; + args[argMatch.groups.name] = argMatchValue || ''; } - if (stageNames.includes(copyFromMatch.groups.image)) { - logger.debug( - { image: copyFromMatch.groups.image }, - 'Skipping alias COPY --from' - ); - } else if (Number.isNaN(Number(copyFromMatch.groups.image))) { - const dep = getDep(copyFromMatch.groups.image); - logger.debug( - { - depName: dep.depName, - currentValue: dep.currentValue, - currentDigest: dep.currentDigest, - }, - 'Dockerfile COPY --from' - ); - deps.push(dep); - } else { - logger.debug( - { image: copyFromMatch.groups.image }, - 'Skipping index reference COPY --from' - ); + + const fromRegex = new RegExp( + '^[ \\t]*FROM(?:' + + escapeChar + + '[ \\t]*\\r?\\n| |\\t|#.*?\\r?\\n|--platform=\\S+)+(?<image>\\S+)(?:(?:' + + escapeChar + + '[ \\t]*\\r?\\n| |\\t|#.*?\\r?\\n)+as[ \\t]+(?<name>\\S+))?', + 'im' + ); // TODO #12875 complex for re2 has too many not supported groups + const fromMatch = instruction.match(fromRegex); + if (fromMatch?.groups?.image) { + let fromImage = fromMatch.groups.image; + const lineNumberRanges: number[][] = [[lineNumberInstrStart, lineNumber]]; + + if (fromImage.includes(variableMarker)) { + const variables = extractVariables(fromImage); + for (const [fullVariable, argName] of Object.entries(variables)) { + const resolvedArgValue = args[argName]; + if (resolvedArgValue || resolvedArgValue === '') { + fromImage = fromImage.replace(fullVariable, resolvedArgValue); + lineNumberRanges.push(argsLines[argName]); + } + } + } + + if (fromMatch.groups?.name) { + logger.debug('Found a multistage build stage name'); + stageNames.push(fromMatch.groups.name); + } + if (fromImage === 'scratch') { + logger.debug('Skipping scratch'); + } else if (fromImage && stageNames.includes(fromImage)) { + logger.debug({ image: fromImage }, 'Skipping alias FROM'); + } else { + const dep = getDep(fromImage); + processDepForAutoReplace(dep, lineNumberRanges, lines, lineFeed); + logger.trace( + { + depName: dep.depName, + currentValue: dep.currentValue, + currentDigest: dep.currentDigest, + }, + 'Dockerfile FROM' + ); + deps.push(dep); + } + } + + const copyFromRegex = new RegExp( + '^[ \\t]*COPY(?:' + + escapeChar + + '[ \\t]*\\r?\\n| |\\t|#.*?\\r?\\n|--[a-z]+=[a-zA-Z0-9_.:-]+?)+--from=(?<image>\\S+)', + 'im' + ); // TODO #12875 complex for re2 has too many not supported groups + const copyFromMatch = instruction.match(copyFromRegex); + if (copyFromMatch?.groups?.image) { + if (stageNames.includes(copyFromMatch.groups.image)) { + logger.debug( + { image: copyFromMatch.groups.image }, + 'Skipping alias COPY --from' + ); + } else if (Number.isNaN(Number(copyFromMatch.groups.image))) { + const dep = getDep(copyFromMatch.groups.image); + const lineNumberRanges: number[][] = [ + [lineNumberInstrStart, lineNumber], + ]; + processDepForAutoReplace(dep, lineNumberRanges, lines, lineFeed); + logger.debug( + { + depName: dep.depName, + currentValue: dep.currentValue, + currentDigest: dep.currentDigest, + }, + 'Dockerfile COPY --from' + ); + deps.push(dep); + } else { + logger.debug( + { image: copyFromMatch.groups.image }, + 'Skipping index reference COPY --from' + ); + } } + + lineNumber += 1; } + if (!deps.length) { return null; } -- GitLab