mirror of
https://github.com/drone-plugins/drone-docker.git
synced 2026-06-04 18:24:24 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 123a133f01 | |||
| 58bfad7a29 | |||
| 0493478ac1 |
+162
@@ -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
|
||||
+32
-9
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
docker "github.com/drone-plugins/drone-docker"
|
||||
azureutil "github.com/drone-plugins/drone-docker/internal/azure"
|
||||
)
|
||||
|
||||
type subscriptionUrlResponse struct {
|
||||
@@ -62,12 +63,14 @@ func main() {
|
||||
password = getenv("SERVICE_PRINCIPAL_CLIENT_SECRET")
|
||||
|
||||
// Service principal credentials
|
||||
clientId = getenv("CLIENT_ID")
|
||||
clientSecret = getenv("CLIENT_SECRET")
|
||||
clientCert = getenv("CLIENT_CERTIFICATE")
|
||||
tenantId = getenv("TENANT_ID")
|
||||
subscriptionId = getenv("SUBSCRIPTION_ID")
|
||||
publicUrl = getenv("DAEMON_REGISTRY")
|
||||
clientId = getenv("CLIENT_ID", "AZURE_CLIENT_ID", "AZURE_APP_ID", "PLUGIN_CLIENT_ID")
|
||||
clientSecret = getenv("CLIENT_SECRET", "PLUGIN_CLIENT_SECRET")
|
||||
clientCert = getenv("CLIENT_CERTIFICATE", "PLUGIN_CLIENT_CERTIFICATE")
|
||||
tenantId = getenv("TENANT_ID", "AZURE_TENANT_ID", "PLUGIN_TENANT_ID")
|
||||
subscriptionId = getenv("SUBSCRIPTION_ID", "PLUGIN_SUBSCRIPTION_ID")
|
||||
publicUrl = getenv("DAEMON_REGISTRY", "PLUGIN_DAEMON_REGISTRY")
|
||||
authorityHost = getenv("AZURE_AUTHORITY_HOST", "PLUGIN_AZURE_AUTHORITY_HOST")
|
||||
idToken = getenv("PLUGIN_OIDC_TOKEN_ID")
|
||||
)
|
||||
|
||||
// default registry value
|
||||
@@ -80,9 +83,29 @@ func main() {
|
||||
// docker login credentials are not provided
|
||||
var err error
|
||||
username = defaultUsername
|
||||
password, publicUrl, err = getAuth(clientId, clientSecret, clientCert, tenantId, subscriptionId, registry)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
if idToken != "" && clientId != "" && tenantId != "" {
|
||||
logrus.Debug("Using OIDC authentication flow")
|
||||
var aadToken string
|
||||
aadToken, err = azureutil.GetAADAccessTokenViaClientAssertion(context.Background(), tenantId, clientId, idToken, authorityHost)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
var p string
|
||||
p, err = getPublicUrl(aadToken, registry, subscriptionId)
|
||||
if err == nil {
|
||||
publicUrl = p
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "failed to get public url with error: %s\n", err)
|
||||
}
|
||||
password, err = fetchACRToken(tenantId, aadToken, registry)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
password, publicUrl, err = getAuth(clientId, clientSecret, clientCert, tenantId, subscriptionId, registry)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetAuthInputValidation(t *testing.T) {
|
||||
// missing tenant
|
||||
if _, _, err := getAuth("client", "secret", "", "", "sub", "registry.azurecr.io"); err == nil {
|
||||
t.Fatalf("expected error for missing tenantId")
|
||||
}
|
||||
// missing clientId
|
||||
if _, _, err := getAuth("", "secret", "", "tenant", "sub", "registry.azurecr.io"); err == nil {
|
||||
t.Fatalf("expected error for missing clientId")
|
||||
}
|
||||
// missing both secret and cert
|
||||
if _, _, err := getAuth("client", "", "", "tenant", "sub", "registry.azurecr.io"); err == nil {
|
||||
t.Fatalf("expected error for missing credentials")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetenvAuthorityHost(t *testing.T) {
|
||||
os.Setenv("AZURE_AUTHORITY_HOST", "https://login.microsoftonline.us")
|
||||
defer os.Unsetenv("AZURE_AUTHORITY_HOST")
|
||||
|
||||
got := getenv("AZURE_AUTHORITY_HOST")
|
||||
if got != "https://login.microsoftonline.us" {
|
||||
t.Fatalf("expected AZURE_AUTHORITY_HOST to be returned, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
@@ -193,6 +201,9 @@ func (p Plugin) Exec() error {
|
||||
fmt.Println(out)
|
||||
return fmt.Errorf("Error authenticating base connector: exit status 1")
|
||||
}
|
||||
} else {
|
||||
fmt.Println("\033[33mTo ensure consistent and reliable pipeline execution, we recommend setting up a Base Image Connector.\033[0m\n" +
|
||||
"\033[33mWhile optional at this time, configuring it helps prevent failures caused by Docker Hub's rate limits.\033[0m")
|
||||
}
|
||||
|
||||
// login to the Docker registry
|
||||
@@ -246,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
|
||||
|
||||
@@ -287,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
|
||||
@@ -642,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)
|
||||
}
|
||||
@@ -678,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
|
||||
@@ -692,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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -24,6 +24,10 @@ LABEL maintainer="Drone.IO Community <drone-dev@googlegroups.com>" `
|
||||
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
|
||||
|
||||
@@ -22,6 +22,10 @@ LABEL maintainer="Drone.IO Community <drone-dev@googlegroups.com>" `
|
||||
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
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultResource = "https://management.azure.com/"
|
||||
const defaultAuthorityHost = "https://login.microsoftonline.com"
|
||||
const defaultHTTPTimeout = 30 * time.Second
|
||||
|
||||
// GetAADAccessTokenViaClientAssertion exchanges an external OIDC ID token for an Azure AD access token
|
||||
|
||||
func GetAADAccessTokenViaClientAssertion(ctx context.Context, tenantID, clientID, oidcToken, authorityHost string) (string, error) {
|
||||
resource := DefaultResource
|
||||
|
||||
form := url.Values{
|
||||
"client_id": {clientID},
|
||||
"scope": {resource + ".default"},
|
||||
"grant_type": {"client_credentials"},
|
||||
"client_assertion_type": {"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"},
|
||||
"client_assertion": {oidcToken},
|
||||
}
|
||||
|
||||
base := authorityHost
|
||||
if strings.TrimSpace(base) == "" {
|
||||
base = defaultAuthorityHost
|
||||
}
|
||||
base = strings.TrimRight(base, "/")
|
||||
endpoint := fmt.Sprintf("%s/%s/oauth2/v2.0/token", base, tenantID)
|
||||
|
||||
client := &http.Client{Timeout: defaultHTTPTimeout}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
var aadErr struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
limited := io.LimitedReader{R: resp.Body, N: 4096}
|
||||
_ = json.NewDecoder(&limited).Decode(&aadErr)
|
||||
if aadErr.Error != "" {
|
||||
return "", fmt.Errorf("AAD token request failed: status=%d, error=%s", resp.StatusCode, aadErr.Error)
|
||||
}
|
||||
return "", fmt.Errorf("AAD token request failed: status=%d", resp.StatusCode)
|
||||
}
|
||||
var payload struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if payload.AccessToken == "" {
|
||||
return "", fmt.Errorf("AAD token response missing access_token")
|
||||
}
|
||||
return payload.AccessToken, nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetAADAccessTokenViaClientAssertion_Success(t *testing.T) {
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "application/x-www-form-urlencoded") {
|
||||
t.Fatalf("expected form content-type, got %s", ct)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("failed parsing form: %v", err)
|
||||
}
|
||||
assertEq(t, r.Form.Get("client_id"), "client")
|
||||
assertEq(t, r.Form.Get("grant_type"), "client_credentials")
|
||||
assertEq(t, r.Form.Get("client_assertion_type"), "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
|
||||
assertEq(t, r.Form.Get("client_assertion"), "idtoken")
|
||||
assertEq(t, r.Form.Get("scope"), DefaultResource+".default")
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"access_token":"AT","token_type":"Bearer","expires_in":3600}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
tok, err := GetAADAccessTokenViaClientAssertion(context.Background(), "tenant", "client", "idtoken", ts.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tok != "AT" {
|
||||
t.Fatalf("expected access token AT, got %q", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAADAccessTokenViaClientAssertion_400WithErrorField(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":"invalid_client","error_description":"bad"}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
_, err := GetAADAccessTokenViaClientAssertion(context.Background(), "tenant", "client", "idtoken", ts.URL)
|
||||
if err == nil || !strings.Contains(err.Error(), "status=400") || !strings.Contains(err.Error(), "invalid_client") {
|
||||
t.Fatalf("expected 400 with invalid_client error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAADAccessTokenViaClientAssertion_400WithoutErrorField(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
_, err := GetAADAccessTokenViaClientAssertion(context.Background(), "tenant", "client", "idtoken", ts.URL)
|
||||
if err == nil || !strings.Contains(err.Error(), "status=400") {
|
||||
t.Fatalf("expected 400 error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAADAccessTokenViaClientAssertion_MalformedJSON(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("not-json"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
_, err := GetAADAccessTokenViaClientAssertion(context.Background(), "tenant", "client", "idtoken", ts.URL)
|
||||
if err == nil {
|
||||
t.Fatalf("expected JSON decode error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAADAccessTokenViaClientAssertion_MissingAccessToken(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"token_type":"Bearer","expires_in":3600}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
_, err := GetAADAccessTokenViaClientAssertion(context.Background(), "tenant", "client", "idtoken", ts.URL)
|
||||
if err == nil || !strings.Contains(err.Error(), "missing access_token") {
|
||||
t.Fatalf("expected missing access_token error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEq(t *testing.T, got, want string) {
|
||||
t.Helper()
|
||||
if got != want {
|
||||
t.Fatalf("mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user