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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,9 @@ controls that customers can map into their own security and compliance program.
The Helm chart renders default-deny egress NetworkPolicies for selected pods
when `networkPolicy.enabled=true`. It can also create a gVisor `RuntimeClass`
and register a mutating webhook for labeled pods. The cluster must provide an
enforcing CNI and install `runsc`. See [docs/07-security.md](docs/07-security.md).
enforcing CNI and install `runsc`. `agentctl doctor network-policy` reports
passive CNI evidence. Its `--active-probe` mode tests scratch-namespace packet
flow when given an updated operator image. See [docs/07-security.md](docs/07-security.md).

---

Expand Down
66 changes: 66 additions & 0 deletions cmd/agentctl/doctor_sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,25 @@ import (
"errors"
"fmt"
"io"
"time"

"github.com/spf13/cobra"
corev1 "k8s.io/api/core/v1"
nodev1 "k8s.io/api/node/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/client"

"github.com/Clawdlinux/agentic-operator-core/internal/admission"
"github.com/Clawdlinux/agentic-operator-core/internal/netpolicy"
)

func newDoctorCommand(opts *cliOptions) *cobra.Command {
command := &cobra.Command{Use: "doctor", Short: "Check cluster readiness"}
command.AddCommand(newSandboxDoctorCommand(opts))
command.AddCommand(newNetworkPolicyDoctorCommand(opts))
return command
}

Expand Down Expand Up @@ -85,3 +90,64 @@ func runSandboxDoctor(ctx context.Context, reader client.Reader, runtimeClassNam
}
return errors.New("sandbox is not ready")
}

func newNetworkPolicyDoctorCommand(opts *cliOptions) *cobra.Command {
var activeProbe bool
var probeImage string
var timeout time.Duration
var allowKnownNonEnforcing bool
var keepNamespace bool
command := &cobra.Command{
Use: "network-policy",
Short: "Detect NetworkPolicy CNI support or run an explicit packet-flow probe",
RunE: func(command *cobra.Command, _ []string) error {
return runNetworkPolicyDoctor(command.Context(), opts.kube, opts.discovery, networkPolicyDoctorOptions{activeProbe: activeProbe, probeImage: probeImage, timeout: timeout, keepNamespace: keepNamespace, allowKnownNonEnforcing: allowKnownNonEnforcing}, command.OutOrStdout())
},
}
command.Flags().BoolVar(&activeProbe, "active-probe", false, "Create short-lived probe Pods and a deny-egress NetworkPolicy")
command.Flags().StringVar(&probeImage, "probe-image", "", "Operator image that includes the netprobe command (required with --active-probe)")
command.Flags().DurationVar(&timeout, "timeout", 90*time.Second, "Maximum duration for the active probe (110s maximum)")
command.Flags().BoolVar(&allowKnownNonEnforcing, "probe-known-non-enforcing", false, "Allow active probing despite a known non-enforcing CNI fingerprint")
command.Flags().BoolVar(&keepNamespace, "keep-namespace", false, "Keep the probe namespace for inspection")
return command
}

type networkPolicyDoctorOptions struct {
activeProbe bool
probeImage string
timeout time.Duration
keepNamespace bool
allowKnownNonEnforcing bool
}

func runNetworkPolicyDoctor(ctx context.Context, kube kubernetes.Interface, discoveryClient netpolicy.DiscoveryClient, options networkPolicyDoctorOptions, writer io.Writer) error {
if kube == nil || discoveryClient == nil {
return errors.New("kubernetes clients not initialized; check kubeconfig")
}
daemonSets := kube.AppsV1().DaemonSets(metav1.NamespaceSystem)
detection, err := netpolicy.DetectCNIEnforcement(ctx, discoveryClient, daemonSets)
if err != nil {
return fmt.Errorf("detect NetworkPolicy enforcement: %w", err)
}
if !options.activeProbe {
_, _ = fmt.Fprintf(writer, "NetworkPolicy CNI: %s\nConfigured only. Active packet probe not run.\n", detection.Enforcement)
return nil
}
result, err := netpolicy.RunActiveProbe(ctx, kube, discoveryClient, daemonSets, netpolicy.ActiveProbeOptions{ProbeImage: options.probeImage, Timeout: options.timeout, KeepNamespace: options.keepNamespace, AllowKnownNonEnforcing: options.allowKnownNonEnforcing})
if err != nil {
return err
}
_, _ = fmt.Fprintf(writer, "NetworkPolicy probe: %s\n%s\n", result.Verdict, result.Reason)
if result.Namespace != "" {
_, _ = fmt.Fprintf(writer, "Probe namespace: %s\n", result.Namespace)
if result.Cleaned {
_, _ = fmt.Fprintln(writer, "Cleanup: namespace deletion accepted")
} else if options.keepNamespace {
_, _ = fmt.Fprintln(writer, "Cleanup: namespace kept for inspection")
}
}
if result.Verdict != netpolicy.ProbeVerdictEnforcing {
return fmt.Errorf("NetworkPolicy probe result: %s: %s", result.Verdict, result.Reason)
}
Comment thread
shreyanshjain7174 marked this conversation as resolved.
return nil
}
55 changes: 55 additions & 0 deletions cmd/agentctl/doctor_sandbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
nodev1 "k8s.io/api/node/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
kubefake "k8s.io/client-go/kubernetes/fake"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)
Expand Down Expand Up @@ -124,3 +125,57 @@ func TestNewDoctorCommandIncludesSandbox(t *testing.T) {
t.Fatalf("doctor command Use = %q", command.Use)
}
}

func TestNewDoctorCommandIncludesNetworkPolicy(t *testing.T) {
command := newDoctorCommand(&cliOptions{})
child, _, err := command.Find([]string{"network-policy"})
if err != nil || child == nil || child.Name() != "network-policy" {
t.Fatalf("network-policy command = %v, %v, want network-policy", child, err)
}
if flag := child.Flags().Lookup("active-probe"); flag == nil || flag.DefValue != "false" {
t.Fatalf("active-probe flag = %#v, want default false", flag)
}
}

func TestRunNetworkPolicyDoctorPassiveDoesNotCreateResources(t *testing.T) {
kube := kubefake.NewClientset()
var output bytes.Buffer
err := runNetworkPolicyDoctor(context.Background(), kube, doctorNetworkPolicyDiscovery{groups: []metav1.APIGroup{{Name: "cilium.io"}}}, networkPolicyDoctorOptions{}, &output)
if err != nil {
t.Fatalf("runNetworkPolicyDoctor() error = %v", err)
}
if output.String() != "NetworkPolicy CNI: Enforcing\nConfigured only. Active packet probe not run.\n" {
t.Fatalf("output = %q", output.String())
}
for _, action := range kube.Actions() {
if action.GetVerb() == "create" || action.GetVerb() == "delete" || action.GetVerb() == "patch" || action.GetVerb() == "update" {
t.Fatalf("passive command performed mutation: %#v", action)
}
}
}

func TestRunNetworkPolicyDoctorRequiresImageBeforeActiveProbe(t *testing.T) {
kube := kubefake.NewClientset()
var output bytes.Buffer
err := runNetworkPolicyDoctor(context.Background(), kube, doctorNetworkPolicyDiscovery{}, networkPolicyDoctorOptions{activeProbe: true}, &output)
if err == nil {
t.Fatal("runNetworkPolicyDoctor() error = nil, want missing image error")
}
for _, action := range kube.Actions() {
if action.GetVerb() == "create" || action.GetVerb() == "delete" || action.GetVerb() == "patch" || action.GetVerb() == "update" {
t.Fatalf("active command without image performed mutation: %#v", action)
}
}
}

type doctorNetworkPolicyDiscovery struct {
groups []metav1.APIGroup
err error
}

func (d doctorNetworkPolicyDiscovery) ServerGroups() (*metav1.APIGroupList, error) {
if d.err != nil {
return nil, d.err
}
return &metav1.APIGroupList{Groups: d.groups}, nil
}
5 changes: 5 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import (
runtimeadmission "github.com/Clawdlinux/agentic-operator-core/internal/admission"
"github.com/Clawdlinux/agentic-operator-core/internal/controller"
"github.com/Clawdlinux/agentic-operator-core/internal/netpolicy"
"github.com/Clawdlinux/agentic-operator-core/internal/netpolicy/netprobe"
"github.com/Clawdlinux/agentic-operator-core/pkg/evaluation"
"github.com/Clawdlinux/agentic-operator-core/pkg/finops"
"github.com/Clawdlinux/agentic-operator-core/pkg/governance"
Expand All @@ -81,6 +82,10 @@ func init() {

// nolint:gocyclo
func main() {
if len(os.Args) > 1 && os.Args[1] == "netprobe" {
os.Exit(netprobe.Run(os.Args[2:], os.Stdout, os.Stderr))
}

var metricsAddr string
var metricsCertPath, metricsCertName, metricsCertKey string
var webhookCertPath, webhookCertName, webhookCertKey string
Expand Down
11 changes: 11 additions & 0 deletions docs/07-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,17 @@ external OPA (when configured). Operator-supplied additions go under
Managed workload namespaces require separate policy application and matching labels.
Optional Cilium FQDN policies require Cilium and explicit chart configuration.

Run `agentctl doctor network-policy` for passive CNI detection. It does not
verify packet enforcement. To run an intrusive scratch-namespace test, use:

```bash
agentctl doctor network-policy --active-probe --probe-image <updated-operator-image>
```

The active probe requires your kubeconfig identity to create and delete a
namespace, Pods, a Service, and a NetworkPolicy. It proves only that a
deny-egress policy blocked TCP to a test Service in the scratch namespace.

## Sandbox Technology

Agent code should be treated as untrusted because the agent's instructions
Expand Down
6 changes: 6 additions & 0 deletions docs/10-troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ Confirm:

Policy-object presence alone does not prove packet enforcement.

Use `agentctl doctor network-policy` to inspect passive CNI evidence. To test
packet flow, run `agentctl doctor network-policy --active-probe --probe-image
<updated-operator-image>`. The command creates and deletes a scratch namespace.
It verifies only the probe policy and test Service. It does not verify your
production policy's selectors or allow-list.

## Audit Verification

`audit-verify` currently supports JSONL. The ClickHouse source adapter is a stub.
Expand Down
106 changes: 106 additions & 0 deletions internal/netpolicy/netprobe/runner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package netprobe

import (
"context"
"flag"
"fmt"
"io"
"net"
"strconv"
"time"
)

const (
ExitSuccess = 0
ExitDialFailed = 10
ExitDNSFailed = 20
ExitInvalidArgs = 30
ExitServerFailed = 40
defaultDialTimeout = 5 * time.Second
defaultServeTimeout = 2 * time.Minute
)

// Run executes the minimal TCP server or client used by the active policy probe.
func Run(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
_, _ = fmt.Fprintln(stderr, "usage: netprobe serve|dial")
return ExitInvalidArgs
}
switch args[0] {
case "serve":
return runServeCommand(args[1:], stderr)
case "dial":
return runDialCommand(args[1:], stdout, stderr)
default:
_, _ = fmt.Fprintf(stderr, "unknown netprobe command %q\n", args[0])
return ExitInvalidArgs
}
}

func runServeCommand(args []string, stderr io.Writer) int {
flags := flag.NewFlagSet("serve", flag.ContinueOnError)
flags.SetOutput(stderr)
address := flags.String("address", ":18443", "TCP listen address")
if err := flags.Parse(args); err != nil || flags.NArg() != 0 {
return ExitInvalidArgs
}
ctx, cancel := context.WithTimeout(context.Background(), defaultServeTimeout)
defer cancel()
if err := serve(ctx, *address); err != nil {
_, _ = fmt.Fprintln(stderr, err)
return ExitServerFailed
}
return ExitSuccess
}

func serve(ctx context.Context, address string) error {
listener, err := net.Listen("tcp", address)
if err != nil {
return fmt.Errorf("listen: %w", err)
}
defer listener.Close()

go func() {
<-ctx.Done()
_ = listener.Close()
}()
for {
connection, err := listener.Accept()
if err != nil {
if ctx.Err() != nil {
return nil
}
return fmt.Errorf("accept: %w", err)
}
_ = connection.Close()
}
}

func runDialCommand(args []string, stdout, stderr io.Writer) int {
if len(args) != 2 {
_, _ = fmt.Fprintln(stderr, "usage: netprobe dial <host> <port>")
return ExitInvalidArgs
}
if _, err := strconv.ParseUint(args[1], 10, 16); err != nil {
_, _ = fmt.Fprintf(stderr, "invalid TCP port %q\n", args[1])
return ExitInvalidArgs
}
ctx, cancel := context.WithTimeout(context.Background(), defaultDialTimeout)
defer cancel()
return dial(ctx, args[0], args[1], stdout, stderr)
}

func dial(ctx context.Context, host, port string, stdout, stderr io.Writer) int {
if _, err := net.DefaultResolver.LookupHost(ctx, host); err != nil {
_, _ = fmt.Fprintf(stderr, "resolve %s: %v\n", host, err)
return ExitDNSFailed
}
connection, err := (&net.Dialer{}).DialContext(ctx, "tcp", net.JoinHostPort(host, port))
if err != nil {
_, _ = fmt.Fprintf(stderr, "dial %s:%s: %v\n", host, port, err)
return ExitDialFailed
}
defer connection.Close()
_, _ = fmt.Fprintln(stdout, "connected")
return ExitSuccess
}
50 changes: 50 additions & 0 deletions internal/netpolicy/netprobe/runner_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package netprobe

import (
"bytes"
"context"
"net"
"strconv"
"testing"
"time"
)

func TestRunDial(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
go func() {
connection, err := listener.Accept()
if err == nil {
_ = connection.Close()
}
}()

port := strconv.Itoa(listener.Addr().(*net.TCPAddr).Port)
var stdout, stderr bytes.Buffer
if exitCode := Run([]string{"dial", "127.0.0.1", port}, &stdout, &stderr); exitCode != ExitSuccess {
t.Fatalf("Run(dial) = %d, stderr=%q", exitCode, stderr.String())
}
if stdout.String() != "connected\n" {
t.Fatalf("stdout = %q, want connected", stdout.String())
}
}

func TestDialReturnsDNSFailure(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if exitCode := dial(ctx, "does-not-exist.invalid", "18443", &bytes.Buffer{}, &bytes.Buffer{}); exitCode != ExitDNSFailed {
t.Fatalf("dial() = %d, want %d", exitCode, ExitDNSFailed)
}
}

func TestRunRejectsInvalidArguments(t *testing.T) {
if exitCode := Run(nil, &bytes.Buffer{}, &bytes.Buffer{}); exitCode != ExitInvalidArgs {
t.Fatalf("Run() = %d, want %d", exitCode, ExitInvalidArgs)
}
if exitCode := Run([]string{"dial", "localhost"}, &bytes.Buffer{}, &bytes.Buffer{}); exitCode != ExitInvalidArgs {
t.Fatalf("Run(dial) = %d, want %d", exitCode, ExitInvalidArgs)
}
}
Loading
Loading