diff --git a/.github/workflows/test-package-create.yml b/.github/workflows/test-package-create.yml index 8572a3389a..2c4e7e40cb 100644 --- a/.github/workflows/test-package-create.yml +++ b/.github/workflows/test-package-create.yml @@ -18,7 +18,7 @@ jobs: - name: Checkout uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: go-version-file: go.mod diff --git a/packages/zarf-registry/chart/templates/deployment.yaml b/packages/zarf-registry/chart/templates/deployment.yaml index e0e878eb82..f4263ca731 100644 --- a/packages/zarf-registry/chart/templates/deployment.yaml +++ b/packages/zarf-registry/chart/templates/deployment.yaml @@ -33,8 +33,11 @@ spec: {{- end }} priorityClassName: system-node-critical securityContext: - fsGroup: 1000 runAsUser: 1000 + fsGroup: 2000 + runAsGroup: 2000 + seccompProfile: + type: "RuntimeDefault" containers: - name: {{ .Chart.Name }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" @@ -53,6 +56,12 @@ spec: httpGet: path: / port: 5000 + securityContext: + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + runAsNonRoot: true + capabilities: + drop: ["ALL"] resources: {{ toYaml .Values.resources | indent 12 }} env: diff --git a/src/pkg/cluster/injector.go b/src/pkg/cluster/injector.go index 644e2a37ad..d7c1e579f6 100644 --- a/src/pkg/cluster/injector.go +++ b/src/pkg/cluster/injector.go @@ -297,6 +297,9 @@ func hasBlockingTaints(taints []corev1.Taint) bool { func buildInjectionPod(nodeName, image string, payloadCmNames []string, shasum string, resReq *v1ac.ResourceRequirementsApplyConfiguration) *v1ac.PodApplyConfiguration { // Initialize base volumes executeMode := int32(0777) + userID := int64(1000) + groupID := int64(2000) + fsGroupID := int64(2000) volumes := []*v1ac.VolumeApplyConfiguration{ v1ac.Volume(). WithName("init"). @@ -307,8 +310,7 @@ func buildInjectionPod(nodeName, image string, payloadCmNames []string, shasum s ), v1ac.Volume(). WithName("seed"). - WithEmptyDir(&v1ac.EmptyDirVolumeSourceApplyConfiguration{}), - } + WithEmptyDir(&v1ac.EmptyDirVolumeSourceApplyConfiguration{})} // Initialize base volume mounts volumeMounts := []*v1ac.VolumeMountApplyConfiguration{ @@ -345,6 +347,16 @@ func buildInjectionPod(nodeName, image string, payloadCmNames []string, shasum s v1ac.PodSpec(). WithNodeName(nodeName). WithRestartPolicy(corev1.RestartPolicyNever). + WithSecurityContext( + v1ac.PodSecurityContext(). + WithRunAsUser(userID). + WithRunAsGroup(groupID). + WithFSGroup(fsGroupID). + WithSeccompProfile( + v1ac.SeccompProfile(). + WithType(corev1.SeccompProfileTypeRuntimeDefault), + ), + ). WithContainers( v1ac.Container(). WithName("injector"). @@ -353,6 +365,13 @@ func buildInjectionPod(nodeName, image string, payloadCmNames []string, shasum s WithWorkingDir("/zarf-init"). WithCommand("/zarf-init/zarf-injector", shasum). WithVolumeMounts(volumeMounts...). + WithSecurityContext( + v1ac.SecurityContext(). + WithReadOnlyRootFilesystem(true). + WithAllowPrivilegeEscalation(false). + WithRunAsNonRoot(true). + WithCapabilities(v1ac.Capabilities().WithDrop(corev1.Capability("ALL"))), + ). WithReadinessProbe( v1ac.Probe(). WithPeriodSeconds(2). diff --git a/src/pkg/cluster/testdata/expected-injection-pod.json b/src/pkg/cluster/testdata/expected-injection-pod.json index 69b0d72562..bac5d41f86 100644 --- a/src/pkg/cluster/testdata/expected-injection-pod.json +++ b/src/pkg/cluster/testdata/expected-injection-pod.json @@ -1 +1 @@ -{"kind":"Pod","apiVersion":"v1","metadata":{"name":"injector","namespace":"zarf","labels":{"app":"zarf-injector","zarf.dev/agent":"ignore"}},"spec":{"volumes":[{"name":"init","configMap":{"name":"rust-binary","defaultMode":511}},{"name":"seed","emptyDir":{}},{"name":"foo","configMap":{"name":"foo"}},{"name":"bar","configMap":{"name":"bar"}}],"containers":[{"name":"injector","image":"docker.io/library/ubuntu:latest","command":["/zarf-init/zarf-injector","shasum"],"workingDir":"/zarf-init","resources":{"limits":{"cpu":"1","memory":"256Mi"},"requests":{"cpu":"500m","memory":"64Mi"}},"volumeMounts":[{"name":"init","mountPath":"/zarf-init/zarf-injector","subPath":"zarf-injector"},{"name":"seed","mountPath":"/zarf-seed"},{"name":"foo","mountPath":"/zarf-init/foo","subPath":"foo"},{"name":"bar","mountPath":"/zarf-init/bar","subPath":"bar"}],"readinessProbe":{"httpGet":{"path":"/v2/","port":5000},"periodSeconds":2,"successThreshold":1,"failureThreshold":10},"imagePullPolicy":"IfNotPresent"}],"restartPolicy":"Never","nodeName":"injection-node"}} +{"kind":"Pod","apiVersion":"v1","metadata":{"name":"injector","namespace":"zarf","labels":{"app":"zarf-injector","zarf.dev/agent":"ignore"}},"spec":{"volumes":[{"name":"init","configMap":{"name":"rust-binary","defaultMode":511}},{"name":"seed","emptyDir":{}},{"name":"foo","configMap":{"name":"foo"}},{"name":"bar","configMap":{"name":"bar"}}],"containers":[{"name":"injector","image":"docker.io/library/ubuntu:latest","command":["/zarf-init/zarf-injector","shasum"],"workingDir":"/zarf-init","resources":{"limits":{"cpu":"1","memory":"256Mi"},"requests":{"cpu":"500m","memory":"64Mi"}},"volumeMounts":[{"name":"init","mountPath":"/zarf-init/zarf-injector","subPath":"zarf-injector"},{"name":"seed","mountPath":"/zarf-seed"},{"name":"foo","mountPath":"/zarf-init/foo","subPath":"foo"},{"name":"bar","mountPath":"/zarf-init/bar","subPath":"bar"}],"readinessProbe":{"httpGet":{"path":"/v2/","port":5000},"periodSeconds":2,"successThreshold":1,"failureThreshold":10},"imagePullPolicy":"IfNotPresent","securityContext":{"capabilities":{"drop":["ALL"]},"runAsNonRoot":true,"readOnlyRootFilesystem":true,"allowPrivilegeEscalation":false}}],"restartPolicy":"Never","nodeName":"injection-node","securityContext":{"runAsUser":1000,"runAsGroup":2000,"fsGroup":2000,"seccompProfile":{"type":"RuntimeDefault"}}}} diff --git a/src/pkg/logger/logger.go b/src/pkg/logger/logger.go new file mode 100644 index 0000000000..41400666d8 --- /dev/null +++ b/src/pkg/logger/logger.go @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +// Package logger implements a log/slog based logger in Zarf. +package logger + +import ( + "fmt" + "io" + "log/slog" + "os" + "strings" + "sync/atomic" +) + +var defaultLogger atomic.Pointer[slog.Logger] + +// init sets a logger with default config when the package is initialized. +func init() { + l, _ := New(ConfigDefault()) //nolint:errcheck + SetDefault(l) +} + +// Level declares each supported log level. These are 1:1 what log/slog supports by default. Info is the default level. +type Level int + +// Store names for Levels +var ( + Debug = Level(slog.LevelDebug) // -4 + Info = Level(slog.LevelInfo) // 0 + Warn = Level(slog.LevelWarn) // 4 + Error = Level(slog.LevelError) // 8 +) + +// validLevels is a set that provides an ergonomic way to check if a level is a member of the set. +var validLevels = map[Level]bool{ + Debug: true, + Info: true, + Warn: true, + Error: true, +} + +// strLevels maps a string to its Level. +var strLevels = map[string]Level{ + "debug": Debug, + "info": Info, + "warn": Warn, + "error": Error, +} + +// ParseLevel takes a string representation of a Level, ensure it exists, and then converts it into a Level. +func ParseLevel(s string) (Level, error) { + k := strings.ToLower(s) + l, ok := strLevels[k] + if !ok { + return 0, fmt.Errorf("invalid log level: %s", k) + } + return l, nil +} + +// Format declares the kind of logging handler to use. An empty Format defaults to text. +type Format string + +// ToLower takes a Format string and converts it to lowercase for case-agnostic validation. Users shouldn't have to care +// about "json" vs. "JSON" for example - they should both work. +func (f Format) ToLower() Format { + return Format(strings.ToLower(string(f))) +} + +// TODO(mkcp): Add dev format +var ( + // FormatText uses the standard slog TextHandler + FormatText Format = "text" + // FormatJSON uses the standard slog JSONHandler + FormatJSON Format = "json" + // FormatNone sends log writes to DestinationNone / io.Discard + FormatNone Format = "none" +) + +// More printers would be great, like dev format https://github.com/golang-cz/devslog +// and a pretty console slog https://github.com/phsym/console-slog + +// Destination declares an io.Writer to send logs to. +type Destination io.Writer + +var ( + // DestinationDefault points to Stderr + DestinationDefault Destination = os.Stderr + // DestinationNone discards logs as they are received + DestinationNone Destination = io.Discard +) + +// Config is configuration for a logger. +type Config struct { + // Level sets the log level. An empty value corresponds to Info aka 0. + Level + Format + Destination +} + +// ConfigDefault returns a Config with defaults like Text formatting at Info level writing to Stderr. +func ConfigDefault() Config { + return Config{ + Level: Info, + Format: FormatText, + Destination: DestinationDefault, // Stderr + } +} + +// New takes a Config and returns a validated logger. +func New(cfg Config) (*slog.Logger, error) { + var handler slog.Handler + opts := slog.HandlerOptions{} + + // Use default destination if none + if cfg.Destination == nil { + cfg.Destination = DestinationDefault + } + + // Check that we have a valid log level. + if !validLevels[cfg.Level] { + return nil, fmt.Errorf("unsupported log level: %d", cfg.Level) + } + opts.Level = slog.Level(cfg.Level) + + switch cfg.Format.ToLower() { + // Use Text handler if no format provided + case "", FormatText: + handler = slog.NewTextHandler(cfg.Destination, &opts) + case FormatJSON: + handler = slog.NewJSONHandler(cfg.Destination, &opts) + // TODO(mkcp): Add dev format + // case FormatDev: + // handler = slog.NewTextHandler(DestinationNone, &slog.HandlerOptions{ + // AddSource: true, + // }) + case FormatNone: + handler = slog.NewTextHandler(DestinationNone, &slog.HandlerOptions{}) + // Format not found, let's error out + default: + return nil, fmt.Errorf("unsupported log format: %s", cfg.Format) + } + + log := slog.New(handler) + return log, nil +} + +// Default retrieves a logger from the package default. This is intended as a fallback when a logger cannot easily be +// passed in as a dependency, like when developing a new function. Use it like you would use context.TODO(). +func Default() *slog.Logger { + return defaultLogger.Load() +} + +// SetDefault takes a logger and atomically stores it as the package default. This is intended to be called when the +// application starts to override the default config with application-specific config. See Default() for more usage +// details. +func SetDefault(l *slog.Logger) { + defaultLogger.Store(l) +} diff --git a/src/pkg/logger/logger_test.go b/src/pkg/logger/logger_test.go new file mode 100644 index 0000000000..db8851e25c --- /dev/null +++ b/src/pkg/logger/logger_test.go @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +// Package logger implements a log/slog based logger in Zarf. +package logger + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_New(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + cfg Config + }{ + { + name: "Empty level, format, and destination are ok", + cfg: Config{}, + }, + { + name: "Default config is ok", + cfg: ConfigDefault(), + }, + { + name: "Debug logs are ok", + cfg: Config{ + Level: Debug, + }, + }, + { + name: "Info logs are ok", + cfg: Config{ + Level: Info, + }, + }, + { + name: "Warn logs are ok", + cfg: Config{ + Level: Warn, + }, + }, + { + name: "Error logs are ok", + cfg: Config{ + Level: Error, + }, + }, + { + name: "Text format is supported", + cfg: Config{ + Format: FormatText, + }, + }, + { + name: "JSON format is supported", + cfg: Config{ + Format: FormatJSON, + }, + }, + { + name: "FormatNone is supported to disable logs", + cfg: Config{ + Format: FormatNone, + }, + }, + { + name: "DestinationNone is supported to disable logs", + cfg: Config{ + Destination: DestinationNone, + }, + }, + { + name: "users can send logs to any io.Writer", + cfg: Config{ + Destination: os.Stdout, + }, + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + res, err := New(tc.cfg) + require.NoError(t, err) + require.NotNil(t, res) + }) + } +} + +func Test_NewErrors(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + cfg Config + }{ + { + name: "unsupported log level errors", + cfg: Config{ + Level: 3, + }, + }, + { + name: "wildly unsupported log level errors", + cfg: Config{ + Level: 42389412389213489, + }, + }, + { + name: "unsupported format errors", + cfg: Config{ + Format: "foobar", + }, + }, + { + name: "wildly unsupported format errors", + cfg: Config{ + Format: "^\\w+([-+.']\\w+)*@\\w+([-.]\\w+)*\\.\\w+([-.]\\w+)*$ lorem ipsum dolor sit amet 243897 )*&($#", + }, + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + res, err := New(tc.cfg) + require.Error(t, err) + require.Nil(t, res) + }) + } +} + +func Test_ParseLevel(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + s string + expect Level + }{ + { + name: "can parse debug", + s: "debug", + expect: Debug, + }, + { + name: "can parse info", + s: "Info", + expect: Info, + }, + { + name: "can parse warn", + s: "warn", + expect: Warn, + }, + { + name: "can parse error", + s: "error", + expect: Error, + }, + { + name: "can handle uppercase", + s: "ERROR", + expect: Error, + }, + { + name: "can handle inconsistent uppercase", + s: "errOR", + expect: Error, + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + res, err := ParseLevel(tc.s) + require.NoError(t, err) + require.Equal(t, tc.expect, res) + }) + } +} + +func Test_ParseLevelErrors(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + s string + }{ + { + name: "errors out on unknown level", + s: "SUPER-DEBUG-10x-supremE", + }, + { + name: "is precise about character variations", + s: "érrør", + }, + { + name: "does not partial match level", + s: "error-info", + }, + { + name: "does not partial match level 2", + s: "info-error", + }, + { + name: "does not partial match level 3", + s: "info2", + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + _, err := ParseLevel(tc.s) + require.Error(t, err) + }) + } +}