Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Built docker image/manifest digest is not exposed to image signing step, leading to potential race condition vulnerability #3496

Closed
2 tasks done
ethan-lowman-dd opened this issue Oct 26, 2022 · 17 comments · Fixed by #3540
Assignees
Labels
enhancement New feature or request
Milestone

Comments

@ethan-lowman-dd
Copy link

ethan-lowman-dd commented Oct 26, 2022

Is your feature request related to a problem? Please describe.

Currently, the Docker image digest built by dockers: ... is not exposed to docker_signs: ... in the environment variables. If the image tag in the registry is modified after pushing and before signing, signer tools that re-pull an image to sign it (including cosign) will potentially sign an image different from the one that was produced by goreleaser.

Describe the solution you'd like

Docker exposes the --metadata-file flag to docker build yields a file that looks like this in the goreleaserdocker temp dir:

{
  "containerimage.digest": "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
}

If we expose this built digest to the docker_signs tools via an environment variable artifactDigest, users would have the ability to pass the digest to the signing tool and avoid any chance of a race condition. In my opinion, this should be the default behavior.

docker_signs:
  - artifacts: all
    cmd: cosign
    args: ["sign", "${artifact}@${artifactDigest}"]

Describe alternatives you've considered

Assuming you're using a tool like cosign that takes an image as an argument, you can fetch the digest from the local Docker daemon and pass that to the signer tool like so:

docker_signs:
  - artifacts: all
    cmd: sh
    args:
      - -c
      - cosign sign ${artifact}@$(docker inspect ${artifact} -f '{{"{{ (index .RepoDigests 0) }}"}}' | cut -d"@" -f2)

I'm not 100% sure this is actually correct though -- docker inspect returns a list of digests for a given tag, which suggests there may be conditions in which multiple digests are returned for a single tag in the Docker daemon's image store. I couldn't find any such case in a quick code search of Docker, however.

This is quite a hack and it should be easier to avoid the race condition.

Search

  • I did search for other open and closed issues before opening this.

Code of Conduct

  • I agree to follow this project's Code of Conduct

Additional context

@ethan-lowman-dd ethan-lowman-dd added enhancement New feature or request triage Issue pending triage by one of the maintainers labels Oct 26, 2022
@caarlos0
Copy link
Member

interesting, I had the impression cosign wouldn't pull the image if its already present, though... do you have a doc/code link confirming that?

if thats what happens, I'm all in for looking into this.

@ethan-lowman-dd
Copy link
Author

This issue describes the race condition: sigstore/cosign#2047

@ethan-lowman-dd
Copy link
Author

One downside of the --metadata-file approach is that it's not supported to the docker manifest builder command. One alternate solution that would work for both images and digests, without the issue of multiple --metadata-file flags, is to do the Go equivalent of docker inspect ${artifact} using Client.ImageInspectWithRaw right before shelling out to the signing command. If the local Docker daemon were untrustworthy, then the image that's pushed and signed would be untrustworthy, so I think using the local Docker daemon is a safe way to resolve the digest.

@ethan-lowman-dd
Copy link
Author

Unfortunately Client.ImageInspectWithRaw doesn't work for manifest lists and there's no exported Docker API for getting digests for manifest digests.

@caarlos0
Copy link
Member

caarlos0 commented Nov 2, 2022

I think we can literally run docker inspect after building or before signing the image/manifest and parse the output, can't we?

@ethan-lowman-dd
Copy link
Author

ethan-lowman-dd commented Nov 2, 2022

From my tests, docker inspect doesn't return results for manifests built with docker manifest create, since a pushed manifest isn't in the image store, it's just a local directory on disk in ~/.docker/manifests (see here). You could do docker manifest inspect which returns the raw manifest, but you have to strip the trailing newline and calculate the digest yourself. docker manifest inspect does favor the local manifest list, but falls back on a remote manifest list if the local one is missing. This usually shouldn't happen unless you pass the --purge flag to docker manifest push via the goreleaser push_flags config. That might be an okay compromise, if warnings are logged. Alternatively, it might be better to just contribute something to the Docker CLI that exposes the local manifest digest in a reliable way. I'm not sure how likely that is to be merged, but maybe worth a try.

@caarlos0
Copy link
Member

caarlos0 commented Nov 2, 2022

hmm, understood, so right now, nothing we can do here, right?

maybe worth adding a note to our docs about it?

@gal-legit
Copy link
Contributor

Actually, the digest should be fetched from the output of docker push.
I demonstrate it in this blog post, which I wrote as a follow-up to my report in SigStore's Slack channel that led to sigstore/cosign#2047.
I checked goreleaser's code briefly, so I hope I got things right:
It seems that the fix takes changing the following function to also fetch stdout and parse it:

func (i dockerImager) Push(ctx *context.Context, image string, flags []string) error {
	if err := runCommand(ctx, ".", "docker", "push", image); err != nil {
		return fmt.Errorf("failed to push %s: %w", image, err)
	}
	return nil
}

This would also force fixing the imager interface accordingly.

@caarlos0
Copy link
Member

caarlos0 commented Nov 9, 2022

hmmm that does not seem too bad @gal-legit... 100% doable, I think!

@ethan-lowman-dd
Copy link
Author

Good catch, I missed that docker push has the digest in the output. I wonder whether that's an API contract though, or if multiple digests could ever show up in the output?

docker manifest push has the digest as the only output, so that's probably more likely to be an API contract of the command.

docker buildx build ... --push has the digest in the output, thought the output looks like it's only intended to be read by a human. However, I don't see that goreleaser uses this build & push command.

@gal-legit
Copy link
Contributor

gal-legit commented Nov 9, 2022

@ethan-lowman-dd

I wonder whether that's an API contract though

I failed to find documentation about it for the docker client, yet IMO it's pretty safe to assume that it won't change because:

  1. The docker client outputs in this format for a long time, so changing it would break compatibility for lots of projects.
  2. Without getting the digest from the registry, the client doesn't really have any way to use the remote image without fear of race conditions.
  3. Although the client doesn't state that, the Docker Registry HTTP API clearly states that the digest is returned from the server upon completion. So the only way to get it is from the client running the push (the client might change the output format; I addressed that in the first point). see: https://docs.docker.com/registry/spec/api/#completed-upload

, or if multiple digests could ever show up in the output?

AFAIK, the only case that it might happen is by using the --all-tags flag, but goreleaser doesn't do that.

p.s. you can use crane push -v to get a better sense of the behavior.

@ethan-lowman-dd
Copy link
Author

I double checked -- the docker push output has been stable for 6 or 7 years: https://github.com/docker/docker-ce/blame/44a430f4c43e61c95d4e9e9fd6a0573fa113a119/components/engine/distribution/push_v2.go#L229

Seems okay to parse that output since it's so much simpler than the alternative.

For manifest pushes, the docker manifest commands are marked as experimental, but it seems like a design goal that the output of docker manifest push is just the pushed digest, so parsing that seems reasonable as well.

@caarlos0 caarlos0 removed the triage Issue pending triage by one of the maintainers label Nov 12, 2022
caarlos0 added a commit that referenced this issue Nov 12, 2022
Extract the digest (sha256) of docker images from the `docker push`
command for dockers published to the docker registry.
Outputting the digest is required to avoid a race condition when
referencing the image, where the image tag is being modified before the
reference is done.
See this [blog
post](#3496) for more
info.
This PR fixes #3496.

Note that the 'publish' pipe now must run before the 'metadata' pipe, so
that the information extracted during the 'publish' pipe would appear in
the metadata.
Previously, the published docker images metadata wasn't printed (because
of the order). It made sense because the content of the published image
was just a subset of the local one.
Now that it is printed to the metadata, it should have a different name
to avoid confusion.
As I mentioned, it wasn't printed before - so there shouldn't be any
backward-compatibility issues.

---
Local tests:
```
go test -v .
=== RUN   TestVersion
=== RUN   TestVersion/only_version
=== RUN   TestVersion/version_and_date
=== RUN   TestVersion/version,_date,_built_by
=== RUN   TestVersion/all_empty
=== RUN   TestVersion/complete
--- PASS: TestVersion (0.00s)
    --- PASS: TestVersion/only_version (0.00s)
    --- PASS: TestVersion/version_and_date (0.00s)
    --- PASS: TestVersion/version,_date,_built_by (0.00s)
    --- PASS: TestVersion/all_empty (0.00s)
    --- PASS: TestVersion/complete (0.00s)
PASS
ok      github.com/goreleaser/goreleaser        0.764s
```

Output example:
```
  {
    "name": "gallegit/hello-world:latest",
    "path": "gallegit/hello-world:latest",
    "goos": "linux",
    "goarch": "amd64",
    "internal_type": 10,
    "type": "Published Docker Image",
    "extra": {
      "digest": "sha256:c3f7dd196a046dc061236d3c6ae1e2946269e90da30b0a959240ca799750e632"
    }
  }
```

Signed-off-by: Carlos A Becker <caarlos0@users.noreply.github.com>
Co-authored-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
@ethan-lowman-dd
Copy link
Author

ethan-lowman-dd commented Nov 14, 2022

I don't think #3540 fully closes this issue. We still need support for extracting the pushed manifest digest and to wire the digest into the sign pipeline around here:

env["artifactID"] = art.ID()

@caarlos0
Copy link
Member

ah, right

@caarlos0
Copy link
Member

@ethan-lowman-dd #3556 and #3555 should take care of the remaining parts

@caarlos0 caarlos0 added this to the v1.13.0 milestone Nov 15, 2022
caarlos0 added a commit that referenced this issue Nov 15, 2022
this drives it home by using the actual images/manifest digests to sign
with cosign by default.

the default signing command is changing in this PR, but since `digest`
should be always there (if not, the pipeline will fail way earlier), it
should be fine.

refs #3496
refs #3540

Signed-off-by: Carlos A Becker <caarlos0@users.noreply.github.com>
caarlos0 added a commit that referenced this issue Nov 15, 2022
this use the digests on the manifest creation.
Another PR will add it to signing too.

refs #3496
refs #3540

Signed-off-by: Carlos A Becker <caarlos0@users.noreply.github.com>
caarlos0 added a commit that referenced this issue Nov 15, 2022
refs #3496

Signed-off-by: Carlos A Becker <caarlos0@users.noreply.github.com>
@caarlos0
Copy link
Member

should be good now

@caarlos0
Copy link
Member

thanks everyone!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants