Skip to content

Commit cfb31c8

Browse files
ericsmallingclaude
andcommitted
jenkins: fix shell-injection risk in cgSign/cgVerify image arg
Both helpers built their sh script with a Groovy GString and inlined the caller-supplied `image` value inside a single-quoted shell string: IMAGE='${image}' If `image` ever contained a single quote (or other shell metacharacter) it would break out of the quoting and be parsed as shell. Switch the sh body to a single-quoted Groovy string (no Groovy interpolation) and pass `image` through the sh step's environment via withEnv. The shell then references "$IMAGE" with normal double-quoting, which is safe regardless of contents. While here, drop the local `def org = env.CHAINGUARD_ORG` indirection in favor of referencing $CHAINGUARD_ORG directly from the shell — Jenkins already exposes env entries to the sh step, so there's no need to Groovy-interpolate that one either. Same class of issue, though that value is controller-set rather than caller-supplied. Behavior for well-formed image refs is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 10b6ebc commit cfb31c8

2 files changed

Lines changed: 75 additions & 69 deletions

File tree

jenkins/shared-libraries/cg-images/vars/cgSign.groovy

Lines changed: 43 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -39,44 +39,48 @@ def call(String image) {
3939
if (!image?.trim()) {
4040
error('cgSign: image argument is required')
4141
}
42-
def org = env.CHAINGUARD_ORG
43-
if (!org) error('cgSign: env.CHAINGUARD_ORG is empty — JCasC globalNodeProperties should set it from the controller env (jenkins/casc/jenkins.yaml). Re-run setup.sh.')
44-
withCredentials([
45-
file(credentialsId: 'cosign-private-key', variable: 'COSIGN_KEY_FILE'),
46-
string(credentialsId: 'cosign-password', variable: 'COSIGN_PASSWORD'),
47-
]) {
48-
sh """
49-
set -eu
50-
# Pick the RepoDigest whose repo matches the image we just pushed.
51-
# The local image cache may have stale RepoDigests from prior runs
52-
# under different registries (e.g. localhost/library from a Mode C
53-
# session, ttl.sh from a Mode A session) — `{{index .RepoDigests 0}}`
54-
# returned whichever happened to be first and tripped cosign over.
55-
IMAGE='${image}'
56-
REPO=\${IMAGE%:*}
57-
DIGEST=\$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "\$IMAGE" | grep -F "\${REPO}@" | head -1)
58-
if [ -z "\$DIGEST" ]; then
59-
echo "cgSign: could not resolve digest for \$IMAGE under repo \$REPO (was it pushed?)." >&2
60-
exit 1
61-
fi
62-
# cosign's reference parser (via go-containerregistry) doesn't accept
63-
# bare 'localhost' as a registry hostname — it falls back to treating
64-
# the whole ref as a Docker Hub path (index.docker.io/localhost/...).
65-
# Adding ':80' forces the host:port split, so the parser recognizes
66-
# localhost:80 as the registry. Other hostnames (ttl.sh, harbor.foo.bar)
67-
# contain a '.' and parse correctly without modification.
68-
case "\$DIGEST" in
69-
localhost/*) DIGEST="localhost:80/\${DIGEST#localhost/}" ;;
70-
esac
71-
docker run --rm --network host \\
72-
-v "\$COSIGN_KEY_FILE:/cosign.key:ro" \\
73-
-v "\$DOCKER_CONFIG:/jenkins-docker:ro" \\
74-
-e "COSIGN_PASSWORD=\$COSIGN_PASSWORD" \\
75-
-e DOCKER_CONFIG=/jenkins-docker \\
76-
--entrypoint=/usr/bin/cosign \\
77-
cgr.dev/${org}/cosign:latest-dev \\
78-
sign --yes --allow-http-registry --key /cosign.key "\$DIGEST"
79-
echo "cgSign: signed \$DIGEST"
80-
"""
42+
if (!env.CHAINGUARD_ORG) error('cgSign: env.CHAINGUARD_ORG is empty — JCasC globalNodeProperties should set it from the controller env (jenkins/casc/jenkins.yaml). Re-run setup.sh.')
43+
// Pass `image` through the sh step's environment rather than interpolating
44+
// it into the script body — otherwise an image ref containing a single
45+
// quote (or other shell metacharacter) could break out of the surrounding
46+
// quoting. CHAINGUARD_ORG is already exposed to the shell by Jenkins.
47+
withEnv(["IMAGE=${image}"]) {
48+
withCredentials([
49+
file(credentialsId: 'cosign-private-key', variable: 'COSIGN_KEY_FILE'),
50+
string(credentialsId: 'cosign-password', variable: 'COSIGN_PASSWORD'),
51+
]) {
52+
sh '''
53+
set -eu
54+
# Pick the RepoDigest whose repo matches the image we just pushed.
55+
# The local image cache may have stale RepoDigests from prior runs
56+
# under different registries (e.g. localhost/library from a Mode C
57+
# session, ttl.sh from a Mode A session) — `{{index .RepoDigests 0}}`
58+
# returned whichever happened to be first and tripped cosign over.
59+
REPO="${IMAGE%:*}"
60+
DIGEST=$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$IMAGE" | grep -F "${REPO}@" | head -1)
61+
if [ -z "$DIGEST" ]; then
62+
echo "cgSign: could not resolve digest for $IMAGE under repo $REPO (was it pushed?)." >&2
63+
exit 1
64+
fi
65+
# cosign's reference parser (via go-containerregistry) doesn't accept
66+
# bare 'localhost' as a registry hostname — it falls back to treating
67+
# the whole ref as a Docker Hub path (index.docker.io/localhost/...).
68+
# Adding ':80' forces the host:port split, so the parser recognizes
69+
# localhost:80 as the registry. Other hostnames (ttl.sh, harbor.foo.bar)
70+
# contain a '.' and parse correctly without modification.
71+
case "$DIGEST" in
72+
localhost/*) DIGEST="localhost:80/${DIGEST#localhost/}" ;;
73+
esac
74+
docker run --rm --network host \
75+
-v "$COSIGN_KEY_FILE:/cosign.key:ro" \
76+
-v "$DOCKER_CONFIG:/jenkins-docker:ro" \
77+
-e "COSIGN_PASSWORD=$COSIGN_PASSWORD" \
78+
-e DOCKER_CONFIG=/jenkins-docker \
79+
--entrypoint=/usr/bin/cosign \
80+
"cgr.dev/${CHAINGUARD_ORG}/cosign:latest-dev" \
81+
sign --yes --allow-http-registry --key /cosign.key "$DIGEST"
82+
echo "cgSign: signed $DIGEST"
83+
'''
84+
}
8185
}
8286
}

jenkins/shared-libraries/cg-images/vars/cgVerify.groovy

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -25,35 +25,37 @@ def call(String image) {
2525
if (!image?.trim()) {
2626
error('cgVerify: image argument is required')
2727
}
28-
def org = env.CHAINGUARD_ORG
29-
if (!org) error('cgVerify: env.CHAINGUARD_ORG is empty — JCasC globalNodeProperties should set it from the controller env (jenkins/casc/jenkins.yaml). Re-run setup.sh.')
30-
withCredentials([
31-
file(credentialsId: 'cosign-public-key', variable: 'COSIGN_PUB_FILE'),
32-
]) {
33-
sh """
34-
set -eu
35-
# Pick the RepoDigest whose repo matches the image we want to verify.
36-
# See cgSign.groovy for why .RepoDigests can have stale entries from
37-
# prior runs.
38-
IMAGE='${image}'
39-
REPO=\${IMAGE%:*}
40-
DIGEST=\$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "\$IMAGE" | grep -F "\${REPO}@" | head -1)
41-
if [ -z "\$DIGEST" ]; then
42-
echo "cgVerify: could not resolve digest for \$IMAGE under repo \$REPO (was it pushed?)." >&2
43-
exit 1
44-
fi
45-
# See cgSign.groovy for why we rewrite bare 'localhost' to 'localhost:80'.
46-
case "\$DIGEST" in
47-
localhost/*) DIGEST="localhost:80/\${DIGEST#localhost/}" ;;
48-
esac
49-
docker run --rm --network host \\
50-
-v "\$COSIGN_PUB_FILE:/cosign.pub:ro" \\
51-
-v "\$DOCKER_CONFIG:/jenkins-docker:ro" \\
52-
-e DOCKER_CONFIG=/jenkins-docker \\
53-
--entrypoint=/usr/bin/cosign \\
54-
cgr.dev/${org}/cosign:latest-dev \\
55-
verify --allow-http-registry --key /cosign.pub "\$DIGEST" >/dev/null
56-
echo "cgVerify: signature OK for \$DIGEST"
57-
"""
28+
if (!env.CHAINGUARD_ORG) error('cgVerify: env.CHAINGUARD_ORG is empty — JCasC globalNodeProperties should set it from the controller env (jenkins/casc/jenkins.yaml). Re-run setup.sh.')
29+
// Pass `image` through the sh step's environment rather than interpolating
30+
// it into the script body — see cgSign.groovy for the same reasoning.
31+
withEnv(["IMAGE=${image}"]) {
32+
withCredentials([
33+
file(credentialsId: 'cosign-public-key', variable: 'COSIGN_PUB_FILE'),
34+
]) {
35+
sh '''
36+
set -eu
37+
# Pick the RepoDigest whose repo matches the image we want to verify.
38+
# See cgSign.groovy for why .RepoDigests can have stale entries from
39+
# prior runs.
40+
REPO="${IMAGE%:*}"
41+
DIGEST=$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$IMAGE" | grep -F "${REPO}@" | head -1)
42+
if [ -z "$DIGEST" ]; then
43+
echo "cgVerify: could not resolve digest for $IMAGE under repo $REPO (was it pushed?)." >&2
44+
exit 1
45+
fi
46+
# See cgSign.groovy for why we rewrite bare 'localhost' to 'localhost:80'.
47+
case "$DIGEST" in
48+
localhost/*) DIGEST="localhost:80/${DIGEST#localhost/}" ;;
49+
esac
50+
docker run --rm --network host \
51+
-v "$COSIGN_PUB_FILE:/cosign.pub:ro" \
52+
-v "$DOCKER_CONFIG:/jenkins-docker:ro" \
53+
-e DOCKER_CONFIG=/jenkins-docker \
54+
--entrypoint=/usr/bin/cosign \
55+
"cgr.dev/${CHAINGUARD_ORG}/cosign:latest-dev" \
56+
verify --allow-http-registry --key /cosign.pub "$DIGEST" >/dev/null
57+
echo "cgVerify: signature OK for $DIGEST"
58+
'''
59+
}
5860
}
5961
}

0 commit comments

Comments
 (0)