Rules / CFG044
CFG044 permissions.deny does not restrict SSH private keys
Severity: error
OWASP: LLM02:2026 – Sensitive Information Disclosure
What cfgaudit checks
SSH private keys grant access to remote systems and are a prime exfiltration target. cfgaudit flags a permissions.deny block that exists but does not cover them. Coverage is satisfied by either:
- a
.sshdirectory pattern —Read(**/.ssh/**),Read(~/.ssh/*),.ssh/*, or - a well-known private-key filename —
id_rsa,id_ed25519,id_ecdsa,id_dsa.
A completely absent permissions.deny block is reported by CFG006, not here.
A deny-all entry suppresses this finding entirely: Read(*) / Read(**) / Read(**/*) (any version), or the bare "*" deny-all-tools glob on Claude Code ≥ 2.1.166 (a no-op on older releases, so the finding still fires there; treated as covering when the version is unknown).
An exception rule (Read(!<glob>)) denies nothing and removes the paths it matches from the deny rules before it, so it does not count as coverage; a deny-all with the SSH key paths carved back out leaves them readable and the finding fires.
Trigger example
{
"permissions": {
"deny": ["Read(.env)", "Read(**/*.pem)"]
}
}
→ error (no .ssh / id_* coverage).
Anchor the pattern at the filesystem root
A path rule's anchor decides what it can reach, and the bare **/ form does not reach the home directory. Measured against Claude Code 2.1.231 with a marker file placed both inside and outside a project:
Rule in the project's .claude/settings.json |
<project>/zzsecret/marker |
<outside>/zzsecret/marker |
|---|---|---|
Read(**/zzsecret/**) |
blocked | read succeeded |
Read(//**/zzsecret/**) |
blocked | blocked |
So Read(**/.ssh/**) in a committed project file protects a .ssh directory that happens to sit inside the repository, and leaves ~/.ssh/id_rsa readable, which is the file the rule exists for. Upstream states the same rule: "A deny rule such as Read(/secrets/**) in user settings blocks ~/.claude/secrets/**, not a secrets directory in your project. To write a rule in user settings that applies inside every project, use a // absolute path or a ~/ home-relative path instead."
cfgaudit still accepts the bare form as coverage, because it does restrict something and reporting it would be a second finding on a file that already tried. The suggestion it prints is the anchored one.
Safe alternative
{ "permissions": { "deny": ["Read(//**/.ssh/**)"] } }
or explicitly:
{
"permissions": {
"deny": ["Read(**/id_rsa)", "Read(**/id_ed25519)", "Read(**/id_ecdsa)", "Read(**/id_dsa)"]
}
}
References
- OWASP LLM02:2026 – Sensitive Information Disclosure
- CFG006 (deny absent/empty) · CFG041 (
.env) · CFG042 (keys/certs) · CFG043 (cloud creds) · CFG037 (hook reads SSH keys)
Documentation for cfgaudit 1.18.1. Source of this page · Rule implementation