feat: add audit pattern harness and security patterns example#5835
feat: add audit pattern harness and security patterns example#5835moul wants to merge 32 commits into
Conversation
Assisted-By: OpenAI Codex
🛠 PR Checks SummaryAll Automated Checks passed. ✅ Manual Checks (for Reviewers):
Read More🤖 This bot helps streamline PR reviews by verifying automated checks and providing guidance for contributors and reviewers. ✅ Automated Checks (for Contributors):🟢 Maintainers must be able to edit this pull request (more info) ☑️ Contributor Actions:
☑️ Reviewer Actions:
📚 Resources:Debug
|
Assisted-By: OpenAI Codex
…odex/audit-guide-examples
Assisted-By: OpenAI Codex
Assisted-By: OpenAI Codex
Assisted-By: OpenAI Codex
Assisted-By: OpenAI Codex
Assisted-By: OpenAI Codex
Assisted-By: OpenAI Codex
Assisted-By: OpenAI Codex
Assisted-By: OpenAI Codex
Assisted-By: OpenAI Codex
- Remove 7 relative file links in community-packages.md and gno-data-structures.md that referenced packages not present in the examples/ directory; replace with plain code-text package paths so the docs linter no longer flags them - Fix avl/v0/list broken link in gno-data-structures.md (avl/v0 has no list subdir); point to avl/v0/README.md instead - Add missing `gno = ""` to examples/gno.land/r/docs/security_patterns/gnomod.toml so mod-tidy diff is clean Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- run.go: forward os.Environ() to gno test subprocess so GNOROOT reaches the child process in dev setups - run.go: tag output with "[timeout]" when ctx deadline is exceeded so callers can distinguish timeout from test failure - run.go: remove omitempty from GNOTestOutput so JSON always includes it, making failures debuggable without re-running in markdown mode - run.go: tighten callbackParamHits to check non-trimmed line prefix so indented function literals inside bodies are not mistaken for top-level callback-parameter declarations - record.go: add ValidatePaths() and call it from LoadRecord so fixture paths that don't exist are caught at load time rather than at gno test - run_test.go: anchor all fixture/spec paths via runtime.Caller(0) so tests are robust when invoked outside the normal go test cwd - security_patterns.gno: add comment explaining assertAdmin is a non-crossing helper and why the caller must pass cur down - security_patterns_test.gno: add comment explaining why cross(cur) is needed to shift PreviousRealm for the test's user realm - README.md: add Known Limitations section covering current_guard helper false negatives, payment_user_call IsUser vs IsUserCall detection, render_markdown_escape intermediate-variable false negatives, and the spec corpus test's keyword-only checking Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
I'm looking for individuals to help me challenge and improve the following:
The goal is to release a V1 and then iterate. We aim to use the documentation increasingly to start writing and reviewing realms based on it. We'll definitely want to update gnoverse/gno-mcp. |
Add gno-security-guide.md to the before-writing reading list and add a dedicated Security section with a trigger table covering the eight known vulnerability families (caller identity, OriginSend guard, cross-realm access control, Render path sanitization, exported state pointers, callback parameters, interface realm exposure). Also points to the audit pattern harness in misc/audit-pattern-harness/ as a quick sanity check for unfamiliar realm code. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| if exportedPointerVarRE.MatchString(trimmed) { | ||
| return true | ||
| } | ||
| return strings.HasPrefix(trimmed, "func ") && |
There was a problem hiding this comment.
This won't catch bad formatted files:
func GetVault() *Vault { ✅ hit
func GetVault()*Vault{ ❌ miss
func (vault *Vault) GetVault()*Vault{ ❌ miss
maybe use a regex also here ? I don't know if it makes sense also to catch for methods
var exportedFuncPointerRE = regexp.MustCompile(^func\s+(?:([^)])\s+)?[A-Z]\w\s*(.)\s*)
works also for methods
There was a problem hiding this comment.
I will add a formatting preliminary step.
There was a problem hiding this comment.
Done in 278bd24 — added a gofmt pre-step (go/format.Source) that normalizes each .gno file before the matchers run, so func GetVault()*Vault{ is canonicalized to func GetVault() *Vault { and gets caught. Falls back to the raw bytes if a fixture is intentionally unparseable. Added TestRuleNormalizesFormatting covering your exact example.
Methods (func (v *Vault) Get() *Vault) are still out of scope here — the exported-pointer matcher only targets top-level funcs — but the formatting fix removes the spacing-based misses.
There was a problem hiding this comment.
Ran the reference realm and the harness guards on 34ac1e7; each rejects the attacker case it claims to.
The new audit-pattern-harness module is absent from the ci-dir-misc.yml matrix, so its Go tests and TestAgentPatternContract never run in CI. Add it to the matrix.
First review pass, I need to do a second one.
|
|
||
| | Trigger | What to look for | | ||
| |---------|-----------------| | ||
| | Caller-identity checks | Use `cur.Previous().IsUserCall()`, not `IsUser()` or `OriginCaller()`, for auth | |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
There was a problem hiding this comment.
Fixed in 02640e1 — row now says authenticate the immediate caller with cur.Previous() under a cur.IsCurrent() guard, and notes IsUserCall() is for the payment row only.
| func escapeRenderText(s string) string { | ||
| return md.EscapeText(s) | ||
| } |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
There was a problem hiding this comment.
Fixed in 02640e1 — swapped to sanitize.InlineText. Verified gno test still passes: InlineText escapes [ ] ( ) > (the inlineEscapeSet), so the existing escaping assertions hold.
|
|
||
| ```go | ||
| func SetPaused(cur realm, next bool) { | ||
| if runtime.OriginCaller() != owner { |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
There was a problem hiding this comment.
Fixed in 02640e1 — dropped the stale runtime. qualifier; the snippet now uses bare OriginCaller() like the rest of §5.8.
| vulnerableHits, err := RunRule(rec.Rule, vulnerable.Path) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if len(vulnerableHits) != vulnerable.WantPatternHits { | ||
| t.Fatalf("vulnerable hits: got %d, want %d: %+v", len(vulnerableHits), vulnerable.WantPatternHits, vulnerableHits) | ||
| } | ||
|
|
||
| fixedHits, err := RunRule(rec.Rule, fixed.Path) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if len(fixedHits) != 0 { | ||
| t.Fatalf("fixed fixture still has pattern hits: %+v", fixedHits) | ||
| } |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
There was a problem hiding this comment.
Addressed in ee38075 — TestAgentPatternContract now asserts each vulnerable hit's text contains the rule's detection signal (.Previous(), OriginSend(), range , *, …), so a rule rewritten to flag a coincidental line (an import, etc.) fails. Full per-line golden assertions across all rules would be stronger still — left as a follow-up.
| @@ -0,0 +1,219 @@ | |||
| # Community Packages | |||
|
|
|||
| Gno packages under `examples/gno.land/p/...` are useful references and may be | |||
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
There was a problem hiding this comment.
Fixed in ee38075 — added an intro note explaining the (quarantined) marker and flagged all 7 quarantined-only packages (collection, bitset, pausable, query, forms, blog, chess).
| @@ -0,0 +1,2 @@ | |||
| module = "gno.land/r/docs/security_patterns" | |||
| gno = "" | |||
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
| seenIsCurrent = false | ||
| } | ||
| if inFunc { | ||
| braceDepth += strings.Count(line, "{") |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
There was a problem hiding this comment.
Fixed in ee38075 — added codeLines() (go/scanner) that blanks string/char-literal contents and comment bodies before matching, so a } (or keyword/call) inside a literal can no longer fool brace-depth tracking or the substring checks. Hits are still reported against the original source. Added TestBraceInStringNoFalsePositive (your repro). The 5-scanners→2 refactor I left as a follow-up.
| package hook | ||
|
|
||
| type Notifier interface { | ||
| Notify(cur realm, message string) |
There was a problem hiding this comment.
The interface-realm-param and callback-param slices show the bad pattern, a realm handed to caller-supplied code, but not the safe one: threading cur through your own concrete /p/ functions, which daokit's interrealm-v2 port needs. The danger is specifically a caller-supplied func or interface value, because a realm token grants authority only while cur.IsCurrent() holds. Spell that out in one sentence, or readers avoid passing realms to /p/ at all and lose the safe threading pattern.
There was a problem hiding this comment.
Good point, not done yet. The safe pattern — thread your own cur through concrete /p/ functions; a realm token only grants authority while cur.IsCurrent() holds — deserves an explicit sentence (the danger is specifically a caller-supplied func/interface). Ill add it to the security guide alongside the callback/interface families; open to where youd prefer it.
There was a problem hiding this comment.
It would suit the best at §5.3, right under the "never invoke a caller-supplied function/interface value" rule.
Co-authored-by: Miguel Victoria Villaquiran <miguelvicvil@gmail.com>
There was a problem hiding this comment.
Verified on e8281bc.
Follow-up, not blocking: AGENTS.md:98 positions the harness as a regular sanity check, but the docs name it only in one hard-to-spot bullet. Surface it somewhere discoverable like the quickstart.
After this, I think this LGTM
| func readGnoSource(file string) ([]byte, error) { | ||
| data, err := os.ReadFile(file) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| formatted, err := format.Source(data) | ||
| if err != nil { | ||
| return data, nil | ||
| } | ||
| return formatted, nil | ||
| } |
There was a problem hiding this comment.
On code that is not already gofmt-clean, the reported file:line and text come from the gofmt-reformatted buffer, not the on-disk file, so a hit can point at the wrong line. Committed and editor-formatted realms are already gofmt-clean, so this only bites on unformatted input such as generated or pasted code.
| func Render(path string) string { | ||
| out := "# Security Patterns\n\n" | ||
| out += "Admin: `" + admin.String() + "`\n\n" | ||
| out += "Message: " + escapeRenderText(message) + "\n" | ||
| if path != "" { | ||
| out += "\nPath: `" + escapeRenderText(path) + "`\n" | ||
| } | ||
| return out | ||
| } |
There was a problem hiding this comment.
The patterns are implicit: Render prints only the admin, message, and path, and only assertAdmin has a comment. Add a short explanation in Render, which gnoweb renders as the page body, or comments on the code, saying what each pattern is and why it is safer: the cur.IsCurrent() guard, cur.Previous().Address() over OriginCaller(), and InlineText on user text.
| out += "Admin: `" + admin.String() + "`\n\n" | ||
| out += "Message: " + escapeRenderText(message) + "\n" | ||
| if path != "" { | ||
| out += "\nPath: `" + escapeRenderText(path) + "`\n" |
There was a problem hiding this comment.
A backtick in path closes the manual code span, and path reaches Render as arbitrary bytes. Backslash-escaping does not help, since a backslash is literal inside a code span. md.InlineCode(path) is the safe primitive.
repro
# from a local clone of gnolang/gno:
gh pr checkout 5835 -R gnolang/gno
go build -o /tmp/gno ./gnovm/cmd/gno
export GNOROOT=$(pwd)
cd examples/gno.land/r/docs/security_patterns
cat > zz_bt_test.gno <<'GO'
package securitypatterns
import "testing"
func TestBacktickPath(t *testing.T) { t.Logf("%q", Render("a`b")) }
GO
/tmp/gno test -v -run TestBacktickPath .
rm zz_bt_test.gnozz_bt_test.go:5: "# Security Patterns\n\n...\n\nPath: `a\`b`\n"
The ````` is the escape, but inside a code span the backslash is literal, so the span closes at the user's backtick and b lands outside it with a dangling backtick.
| if strings.Contains(line, "range ") { | ||
| // Normalize to remove extra spaces: range var -> range var | ||
| normalized := strings.Join(strings.Fields(line), " ") | ||
| for name := range mapVars { | ||
| if strings.Contains(normalized, "range "+name) { |
There was a problem hiding this comment.
After the whitespace-normalization fix, render_map_iteration still matches range +name as a substring with no right word boundary, so a map named scores flags range scoresList, an unrelated slice. A word boundary after the map name removes it.
repro
# from a local clone of gnolang/gno:
gh pr checkout 5835 -R gnolang/gno
cd misc/audit-pattern-harness
cat > internal/auditpattern/zz_mapfp_test.go <<'GO'
package auditpattern
import (
"os"
"path/filepath"
"testing"
)
func TestMapIterSubstringFP(t *testing.T) {
d := t.TempDir()
src := "package x\n\nvar scores = map[string]int{}\nvar scoresList = []int{}\n\nfunc Render(path string) string {\n\tfor _, v := range scoresList {\n\t\t_ = v\n\t}\n\treturn \"\"\n}\n"
os.WriteFile(filepath.Join(d, "a.gno"), []byte(src), 0o644)
hits, _ := RunRule("render_map_iteration", d)
if len(hits) != 0 {
t.Fatalf("unrelated slice flagged: %+v", hits)
}
}
GO
go test -count=1 -run TestMapIterSubstringFP ./internal/auditpattern/
rm internal/auditpattern/zz_mapfp_test.go--- FAIL: TestMapIterSubstringFP (0.00s)
unrelated slice flagged: [{File:a.gno Line:7 Text:for _, v := range scoresList {}]
FAIL
| return hits, nil | ||
| } | ||
|
|
||
| func originCallerAuthHits(dir string) ([]Hit, error) { |
There was a problem hiding this comment.
origin_caller_auth flags every OriginCaller() read, including a benign emit("actor", unsafe.OriginCaller().String()) with no comparison and no auth. exported_pointer_leak also flags func NewVault() *Vault, a normal constructor whose returned pointer is to a fresh object, not shared state, so there is nothing to leak. Neither rule has a README "Known limitations" entry, so tighten the heuristics or extend the caveats.
repro
# from a local clone of gnolang/gno:
gh pr checkout 5835 -R gnolang/gno
cd misc/audit-pattern-harness
cat > internal/auditpattern/zz_fpfn_test.go <<'GO'
package auditpattern
import (
"os"
"path/filepath"
"testing"
)
func write(t *testing.T, src string) string {
d := t.TempDir()
os.WriteFile(filepath.Join(d, "a.gno"), []byte(src), 0o644)
return d
}
func TestOriginCallerBenignFP(t *testing.T) {
src := "package x\n\nimport \"chain/runtime/unsafe\"\n\nfunc Log() {\n\temit(\"actor\", unsafe.OriginCaller().String())\n}\n"
t.Logf("origin_caller_auth benign-read hits: %+v", mustRun(t, "origin_caller_auth", write(t, src)))
}
func TestConstructorFP(t *testing.T) {
src := "package x\n\ntype Vault struct{ B int }\n\nfunc NewVault() *Vault {\n\treturn &Vault{}\n}\n"
t.Logf("exported_pointer_leak constructor hits: %+v", mustRun(t, "exported_pointer_leak", write(t, src)))
}
func mustRun(t *testing.T, rule, dir string) []Hit { h, _ := RunRule(rule, dir); return h }
GO
go test -count=1 -v -run 'TestOriginCallerBenignFP|TestConstructorFP' ./internal/auditpattern/
rm internal/auditpattern/zz_fpfn_test.go origin_caller_auth benign-read hits: [{File:a.gno Line:6 Text:emit("actor", unsafe.OriginCaller().String())}]
exported_pointer_leak constructor hits: [{File:a.gno Line:5 Text:func NewVault() *Vault {}]
There was a problem hiding this comment.
Addressed in 3700f767f.
I tightened both heuristics and added regression tests:
origin_caller_authnow requires a direct==or!=comparison aroundOriginCaller(), so benign logging likeemit("actor", unsafe.OriginCaller().String())is ignored.exported_pointer_leaknow ignores fresh constructors shaped likeNewX() *X { return &X{} }, while keeping hits for exported pointer globals and pointer getters that can alias package state.- Added
TestOriginCallerBenignReadandTestExportedPointerLeakIgnoresFreshConstructor. - Added README caveats for the remaining heuristic limits.
| - `gnovm/tests/files/zrealm_launder_*.gno` — exploit-attempt filetest | ||
| corpus referenced throughout this guide. Each test is annotated | ||
| with the attack mechanism and why it succeeds or fails. | ||
| - `misc/audit-pattern-harness` — audit pattern harness fixtures and expected records |
There was a problem hiding this comment.
The docs point a human at the harness but never document it: how to run it, what it outputs, how to read a result. This matters even agent-first: when a human does not understand a flagged result, the agent needs human docs to redirect them to. Document it in the harness README and link this bullet to it.
There was a problem hiding this comment.
Addressed in 3700f767f.
I expanded misc/audit-pattern-harness/README.md with:
- how to run the harness;
- how to read the markdown report;
- what
gno test, pattern-hit counts, and hit lines mean; - known limitations for the tightened heuristic rules.
I also changed this security-guide bullet to link directly to the README, so humans and agents have a concrete place to land.
| var owner = address("g1qz8e0fz3y0pl9y4dq9d7c5dwnyu6qf04hs7z0a") | ||
|
|
||
| func TransferOwnership(cur realm, next address) { | ||
| if cur.Previous().Address() != owner { |
There was a problem hiding this comment.
None of the 16 fixtures carry a comment, so a human reading a vulnerable/fixed pair learns nothing about what is wrong or what the fix changed. Add a one-line comment marking the vulnerable construct and the fix, here the cur.Previous() read with no cur.IsCurrent() guard.
There was a problem hiding this comment.
Addressed in 3700f767f.
I added one-line teaching comments to every vulnerable/fixed fixture pair, including this one. For current-guard, the vulnerable fixture now calls out the unguarded cur.Previous() read, and the fixed fixture calls out the cur.IsCurrent() guard before reading the previous frame.
|
Pushed Changes included:
Local checks run:
|
|
Two new patterns surfaced while analyzing agent-generated contracts against the audit harness — both are missing from the current pattern set. Missing pattern:
|
…xamples # Conflicts: # AGENTS.md
|
I imagine from this latest comment that there's a bit of work yet to be done on this PR? (I can review if not) |
## Summary Adds `docs/resources/gno-ai-contract-review.md` — a short, checklist-style guide specifically for AI agents reviewing Gno realm code. The guide covers the seven highest-yield issues: 1. Caller identity via `cur realm`, not attacker-controlled `address` parameters 2. Payment guards: `IsUserCall()` not `IsUser()` 3. No exported pointers to mutable state 4. No caller-supplied callbacks invoked under realm authority 5. Interface parameters need canonical-type assertion 6. Never store `realm` values (store `Address()` strings instead) 7. `/p/`-embedded types with callback iterators must stay unexported ## Relationship to existing work This is intentionally more minimal than the other security resources: | Resource | Scope | |----------|-------| | `gno-security-guide.md` | Deep technical threat model, borrow rules, proofs — for realm authors who want to understand why | | `misc/audit-pattern-harness/` ([#5835](#5835)) | Automated tooling: YAML-defined patterns, sanitized fixtures, `gno test` execution, Markdown/JSON reports | | [gno-mcp audit skill](https://github.com/gnoverse/gno-mcp/tree/main/skills/gno-audit) | MCP-packaged harness for AI agents to run audits programmatically | | **this file** | Concise checklist an AI can apply inline during code review without tooling | The audit harness and gno-mcp skill are powerful when you want to run automated checks. This guide fills the lighter-weight use case: an AI agent reading a contract and knowing what to look for, without needing a separate tool invocation. --------- Co-authored-by: Morgan <morgan@morganbaz.com>
Summary
Moves the audit pattern harness into this repo and keeps the public builder-facing updates alongside it.
This adds:
misc/audit-pattern-harness: a small audit pattern harness with YAML expected records, sanitized fixtures,gno testexecution, and Markdown/JSON reports viacmd/auditpatterncur.Previous()beforecur.IsCurrent()Render(path)markdown outputRenderoutput that depends on map iteration orderOriginSend()without anIsUserCall()guardOriginCaller()used as authorization identitycur realmgno.land/r/docs/security_patterns: a compact example realm showing authenticated mutators and safe Render outputgno-data-structures.mdcommunity-packages.md: a disclaimer-backed place for non-official helper packages such asmoul/md,ulist,addrset, andfifoThe additional patterns were calibrated from local
examples/plus checked-out Gno contract repos including GnoSwap, Akkadia, gno-ibc, and gno-mcp fixtures, then reduced into sanitized fixtures rather than copying source.Validation
go test ./...inmisc/audit-pattern-harnessmake run GO=/home/russel/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.25.9.linux-amd64/bin/go AUDIT_PATTERN_FLAGS='-gno-bin /tmp/gno-audit-harness-gno'inmisc/audit-pattern-harnesswithGNOROOT=/home/russel/p/gh/gnoland/gnogno test .inexamples/gno.land/r/docs/security_patternswithGNOROOT=/home/russel/p/gh/gnoland/gnogno test .inexamples/gno.land/r/docs/homewithGNOROOT=/home/russel/p/gh/gnoland/gnogit diff --checkAssisted-By: OpenAI Codex