Cosmic Bull

Rendered from docs/SECURITY.md at commit f191063fd89d in the project repository. The committed file is the source of truth; this page is a rendering of it.

Security and validation methodology

The audit method, and the lessons accumulated through Applications #1–#5.


Method

Evidence-gated findings

No finding without a quoted line. A finding names the file, the line, and the code. A suspicion that cannot be grounded in source is not reported as a finding — it is reported as a question, or dropped.

Audit evidence is whole files. The structural outline that tooling returns by default is navigation, never evidence.

Severity

GradeMeaningGate
REDExploitable, or a design property that lets one actor deny the application to everyoneMust be resolved before deployment
YELLOW (material)A real weakness — latent vulnerability, missing guard, unbounded surface, or a correctness property enforced only by disciplineMust be resolved before deployment
YELLOWA weakness in documentation, coverage, or observabilityResolved before deployment in practice
GREEN / INFORMATIONALAssessed, understood, and accepted, with the reasoning written downRecorded, not fixed

Findings are never downgraded to reach a deployment. If a finding is graded INFORMATIONAL, the reasoning is in the FIXES.md and a reviewer can disagree with it on the record. Worked example: permission_registry's expired-tombstone growth — graded INFORMATIONAL because retired is never iterated and the cost falls on the caller's own storage deposit.

Two-pass false-positive filter

Findings are re-derived independently before they are reported. The recurring false-positive shapes in this codebase:

Scope honesty

Every audit states what was read, what was not, and what remains unverified. An audit of local source is reported as as-provided, not verified against any deployment unless the bytes were fetched from the chain.

Layering

The port is proven green before remediation is layered on. Consequence: every later test failure is attributable to the remediation alone, and the port's fidelity is not entangled with the fix's correctness.

Port scripts are neutered after use (sys.exit, exit code verified). They pre-date the remediation layer, and re-running one would silently revert the fixes.


Accumulated lessons — Applications #1–#5

These are the findings that recurred, generalized into rules.

Caller identity comes from the realm capability, not the stack

Derive identity from a crossing entrypoint's runtime-current cur.Previous().Address(), inline. Never from a stack-walking unsafe.PreviousRealm() inside a non-crossing helper.

// WRONG — bug Class 2. Returns whatever was previous at the last realm
// boundary, not the immediate caller.
func caller() address { return unsafe.PreviousRealm().Address() }

// RIGHT
func Deregister(cur realm, name string) {
    owner := cur.Previous().Address()
    …
}

This was found in all five applications — Y1 in every case. In every case it was latent, not live: correct on all existing paths, enforced only by call-site discipline. That is precisely why it was fixed rather than accepted. A property that holds by discipline stops holding the first time someone adds a caller.

Keep unsafe only where stack-walking is what is actually wanted: OriginSend() for the tx envelope, and OriginCaller() inside init().

Reject stray coin sends on non-payable crossing functions

A realm with no banker cannot move coins out. Coins attached to any call strand at its address forever.

func rejectStraySend(cur realm) {
    if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
        panic("this realm does not accept coins")
    }
}

First statement of every crossing entrypoint. Guard on IsUserCall(), not IsUser() — the MsgRun ephemeral can consume the envelope before forwarding control.

All five non-custodial pipeline applications ship this guard, and it is proven live on each by attaching coins to an otherwise-valid call — so the coin guard is the only possible cause of the abort.

Bound third-party-reachable rendering

Render is served to everyone. If a third party can grow the state it iterates, they can make the page expensive — and the cost falls on readers, not on whoever grew it.

Cap the row count, and emit an explicit truncation notice so the bound is visible rather than silently lossy. service_registry: MaxRenderServices = 25. permission_registry: 20 resources, 8 permissions.

Related: iterate deterministically. Never range a map into rendered output.

Sanitize with the ecosystem sanitizer, not a hand-rolled one

A hand-rolled escaper that misses < and > lets raw HTML — including <script> — reach a shared page (timelock_guardian Y3). One that handles brackets but not markdown link syntax permits link/image phishing (service_registry Y5).

Use gno.land/p/nt/markdown/sanitize/v0.

Two-step ownership transfers

A one-step TransferOwnership to a wrong or dead address bricks the entry. In a shared namespace this is worse than it sounds: a bricked entry can never be deregistered, so it permanently holes the name.

Two-step: Transfer… nominates, Accept… (nominee-only) completes, and a Cancel… clears the nomination. A nomination must be inert — it confers nothing until consent. Proven live in both directions on service_registry.

Where ownership changes affect a per-owner quota, check the quota at consent time. Checking at nomination lets an unsolicited nomination push an account past its limit without that account's agreement — and, worse, lets a nomination consume quota that the nominee never asked for.

Corollary: move the quota index atomically with the ownership change, and keep it zero-free (delete the key at zero rather than leaving a 0 entry).

Avoid unbounded attacker-controlled iteration

O(n) scans over a globally shared, attacker-growable list are a starvation vector. timelock_guardian Y2/Y4: a long-delay sybil wall in a global FIFO window made victims' expired actions unreapable, wedging quota and SetGuardian.

Fix shape: partition per-target (map[string][]string, each bounded), do per-target sweeps rather than a global budget, derive counters from list length so there is no counter to desync, and add a permissionless recovery path so that recovery never depends on sweep order.

Cap what a single actor can occupy in a shared permissionless registry

Namespace monopolization was the RED finding in both registries (#4, #5): one funded key could take the entire registry.

Fix: a per-owner cap (MaxServicesPerOwner = 20, MaxResourcesPerAdmin = 20) backed by an O(1) index — not a scan.

Residual, stated honestly: this raises monopolization from one funded key to N funded keys. It does not make it impossible. That is written in the FIXES.md rather than implied away.

Distinguish documented design limitations from vulnerabilities

Not every sharp edge is a bug. A frozen-entry wedge whose only cause is the registrant's own key loss is a documented limitation with guidance, not a vulnerability. An unbounded-hops read is a cost warning for integrators, not a defect.

The test is whether an attacker can cause it, or only the owner can cause it to themselves.

Know when a fix would change what the application is

service_registry Y6: pkgPath is an unverified claim. The maximal fix — self-proving registration, as r/demo/defi/grc20reg does — would mean that only realms, never their human operators, could ever register a name.

That is a fundamentally different application, which is a hard boundary, not a routine in-scope fix. The refusal is recorded, and the proportionate remediation shipped instead:

Record the refusal and the reasoning so a reviewer can disagree on evidence.

Preserve failure atomicity

panic reverts the whole transaction; a returned error does not. Debit before payout (checks-effects-interactions) so that an abort reverts the debit and the send together. Never leave a partial state mutation on an error path.

Never trust a designation passed as a parameter

Taking creator or owner as a string/address parameter is designation forgery. Derive it from cur.Previous().Address().

Corollary for /p/ packages: an interface method signature must never contain cur realm. Take an address and let the caller derive it. A secondary rlm realm parameter must be rlm.IsCurrent()-checked before use.


Deployment-integrity rules

Verify the deployment payload bytes before broadcasting

The deploy tool takes file bodies as strings, not paths. Transcribing a 600-line file into a tool argument is a silent byte-drift risk.

Invert the order: write the transcription to disk, cmp it against the committed source before broadcast, and only then pass that exact verified string. Do not deploy and hope the post-hoc check catches it.

Verify the deployed source bytes after broadcasting

Fetch the on-chain file (vm/qfile) and compare hashes against the committed bytes. A deployment record without a byte match is not a deployment record.

Expected difference: gnomod.toml always differs on-chain — the chain appends an [addpkg] creator/height stanza. That is metadata, not divergence. Confirm the form against an already-verified sibling.

Also verify the file list: a test file must not have leaked into the deployed package.

Build the payload from the committed bytes, not the working tree

Clone fresh, build the payload from the clone, then cmp the working tree against it. The working tree is where uncommitted drift lives.

An unchanged [addpkg] height proves no redeploy

Realm bytes are immutable after addpkg. Re-read every sibling's height after each deployment; unchanged heights are positive proof that nothing else moved.

Never fabricate a deployment record

Every claim carries a transaction hash, a height, and a verifiable artifact. If something was not exercised, it is listed as not exercised, with the reason and the committed test that covers it instead.

Never bypass tool or harness safety controls

If a permission classifier blocks a tool, report the block and record the resulting coverage gap. Do not route around it with gnokey, shell signing, or key-file access.

Worked example: during Application #5, key generation was blocked by the harness. No workaround was attempted; the pre-existing keys were used, and the second-identity coverage limit was written into the deployment record.


Live verification

Deployment is not the end of verification — it is the start of the real one.

  1. Empty-state reads before any write — confirms init() ran and no read path panics on empty state.
  2. Functional writes — each recorded by tx hash and height, with the resulting state read back.
  3. Adversarial battery — every guard attacked, with the abort message byte-matched to the audited source.
  4. Invariants — bank state, conservation identities, sibling heights.

Adversarial probes run at simulate: the deploy tooling pre-simulates every call, so a rejected attack consumes no gas, touches no state, and consumes no sequence number. There is no excuse for an unexercised guard.

Between 6 and 9 distinct attack shapes were rejected live per pipeline application. Full per-application results: catalog/applications.md.


Value invariants

For every realm that holds funds:

Checked against real bank state (auth/accounts) at every checkpoint, and drained to exact zero at the end.

For every realm that does not hold funds — all five pipeline applications — the invariant is stronger and simpler: the realm address is exists:false / coins "0" at every checkpoint, including after a deliberate stray-send attempt, and the deployed bytes import no chain/banker.


Known residual limitations

Recorded rather than resolved: