fix(go-audit): hash entries with length-prefix encoding, not | delimiter#2177
Merged
imran-siddique merged 1 commit intoMay 12, 2026
Merged
Conversation
…miter
computeHash concatenated AuditEntry fields with "|" as a separator before
hashing:
data := e.Timestamp.Format(...) + "|" +
e.AgentID + "|" + e.Action + "|" +
string(e.Decision) + "|" + e.PreviousHash
This was forgeable across field boundaries when any user-controlled field
contained a "|". For example AgentID="a", Action="b|c" produced the
identical pre-hash bytes "...|a|b|c|..." as AgentID="a|b", Action="c".
Both AgentID and Action are caller-supplied via AuditLogger.Log, so an
attacker who controls one field can fabricate an entry that hashes the
same as a legitimate one with the boundary shifted, breaking the
tamper-detection guarantee the hash chain is meant to give.
Replace the "|" concatenation with length-prefixed encoding:
[1 byte version] [4-byte BE length | bytes] x 5 fields
Length-prefix commits every field's length to the digest, so no value
can impersonate a different field-boundary layout. This is more
principled than JSON-encoding for a hash input — it has no schema
dependency, a deterministic byte layout, and no allocator surprises.
A 1-byte version prefix is reserved at the head so the encoding can be
evolved later without ambiguity.
Compatibility: the audit log is in-memory only and there is no
ImportJSON / load-and-verify path in this package, so changing the hash
input does not break any persisted-and-re-verified state. Verify()
continues to recompute hashes from in-store fields, so it always uses
the current format.
Regression tests:
- TestComputeHashSeparatorForgery — the canonical AgentID="a",
Action="b|c" vs AgentID="a|b", Action="c" pair, which collided under
the old format, now hashes differently.
- TestComputeHashDistinguishesFieldBoundaries — also exercises the
action/decision and decision/previous_hash boundaries.
All existing audit tests continue to pass.
🤖 AI Agent: docs-sync-checker — Docs SyncDocs Sync
|
🤖 AI Agent: breaking-change-detector — API CompatibilityAPI Compatibility
|
🤖 AI Agent: test-generator — `audit.go`
|
🤖 AI Agent: security-scanner — Security ReviewSecurity Review
|
🤖 AI Agent: code-reviewer — View detailsTL;DR: 0 blockers, 0 warnings. No issues found. Clean change. |
|
🟡 Contributor Check: MEDIUM
Automated check by AGT Contributor Check. |
PR Review Summary
Verdict: ✅ Ready for human review |
MohammadHaroonAbuomar
pushed a commit
to MohammadHaroonAbuomar/agt-acs
that referenced
this pull request
Jun 1, 2026
…miter (microsoft#2177) computeHash concatenated AuditEntry fields with "|" as a separator before hashing: data := e.Timestamp.Format(...) + "|" + e.AgentID + "|" + e.Action + "|" + string(e.Decision) + "|" + e.PreviousHash This was forgeable across field boundaries when any user-controlled field contained a "|". For example AgentID="a", Action="b|c" produced the identical pre-hash bytes "...|a|b|c|..." as AgentID="a|b", Action="c". Both AgentID and Action are caller-supplied via AuditLogger.Log, so an attacker who controls one field can fabricate an entry that hashes the same as a legitimate one with the boundary shifted, breaking the tamper-detection guarantee the hash chain is meant to give. Replace the "|" concatenation with length-prefixed encoding: [1 byte version] [4-byte BE length | bytes] x 5 fields Length-prefix commits every field's length to the digest, so no value can impersonate a different field-boundary layout. This is more principled than JSON-encoding for a hash input — it has no schema dependency, a deterministic byte layout, and no allocator surprises. A 1-byte version prefix is reserved at the head so the encoding can be evolved later without ambiguity. Compatibility: the audit log is in-memory only and there is no ImportJSON / load-and-verify path in this package, so changing the hash input does not break any persisted-and-re-verified state. Verify() continues to recompute hashes from in-store fields, so it always uses the current format. Regression tests: - TestComputeHashSeparatorForgery — the canonical AgentID="a", Action="b|c" vs AgentID="a|b", Action="c" pair, which collided under the old format, now hashes differently. - TestComputeHashDistinguishesFieldBoundaries — also exercises the action/decision and decision/previous_hash boundaries. All existing audit tests continue to pass.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
computeHashinaudit.gojoinedAuditEntryfields with a literal|separator before hashing. The separator was forgeable across field boundaries when any user-controlled field contained a|.For example, with the previous format:
ab|c...|a|b|c|...a|bc...|a|b|c|...Both fields are caller-supplied via
AuditLogger.Log, so an attacker who controls one of them can fabricate an entry that hashes identically to a legitimate one with the field boundary shifted — undermining the tamper-detection guarantee the hash chain is meant to provide.Change
Replace the
|-concatenated pre-hash with length-prefixed encoding:Each field's length is committed to the digest, so no value can impersonate a different field-boundary layout. A 1-byte version prefix is reserved at the head so the encoding can be evolved later without ambiguity. Length-prefix is more principled here than JSON-encoding the hash input: no schema dependency, deterministic byte layout, and no allocator surprises.
Compatibility
The audit log is in-memory only and there is no
ImportJSON/ load-and-verify path in this package —Verify()always recomputes hashes from in-store fields using the current format. So changing the hash input does not break any persisted-and-re-verified state.Tests
go test ./...fromagent-governance-golang/packages/agentmesh/passes. New regression tests:TestComputeHashSeparatorForgery— the canonicalAgentID=\"a\", Action=\"b|c\"vsAgentID=\"a|b\", Action=\"c\"pair, which collided under the old format, now hashes differently.TestComputeHashDistinguishesFieldBoundaries— exercises the action/decision and decision/previous_hash boundaries as well.All existing audit tests continue to pass (
TestAuditLogAndVerify,TestAuditHashDeterministic, the retention/seam tests, etc.).Test plan
go test ./...passes fromagent-governance-golang/packages/agentmesh/.|boundary now produce different hashes.Surfaced during independent audit conducted by @finnoybu (Ken Tannenbaum, AEGIS Initiative); [LOW, Go].