Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions pkg/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ import (
yaml "gopkg.in/yaml.v3"
)

// MaxConfigFileSize is the maximum allowed size for YAML config and scenario files (1 MB).
// This prevents denial-of-service via oversized or malicious YAML files (e.g., YAML bombs).
const MaxConfigFileSize = 1 * 1024 * 1024

// validateFileSize checks that the file at the given path does not exceed MaxConfigFileSize.
func validateFileSize(path string) error {
info, err := os.Stat(path)
if err != nil {
return err
}
if info.Size() > MaxConfigFileSize {
return fmt.Errorf("file '%s' is too large (%d bytes, max %d bytes). This limit prevents DoS via oversized YAML",
path, info.Size(), MaxConfigFileSize)
}
return nil
}

func setDefaults(cfg *ChaosConfig) {
if cfg.Safety.MaxDown == 0 {
cfg.Safety.MaxDown = 1
Expand Down Expand Up @@ -47,6 +64,9 @@ func LoadConfig(path string) (*ChaosConfig, error) {
if path == "" {
path = "chaos.yaml"
}
if err := validateFileSize(path); err != nil {
return nil, fmt.Errorf("config file validation failed: %w", err)
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("config file not found: '%s'\n → Copy chaos.example.yaml to chaos.yaml and edit it", path)
Expand All @@ -66,6 +86,9 @@ func LoadConfig(path string) (*ChaosConfig, error) {
}

func LoadScenario(path string) (*ScenarioConfig, error) {
if err := validateFileSize(path); err != nil {
return nil, fmt.Errorf("scenario file validation failed: %w", err)
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("scenario file not found: '%s'", path)
Expand Down
33 changes: 27 additions & 6 deletions pkg/engine/kubernetes_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,21 @@ func NewKubernetesClient(allowedTargets []string) (*KubernetesClient, error) {
return nil, fmt.Errorf("refusing to run in production environment. Set ENTROPY_ALLOW_PRODUCTION=true to override")
}

// Build config and clientset from a single source of truth.
// Previously, buildK8sConfig() and newK8sClientSet() were called separately,
// creating two independent configs that could theoretically diverge.
config, err := buildK8sConfig()
if err != nil {
return nil, err
}

clientset, ns, err := newK8sClientSet("")
clientset, err := kubernetes.NewForConfig(config)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to create kubernetes client: %w", err)
}

ns := resolveNamespace("")

var allowed map[string]bool
if allowedTargets != nil {
allowed = make(map[string]bool)
Expand Down Expand Up @@ -225,22 +230,38 @@ func (k *KubernetesClient) injectEphemeralNetshoot(ctx context.Context, pod *cor
}
}

// Make the netshoot image configurable for air-gapped environments
// Make the netshoot image configurable for air-gapped environments.
// SECURITY: Default is pinned to a specific version tag instead of :latest
// to prevent supply chain attacks via mutable image tags.
// For maximum security, use a digest: ENTROPY_NETSHOOT_IMAGE=nicolaka/netshoot@sha256:<hash>
netshootImage := os.Getenv("ENTROPY_NETSHOOT_IMAGE")
if netshootImage == "" {
netshootImage = "nicolaka/netshoot:latest"
netshootImage = "nicolaka/netshoot:v0.13"
}

// Build the ephemeral container spec
// SECURITY: Hardened security context for the ephemeral container.
// NET_ADMIN is the only capability granted (required for tc/netem).
// All other capabilities are explicitly dropped to minimize the attack surface.
allowPrivEsc := false
readOnlyFS := true
runAsNonRoot := true
var runAsUser int64 = 1000

// Build the ephemeral container spec with hardened security
ec := corev1.EphemeralContainer{
EphemeralContainerCommon: corev1.EphemeralContainerCommon{
Name: "chaos-netshoot",
Image: netshootImage,
ImagePullPolicy: corev1.PullIfNotPresent,
SecurityContext: &corev1.SecurityContext{
Capabilities: &corev1.Capabilities{
Add: []corev1.Capability{"NET_ADMIN"},
Add: []corev1.Capability{"NET_ADMIN"},
Drop: []corev1.Capability{"ALL"},
},
AllowPrivilegeEscalation: &allowPrivEsc,
ReadOnlyRootFilesystem: &readOnlyFS,
RunAsNonRoot: &runAsNonRoot,
RunAsUser: &runAsUser,
},
TTY: false,
Stdin: false,
Expand Down
53 changes: 53 additions & 0 deletions pkg/engine/probes.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,56 @@ package engine
import (
"context"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"path/filepath"
"strings"
"time"

"github.com/ibrahimkizilarslan/entropy/pkg/config"
)

// blockedExecCommands contains executable names that are blocked from exec probes
// to prevent remote code execution via user-supplied scenario YAML files.
// These commands can be used to establish reverse shells, exfiltrate data,
// or pivot within the network.
var blockedExecCommands = []string{
"sh", "bash", "zsh", "ash", "csh", "ksh", "dash", "fish", // Shells
"curl", "wget", // Data exfiltration / download
"nc", "ncat", "netcat", "socat", // Network pivoting
"python", "python3", "python2", "perl", "ruby", "node", // Script interpreters
"php", "lua", // Script interpreters
"chmod", "chown", "chroot", // Permission manipulation
"mount", "umount", // Filesystem manipulation
"dd", "mkfs", // Disk operations
"rm", "rmdir", // Destructive operations
"nslookup", "dig", // DNS reconnaissance
"ssh", "scp", "sftp", // Remote access
}

// validateExecCommand checks that the command's base executable is not in the blocklist.
// This prevents using exec probes as an RCE vector through scenario YAML files.
func validateExecCommand(cmdParts []string) error {
if len(cmdParts) == 0 {
return fmt.Errorf("empty exec command")
}

// Extract the base name of the executable (handles absolute paths like /bin/sh)
executable := filepath.Base(cmdParts[0])

for _, blocked := range blockedExecCommands {
if strings.EqualFold(executable, blocked) {
return fmt.Errorf("exec probe command '%s' is blocked for security. "+
"Blocked executables include shells, interpreters, and network tools. "+
"Use simple diagnostic commands like 'cat', 'ls', 'stat', or 'test'", executable)
}
}

return nil
}

type ProbeResult struct {
Success bool
Message string
Expand Down Expand Up @@ -215,6 +256,18 @@ func runExecProbe(ctx context.Context, spec *config.ProbeSpec, runtime Container
return ProbeResult{Success: false, Message: "empty exec command"}
}

// Security: block dangerous executables to prevent RCE via scenario YAML
if err := validateExecCommand(cmdParts); err != nil {
return ProbeResult{Success: false, Message: fmt.Sprintf("security: %v", err)}
}

// Audit logging: log every exec probe invocation for security monitoring
slog.Warn("EXEC_PROBE",
slog.String("target", spec.Target),
slog.String("command", spec.Command),
slog.String("executable", filepath.Base(cmdParts[0])),
)

exitCode, err := runtime.ExecCommand(ctx, spec.Target, cmdParts)
if err != nil {
return ProbeResult{Success: false, Message: fmt.Sprintf("Exec failed: %v", err)}
Expand Down
Loading