Audit log¶
Every fix Nephilex applies and every suppression that hides or accepts a finding
is recorded in .nephilex/audit.log, inside the repository. The file is meant to
be committed: it gives reviewers and auditors one place to answer "who
suppressed this, when, and why" and "what did the automated fixes change".
Records are hash-chained, so nephilex audit verify detects a record that was
edited, deleted, inserted or moved. Read What it proves
before relying on it as evidence.
What is recorded¶
| Action | When |
|---|---|
fix.applied |
nephilex fix (or apply_fixes) wrote a deterministic fix to a file. One record per fix. With --verify-with-tests, details.tests records the test result (passed; fixes whose tests did not pass are never applied). |
fix.pr_opened |
nephilex fix --open-pr opened a pull request for the fixes. One record per fix, with the PR URL. |
suppression.added |
Nephilex saw a suppression definition for the first time: an allowlist entry, a .nephilex/exceptions.yml entry, an inline nephilex-ignore comment (when it first hides a finding), or a baseline file. |
suppression.matched |
A suppression hid or accepted a finding. kind is allowlist, exception, inline or baseline. |
Suppression events are recorded once per content: a suppression.matched
record is keyed by kind, finding fingerprint and reason, and a
suppression.added record by kind, selector and reason. Scanning the same
code again adds nothing; changing the reason of a suppression records it
again, because that is a new decision.
Suppression events are written only when the project configuration is
trusted and the target is a local directory, the same rule that decides
whether inline comments and exceptions are honoured at all (see
Which configuration is trusted).
Scans of cloned remotes, --config-from-ref runs, nephilex agent and nephilex mcp
never write to the scanned tree.
Baseline filtering (--baseline on nephilex scan, nephilex import and
nephilex review) runs in the command after the scan and is recorded under the
same rule: one suppression.added per baseline file content and one
suppression.matched per finding it hid. Other callers can use the
nephilex.audit.record_baseline helper.
Turning it off¶
Set audit_log: false in .nephilex.yml, or NEPHILEX_AUDIT_LOG=off in the
environment (also 0, false, no). Both also cover the fix.applied
and fix.pr_opened records of nephilex fix.
Format¶
JSON Lines, ASCII only, one record per line. Every record has the same keys,
with null where a field does not apply:
{"action":"suppression.matched","actor":"Jane Developer <jane@example.com>",
"details":{"line":12,"selector":"python-shell-true","severity":"high","until":null},
"fingerprint":"patterns.python-shell-true|tools/run.py|5d41402abc4b",
"hash":"9c1e...","kind":"inline","path":"tools/run.py",
"prev_hash":"77ab...","reason":"command is a constant list","rule":"patterns.python-shell-true",
"seq":42,"ts":"2026-10-22T09:14:03.120Z","v":1}
(Shown wrapped; in the file each record is one line.)
| Field | Meaning |
|---|---|
v |
Record format version, currently 1. |
seq |
Position in the chain, starting at 1. |
ts |
UTC time, millisecond precision. |
actor |
user.name <user.email> from git config, else GIT_AUTHOR_NAME/GIT_AUTHOR_EMAIL, else the OS user name. |
action |
One of the actions above. |
kind |
For suppressions: allowlist, exception, inline, baseline. |
fingerprint |
The finding's stable fingerprint, the same value baselines and exceptions use. |
rule, path |
Rule id and project-relative path. |
reason |
The suppression's reason, or for fixes whether the fix was verified. |
details |
Extra context: line, selector, expiry, exception status and owner, branch, commit, PR URL. |
prev_hash |
hash of the previous record; 64 zeros for the first. |
hash |
SHA-256 of the record's canonical form. |
The hash chain¶
The canonical form of a record is its JSON with every key except hash,
keys sorted, no whitespace (separators=(",", ":")), ASCII only. hash is
the hex SHA-256 of those bytes. To check a record by hand in Python:
import hashlib, json
rec = json.loads(line)
body = {k: v for k, v in rec.items() if k != "hash"}
canon = json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
assert hashlib.sha256(canon.encode("ascii")).hexdigest() == rec["hash"]
Each record's prev_hash must equal the previous record's hash, and seq
must count up by one. Editing any field changes the hash; deleting,
inserting or reordering a record breaks a prev_hash link or the seq
count at that point.
What is never written¶
The log is committed, so it must not leak what the scanner found. Nephilex never writes code lines or finding snippets to it (fixes record rule, path, line, fingerprint and verification state only). Every string field is passed through the same secret masking the secrets scanner uses for report snippets, and credentials in URLs are removed, so a token pasted into a suppression reason is masked. Fields are capped at 500 characters.
Commands¶
nephilex audit verify [PATH] [--anchor HASH] [--json]
nephilex audit show [PATH] [--action A] [--kind K] [--rule R] [--path GLOB]
[--actor TEXT] [--fingerprint FP] [--since DATE]
[--until DATE] [-n N] [--json]
nephilex audit export [PATH] -f csv|json [-o FILE] [--action A] [--kind K]
[--since DATE] [--until DATE]
PATH is the project directory (default .) or the log file itself.
verifyexits 0 and prints the record count and head hash when the chain is intact, and exits 1 naming the first broken record (line number,seqand what is wrong) otherwise. Without a log it exits 0, unless--anchoris given.showlists records oldest first, filtered;-n 20shows the last 20 matches;--jsonprints JSON Lines.export -f jsonwrites the matching records together with the chain's verification result;-f csvwrites one row per record, withdetailsas JSON and cells that a spreadsheet would evaluate as a formula prefixed with'.
Concurrency and failure¶
Writers take an exclusive lock on the log (flock on Linux and macOS; on
Windows a byte-range lock placed past the end of the file, so readers are
never blocked), read the current head under the lock, and append all of their
records in one write. Parallel scans and fixes therefore extend one chain
instead of forking it. A writer waits up to 15 seconds for the lock.
The audit log never fails a scan or a fix. If the log cannot be written (the
directory is read-only, .nephilex is not a directory, the lock times out, or
the log or .nephilex is a symlink, which is refused so a hostile repository
cannot redirect the append), the run continues and the problem is reported
as a warning and in the report's project_summary.audit.warning. If the last
line is damaged (for example a crash mid-write), new records are still
appended on a fresh line and verify points at the damaged line.
With nephilex fix¶
nephilex fix refuses to run on a working tree with uncommitted changes, with
one exception: .nephilex/audit.log itself, because the scan that produced the
findings may just have appended to it. The fix commit includes the log, so
the fix.applied records travel with the fixes they describe. If .nephilex/ is
ignored by git, the log is left out of the commit and a note says so (add
!.nephilex/audit.log to .gitignore to commit it). If the fix fails and is
rolled back, the log is restored to its state before the run.
fix.pr_opened is appended after the branch is pushed, so it is in the
working tree of the fix branch, not in the pull request. Commit it with your
next change.
What it proves and what it does not¶
The log is tamper-evident, not tamper-proof.
It does show:
- that no record in the middle of the chain was edited, removed, inserted or reordered since it was written, provided the chain verifies;
- the order in which fixes and suppressions were recorded, and the reason given for each suppression at the time.
It does not show, on its own:
- that nothing was removed from the end. Truncating the last records leaves a valid, shorter chain;
- that the log was not rewritten. Anyone who can write the file can recompute every hash and produce a valid chain with different content;
- who acted.
actorcomes from git config, which anyone can set; it is a claim, not an authenticated identity; - when. Timestamps come from the writer's clock;
- that every suppression was recorded. Runs with the log disabled, untrusted runs, and suppressions applied by other tools are not in it.
To close the first two gaps, anchor the chain outside the repository: after
each merge to the main branch, have CI run nephilex audit verify --json and
store the head value somewhere the repository's writers cannot change (a CI
artifact, a release note, a signed tag, a transparency log). Later,
nephilex audit verify --anchor <stored head> fails unless that record is still
in the chain, which catches truncation and wholesale rewrites. Git history
helps too: the log is committed, so git log -p .nephilex/audit.log shows every
change to it, and protected branches with signed commits make rewriting it
visible. For identity, rely on your forge's authenticated commit and review
records, and treat actor as a convenience.