Audit Evidence

Does obfuscation break audit log integrity?

An audit trail is the control you never use until the day it is the only thing you have. Its value is entirely in properties you cannot see by looking at it: that rows are chained so a deletion is detectable, that rows cannot be rewritten, that the chain is actually checked rather than assumed, and that entries carry enough to identify who did what. Each of those is a property name on an options object read by a library you installed. We measured what happens to each.

What the sample actually does

The file configures a tamper-evident audit trail the way a careful team configures it. Entries are hash-chained, so each carries the digest of the one before it and removing a row breaks every row after it. The trail is append-only, so a row cannot be rewritten in place. It is verified on read rather than trusted. Three fields are required on every entry - who, what, when. Card numbers are redacted. And the application signs entries with its release key.

The trail is then exercised against tampering that only one guard catches. Three ordinary entries go in. An entry with no actor on it must be refused, because a row that does not say who did something is not evidence. An entry carrying a card number must be stored redacted. An attempt to rewrite the refund row after the fact must be refused. And a stored chain with its middle row deleted - exactly what a chain exists to reveal - must be reported broken.

Before any protected arm was read, each option was deleted from the sample's own configuration and the output compared. Every option moved the result except the one pinned equal to the library's default.

Protection alone was applied first, on five presets. All five behaved identically to the unprotected file. Every result below required member renaming aimed at the names.

The deleted row that nobody detected

Renaming the verify-on-read option reverted it to the library's default of false. The measured line went from stored-chain-check=ran intact=false (broken-link-at-1(expected aaaa1111 found bbbb2222)) to stored-chain-check=SKIPPED intact=true (not-verified-on-read), and deleted-row-detected went from true to false.

Read the second half of that carefully. The check did not fail. It did not run, and the function returned intact=true anyway, because that is what a verifier that has been asked not to verify returns. The tampered chain reported itself intact. The row is still missing, the digests still do not line up, and the only thing that would have said so was consulted and declined.

Renaming the hash-chain option produced the same end state by a different route: entries stopped carrying digests at all (trail=signin[no-digest] -> refund-order[no-digest] ...), and the verifier reported no-chain-to-verify, again with intact=true. Renaming both together was, as expected, the same silence.

This is the general hazard with any integrity control: the honest answer to "is the chain intact?" and the answer to "did anything tell us otherwise?" are the same word, and only one of them means what you want it to mean. A verifier that skipped should report skipped, not intact, and the fact that it does not is a design decision you inherit from the library.

Renaming the append-only option let the trail be edited in place: rewrite-the-refund-row=refused(append-only-trail) became REWRITTEN(row-rewritten), and the refund row in the trail became a view-order row. Note what that arm does not do - it does not update the digest, so the chain would catch it. Unless, of course, verification has been switched off by the option two paragraphs up.

A card number in the compliance evidence

Two separate arms put unredacted card data into the trail, by two different mechanisms, and both are worth naming because the second one is not the one people expect.

Renaming the redaction-list option reverted it to the library's default of redacting nothing. card-in-trail=false became true: a full card number written verbatim into a log that is, by design, exported to auditors and retained for years.

Then renaming the cardNumber field itself on the entry your code builds produced exactly the same outcome. The redaction list is a list of strings - the characters cardNumber - and the library compares them against the keys of the object you hand it. Rename the key and the comparison misses. The redaction is still configured, still enabled, still running over every entry, and matching nothing.

That second mechanism is the one to remember: a security rule written as data does not move when the property name it refers to moves. The rule keeps running and reports itself installed. It is the same reason a denylist of forbidden keys stops blocking anything after a rename - and it is why an allowlist of fields you will write is the more robust shape here, since a field it cannot recognise is simply not written.

Renaming the required-fields option went the same permissive direction: the entry with no actor on it, recording a delete-account, was appended. The chain then reported chain-intact(5 entries) - one longer than before, perfectly valid, and containing a row that does not say who deleted the account.

Renaming the signer substituted the library's builtin, a constant, so every entry carries the same meaningless signature. The trail still looks signed.

The half that failed closed

Not everything here failed open, and the parts that did not are worth reporting honestly, because they are the shape you want.

Renaming the entries' required field names - the actor and action keys on objects this file builds and hands to the library - refused every entry: entry-signin=APPENDED(appended) became refused(missing-required(actor)), and so did every other entry in the file. The required-fields rule is a list of strings that no longer matches the renamed keys, so nothing satisfies it. That is a total loss of the audit trail, but it is a loud one - every append fails, and any caller checking the return value knows immediately.

The chain-link fields behaved the same way. Renaming the digest and previous-digest fields on a chain rebuilt in code left the verifier reporting a break: broken-link-at-0(expected genesis found undefined). An integrity check that cannot read the links says the chain is broken, which is the correct direction to fail. It will wake somebody up, and the somebody it wakes will be looking at a real chain that is fine.

There is one nuance measured alongside it, and it repeats a finding from earlier in this pass. The stored chain - the one parsed from text, as it would arrive from a database - was completely unaffected by those renames, because keys inside a JSON document are characters and not names. Only the chain rebuilt in code moved. Your archived evidence is not at risk from renaming; the objects you assemble in memory to verify it are.

What to do about it

Scope the rename. RenameMembers takes a MemberRegexp, and the option keys and entry field names of anything you did not compile yourself belong outside it. For this area that includes the field names your redaction list refers to, which are data on one side of the comparison and code on the other.

Then fix the one thing that is not about obfuscation at all: make your verifier distinguish "checked and intact" from "not checked". A function that returns intact when it was never asked to verify will mislead you on the worst possible day, whether the cause is a renamed option, a configuration mistake, or a flag somebody flipped during an incident and forgot.

Probe rather than review. Delete a row from a copy of the trail and require the verifier to report it broken. Write an entry containing a card-shaped string and grep the stored trail for it. Attempt to rewrite a row and require a refusal. Append an entry with no actor and require it to be rejected. Four probes, and every arm in this article fails at least one of them.

Prefer an allowlist of fields you will write over a denylist of fields you will redact. A field the allowlist does not recognise is not written; a field the denylist does not recognise is written verbatim, and in this measurement that field was a card number.

Frequently asked questions

Does protecting my JavaScript break audit logging on its own?

Not in this measurement. The sample was protected on five profiles covering both output targets, the gate profile and the compressed profile, and all five behaved identically to the unprotected file. Every result required member renaming pointed at property names.

What was the worst result?

Renaming the verify-on-read option. A chain with a deleted row reported itself intact, because a verifier that has been asked not to verify returns intact rather than reporting that it was skipped.

How did a card number end up in the trail?

Two ways. Renaming the redaction-list option reverted it to redacting nothing. Separately, renaming the cardNumber key on the entry object made the redaction list - which is a list of strings - stop matching, so the rule ran over every entry and matched nothing.

Is my archived audit data at risk?

No. Keys inside stored JSON are text and arrive spelled the way they were written, so the installed library reads them fine. Only the chain objects rebuilt in code moved, which is the opposite of the intuition most people have.

Did any of this fail safely?

Yes, and it is worth knowing which. Renaming the entry field names refused every append, and renaming the chain-link fields made the verifier report a break. Both are total losses of function but they are loud, and loud is recoverable.

Why prefer an allowlist for redaction?

Because a field an allowlist does not recognise is never written, while a field a denylist does not recognise is written verbatim. In this measurement that difference was a full card number in a log retained for years and exported to auditors.

What is the smallest change that prevents all of this?

Scope RenameMembers with a MemberRegexp that excludes installed dependencies' option and entry field names, then make the verifier report skipped separately from intact and probe it by deleting a row from a copy of the trail.

Related reading