From d10b5ea8b85a2f4908a3ab1861902d4884c7aa45 Mon Sep 17 00:00:00 2001 From: Raghav Date: Tue, 14 Jul 2026 20:02:58 +0530 Subject: [PATCH] feat: [CI-23743]: Add HARNESS_CA_PATH support for egress-proxy CA trust (#517) The plugin now reads a HARNESS_CA_PATH environment variable pointing to a PEM CA file and installs it into the host system trust store before starting the Docker daemon. This solves x509 errors when builds run behind TLS-intercepting egress proxies that re-sign upstream TLS with their own CA. - Linux: installs into distro anchor directory (/usr/local/share/ca-certificates or /etc/pki/ca-trust/source/anchors) and refreshes system bundle via update-ca-certificates/update-ca-trust, with direct bundle append fallback - Windows: imports via certutil -addstore Root - macOS: logged no-op (not currently supported) - Best-effort, idempotent, documented in README.md with usage examples - New platform-specific source files: ca_linux.go, ca_windows.go, ca_other.go - Comprehensive tests added: ca_test.go, ca_linux_test.go Co-Authored-By: Claude Sonnet 4.5 AI-Session-Id: fe2aa5fe-1bf5-4257-ab76-2c862c5637fe AI-Tool: claude-code AI-Model: unknown --- README.md | 48 +++++++++++++++++++++ ca_linux.go | 110 +++++++++++++++++++++++++++++++++++++++++++++++ ca_linux_test.go | 82 +++++++++++++++++++++++++++++++++++ ca_other.go | 12 ++++++ ca_test.go | 87 +++++++++++++++++++++++++++++++++++++ ca_windows.go | 51 ++++++++++++++++++++++ docker.go | 52 +++++++++++++++++++--- 7 files changed, 435 insertions(+), 7 deletions(-) create mode 100644 ca_linux.go create mode 100644 ca_linux_test.go create mode 100644 ca_other.go create mode 100644 ca_test.go create mode 100644 ca_windows.go diff --git a/README.md b/README.md index 8f5db0c..542f6a3 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,54 @@ COOL BANANAS ``` +### Trusting a custom / egress-proxy CA (`HARNESS_CA_PATH`) + +When builds run behind a TLS-intercepting egress proxy (e.g. Harness egress +control), the proxy re-signs upstream TLS with its own CA. The Docker daemon and +its embedded BuildKit verify registry TLS against the **host system trust +store**, so base-image pulls fail with +`x509: certificate signed by unknown authority` unless that CA is trusted. + +Set the `HARNESS_CA_PATH` environment variable to a PEM CA (bundle) file and the +plugin installs it into the host trust store **before starting the Docker +daemon**: + +```yaml +steps: +- name: build and push + image: plugins/docker + pull: never + environment: + HARNESS_CA_PATH: /etc/harness-certs/ca.crt + settings: + repo: octocat/hello-world + tags: latest +``` + +Behavior: + +- **Linux** — installs into the distro anchor dir + (`/usr/local/share/ca-certificates` on Debian/Ubuntu/Alpine, + `/etc/pki/ca-trust/source/anchors` on RHEL/CentOS/Fedora) and runs + `update-ca-certificates` / `update-ca-trust`. It also appends the CA directly + to the consolidated bundle (`/etc/ssl/certs/ca-certificates.crt` or the RHEL + equivalent) so trust holds even on minimal images without a refresh tool. +- **Windows** — imports the CA into the machine trust store via + `certutil -addstore -f Root`. +- **macOS / other** — logged no-op (not currently supported). + +Notes: + +- It is **best-effort and idempotent**: when `HARNESS_CA_PATH` is unset, the file + is missing, or the file is empty, the plugin logs and continues. Re-running the + step will not duplicate the CA in the bundle. +- The CA is trusted for the daemon's own registry TLS (base-image pulls, cache + endpoints). To make the CA available to `RUN` steps inside the build, also pass + it as needed via the Dockerfile / build args. +- This is separate from the proxy build args + (`http_proxy`/`https_proxy`/`no_proxy`), which the plugin already injects from + the environment (including `HARNESS_`-prefixed variants). + ### Running from the CLI ```console diff --git a/ca_linux.go b/ca_linux.go new file mode 100644 index 0000000..b893d4d --- /dev/null +++ b/ca_linux.go @@ -0,0 +1,110 @@ +//go:build linux +// +build linux + +package docker + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// anchorCandidate is a distro CA anchor directory plus the tool that refreshes +// the consolidated bundle from it. +type anchorCandidate struct { + dir string + refresh []string +} + +// linuxAnchorCandidates and linuxBundleCandidates are package vars (not consts) +// so tests can point them at a temp trust store. Order matters: the first +// existing entry wins for the anchor install. +var linuxAnchorCandidates = []anchorCandidate{ + {"/usr/local/share/ca-certificates", []string{"update-ca-certificates"}}, // Debian/Ubuntu/Alpine + {"/etc/pki/ca-trust/source/anchors", []string{"update-ca-trust", "extract"}}, // RHEL/CentOS/Fedora +} + +var linuxBundleCandidates = []string{ + "/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu/Alpine + "/etc/pki/tls/certs/ca-bundle.crt", // RHEL/CentOS +} + +// installHarnessCA drops the CA into the distro anchor directory and refreshes +// the system bundle. It also appends directly to the consolidated bundle so +// trust is effective even on images that lack update-ca-certificates (e.g. +// minimal distros); the Docker daemon and Go's crypto/x509 read from that +// bundle. Best-effort: failures are logged, never fatal. +func installHarnessCA(caPEM []byte) { + anchorCandidates := linuxAnchorCandidates + + installedViaAnchor := false + for _, c := range anchorCandidates { + if _, err := os.Stat(c.dir); err != nil { + continue + } + dst := filepath.Join(c.dir, "harness-egress-ca.crt") + if err := os.WriteFile(dst, caPEM, 0644); err != nil { + fmt.Printf("Could not write Harness CA to %s: %s\n", dst, err) + continue + } + if _, err := exec.LookPath(c.refresh[0]); err != nil { + continue + } + cmd := exec.Command(c.refresh[0], c.refresh[1:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Printf("Warning: %s failed: %s\n", c.refresh[0], err) + continue + } + fmt.Printf("Installed Harness egress CA into %s and refreshed system trust store.\n", c.dir) + installedViaAnchor = true + break + } + + // Belt-and-suspenders: append to the consolidated bundle(s) directly so the + // CA is trusted even when no refresh tool exists or the anchor step failed. + bundleCandidates := linuxBundleCandidates + appended := false + for _, bundle := range bundleCandidates { + if _, err := os.Stat(bundle); err != nil { + continue + } + if bundleContainsCA(bundle, caPEM) { + appended = true + continue + } + f, err := os.OpenFile(bundle, os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + fmt.Printf("Could not append Harness CA to %s: %s\n", bundle, err) + continue + } + payload := caPEM + if !bytes.HasPrefix(payload, []byte("\n")) { + payload = append([]byte("\n"), payload...) + } + if _, err := f.Write(payload); err != nil { + fmt.Printf("Could not append Harness CA to %s: %s\n", bundle, err) + } else { + fmt.Printf("Appended Harness egress CA to %s.\n", bundle) + appended = true + } + f.Close() + } + + if !installedViaAnchor && !appended { + fmt.Println("Warning: could not install the Harness egress CA into any known trust store location; base-image pulls through the egress proxy may fail with x509 errors.") + } +} + +// bundleContainsCA reports whether the CA bytes are already present in the +// bundle, to keep the step idempotent across retries. +func bundleContainsCA(bundle string, caPEM []byte) bool { + existing, err := os.ReadFile(bundle) + if err != nil { + return false + } + return bytes.Contains(existing, bytes.TrimSpace(caPEM)) +} diff --git a/ca_linux_test.go b/ca_linux_test.go new file mode 100644 index 0000000..3e8f48b --- /dev/null +++ b/ca_linux_test.go @@ -0,0 +1,82 @@ +//go:build linux +// +build linux + +package docker + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// withTempTrustStore points the Linux anchor/bundle candidates at a temp dir so +// installHarnessCA can be exercised without touching the real system store. +func withTempTrustStore(t *testing.T) (anchorDir, bundlePath string) { + t.Helper() + dir := t.TempDir() + anchorDir = filepath.Join(dir, "anchors") + bundlePath = filepath.Join(dir, "ca-certificates.crt") + if err := os.MkdirAll(anchorDir, 0755); err != nil { + t.Fatal(err) + } + // Seed an existing bundle so the append path is exercised. + if err := os.WriteFile(bundlePath, []byte("# existing roots\n"), 0644); err != nil { + t.Fatal(err) + } + + origAnchors := linuxAnchorCandidates + origBundles := linuxBundleCandidates + // No refresh tool on the anchor entry so the test doesn't shell out. + linuxAnchorCandidates = []anchorCandidate{{dir: anchorDir, refresh: []string{"harness-nonexistent-refresh-tool"}}} + linuxBundleCandidates = []string{bundlePath} + t.Cleanup(func() { + linuxAnchorCandidates = origAnchors + linuxBundleCandidates = origBundles + }) + return anchorDir, bundlePath +} + +func TestInstallHarnessCA_Linux(t *testing.T) { + anchorDir, bundlePath := withTempTrustStore(t) + + installHarnessCA([]byte(testCAPEM)) + + // Anchor file should be written. + anchorFile := filepath.Join(anchorDir, "harness-egress-ca.crt") + got, err := os.ReadFile(anchorFile) + if err != nil { + t.Fatalf("expected anchor file at %s: %s", anchorFile, err) + } + if string(got) != testCAPEM { + t.Fatalf("anchor file = %q, want %q", string(got), testCAPEM) + } + + // Bundle should have the CA appended (refresh tool is absent, so the + // direct-append fallback is what provides trust here). + bundle, err := os.ReadFile(bundlePath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(bundle), strings.TrimSpace(testCAPEM)) { + t.Fatalf("bundle %q does not contain appended CA", string(bundle)) + } + if !strings.HasPrefix(string(bundle), "# existing roots") { + t.Fatalf("bundle append clobbered existing roots: %q", string(bundle)) + } +} + +func TestInstallHarnessCA_Linux_Idempotent(t *testing.T) { + _, bundlePath := withTempTrustStore(t) + + installHarnessCA([]byte(testCAPEM)) + installHarnessCA([]byte(testCAPEM)) + + bundle, err := os.ReadFile(bundlePath) + if err != nil { + t.Fatal(err) + } + if n := strings.Count(string(bundle), strings.TrimSpace(testCAPEM)); n != 1 { + t.Fatalf("expected CA to appear exactly once after two installs, got %d", n) + } +} diff --git a/ca_other.go b/ca_other.go new file mode 100644 index 0000000..fa09c72 --- /dev/null +++ b/ca_other.go @@ -0,0 +1,12 @@ +//go:build !linux && !windows +// +build !linux,!windows + +package docker + +import "fmt" + +// installHarnessCA is a no-op on platforms where egress control is not yet +// supported (e.g. macOS). HARNESS_CA_PATH is honored on Linux and Windows. +func installHarnessCA(caPEM []byte) { + fmt.Println("HARNESS_CA_PATH is set but egress-proxy CA trust injection is not supported on this platform; skipping.") +} diff --git a/ca_test.go b/ca_test.go new file mode 100644 index 0000000..d45e079 --- /dev/null +++ b/ca_test.go @@ -0,0 +1,87 @@ +package docker + +import ( + "os" + "path/filepath" + "testing" +) + +const testCAPEM = `-----BEGIN CERTIFICATE----- +MIIBAg== +-----END CERTIFICATE----- +` + +// withStubbedInstall replaces the platform installer with a capturing stub for +// the duration of a test, returning a pointer to the captured CA bytes and a +// pointer to a call counter. +func withStubbedInstall(t *testing.T) (captured *[]byte, calls *int) { + t.Helper() + captured = new([]byte) + calls = new(int) + orig := installHarnessCAFn + installHarnessCAFn = func(caPEM []byte) { + *calls++ + *captured = append([]byte(nil), caPEM...) + } + t.Cleanup(func() { installHarnessCAFn = orig }) + return captured, calls +} + +func TestTrustHarnessCA_Unset(t *testing.T) { + os.Unsetenv("HARNESS_CA_PATH") + _, calls := withStubbedInstall(t) + + trustHarnessCA() + + if *calls != 0 { + t.Fatalf("expected installer not to be called when HARNESS_CA_PATH is unset, got %d calls", *calls) + } +} + +func TestTrustHarnessCA_MissingFile(t *testing.T) { + missing := filepath.Join(t.TempDir(), "does-not-exist.crt") + os.Setenv("HARNESS_CA_PATH", missing) + defer os.Unsetenv("HARNESS_CA_PATH") + _, calls := withStubbedInstall(t) + + trustHarnessCA() + + if *calls != 0 { + t.Fatalf("expected installer not to be called when CA file is missing, got %d calls", *calls) + } +} + +func TestTrustHarnessCA_EmptyFile(t *testing.T) { + empty := filepath.Join(t.TempDir(), "empty.crt") + if err := os.WriteFile(empty, []byte(" \n\t"), 0644); err != nil { + t.Fatal(err) + } + os.Setenv("HARNESS_CA_PATH", empty) + defer os.Unsetenv("HARNESS_CA_PATH") + _, calls := withStubbedInstall(t) + + trustHarnessCA() + + if *calls != 0 { + t.Fatalf("expected installer not to be called for whitespace-only CA, got %d calls", *calls) + } +} + +func TestTrustHarnessCA_ValidFile(t *testing.T) { + valid := filepath.Join(t.TempDir(), "ca.crt") + if err := os.WriteFile(valid, []byte(testCAPEM), 0644); err != nil { + t.Fatal(err) + } + os.Setenv("HARNESS_CA_PATH", valid) + defer os.Unsetenv("HARNESS_CA_PATH") + captured, calls := withStubbedInstall(t) + + trustHarnessCA() + + if *calls != 1 { + t.Fatalf("expected installer to be called once for a valid CA, got %d calls", *calls) + } + if string(*captured) != testCAPEM { + t.Fatalf("installer received %q, want %q", string(*captured), testCAPEM) + } +} diff --git a/ca_windows.go b/ca_windows.go new file mode 100644 index 0000000..d4db1e2 --- /dev/null +++ b/ca_windows.go @@ -0,0 +1,51 @@ +//go:build windows +// +build windows + +package docker + +import ( + "fmt" + "os" + "os/exec" +) + +// installHarnessCA imports the Harness egress-proxy CA into the Windows +// certificate store under LocalMachine\Root (the machine-wide trusted roots the +// Docker daemon and Go's crypto/x509 consult). Best-effort: failures are logged, +// never fatal. +// +// certutil needs a file path, so the (already-validated) CA bytes are written to +// a temp .crt first, then added with `certutil -addstore -f Root `. +func installHarnessCA(caPEM []byte) { + tmp, err := os.CreateTemp("", "harness-egress-ca-*.crt") + if err != nil { + fmt.Printf("Could not create temp file for Harness CA: %s\n", err) + return + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + if _, err := tmp.Write(caPEM); err != nil { + tmp.Close() + fmt.Printf("Could not write temp Harness CA: %s\n", err) + return + } + tmp.Close() + + if _, err := exec.LookPath("certutil"); err != nil { + fmt.Printf("certutil not found on PATH; cannot install Harness egress CA: %s\n", err) + fmt.Println("Base-image pulls through the egress proxy may fail with x509 errors.") + return + } + + // -f overwrites an existing entry, keeping the step idempotent across retries. + cmd := exec.Command("certutil", "-addstore", "-f", "Root", tmpPath) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Printf("Warning: certutil -addstore Root failed: %s\n", err) + fmt.Println("Base-image pulls through the egress proxy may fail with x509 errors.") + return + } + fmt.Println("Installed Harness egress CA into the Windows LocalMachine\\Root store.") +} diff --git a/docker.go b/docker.go index c1e7eff..6c07dec 100644 --- a/docker.go +++ b/docker.go @@ -1,6 +1,7 @@ package docker import ( + "bytes" "errors" "fmt" "os" @@ -131,6 +132,14 @@ type ( // Exec executes the plugin step func (p Plugin) Exec() error { + // Trust the Harness egress-proxy CA (if HARNESS_CA_PATH is set) BEFORE the + // daemon starts. The Docker daemon and its embedded BuildKit verify registry + // TLS against the host system trust store; when traffic goes through the + // TLS-intercepting egress proxy, the presented cert is signed by the Harness + // CA and must be trusted here or base-image pulls fail with an x509 + // "unknown authority" error. + trustHarnessCA() + // start the Docker daemon server if !p.Daemon.Disabled { p.startDaemon() @@ -331,7 +340,7 @@ func (p Plugin) Exec() error { 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) @@ -339,7 +348,7 @@ func (p Plugin) Exec() error { } 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) @@ -574,6 +583,35 @@ func getSecretCmdArg(kvp string, file bool) (string, error) { return fmt.Sprintf("id=%s,env=%s", key, value), nil } +// trustHarnessCA installs the Harness egress-proxy CA into the host trust store +// so the Docker daemon / embedded BuildKit trust the TLS-intercepting proxy when +// pulling base images. It is driven by HARNESS_CA_PATH (path to a PEM CA bundle) +// and is a best-effort no-op when the var is unset, the file is missing, or the +// platform isn't supported — build failures are surfaced later by docker itself. +// The platform-specific installation is provided by installHarnessCA (see +// ca_linux.go / ca_windows.go / ca_other.go); it is indirected through +// installHarnessCAFn so tests can stub the actual system-trust write. +var installHarnessCAFn = installHarnessCA + +func trustHarnessCA() { + caPath := os.Getenv("HARNESS_CA_PATH") + if caPath == "" { + return + } + + caPEM, err := os.ReadFile(caPath) + if err != nil { + fmt.Printf("HARNESS_CA_PATH is set to %q but the CA could not be read: %s. Skipping CA trust.\n", caPath, err) + return + } + if len(bytes.TrimSpace(caPEM)) == 0 { + fmt.Printf("HARNESS_CA_PATH %q is empty. Skipping CA trust.\n", caPath) + return + } + + installHarnessCAFn(caPEM) +} + // helper function to add proxy values from the environment func addProxyBuildArgs(build *Build) { addProxyValue(build, "http_proxy") @@ -853,7 +891,7 @@ func isValidPEMKey(pemContent string) bool { // 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") @@ -861,21 +899,21 @@ func createCosignCommand(imageRef string, cosign CosignConfig) *exec.Cmd { } 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...) }