Skip to content
Open
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
36 changes: 36 additions & 0 deletions config/safe-exec-profiles.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,38 @@
# run (e.g. ./gradlew); everything else must be a bare basename.
# - `keep_env` names env vars preserved (beyond the scrubbed base set)
# when safe_exec runs the command.
# - `keep_env_heads` narrows that: only segments whose head basename is
# listed receive `keep_env`/`extra_env`. Empty or absent = every segment.
# - `curl_allowed_hosts` pins every curl URL operand to those hostnames
# (exact match, case-insensitive, no globs; URL and host:port forms
# normalize). A non-empty list also denies redirect-following.
# - Granting `git` never grants git network subcommands or dangerous
# `-c` config keys — those stay denied inside the validator.
# - Network posture: dependency-
# manager egress from build tools (go mod, mvn, npm to configured
# mirrors) is accepted residual risk — safe_exec constrains the argv
# and environment, not child-process sockets; hermetic prefetch is a
# deferred hardening.
#
# Posture framework (modeled on Kubernetes Pod Security Standards):
# - `restricted` (alias `high`): "fail closed" — every curl URL is denied unless
# its host is listed in `curl_allowed_hosts` or supplied at call-site (ROE
# engagement scope). Redirects are denied. `keep_env_heads` is enforced when
# pipelines and env vars are present.
# - `baseline` (alias `medium`): "somewhere in between" — public http/https
# destinations are permitted; private/internal IP ranges (RFC 1918, loopback,
# link-local/cloud metadata 169.254.169.254) and local domains (.local,
# .internal, .cluster.local, localhost) are denied unless explicitly
# allowlisted. Redirects are denied.
# - `privileged` (alias `low`): "fail open" — permissive defaults; empty host
# list allows any destination. Legacy behavior.
version: 1

# File-wide fallbacks; any profile may override.
defaults:
# Posture: restricted | baseline | privileged (or high | medium | low)
posture: baseline

profiles:
validation-step:
description: >-
Expand All @@ -29,35 +53,45 @@ profiles:
SOUND — without it every bearer-auth probe silently ran
unauthenticated and its refutations were unsound; the token is
lab-cluster-scoped and short-TTL.
# Set to restricted for fail-closed curl validation (ROE supplies lab hosts)
posture: restricted
allow: [curl, oc, kubectl, jq, grep, base64, head, tail, tr, wc,
cat, sleep, echo, printf]
allow_pipelines: true
keep_env: [KUBECONFIG, VF_OAUTH_TOKEN]
# keeps the token out of a `| grep x` tail
keep_env_heads: [curl, oc, kubectl]
curl_allowed_hosts: []
go-fuzz:
description: >-
create-fuzzing Go-native build/run steps (skill integration
pending). Cloning stays in the skill's
S3-gated git step, never here.
posture: baseline
allow: [go, make, gofmt, git]
allow_pipelines: false
keep_env: [GOPATH, GOCACHE, GOMODCACHE, GOFLAGS]
java-build:
description: Maven/Gradle build-verification steps (integration pending).
posture: baseline
allow: [mvn, gradle, java, javac, jar, make, git]
allowed_path_heads: ["./gradlew", "./mvnw"]
allow_pipelines: false
keep_env: [JAVA_HOME]
python-test:
description: Target test-suite execution for /patch (integration pending).
posture: baseline
allow: [python3, pytest, make, git]
allow_pipelines: false
keep_env: [VIRTUAL_ENV]
node-build:
description: Node build/test steps (integration pending).
posture: baseline
allow: [npm, npx, node, make, git]
allow_pipelines: false
rust-fuzz:
description: cargo-fuzz build/run steps (integration pending).
posture: baseline
allow: [cargo, rustc, make, git]
allow_pipelines: false
keep_env: [CARGO_HOME, RUSTUP_HOME]
Expand All @@ -67,10 +101,12 @@ profiles:
(python3 -m traust.cli adapters govulncheck). govulncheck type-checks and builds
the target module, so hostile build tags/cgo see only the
scrubbed env — no ambient tokens to exfiltrate (gate rule S10).
posture: baseline
allow: [govulncheck]
allow_pipelines: false
keep_env: [GOPATH, GOCACHE, GOMODCACHE, GOFLAGS, GOPROXY, GONOSUMDB, GONOSUMCHECK, GOSUMDB]
generic-build:
description: Make-only fallback for unclassified build systems.
posture: baseline
allow: [make, git]
allow_pipelines: false
91 changes: 87 additions & 4 deletions docs/safe-exec.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,97 @@ fallback copy of the `validation-step` profile lives in the module — keep them
in sync; the harness ships the template as
`config/safe-exec-profiles.example.yaml`). Each profile declares `allow`
(binary heads), optional `allowed_path_heads` (e.g. `./gradlew`),
`allow_pipelines`, and `keep_env`. Current profiles and their consumers:
`allow_pipelines`, `keep_env`, and optionally `keep_env_heads` (which segment
heads receive `keep_env`), `curl_allowed_hosts` and `posture`.
Current profiles and their consumers:

| Profile | Used by | Notes |
|---|---|---|
| `validation-step` | validate-findings adapters (string-form PoC steps) | pipelines allowed; `KUBECONFIG` + `VF_OAUTH_TOKEN` kept (the token keeps bearer-auth probes sound) |
| `go-scan` | python3 -m traust.cli adapters govulncheck | govulncheck type-checks and builds the target module — hostile build tags/cgo see only the scrubbed env |
| `go-fuzz`, `java-build`, `python-test`, `node-build`, `rust-fuzz`, `generic-build` | build/test lanes (fuzz-harness and patch-verification steps) | dependency-manager egress from build tools is accepted residual risk: safe_exec constrains argv and environment, not child-process sockets; hermetic prefetch is a deferred hardening |

### Security postures and curl host posture

Safe-exec provides a posture framework modeled on Kubernetes Pod Security Standards:

| Posture | Alias | Egress / Host Semantics | Environment & Redirects |
|---|---|---|---|
| `restricted` | `high` | **Fail closed**: Every curl URL is denied unless its destination is listed in `curl_allowed_hosts` or passed at call time (ROE hosts). | Redirects denied. `keep_env_heads` required on pipelines when env vars are kept. |
| `baseline` | `medium` | **Public allowed, private denied**: Public http/https egress is permitted. Non-global IP ranges (RFC 1918, loopback, link-local/cloud metadata 169.254.169.254) and internal domains (.local, .internal, .cluster.local, localhost) are denied unless explicitly allowlisted. | Redirects denied. `keep_env_heads` enforced when pipelines and env vars are present. |
| `privileged` | `low` | **Fail open**: Unrestricted destinations; empty host list allows any destination. | Redirects allowed when host list is empty. Legacy default. |

Static config cannot name a per-engagement lab cluster. Keep the restricted
profile's static list empty and declare authorization in `targets.yaml`.
`clusters[].api` and explicit `http_targets` provide hosts for command-text probes.
Structured HTTP probes resolve a single endpoint, check resource scope and pass
only that endpoint to safe_exec. Reproduce a host verdict with `--allowed-host`.

### Engagement HTTP authorization

The [targets template](../harnessing/5-validate/validate-findings/targets.example.yaml)
contains optional `http_targets` and per-cluster `http_discovery` fields. These
are explicit-only grants; finding text and inferred scope cannot populate them.

- `http_targets`: exact host, required cluster `context`, optional `namespace`,
`resource` and `name`, and an independent `credentials` permission.
- `http_discovery.routes`: concrete namespace, optional exact resource `names`,
approved DNS `domains`, and optional `credentials`. Domain boundaries use a
DNS-label boundary, not an arbitrary suffix. A discovered hostname is not a grant.
- `http_discovery.nodes`: optional exact `names`, permitted CIDR `networks`, and
`address_types` (defaults to InternalIP). This narrowly authorizes node reads
and HTTP probes without adding a wildcard namespace grant.
- `http_discovery.port_forward_credentials`: permits authenticated probes through
authorized, adapter-owned Pod tunnels. Default false.
- `http_discovery.kube_env`: explicit environment variable names needed by a
kubeconfig exec-auth plugin when opening a tunnel. No additional variables
are preserved by default.
- `http_discovery.ca_bundle`: operator-owned CA file for lab TLS. Verification
stays enabled by default. `insecure_tls: true` is an explicit engagement-only
exception, never inferred from a finding.
- `http_discovery.session_check_path`: origin-relative identity endpoint that
returns 2xx only for an authenticated session. Required for authenticated CSRF
probes; a login page or CSRF cookie alone is not proof of authentication.

Discovery uses the named kubeconfig context and checks any declared API against
that context. Collection reads require `list`, named reads require `get`;
expiry, verb denies and off-limits rules continue to apply. Ambiguous endpoints,
RBAC failures, malformed responses and unauthorized destinations fail closed.
Route/console probes use scoped route discovery; kubelet probes use scoped node
addresses. Service discovery selects a matching Pod and resolves the Service's
target port. The adapter owns the ephemeral local port, readiness deadline and
cleanup. Loopback is not an engagement-wide grant. Service transport defaults to
HTTP unless Service port metadata identifies HTTPS; a reviewed request can select
its scheme explicitly. TLS tunnels retain the service DNS identity and pin the
connection to the owned local port. Proxy environment variables cannot redirect
structured requests.

Generated HTTP plans contain `target.http` rather than shell programs. The request
includes mode, method, relative path, transport, optional port, concrete namespaces
and selection hints. Authentication and CSRF are explicit request options and
require a credential grant; anonymous probes remain anonymous. Token creation
also requires permission for `create serviceaccounts/token`. Console session
cookies stay in memory. HTTP DELETE requires destructive permission; other write
methods are classified as mutating and checked against the corresponding verb.
No shell execution, redirect following or arbitrary cookie-file writes are enabled.
Direct structured cluster-API requests are limited to GET/HEAD health and version
endpoints; Kubernetes resource requests must use resource-aware operations instead.
An authenticated-caller claim is inconclusive until a reviewed authenticated probe
satisfies its identity precondition. Reflected request credentials and CSRF secrets
are redacted before HTTP output reaches evidence artifacts. Sensitive headers
are validated in memory and passed through adapter-owned stdin, not process
arguments. Only the trusted adapter introduces this stdin header source and the
owned-tunnel connection mapping after validation; command text cannot grant them.
Structured steps cannot carry shell rollback commands. Session initialization
must succeed and preserve the server's cookie names; HEAD uses curl's HEAD mode.

The engine allowlist remains hostname-only: it is not port/path isolation or a
DNS-rebinding defense. Optional resource selectors are enforced by the structured
resolver, not by the command-text host allowlist. Use structured requests for
resource-scoped grants and review operator-approved DNS domains/CIDRs accordingly.
Package pins, the deployed skill tree and the selected config home must all carry
this implementation before enabling restricted policy in an estate.

## Modes and the bypass

- **`SAFE_EXEC_MODE=warn|enforce`** — `warn` logs what enforce would have
Expand All @@ -82,9 +165,9 @@ in sync; the harness ships the template as
stderr and appends to the bypass log (`~/.local/state/…`, 0700). A bypass
with no recorded reason is a finding, not a convenience.
- Blocked commands return exit 126 with a `[safe_exec blocked: …]` reason.
In validate-findings, a blocked step becomes an `inconclusive` verdict —
never a silent pass, and never grounds for a `refuted` (refutation-soundness
gate).
Kubernetes validation policy refusals become `blocked_by_scope`; resolution or
transport failures are inconclusive. Neither provides evidence to confirm or
refute a finding.

## CLI

Expand Down
24 changes: 23 additions & 1 deletion harnessing/5-validate/validate-findings/adapters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
if not __package__:
sys.path.insert(0, str(Path(__file__).parent))

from typing import Any

from .base import AdapterBase, Fingerprint, StepResult
from .container import ContainerAdapter
from .k8s import K8sAdapter
Expand Down Expand Up @@ -48,13 +50,31 @@ def get_adapter(name: str) -> AdapterBase:


def new_adapter(name: str) -> AdapterBase:
"""Return a fresh adapter instance with independent state."""
"""Return a fresh adapter instance with independent state.

Unbound: call its bind_scope() before running steps, or a closed-posture
profile refuses every curl."""
try:
return _ADAPTER_CLASSES[name]()
except KeyError as e:
raise ValueError(f"unknown adapter: {name!r}") from e


def bind_scope(scope: object) -> None:
"""Bind the engagement scope to every shared adapter singleton.

Call once per run, before preflight: the adapters are module-level
singletons, and an unbound one carries no curl hosts."""
for adapter in TARGET_ADAPTERS.values():
adapter.bind_scope(scope)


def bind_profile_map(profile_map: dict[str, Any] | None) -> None:
"""Bind resolved safe_exec profiles to every shared adapter singleton."""
for adapter in TARGET_ADAPTERS.values():
adapter.bind_profile_map(profile_map)


__all__ = [
"TARGET_ADAPTERS",
"AdapterBase",
Expand All @@ -63,6 +83,8 @@ def new_adapter(name: str) -> AdapterBase:
"K8sAdapter",
"StepResult",
"WasmAdapter",
"bind_profile_map",
"bind_scope",
"get_adapter",
"new_adapter",
]
42 changes: 30 additions & 12 deletions harnessing/5-validate/validate-findings/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import subprocess
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import ClassVar
from typing import Any, ClassVar


@dataclass
Expand Down Expand Up @@ -93,8 +93,22 @@ class AdapterBase:
#: verbs that delete data, kill workloads, or cannot be undone
DESTRUCTIVE_VERBS: ClassVar[set[str]] = {"delete", "scale-zero", "kill", "fuzz-import"}

def __init__(self):
pass
def __init__(self) -> None:
#: extra curl hosts for safe_exec; see bind_scope()
self._curl_hosts: tuple[str, ...] = ()
self._safe_exec_profile_map: dict[str, Any] | None = None

def bind_scope(self, scope: object) -> None:
"""Take the curl host allowlist from the engagement scope.

Lets a deployment run validation-step with `posture: restricted`
and still reach the cluster it was authorized against."""
getter = getattr(scope, "curl_hosts", None)
self._curl_hosts = tuple(getter()) if callable(getter) else ()

def bind_profile_map(self, profile_map: dict[str, Any] | None) -> None:
"""Take the resolved safe_exec profile map for this deployment."""
self._safe_exec_profile_map = profile_map

# ----- classification ---------------------------------------------

Expand Down Expand Up @@ -191,27 +205,31 @@ def _safe_exec(cls):
cls._safe_exec_mod = importlib.import_module("traust_engine._util.safe_exec")
return cls._safe_exec_mod

@classmethod
def _vet_shell_string(cls, cmd: str) -> tuple[list[str] | None, str]:
def _vet_shell_string(self, cmd: str) -> tuple[list[str] | None, str]:
"""Vet a PoC-derived command string via safe_exec. Returns
(argv, "") for a pipeless command, (None, "") for an approved
pipeline, or (None, reason) when rejected."""
se = cls._safe_exec()
v = se.vet_command_string(cmd, se.get_profile(cls._SAFE_EXEC_PROFILE))
se = self._safe_exec()
v = se.vet_command_string(
cmd,
se.get_profile(self._SAFE_EXEC_PROFILE, profile_map=self._safe_exec_profile_map),
allowed_hosts=self._curl_hosts,
)
if not v.ok:
return None, v.reason
if len(v.segments) == 1:
return list(v.segments[0]), ""
return None, "" # approved pipeline

@classmethod
def _run(
cls, cmd: str | list[str], *, timeout: int = 120, input_: str | None = None
self, cmd: str | list[str], *, timeout: int = 120, input_: str | None = None
) -> tuple[int, str, str]:
if isinstance(cmd, str):
se = cls._safe_exec()
profile = se.get_profile(cls._SAFE_EXEC_PROFILE)
v = se.vet_command_string(cmd, profile)
se = self._safe_exec()
profile = se.get_profile(
self._SAFE_EXEC_PROFILE, profile_map=self._safe_exec_profile_map
)
v = se.vet_command_string(cmd, profile, allowed_hosts=self._curl_hosts)
if not v.ok:
return 126, "", f"[step blocked: {v.reason}]"
# single command or approved pipeline — both execute
Expand Down
Loading