fix(engine): security rework — foothold-gate + anchor + normalize on-host credential corroboration (JEF-320)#278
Conversation
…aths (JEF-320) Closes the F0 Falco-parity audit's G3 gap (docs/falco-parity-audit.md §8, PR #169): the agent's SecretRead/CredentialAccess corroboration was scoped to the tmpfs superblock (k8s Secret mounts), so a read of a host-filesystem credential file (the host shadow/passwd file, an SSH private-key dir, a cloud-provider credential file) produced no corroboration even though Falco's "Read sensitive file untrusted" / "Read ssh information" rules fire on exactly this. - engine::observe::host_credential_class: engine-side path classifier (JEF-113 — policy stays off the wire) for the well-known on-host credential paths, wired into RuntimeAdapter's existing FileRead→SecretRead refinement in enrich.rs as a fallback when a path isn't under a k8s Secret mount. - behavior::SecretReadSource::HostPath: a new "how observed" wire variant alongside Mounted/Api (pure data, mirrors the JEF-269 precedent). - agent-ebpf: security_file_open's tmpfs-only filter is now widened by a small, fixed basename allowlist (is_sensitive_credential_basename) — a cheap structural volume gate, not the security classification, which stays engine-side. ON-NODE LOAD VALIDATION IS PENDING for this probe widening (this crate can't be compiled or verifier-tested off the fleet, per docs/ebpf-testing-on-nodes.md). - FP-scoping: corroboration only ever fires for events already resolved to a Pod (RuntimeAdapter's existing attribution mechanism) — a bare host-system daemon has no such attribution and is dropped upstream. Documented in host_credential_class.rs; a legitimate in-container auth process (e.g. a bastion pod's own sshd) can still trip it, accepted since this is corroboration-only, shadow-only, combined with model reasoning. - behavior/src/lib.rs's test module split into behavior/src/tests.rs (file-size cap); enrich.rs's new tests split into enrich/host_credential_tests.rs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
|
HELD — not merging. Two independent reasons: (1) confirmed false-corroboration / path-handling findings below, and (2) the eBPF widening still needs operator on-node load validation (the widening itself audited clean — bounded 48-byte read, HIGH — MEDIUM — MEDIUM (path traversal) — no path normalization → forge and evade. MEDIUM (broken access control) — HostPath SecretRead corroboration arm is not foothold-gated. The G3 Falco-parity goal (host-credential reads, ADR-0014) is sound; the classifier just needs anchoring + normalization + gating + the passwd removal, and the probe needs on-node validation. Leaving open. |
Fixes four findings from a HELD security review of the JEF-320 SecretRead/ HostPath extension (PR #278): - HIGH: drop /etc/passwd from the credential allowlist — world-readable, no secret material, read constantly by benign processes (id/ls -l/NSS/PAM), producing a standing false CredentialAccess corroboration on ~every workload. Falco itself only fires on shadow, never passwd. Also drop known_hosts (other hosts' public keys, no secret material); add gshadow and sudoers, which do gate genuine credential material. - MEDIUM: anchor the `.ssh` match to a real home root (`/root/.ssh/<file>` or `/home/<user>/.ssh/<file>`) with a required non-empty file component — a `.ssh` segment at any depth (including the bare directory) let a world-writable temp/shm path forge an SSH-key corroboration. - MEDIUM: lexically normalize the observed path via `std::path::Path::components()` (never `canonicalize`, which would touch the engine host's own disk and break zero-egress) before every check — closes a path-traversal gap that could both forge a match (`..` past a matched segment) and evade one (`.`/`..`/`//` noise on an exact path). - MEDIUM (broken access control): gate the `SecretRead { source: HostPath }` corroboration arm on the proven foothold entry (`host_credential_read_on_foothold`), mirroring `cross_tenant_lateral`/ `privilege_escalation_on_foothold`/`drop_then_execute` — an on-host credential read can come from a legitimate in-container process (a bastion pod's own sshd) unrelated to any chain. The eBPF basename pre-filter is unchanged (audited clean, kept as-is); this is engine-side classification/corroboration policy only (JEF-113). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
Summary
Closes the F0 Falco-parity audit's residual G3 gap: the agent's
SecretRead→CredentialAccess corroboration was scoped to the tmpfs superblock (k8s
Secret/ConfigMap/projected-volume mounts), so a read of a host-filesystem
credential file — the host shadow file, a per-user SSH private-key
directory, a cloud-provider (AWS/GCP/Azure) credential file — produced NO
corroboration, even though it's exactly what Falco's "Read sensitive file
untrusted" / "Read ssh information" rules fire on.
Authoritative path-list source: the F0 audit doc
(
docs/falco-parity-audit.md§3 Class E / §7-8 "G3", landed in PR #169). Thedoc itself was removed from the tree in the later F8 Falco-retirement commit
(
408217c/ #181) but is preserved in git history —git show a2c7620:docs/falco-parity-audit.md. Cloud-provider file names/paths(AWS
~/.aws/credentials, gcloud'sapplication_default_credentials.json,Azure's
azureProfile.json/accessTokens.json/msal_token_cache.json) arethe well-known on-disk conventions for those CLIs — the F0 doc names AWS creds
generically ("Find AWS Credentials", a grep-based Falco rule, out of scope)
without a literal path, so I sourced the literal filenames myself.
What changed
engine::observe::host_credential_class(new): the engine-side pathclassifier for the on-host credential paths — exact match for
/etc/shadow//etc/sudoers//etc/gshadow, an anchored.sshfile match,and cloud-credential basenames paired with their provider's config-directory
segment (to avoid a same-named file elsewhere false-positiving). Policy
stays engine-side, off the wire (JEF-113) — mirrors
exec_class.enrich.rs:RuntimeAdapter's existingFileRead → SecretReadrefinement now falls back to
host_credential_classwhen a path isn't undera k8s Secret mount.
behavior::SecretReadSource::HostPath: a new pure-data "how observed"wire variant alongside
Mounted/Api(same precedent as JEF-269'sApiaddition) —
summary()renders"reads secret {path} (on-host filesystem)".eBPF change — ON-NODE LOAD VALIDATION PENDING
Yes, a minimal agent-ebpf change was needed and it has NOT been
validated on a real kernel — this crate can't be compiled or verifier-tested
off the fleet (
docs/ebpf-testing-on-nodes.md: "eBPF can't be compiled orload-tested locally (macOS)... Every iteration goes through a CI image build").
Why it was unavoidable:
security_file_open's existing probe filters to thetmpfs superblock in-kernel, before the path is ever extracted — a host-fs
(overlayfs) read of
/etc/shadownever reaches userspace today, so no amountof engine-side classification alone can see it. I widened
try_file_openwitha structural, cheap volume gate —
is_sensitive_credential_basename,which reads the leaf dentry's
d_namedirectly (same allowed-anywhere patternas the existing
emit_lib_name, nobpf_d_pathcall) and checks it against asmall, fixed basename allowlist — before deciding whether to pay for the
bpf_d_pathfull-path extraction. This is a volume gate, not the securitydecision (which stays 100% engine-side, reading the full path
bpf_d_pathreturns). This eBPF pre-filter is unchanged by the securityrework below — it was audited clean and left as-is.
This PR remains HELD pending the operator's interactive on-node SSH
validation — confirm the widened
security_file_openprobe stillattaches/loads cleanly and doesn't flood the ring buffer on a live node
before merging to main.
Security rework (this update) — 4 fixes from a HELD security review
A security review of the original PR held it on four findings in the
classification/corroboration logic (the eBPF pre-filter itself was reviewed
clean and is untouched):
/etc/passwdwas in the credentialallowlist, but it's world-readable and read constantly by benign processes
(
id,ls -l, NSS, PAM) — producing a standing false CredentialAccesscorroboration on ~every workload. Fix: dropped
/etc/passwdand~/.ssh/known_hosts(other hosts' public keys, no secret material) fromthe allowlist. Kept
/etc/shadow; added/etc/gshadowand/etc/sudoers. Falco itself fires onshadow, neverpasswd, forexactly this reason.
.sshmatch. The SSH match tested for a.sshpath component at any depth (and even matched the
.sshdirectoryitself), so a read under a world-writable
/tmp//dev/shmdot-ssh dircould forge an SSH-key corroboration. Fix: anchored the match — the
.sshsegment must sit directly under a real home root (/root/.ssh/…or
/home/<user>/.ssh/…) AND a non-empty file component must follow it(not the bare directory).
was matched raw, so
./..///forms could both FORGE a match (acloud-cred basename textually reached via a
..that walks back out of amatched segment, e.g.
/tmp/.aws/../credentials) and EVADE one (adot/double-slash variant of
/etc/shadowslipping past the exact-stringcheck). Fix: the path is now lexically normalized via
std::path::Path::components()(collapsing./../empty segments)before every check — deliberately not
std::fs::canonicalize, whichwould touch the engine host's own disk and break zero-egress.
SecretRead { source: HostPath }corroboration arm was NOT gated on
entry.is_foothold, unlike itssiblings
cross_tenant_lateral/privilege_escalation_on_foothold/drop_then_execute— it corroborated on ANY workload. Fix: addedhost_credential_read_on_foothold, mirroringcross_tenant_lateral'sentry.is_footholdgate; the flat, context-freecorroborates()relationnow excludes
SecretReadSource::HostPathexplicitly (aMounted/Apik8s-Secret read is unambiguous and stays context-free — only the on-host
path needs the foothold gate, since a legitimate in-container process,
e.g. a bastion pod's own
sshdreading its own/etc/shadow, can tripit).
New/updated tests (
host_credential_class.rs,corroborate_host_credential_tests.rs):each remaining credential class still matches;
/etc/passwdandknown_hostsno longer match; the anchored.sshrule rejects atemp/shm dot-ssh path and the bare
.sshdirectory, but accepts a realhome-rooted key file; the normalization handles both the forge and evade
cases; the corroboration fires only on a proven foothold entry (and a
Mountedsecret read still corroborates context-free, unaffected).Per the ticket, on-node validation for this rework is done interactively
over SSH by the operator — no spike-DaemonSet or extra logging was added.
FP-scoping (documented in
host_credential_class.rs)Every
RuntimeSignalthat reaches a Workload node is already attributed to aPod (
RuntimeAdapter's existing UID/namespaced-name resolution) — a barehost-system daemon (e.g. the node's real
sshd, outside any container cgroup)has no resolvable attribution and is dropped upstream (
unresolved) beforethis classifier ever runs. That gives "container/entry context only" for free.
What it does NOT filter: a legitimate in-container auth process (e.g. a
bastion pod's own
sshdreading its own/etc/shadow/authorized_keys)still matches. Accepted deliberately — this is corroboration only
(
corroborates()only ever setscorroborated, never actuates — shadow-only,ADR-0014), combined with the model's own reasoning and every other piece of
evidence, and (post security-rework) additionally gated to a proven
internet-facing foothold entry.
Testing
engine::observe::host_credential_class— unit tests: each path classmatches (host shadow/gshadow/sudoers, anchored SSH key material, cloud
creds for AWS/GCP/Azure); near-miss/benign paths,
passwd,known_hosts,unanchored
.sshpaths, and the forge/evade traversal cases do NOT.engine::reason::proof::corroborate_host_credential_tests(new) — thefoothold-gated corroboration shape end-to-end and at the predicate level,
both positive and negative, plus a regression guard that the flat
corroborates()relation stays silent forHostPath.engine::observe::adapter::enrich::host_credential_tests— end-to-endthrough
RuntimeAdapter/build_graph: a host-credentialFileReadattaches as
SecretRead{source: HostPath}; SSH/cloud paths attach too; abenign read is dropped; an event with no resolvable pod attribution never
attaches.
behavior::tests::secret_read_source_distinguishes_host_path_from_mounted_and_api— wire round-trip, summary, and fingerprint-key distinctness.
cargo fmt --all,cargo clippy -p protector --all-targets -- -D warnings(clean),
cargo test -p protector -p protector-behavior(all green: 943engine lib + 5 + 9 + 3 + 1 + 31 behavior tests passed).
/soundcheck:pr-review(no Critical/High findings — the two classes itmatches against, path-traversal and broken-access-control, are exactly the
two classes this rework remediates; verified edge cases — empty path,
trailing slash — don't panic or bypass the checks) and
/simplify(single-pass, Agent tool unavailable in this context — factored the
cloud-credential check into its own named function for symmetry with the
SSH-anchoring check; the pre-existing per-file test-helper duplication
across the
corroborate_*_tests.rssiblings was left as-is, matchingestablished convention, out of this ticket's scope).
eBPF crate still has no test harness and can't be compiled off the fleet
(
no_std/no_main) — genuinely untestable locally, unchanged by thisrework; CI's
ebpfjob and the operator's on-node SSH validation are thereal checks, per above.
Closes JEF-320.