Skip to content

.env.example is classified as credential access, manufacturing critical findings #44

Description

@blitzcrieg1

ENV_FILE in core/audit/detection/traits.py treats .env.example exactly like .env. Reading a committed template is classified as credential access, which manufactures the credential half of every sequence rule that depends on it.

This is not theoretical. It produced 6 of the 17 findings in dogfood week 1 (2026-08-08 to 08-14), five of them CRITICAL:

Rule Count Severity
credential-exfil 3 critical
dotfile-read-then-git-push 2 critical
untrusted-input-then-risky-action 1 high

Every one traced to a developer reading their own .env.example and then doing something ordinary in the same session: a git push, a WebSearch, a curl to a public API.

Why the classification is wrong

.env.example is a committed file of placeholder keys. That is its entire purpose, it is checked into the repository, and it is the file a project tells new contributors to copy. Reading one discloses nothing. The convention is near-universal, which is what makes the false positive systematic rather than occasional.

The pattern

ENV_FILE = re.compile(
    r"[\w.~$-]*[/\]\.env(?:\.[A-Za-z0-9_-]+)?\b|"
    ...

The suffix group (?:\.[A-Za-z0-9_-]+)? is doing correct and necessary work for .env.local and .env.production, which are real credential files. It also accepts every template convention:

.env              ENV_FILE=True   correct
.env.local        ENV_FILE=True   correct
.env.production   ENV_FILE=True   correct
.env.example      ENV_FILE=True   WRONG
.env.sample       ENV_FILE=True   WRONG
.env.template     ENV_FILE=True   WRONG
.env.dist         ENV_FILE=True   WRONG

Reproduce:

from agentmetry.core.audit.detection.traits import classify_command
classify_command("cat .env.example")   # ['credential_access']

and the mapper agrees, returning T1552.001, so _is_credential_access is satisfied by either classifier.

Proposed fix

Exclude the template suffixes rather than narrowing the suffix group, so .env.local and any future real variant keep working:

_ENV_TEMPLATE_SUFFIX = r"(?!\.(?:example|sample|template|dist|defaults?)\b)"

inserted before each (?:\.[A-Za-z0-9_-]+)?. Cheap, and it fails closed: an unrecognised suffix stays credential access.

Worth a corpus case in both directions, since the risk of a fix here is over-narrowing. .env.example must not fire; .env.production must still fire. The benign half is the one that regresses silently.

Not fixing this yet, deliberately

traits.py is one of the four files under the dogfood freeze fingerprint. Landing it restarts a four-week gate that is currently 1 of 4 weeks green. That is a real cost and it is not obviously worth paying to remove a false positive that is now understood, documented, and dispositioned.

Two options, and this issue exists partly to record the choice:

  1. Land it in the next freeze window and accept the clock restart.
  2. Hold until the gate closes, then land it as the first change after.

Leaning towards (2). The findings are noisy rather than dangerous, the triage notes explain them, and a gate that keeps restarting never certifies anything. But the argument for (1) is that four weeks of dogfooding against a ruleset with a known systematic false positive certifies the wrong ruleset, which is a fair objection.

Metadata

Metadata

Assignees

No one assigned

    Labels

    detection-ruleBehavioral sequence detection

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions