Skip to content

fix(engine): security rework — foothold-gate + anchor + normalize on-host credential corroboration (JEF-320)#278

Open
thejefflarson wants to merge 2 commits into
mainfrom
thejefflarson/jef-320-retire-falco-g3-extend-secretread-to-on-host-credential
Open

fix(engine): security rework — foothold-gate + anchor + normalize on-host credential corroboration (JEF-320)#278
thejefflarson wants to merge 2 commits into
mainfrom
thejefflarson/jef-320-retire-falco-g3-extend-secretread-to-on-host-credential

Conversation

@thejefflarson

@thejefflarson thejefflarson commented Jul 27, 2026

Copy link
Copy Markdown
Owner

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). The
doc 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's application_default_credentials.json,
Azure's azureProfile.json/accessTokens.json/msal_token_cache.json) are
the 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 path
    classifier for the on-host credential paths — exact match for
    /etc/shadow//etc/sudoers//etc/gshadow, an anchored .ssh file 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 existing FileRead → SecretRead
    refinement now falls back to host_credential_class when a path isn't under
    a k8s Secret mount.
  • behavior::SecretReadSource::HostPath: a new pure-data "how observed"
    wire variant alongside Mounted/Api (same precedent as JEF-269's Api
    addition) — 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 or
load-tested locally (macOS)... Every iteration goes through a CI image build").

Why it was unavoidable: security_file_open's existing probe filters to the
tmpfs superblock in-kernel, before the path is ever extracted — a host-fs
(overlayfs) read of /etc/shadow never reaches userspace today, so no amount
of engine-side classification alone can see it. I widened try_file_open with
a structural, cheap volume gateis_sensitive_credential_basename,
which reads the leaf dentry's d_name directly (same allowed-anywhere pattern
as the existing emit_lib_name, no bpf_d_path call) and checks it against a
small, fixed basename allowlist — before deciding whether to pay for the
bpf_d_path full-path extraction. This is a volume gate, not the security
decision (which stays 100% engine-side, reading the full path
bpf_d_path returns). This eBPF pre-filter is unchanged by the security
rework 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_open probe still
attaches/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):

  1. HIGH — over-broad allowlist. /etc/passwd was in the credential
    allowlist, but it's world-readable and read constantly by benign processes
    (id, ls -l, NSS, PAM) — producing a standing false CredentialAccess
    corroboration on ~every workload. Fix: dropped /etc/passwd and
    ~/.ssh/known_hosts (other hosts' public keys, no secret material) from
    the allowlist. Kept /etc/shadow; added /etc/gshadow and
    /etc/sudoers. Falco itself fires on shadow, never passwd, for
    exactly this reason.
  2. MEDIUM — unanchored .ssh match. The SSH match tested for a .ssh
    path component at any depth (and even matched the .ssh directory
    itself), so a read under a world-writable /tmp//dev/shm dot-ssh dir
    could forge an SSH-key corroboration. Fix: anchored the match — the
    .ssh segment 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).
  3. MEDIUM — no path normalization (path-traversal). The untrusted path
    was matched raw, so ./../// forms could both FORGE a match (a
    cloud-cred basename textually reached via a .. that walks back out of a
    matched segment, e.g. /tmp/.aws/../credentials) and EVADE one (a
    dot/double-slash variant of /etc/shadow slipping past the exact-string
    check). Fix: the path is now lexically normalized via
    std::path::Path::components() (collapsing ./../empty segments)
    before every check — deliberately not std::fs::canonicalize, which
    would touch the engine host's own disk and break zero-egress.
  4. MEDIUM — broken access control. The SecretRead { source: HostPath }
    corroboration arm was NOT gated on entry.is_foothold, unlike its
    siblings cross_tenant_lateral / privilege_escalation_on_foothold /
    drop_then_execute — it corroborated on ANY workload. Fix: added
    host_credential_read_on_foothold, mirroring cross_tenant_lateral's
    entry.is_foothold gate; the flat, context-free corroborates() relation
    now excludes SecretReadSource::HostPath explicitly (a Mounted/Api
    k8s-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 sshd reading its own /etc/shadow, can trip
    it).

New/updated tests (host_credential_class.rs, corroborate_host_credential_tests.rs):
each remaining credential class still matches; /etc/passwd and
known_hosts no longer match; the anchored .ssh rule rejects a
temp/shm dot-ssh path and the bare .ssh directory, but accepts a real
home-rooted key file; the normalization handles both the forge and evade
cases; the corroboration fires only on a proven foothold entry (and a
Mounted secret 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 RuntimeSignal that reaches a Workload node is already attributed to a
Pod (RuntimeAdapter's existing UID/namespaced-name resolution) — a bare
host-system daemon (e.g. the node's real sshd, outside any container cgroup)
has no resolvable attribution and is dropped upstream (unresolved) before
this 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 sshd reading its own /etc/shadow/authorized_keys)
still matches. Accepted deliberately — this is corroboration only
(corroborates() only ever sets corroborated, 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 class
    matches (host shadow/gshadow/sudoers, anchored SSH key material, cloud
    creds for AWS/GCP/Azure); near-miss/benign paths, passwd, known_hosts,
    unanchored .ssh paths, and the forge/evade traversal cases do NOT.
  • engine::reason::proof::corroborate_host_credential_tests (new) — the
    foothold-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 for HostPath.
  • engine::observe::adapter::enrich::host_credential_tests — end-to-end
    through RuntimeAdapter/build_graph: a host-credential FileRead
    attaches as SecretRead{source: HostPath}; SSH/cloud paths attach too; a
    benign 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: 943
    engine lib + 5 + 9 + 3 + 1 + 31 behavior tests passed).
  • Ran /soundcheck:pr-review (no Critical/High findings — the two classes it
    matches 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.rs siblings was left as-is, matching
    established 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 this
rework; CI's ebpf job and the operator's on-node SSH validation are the
real checks, per above.

Closes JEF-320.

…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
@thejefflarson

Copy link
Copy Markdown
Owner Author

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, bpf_d_path correctly avoided, no off-by-one — but must be validated on a real node before merge regardless). Findings are severity-ordered; all in engine/src/engine/observe/host_credential_class.rs unless noted.

HIGH — EXACT_HOST_PATHS includes /etc/passwd → standing false CredentialAccess corroboration on ~every workload.
const EXACT_HOST_PATHS = ["/etc/shadow", "/etc/passwd", "/etc/sudoers"]. /etc/passwd is world-readable and read constantly by benign processes (id, ls, NSS, PAM) — so this raises a CredentialAccess corroboration on essentially every workload, all the time. Falco fires on shadow, not passwd, for exactly this reason. Fix: remove /etc/passwd (and drop known_hosts from the SSH breadth — it's not secret material); keep /etc/shadow, add /etc/gshadow and /etc/sudoers.

MEDIUM — .ssh match is unanchored (any depth) → forgeable SSH-key corroboration.
has_segment(path, ".ssh") matches a .ssh component at any depth — the PR's own test asserts /.ssh/id_rsa matches, so /tmp/.ssh/x or /dev/shm/.ssh/x (attacker-writable) forges an SSH-key CredentialAccess hit. Fix: anchor to /root/.ssh/<file> or /home/<user>/.ssh/<file>, and require a file beneath .ssh (not the dir itself).

MEDIUM (path traversal) — no path normalization → forge and evade.
Matching is done on the raw string. /srv/.aws/../app/credentials has segment .aws + basename credentials, so it forges an AWS hit even though it resolves to /srv/app/credentials. Conversely /etc/./shadow and //etc/shadow are != the exact strings and therefore evade detection. Fix: lexically normalize via Path::components() before matching — NOT fs::canonicalize (that touches disk / breaks zero-egress).

MEDIUM (broken access control) — HostPath SecretRead corroboration arm is not foothold-gated.
The HostPath SecretRead corroboration arm (corroborate.rs ~L86) is not entry.is_foothold-gated, unlike its sibling arms. Fix: foothold-gate the arm so an unrelated pod's host-cred read can't corroborate an arbitrary chain.

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
@thejefflarson thejefflarson changed the title feat(engine): extend SecretRead corroboration to on-host credential paths (JEF-320) fix(engine): security rework — foothold-gate + anchor + normalize on-host credential corroboration (JEF-320) Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant