Wardengate

Operate

Command filtering

Command filtering is the guardrail that runs inside an already-admitted session. Wardengate sits on the wire between the user and the target, which means it can see each command before it reaches the host — and it can decide whether to let it pass, alert on it, quietly log it, or drop it with a clear error back to the user. The goal is not to replace judgment; it is to keep the destructive accidents (rogue rm -rf, a schema-migrating DROP TABLE, an unreviewed kubectl delete ns prod) out of production.

How filtering works

Each session attaches a CommandPolicy — either directly from the access policy that admitted the session or resolved from a tag on the target. As commands or statements stream through the gateway, the protocol module parses them (shell parser for SSH, SQL parser for database, argv parser for kubectl/aws/etc.) and the policy engine matches them against the rules in the attached CommandPolicy. The first matching rule wins.

Actions are small and explicit:

  • allow — forward the command as-is.
  • deny — drop the command before it reaches the host, return a user-visible error.
  • alert — forward the command, emit a high-severity event, optionally notify a channel.
  • approve — pause the session, request just-in-time approval from a reviewer, resume only on accept.
  • warn— forward but surface a banner to the user (“you’re running a destructive verb, proceed?”).

SSH allow/deny lists

SSH filtering parses the shell command line with a permissive tokenizer — backticks and $(...) substitutions are unwound, pipelines are expanded into their component commands, and aliases resolved by the target shell are replayed so evasive one-liners do not slip past.

apiVersion: wardengate/v1
kind: CommandPolicy
metadata:
  name: prod-ssh-rails
spec:
  protocols: [ssh]
  rules:
    - name: block-disk-wipes
      match:
        cmd: "rm"
        argv: ["-rf", "/*"]
      action: deny
      message: "full-root wipe blocked by policy prod-ssh-rails"

    - name: warn-on-sudo-i
      match:
        cmd: "sudo"
        argv: ["-i"]
      action: warn

    - name: approve-user-mgmt
      match:
        cmd: ["useradd", "userdel", "groupadd", "passwd"]
      action: approve
      approvers: ["group:sre-leads"]

    - name: allow-standard-tools
      match:
        cmd: ["ls", "cat", "less", "grep", "tail", "journalctl", "systemctl"]
      action: allow

    - name: default-deny-everything-else
      action: alert

SQL statement classification

The database protocol parses every statement and tags it with a classification that is far more useful than a regex. A single rule can cover “any write to the userstable” without you having to anticipate the exact SQL dialect quirks an engineer is going to reach for.

ClassIncludes
dqlSELECT, SHOW, EXPLAIN, WITH…SELECT
dmlINSERT, UPDATE, DELETE, MERGE, UPSERT, COPY…FROM
ddlCREATE, ALTER, DROP, TRUNCATE, RENAME
dclGRANT, REVOKE, SET ROLE
tclBEGIN, COMMIT, ROLLBACK, SAVEPOINT
adminVACUUM, ANALYZE, REINDEX, KILL, EXEC sp_…
apiVersion: wardengate/v1
kind: CommandPolicy
metadata:
  name: prod-db-readonly
spec:
  protocols: [postgres, mysql, mssql]
  rules:
    - name: allow-reads
      match: { class: [dql, tcl] }
      action: allow

    - name: approve-pii-writes
      match:
        class: [dml]
        tables: ["users", "accounts", "payment_methods"]
      action: approve
      approvers: ["group:data-governance"]

    - name: deny-ddl
      match: { class: [ddl] }
      action: deny
      message: "DDL is not permitted on production; use a migration PR"

    - name: alert-mass-delete
      match:
        class: [dml]
        verb: delete
        rowsGt: 10000
      action: alert
      severity: high

kubectl verb filtering

When the target is a Kubernetes API server proxied through Wardengate, the gateway parses the incoming HTTP request into verb, resource, namespace, and label selectors before forwarding it. This lets filtering talk in the same terms as RBAC.

apiVersion: wardengate/v1
kind: CommandPolicy
metadata:
  name: prod-k8s-oncall
spec:
  protocols: [kubernetes]
  rules:
    - name: allow-reads
      match: { verb: [get, list, watch] }
      action: allow

    - name: approve-deletes
      match: { verb: [delete, deletecollection] }
      action: approve
      approvers: ["group:sre-leads"]

    - name: deny-namespace-delete
      match: { verb: delete, resource: namespaces }
      action: deny

    - name: warn-exec
      match: { verb: [create], resource: pods/exec }
      action: warn

    - name: allow-scale
      match: { verb: [patch, update], resource: deployments, subresource: scale }
      action: allow

Windows process allowlists

RDP is lossy for command-level filtering — you cannot see what a user types inside a remote Explorer window. Wardengate compensates with a lightweight in-session agent on Windows targets that reports process creation and termination events back to the gateway. The policy engine evaluates those against a per-session allowlist and can ask the agent to refuse the launch (or terminate the process) when a deny rule fires.

apiVersion: wardengate/v1
kind: CommandPolicy
metadata:
  name: prod-rdp-dbadmin
spec:
  protocols: [rdp]
  rules:
    - name: allow-dbadmin-tools
      match:
        process: ["ssms.exe", "mmc.exe", "powershell.exe", "cmd.exe"]
      action: allow

    - name: approve-registry-editor
      match: { process: "regedit.exe" }
      action: approve
      approvers: ["group:win-admins"]

    - name: deny-offensive-tooling
      match:
        process: ["mimikatz.exe", "procdump.exe", "psexec.exe"]
      action: deny
      severity: critical
      alertChannels: ["pagerduty:soc", "slack:#sec-red"]

Rule syntax

A rule is a name, a match block, and an action. Match blocks accept both structured matchers (the typed fields above — verb, class, tables, process, argv) and raw regex under raw. Prefer structured matchers where they exist — they are faster, dialect-aware, and survive protocol-level quoting games. Fall back to regex for the edges.

rules:
  - name: block-curl-to-metadata
    match:
      cmd: curl
      raw: "169\\.254\\.169\\.254"
    action: deny

Rule precedence

Rules are evaluated in file order, top to bottom, and stop at the first match. Multiple CommandPolicy objects can apply to the same session (from the access policy, from the target tags, and from a gateway-wide baseline); they are joined in that order and evaluated as a single list. A policy can be marked authoritative: true to skip anything later, which is how you keep a break-glass policy from being undone by an aggressive global allow.

Dry-run mode

Every command policy supports a dryRun: true flag. In dry-run, matches are logged with the action they would have taken, but nothing is blocked or paused. Use dry-run when rolling out a new deny list — flip it on for a week, review the hits in the audit report, then flip it back off when the rules are tuned.

wgctl policy diff prod-ssh-rails.yaml --since 7d

would deny   23x  rm -rf /tmp/*          (alice, bob, 7 others)
would approve 4x  useradd backup-runner  (dan)
would warn  147x  sudo -i

Alert-on-match without blocking

Sometimes the right answer is “let it through, but page someone who cares.” Use action: alert with a severity and channel and the matching command still runs, while a structured event fires out to SIEM and to the configured notifiers. Combine with detachRecording: true to spin off a dedicated clip of just the surrounding window, handy for incident response hand-offs.

Bypass with approval

Any rule with action: approve pauses the session in place — the user sees a message telling them approval has been requested and who is on the hook. If the approval lands inside the configured window the command is released and execution continues; if it times out, the session resumes with the command dropped as though deny had been the action. Every approved bypass is stamped onto the recording and linked to the approver’s identity.

Performance overhead

Filtering runs in the hot path, so performance matters. Measured on a 4-vCPU gateway with a policy of ~300 rules:

  • SSH command parse + match: p99 < 0.4 ms per command; a human typing cannot feel it.
  • SQL parse for a 1 KB statement: p99 < 1.2 ms. Prepared statements are cached by text hash, so repeats are sub-millisecond.
  • kubectl verb parse: p99 < 0.2 ms; the parser is a thin decorator over the HTTP router.
  • Windows process event evaluation: p99 < 0.5 ms, debounced on short-lived helper processes.

The one anti-pattern worth flagging: pure raw regex rules with backtracking constructs (.* inside a capture group followed by another .*). The rule compiler warns on these, and a non-backtracking flavour is available with rawRe2: ….

Next steps