Phase 6 first commit. Generator produces a real-looking kubeconfig
artifact that points kubectl at the canary's /k/{id} endpoint:
- text/template embedded YAML following the spec §9.5 layout (one
cluster + one context + one user, all referencing the same
prod-cluster + svc-backup-reader names for verisimilitude)
- APIServerURL = baseURL + "/k/" + t.ID (trailing-slash safe via
strings.TrimRight)
- Token = t.ID embedded as the user's bearer token — when kubectl
fires a request, the bearer in the Authorization header is exactly
this id, giving us a second lookup path beyond the URL path
- ClusterName = "prod-cluster", UserName = "svc-backup-reader" —
hardcoded per spec §9.5 lines 1193-1194 (tempting bait names that
look like a real prod service-account kubeconfig)
- Artifact.Kind = KindText (different from docx/pdf KindFile — YAML
is text)
- ContentType "application/yaml"
- Filename default "kubeconfig" with the standard *string deref+trim
pattern from docx/pdf
The template file is named template.yaml.tmpl (not .yaml) so the
repo's pre-commit check-yaml hook does not parse it as pure YAML —
the {{.X}} Go-template tokens are not valid YAML flow mappings and
would fail the parser. .tmpl is the conventional extension for
Go-templated source files; identify-cli (which check-yaml uses to
match files) does not classify .yaml.tmpl as YAML. Future phases
needing templated YAML (envfile recipes, etc.) should follow the
same convention.
Tests parse the rendered output through gopkg.in/yaml.v3 against a
typed schema struct, asserting every field flows through correctly:
APIVersion, Kind, current-context, Clusters[0].name + .cluster.server,
Contexts[0].name + .context.cluster + .context.user, Users[0].name +
.user.token. Plus Filename defaulting subtests, base-URL
trailing-slash trim, subpath preserve, distinct-ids → distinct
outputs, bearer-token-embedded check, ends-with-newline check.
Handler (Trigger) ships in the next commit per spec §9.5 file split
({generator, handler, template}). At this commit kubeconfig.Generator
has Type() + Generate() but no Trigger() — the package compiles in
isolation but does not yet satisfy generators.Generator interface.
The registry wires it in commit 3.
go test -race -timeout=60s ./internal/token/generators/kubeconfig/...
passes.
Phase 5 ships the fourth generator in the canary registry. PDF canary
tokens are produced by in-place byte-replacing a 76-char fixed-width
placeholder in a committed template.pdf — the placeholder occupies a
direct dictionary value in the page's /AA /O /URI action (uncompressed,
not inside any Flate stream), so substitution preserves byte length and
keeps the cross-reference table valid.
Implementation commits in this phase:
- c9c93332 feat(canary): pdf template (binary blob + build helper)
- 13cd6cd1 feat(canary): pdf generator (byte-replace fixed-width URI action)
- 73dd8373 feat(canary): register pdf generator in registry
Deliverables:
- 477-byte committed template.pdf (sha256 70c63590…7e103), reproducible
via go run ./cmd/buildpdftemplate -out ... (rebuild produces byte-
identical output)
- cmd/buildpdftemplate hand-builds the PDF-1.4 with computed xref byte
offsets, validates via pdfcpu.api.Validate before writing
- pdf.Generator: ErrTriggerURLTooLong sentinel, length-preservation
guard, post-substitution bytes.Contains check, _ underscore padding
(handoff overrides spec §9.4 line 1150 space padding — see anti-
relitigation set), defaultFilename fallback for nil/empty/whitespace
*string Filename
- 22 top-level tests + 11 IP-precedence subtests + 4 filename subtests
+ 3 trigger-URL subtests = ~40 effective cases under -race. All 5
spec-required tests present (LengthUnchanged, ContainsTriggerURL,
PlaceholderRemoved, TooLongURL_ReturnsError, PdfcpuValidates) plus
template-invariant + boundary + same-position regression guards
- Registry expanded 3 → 4; pending list shrinks to {kubeconfig,
envfile, mysql}
Audit outcome (2 agents in parallel per standing pattern):
- superpowers:code-reviewer: PASS (0 BLOCKER, 0 SHOULD-FIX, 5 NITs all
flagged by the agent as "not actionable" / "leave as-is")
- general-purpose spec-adherence vs §9.4 + §8.5: PASS
(0 BLOCKER, 0 SHOULD-FIX, 2 NITs: N1 was a miscount — pdf has 11 IP
precedence subcases identical to docx, verified via grep; N2 was the
Chromium/PDF.js /AA-stripping caveat, sanctioned-deferred to
learn/04-CHALLENGES.md per the post-Phase-17 learn/ allowance)
No audit-fix commit needed this phase — zero actionable findings. Unlike
Phase 3 (a46fa446) and Phase 4 (10ae118d) which cleared real findings
before rollup, Phase 5 lands clean.
Pre-rollup acceptance gates clean at HEAD 73dd8373:
- go build ./... + go vet ./... clean
- go test -race -timeout=60s ./... — all packages pass
- go test -tags=integration -race -timeout=300s ./internal/token/...
./internal/event/... — all pass
- golangci-lint run ./... → 0 issues.
- grep -rn "//nolint" --include="*.go" returns nothing (lint discipline
inherited from Phase 0 supplement preserved)
- BACKLOG.md open section still _(none)_
Forward state for Phase 6 (kubeconfig generator): different shape from
file-artifact generators — kubeconfig is YAML text + a path-based
/k/{id}/* handler that records kubectl method/path/UA and responds with
a Kubernetes-shaped 403. Trigger pattern departs from the /c/{id}+GIF
convention webbug/slowredirect/docx/pdf share.
Adds the foundations tier: three Python projects pitched at someone
who has never written Python before, ramping from a single-file hash
identifier up to the cryptographic boundary of the beginner tier.
Projects
- hash-identifier: identify ~30 hash formats by prefix, length, and
charset (one file, ~30 tests).
- http-headers-scanner: fetch a URL and grade its security headers
A–F (httpx + rich + respx, single-file scanner, 13 tests).
- password-manager: Argon2id + AES-256-GCM encrypted CLI vault with
atomic durable writes and advisory file locking (the hardest
foundations project — stepping stone into the beginner tier).
Each project ships with
- Heavily-commented source as a teaching aid (foundations-tier override
of the no-comments rule — explanations targeted at first-time readers).
- A full learn/ folder: Overview, Concepts, Architecture, line-by-line
Implementation walkthrough, Challenges.
- README with ASCII banner, badges (incl. tier + difficulty), demo
sessions, command tables, learn-folder index, see-also links.
- install.sh + justfile + uv-managed dependencies.
- pytest + ruff + mypy + pylint config, all linters at 10.00/10.
Also centralizes the docs/ exclude in .gitignore (was a per-project
list of advanced/beginner docs paths) so dev-only audit reports and
plan documents stay local across the whole repo without needing
individual entries.
Phase 5 first commit. Adds the committed template.pdf binary blob plus
the pure-Go one-shot utility that produced it, matching the docx Phase
4 pattern (cmd/build<format>template/main.go excluded from production).
cmd/buildpdftemplate/main.go assembles a minimal PDF-1.4 by hand:
- Header: %PDF-1.4 + 4 high-bit bytes to mark as binary
- Object 1: Catalog → Pages root
- Object 2: Pages → single Kids ref + Count 1
- Object 3: Page with /AA << /O << /Type /Action /S /URI /URI (...) >> >>
where the URI value is the 76-char placeholder
HONEY_TRACK_URL_PADDED_TO_FIXED_WIDTH______________________________________
- xref table with computed byte offsets
- trailer + startxref + %%EOF
The placeholder is a direct dictionary value (NOT inside a stream), so
substitution can be a plain byte-replace at runtime without breaking the
cross-reference table — spec §9.4 line 1133. No FlateDecode anywhere.
pdfcpu (v0.12.1) is used for validation only — the builder calls
api.Validate before writing, and tests will call it again on substituted
output to confirm the PDF stays well-formed.
Verification at HEAD:
- 477-byte template.pdf
- sha256: 70c6359016ceb780539f8c4497991b98ff82201e35a38d36090d88856027e103
- Reproducible: rebuild produces byte-identical output
- grep -aob HONEY_TRACK_URL → one offset (240)
- file: PDF document, version 1.4, 1 page(s)
- api.Validate passes
go.mod adds pdfcpu as a direct dep with its transitive deps via go mod
tidy. golang.org/x/crypto returns to direct (pdfcpu uses it for AES
encryption support).
The standing "no //nolint anywhere" rule (handoff anti-relitigation
set) was honored by Phases 2-4 but inherited template code in
core/database.go, middleware/ratelimit.go, and health/handler.go
carried 6 pragmas from the original template import. Replacing each
with proper error handling rather than silencing the linter:
core/database.go
NewDatabase ping-failure path: propagates db.Close() error via
multi-%w fmt.Errorf rather than discarding it via _ = db.Close().
Pattern matches the existing rollback error wrap on line 102.
InTx + InTxWithOptions panic-recovery defer: rollback failure now
logs via slog.Error before re-panicking, rather than discarding
the error via _ = tx.Rollback(). The original error context is
preserved by the panic; the rollback failure is observable.
jitteredDuration: switches from math/rand/v2 to crypto/rand +
math/big. The function runs once per pool init at startup so the
per-call cost (~microseconds) is irrelevant. Eliminates the gosec
G404 trigger without a global exclude (handoff anti-relitigation:
"don't add new excludes for one-off issues"). Adds the missing
maxJitter <= 0 guard that the rand.Int64N call would have panicked
on with a base smaller than 7ns.
middleware/ratelimit.go
writeRateLimitExceeded: json.NewEncoder().Encode error is now
logged via slog.Error rather than discarded. slog is already
imported and used elsewhere in the file (line 60).
health/handler.go
writeStatus: same pattern; adds log/slog import. Brings the file
in line with the rest of the codebase's slog-everywhere discipline.
After: zero //nolint pragmas anywhere in backend Go code
(grep -rn "//nolint" returns nothing). golangci-lint run ./... reports
0 issues. All unit tests pass under -race; all integration tests pass
under -race -tags=integration with the testcontainer Postgres.
Surfaced by the pre-Phase-5 cross-phase alignment audit. Cleared as
part of finishing the Phase 0 lint-discipline alignment so Phases 5+
inherit a fully consistent codebase.
Auth-template residue in core/errors.go + core/response.go:
- UnauthorizedError() / ForbiddenError() + Err{Unauthorized,Forbidden}
sentinels
- TokenExpiredError() / TokenInvalidError() / TokenRevokedError() +
Err{TokenExpired,TokenInvalid,TokenRevoked} sentinels
- core.Unauthorized() / core.Forbidden() response wrappers
Zero call sites anywhere in the codebase. Worse, the Token*Error names
semantically collide with canary tokens (same package, completely
unrelated meaning) — a future-reader trap that would compound once
Phase 9 wires real operator-bearer auth gates on the admin handler.
Generic AppError plumbing (NewAppError, NotFoundError, DuplicateError,
ValidationError, InternalError, RateLimitError, IsAppError, GetAppError)
is preserved — all in active use.
When Phase 9 needs operator-bearer or turnstile-gated responses, helpers
can be reintroduced with names appropriate to the actual auth design
rather than carrying generic JWT-shaped vocabulary forward into a
canary-token namespace.
Surfaced by the pre-Phase-5 cross-phase alignment audit. Cleared per
the fix-in-phase rule. BACKLOG closed section records the resolution.
internal/core/security.go was preserved through Phase 0's auth prune
untouched. Audit: zero callers anywhere in the codebase, yet the file
kept golang.org/x/crypto as a direct dep and ran a full Argon2id KDF
on every process start via a package-level init() that pre-computed
a dummy hash "to prevent timing attacks" — wasted boot CPU for an
unused codepath.
Deleted symbols:
- HashPassword, VerifyPassword, VerifyPasswordWithRehash
- VerifyPasswordTimingSafe + dummyHash init()
- decodeHash, needsRehash internal helpers
- GenerateSecureToken, GenerateRefreshToken
- HashToken, CompareTokenHash
go mod tidy demotes golang.org/x/crypto from direct to indirect
(pgx/v5 still pulls it transitively).
Surfaced by the pre-Phase-5 cross-phase alignment audit. Cleared as
part of finishing Phase 0's auth prune rather than logged as deferred
work. BACKLOG closed section records the item + resolution.
Phase 4 ships the third generator in the canary registry. docx canary
tokens are produced by zip-patching a committed template.docx — the
HONEY_TRACK_URL placeholder in word/footer2.xml's INCLUDEPICTURE field
is replaced at Generate time with the canary's /c/{id} trigger URL.
Per-entry zip Method (STORE/DEFLATE) is preserved so Word/LibreOffice
still opens the document. Trigger is shared with webbug (spec §9.3
line 1118): 200 + 43-byte transparent GIF + cache headers; nil-token
returns the same response per spec §8.5 defense-in-depth.
Implementation commits:
f36cce9a feat(canary): docx template (binary blob + build helper)
82bb3a9f feat(canary): docx generator (zip-patch INCLUDEPICTURE)
5884f986 feat(canary): register docx generator in registry
Audit (two agents in parallel; spec §9.3 + §8.5):
superpowers:code-reviewer PASS — 0 BLOCKER / 0 SHOULD-FIX / 3 NIT
general-purpose (spec adherence) PASS — 0 SPEC-VIOLATION, 6/6 invariants MATCH
Cleared in-phase via 10ae118d (fix(canary-phase4): address audit nits
before rollup):
N1 — added 'RemoteAddr loopback IPv6 [::1]:9999' + 'XFF IPv6 rightmost'
subtests to docx generator_test.go for symmetry with webbug
N2 — renamed shadowed cErr to hErr in patchTemplate's loop body
N3 — newDocxToken helper sets Memo/Type matching webbug's pattern;
not actionable (stylistic positive)
Decisions baked in this phase (anti-relitigation):
- Spec §9.3 Filename example (`if t.Filename == ""`) does not compile
against the actual schema — Token.Filename is *string per entity.go:52.
resolveFilename safely deref's, trims, and falls back to 'Document.docx'
for nil / empty / whitespace pointers.
- Spec example does not propagate rc.Close() after io.ReadAll — the
implementation does, ordered so a read error wins over a close error
(required by .golangci.yml errcheck.check-blank: true).
- cmd/builddocxtemplate is a pure-Go OOXML builder rather than the
spec's suggested LibreOffice/python-docx workflow. Matches the
Phase 5 cmd/buildpdftemplate precedent in the implementation plan.
Lives at cmd/builddocxtemplate (excluded from the production binary).
- cmd helper uses filepath.Clean(out) before os.OpenFile to satisfy
gosec G304 on an operator-supplied -out flag — no //nolint pragma
(banned by project rule).
State after Phase 4:
Registered generators: 3 (webbug, slowredirect, docx)
Pending generators: 4 (pdf, kubeconfig, envfile, mysql)
BACKLOG.md open section: still empty
Pre-audit gate: BUILD/VET/unit/integration/lint clean across backend
Manual smoke (Task 4.6) is operator-deferred: open a generated docx
in LibreOffice with a network monitor and confirm the GET to /c/{id}
fires when the document opens. Not blocking phase rollup.
Phase 5 next: cmd/buildpdftemplate + pdf generator with byte-padded
HONEY_TRACK_URL_PADDED_…_76 placeholder in an /AA /O /URI action.
Phase 4 audit (2 agents) returned PASS with three NITs. N1 + N2 cleared
in-phase per the standing fix-in-phase rule. N3 was stylistic-consistency
positive and not actionable.
N1 — generator_test.go IP-precedence subtests: added two missing cases
present in webbug's identical suite (the realIP helper triplet is byte-
identical to webbug, so this is symmetry rather than coverage):
- "RemoteAddr loopback IPv6 strips brackets and port" → "[::1]:9999"
- "XFF IPv6 rightmost" → "198.51.100.1, 2001:db8::dead"
N2 — generator.go patchTemplate loop: the second `cErr := ...` shadowed
nothing but reused the name from the close-error block earlier in the
same iteration. Renamed the header-create variant to `hErr` for clarity.
Audit verdicts:
- superpowers:code-reviewer: PASS (0 B / 0 S / 3 N)
- general-purpose spec-adherence vs §9.3 / §8.5: PASS (0 violations,
6/6 invariants MATCH, BACKLOG.md still empty)
Phase 4 Task 4.3+4.4 — docx Generator that produces a tracked Word
document by embedding template.docx and runtime-substituting the
HONEY_TRACK_URL placeholder in word/footer2.xml with the per-token
trigger URL. Preserves per-entry zip Method (STORE/DEFLATE) so
Word/LibreOffice still opens the result; non-footer entry bodies are
byte-identical to the template.
Generate returns {Kind: KindFile, Filename: t.Filename ?? "Document.docx",
Content: <patched zip>, ContentType: wordprocessingml MIME}. The
spec §9.3 reference snippet uses `t.Filename == ""` which won't
compile against the real schema — Token.Filename is *string. The
generator dereferences safely via resolveFilename, trimming and
falling back to the default for nil/empty/whitespace pointers.
Trigger mirrors webbug exactly per spec §9.3 line 1118: 200 + 43-byte
transparent GIF via pixel.Clone() + cache-control no-store + pragma
no-cache. Nil-token returns the same response with nil event
(spec §8.5 — no token-existence enumeration). realIP /
lastNonEmptyXFF / optionalHeader helpers are copied from webbug per
the standing rule (sanctioned duplication until Phase 9 middleware
extraction).
24 test cases including the six prescribed by implementation plan
§4.3 (OutputIsValidZip, FooterContainsTriggerURL,
FooterDoesNotContainPlaceholder, OtherEntriesUnchanged,
PreservesCompressionMethods, ReturnsGIFLikeWebbug) plus surrounding
correctness/regression coverage (type, kind, content type, filename
defaulting incl. whitespace, trigger-URL trailing-slash + subpath +
uniqueness, source-IP precedence with 9 subtests mirroring webbug,
GIF body independence per Trigger call, nil-token defense).
Also fixes pre-existing lint debt in cmd/builddocxtemplate from
commit f36cce9a: errcheck on the deferred f.Close (now propagates
via named return) and gosec G304 on os.OpenFile (now filepath.Clean
on the operator-supplied -out path). Rebuilding template.docx after
this change produces byte-identical output (verified via sha256sum).
Phase 4 Task 4.1+4.2 — pure-Go OOXML template builder under
backend/cmd/builddocxtemplate/. Run:
go run ./cmd/builddocxtemplate \
-out ./internal/token/generators/docx/template/template.docx
produces a minimal valid .docx (5 zip entries, mixed STORE/DEFLATE
compression) whose word/footer2.xml carries an INCLUDEPICTURE field
referencing the literal placeholder string HONEY_TRACK_URL with the
\\d switch (forces fetch-on-open, no local cache).
The committed template.docx is the embedded source for the Phase 4
docx generator (next commit). Mixed compression methods in the template
make Task 4.3's TestGenerate_PreservesCompressionMethods a meaningful
regression guard — a hardcode-DEFLATE generator would now mismatch on
[Content_Types].xml + word/_rels/document.xml.rels (both STORE).
cmd/builddocxtemplate is excluded from the production binary (separate
cmd/ subdir from cmd/canary).
Phase 3 of 18. Adds the second token type: a slowredirect generator
that returns a `KindURL` artifact pointing at `/c/{id}`, serves an
embedded HTML+JS fingerprinting page on trigger, and exposes a
sibling `POST /c/{id}/fingerprint` endpoint that enriches the most
recent matching event's `Extra` JSONB via the existing
event.Repository.AttachFingerprint method. Generator interface
contract from Phase 2 unchanged; registry adds the second entry.
Implementation commits:
71823f5d feat(canary): slowredirect generator (Generate + Trigger + template)
6dd520f9 feat(canary): slowredirect fingerprint endpoint
be13d2f1 feat(canary): register slowredirect generator in registry
a46fa446 fix(canary-phase3): address audit findings before rollup
Audit outcome — TWO agents (superpowers:code-reviewer + general-purpose
spec adherence) dispatched in parallel. Code-reviewer returned FAIL
with 1 blocker + 4 should-fix + 7 nits; spec-adherence returned PASS-
with-1-violation. Both findings cleared in commit a46fa446 per the
fix-in-phase rule:
• BLOCKER B1 — template.html used `{{...|js}}` which Go's html/
template double-escapes in a JS string-literal context. The
on-the-wire output became `\\u003D` / `\\u0026` (double backslash),
which JS parses as literal backslash-u-text rather than `=` / `&`.
Realistic URLs like `?utm_source=newsletter&utm_medium=email`
were silently corrupted before `window.location.replace`. The
bug was faithfully transcribed from spec §9.2 lines 1031, 1038,
1045 — the spec's parenthetical claiming `js` is a custom func
is factually wrong. Resolution: drop `| js` from both JS-context
actions; rely on html/template's built-in jsstrescaper.
Regression test TestTrigger_DestinationRoundtripsThroughJSStringDecode
asserts the absence of `\\u003D` / `\\u0026` for a real URL.
Spec doc (local-only) updated to match.
• SPEC-VIOLATION #1 — nil-token Trigger returned `404 + "Not Found"`,
contradicting spec §8.5 ("token not found | 200, ... deliberately
do NOT 404") and §8.5 line 964 ("/c/* endpoints never return JSON
errors"). 404 is a token-enumeration oracle. Resolution: nil-token
branch renders the same template with a benign decoy destination
("/") and returns 200 + text/html + the full CSP/cache header set.
Body is byte-shape indistinguishable from a valid response except
for the embedded destination URL. The `(nil event, &resp, nil
error)` return shape from the Phase 2 anti-relitigation rule is
preserved. New test
TestTrigger_TokenNotFound_HasSameResponseShapeAsValidToken
asserts the indistinguishability invariant.
• SHOULD-FIX S1 — extractDestination allowlists `http(s)://`
(case-insensitive); other schemes (`javascript:`, `data:`,
`file:`, `vbscript:`, scheme-relative `//host`, no-scheme) now
return the new exported ErrInvalidDestinationScheme. Phase 9's
upstream `validate:"url"` is necessary but not sufficient (the
validator's `url` tag accepts any scheme). 8-case
TestGenerate_RejectsDangerousDestinationSchemes + 4-case
TestGenerate_AcceptsHTTPAndHTTPSSchemes added.
• SHOULD-FIX S3 — fingerprint handler silently 204s any
Content-Type that doesn't start with `application/json` (the
embedded template always sends JSON). Rejects before the
MaxBytesReader read. Test added.
• SHOULD-FIX S4 — added integration tests for oversize body
(128 KiB → 204, Extra untouched) and empty token id
(`/c//fingerprint`).
• SHOULD-FIX S2 — nonce-based CSP suggested as a senior-bar polish.
Deferred: this is a spec deviation (§9.2 line 1047 specifies
`script-src 'unsafe-inline'` verbatim) and crosses into Phase 9
territory (global SecurityHeaders middleware). Re-evaluate when
Phase 9 wires the trigger handler.
• Nits N1-N7 reviewed; the realIP/lastNonEmptyXFF duplication
is sanctioned per the Phase 2 anti-relitigation note (Phase 9
middleware promotion is the cleanup point). Other nits either
correct as-is or deferred to Phase 9+ tasks.
Pre-rollup gate clean: `go build ./...`, `go vet ./...`,
`go test -race -timeout=60s ./...` (unit), `go test -tags=integration
-race -timeout=300s ./internal/token/... ./internal/event/...`, and
`golangci-lint run ./...` (0 issues). BACKLOG.md open section
remains empty.
Registry cardinality: 2 of 7 token types live (webbug + slowredirect).
Five remain: docx (Phase 4), pdf (Phase 5), kubeconfig (Phase 6),
envfile (Phase 7), mysql (Phase 8).
Phase 3 audits returned one BLOCKER and one SPEC-VIOLATION plus two
should-fix items; all cleared in-phase per the no-rot rule.
BLOCKER — template.html double-escaped JS-context interpolations.
Go's html/template auto-escapes {{.X}} in <script> string-literal
position via its built-in jsstrescaper. Piping through the explicit
`| js` (JSEscaper) re-ran the escaper, producing `\\u003D` /
`\\u0026` on the wire (double backslash). JS parses `\\u003D` as a
literal backslash followed by "u003D" text rather than `=`, so any
realistic destination URL — e.g.
https://news.example.com/article?utm_source=newsletter&utm_medium=email
— was corrupted into
https://news.example.com/article?utm_source=newsletter&utm_medium=email
before being passed to window.location.replace. The unit tests
previously missed it because every test destination was an
escape-free string. Fix: drop `| js` from both JS-context actions
in template.html; the contextual auto-escaper alone produces single-
backslash `=` which JS correctly decodes. Spec §9.2 line 1031
and 1038 (and the explanatory paragraph 1045) are factually wrong
about `| js` being a custom template func — it is `JSEscaper`, and
adding it on top of the built-in contextual JS string escaper is
pure double-escape harm. Spec docs (local-only, not committed)
amended accordingly. Added
TestTrigger_DestinationRoundtripsThroughJSStringDecode as a
regression guard against re-introducing the pipeline.
SPEC-VIOLATION — nil-token Trigger returned `404 + "Not Found"`,
breaking spec §8.5 ("token not found | 200, ... deliberately do NOT
404") and §8.5 line 964 ("/c/* endpoints never return JSON errors").
A 404 on a non-existent token ID lets scanners enumerate which IDs
are real. Fix: nil-token branch now renders the same template with a
benign decoy destination ("/") and returns 200 + text/html with the
full CSP override and cache headers. Response is byte-shape
indistinguishable from a valid-token response except for the
destination URL embedded in the script. Added
TestTrigger_TokenNotFound_HasSameResponseShapeAsValidToken to
assert the indistinguishability invariant. The `(nil event, &resp,
nil error)` return shape from the handoff anti-relitigation is
preserved.
SHOULD-FIX S1 — extractDestination now allowlists `http://` and
`https://` (case-insensitive) and returns the new
ErrInvalidDestinationScheme sentinel otherwise. The noscript
meta-refresh sits in an HTML-attribute context where html/template
does NOT recognize `url=...` as a URL context, so `javascript:`,
`data:`, `file:`, `vbscript:` and scheme-relative `//example.com`
would otherwise render verbatim and follow on JS-disabled clients.
Phase 9's `validate:"url"` on input is necessary but not sufficient
(the validator's `url` tag accepts any scheme). Added
TestGenerate_RejectsDangerousDestinationSchemes (8 cases) and
TestGenerate_AcceptsHTTPAndHTTPSSchemes (4 case-variants).
SHOULD-FIX S3 — fingerprint handler now silently 204s on any
Content-Type that does not start with "application/json". The
embedded template is the only legitimate caller and always sends
JSON; this rejects adversarial probes before the MaxBytesReader
read. Added
TestFingerprintHandler_WrongContentType_Returns204AndDoesNotTouchEvent.
SHOULD-FIX S4 — added integration tests for the oversize-body cap
(128 KiB body returns 204, Extra untouched) and the empty-token-id
guard (`/c//fingerprint`); the latter accepts chi's natural routing
behavior alongside the in-handler 204.
Non-functional cleanups: extracted shared template-render path
into renderWith / renderResponse / renderDecoyResponse to keep the
nil-token + happy-path bodies definitionally identical;
ErrMissingDestination and ErrInvalidDestinationScheme exported so
callers (and tests) can ErrorIs them; TestGenerate_RejectsMissing*
tightened to use require.ErrorIs(tc.wantErr) instead of bare
require.Error.
Pre-rollup gate (go build / vet / test -race unit+integration /
golangci-lint) is clean.
Phase 3 wiring: registry.Build now maps token.TypeSlowRedirect to
slowredirect.New() alongside the existing webbug entry. The pending-
types regression guard sheds SlowRedirect from its list (5 remain:
docx, pdf, kubeconfig, envfile, mysql) and the cardinality assertion
moves from "exactly one generator" to "exactly two".
POST /c/{id}/fingerprint handler that decodes a JSON fingerprint body
and merges it into the most recent event's Extra JSONB for the same
(token_id, source_ip) tuple within a 30s window via the existing
event.Repository.AttachFingerprint method. Decoupled from the concrete
repository through a small FingerprintAttacher interface so future
event.Service can swap in without touching this package.
Per spec §9.2 the fingerprint POST is enrichment-only: a missing match
(event.ErrNotFound), an invalid JSON body, an empty body, and a body
exceeding 64 KiB all silently return 204. Only unexpected repository
errors are logged via slog. The token id is read from chi.URLParam("id")
so the route mounts naturally under the existing trigger router in
Phase 9.
Three integration tests against testcontainers cover the happy-path
JSONB merge, the no-matching-event path (silent 204), and the
invalid-JSON path (204 plus stored Extra left untouched).
HTML+JS browser-fingerprint redirect page rendered via html/template:
Generate reads metadata.destination_url off the token row and returns
KindURL with the trigger URL + persisted destination; Trigger renders
the embedded template (noscript meta-refresh + inline fetch posting
the fingerprint, then window.location.replace), returns the page with
Content-Security-Policy override allowing inline script + connect-src
'self', and emits the event capturing token id / source IP / UA /
Referer.
Nil-token Trigger returns nil event (FK guard) plus a 404 HTML body,
matching the established defensive-rendering pattern from webbug.
Destination_url is extracted defensively (missing/empty/whitespace
all surface ErrMissingDestination). realIP precedence copied from
webbug pending the Phase 9 middleware promotion.
19 unit-test assertions cover Type, Generate's URL+destination output,
five missing-destination edge cases, XSS neutralization in two
injection contexts (attribute escape + script-closing escape), CSP
override + cache headers, event-recording metadata, nil-token contract,
and per-call response independence.
Phase 2 of the 18-phase plan. Lands the plugin scaffolding (Generator
interface, shared transparent-pixel package, generator registry) and the
first concrete generator (webbug). Phase 2 is intentionally the smallest
phase — subsequent phases add slowredirect, docx, pdf, kubeconfig,
envfile, and mysql generators against the same Generator interface.
Implementation commits (oldest → newest):
6cc724c1 feat(canary): Generator interface for token-type plugins
0b6e586e feat(canary): shared 43-byte transparent GIF for image triggers
9c0ad749 feat(canary): webbug generator (Generate + Trigger)
bb95f745 feat(canary): generator registry (token.Type → Generator)
c39b56b9 fix(canary): clear pre-audit lint debt + migrate golangci config to v2
1a240952 fix(canary-phase2): address audit findings before rollup
Deliverables vs. plan:
- Generator interface (spec §6.2): Type/Generate/Trigger contract with
ArtifactKind url|file|text|connection_string, Artifact discriminated
by Kind, TriggerResponse value type so generators stay pure.
- Pixel package: 43-byte canonical GIF89a literal exposed via
pixel.Clone() (immutable source, independent slice per call) +
pixel.Len() helper + ContentType const.
- Webbug generator (spec §9.1 + §8.5): Generate returns KindURL
artifact with baseURL/c/{id} (trailing slash trimmed). Trigger returns
200 + image/gif + 43-byte pixel + no-store cache headers.
realIP precedence: CF-Connecting-IP > X-Forwarded-For (rightmost
non-empty) > X-Real-IP > net.SplitHostPort(RemoteAddr). Handles
IPv4/IPv6 (including bracketed form). Empty XFF entries (trailing
comma, all-empty list) fall through to the next source. Nil token
returns nil event + non-nil GIF response — the contract is explicit
in the return shape, so the future Phase 9 handler cannot accidentally
insert an event row with empty TokenID (which would fail the events
→ tokens FK anyway).
- Generator registry: lives in backend/internal/token/generators/registry/
(sub-package, NOT the generators package itself) to break the
generators → webbug → generators import cycle the implementation plan
inadvertently created. registry.Config + registry.Registry +
registry.Build. Phase 2 registers webbug only; cardinality test
asserts exactly 1 and enumerates the 6 future types as absent.
- Cross-cutting cleanup (c39b56b9): pre-audit gate found Phase 0/1
golangci-lint debt the prior phase audits didn't catch. Per
fix-in-phase rule, cleared all 14 (5 errcheck, 6 golines, 1 funlen
via run() → run+initTelemetry+mountRouter+gracefulShutdown split,
1 govet shadow, plus the 1 Phase-2-introduced funlen-on-test that
the broken .golangci.yml v1-syntax exclude-rules failed to suppress
under golangci-lint v2). Also migrated .golangci.yml to v2 schema
(linters.exclusions.{paths,rules}) and added gosec G706 to excludes
(false-positive log-injection on slog structured-logging call sites).
Phase 2 audit — TWO agents in parallel per standing pattern:
superpowers:code-reviewer → PASS (9 findings, all cleared in-phase
by commit 1a240952)
general-purpose (spec-adherence)→ PASS (every spec contract item
verified; 3 known deltas
accepted: Type() refined to
token.Type, registry moved to
subpackage to break cycle,
optionalHeader bridges
plan-vs-schema pointer types)
Findings cleared in-phase (from code-reviewer):
MEDIUM — realIP fallback returned "IP:port"; now net.SplitHostPort
MEDIUM — XFF rightmost empty entry skipped fallback; now walks right-
to-left and falls through cleanly
MEDIUM — pixel.TransparentGIF was exported mutable []byte; now
unexported + pixel.Clone() per call
LOW — nil token returned non-nil event with empty TokenID; now
returns nil event (explicit contract)
LOW — no IPv6 coverage in IP precedence tests; added 3 IPv6 cases
+ XFF IPv6 + port-less RemoteAddr
NIT — gracefulShutdown swallowed shutdown errors; now errors.Join
Accepted as-is (NIT, Phase 3+ concern):
NIT — Artifact discriminated-union may want sealed-interface
refactor once non-URL kinds land
NIT — Registry exposed bare map (acceptable: read-only post-Build,
Go memory model permits concurrent map reads with no writes)
NIT — registry.Build accepts but ignores its Config (signature
reserved for future stateful generators like mysql)
Forward-looking notes for Phase 3 (NOT deferred items, just heads-up):
- Phase 3's main.go wire-up cannot copy spec §6.3's
`generators.BuildRegistry(cfg.Canary)` verbatim; the call is now
registry.Build(registry.Config{...}) and the cfg.Canary field will
need to be declared on config.Config.
Deferred items: NONE. BACKLOG.md open section remains empty.
Quality gates (final, post-fix):
go build ./... OK
go vet ./... OK
go test -race ./... PASS (4 packages, 20 unit tests)
go test -tags=integration -race PASS (token + event integration,
~12s testcontainers)
golangci-lint run ./... 0 issues
Audited: PASS.
Two audit agents (code-reviewer + spec-adherence) both returned PASS but
flagged substantive findings. Per fix-in-phase / no-backlog-rot, clearing
every MEDIUM + LOW in-phase.
Findings addressed:
MEDIUM — realIP RemoteAddr fallback returned "IP:port" verbatim, where
the spec wants the bare IP (geoip + downstream parsing assume host-only).
Now uses net.SplitHostPort with raw-string fallback if not host:port.
Adds 4 RemoteAddr cases: IPv4 strips port, IPv6 bracket form strips
brackets+port, loopback IPv6, and the port-less raw fallback.
MEDIUM — realIP XFF branch accepted whatever rightmost-comma-split
produced, so headers like "198.51.100.1, " (trailing comma) yielded
"" and skipped XRI/RemoteAddr entirely. Extracted into lastNonEmptyXFF
which walks right-to-left and falls through cleanly. Adds two cases:
trailing-comma falls through, all-empty entries fall through.
MEDIUM — pixel.TransparentGIF was an exported mutable []byte; any caller
could clobber it process-wide (mutating one response's body would affect
every subsequent webbug trigger). Renamed to unexported transparentGIF
+ exposed pixel.Clone() and pixel.Len(). Webbug now calls pixel.Clone()
per trigger; new test TestTrigger_ResponseBodyIsIndependentCopyPerCall
verifies two triggers return independent slices.
LOW (reviewer-escalated to HIGH-for-Phase-3) — Trigger on nil token
returned a non-nil event with empty TokenID. Since events.token_id is a
NOT NULL FK to tokens.id, the Phase 3 handler could not have persisted
that event anyway, and the contract was implicit (would have required
an inline comment, which the no-comments rule forbids). Now: nil token
in → nil event, non-nil response out. Contract is explicit in the
return shape. Test asserts evt is nil for nil-token path.
LOW — no IPv6 coverage in IP-precedence tests. Added IPv6 cases for
XFF rightmost and three RemoteAddr forms (bracketed, loopback, no-port).
NIT — gracefulShutdown swallowed every shutdown error, returning nil
unconditionally. Now collects with errors.Join so callers can detect
partial-shutdown for telemetry/exit-code purposes.
Audits accepted as-is (NIT, not blocking):
- Artifact discriminated-union shape (Phase 3+ concern when other Kinds
are produced)
- Registry returning bare map (read-only post-Build; concurrent reads
are safe by Go's memory model)
- Build(_ Config) ignoring its arg (signature reserved for future
stateful generators)
Quality verified post-fix: build/vet/lint clean (0 issues),
14 webbug tests + 6 pixel tests + 4 registry tests PASS under -race,
integration tests unchanged.
Pre-audit gate (`golangci-lint run`) at the start of Phase 2 surfaced 15
issues that should have been zeroed before Phase 1 rollup. Per the
fix-in-phase rule (no backlog rot), clearing everything before Phase 2
audit agents run on a green tree.
Config:
- .golangci.yml: migrate `issues.exclude-rules` and `issues.exclude-dirs`
to v2 syntax (`linters.exclusions.{rules,paths}`); test-file funlen/
dupl/goconst exclusion now actually applies under golangci-lint v2.10
- .golangci.yml: add G706 to gosec excludes — false-positive log-injection
reports on slog structured-logging call sites (slog separates message
from kv args, immune to log-line injection by construction)
errcheck (5):
- cmd/canary/main.go: `_ = telemetry.Shutdown(...)` → log on error
- internal/token/repository.go: `defer stmt.Close()` → log on close error
- internal/event/repository.go: same as token
- internal/testutil/postgres.go: `_ = pgContainer.Terminate(...)` and
`_ = db.Close()` → t.Logf on cleanup error (use distinct err names to
avoid govet shadow)
funlen (1):
- cmd/canary/main.go: split `run` into `run` + `initTelemetry` +
`mountRouter` + `gracefulShutdown` (was 55 statements, now under
the 50 cap; helpers are individually well under)
govet shadow (1):
- cmd/canary/main.go: migrations check switched from `if err :=` to
`if err =` (reuses outer err whose value was already consumed) — the
inner short-decl was lexically shadowing without intent
- cmd/canary/main.go: select-case `err` renamed to `startErr` to avoid
the same shadow pattern
golines (6, all auto-fixed by `golangci-lint run --fix`):
- main.go, config.go, telemetry.go, event/repository.go, token/dto.go,
token/repository.go — long lines wrapped to 80-col
Verified post-fix: build OK, vet OK, unit tests PASS under -race,
integration tests PASS under -tags=integration -race (12s testcontainers),
golangci-lint reports 0 issues.