Skip to content

One Door, One ID: An Audit Tool That Cannot Be Fooled

Adityo Guni Waluyo

A review flagged read() following symlinks into .git and a lint matching IDs across file boundaries. What an evidence verifier owes its own integrity.

TL;DR

An audit tool review found two bugs. Its read() helper followed symlinks anywhere, so it now rejects all symlinks outright, dodging the race between resolving and opening. Also, joining lint corpus files with an empty string fused IDs across file boundaries, causing false matches; a newline separator fixed that.

Two findings arrived from a single review session on a small audit tool. First, the read() helper responsible for reading trace files would follow a symlink anywhere it pointed, including into .git/. Second, the lint corpus for Done cards joined files with an empty string, so half an ID at the end of file A could fuse with half an ID at the start of file B without ever looking like two separate files.

The first fix that comes to mind feels right: resolve the symlink with realpath, then check that the result still sits inside the allowed directory. The Python documentation ships exactly this primitive: os.path.realpath returns the canonical path, eliminating symbolic links encountered along the way [3].

Denying Proves Stronger than Resolving

For a read-only audit tool, the deny path wins. Instead of resolving links, the function now rejects every symlink outright by returning an empty string. No resolution, no time gap between resolution and opening, nothing to regress. The decision rests on an established security principle. CWE-59 defines improper link resolution before file access, also called link following, as accessing a file based on its name without preventing that name from resolving through a link to an unintended resource [1]; the consequences include unintended file reads and modification, plus bypass of protection mechanisms.

The symlink(7) manual page explains why: a symbolic link is a pointer to a name, not to an underlying object, and a link may refer to a pathname that does not exist at all [2]. Such links even have their own name, dangling links. That is exactly how a permissive read() gets silently redirected. If the link is swapped right after the canonical-path check but before the file opens, the audit tool still reads a file outside the allowed directory. The tool chose absolute denial deliberately: a read-only tool gains nothing from following links.

A Corpus Without Separators Makes a Verifier Lie

The second gap is subtler. When the corpus of text from many files is joined with an empty string, file boundaries become invisible to the machine. A file ending in B-12 followed by a file starting with 3 produces B-123, and the verifier reports a match for an ID that never existed. That false positive corrodes audit integrity: a verifier that reports success over broken evidence is far more dangerous than one that fails loudly.

The fix is one character: a newline separator per file when the corpus is assembled. Boundaries become visible again, and an ID straddling two files can no longer match.

One ID, One Command, the Whole Chain

The tool stands on a larger discipline. Every card marked Done must leave a literal card-ID footprint under the project's bookkeeping directory; the DONE-FOOTPRINT lint rejects cards without one, with a stated exception for the History archive. When an ID needs verification, trace.py walks it across every door at once: the board for task status, the decisions log for context, issue files for history, QA findings and scan results for evidence, down to git commits for the actual code change. One command, the whole chain.

NIST formulates the same principle for forensic evidence: chain of custody is a process that tracks the movement of evidence through its collection, safeguarding, and analysis lifecycle by documenting each person who handled it [4]. Engineering bookkeeping borrows that discipline with one advantage: engineering evidence can be checked by machines. A chain is valid when a script can re-derive it, not when a human retells it.

The precondition is one: the checker itself must not be foolable. A read cap of about five MB per file closes the memory-exhaustion vector. Symlink denial closes path redirection. The newline separator closes IDs fusing across files. Three small fixes in one session, and the evidence chain is trustworthy again.

Sources

[1] CWE-59: Improper Link Resolution Before File Access (Link Following)
[2] symlink(7) - Linux manual page
[3] os.path - Common pathname manipulations, Python documentation
[4] Chain of Custody - NIST CSRC Glossary

Sources: [1] https://cwe.mitre.org/data/definitions/59.html [2] https://man7.org/linux/man-pages/man7/symlink.7.html [3] https://docs.python.org/3/library/os.path.html [4] https://csrc.nist.gov/glossary/term/chain_of_custody

Related articles