security: audit tail-truncation detection, writeRoots confinement, constant-time token#69
Conversation
…nstant-time token
Three hardening fixes from a security review of the trust boundary. None is a
classifier bypass; they close defense-in-depth gaps where a documented guarantee
did not fully hold.
- Audit tail-truncation is now detectable. A hash chain proves no past record was
edited or deleted from the middle, but a valid prefix still verifies — so deleting
the most-recent entries went unnoticed and a restart re-seeded from the truncated
tail. ChainedFileAudit can anchor the chain head in a 0600 <audit>.chk checkpoint
({ checkpoint: true }, which the streaming daemon enables), and
verifyAuditFile(path, { head, count }) pins the checkpointed prefix: fewer records
= truncated, the count-th record's hash not matching = rollback/regrow. Growth past
the checkpoint still verifies (no stale-checkpoint false alarm). The same-fs sidecar
raises the bar against accidental/naive truncation; a retained checkpoint on
separate-trust storage is the full-strength path. SECURITY.md scope updated to match.
- writeRoots confinement no longer bypassable by `..` traversal or a shared-prefix
sibling. The gate compared paths with a raw startsWith, so `src/../../etc/x`
(traverses out) and a `data` root admitting `database/...` both slipped the review
gate. Paths are normalized (./.. collapsed) and matched on a separator boundary.
- Daemon capability token compared in constant time. `!==` returned early on the
first differing byte — a timing oracle a local process on the loopback listener
could walk. Now a fixed-length SHA-256 digest compare via crypto.timingSafeEqual.
Tests: +11 (audit truncation/rollback/growth, writeRoots traversal/sibling, token
auth). Full suite 199/199. Bench unchanged (97% recall / 100% precision).
sprayberry-reviewer
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Verdict: no blocking issues — approving.
Reviewed: src/audit.mjs, src/daemon.mjs, src/index.mjs, src/cli.mjs, and all three test files. All CI checks pass (199/199 tests, CodeQL clean, arena benchmark unchanged).
What I checked
1. Audit tail-truncation (src/audit.mjs)
The fix is sound. The key logic path:
chainStateOfcounts only records with bothprevandhashpresent — exactly the same filterverifyAuditFileuses forn. Socheckpoint.countand the verification counternare always over the same population; a mixed chained/unchained log doesn't produce a false truncation alarm.- After
appendFileSyncsucceeds,this.prevandthis.countare updated beforewriteCheckpointis called. IfappendFileSyncthrows, neither state nor sidecar advances — no partial write. wantCount > 0is enforced on line 162, makingwantCountalwaysnullor a positive integer. The truthy guard on line 169 (if (wantCount)) is therefore consistent and can't misfire on 0.- The
wantHead-only branch (lines 190–191) correctly handles callers who pass{ head }without a count — it checksprev !== wantHeadafter the full scan, which is the right sentinel for a head-only checkpoint. - Growth past a stale checkpoint (
n > wantCount) correctly falls through without flagging; only the pinned prefix is verified.
Adversarial trace verified: attacker truncates a 3-record log to 1 record; n ends at 1, wantCount is 3; line 188 fires { ok: false, reason: 'truncated' }. ✓
2. writeRoots path confinement (src/index.mjs)
normPath uses path.posix.normalize after unifying separators, then strips a trailing / without a regex (correctly noted as ReDoS-avoidance). withinRoots uses root + '/' as the separator boundary, not a bare startsWith. Verified manually:
src/../../etc/x → normPath → '../etc/x' → startsWith('src/') = false ✓
database/evil → normPath → 'database/evil' → startsWith('data/') = false ✓
src/deep/a.ts → normPath → 'src/deep/a.ts' → startsWith('src/') = true ✓
/ root → target.startsWith('/') = true for any path ✓
The absolute-root case (root === '/') is handled by the ternary on line 42, avoiding a double-slash comparison. Clean.
3. Constant-time token comparison (src/daemon.mjs)
tokenDigest is a 32-byte Buffer (SHA-256). Any presented token — regardless of its string length — also produces a 32-byte digest, so crypto.timingSafeEqual never throws on a length mismatch. The typeof presented === 'string' ? presented : '' guard prevents a TypeError if a non-string token arrives from a malformed request. Verified: absent (undefined), short, and same-length-but-wrong tokens all produce a digest that does not compare equal. ✓
4. Tests
Eleven new tests cover exactly the scenarios addressed:
- Truncation, restart-across-checkpoint, file-deleted, rollback, truncate-and-regrow, stale-checkpoint growth, and back-compat (no sidecar written, legacy return shape preserved).
- Traversal, sibling directory, normal inside-root write.
- Token accept, wrong same-length, length-mismatch, and absent.
Every attack scenario the PR describes in its changelog has a corresponding regression test that would have caught the pre-fix behaviour.
What's good
The threat model is stated honestly and precisely in SECURITY.md: the sidecar catches accidental truncation and a naive attacker; a same-directory attacker who rewrites both files is explicitly documented as out of scope for the sidecar, with a clear path to full protection via separate-trust storage. This is exactly the right level of precision for a SECURITY.md clause. The "not addressed" note on the 64 KB scan cap is also a good-faith disclosure.
No blocking issues found.
What
Three hardening fixes from a security review of redstamp's trust boundary. None is a classifier bypass (the risk classifier held up to an adversarial read); each closes a defense-in-depth gap where a stated guarantee did not fully hold, verified by exploit repro before the fix and by regression tests after.
1. Audit tail-truncation is now detectable (the substantive one)
A hash chain proves no past record was edited or deleted from the middle, but a valid prefix still verifies — so an attacker who can write the log could delete the most-recent entries (the ones recording their own action) undetected, and a restart re-seeded from the truncated tail. SECURITY.md listed exactly this ("deletion of a past audit entry that
verifyAuditFile()fails to detect") as in-scope.ChainedFileAuditcan anchor the chain head in a0600<audit>.chkcheckpoint ({ checkpoint: true }, which the streaming daemon now enables).verifyAuditFile(path, { head, count })pins the checkpointed prefix: fewer records than promised →truncated; the count-th record's hash not matching →rollback(catches truncate-and-regrow). Growth past the checkpoint still verifies, so a stale checkpoint never false-alarms.expectedarg).2.
writeRootsconfinement no longer bypassable by../ shared-prefix siblingThe gate compared paths with a raw
startsWith, sosrc/../../etc/x(traverses out) and adataroot admittingdatabase/…(shared string prefix) both slipped the review gate. Paths are now normalized (./..collapsed) and matched on a separator boundary.3. Daemon capability token compared in constant time
req.token !== tokenreturned early on the first differing byte — a timing oracle a local process on the loopback listener could walk. Now a fixed-length SHA-256 digest compare viacrypto.timingSafeEqual(constant-time and length-independent).Verification
{ ok:false, reason:'truncated' }).npm run benchunchanged: 97% recall, 100% precision.verifyAuditFile(path)with no checkpoint returns the exact legacy shape.Not addressed (noted for follow-up)
The 64 KB input scan cap (
index.mjs) can drop a secret past detection on a padded NET body — a deliberate DoS tradeoff; a targeted fix (scan a window around each detected external host) is a separate change.