VYPR
Critical severityOSV Advisory· Published Jan 15, 2026· Updated Jan 15, 2026

Arcane has a Command Injection in Arcane Updater Lifecycle Labels Enables RCE

CVE-2026-23520

Description

Arcane provides modern docker management. Prior to 1.13.0, Arcane has a command injection in the updater service. Arcane’s updater service supported lifecycle labels com.getarcaneapp.arcane.lifecycle.pre-update and com.getarcaneapp.arcane.lifecycle.post-update that allowed defining a command to run before or after a container update. The label value is passed directly to /bin/sh -c without sanitization or validation. Because any authenticated user (not limited to administrators) can create projects through the API, an attacker can create a project that specifies one of these lifecycle labels with a malicious command. When an administrator later triggers a container update (either manually or via scheduled update checks), Arcane reads the lifecycle label and executes its value as a shell command inside the container. This vulnerability is fixed in 1.13.0.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/getarcaneapp/arcane/backendGo
< 0.0.0-20260114065515-5a9c2f92e11f0.0.0-20260114065515-5a9c2f92e11f

Affected products

1

Patches

1
5a9c2f92e11f

fix: remove updater lifecycle hooks (#1468)

https://github.com/getarcaneapp/arcaneKyle MendellJan 14, 2026via ghsa
5 files changed · +0 626
  • backend/internal/services/updater_service.go+0 40 modified
    @@ -654,19 +654,6 @@ func (s *UpdaterService) updateContainer(ctx context.Context, cnt container.Summ
     
     	slog.DebugContext(ctx, "updateContainer: starting update", "containerId", cnt.ID, "containerName", name, "newRef", newRef, "isArcane", isArcane)
     
    -	// Execute pre-update lifecycle hook
    -	hookResult := arcaneupdater.ExecutePreUpdateCommand(ctx, dcli, cnt.ID, labels)
    -	if hookResult.Executed {
    -		if hookResult.SkipUpdate {
    -			slog.InfoContext(ctx, "updateContainer: container requested skip via pre-update hook", "containerId", cnt.ID, "containerName", name)
    -			return fmt.Errorf("container requested skip update via exit code %d", arcaneupdater.ExitCodeSkipUpdate)
    -		}
    -		if hookResult.Error != nil {
    -			slog.WarnContext(ctx, "updateContainer: pre-update hook failed", "containerId", cnt.ID, "err", hookResult.Error)
    -			// Continue with update despite hook failure (configurable in future)
    -		}
    -	}
    -
     	originalName := inspect.Name
     
     	// Get custom stop signal if configured
    @@ -732,13 +719,6 @@ func (s *UpdaterService) updateContainer(ctx context.Context, cnt container.Summ
     	}
     	_ = s.eventService.LogContainerEvent(ctx, models.EventTypeContainerStart, resp.ID, name, systemUser.ID, systemUser.Username, "0", models.JSON{"action": "updater_start"})
     
    -	// Execute post-update lifecycle hook on the new container
    -	hookResult = arcaneupdater.ExecutePostUpdateCommand(ctx, dcli, resp.ID, labels)
    -	if hookResult.Executed && hookResult.Error != nil {
    -		slog.WarnContext(ctx, "updateContainer: post-update hook failed", "newContainerId", resp.ID, "err", hookResult.Error)
    -		// Log but don't fail the update
    -	}
    -
     	_ = s.eventService.LogContainerEvent(ctx, models.EventTypeContainerUpdate, resp.ID, name, systemUser.ID, systemUser.Username, "0", models.JSON{
     		"oldContainerId": cnt.ID,
     		"newContainerId": resp.ID,
    @@ -1136,26 +1116,6 @@ func (s *UpdaterService) restartContainersUsingOldIDs(ctx context.Context, oldID
     			NewImages:    map[string]string{"main": s.normalizeRef(p.newRef)},
     		}
     
    -		// Lifecycle hooks for "check" phase (best-effort)
    -		if !arcaneupdater.IsArcaneContainer(labels) {
    -			pre := arcaneupdater.ExecutePreCheckCommand(ctx, dcli, p.cnt.ID, labels)
    -			if pre.Executed {
    -				if pre.SkipUpdate {
    -					res.Status = "skipped"
    -					res.Error = fmt.Sprintf("container requested skip via pre-check hook (exit %d)", arcaneupdater.ExitCodeSkipUpdate)
    -					results = append(results, res)
    -					continue
    -				}
    -				if pre.Error != nil {
    -					slog.WarnContext(ctx, "restartContainersUsingOldIDs: pre-check hook failed", "container", name, "error", pre.Error.Error())
    -				}
    -			}
    -			post := arcaneupdater.ExecutePostCheckCommand(ctx, dcli, p.cnt.ID, labels)
    -			if post.Executed && post.Error != nil {
    -				slog.WarnContext(ctx, "restartContainersUsingOldIDs: post-check hook failed", "container", name, "error", post.Error.Error())
    -			}
    -		}
    -
     		if p.newRef == "" {
     			res.Status = "skipped"
     			res.Error = "no matching updated image"
    
  • backend/internal/utils/arcaneupdater/labels.go+0 30 modified
    @@ -7,21 +7,9 @@ const (
     	LabelArcane  = "com.getarcaneapp.arcane"         // Identifies the Arcane container itself
     	LabelUpdater = "com.getarcaneapp.arcane.updater" // Enable/disable updates (true/false)
     
    -	// Lifecycle hook labels
    -	LabelPreCheck   = "com.getarcaneapp.arcane.lifecycle.pre-check"   // Command to run before checking for updates
    -	LabelPostCheck  = "com.getarcaneapp.arcane.lifecycle.post-check"  // Command to run after checking for updates
    -	LabelPreUpdate  = "com.getarcaneapp.arcane.lifecycle.pre-update"  // Command to run before stopping container
    -	LabelPostUpdate = "com.getarcaneapp.arcane.lifecycle.post-update" // Command to run after starting new container
    -
    -	// Lifecycle timeout labels (in seconds)
    -	LabelPreUpdateTimeout  = "com.getarcaneapp.arcane.lifecycle.pre-update-timeout"
    -	LabelPostUpdateTimeout = "com.getarcaneapp.arcane.lifecycle.post-update-timeout"
    -
     	// Dependency labels
     	LabelDependsOn  = "com.getarcaneapp.arcane.depends-on"  // Comma-separated list of container names this depends on
     	LabelStopSignal = "com.getarcaneapp.arcane.stop-signal" // Custom stop signal (e.g., SIGINT)
    -
    -	ExitCodeSkipUpdate = 75
     )
     
     // IsArcaneContainer checks if the container is the Arcane application itself
    @@ -59,24 +47,6 @@ func IsUpdateDisabled(labels map[string]string) bool {
     	return false
     }
     
    -// GetLifecycleCommand returns the lifecycle command for the given label, if set
    -func GetLifecycleCommand(labels map[string]string, lifecycleLabel string) []string {
    -	if labels == nil {
    -		return nil
    -	}
    -	for k, v := range labels {
    -		if strings.EqualFold(k, lifecycleLabel) {
    -			v = strings.TrimSpace(v)
    -			if v == "" {
    -				return nil
    -			}
    -			// Simple shell-style split (use /bin/sh -c for complex commands)
    -			return []string{"/bin/sh", "-c", v}
    -		}
    -	}
    -	return nil
    -}
    -
     // GetStopSignal returns the custom stop signal if set, otherwise empty string
     func GetStopSignal(labels map[string]string) string {
     	if labels == nil {
    
  • backend/internal/utils/arcaneupdater/labels_test.go+0 85 modified
    @@ -143,91 +143,6 @@ func TestIsUpdateDisabled(t *testing.T) {
     	}
     }
     
    -func TestGetLifecycleCommand(t *testing.T) {
    -	tests := []struct {
    -		name           string
    -		labels         map[string]string
    -		lifecycleLabel string
    -		want           []string
    -	}{
    -		{
    -			name:           "nil labels",
    -			labels:         nil,
    -			lifecycleLabel: LabelPreUpdate,
    -			want:           nil,
    -		},
    -		{
    -			name:           "empty labels",
    -			labels:         map[string]string{},
    -			lifecycleLabel: LabelPreUpdate,
    -			want:           nil,
    -		},
    -		{
    -			name:           "no matching label",
    -			labels:         map[string]string{"other": "value"},
    -			lifecycleLabel: LabelPreUpdate,
    -			want:           nil,
    -		},
    -		{
    -			name:           "empty command",
    -			labels:         map[string]string{LabelPreUpdate: ""},
    -			lifecycleLabel: LabelPreUpdate,
    -			want:           nil,
    -		},
    -		{
    -			name:           "whitespace only command",
    -			labels:         map[string]string{LabelPreUpdate: "   "},
    -			lifecycleLabel: LabelPreUpdate,
    -			want:           nil,
    -		},
    -		{
    -			name:           "valid command",
    -			labels:         map[string]string{LabelPreUpdate: "echo 'before update'"},
    -			lifecycleLabel: LabelPreUpdate,
    -			want:           []string{"/bin/sh", "-c", "echo 'before update'"},
    -		},
    -		{
    -			name:           "command with leading/trailing whitespace",
    -			labels:         map[string]string{LabelPreUpdate: "  echo test  "},
    -			lifecycleLabel: LabelPreUpdate,
    -			want:           []string{"/bin/sh", "-c", "echo test"},
    -		},
    -		{
    -			name:           "case insensitive label key",
    -			labels:         map[string]string{"COM.GETARCANEAPP.ARCANE.LIFECYCLE.PRE-UPDATE": "echo test"},
    -			lifecycleLabel: LabelPreUpdate,
    -			want:           []string{"/bin/sh", "-c", "echo test"},
    -		},
    -		{
    -			name:           "pre-check command",
    -			labels:         map[string]string{LabelPreCheck: "curl -f http://health"},
    -			lifecycleLabel: LabelPreCheck,
    -			want:           []string{"/bin/sh", "-c", "curl -f http://health"},
    -		},
    -		{
    -			name:           "post-update command",
    -			labels:         map[string]string{LabelPostUpdate: "docker exec -it nginx nginx -s reload"},
    -			lifecycleLabel: LabelPostUpdate,
    -			want:           []string{"/bin/sh", "-c", "docker exec -it nginx nginx -s reload"},
    -		},
    -	}
    -
    -	for _, tt := range tests {
    -		t.Run(tt.name, func(t *testing.T) {
    -			got := GetLifecycleCommand(tt.labels, tt.lifecycleLabel)
    -			if len(got) != len(tt.want) {
    -				t.Errorf("GetLifecycleCommand() len = %v, want %v", len(got), len(tt.want))
    -				return
    -			}
    -			for i := range got {
    -				if got[i] != tt.want[i] {
    -					t.Errorf("GetLifecycleCommand()[%d] = %v, want %v", i, got[i], tt.want[i])
    -				}
    -			}
    -		})
    -	}
    -}
    -
     func TestGetStopSignal(t *testing.T) {
     	tests := []struct {
     		name   string
    
  • backend/internal/utils/arcaneupdater/lifecycle.go+0 211 removed
    @@ -1,211 +0,0 @@
    -package arcaneupdater
    -
    -import (
    -	"bytes"
    -	"context"
    -	"fmt"
    -	"log/slog"
    -	"strconv"
    -	"strings"
    -	"time"
    -
    -	"github.com/docker/docker/api/types/container"
    -	"github.com/docker/docker/client"
    -)
    -
    -const (
    -	DefaultPreUpdateTimeout  = 60 * time.Second
    -	DefaultPostUpdateTimeout = 60 * time.Second
    -)
    -
    -// LifecycleHookResult contains the result of executing a lifecycle hook
    -type LifecycleHookResult struct {
    -	Executed   bool
    -	SkipUpdate bool // True if exit code was ExitCodeSkipUpdate (75)
    -	ExitCode   int
    -	Output     string
    -	Error      error
    -}
    -
    -// ExecutePreUpdateCommand runs the pre-update lifecycle hook on a container
    -// Returns SkipUpdate=true if the command exits with code 75 (EX_TEMPFAIL)
    -func ExecutePreUpdateCommand(ctx context.Context, dcli *client.Client, containerID string, labels map[string]string) LifecycleHookResult {
    -	cmd := GetLifecycleCommand(labels, LabelPreUpdate)
    -	if len(cmd) == 0 {
    -		return LifecycleHookResult{Executed: false}
    -	}
    -
    -	timeout := getTimeout(labels, LabelPreUpdateTimeout, DefaultPreUpdateTimeout)
    -
    -	slog.DebugContext(ctx, "ExecutePreUpdateCommand: running pre-update hook",
    -		"containerID", containerID,
    -		"command", cmd,
    -		"timeout", timeout)
    -
    -	return executeLifecycleCommand(ctx, dcli, containerID, cmd, timeout)
    -}
    -
    -// ExecutePostUpdateCommand runs the post-update lifecycle hook on a container
    -func ExecutePostUpdateCommand(ctx context.Context, dcli *client.Client, containerID string, labels map[string]string) LifecycleHookResult {
    -	cmd := GetLifecycleCommand(labels, LabelPostUpdate)
    -	if len(cmd) == 0 {
    -		return LifecycleHookResult{Executed: false}
    -	}
    -
    -	timeout := getTimeout(labels, LabelPostUpdateTimeout, DefaultPostUpdateTimeout)
    -
    -	slog.DebugContext(ctx, "ExecutePostUpdateCommand: running post-update hook",
    -		"containerID", containerID,
    -		"command", cmd,
    -		"timeout", timeout)
    -
    -	return executeLifecycleCommand(ctx, dcli, containerID, cmd, timeout)
    -}
    -
    -// ExecutePreCheckCommand runs the pre-check lifecycle hook on a container
    -func ExecutePreCheckCommand(ctx context.Context, dcli *client.Client, containerID string, labels map[string]string) LifecycleHookResult {
    -	cmd := GetLifecycleCommand(labels, LabelPreCheck)
    -	if len(cmd) == 0 {
    -		return LifecycleHookResult{Executed: false}
    -	}
    -
    -	return executeLifecycleCommand(ctx, dcli, containerID, cmd, DefaultPreUpdateTimeout)
    -}
    -
    -// ExecutePostCheckCommand runs the post-check lifecycle hook on a container
    -func ExecutePostCheckCommand(ctx context.Context, dcli *client.Client, containerID string, labels map[string]string) LifecycleHookResult {
    -	cmd := GetLifecycleCommand(labels, LabelPostCheck)
    -	if len(cmd) == 0 {
    -		return LifecycleHookResult{Executed: false}
    -	}
    -
    -	return executeLifecycleCommand(ctx, dcli, containerID, cmd, DefaultPostUpdateTimeout)
    -}
    -
    -func executeLifecycleCommand(ctx context.Context, dcli *client.Client, containerID string, cmd []string, timeout time.Duration) LifecycleHookResult {
    -	result := LifecycleHookResult{Executed: true}
    -
    -	// Create exec configuration
    -	execConfig := container.ExecOptions{
    -		Cmd:          cmd,
    -		AttachStdout: true,
    -		AttachStderr: true,
    -	}
    -
    -	// Create the exec instance
    -	execResp, err := dcli.ContainerExecCreate(ctx, containerID, execConfig)
    -	if err != nil {
    -		result.Error = fmt.Errorf("failed to create exec: %w", err)
    -		slog.WarnContext(ctx, "executeLifecycleCommand: failed to create exec",
    -			"containerID", containerID,
    -			"error", err)
    -		return result
    -	}
    -
    -	// Attach to the exec instance to get output
    -	attachResp, err := dcli.ContainerExecAttach(ctx, execResp.ID, container.ExecAttachOptions{})
    -	if err != nil {
    -		result.Error = fmt.Errorf("failed to attach to exec: %w", err)
    -		slog.WarnContext(ctx, "executeLifecycleCommand: failed to attach to exec",
    -			"containerID", containerID,
    -			"error", err)
    -		return result
    -	}
    -	defer attachResp.Close()
    -
    -	// Read output with timeout
    -	outputChan := make(chan []byte, 1)
    -	errChan := make(chan error, 1)
    -
    -	go func() {
    -		var buf bytes.Buffer
    -		_, err := buf.ReadFrom(attachResp.Reader)
    -		if err != nil {
    -			errChan <- err
    -			return
    -		}
    -		outputChan <- buf.Bytes()
    -	}()
    -
    -	// Wait for output or timeout
    -	timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
    -	defer cancel()
    -
    -	select {
    -	case <-timeoutCtx.Done():
    -		result.Error = fmt.Errorf("lifecycle command timed out after %v", timeout)
    -		slog.WarnContext(ctx, "executeLifecycleCommand: command timed out",
    -			"containerID", containerID,
    -			"timeout", timeout)
    -		return result
    -	case err := <-errChan:
    -		result.Error = fmt.Errorf("error reading output: %w", err)
    -		return result
    -	case output := <-outputChan:
    -		result.Output = string(output)
    -	}
    -
    -	// Inspect the exec to get exit code
    -	execInspect, err := dcli.ContainerExecInspect(ctx, execResp.ID)
    -	if err != nil {
    -		result.Error = fmt.Errorf("failed to inspect exec: %w", err)
    -		slog.WarnContext(ctx, "executeLifecycleCommand: failed to inspect exec",
    -			"containerID", containerID,
    -			"error", err)
    -		return result
    -	}
    -
    -	result.ExitCode = execInspect.ExitCode
    -
    -	// Check for skip update signal (exit code 75 = EX_TEMPFAIL)
    -	if result.ExitCode == ExitCodeSkipUpdate {
    -		result.SkipUpdate = true
    -		slog.InfoContext(ctx, "executeLifecycleCommand: container requested skip update",
    -			"containerID", containerID,
    -			"exitCode", result.ExitCode)
    -		return result
    -	}
    -
    -	// Non-zero exit code (other than 75) is an error
    -	if result.ExitCode != 0 {
    -		result.Error = fmt.Errorf("lifecycle command exited with code %d", result.ExitCode)
    -		slog.WarnContext(ctx, "executeLifecycleCommand: command failed",
    -			"containerID", containerID,
    -			"exitCode", result.ExitCode,
    -			"output", result.Output)
    -		return result
    -	}
    -
    -	slog.DebugContext(ctx, "executeLifecycleCommand: command completed successfully",
    -		"containerID", containerID,
    -		"exitCode", result.ExitCode)
    -
    -	return result
    -}
    -
    -func getTimeout(labels map[string]string, timeoutLabel string, defaultTimeout time.Duration) time.Duration {
    -	if labels == nil {
    -		return defaultTimeout
    -	}
    -
    -	for k, v := range labels {
    -		if k == timeoutLabel {
    -			v = strings.TrimSpace(v)
    -			if v == "" {
    -				return defaultTimeout
    -			}
    -
    -			// Try parsing as seconds
    -			if secs, err := strconv.Atoi(v); err == nil && secs > 0 {
    -				return time.Duration(secs) * time.Second
    -			}
    -
    -			// Try parsing as duration string
    -			if d, err := time.ParseDuration(v); err == nil && d > 0 {
    -				return d
    -			}
    -		}
    -	}
    -
    -	return defaultTimeout
    -}
    
  • backend/internal/utils/arcaneupdater/lifecycle_test.go+0 260 removed
    @@ -1,260 +0,0 @@
    -package arcaneupdater
    -
    -import (
    -	"context"
    -	"errors"
    -	"strings"
    -	"testing"
    -	"time"
    -)
    -
    -func TestGetTimeout(t *testing.T) {
    -	tests := []struct {
    -		name         string
    -		labels       map[string]string
    -		label        string
    -		defaultValue time.Duration
    -		want         time.Duration
    -	}{
    -		{
    -			name:         "no label - use default",
    -			labels:       map[string]string{},
    -			label:        LabelPreUpdateTimeout,
    -			defaultValue: 60 * time.Second,
    -			want:         60 * time.Second,
    -		},
    -		{
    -			name:         "nil labels - use default",
    -			labels:       nil,
    -			label:        LabelPreUpdateTimeout,
    -			defaultValue: 60 * time.Second,
    -			want:         60 * time.Second,
    -		},
    -		{
    -			name:         "valid timeout 30",
    -			labels:       map[string]string{LabelPreUpdateTimeout: "30"},
    -			label:        LabelPreUpdateTimeout,
    -			defaultValue: 60 * time.Second,
    -			want:         30 * time.Second,
    -		},
    -		{
    -			name:         "valid timeout 120",
    -			labels:       map[string]string{LabelPostUpdateTimeout: "120"},
    -			label:        LabelPostUpdateTimeout,
    -			defaultValue: 60 * time.Second,
    -			want:         120 * time.Second,
    -		},
    -		{
    -			name:         "invalid timeout - use default",
    -			labels:       map[string]string{LabelPreUpdateTimeout: "invalid"},
    -			label:        LabelPreUpdateTimeout,
    -			defaultValue: 60 * time.Second,
    -			want:         60 * time.Second,
    -		},
    -		{
    -			name:         "negative timeout - use default",
    -			labels:       map[string]string{LabelPreUpdateTimeout: "-10"},
    -			label:        LabelPreUpdateTimeout,
    -			defaultValue: 60 * time.Second,
    -			want:         60 * time.Second,
    -		},
    -		{
    -			name:         "zero timeout - use default",
    -			labels:       map[string]string{LabelPreUpdateTimeout: "0"},
    -			label:        LabelPreUpdateTimeout,
    -			defaultValue: 60 * time.Second,
    -			want:         60 * time.Second,
    -		},
    -		{
    -			name:         "timeout with whitespace",
    -			labels:       map[string]string{LabelPreUpdateTimeout: "  45  "},
    -			label:        LabelPreUpdateTimeout,
    -			defaultValue: 60 * time.Second,
    -			want:         45 * time.Second,
    -		},
    -	}
    -
    -	for _, tt := range tests {
    -		t.Run(tt.name, func(t *testing.T) {
    -			got := getTimeout(tt.labels, tt.label, tt.defaultValue)
    -			if got != tt.want {
    -				t.Errorf("getTimeout() = %v, want %v", got, tt.want)
    -			}
    -		})
    -	}
    -}
    -
    -func TestLifecycleHookResult_SkipUpdate(t *testing.T) {
    -	tests := []struct {
    -		name     string
    -		result   LifecycleHookResult
    -		wantSkip bool
    -	}{
    -		{
    -			name: "exit code 75 - should skip",
    -			result: LifecycleHookResult{
    -				Executed:   true,
    -				SkipUpdate: true,
    -				ExitCode:   ExitCodeSkipUpdate,
    -			},
    -			wantSkip: true,
    -		},
    -		{
    -			name: "exit code 0 - should not skip",
    -			result: LifecycleHookResult{
    -				Executed:   true,
    -				SkipUpdate: false,
    -				ExitCode:   0,
    -			},
    -			wantSkip: false,
    -		},
    -		{
    -			name: "not executed - should not skip",
    -			result: LifecycleHookResult{
    -				Executed:   false,
    -				SkipUpdate: false,
    -			},
    -			wantSkip: false,
    -		},
    -		{
    -			name: "error occurred - should not skip",
    -			result: LifecycleHookResult{
    -				Executed: true,
    -				ExitCode: 1,
    -				Error:    errors.New("command failed"),
    -			},
    -			wantSkip: false,
    -		},
    -	}
    -
    -	for _, tt := range tests {
    -		t.Run(tt.name, func(t *testing.T) {
    -			if got := tt.result.SkipUpdate; got != tt.wantSkip {
    -				t.Errorf("LifecycleHookResult.SkipUpdate = %v, want %v", got, tt.wantSkip)
    -			}
    -		})
    -	}
    -}
    -
    -func TestExecuteLifecycleCommands_NoCommand(t *testing.T) {
    -	ctx := context.Background()
    -
    -	// Test with no pre-update command
    -	result := ExecutePreUpdateCommand(ctx, nil, "test-container", map[string]string{})
    -	if result.Executed {
    -		t.Error("ExecutePreUpdateCommand() should not execute when no command is configured")
    -	}
    -
    -	// Test with no post-update command
    -	result = ExecutePostUpdateCommand(ctx, nil, "test-container", map[string]string{})
    -	if result.Executed {
    -		t.Error("ExecutePostUpdateCommand() should not execute when no command is configured")
    -	}
    -
    -	// Test with no pre-check command
    -	result = ExecutePreCheckCommand(ctx, nil, "test-container", map[string]string{})
    -	if result.Executed {
    -		t.Error("ExecutePreCheckCommand() should not execute when no command is configured")
    -	}
    -
    -	// Test with no post-check command
    -	result = ExecutePostCheckCommand(ctx, nil, "test-container", map[string]string{})
    -	if result.Executed {
    -		t.Error("ExecutePostCheckCommand() should not execute when no command is configured")
    -	}
    -}
    -
    -func TestLifecycleCommandOutput(t *testing.T) {
    -	tests := []struct {
    -		name         string
    -		output       string
    -		wantContains string
    -	}{
    -		{
    -			name:         "output with timestamp",
    -			output:       "2024-01-01T00:00:00Z Starting backup...",
    -			wantContains: "Starting backup",
    -		},
    -		{
    -			name:         "multi-line output",
    -			output:       "Line 1\nLine 2\nLine 3",
    -			wantContains: "Line 2",
    -		},
    -		{
    -			name:         "empty output",
    -			output:       "",
    -			wantContains: "",
    -		},
    -	}
    -
    -	for _, tt := range tests {
    -		t.Run(tt.name, func(t *testing.T) {
    -			result := LifecycleHookResult{
    -				Executed: true,
    -				Output:   tt.output,
    -			}
    -			if tt.wantContains != "" && !strings.Contains(result.Output, tt.wantContains) {
    -				t.Errorf("Output does not contain expected string: want %q in %q", tt.wantContains, result.Output)
    -			}
    -		})
    -	}
    -}
    -
    -func TestDefaultTimeouts(t *testing.T) {
    -	if DefaultPreUpdateTimeout != 60*time.Second {
    -		t.Errorf("DefaultPreUpdateTimeout = %v, want %v", DefaultPreUpdateTimeout, 60*time.Second)
    -	}
    -	if DefaultPostUpdateTimeout != 60*time.Second {
    -		t.Errorf("DefaultPostUpdateTimeout = %v, want %v", DefaultPostUpdateTimeout, 60*time.Second)
    -	}
    -}
    -
    -func TestExitCodeSkipUpdate(t *testing.T) {
    -	if ExitCodeSkipUpdate != 75 {
    -		t.Errorf("ExitCodeSkipUpdate = %d, want 75", ExitCodeSkipUpdate)
    -	}
    -}
    -
    -func TestLifecycleHookResult_ErrorHandling(t *testing.T) {
    -	tests := []struct {
    -		name      string
    -		result    LifecycleHookResult
    -		wantError bool
    -	}{
    -		{
    -			name: "no error",
    -			result: LifecycleHookResult{
    -				Executed: true,
    -				ExitCode: 0,
    -				Error:    nil,
    -			},
    -			wantError: false,
    -		},
    -		{
    -			name: "with error",
    -			result: LifecycleHookResult{
    -				Executed: true,
    -				ExitCode: 1,
    -				Error:    errors.New("command failed"),
    -			},
    -			wantError: true,
    -		},
    -		{
    -			name: "not executed no error",
    -			result: LifecycleHookResult{
    -				Executed: false,
    -				Error:    nil,
    -			},
    -			wantError: false,
    -		},
    -	}
    -
    -	for _, tt := range tests {
    -		t.Run(tt.name, func(t *testing.T) {
    -			hasError := tt.result.Error != nil
    -			if hasError != tt.wantError {
    -				t.Errorf("LifecycleHookResult has error = %v, want %v", hasError, tt.wantError)
    -			}
    -		})
    -	}
    -}
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.