From 58bfad7a2903bc55ab998cad1c631de15405603a Mon Sep 17 00:00:00 2001 From: "OP (oppenheimer)" <21008429+Ompragash@users.noreply.github.com> Date: Fri, 1 Aug 2025 00:42:10 +0530 Subject: [PATCH] feat: [CI-18308]: Add Cosign Image Signing Support (#494) * Add signing support via cosign * Updated docker.go * Add signing support via cosign * Updated docker.go * Updated docker.go * Updated docker.go * Updated docker.go * Updated docker.go * Updated dockerfiles --- COSIGN_USAGE.md | 162 ++++++++++++++++ cmd/drone-docker/main.go | 21 +++ daemon.go | 1 + daemon_win.go | 2 + docker.go | 173 ++++++++++++++++-- docker/docker/Dockerfile.linux.amd64 | 4 + docker/docker/Dockerfile.linux.arm64 | 4 + docker/docker/Dockerfile.windows.amd64.1809 | 4 + .../docker/Dockerfile.windows.amd64.ltsc2022 | 4 + 9 files changed, 364 insertions(+), 11 deletions(-) create mode 100644 COSIGN_USAGE.md diff --git a/COSIGN_USAGE.md b/COSIGN_USAGE.md new file mode 100644 index 0000000..f4bc7f0 --- /dev/null +++ b/COSIGN_USAGE.md @@ -0,0 +1,162 @@ +# Cosign Integration for Drone-Docker + +This document describes how to use the cosign container image signing feature in drone-docker. + +## Overview + +The drone-docker plugin now supports automatic container image signing using cosign after each successful push. This provides cryptographic verification that images haven't been tampered with. + +## Environment Variables + +The plugin accepts three cosign-related environment variables: + +### `PLUGIN_COSIGN_PRIVATE_KEY` (Required for signing) +- **Description**: Private key for signing (PEM format content or file path) +- **Format**: Either PEM content or file path to private key +- **Usage**: Should be provided via secrets + +### `PLUGIN_COSIGN_PASSWORD` (Optional) +- **Description**: Password for encrypted private keys +- **Usage**: Only needed if your private key is password-protected + +### `PLUGIN_COSIGN_PARAMS` (Optional) +- **Description**: Additional cosign parameters +- **Examples**: + - `-a build_id=123` (add annotations) + - `--tlog-upload=false` (disable transparency log) + - `--rekor-url=https://custom-rekor.example.com` (custom rekor instance) + +## Usage Examples + +### 1. Basic Signing (Drone) + +```yaml +kind: pipeline +type: docker +name: default + +steps: +- name: docker + image: plugins/docker + settings: + repo: myregistry/myapp + tags: latest + cosign_private_key: + from_secret: cosign_private_key + cosign_password: + from_secret: cosign_password +``` + +### 2. Advanced Signing with Annotations (Drone) + +```yaml +steps: +- name: docker + image: plugins/docker + settings: + repo: myregistry/myapp + tags: + - latest + - ${DRONE_BUILD_NUMBER} + cosign_private_key: + from_secret: cosign_private_key + cosign_params: "-a build_id=${DRONE_BUILD_NUMBER} -a commit_sha=${DRONE_COMMIT_SHA} -a branch=${DRONE_BRANCH}" +``` + +### 3. Harness CI/CD Usage + +```yaml +- step: + type: Plugin + name: Build and Sign + identifier: build_and_sign + spec: + connectorRef: account.harnessImage + image: plugins/docker + settings: + repo: myregistry/myapp + tags: <+pipeline.sequenceId> + cosign_private_key: <+secrets.getValue("cosign_private_key")> + cosign_password: <+secrets.getValue("cosign_password")> + cosign_params: "-a harness_build=<+pipeline.sequenceId> -a harness_project=<+project.name>" +``` + +## Key Management + +### Generating Cosign Keys + +```bash +# Generate a new key pair +cosign generate-key-pair + +# This creates: +# - cosign.key (private key) +# - cosign.pub (public key) +``` + +### Storing Keys Securely +**Harness Secrets:** +1. Go to Project Settings → Secrets +2. Create new secret with type "File" for private key +3. Create new secret with type "Text" for password + +## Security Features + +### Automatic Validation +- ✅ **Private key format validation**: Ensures PEM format is correct +- ✅ **Password requirement detection**: Warns if encrypted key needs password +- ✅ **Keyless signing prevention**: Warns that OIDC keyless signing isn't supported + +### Error Handling +- **Invalid private key**: `❌ Invalid private key format. Expected PEM format` +- **Missing password**: `🔐 Encrypted private key requires password. Set PLUGIN_COSIGN_PASSWORD` +- **Keyless signing**: `⚠️ WARNING: Keyless signing (OIDC) isn't supported yet in this plugin` + +## Signing Behavior + +### When Signing Occurs +- ✅ **After each successful push**: Images are signed immediately after push +- ✅ **Multiple tags**: Each tag gets signed individually +- ✅ **Push-only mode**: Works with existing images +- ✅ **Dry-run respect**: Skips signing in dry-run mode + +### Image References +- **Preferred**: Signs by digest (e.g., `image@sha256:abc123...`) for security +- **Fallback**: Signs by tag if digest unavailable + +### Authentication +- **Registry auth**: Automatically uses existing Docker registry credentials + +## Verification + +To verify a signed image: + +```bash +# Verify with public key +cosign verify --key cosign.pub myregistry/myapp:latest + +# Verify with annotations +cosign verify --key cosign.pub \ + -a build_id=123 \ + myregistry/myapp:latest +``` + +## Troubleshooting + +### Common Issues + +1. **"cosign: command not found"** + - The container image includes cosign binary + - Use the latest plugin image: `plugins/docker:latest` + +2. **"keyless signing not supported"** + - This plugin only supports private key signing + - Don't use `--oidc` or `--identity-token` in `cosign_params` + +3. **"encrypted private key requires password"** + - Set `PLUGIN_COSIGN_PASSWORD` environment variable + - Or use an unencrypted private key + +4. **Registry authentication issues** + - Cosign uses the same Docker registry credentials + - Ensure Docker login is working first \ No newline at end of file diff --git a/cmd/drone-docker/main.go b/cmd/drone-docker/main.go index 0d319bb..dd6a3ee 100644 --- a/cmd/drone-docker/main.go +++ b/cmd/drone-docker/main.go @@ -323,6 +323,22 @@ func main() { Usage: "access token", EnvVar: "ACCESS_TOKEN", }, + // Cosign signing configuration + cli.StringFlag{ + Name: "cosign.private-key", + Usage: "cosign private key content or file path for signing", + EnvVar: "PLUGIN_COSIGN_PRIVATE_KEY", + }, + cli.StringFlag{ + Name: "cosign.password", + Usage: "password for encrypted cosign private key", + EnvVar: "PLUGIN_COSIGN_PASSWORD", + }, + cli.StringFlag{ + Name: "cosign.params", + Usage: "additional cosign parameters (e.g., annotations, flags)", + EnvVar: "PLUGIN_COSIGN_PARAMS", + }, } if err := app.Run(os.Args); err != nil { @@ -398,6 +414,11 @@ func run(c *cli.Context) error { BaseImageRegistry: c.String("docker.baseimageregistry"), BaseImageUsername: c.String("docker.baseimageusername"), BaseImagePassword: c.String("docker.baseimagepassword"), + Cosign: docker.CosignConfig{ + PrivateKey: c.String("cosign.private-key"), + Password: c.String("cosign.password"), + Params: c.String("cosign.params"), + }, } if c.Bool("tags.auto") { diff --git a/daemon.go b/daemon.go index df235e8..7a64688 100644 --- a/daemon.go +++ b/daemon.go @@ -11,6 +11,7 @@ import ( const dockerExe = "/usr/local/bin/docker" const dockerdExe = "/usr/local/bin/dockerd" const dockerHome = "/root/.docker/" +const cosignExe = "/usr/local/bin/cosign" func (p Plugin) startDaemon() { cmd := commandDaemon(p.Daemon) diff --git a/daemon_win.go b/daemon_win.go index 77e0543..3acc747 100644 --- a/daemon_win.go +++ b/daemon_win.go @@ -1,3 +1,4 @@ +//go:build windows // +build windows package docker @@ -5,6 +6,7 @@ package docker const dockerExe = "C:\\bin\\docker.exe" const dockerdExe = "" const dockerHome = "C:\\ProgramData\\docker\\" +const cosignExe = "C:\\bin\\cosign.exe" func (p Plugin) startDaemon() { // this is a no-op on windows diff --git a/docker.go b/docker.go index 32e1af6..88ee0b2 100644 --- a/docker.go +++ b/docker.go @@ -76,18 +76,26 @@ type ( SSHKeyPath string // Docker build ssh key path } + // CosignConfig defines Cosign signing parameters. + CosignConfig struct { + PrivateKey string // Private key content (PEM format) or file path + Password string // Password for encrypted private keys + Params string // Additional cosign parameters + } + // Plugin defines the Docker plugin parameters. Plugin struct { - Login Login // Docker login configuration - Build Build // Docker build configuration - Daemon Daemon // Docker daemon configuration - Dryrun bool // Docker push is skipped - Cleanup bool // Docker purge is enabled - CardPath string // Card path to write file to - ArtifactFile string // Artifact path to write file to - BaseImageRegistry string // Docker registry to pull base image - BaseImageUsername string // Docker registry username to pull base image - BaseImagePassword string // Docker registry password to pull base image + Login Login // Docker login configuration + Build Build // Docker build configuration + Daemon Daemon // Docker daemon configuration + Cosign CosignConfig // Cosign signing configuration + Dryrun bool // Docker push is skipped + Cleanup bool // Docker purge is enabled + CardPath string // Card path to write file to + ArtifactFile string // Artifact path to write file to + BaseImageRegistry string // Docker registry to pull base image + BaseImageUsername string // Docker registry username to pull base image + BaseImagePassword string // Docker registry password to pull base image } Card []struct { @@ -249,6 +257,14 @@ func (p Plugin) Exec() error { cmds = append(cmds, commandBuild(p.Build)) // docker build + // Validate cosign configuration if present + if p.shouldSignWithCosign() { + if err := validateCosignConfig(p.Cosign); err != nil { + return fmt.Errorf("cosign validation failed: %w", err) + } + fmt.Println("🔐 Cosign signing enabled - images will be signed after push") + } + for _, tag := range p.Build.Tags { cmds = append(cmds, commandTag(p.Build, tag)) // docker tag @@ -290,6 +306,31 @@ func (p Plugin) Exec() error { } } + // Handle cosign signing after all commands complete (like artifact generation) + if p.shouldSignWithCosign() && !p.Dryrun { + // Set up environment variables for cosign + os.Setenv("COSIGN_YES", "true") + + if digest, err := getDigest(p.Build.TempTag); err == nil { + fmt.Printf("🔐 Found image digest: %s\n", digest) + + // Sign with digest reference + imageRef := fmt.Sprintf("%s@%s", p.Build.Repo, digest) + cosignCmd := createCosignCommand(imageRef, p.Cosign) + executeCosignCommand(cosignCmd) + } else { + fmt.Printf("⚠️ WARNING: Could not get image digest for cosign signing: %s\n", err) + fmt.Printf(" Falling back to tag-based signing\n") + + // Fall back to tag-based signing for each tag + for _, tag := range p.Build.Tags { + imageRef := fmt.Sprintf("%s:%s", p.Build.Repo, tag) + cosignCmd := createCosignCommand(imageRef, p.Cosign) + executeCosignCommand(cosignCmd) + } + } + } + // execute cleanup routines in batch mode if p.Cleanup { // clear the slice @@ -645,6 +686,11 @@ func isCommandRmi(args []string) bool { return len(args) > 2 && args[1] == "rmi" } +// helper to check if args match "cosign sign" +func isCommandCosign(args []string) bool { + return len(args) > 1 && args[0] == cosignExe +} + func commandRmi(tag string) *exec.Cmd { return exec.Command(dockerExe, "rmi", tag) } @@ -681,7 +727,7 @@ func GetDroneDockerExecCmd() string { } func getDigest(buildName string) (string, error) { - cmd := exec.Command("docker", "inspect", "--format='{{index .RepoDigests 0}}'", buildName) + cmd := exec.Command(dockerExe, "inspect", "--format='{{index .RepoDigests 0}}'", buildName) output, err := cmd.Output() if err != nil { return "", err @@ -695,3 +741,108 @@ func getDigest(buildName string) (string, error) { } return "", errors.New("unable to fetch digest") } + +// shouldSignWithCosign determines if cosign signing should be performed +func (p Plugin) shouldSignWithCosign() bool { + return p.Cosign.PrivateKey != "" +} + +// validateCosignConfig validates the cosign configuration +func validateCosignConfig(config CosignConfig) error { + if config.PrivateKey == "" { + return nil // No cosign config, skip silently + } + + // Check if cosign binary is available + if _, err := exec.LookPath(cosignExe); err != nil { + fmt.Printf("❌ ERROR: cosign binary not found at %s\n", cosignExe) + fmt.Println(" Ensure you're using a plugin image that includes cosign") + return fmt.Errorf("cosign binary not available: %w", err) + } + + // Check if it's trying to use keyless signing + if strings.Contains(config.Params, "--oidc") || + strings.Contains(config.Params, "--identity-token") { + fmt.Println("⚠️ WARNING: Keyless signing (OIDC) isn't supported yet in this plugin. Use private key signing instead.") + return errors.New("keyless signing not supported") + } + + // Validate private key format if it's PEM content + if strings.HasPrefix(config.PrivateKey, "-----BEGIN") { + if !isValidPEMKey(config.PrivateKey) { + return errors.New("❌ Invalid private key format. Expected PEM format") + } + + // Check encrypted key password requirement + if isEncryptedPEMKey(config.PrivateKey) && config.Password == "" { + return errors.New("🔐 Encrypted private key requires password. Set PLUGIN_COSIGN_PASSWORD") + } + + } else { + // File-based key - check if it's accessible (basic check) + if _, err := os.Stat(config.PrivateKey); err != nil { + fmt.Printf("⚠️ WARNING: Private key file may not be accessible: %s\n", config.PrivateKey) + fmt.Println(" This will be verified during signing") + } + } + + return nil +} + +// isEncryptedPEMKey checks if a PEM key is encrypted +func isEncryptedPEMKey(pemContent string) bool { + return strings.Contains(pemContent, "ENCRYPTED") +} + +// isValidPEMKey performs basic PEM format validation +func isValidPEMKey(pemContent string) bool { + return strings.Contains(pemContent, "-----BEGIN") && + strings.Contains(pemContent, "-----END") && + (strings.Contains(pemContent, "PRIVATE KEY") || + strings.Contains(pemContent, "RSA PRIVATE KEY") || + strings.Contains(pemContent, "EC PRIVATE KEY")) +} + +// createCosignCommand creates a cosign sign command with the given image reference +func createCosignCommand(imageRef string, cosign CosignConfig) *exec.Cmd { + args := []string{"sign", "--yes"} + + // Handle private key (content vs file path) + if strings.HasPrefix(cosign.PrivateKey, "-----BEGIN") { + args = append(args, "--key", "env://COSIGN_PRIVATE_KEY") + os.Setenv("COSIGN_PRIVATE_KEY", cosign.PrivateKey) + } else { + args = append(args, "--key", cosign.PrivateKey) + } + + // Set password if provided + if cosign.Password != "" { + os.Setenv("COSIGN_PASSWORD", cosign.Password) + } + + // Add any extra parameters + if cosign.Params != "" { + extraArgs := strings.Fields(cosign.Params) + args = append(args, extraArgs...) + } + + // Add the image reference to sign + args = append(args, imageRef) + + return exec.Command(cosignExe, args...) +} + +// executeCosignCommand executes the given cosign command and handles errors +func executeCosignCommand(cmd *exec.Cmd) { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + fmt.Printf("🚀 Executing: %s %s\n", cmd.Path, strings.Join(cmd.Args[1:], " ")) + + if err := cmd.Run(); err != nil { + fmt.Printf("⚠️ WARNING: Image signing failed: %s\n", err) + fmt.Printf(" Image was pushed successfully but could not be signed\n") + fmt.Printf(" This is not fatal - continuing with the build\n") + } +} + + diff --git a/docker/docker/Dockerfile.linux.amd64 b/docker/docker/Dockerfile.linux.amd64 index 1462fd3..8c61c25 100644 --- a/docker/docker/Dockerfile.linux.amd64 +++ b/docker/docker/Dockerfile.linux.amd64 @@ -2,5 +2,9 @@ FROM docker:28.1.1-dind ENV DOCKER_HOST=unix:///var/run/docker.sock +# Install cosign for container image signing +RUN wget -O /usr/local/bin/cosign https://github.com/sigstore/cosign/releases/download/v2.5.3/cosign-linux-amd64 \ + && chmod +x /usr/local/bin/cosign + ADD release/linux/amd64/drone-docker /bin/ ENTRYPOINT ["/usr/local/bin/dockerd-entrypoint.sh", "/bin/drone-docker"] diff --git a/docker/docker/Dockerfile.linux.arm64 b/docker/docker/Dockerfile.linux.arm64 index cbb0c0f..86fd627 100644 --- a/docker/docker/Dockerfile.linux.arm64 +++ b/docker/docker/Dockerfile.linux.arm64 @@ -2,5 +2,9 @@ FROM arm64v8/docker:28.1.1-dind ENV DOCKER_HOST=unix:///var/run/docker.sock +# Install cosign for container image signing +RUN wget -O /usr/local/bin/cosign https://github.com/sigstore/cosign/releases/download/v2.5.3/cosign-linux-arm64 \ + && chmod +x /usr/local/bin/cosign + ADD release/linux/arm64/drone-docker /bin/ ENTRYPOINT ["/usr/local/bin/dockerd-entrypoint.sh", "/bin/drone-docker"] diff --git a/docker/docker/Dockerfile.windows.amd64.1809 b/docker/docker/Dockerfile.windows.amd64.1809 index f699425..5113e16 100644 --- a/docker/docker/Dockerfile.windows.amd64.1809 +++ b/docker/docker/Dockerfile.windows.amd64.1809 @@ -24,6 +24,10 @@ LABEL maintainer="Drone.IO Community " ` org.label-schema.schema-version="1.0" RUN mkdir C:\bin + +# Install cosign for container image signing +ADD https://github.com/sigstore/cosign/releases/download/v2.5.3/cosign-windows-amd64.exe C:/bin/cosign.exe + COPY --from=download /windows/system32/netapi32.dll /windows/system32/netapi32.dll COPY --from=download /app/docker.exe C:/bin/docker.exe ADD release/windows/amd64/drone-docker.exe C:/bin/drone-docker.exe diff --git a/docker/docker/Dockerfile.windows.amd64.ltsc2022 b/docker/docker/Dockerfile.windows.amd64.ltsc2022 index 0274d0a..d85fc60 100644 --- a/docker/docker/Dockerfile.windows.amd64.ltsc2022 +++ b/docker/docker/Dockerfile.windows.amd64.ltsc2022 @@ -22,6 +22,10 @@ LABEL maintainer="Drone.IO Community " ` org.label-schema.schema-version="1.0" RUN mkdir C:\bin + +# Install cosign for container image signing +ADD https://github.com/sigstore/cosign/releases/download/v2.5.3/cosign-windows-amd64.exe C:/bin/cosign.exe + COPY --from=download /windows/system32/netapi32.dll /windows/system32/netapi32.dll COPY --from=download /app/docker.exe C:/bin/docker.exe ADD release/windows/amd64/drone-docker.exe C:/bin/drone-docker.exe