Skip to content

Commit 34d80c8

Browse files
authored
feat: verify binary checksums before execution (#797)
Signed-off-by: Rado M <radkomih@gmail.com>
1 parent 8d3d95a commit 34d80c8

19 files changed

Lines changed: 726 additions & 16 deletions

internal/state/state_reader.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,21 @@ type PromptDefaultsDoc struct {
8787
} `yaml:"state"`
8888
}
8989

90+
// SoftwareVersionsDoc is the minimal YAML shape for reading each installed host
91+
// component's recorded version from machineState.software. The map key (installer
92+
// key, e.g. "crio") and the nested name (catalog artifact name, e.g. "cri-o") may
93+
// differ, so softwareVersionFromDoc matches on either.
94+
type SoftwareVersionsDoc struct {
95+
State struct {
96+
MachineState struct {
97+
Software map[string]struct {
98+
Name string `yaml:"name"`
99+
Version string `yaml:"version"`
100+
} `yaml:"software"`
101+
} `yaml:"machineState"`
102+
} `yaml:"state"`
103+
}
104+
90105
// ReadProvisionerVersionFromDisk extracts the provisioner version from the on-disk state file
91106
// without loading the full state into memory. Returns an empty string when no state file exists.
92107
func ReadProvisionerVersionFromDisk() (string, error) {
@@ -103,6 +118,40 @@ func ReadProvisionerVersionFromDisk() (string, error) {
103118
return doc.State.Provisioner.Version, nil
104119
}
105120

121+
// ReadSoftwareVersionFromDisk reads a single host component's recorded version
122+
// (by catalog artifact name) from the on-disk state file without loading the
123+
// full state. Returns an empty string when the state file or component is absent.
124+
func ReadSoftwareVersionFromDisk(name string) (string, error) {
125+
data, err := readStateFileBytes()
126+
if err != nil || data == nil {
127+
return "", err
128+
}
129+
130+
var doc SoftwareVersionsDoc
131+
if err := unmarshalStateDoc(data, &doc); err != nil {
132+
return "", err
133+
}
134+
135+
return softwareVersionFromDoc(doc, name), nil
136+
}
137+
138+
// softwareVersionFromDoc resolves a component's recorded version, preferring a
139+
// direct map-key hit and falling back to matching an entry's recorded name (so a
140+
// lookup by artifact name finds an entry keyed by its installer key). Returns an
141+
// empty string when the component is not recorded.
142+
func softwareVersionFromDoc(doc SoftwareVersionsDoc, name string) string {
143+
software := doc.State.MachineState.Software
144+
if entry, ok := software[name]; ok {
145+
return entry.Version
146+
}
147+
for _, entry := range software {
148+
if entry.Name == name {
149+
return entry.Version
150+
}
151+
}
152+
return ""
153+
}
154+
106155
// BlockNodeSummary holds the prompt-relevant fields from BlockNodeState
107156
// read from the on-disk state file. HelmReleaseInfo is yaml:",inline"
108157
// inside BlockNodeState, so its keys (name, namespace, version) live

internal/state/state_reader_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,42 @@ state:
2525
}
2626
}
2727

28+
func TestReadSoftwareVersion_ParsesRecordedVersion(t *testing.T) {
29+
// cri-o is stored under the installer key "crio", not its artifact name.
30+
data := []byte(`
31+
state:
32+
machineState:
33+
software:
34+
cilium:
35+
name: cilium
36+
version: "0.18.7"
37+
installed: true
38+
crio:
39+
name: cri-o
40+
version: "1.30.0"
41+
`)
42+
var doc SoftwareVersionsDoc
43+
if err := unmarshalStateDoc(data, &doc); err != nil {
44+
t.Fatalf("unexpected parse error: %v", err)
45+
}
46+
// Direct map-key hit.
47+
if got := softwareVersionFromDoc(doc, "cilium"); got != "0.18.7" {
48+
t.Fatalf("expected cilium version '0.18.7', got %q", got)
49+
}
50+
// Artifact name resolves via the name fallback despite the differing map key.
51+
if got := softwareVersionFromDoc(doc, "cri-o"); got != "1.30.0" {
52+
t.Fatalf("expected cri-o version '1.30.0' via name fallback, got %q", got)
53+
}
54+
// The installer key still resolves directly too.
55+
if got := softwareVersionFromDoc(doc, "crio"); got != "1.30.0" {
56+
t.Fatalf("expected cri-o version '1.30.0' via map key, got %q", got)
57+
}
58+
// Unrecorded component resolves to the empty string.
59+
if got := softwareVersionFromDoc(doc, "teleport"); got != "" {
60+
t.Fatalf("expected empty version for unrecorded component, got %q", got)
61+
}
62+
}
63+
2864
func TestReadPromptDefaults_ParsesProfile(t *testing.T) {
2965
data := []byte(`
3066
state:

internal/workflows/cluster.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ func kubernetesSetupWorkflow(mr software.MachineRuntime) *automa.WorkflowBuilder
4242

4343
// kubelet
4444
steps.SetupKubelet(mr),
45+
steps.VerifyExecutablesStep("kubelet"),
4546
steps.SetupSystemdService(software.KubeletServiceName),
4647

4748
// setup cli tools
@@ -51,6 +52,7 @@ func kubernetesSetupWorkflow(mr software.MachineRuntime) *automa.WorkflowBuilder
5152

5253
// CRI-O
5354
steps.SetupCrio(mr),
55+
steps.VerifyExecutablesStep(software.CrioArtifactName),
5456
steps.SetupSystemdService(software.CrioServiceName),
5557

5658
// kubeadm

internal/workflows/migration_cilium_acceleration.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ var (
5555
reconfigureCiliumConfig = software.ReconfigureCiliumConfig
5656
kubernetesInstalled = defaultKubernetesInstalled
5757
readCiliumState = defaultReadCiliumState
58+
verifyCiliumExecutable = func() error { return software.VerifyExecutables("cilium") }
5859
)
5960

6061
// CiliumAccelerationMigration re-renders cilium-config.yaml and runs `cilium upgrade`
@@ -117,6 +118,11 @@ func (m *CiliumAccelerationMigration) Execute(ctx context.Context, mctx *migrati
117118
Str("to", disabledAcceleration).
118119
Msg("Applying cilium-config (loadBalancer.acceleration=disabled) to existing cluster via 'cilium upgrade'")
119120

121+
// Verify the cilium binary before executing it.
122+
if err := verifyCiliumExecutable(); err != nil {
123+
return err
124+
}
125+
120126
upgrade := []string{
121127
fmt.Sprintf("/usr/bin/sudo %s/cilium upgrade --values %s --wait",
122128
models.Paths().SandboxBinDir, configPath),

internal/workflows/migration_cilium_acceleration_test.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,9 @@ func TestCiliumAccelerationMigration_Applies(t *testing.T) {
5151
}
5252

5353
func TestCiliumAccelerationMigration_Execute(t *testing.T) {
54-
origK8s, origState, origReconfigure, origShell := kubernetesInstalled, readCiliumState, reconfigureCiliumConfig, runShell
54+
origK8s, origState, origReconfigure, origShell, origVerify := kubernetesInstalled, readCiliumState, reconfigureCiliumConfig, runShell, verifyCiliumExecutable
5555
t.Cleanup(func() {
56-
kubernetesInstalled, readCiliumState, reconfigureCiliumConfig, runShell = origK8s, origState, origReconfigure, origShell
56+
kubernetesInstalled, readCiliumState, reconfigureCiliumConfig, runShell, verifyCiliumExecutable = origK8s, origState, origReconfigure, origShell, origVerify
5757
})
5858

5959
m := NewCiliumAccelerationMigration()
@@ -84,8 +84,9 @@ func TestCiliumAccelerationMigration_Execute(t *testing.T) {
8484
assert.False(t, ran, "must not run before Kubernetes is installed")
8585
})
8686

87-
// Remaining cases assume Kubernetes is present.
87+
// Remaining cases assume Kubernetes is present and cilium verification passes.
8888
kubernetesInstalled = func() bool { return true }
89+
verifyCiliumExecutable = func() error { return nil }
8990

9091
t.Run("skips when Cilium is not installed", func(t *testing.T) {
9192
var ran bool
@@ -128,4 +129,19 @@ func TestCiliumAccelerationMigration_Execute(t *testing.T) {
128129
assert.Contains(t, cmd, "--values")
129130
assert.Contains(t, cmd, "--wait")
130131
})
132+
133+
t.Run("aborts before upgrade when the cilium binary fails verification", func(t *testing.T) {
134+
var ran bool
135+
var cmd string
136+
stateReturns(true, "best-effort", nil)
137+
reconfigureCiliumConfig = func() (string, error) {
138+
return "/opt/solo/weaver/sandbox/etc/weaver/cilium-config.yaml", nil
139+
}
140+
trackUpgrade(&cmd, &ran)
141+
verifyCiliumExecutable = func() error { return assert.AnError }
142+
t.Cleanup(func() { verifyCiliumExecutable = func() error { return nil } })
143+
144+
require.Error(t, m.Execute(context.Background(), mctx))
145+
assert.False(t, ran, "cilium upgrade must not run when the binary fails checksum verification")
146+
})
131147
}

internal/workflows/migration_cilium_host_legacy_routing.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,11 @@ func (m *CiliumHostLegacyRoutingMigration) Execute(ctx context.Context, mctx *mi
121121
Str("to", "true").
122122
Msg("Applying cilium-config (enable-host-legacy-routing=true) to existing cluster via 'cilium upgrade'")
123123

124+
// Verify the cilium binary before executing it.
125+
if err := verifyCiliumExecutable(); err != nil {
126+
return err
127+
}
128+
124129
upgrade := []string{
125130
fmt.Sprintf("/usr/bin/sudo %s/cilium upgrade --values %s --wait",
126131
models.Paths().SandboxBinDir, configPath),

internal/workflows/migration_cilium_host_legacy_routing_test.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,13 @@ func TestCiliumHostLegacyRoutingMigration_Execute(t *testing.T) {
4646
origState := readHostLegacyRoutingState
4747
origReconfigure := reconfigureCiliumConfig
4848
origShell := runShell
49+
origVerify := verifyCiliumExecutable
4950
t.Cleanup(func() {
5051
kubernetesInstalled = origK8s
5152
readHostLegacyRoutingState = origState
5253
reconfigureCiliumConfig = origReconfigure
5354
runShell = origShell
55+
verifyCiliumExecutable = origVerify
5456
})
5557

5658
m := NewCiliumHostLegacyRoutingMigration()
@@ -83,8 +85,10 @@ func TestCiliumHostLegacyRoutingMigration_Execute(t *testing.T) {
8385
assert.False(t, ran, "must not run before Kubernetes is installed")
8486
})
8587

86-
// Remaining cases assume Kubernetes is present.
88+
// Remaining cases assume Kubernetes is present and the cilium binary passes
89+
// checksum verification unless a case overrides it.
8790
kubernetesInstalled = func() bool { return true }
91+
verifyCiliumExecutable = func() error { return nil }
8892

8993
t.Run("skips when Cilium is not installed", func(t *testing.T) {
9094
var ran bool
@@ -173,4 +177,19 @@ func TestCiliumHostLegacyRoutingMigration_Execute(t *testing.T) {
173177
assert.Contains(t, cmd, "--values")
174178
assert.Contains(t, cmd, "--wait")
175179
})
180+
181+
t.Run("aborts before upgrade when the cilium binary fails verification", func(t *testing.T) {
182+
var ran bool
183+
var cmd string
184+
stateReturns(true, "", "", nil)
185+
reconfigureCiliumConfig = func() (string, error) {
186+
return "/opt/solo/weaver/sandbox/etc/weaver/cilium-config.yaml", nil
187+
}
188+
trackUpgrade(&cmd, &ran)
189+
verifyCiliumExecutable = func() error { return assert.AnError }
190+
t.Cleanup(func() { verifyCiliumExecutable = func() error { return nil } })
191+
192+
require.Error(t, m.Execute(context.Background(), mctx))
193+
assert.False(t, ran, "cilium upgrade must not run when the binary fails checksum verification")
194+
})
176195
}

internal/workflows/steps/step_cilium.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,11 @@ func installCiliumCNI(version string) *automa.StepBuilder {
203203
WithExecute(func(ctx context.Context, stp automa.Step) *automa.Report {
204204
logx.As().Info().Str("step_id", stp.Id()).Str("software", "cilium-cni").Str("version", version).Msgf("cilium-cni version: %s", version)
205205

206+
// Verify the cilium binary before executing it.
207+
if err := verifyExecutables("cilium"); err != nil {
208+
return automa.FailureReport(stp, automa.WithError(err))
209+
}
210+
206211
// Prepare metadata for reporting
207212
meta := map[string]string{}
208213

@@ -241,6 +246,11 @@ func installCiliumCNI(version string) *automa.StepBuilder {
241246
return automa.SkippedReport(stp, automa.WithDetail("Cilium CNI was not installed by this step, skipping rollback"))
242247
}
243248

249+
// Verify the cilium binary before executing it on rollback.
250+
if err := verifyExecutables("cilium"); err != nil {
251+
return automa.FailureReport(stp, automa.WithError(err))
252+
}
253+
244254
// Uninstall Cilium CNI
245255
scripts := []string{
246256
fmt.Sprintf("/usr/bin/sudo %s/cilium uninstall", models.Paths().SandboxBinDir),

internal/workflows/steps/step_cilium_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
package steps
44

55
import (
6+
"context"
67
"testing"
78

9+
"github.com/automa-saga/automa"
810
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
912
)
1013

1114
// Test_bandwidthManagerEnabled covers the decision the StartCilium guard makes on
@@ -32,3 +35,36 @@ func Test_bandwidthManagerEnabled(t *testing.T) {
3235
})
3336
}
3437
}
38+
39+
// The install-cilium-cni step verifies the cilium binary before shelling out to
40+
// it on both install and rollback. These cases pin that a failed checksum aborts
41+
// before the binary is executed.
42+
43+
func TestInstallCiliumCNI_FailsWhenCiliumVerificationFails(t *testing.T) {
44+
calls := stubVerifyExecutables(t, func(string) error { return assert.AnError })
45+
46+
step, err := installCiliumCNI("1.16.5").Build()
47+
require.NoError(t, err)
48+
49+
report := step.Execute(context.Background())
50+
require.Error(t, report.Error)
51+
assert.Equal(t, automa.StatusFailed, report.Status)
52+
require.ErrorIs(t, report.Error, assert.AnError)
53+
assert.Equal(t, []string{"cilium"}, *calls)
54+
}
55+
56+
func TestInstallCiliumCNI_RollbackFailsWhenCiliumVerificationFails(t *testing.T) {
57+
stubVerifyExecutables(t, func(string) error { return assert.AnError })
58+
59+
step, err := installCiliumCNI("1.16.5").Build()
60+
require.NoError(t, err)
61+
62+
// Rollback is a no-op unless this step performed the install; mark it so
63+
// rollback proceeds past that guard and reaches the checksum verification.
64+
step.State().Local().Set(InstalledByThisStep, true)
65+
66+
report := step.Rollback(context.Background())
67+
require.Error(t, report.Error)
68+
assert.Equal(t, automa.StatusFailed, report.Status)
69+
require.ErrorIs(t, report.Error, assert.AnError)
70+
}

internal/workflows/steps/step_kubeadm.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,11 @@ func InitializeCluster() *automa.StepBuilder {
186186
notify.As().StepCompletion(ctx, stp, rpt, "Kubernetes cluster initialized successfully")
187187
}).
188188
WithExecute(func(ctx context.Context, stp automa.Step) *automa.Report {
189+
// Verify the kubectl binary before executing it.
190+
if err := verifyExecutables("kubectl"); err != nil {
191+
return automa.FailureReport(stp, automa.WithError(err))
192+
}
193+
189194
// Check if cluster is already initialized
190195
scripts := []string{kubectlGetNodesCmd}
191196
output, err := automa_steps.RunBashScript(scripts, "")
@@ -198,6 +203,11 @@ func InitializeCluster() *automa.StepBuilder {
198203

199204
// Cluster not initialized, proceed with full initialization
200205

206+
// Verify the kubeadm binary before executing it.
207+
if err := verifyExecutables("kubeadm"); err != nil {
208+
return automa.FailureReport(stp, automa.WithError(err))
209+
}
210+
201211
// Step 1: Pull kubeadm images
202212
logx.As().Info().Msg("Pulling kubeadm images, this may take a while...")
203213
pullImageCmd := []string{
@@ -233,6 +243,11 @@ func InitializeCluster() *automa.StepBuilder {
233243
return automa.SuccessReport(stp, automa.WithDetail("Cluster initialized successfully"))
234244
}).
235245
WithRollback(func(ctx context.Context, stp automa.Step) *automa.Report {
246+
// Verify the kubeadm binary before executing it on rollback.
247+
if err := verifyExecutables("kubeadm"); err != nil {
248+
return automa.FailureReport(stp, automa.WithError(err))
249+
}
250+
236251
scripts := []string{
237252
fmt.Sprintf("sudo %s/kubeadm reset --force --cri-socket unix:///opt/solo/weaver/sandbox/var/run/crio/crio.sock", models.Paths().SandboxBinDir),
238253
}
@@ -260,6 +275,13 @@ func ResetCluster() *automa.StepBuilder {
260275
notify.As().StepCompletion(ctx, stp, rpt, "Kubernetes cluster reset successfully")
261276
}).
262277
WithExecute(func(ctx context.Context, stp automa.Step) *automa.Report {
278+
// Teardown is best-effort: if the kubeadm binary fails verification,
279+
// skip the reset and continue rather than execute a tampered binary.
280+
if err := verifyExecutables("kubeadm"); err != nil {
281+
logx.As().Warn().Err(err).Msg("kubeadm checksum verification failed, skipping kubeadm reset and continuing with teardown")
282+
return automa.SuccessReport(stp)
283+
}
284+
263285
scripts := []string{
264286
fmt.Sprintf("sudo %s/kubeadm reset --force --cri-socket unix:///opt/solo/weaver/sandbox/var/run/crio/crio.sock", models.Paths().SandboxBinDir),
265287
}

0 commit comments

Comments
 (0)