Merge pull request #235 from CarterPerez-dev/project/canary-token-generator

Project/canary token generator
This commit is contained in:
Carter Perez 2026-05-18 00:44:30 -04:00 committed by GitHub
commit 79ca7006b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
221 changed files with 33548 additions and 2 deletions

View File

@ -0,0 +1,106 @@
# =============================================================================
# ©AngelaMos | 2026
# .env.example
# =============================================================================
# Copy this file to .env and fill in values. .env is gitignored.
# Run `just init` to auto-generate POSTGRES_PASSWORD + OPERATOR_TOKEN if empty.
# =============================================================================
# ---------------------------------------------------------------------------
# Public-facing
# ---------------------------------------------------------------------------
APP_NAME=canary-token-generator
# Production nginx host port (compose.yml). Dev compose has its own default
# of 58495 in dev.compose.yml; do not override NGINX_HOST_PORT in shared .env.
NGINX_HOST_PORT=22784
# Used to construct trigger URLs embedded in artifacts (e.g. canary docs).
# Must be the externally-reachable URL of the deployed service.
PUBLIC_BASE_URL=https://canary.your.domain
# Frontend build-time vars (baked into the bundle by Vite)
VITE_APP_TITLE="Canary Token Generator"
VITE_API_URL=/api
# ---------------------------------------------------------------------------
# Cloudflare Turnstile (free anti-bot)
# ---------------------------------------------------------------------------
# Get keys from https://dash.cloudflare.com/?to=/:account/turnstile
# Leave blank to disable Turnstile in development.
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET=
# Mirror of TURNSTILE_SITE_KEY for frontend (must be the same value)
VITE_TURNSTILE_SITE_KEY=
# ---------------------------------------------------------------------------
# Operator (you) — for /api/admin/* endpoints
# ---------------------------------------------------------------------------
# Auto-generated by scripts/init.sh if blank.
# Send as `Authorization: Bearer <token>` to access admin endpoints.
OPERATOR_TOKEN=
# ---------------------------------------------------------------------------
# Postgres
# ---------------------------------------------------------------------------
# Auto-generated by scripts/init.sh if blank.
POSTGRES_PASSWORD=
# Dev-only Postgres host port (preserves randomized assignment across projects)
POSTGRES_DEV_PORT=5447
# ---------------------------------------------------------------------------
# Redis (dev host port)
# ---------------------------------------------------------------------------
REDIS_DEV_PORT=6022
# ---------------------------------------------------------------------------
# GeoIP (optional — geolocation enrichment of triggered events)
# ---------------------------------------------------------------------------
# Register free at https://www.maxmind.com/en/geolite2/signup
# scripts/init.sh fetches GeoLite2-City.mmdb when both vars are set.
MAXMIND_ACCOUNT_ID=
MAXMIND_LICENSE_KEY=
# ---------------------------------------------------------------------------
# Webhooks (optional)
# ---------------------------------------------------------------------------
# When set, webhook payloads are signed with HMAC-SHA256 and an
# X-Canary-Signature header is added.
WEBHOOK_HMAC_SECRET=
# ---------------------------------------------------------------------------
# Fake MySQL TCP server (optional)
# ---------------------------------------------------------------------------
# Cloudflare Tunnel does not carry raw TCP — only enable when the canary
# container's port 3306 is reachable directly (e.g. on a VPS).
MYSQL_FAKE_ENABLED=false
MYSQL_HOST_PORT=33606
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
LOG_LEVEL=info
LOG_FORMAT=json
# ---------------------------------------------------------------------------
# OpenTelemetry (optional — dev compose runs Jaeger)
# ---------------------------------------------------------------------------
OTEL_ENABLED=false
OTEL_EXPORTER_OTLP_ENDPOINT=
# Dev port overrides for jaeger (preserve randomized values)
JAEGER_UI_PORT=16686
JAEGER_OTLP_GRPC_PORT=4317
JAEGER_OTLP_HTTP_PORT=4318
# ---------------------------------------------------------------------------
# Frontend dev (vite host port)
# ---------------------------------------------------------------------------
FRONTEND_HOST_PORT=15723
# ---------------------------------------------------------------------------
# Cloudflare Tunnel (overlay)
# ---------------------------------------------------------------------------
# Required if using cloudflared.compose.yml; obtain from Cloudflare Zero Trust dashboard.
CLOUDFLARE_TUNNEL_TOKEN=

View File

@ -0,0 +1,83 @@
# ©AngelaMos | 2026
# .gitignore
# ----------------------------------------------------------------------------
# Dev-only documentation (never committed)
# ----------------------------------------------------------------------------
docs/
# ----------------------------------------------------------------------------
# Environment files (secrets)
# ----------------------------------------------------------------------------
.env
.env.*
!.env.example
# ----------------------------------------------------------------------------
# Backend (Go)
# ----------------------------------------------------------------------------
backend/bin/
backend/tmp/
backend/*.test
backend/coverage.out
backend/coverage.html
backend/.golangci-cache/
backend/data/
backend/keys/
# stray binaries from `go build ./cmd/<name>` run in backend/
backend/canary
backend/healthcheck
backend/buildpdftemplate
backend/builddocxtemplate
# ----------------------------------------------------------------------------
# Frontend (Node / Vite)
# ----------------------------------------------------------------------------
frontend/node_modules/
frontend/dist/
frontend/.vite/
frontend/.vite-temp/
frontend/.tmp/
frontend/.biome_cache/
frontend/.turbo/
frontend/coverage/
# ----------------------------------------------------------------------------
# Local data volumes (when bind-mounted)
# ----------------------------------------------------------------------------
data/
*.mmdb
*.db
*.sqlite
*.sqlite3
# ----------------------------------------------------------------------------
# OS / Editor
# ----------------------------------------------------------------------------
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp
*.swo
*~
# ----------------------------------------------------------------------------
# Logs
# ----------------------------------------------------------------------------
*.log
logs/
# ----------------------------------------------------------------------------
# Generic build artifacts
# ----------------------------------------------------------------------------
dist/
build/
bin/
# ----------------------------------------------------------------------------
# Linter / pre-commit caches
# ----------------------------------------------------------------------------
.cache/
.pre-commit-cache/
.ruff_cache/

View File

@ -0,0 +1,151 @@
```yaml
██████╗ █████╗ ███╗ ██╗ █████╗ ██████╗ ██╗ ██╗
██╔════╝██╔══██╗████╗ ██║██╔══██╗██╔══██╗╚██╗ ██╔╝
██║ ███████║██╔██╗ ██║███████║██████╔╝ ╚████╔╝
██║ ██╔══██║██║╚██╗██║██╔══██║██╔══██╗ ╚██╔╝
╚██████╗██║ ██║██║ ╚████║██║ ██║██║ ██║ ██║
╚═════╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝
```
[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-Project%20%2325-red?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/beginner/canary-token-generator)
[![Go](https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white)](https://go.dev)
[![React](https://img.shields.io/badge/React-19-61DAFB?style=flat&logo=react&logoColor=black)](https://react.dev)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-18-4169E1?style=flat&logo=postgresql&logoColor=white)](https://www.postgresql.org)
[![Redis](https://img.shields.io/badge/Redis-7-DC382D?style=flat&logo=redis&logoColor=white)](https://redis.io)
[![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?style=flat&logo=docker)](https://www.docker.com)
[![MITRE Engage](https://img.shields.io/badge/MITRE-Engage-red?style=flat)](https://engage.mitre.org/)
> Self-hosted honeytoken generator. Mints seven kinds of tripwire artifacts — invisible web bugs, booby-trapped PDF/DOCX files, fake `.env` and kubeconfig credentials, and a real MySQL wire-protocol decoy — then alerts you on Telegram or a webhook the moment an attacker touches one.
*This is a quick overview — security theory, architecture, and full walkthroughs are in the [learn modules](#learn).*
## What It Does
- Seven token types, each disguised as something an attacker would actually try to use: `webbug`, `slowredirect`, `pdf`, `docx`, `envfile`, `kubeconfig`, and a real `mysql` listener that speaks the MySQL v10 handshake
- Per-token Telegram or webhook alerts the instant a token fires (HMAC-signed for webhooks)
- Async notification worker pool with per-channel timeouts and dedup gating (15-minute Redis silence window per `{token, source_ip}` so a curious attacker reloading the page doesn't spam you)
- GeoIP enrichment via MaxMind GeoLite2 (country, region, city, ASN, ASN org) attached to every event
- Browser fingerprint capture for `slowredirect` tokens via a 3-second JS-collection interstitial before the redirect resolves
- Public manage URL (UUID-gated) so you can share a single link with a teammate to view triggers without exposing operator credentials
- Operator-only admin API (constant-time bearer comparison) for global stats, token listing, and force-disable
- Cloudflare Turnstile on token creation, dual-window rate limiting (per-minute + per-hour) keyed by browser fingerprint
- Optional Cloudflare Tunnel overlay — expose the service publicly without opening a port or maintaining a TLS cert
- Defense-grade observability: OpenTelemetry traces, slog structured logs, `/healthz` liveness, graceful shutdown with load-balancer drain delay
## Quick Start
```bash
just init # generates .env, .env.development, randomised ports, operator token
just dev-up # launches nginx + Vite HMR + Go (Air hot-reload) + Postgres + Redis + Jaeger
```
Open the URL printed by `just init` (typically `http://localhost:22784`). Mint a token, watch the manage page, then trigger it from another tab and refresh.
> [!TIP]
> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see every available recipe grouped by area.
>
> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin`
## Token Types
| Type | Artifact | Trigger Mechanism | Where You'd Plant It |
|------|----------|-------------------|----------------------|
| `webbug` | URL to a 1x1 JPEG | Any HTTP GET on the URL | HTML emails, internal wikis, "do-not-touch" docs |
| `slowredirect` | URL with a delayed redirect | Click-through; runs fingerprint JS before redirecting to a real destination | Phishing-bait links in honeyfile chat threads, fake admin panel URLs |
| `pdf` | Patched PDF | Acrobat opens, fires `/AA /O /URI` page-open action | `payroll-q3.pdf` on a shared drive, `vpn-creds.pdf` on a workstation |
| `docx` | Patched Word doc | Word/LibreOffice loads the footer, which contains a remote URI | `customer-list.docx`, `passwords.docx` in `Documents/` |
| `envfile` | Plain-text `.env` | Attacker `curl`s the fake `INTERNAL_METRICS_ENDPOINT` baked into the file | Repository roots, `~/.config/`, container `/app/` directories |
| `kubeconfig` | YAML kubeconfig | Attacker runs `kubectl --kubeconfig=stolen.yaml ...` and our server logs the bearer token | `~/.kube/config`, ops engineer laptops, CI runner home dirs |
| `mysql` | `mysql://...` connection string | Attacker connects with `mysql` CLI; our TCP listener replies with a real MySQL v10 handshake and an `Access denied` packet | `.env` files, `database.yml`, internal wiki snippets |
The `envfile` generator is the densest of the bunch. It picks recipes from `aws.go`, `db.go`, `github.go`, and `stripe.go`, shuffles the resulting sections, and buries a single canary line (`INTERNAL_METRICS_ENDPOINT=https://your-host/c/{tokenID}`) among plausible production config. The attacker harvesting the file gets a fistful of fake secrets to chase *and* trips the wire as soon as one of those secrets is touched.
## HTTP API
Token creation, manage view, and admin are mounted under `/api/`. Trigger routes live at the root so artifacts can carry short URLs.
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| `POST` | `/api/tokens` | Turnstile + rate limit | Mint a new token (`type`, `memo`, `alert_channel`, channel config, type-specific metadata) |
| `GET` | `/api/tokens/types` | Public | List available token types and their metadata schemas |
| `GET` | `/api/m/{manageId}` | Manage UUID | Token details + paginated event feed + dedup silence counter |
| `DELETE` | `/api/m/{manageId}` | Manage UUID | Soft-disable the token (events stop, history retained) |
| `GET` | `/api/admin/stats` | Bearer | Tokens count, events count, breakdowns by type and alert channel |
| `GET` | `/api/admin/tokens` | Bearer | All tokens (offset paginated) |
| `POST` | `/api/admin/tokens/{id}/disable` | Bearer | Force-disable any token |
| `GET` | `/healthz` | Public | Liveness + readiness probe (used by Docker healthchecks) |
| `GET` | `/c/{tokenID}` | Public | **Trigger route.** Records event, fires notification, returns artifact body (pixel, GIF, HTML interstitial, etc.) |
| `POST` | `/c/{tokenID}/fingerprint` | Public | Receives JSON fingerprint payload from the `slowredirect` interstitial |
| `*` | `/k/{tokenID}[/*]` | Public | Kubeconfig trigger — matches `kubectl`'s wildcard API paths |
The MySQL listener does not run on HTTP. It's a separate TCP server bound to a configurable address; an attacker using the connection string from the artifact will speak the MySQL wire protocol with our `protocol.go` handshake builder before getting denied.
## Stack
**Backend:** Go 1.25, chi router, pgx + sqlx, goose migrations, koanf config, slog, validator/v10, OpenTelemetry, pdfcpu, MaxMind GeoLite2, miniredis (tests), testcontainers (integration)
**Frontend:** React 19, TypeScript, Vite, TanStack Query, Zod, Axios, Biome, Stylelint
**Storage:** PostgreSQL 18 (`tokens`, `events` tables; INET + JSONB columns), Redis 7 (dedup gate + rate-limit token buckets)
**Infra:** Docker Compose (dev: nginx + Vite HMR + Air + Postgres + Redis + Jaeger; prod: nginx + Go binary + Postgres + Redis), optional `cloudflared` overlay
## Project Layout
```
canary-token-generator/
├── backend/
│ ├── cmd/canary/ main.go — wiring, signal handling, MySQL listener spawn, retention loop
│ └── internal/
│ ├── token/ Service, repository, handler, generator interface
│ │ └── generators/
│ │ ├── webbug/ Embedded JPEG pixel
│ │ ├── pixel/ Shared 1x1 GIF helper used by every "visited" response
│ │ ├── pdf/ Byte-exact PDF placeholder substitution (76-byte URL window)
│ │ ├── docx/ ZIP-aware footer rewrite
│ │ ├── envfile/ Recipe shuffler + canary line injection
│ │ │ └── recipes/ aws.go, db.go, github.go, stripe.go
│ │ ├── kubeconfig/ text/template renderer + wildcard /k/ handler
│ │ ├── mysql/ protocol.go (handshake + auth + err packets), server.go (TCP), handler.go
│ │ └── slowredirect/ HTML interstitial + fingerprint POST handler
│ ├── event/ Event entity, service (geo enrich → insert → dedup → notify), repository
│ ├── notify/ Worker pool, queue, status writer
│ │ ├── webhook/ HMAC-signed POSTs with exponential backoff
│ │ └── telegram/ Bot API client
│ ├── middleware/ request_id, logging, recovery, realip, fingerprint, ratelimit, turnstile, operator_bearer, headers
│ ├── geoip/ MaxMind MMDB lookup wrapper (nop when no DB present)
│ ├── turnstile/ Cloudflare Turnstile siteverify
│ ├── admin/ Stats, listing, force-disable
│ ├── core/ DB pool, Redis client, migrations, telemetry, errors, validation, response envelopes
│ ├── health/ /healthz handler with readiness/shutdown flags
│ └── server/ chi router shell with graceful shutdown + drain delay
├── frontend/
│ └── src/
│ ├── pages/landing/ Token creation form (type-aware metadata, Turnstile widget, artifact reveal)
│ └── pages/manage/ Token detail + event table (cursor paginated, GeoIP cells, dedup silence)
├── infra/
│ ├── nginx/ prod.nginx, dev.nginx (Vite proxy)
│ └── docker/ Dockerfiles for prod binary, Air hot-reload, Vite HMR
├── compose.yml Production stack
├── dev.compose.yml Dev stack with Jaeger
├── cloudflared.compose.yml Tunnel overlay
├── justfile Recipes grouped by frontend / backend / lint / compose / tunnel / dev / util
└── learn/ You are here
```
## Learn
This project includes step-by-step learning materials covering deception theory, token mechanics, system design, and a code walkthrough.
| Module | Topic |
|--------|-------|
| [00 - Overview](learn/00-OVERVIEW.md) | Prerequisites, quick start, project structure |
| [01 - Concepts](learn/01-CONCEPTS.md) | Honeytokens, deception defense, Thinkst Canary, MITRE Engage, real breaches |
| [02 - Architecture](learn/02-ARCHITECTURE.md) | System design, request lifecycle, schema, dedup gate, notification pipeline |
| [03 - Implementation](learn/03-IMPLEMENTATION.md) | Code walkthrough: generators, trigger handler, event service, MySQL protocol |
| [04 - Challenges](learn/04-CHALLENGES.md) | Extension ideas — new token types, alert channels, evasion-resistance |
## License
AGPL 3.0

View File

@ -0,0 +1,29 @@
# ©AngelaMos | 2026
# .air.toml - Hot reload configuration
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/main ./cmd/canary"
bin = "tmp/main"
full_bin = "./tmp/main"
include_ext = ["go", "yaml", "yml"]
exclude_dir = ["tmp", "vendor", "bin", "keys", "migrations"]
exclude_regex = ["_test\\.go"]
delay = 1000
stop_on_error = true
send_interrupt = true
kill_delay = 500
[log]
time = false
[color]
main = "cyan"
watcher = "magenta"
build = "yellow"
runner = "green"
[misc]
clean_on_exit = true

View File

@ -0,0 +1,40 @@
# ©AngelaMos | 2026
# .gitignore
# Binaries
bin/
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test
*.test
coverage.out
coverage.html
# Build
tmp/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local
.env.*.local
# Vendor (if using)
vendor/
# Debug
__debug_bin*

View File

@ -0,0 +1,132 @@
# ©AngelaMos | 2026
# .golangci.yml
version: "2"
linters:
default: none
enable:
- errcheck
- govet
- gosec
- bodyclose
- nilerr
- errorlint
- exhaustive
- gocritic
- funlen
- gocognit
- dupl
- goconst
- ineffassign
- unused
- unconvert
- unparam
- testifylint
- fatcontext
settings:
errcheck:
check-type-assertions: true
check-blank: true
funlen:
lines: 100
statements: 50
gocognit:
min-complexity: 20
govet:
enable-all: true
disable:
- fieldalignment
revive:
rules:
- name: blank-imports
- name: context-as-argument
- name: context-keys-type
- name: error-return
- name: error-strings
- name: error-naming
- name: exported
- name: increment-decrement
- name: var-declaration
- name: package-comments
disabled: true
- name: range
- name: receiver-naming
- name: time-naming
- name: unexported-return
- name: indent-error-flow
- name: errorf
- name: empty-block
- name: superfluous-else
- name: unreachable-code
staticcheck:
checks:
- all
gosec:
excludes:
- G104
- G706
sloglint:
no-mixed-args: true
kv-only: true
context: all
exclusions:
paths:
- vendor
- testdata
rules:
- path: _test\.go
linters:
- funlen
- dupl
- goconst
- path: internal/notify/telegram/sender\.go
linters:
- gosec
text: "G107|G704"
- path: internal/notify/webhook/sender\.go
linters:
- gosec
text: "G107|G704"
- path: internal/turnstile/verifier\.go
linters:
- gosec
text: "G107|G704"
- path: internal/config/config\.go
linters:
- gosec
text: "G101"
issues:
max-same-issues: 50
formatters:
enable:
- gci # Groups imports
- gofumpt # Whitespace
- golines # Vertical wrap
settings:
golines:
max-len: 80
reformat-tags: true
goimports:
local-prefixes:
- github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator
gci:
sections:
- standard
- default
- prefix(github.com/CarterPerez-dev)
custom-order: true
gofumpt:
extra-rules: true

View File

@ -0,0 +1,136 @@
// ©AngelaMos | 2026
// main.go
package main
import (
"archive/zip"
"flag"
"fmt"
"log"
"os"
"path/filepath"
)
const (
contentTypesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/footer2.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>
</Types>
`
packageRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>
`
documentXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<w:body>
<w:p><w:r><w:t xml:space="preserve">Internal document - confidential. Do not redistribute.</w:t></w:r></w:p>
<w:sectPr>
<w:footerReference w:type="default" r:id="rIdFooter"/>
<w:pgSz w:w="12240" w:h="15840"/>
<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>
</w:sectPr>
</w:body>
</w:document>
`
documentRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rIdFooter" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer2.xml"/>
</Relationships>
`
footer2XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:p>
<w:r><w:fldChar w:fldCharType="begin"/></w:r>
<w:r><w:instrText xml:space="preserve"> INCLUDEPICTURE "HONEY_TRACK_URL" \d \* MERGEFORMAT </w:instrText></w:r>
<w:r><w:fldChar w:fldCharType="end"/></w:r>
</w:p>
</w:ftr>
`
pathContentTypes = "[Content_Types].xml"
pathPackageRels = "_rels/.rels"
pathDocument = "word/document.xml"
pathDocumentRels = "word/_rels/document.xml.rels"
pathFooter2 = "word/footer2.xml"
dirPerm os.FileMode = 0o755
filePerm os.FileMode = 0o644
)
type entry struct {
name string
body string
method uint16
}
func entries() []entry {
return []entry{
{name: pathContentTypes, body: contentTypesXML, method: zip.Store},
{name: pathPackageRels, body: packageRelsXML, method: zip.Deflate},
{name: pathDocument, body: documentXML, method: zip.Deflate},
{name: pathDocumentRels, body: documentRelsXML, method: zip.Store},
{name: pathFooter2, body: footer2XML, method: zip.Deflate},
}
}
func main() {
out := flag.String("out", "", "output path for template.docx")
flag.Parse()
if *out == "" {
log.Fatal("usage: builddocxtemplate -out <path>")
}
if err := buildTemplate(*out); err != nil {
log.Fatalf("build docx template: %v", err)
}
fmt.Printf("wrote %s\n", *out)
}
func buildTemplate(out string) (err error) {
cleaned := filepath.Clean(out)
if mkErr := os.MkdirAll(filepath.Dir(cleaned), dirPerm); mkErr != nil {
return fmt.Errorf("mkdir parent: %w", mkErr)
}
f, oErr := os.OpenFile(
cleaned,
os.O_CREATE|os.O_TRUNC|os.O_WRONLY,
filePerm,
)
if oErr != nil {
return fmt.Errorf("open output: %w", oErr)
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = fmt.Errorf("close output: %w", cerr)
}
}()
w := zip.NewWriter(f)
for _, e := range entries() {
hdr := &zip.FileHeader{Name: e.name, Method: e.method}
fw, cErr := w.CreateHeader(hdr)
if cErr != nil {
return fmt.Errorf("create %s: %w", e.name, cErr)
}
if _, wErr := fw.Write([]byte(e.body)); wErr != nil {
return fmt.Errorf("write %s: %w", e.name, wErr)
}
}
if zErr := w.Close(); zErr != nil {
return fmt.Errorf("close zip writer: %w", zErr)
}
return nil
}

View File

@ -0,0 +1,154 @@
// ©AngelaMos | 2026
// main.go
package main
import (
"bytes"
"crypto/sha256"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/pdfcpu/pdfcpu/pkg/api"
)
const (
placeholderRoot = "HONEY_TRACK_URL_PADDED_TO_FIXED_WIDTH"
placeholderLength = 76
pdfHeader = "%PDF-1.4\n%\xe2\xe3\xcf\xd3\n"
objCatalog = "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"
objPages = "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"
pageDictFmt = "3 0 obj\n" +
"<<\n" +
"/Type /Page\n" +
"/Parent 2 0 R\n" +
"/MediaBox [0 0 612 792]\n" +
"/Resources << >>\n" +
"/AA << /O << /Type /Action /S /URI /URI (%s) >> >>\n" +
">>\nendobj\n"
xrefHeader = "xref\n0 4\n"
xrefFreeRecord = "0000000000 65535 f \n"
xrefRecordFmt = "%010d 00000 n \n"
trailerFmt = "trailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n"
dirPerm os.FileMode = 0o755
filePerm os.FileMode = 0o644
)
func placeholder() string {
return placeholderRoot +
strings.Repeat("_", placeholderLength-len(placeholderRoot))
}
func main() {
out := flag.String("out", "", "output path for template.pdf")
flag.Parse()
if *out == "" {
log.Fatal("usage: buildpdftemplate -out <path>")
}
if err := buildTemplate(*out); err != nil {
log.Fatalf("build pdf template: %v", err)
}
fmt.Printf("wrote %s\n", *out)
}
func buildTemplate(out string) (err error) {
cleaned := filepath.Clean(out)
if mkErr := os.MkdirAll(filepath.Dir(cleaned), dirPerm); mkErr != nil {
return fmt.Errorf("mkdir parent: %w", mkErr)
}
pdfBytes := assemblePDF()
if vErr := api.Validate(
bytes.NewReader(pdfBytes),
nil,
); vErr != nil {
return fmt.Errorf("pdfcpu validate: %w", vErr)
}
full := placeholder()
if c := bytes.Count(pdfBytes, []byte(full)); c != 1 {
return fmt.Errorf(
"full placeholder appears %d times, expected exactly 1",
c,
)
}
if c := bytes.Count(pdfBytes, []byte(placeholderRoot)); c != 1 {
return fmt.Errorf(
"placeholder root appears %d times, expected exactly 1",
c,
)
}
if len(full) != placeholderLength {
return fmt.Errorf(
"placeholder length is %d, expected %d",
len(full),
placeholderLength,
)
}
f, oErr := os.OpenFile(
cleaned,
os.O_CREATE|os.O_TRUNC|os.O_WRONLY,
filePerm,
)
if oErr != nil {
return fmt.Errorf("open output: %w", oErr)
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = fmt.Errorf("close output: %w", cerr)
}
}()
if _, wErr := f.Write(pdfBytes); wErr != nil {
return fmt.Errorf("write pdf: %w", wErr)
}
sum := sha256.Sum256(pdfBytes)
fmt.Printf("sha256: %x\n", sum)
fmt.Printf("size: %d bytes\n", len(pdfBytes))
return nil
}
func assemblePDF() []byte {
pageObj := fmt.Sprintf(pageDictFmt, placeholder())
var buf bytes.Buffer
buf.WriteString(pdfHeader)
offsets := make([]int, 4)
offsets[1] = buf.Len()
buf.WriteString(objCatalog)
offsets[2] = buf.Len()
buf.WriteString(objPages)
offsets[3] = buf.Len()
buf.WriteString(pageObj)
xrefOffset := buf.Len()
buf.WriteString(xrefHeader)
buf.WriteString(xrefFreeRecord)
for i := 1; i <= 3; i++ {
fmt.Fprintf(&buf, xrefRecordFmt, offsets[i])
}
fmt.Fprintf(&buf, trailerFmt, xrefOffset)
return buf.Bytes()
}

View File

@ -0,0 +1,506 @@
// ©AngelaMos | 2026
// main.go
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/admin"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/config"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/core"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/geoip"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/health"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/notify"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/notify/telegram"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/notify/webhook"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/server"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/mysql"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/registry"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/turnstile"
)
const (
drainDelay = 5 * time.Second
shutdownGraceExtra = 5 * time.Second
)
func main() {
configPath := flag.String("config", "config.yaml", "path to config file")
flag.Parse()
if err := run(*configPath); err != nil {
slog.Error("application error", "error", err)
os.Exit(1)
}
}
func run(configPath string) error {
ctx, stop := signal.NotifyContext(
context.Background(),
syscall.SIGINT,
syscall.SIGTERM,
)
defer stop()
cfg, err := config.Load(configPath)
if err != nil {
return err
}
logger := setupLogger(cfg.Log)
slog.SetDefault(logger)
logger.Info("starting canary-token-generator",
"version", cfg.App.Version,
"environment", cfg.App.Environment,
)
if err = middleware.SetTrustedProxyCIDRs(
cfg.Server.TrustedProxyCIDRs,
); err != nil {
return fmt.Errorf("trusted proxy cidrs: %w", err)
}
telemetry := initTelemetry(ctx, cfg, logger)
db, err := core.NewDatabase(ctx, cfg.Database)
if err != nil {
return err
}
logger.Info("database connected")
if err = core.RunMigrations(db.SQLDB()); err != nil {
return fmt.Errorf("run migrations: %w", err)
}
logger.Info("migrations applied")
tokenRepo := token.NewRepository(db.DB)
eventRepo := event.NewRepository(db.DB)
rdb, err := core.NewRedis(ctx, cfg.Redis)
if err != nil {
return err
}
logger.Info("redis connected")
geo, geoCloser := openGeoIP(cfg, logger)
defer geoCloser()
notifySvc, eventSvc := buildEventStack(
cfg, logger, eventRepo, tokenRepo, rdb, geo,
)
tokenSvc, verifier, healthH, tokenH := buildHTTPDeps(
cfg, logger, db, rdb, eventRepo, tokenRepo, eventSvc,
)
adminH := admin.NewHandler(tokenRepo, eventRepo, tokenSvc, logger)
srv := mountRouter(cfg, logger, rdb, healthH, tokenH, adminH, verifier)
var wg sync.WaitGroup
spawnMySQLListener(ctx, cfg, logger, &wg, tokenSvc, eventSvc)
spawnRetentionLoop(ctx, cfg, &wg, eventSvc)
errChan := make(chan error, 1)
go func() { errChan <- srv.Start() }()
select {
case startErr := <-errChan:
return startErr
case <-ctx.Done():
logger.Info("shutdown signal received")
}
shutdownErr := gracefulShutdown(cfg, logger, srv, telemetry, rdb, db)
logger.Info("waiting for in-flight notifications")
notifyShutdownCtx, notifyShutdownCancel := context.WithTimeout(
context.Background(),
cfg.Server.ShutdownTimeout,
)
if nErr := notifySvc.Shutdown(notifyShutdownCtx); nErr != nil {
logger.Warn("notify shutdown timed out", "error", nErr)
}
notifyShutdownCancel()
wg.Wait()
return shutdownErr
}
func buildHTTPDeps(
cfg *config.Config,
logger *slog.Logger,
db *core.Database,
rdb *core.Redis,
eventRepo *event.Repository,
tokenRepo *token.Repository,
eventSvc *event.Service,
) (*token.Service, *turnstile.Verifier, *health.Handler, *token.Handler) {
genRegistry := registry.Build(registry.Config{
BaseURL: cfg.Canary.BaseURL,
MySQLPublicHost: cfg.MySQL.PublicHost,
MySQLPublicPort: cfg.MySQL.PublicPort,
})
tokenSvc := token.NewService(
tokenRepo,
registryAdapter{r: genRegistry},
token.ServiceConfig{
BaseURL: cfg.Canary.BaseURL,
ManageURL: cfg.Canary.ManageURL,
},
)
verifier := turnstile.NewVerifier(
turnstile.Config{SecretKey: cfg.Turnstile.SecretKey},
rdb.Client,
)
healthH := health.NewHandler(db, rdb)
tokenH := token.NewHandler(
tokenSvc,
&eventRecorderAdapter{svc: eventSvc},
&fingerprintRecorderAdapter{
repo: eventRepo,
window: cfg.Notify.FingerprintWindow,
},
eventRepo,
eventSvc,
logger,
cfg.MySQL.Enabled,
)
return tokenSvc, verifier, healthH, tokenH
}
func buildEventStack(
cfg *config.Config,
logger *slog.Logger,
eventRepo *event.Repository,
tokenRepo *token.Repository,
rdb *core.Redis,
geo geoip.Lookuper,
) (*notify.Service, *event.Service) {
tgSender := telegram.NewSender(telegram.Config{
APIBase: cfg.Notify.TelegramAPIBase,
ManageURL: cfg.Canary.ManageURL,
MaxTries: cfg.Notify.MaxTries,
MaxElapsed: cfg.Notify.MaxElapsed,
InitialInterval: cfg.Notify.InitialInterval,
})
whSender := webhook.NewSender(webhook.Config{
ManageURL: cfg.Canary.ManageURL,
HMACSecret: cfg.Notify.WebhookHMACSecret,
MaxTries: cfg.Notify.MaxTries,
MaxElapsed: cfg.Notify.MaxElapsed,
InitialInterval: cfg.Notify.InitialInterval,
})
notifySvc := notify.NewService(eventRepo,
notify.WithLogger(logger),
notify.WithSendTimeout(cfg.Notify.SendTimeout),
)
notifySvc.Register(tgSender, whSender)
eventSvc := event.NewService(
eventRepo,
tokenRepo,
rdb.Client,
notifySvc,
event.ServiceConfig{
DedupTTL: cfg.Notify.DedupTTL,
Logger: logger,
GeoIP: geo,
},
)
return notifySvc, eventSvc
}
func openGeoIP(
cfg *config.Config,
logger *slog.Logger,
) (geoip.Lookuper, func()) {
svc, err := geoip.Open(cfg.GeoIP.Path)
if err != nil {
logger.Warn("geoip unavailable, enrichment disabled",
"path", cfg.GeoIP.Path, "error", err)
return geoip.NopService(), func() {}
}
logger.Info("geoip opened", "path", cfg.GeoIP.Path)
return svc, func() {
if cErr := svc.Close(); cErr != nil {
logger.Warn("geoip close error", "error", cErr)
}
}
}
func spawnMySQLListener(
ctx context.Context,
cfg *config.Config,
logger *slog.Logger,
wg *sync.WaitGroup,
tokenSvc *token.Service,
eventSvc *event.Service,
) {
if !cfg.MySQL.Enabled {
return
}
wg.Add(1)
go func() {
defer wg.Done()
handler := mysql.NewHandler(
&mysqlTokenLookup{svc: tokenSvc},
&eventRecorderAdapter{svc: eventSvc},
)
if mErr := mysql.Run(ctx, cfg.MySQL.Addr, handler); mErr != nil {
logger.Error("mysql server error", "error", mErr)
}
}()
}
func spawnRetentionLoop(
ctx context.Context,
cfg *config.Config,
wg *sync.WaitGroup,
eventSvc *event.Service,
) {
if cfg.Notify.RetentionInterval <= 0 || cfg.Notify.RetentionLimit <= 0 {
return
}
wg.Add(1)
go func() {
defer wg.Done()
eventSvc.RunRetentionLoop(
ctx,
cfg.Notify.RetentionInterval,
cfg.Notify.RetentionLimit,
)
}()
}
func mountRouter(
cfg *config.Config,
logger *slog.Logger,
rdb *core.Redis,
healthH *health.Handler,
tokenH *token.Handler,
adminH *admin.Handler,
verifier *turnstile.Verifier,
) *server.Server {
srv := server.New(server.Config{
ServerConfig: cfg.Server,
HealthHandler: healthH,
Logger: logger,
})
r := srv.Router()
r.Use(middleware.RequestID)
r.Use(middleware.Logger(logger))
r.Use(middleware.Recovery(logger))
r.Use(middleware.SecurityHeaders(cfg.App.Environment == "production"))
healthH.RegisterRoutes(r)
tokenH.RegisterTriggerRoutes(r)
createMin := middleware.NewRateLimiter(
rdb.Client,
middleware.RateLimitConfig{
Limit: middleware.PerMinute(
cfg.RateLimit.CreateMinRate,
cfg.RateLimit.CreateMinBurst,
),
KeyFunc: keyByCreateMin,
FailOpen: true,
},
).Handler
createHour := middleware.NewRateLimiter(
rdb.Client,
middleware.RateLimitConfig{
Limit: middleware.PerHour(
cfg.RateLimit.CreateHourRate,
cfg.RateLimit.CreateHourBurst,
),
KeyFunc: keyByCreateHour,
FailOpen: true,
},
).Handler
r.Route("/api", func(api chi.Router) {
api.Use(middleware.CORS(cfg.CORS))
api.Use(
middleware.NewRateLimiter(rdb.Client, middleware.RateLimitConfig{
Limit: middleware.PerMinute(
cfg.RateLimit.Requests,
cfg.RateLimit.Burst,
),
KeyFunc: middleware.KeyByFingerprint,
FailOpen: true,
}).Handler,
)
api.Get("/tokens/types", tokenH.GetTypes)
api.With(
createMin,
createHour,
middleware.TurnstileVerify(verifier),
).Post("/tokens", tokenH.CreateToken)
tokenH.RegisterManageRoutes(api)
mountAdminRoutes(api, cfg, logger, adminH)
})
return srv
}
func mountAdminRoutes(
api chi.Router,
cfg *config.Config,
logger *slog.Logger,
adminH *admin.Handler,
) {
if cfg.Operator.Token == "" {
logger.Warn("operator admin endpoints disabled",
"reason", "OPERATOR_TOKEN unset")
return
}
api.Route("/admin", func(adm chi.Router) {
adm.Use(middleware.OperatorBearer(cfg.Operator.Token))
adminH.RegisterRoutes(adm)
})
}
func keyByCreateMin(r *http.Request) string {
return "ratelimit:create:min:" + middleware.ExtractFingerprint(r)
}
func keyByCreateHour(r *http.Request) string {
return "ratelimit:create:hour:" + middleware.ExtractFingerprint(r)
}
func gracefulShutdown(
cfg *config.Config,
logger *slog.Logger,
srv *server.Server,
telemetry *core.Telemetry,
rdb *core.Redis,
db *core.Database,
) error {
shutdownCtx, cancel := context.WithTimeout(
context.Background(),
cfg.Server.ShutdownTimeout+drainDelay+shutdownGraceExtra,
)
defer cancel()
var errs []error
if err := srv.Shutdown(shutdownCtx, drainDelay); err != nil {
logger.Error("server shutdown error", "error", err)
errs = append(errs, fmt.Errorf("server shutdown: %w", err))
}
if telemetry != nil {
if err := telemetry.Shutdown(shutdownCtx); err != nil {
logger.Error("telemetry shutdown error", "error", err)
errs = append(errs, fmt.Errorf("telemetry shutdown: %w", err))
}
}
if err := rdb.Close(); err != nil {
logger.Error("redis close error", "error", err)
errs = append(errs, fmt.Errorf("redis close: %w", err))
}
if err := db.Close(); err != nil {
logger.Error("database close error", "error", err)
errs = append(errs, fmt.Errorf("database close: %w", err))
}
logger.Info("application stopped")
return errors.Join(errs...)
}
func initTelemetry(
ctx context.Context,
cfg *config.Config,
logger *slog.Logger,
) *core.Telemetry {
if !cfg.Otel.Enabled {
return nil
}
t, err := core.NewTelemetry(ctx, cfg.Otel, cfg.App)
if err != nil {
logger.Warn("telemetry init failed", "error", err)
return nil
}
return t
}
func setupLogger(cfg config.LogConfig) *slog.Logger {
level := slog.LevelInfo
switch cfg.Level {
case "debug":
level = slog.LevelDebug
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
}
opts := &slog.HandlerOptions{Level: level}
var handler slog.Handler
if cfg.Format == "json" {
handler = slog.NewJSONHandler(os.Stdout, opts)
} else {
handler = slog.NewTextHandler(os.Stdout, opts)
}
return slog.New(handler)
}
type registryAdapter struct{ r registry.Registry }
func (a registryAdapter) Get(t token.Type) (token.Generator, bool) {
g, ok := a.r[t]
return g, ok
}
type eventRecorderAdapter struct {
svc *event.Service
}
func (a *eventRecorderAdapter) Record(
ctx context.Context,
t *token.Token,
evt *event.Event,
) error {
return a.svc.Record(ctx, t.NotifyInfo(), evt)
}
type fingerprintRecorderAdapter struct {
repo *event.Repository
window time.Duration
}
func (f *fingerprintRecorderAdapter) AttachFingerprint(
ctx context.Context,
tokenID, sourceIP string,
fingerprint json.RawMessage,
) error {
return f.repo.AttachFingerprint(
ctx,
tokenID,
sourceIP,
fingerprint,
f.window,
)
}
type mysqlTokenLookup struct{ svc *token.Service }
func (m *mysqlTokenLookup) GetByID(
ctx context.Context,
id string,
) (*token.Token, error) {
return m.svc.GetByID(ctx, id)
}

View File

@ -0,0 +1,27 @@
// ©AngelaMos | 2026
// main.go
package main
import (
"net/http"
"os"
"time"
)
const (
healthURL = "http://127.0.0.1:8080/healthz"
httpDialTO = 2 * time.Second
)
func main() {
client := &http.Client{Timeout: httpDialTO}
resp, err := client.Get(healthURL)
if err != nil {
os.Exit(1)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
os.Exit(1)
}
}

View File

@ -0,0 +1,48 @@
# ©AngelaMos | 2026
# config.yaml - Default configuration
app:
name: "Canary Token Generator"
version: "1.0.0"
server:
host: "0.0.0.0"
port: 8080
read_timeout: 30s
write_timeout: 30s
idle_timeout: 120s
shutdown_timeout: 15s
database:
max_open_conns: 25
max_idle_conns: 5
conn_max_lifetime: 1h
conn_max_idle_time: 30m
redis:
pool_size: 10
min_idle_conns: 5
rate_limit:
requests: 100
window: 1m
burst: 20
cors:
allowed_origins:
- "http://localhost:3000"
- "http://localhost:3420"
allowed_methods:
- "GET"
- "POST"
- "PUT"
- "PATCH"
- "DELETE"
- "OPTIONS"
allowed_headers:
- "Accept"
- "Authorization"
- "Content-Type"
- "X-Request-ID"
allow_credentials: true
max_age: 300

View File

@ -0,0 +1,116 @@
module github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend
go 1.25.7
require (
github.com/alicebob/miniredis/v2 v2.38.0
github.com/cenkalti/backoff/v5 v5.0.3
github.com/go-chi/chi/v5 v5.2.3
github.com/go-playground/validator/v10 v10.23.0
github.com/go-redis/redis_rate/v10 v10.0.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.2
github.com/jmoiron/sqlx v1.4.0
github.com/knadh/koanf/parsers/yaml v1.1.0
github.com/knadh/koanf/providers/env v1.1.0
github.com/knadh/koanf/providers/file v1.2.1
github.com/knadh/koanf/v2 v2.1.2
github.com/oschwald/geoip2-golang/v2 v2.1.0
github.com/pdfcpu/pdfcpu v0.12.1
github.com/pressly/goose/v3 v3.27.1
github.com/redis/go-redis/v9 v9.7.0
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.42.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
golang.org/x/time v0.14.0
google.golang.org/grpc v1.80.0
gopkg.in/yaml.v3 v3.0.1
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect
github.com/hhrutter/lzw v1.0.0 // indirect
github.com/hhrutter/pkcs7 v0.2.2 // indirect
github.com/hhrutter/tiff v1.0.3 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.54.2 // indirect
github.com/moby/moby/client v0.4.1 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/oschwald/maxminddb-golang/v2 v2.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.4.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/image v0.39.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

View File

@ -0,0 +1,290 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-redis/redis_rate/v10 v10.0.1 h1:calPxi7tVlxojKunJwQ72kwfozdy25RjA0bCj1h0MUo=
github.com/go-redis/redis_rate/v10 v10.0.1/go.mod h1:EMiuO9+cjRkR7UvdvwMO7vbgqJkltQHtwbdIQvaBKIU=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI=
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
github.com/hhrutter/pkcs7 v0.2.2 h1:xMoifoVWah1LNym3C0pomEiLmyJyVIBXt/8oTPyPz+8=
github.com/hhrutter/pkcs7 v0.2.2/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE=
github.com/hhrutter/tiff v1.0.3 h1:POV5xITOE1Lt5FvP24ylft0LyCmHmc8GkJ1SVlvUyk0=
github.com/hhrutter/tiff v1.0.3/go.mod h1:zZDLVY4cp9za2FLrryAaGszwWYAUM6DrRiBR0l//mxA=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4=
github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg=
github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc=
github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY=
github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM=
github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
github.com/knadh/koanf/v2 v2.1.2 h1:I2rtLRqXRy1p01m/utEtpZSSA6dcJbgGVuE27kW2PzQ=
github.com/knadh/koanf/v2 v2.1.2/go.mod h1:Gphfaen0q1Fc1HTgJgSTC4oRX9R2R5ErYMZJy8fLJBo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/oschwald/geoip2-golang/v2 v2.1.0 h1:DjnLhNJu9WHwTrmoiQFvgmyJoczhdnm7LB23UBI2Amo=
github.com/oschwald/geoip2-golang/v2 v2.1.0/go.mod h1:qdVmcPgrTJ4q2eP9tHq/yldMTdp2VMr33uVdFbHBiBc=
github.com/oschwald/maxminddb-golang/v2 v2.1.1 h1:lA8FH0oOrM4u7mLvowq8IT6a3Q/qEnqRzLQn9eH5ojc=
github.com/oschwald/maxminddb-golang/v2 v2.1.1/go.mod h1:PLdx6PR+siSIoXqqy7C7r3SB3KZnhxWr1Dp6g0Hacl8=
github.com/pdfcpu/pdfcpu v0.12.1 h1:HwoN72zJCj+pPbfMDChYBTZrT7SY0VwgUzqeaId3I20=
github.com/pdfcpu/pdfcpu v0.12.1/go.mod h1:7KPpVLMavcpliPrtN6o7Kuk3cFtYq8nii3SJnnsK7ps=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo=
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg=
go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM=
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=

View File

@ -0,0 +1,26 @@
// ©AngelaMos | 2026
// dto.go
package admin
import (
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
)
type Stats struct {
TokensCount int64 `json:"tokens_count"`
EventsCount int64 `json:"events_count"`
ByType []token.TypeCount `json:"by_type"`
ByAlertChannel []token.ChannelCount `json:"by_alert_channel"`
}
type TokenListPage struct {
NextOffset int `json:"next_offset"`
HasMore bool `json:"has_more"`
}
type TokenListResponse struct {
Tokens []token.Response `json:"tokens"`
Total int64 `json:"total"`
Page TokenListPage `json:"page"`
}

View File

@ -0,0 +1,257 @@
// ©AngelaMos | 2026
// handler.go
package admin
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
)
const (
urlParamID = "id"
queryParamOffset = "offset"
queryParamLimit = "limit"
defaultPageSize = 50
maxPageSize = 100
headerContentType = "Content-Type"
contentTypeJSON = "application/json"
errorCodeNotFound = "NOT_FOUND"
errorCodeBadParam = "BAD_PARAM"
errorCodeInternalError = "INTERNAL_ERROR"
respMessageNotFound = "not found"
respMessageBadOffset = "invalid offset"
respMessageInternalError = "internal server error"
)
type TokenRepository interface {
ListAll(ctx context.Context, opts token.ListOptions) ([]token.Token, error)
CountAll(ctx context.Context) (int64, error)
CountByType(ctx context.Context) ([]token.TypeCount, error)
CountByAlertChannel(ctx context.Context) ([]token.ChannelCount, error)
SetEnabled(ctx context.Context, id string, enabled bool) error
}
type EventRepository interface {
CountAll(ctx context.Context) (int64, error)
}
type URLBuilder interface {
TriggerURL(id string) string
ManageURL(manageID string) string
}
type Handler struct {
tokens TokenRepository
events EventRepository
urls URLBuilder
logger *slog.Logger
}
func NewHandler(
tokens TokenRepository,
events EventRepository,
urls URLBuilder,
logger *slog.Logger,
) *Handler {
if logger == nil {
logger = slog.Default()
}
return &Handler{
tokens: tokens,
events: events,
urls: urls,
logger: logger,
}
}
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Get("/stats", h.GetStats)
r.Get("/tokens", h.ListTokens)
r.Post("/tokens/{"+urlParamID+"}/disable", h.DisableToken)
}
func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
tokensCount, err := h.tokens.CountAll(ctx)
if err != nil {
h.logger.ErrorContext(ctx, "admin: count tokens", "error", err)
h.writeInternal(w)
return
}
eventsCount, err := h.events.CountAll(ctx)
if err != nil {
h.logger.ErrorContext(ctx, "admin: count events", "error", err)
h.writeInternal(w)
return
}
byType, err := h.tokens.CountByType(ctx)
if err != nil {
h.logger.ErrorContext(ctx, "admin: count by type", "error", err)
h.writeInternal(w)
return
}
byChannel, err := h.tokens.CountByAlertChannel(ctx)
if err != nil {
h.logger.ErrorContext(ctx, "admin: count by channel", "error", err)
h.writeInternal(w)
return
}
stats := Stats{
TokensCount: tokensCount,
EventsCount: eventsCount,
ByType: byType,
ByAlertChannel: byChannel,
}
h.writeJSON(w, http.StatusOK, envelopeData(stats))
}
func (h *Handler) ListTokens(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
offset, err := parseOffset(r.URL.Query().Get(queryParamOffset))
if err != nil {
h.writeJSON(w, http.StatusBadRequest, envelopeError(
errorCodeBadParam, respMessageBadOffset,
))
return
}
limit := parseLimit(r.URL.Query().Get(queryParamLimit))
rows, err := h.tokens.ListAll(ctx, token.ListOptions{
Limit: limit,
Offset: offset,
})
if err != nil {
h.logger.ErrorContext(ctx, "admin: list tokens", "error", err)
h.writeInternal(w)
return
}
total, err := h.tokens.CountAll(ctx)
if err != nil {
h.logger.ErrorContext(ctx, "admin: count tokens", "error", err)
h.writeInternal(w)
return
}
out := make([]token.Response, 0, len(rows))
for i := range rows {
out = append(out, rows[i].ToResponse(
h.urls.TriggerURL(rows[i].ID),
h.urls.ManageURL(rows[i].ManageID),
))
}
next := offset + len(rows)
hasMore := int64(next) < total
resp := TokenListResponse{
Tokens: out,
Total: total,
Page: TokenListPage{
NextOffset: next,
HasMore: hasMore,
},
}
h.writeJSON(w, http.StatusOK, envelopeData(resp))
}
func (h *Handler) DisableToken(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, urlParamID)
if id == "" {
h.writeJSON(w, http.StatusNotFound, envelopeError(
errorCodeNotFound, respMessageNotFound,
))
return
}
if err := h.tokens.SetEnabled(r.Context(), id, false); err != nil {
if errors.Is(err, token.ErrNotFound) {
h.writeJSON(w, http.StatusNotFound, envelopeError(
errorCodeNotFound, respMessageNotFound,
))
return
}
h.logger.ErrorContext(r.Context(), "admin: disable token",
"error", err, "token_id", id)
h.writeInternal(w)
return
}
w.WriteHeader(http.StatusNoContent)
}
func parseOffset(raw string) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, nil
}
v, err := strconv.Atoi(raw)
if err != nil || v < 0 {
return 0, errors.New("invalid offset")
}
return v, nil
}
func parseLimit(raw string) int {
raw = strings.TrimSpace(raw)
if raw == "" {
return defaultPageSize
}
v, err := strconv.Atoi(raw)
if err != nil || v <= 0 {
return defaultPageSize
}
if v > maxPageSize {
return maxPageSize
}
return v
}
func (h *Handler) writeJSON(
w http.ResponseWriter,
status int,
body any,
) {
w.Header().Set(headerContentType, contentTypeJSON)
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(body); err != nil {
h.logger.Warn("write json response", "error", err)
}
}
func (h *Handler) writeInternal(w http.ResponseWriter) {
h.writeJSON(w, http.StatusInternalServerError, envelopeError(
errorCodeInternalError, respMessageInternalError,
))
}
func envelopeData(data any) map[string]any {
return map[string]any{"success": true, "data": data}
}
func envelopeError(code, message string) map[string]any {
return map[string]any{
"success": false,
"error": map[string]any{
"code": code,
"message": message,
},
}
}

View File

@ -0,0 +1,506 @@
// ©AngelaMos | 2026
// handler_test.go
package admin_test
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strconv"
"sync"
"testing"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/admin"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
)
const (
testBaseURL = "https://canary.example.com"
testManageURL = "https://canary.example.com"
)
type fakeURLBuilder struct{}
func (fakeURLBuilder) TriggerURL(id string) string {
return testBaseURL + "/c/" + id
}
func (fakeURLBuilder) ManageURL(manageID string) string {
return testManageURL + "/m/" + manageID
}
type fakeTokenRepo struct {
mu sync.Mutex
tokens []token.Token
disabledCalls []string
setEnabledErr error
listErr error
countErr error
countByTypeErr error
countByChannelErr error
}
func newFakeTokenRepo() *fakeTokenRepo {
return &fakeTokenRepo{tokens: []token.Token{}}
}
func (f *fakeTokenRepo) ListAll(
_ context.Context,
opts token.ListOptions,
) ([]token.Token, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.listErr != nil {
return nil, f.listErr
}
start := opts.Offset
if start > len(f.tokens) {
start = len(f.tokens)
}
end := start + opts.Limit
if end > len(f.tokens) {
end = len(f.tokens)
}
out := make([]token.Token, end-start)
copy(out, f.tokens[start:end])
return out, nil
}
func (f *fakeTokenRepo) CountAll(_ context.Context) (int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.countErr != nil {
return 0, f.countErr
}
return int64(len(f.tokens)), nil
}
func (f *fakeTokenRepo) CountByType(
_ context.Context,
) ([]token.TypeCount, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.countByTypeErr != nil {
return nil, f.countByTypeErr
}
counts := map[token.Type]int64{}
for _, t := range f.tokens {
counts[t.Type]++
}
out := []token.TypeCount{}
for typ, c := range counts {
out = append(out, token.TypeCount{Type: typ, Count: c})
}
return out, nil
}
func (f *fakeTokenRepo) CountByAlertChannel(
_ context.Context,
) ([]token.ChannelCount, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.countByChannelErr != nil {
return nil, f.countByChannelErr
}
counts := map[token.AlertChannel]int64{}
for _, t := range f.tokens {
counts[t.AlertChannel]++
}
out := []token.ChannelCount{}
for ch, c := range counts {
out = append(out, token.ChannelCount{Channel: ch, Count: c})
}
return out, nil
}
func (f *fakeTokenRepo) SetEnabled(
_ context.Context,
id string,
enabled bool,
) error {
f.mu.Lock()
defer f.mu.Unlock()
f.disabledCalls = append(f.disabledCalls, id)
if f.setEnabledErr != nil {
return f.setEnabledErr
}
for i := range f.tokens {
if f.tokens[i].ID == id {
f.tokens[i].Enabled = enabled
return nil
}
}
return token.ErrNotFound
}
type fakeEventRepo struct {
count int64
err error
}
func (f *fakeEventRepo) CountAll(_ context.Context) (int64, error) {
if f.err != nil {
return 0, f.err
}
return f.count, nil
}
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func newRouter(h *admin.Handler) chi.Router {
r := chi.NewRouter()
h.RegisterRoutes(r)
return r
}
func seedToken(id string, typ token.Type, ch token.AlertChannel) token.Token {
return token.Token{
ID: id,
ManageID: "mng-" + id,
Type: typ,
AlertChannel: ch,
Enabled: true,
Metadata: json.RawMessage(`{}`),
}
}
func TestAdmin_GetStats_HappyPath(t *testing.T) {
repo := newFakeTokenRepo()
repo.tokens = []token.Token{
seedToken("a01", token.TypeWebbug, token.ChannelTelegram),
seedToken("a02", token.TypeWebbug, token.ChannelWebhook),
seedToken("a03", token.TypeDocx, token.ChannelTelegram),
}
events := &fakeEventRepo{count: 17}
h := admin.NewHandler(repo, events, fakeURLBuilder{}, quietLogger())
r := newRouter(h)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/stats", nil))
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, "application/json", w.Header().Get("Content-Type"))
var body struct {
Success bool `json:"success"`
Data admin.Stats `json:"data"`
}
require.NoError(t, json.NewDecoder(w.Body).Decode(&body))
require.True(t, body.Success)
require.Equal(t, int64(3), body.Data.TokensCount)
require.Equal(t, int64(17), body.Data.EventsCount)
require.NotEmpty(t, body.Data.ByType)
require.NotEmpty(t, body.Data.ByAlertChannel)
byType := map[token.Type]int64{}
for _, c := range body.Data.ByType {
byType[c.Type] = c.Count
}
require.Equal(t, int64(2), byType[token.TypeWebbug])
require.Equal(t, int64(1), byType[token.TypeDocx])
byChan := map[token.AlertChannel]int64{}
for _, c := range body.Data.ByAlertChannel {
byChan[c.Channel] = c.Count
}
require.Equal(t, int64(2), byChan[token.ChannelTelegram])
require.Equal(t, int64(1), byChan[token.ChannelWebhook])
}
func TestAdmin_GetStats_TokenCountFails_500(t *testing.T) {
repo := newFakeTokenRepo()
repo.countErr = errors.New("db down")
events := &fakeEventRepo{}
h := admin.NewHandler(repo, events, fakeURLBuilder{}, quietLogger())
r := newRouter(h)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/stats", nil))
require.Equal(t, http.StatusInternalServerError, w.Code)
require.Contains(t, w.Body.String(), `"INTERNAL_ERROR"`)
}
func TestAdmin_GetStats_EventCountFails_500(t *testing.T) {
repo := newFakeTokenRepo()
events := &fakeEventRepo{err: errors.New("redis down")}
h := admin.NewHandler(repo, events, fakeURLBuilder{}, quietLogger())
r := newRouter(h)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/stats", nil))
require.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestAdmin_ListTokens_DefaultPagination(t *testing.T) {
repo := newFakeTokenRepo()
for i := range 3 {
repo.tokens = append(repo.tokens, seedToken(
"t0"+string(rune('a'+i)),
token.TypeWebbug,
token.ChannelWebhook,
))
}
h := admin.NewHandler(
repo,
&fakeEventRepo{},
fakeURLBuilder{},
quietLogger(),
)
r := newRouter(h)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/tokens", nil))
require.Equal(t, http.StatusOK, w.Code)
var body struct {
Success bool `json:"success"`
Data admin.TokenListResponse `json:"data"`
}
require.NoError(t, json.NewDecoder(w.Body).Decode(&body))
require.True(t, body.Success)
require.Len(t, body.Data.Tokens, 3)
require.Equal(t, int64(3), body.Data.Total)
require.False(t, body.Data.Page.HasMore)
require.Equal(t, 3, body.Data.Page.NextOffset)
require.Equal(t, testBaseURL+"/c/t0a", body.Data.Tokens[0].TriggerURL)
require.Equal(t, testManageURL+"/m/mng-t0a", body.Data.Tokens[0].ManageURL)
}
func TestAdmin_ListTokens_HasMoreWhenBeyondPage(t *testing.T) {
repo := newFakeTokenRepo()
for i := range 5 {
repo.tokens = append(repo.tokens, seedToken(
"row"+string(rune('a'+i)),
token.TypeWebbug,
token.ChannelWebhook,
))
}
h := admin.NewHandler(
repo,
&fakeEventRepo{},
fakeURLBuilder{},
quietLogger(),
)
r := newRouter(h)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(
http.MethodGet,
"/tokens?limit=2",
nil,
))
require.Equal(t, http.StatusOK, w.Code)
var body struct {
Data admin.TokenListResponse `json:"data"`
}
require.NoError(t, json.NewDecoder(w.Body).Decode(&body))
require.Len(t, body.Data.Tokens, 2)
require.Equal(t, int64(5), body.Data.Total)
require.True(t, body.Data.Page.HasMore)
require.Equal(t, 2, body.Data.Page.NextOffset)
}
func TestAdmin_ListTokens_OffsetPagesThrough(t *testing.T) {
repo := newFakeTokenRepo()
for i := range 5 {
repo.tokens = append(repo.tokens, seedToken(
"pg"+string(rune('a'+i)),
token.TypeWebbug,
token.ChannelWebhook,
))
}
h := admin.NewHandler(
repo,
&fakeEventRepo{},
fakeURLBuilder{},
quietLogger(),
)
r := newRouter(h)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(
http.MethodGet,
"/tokens?limit=2&offset=4",
nil,
))
require.Equal(t, http.StatusOK, w.Code)
var body struct {
Data admin.TokenListResponse `json:"data"`
}
require.NoError(t, json.NewDecoder(w.Body).Decode(&body))
require.Len(t, body.Data.Tokens, 1)
require.False(t, body.Data.Page.HasMore)
require.Equal(t, 5, body.Data.Page.NextOffset)
}
func TestAdmin_ListTokens_InvalidOffset_400(t *testing.T) {
repo := newFakeTokenRepo()
h := admin.NewHandler(
repo,
&fakeEventRepo{},
fakeURLBuilder{},
quietLogger(),
)
r := newRouter(h)
for _, badOffset := range []string{"-1", "abc", "1.5"} {
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(
http.MethodGet, "/tokens?offset="+badOffset, nil,
))
require.Equal(t, http.StatusBadRequest, w.Code, "offset=%s", badOffset)
require.Contains(t, w.Body.String(), `"BAD_PARAM"`)
}
}
func TestAdmin_ListTokens_LimitCappedAt100(t *testing.T) {
repo := newFakeTokenRepo()
for i := range 150 {
repo.tokens = append(repo.tokens, seedToken(
"lim"+strconv.Itoa(i),
token.TypeWebbug,
token.ChannelWebhook,
))
}
h := admin.NewHandler(
repo,
&fakeEventRepo{},
fakeURLBuilder{},
quietLogger(),
)
r := newRouter(h)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(
http.MethodGet,
"/tokens?limit=500",
nil,
))
require.Equal(t, http.StatusOK, w.Code)
var body struct {
Data admin.TokenListResponse `json:"data"`
}
require.NoError(t, json.NewDecoder(w.Body).Decode(&body))
require.Len(t, body.Data.Tokens, 100,
"limit=500 must be capped to maxPageSize=100")
}
func TestAdmin_ListTokens_RepoError_500(t *testing.T) {
repo := newFakeTokenRepo()
repo.listErr = errors.New("db down")
h := admin.NewHandler(
repo,
&fakeEventRepo{},
fakeURLBuilder{},
quietLogger(),
)
r := newRouter(h)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/tokens", nil))
require.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestAdmin_DisableToken_HappyPath(t *testing.T) {
repo := newFakeTokenRepo()
repo.tokens = []token.Token{
seedToken("disok0001a", token.TypeWebbug, token.ChannelWebhook),
}
h := admin.NewHandler(
repo,
&fakeEventRepo{},
fakeURLBuilder{},
quietLogger(),
)
r := newRouter(h)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(
http.MethodPost,
"/tokens/disok0001a/disable",
nil,
))
require.Equal(t, http.StatusNoContent, w.Code)
require.False(t, repo.tokens[0].Enabled)
require.Equal(t, []string{"disok0001a"}, repo.disabledCalls)
}
func TestAdmin_DisableToken_NotFound_404(t *testing.T) {
repo := newFakeTokenRepo()
h := admin.NewHandler(
repo,
&fakeEventRepo{},
fakeURLBuilder{},
quietLogger(),
)
r := newRouter(h)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(
http.MethodPost,
"/tokens/missing/disable",
nil,
))
require.Equal(t, http.StatusNotFound, w.Code)
require.Contains(t, w.Body.String(), `"NOT_FOUND"`)
}
func TestAdmin_DisableToken_RepoError_500(t *testing.T) {
repo := newFakeTokenRepo()
repo.tokens = []token.Token{
seedToken("repoerr01a", token.TypeWebbug, token.ChannelWebhook),
}
repo.setEnabledErr = errors.New("db error")
h := admin.NewHandler(
repo,
&fakeEventRepo{},
fakeURLBuilder{},
quietLogger(),
)
r := newRouter(h)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(
http.MethodPost,
"/tokens/repoerr01a/disable",
nil,
))
require.Equal(t, http.StatusInternalServerError, w.Code)
require.Contains(t, w.Body.String(), `"INTERNAL_ERROR"`)
}
func TestAdmin_DisableToken_GetIsNotRouted(t *testing.T) {
repo := newFakeTokenRepo()
repo.tokens = []token.Token{
seedToken("methodtest1", token.TypeWebbug, token.ChannelWebhook),
}
h := admin.NewHandler(
repo,
&fakeEventRepo{},
fakeURLBuilder{},
quietLogger(),
)
r := newRouter(h)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(
http.MethodGet,
"/tokens/methodtest1/disable",
nil,
))
require.Equal(t, http.StatusMethodNotAllowed, w.Code)
}

View File

@ -0,0 +1,214 @@
// ©AngelaMos | 2026
// integration_test.go
//go:build integration
package admin_test
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/admin"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/testutil"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
)
const (
intgBaseURL = "https://canary.example.com"
intgManageURL = "https://canary.example.com"
intgOperatorToken = "test-operator-token-CONSTANT-COMPARE-OK"
)
type intgURLBuilder struct{}
func (intgURLBuilder) TriggerURL(id string) string {
return intgBaseURL + "/c/" + id
}
func (intgURLBuilder) ManageURL(manageID string) string {
return intgManageURL + "/m/" + manageID
}
type intgStack struct {
router chi.Router
tokenRepo *token.Repository
eventRepo *event.Repository
}
func setupIntgStack(t *testing.T) *intgStack {
t.Helper()
db := sqlx.NewDb(testutil.NewTestDB(t), "pgx")
tokenRepo := token.NewRepository(db)
eventRepo := event.NewRepository(db)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
adminH := admin.NewHandler(tokenRepo, eventRepo, intgURLBuilder{}, logger)
r := chi.NewRouter()
r.Route("/api", func(api chi.Router) {
api.Route("/admin", func(adm chi.Router) {
adm.Use(middleware.OperatorBearer(intgOperatorToken))
adminH.RegisterRoutes(adm)
})
})
return &intgStack{router: r, tokenRepo: tokenRepo, eventRepo: eventRepo}
}
func seedIntgToken(
t *testing.T,
repo *token.Repository,
id string,
typ token.Type,
ch token.AlertChannel,
) *token.Token {
t.Helper()
tok := &token.Token{
ID: id,
ManageID: uuid.New().String(),
Type: typ,
Memo: "admin-intg",
AlertChannel: ch,
CreatedIP: "203.0.113.42",
CreatedFP: "abcdef0123456789",
Metadata: json.RawMessage(`{}`),
Enabled: true,
}
switch ch {
case token.ChannelTelegram:
tok.TelegramBot = testutil.Ptr("111:AAA")
tok.TelegramChat = testutil.Ptr("12345")
case token.ChannelWebhook:
tok.WebhookURL = testutil.Ptr("https://example.com/hook")
}
require.NoError(t, repo.Insert(context.Background(), tok))
return tok
}
func authedGet(t *testing.T, r chi.Router, path, tok string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
if tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
func authedPost(t *testing.T, r chi.Router, path, tok string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, nil)
if tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
func TestIntegration_AdminBearer_404OnMissing(t *testing.T) {
st := setupIntgStack(t)
w := authedGet(t, st.router, "/api/admin/stats", "")
require.Equal(t, http.StatusNotFound, w.Code,
"missing Authorization must return 404, not 401")
require.Empty(t, w.Header().Get("WWW-Authenticate"))
}
func TestIntegration_AdminBearer_404OnWrongToken(t *testing.T) {
st := setupIntgStack(t)
w := authedGet(t, st.router, "/api/admin/stats", "wrong-token")
require.Equal(t, http.StatusNotFound, w.Code)
}
func TestIntegration_AdminStats_Authorized(t *testing.T) {
st := setupIntgStack(t)
ctx := context.Background()
tok := seedIntgToken(t, st.tokenRepo, "adminstats01", token.TypeWebbug, token.ChannelTelegram)
seedIntgToken(t, st.tokenRepo, "adminstats02", token.TypeDocx, token.ChannelWebhook)
require.NoError(t, st.eventRepo.Insert(ctx, &event.Event{
TokenID: tok.ID, SourceIP: "203.0.113.50",
}))
w := authedGet(t, st.router, "/api/admin/stats", intgOperatorToken)
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
var body struct {
Success bool `json:"success"`
Data admin.Stats `json:"data"`
}
require.NoError(t, json.NewDecoder(w.Body).Decode(&body))
require.True(t, body.Success)
require.GreaterOrEqual(t, body.Data.TokensCount, int64(2))
require.GreaterOrEqual(t, body.Data.EventsCount, int64(1))
require.NotEmpty(t, body.Data.ByType)
require.NotEmpty(t, body.Data.ByAlertChannel)
}
func TestIntegration_AdminListTokens_Authorized(t *testing.T) {
st := setupIntgStack(t)
for i := range 4 {
seedIntgToken(t, st.tokenRepo,
"adminlist0"+string(rune('a'+i)),
token.TypeWebbug, token.ChannelWebhook)
}
w := authedGet(t, st.router, "/api/admin/tokens?limit=2", intgOperatorToken)
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
var body struct {
Data admin.TokenListResponse `json:"data"`
}
require.NoError(t, json.NewDecoder(w.Body).Decode(&body))
require.Len(t, body.Data.Tokens, 2)
require.GreaterOrEqual(t, body.Data.Total, int64(4))
require.True(t, body.Data.Page.HasMore)
require.Equal(t, 2, body.Data.Page.NextOffset)
require.Contains(t, body.Data.Tokens[0].TriggerURL, "/c/")
require.Contains(t, body.Data.Tokens[0].ManageURL, "/m/")
}
func TestIntegration_AdminDisableToken_Authorized(t *testing.T) {
st := setupIntgStack(t)
ctx := context.Background()
tok := seedIntgToken(t, st.tokenRepo, "admindis001a", token.TypeWebbug, token.ChannelWebhook)
require.True(t, tok.Enabled)
w := authedPost(t, st.router,
"/api/admin/tokens/"+tok.ID+"/disable",
intgOperatorToken)
require.Equal(t, http.StatusNoContent, w.Code)
got, err := st.tokenRepo.GetByID(ctx, tok.ID)
require.NoError(t, err)
require.False(t, got.Enabled, "disable must persist to DB")
}
func TestIntegration_AdminDisableToken_NotFound(t *testing.T) {
st := setupIntgStack(t)
w := authedPost(t, st.router,
"/api/admin/tokens/admindismiss/disable",
intgOperatorToken)
require.Equal(t, http.StatusNotFound, w.Code)
require.Contains(t, w.Body.String(), `"NOT_FOUND"`)
}

View File

@ -0,0 +1,395 @@
// ©AngelaMos | 2026
// config.go
package config
import (
"fmt"
"sync"
"time"
"github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/env"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2"
)
const defaultCanaryBaseURL = "http://localhost:8080"
type Config struct {
App AppConfig `koanf:"app"`
Server ServerConfig `koanf:"server"`
Database DatabaseConfig `koanf:"database"`
Redis RedisConfig `koanf:"redis"`
RateLimit RateLimitConfig `koanf:"rate_limit"`
CORS CORSConfig `koanf:"cors"`
Log LogConfig `koanf:"log"`
Otel OtelConfig `koanf:"otel"`
Canary CanaryConfig `koanf:"canary"`
Turnstile TurnstileConfig `koanf:"turnstile"`
MySQL MySQLConfig `koanf:"mysql"`
Notify NotifyConfig `koanf:"notify"`
Operator OperatorConfig `koanf:"operator"`
GeoIP GeoIPConfig `koanf:"geoip"`
}
type OperatorConfig struct {
Token string `koanf:"token"`
}
type GeoIPConfig struct {
Path string `koanf:"path"`
}
type NotifyConfig struct {
DedupTTL time.Duration `koanf:"dedup_ttl"`
SendTimeout time.Duration `koanf:"send_timeout"`
MaxTries uint `koanf:"max_tries"`
MaxElapsed time.Duration `koanf:"max_elapsed"`
InitialInterval time.Duration `koanf:"initial_interval"`
RetentionInterval time.Duration `koanf:"retention_interval"`
RetentionLimit int `koanf:"retention_limit"`
WebhookHMACSecret string `koanf:"webhook_hmac_secret"`
TelegramAPIBase string `koanf:"telegram_api_base"`
FingerprintWindow time.Duration `koanf:"fingerprint_window"`
}
type CanaryConfig struct {
BaseURL string `koanf:"base_url"`
ManageURL string `koanf:"manage_url"`
}
type TurnstileConfig struct {
SecretKey string `koanf:"secret_key"`
SiteKey string `koanf:"site_key"`
}
type MySQLConfig struct {
Enabled bool `koanf:"enabled"`
Addr string `koanf:"addr"`
PublicHost string `koanf:"public_host"`
PublicPort int `koanf:"public_port"`
}
type AppConfig struct {
Name string `koanf:"name"`
Version string `koanf:"version"`
Environment string `koanf:"environment"`
}
type ServerConfig struct {
Host string `koanf:"host"`
Port int `koanf:"port"`
ReadTimeout time.Duration `koanf:"read_timeout"`
WriteTimeout time.Duration `koanf:"write_timeout"`
IdleTimeout time.Duration `koanf:"idle_timeout"`
ShutdownTimeout time.Duration `koanf:"shutdown_timeout"`
TrustedProxyCIDRs []string `koanf:"trusted_proxy_cidrs"`
}
type DatabaseConfig struct {
URL string `koanf:"url"`
MaxOpenConns int `koanf:"max_open_conns"`
MaxIdleConns int `koanf:"max_idle_conns"`
ConnMaxLifetime time.Duration `koanf:"conn_max_lifetime"`
ConnMaxIdleTime time.Duration `koanf:"conn_max_idle_time"`
}
type RedisConfig struct {
URL string `koanf:"url"`
PoolSize int `koanf:"pool_size"`
MinIdleConns int `koanf:"min_idle_conns"`
}
type RateLimitConfig struct {
Requests int `koanf:"requests"`
Window time.Duration `koanf:"window"`
Burst int `koanf:"burst"`
CreateMinRate int `koanf:"create_min_rate"`
CreateMinBurst int `koanf:"create_min_burst"`
CreateHourRate int `koanf:"create_hour_rate"`
CreateHourBurst int `koanf:"create_hour_burst"`
}
type CORSConfig struct {
AllowedOrigins []string `koanf:"allowed_origins"`
AllowedMethods []string `koanf:"allowed_methods"`
AllowedHeaders []string `koanf:"allowed_headers"`
AllowCredentials bool `koanf:"allow_credentials"`
MaxAge int `koanf:"max_age"`
}
type LogConfig struct {
Level string `koanf:"level"`
Format string `koanf:"format"`
}
type OtelConfig struct {
Endpoint string `koanf:"endpoint"`
ServiceName string `koanf:"service_name"`
Enabled bool `koanf:"enabled"`
Insecure bool `koanf:"insecure"`
SampleRate float64 `koanf:"sample_rate"`
}
var (
cfg *Config
once sync.Once
)
func Load(configPath string) (*Config, error) {
var loadErr error
once.Do(func() {
k := koanf.New(".")
if err := loadDefaults(k); err != nil {
loadErr = fmt.Errorf("load defaults: %w", err)
return
}
if configPath != "" {
if err := k.Load(
file.Provider(configPath),
yaml.Parser(),
); err != nil {
loadErr = fmt.Errorf("load config file: %w", err)
return
}
}
if err := k.Load(
env.Provider("", ".", envKeyReplacer),
nil,
); err != nil {
loadErr = fmt.Errorf("load env vars: %w", err)
return
}
cfg = &Config{}
if err := k.Unmarshal("", cfg); err != nil {
loadErr = fmt.Errorf("unmarshal config: %w", err)
return
}
if cfg.Canary.ManageURL == "" ||
cfg.Canary.ManageURL == defaultCanaryBaseURL {
cfg.Canary.ManageURL = cfg.Canary.BaseURL
}
if err := validate(cfg); err != nil {
loadErr = fmt.Errorf("validate config: %w", err)
return
}
})
if loadErr != nil {
return nil, loadErr
}
return cfg, nil
}
func Get() *Config {
if cfg == nil {
panic("config not loaded: call Load() first")
}
return cfg
}
func loadDefaults(k *koanf.Koanf) error {
defaults := map[string]any{
"app.name": "Canary Token Generator",
"app.version": "1.0.0",
"app.environment": "development",
"server.host": "0.0.0.0",
"server.port": 8080,
"server.read_timeout": "30s",
"server.write_timeout": "30s",
"server.idle_timeout": "120s",
"server.shutdown_timeout": "15s",
"server.trusted_proxy_cidrs": []string{
"127.0.0.1/32",
"::1/128",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
},
"database.max_open_conns": 25,
"database.max_idle_conns": 5,
"database.conn_max_lifetime": "1h",
"database.conn_max_idle_time": "30m",
"redis.pool_size": 10,
"redis.min_idle_conns": 5,
"rate_limit.requests": 100,
"rate_limit.window": "1m",
"rate_limit.burst": 20,
"rate_limit.create_min_rate": 5,
"rate_limit.create_min_burst": 5,
"rate_limit.create_hour_rate": 20,
"rate_limit.create_hour_burst": 5,
"cors.allowed_origins": []string{"http://localhost:3000"},
"cors.allowed_methods": []string{
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS",
},
"cors.allowed_headers": []string{
"Accept",
"Authorization",
"Content-Type",
"X-Request-ID",
},
"cors.allow_credentials": true,
"cors.max_age": 300,
"log.level": "info",
"log.format": "json",
"otel.enabled": false,
"otel.insecure": true,
"otel.sample_rate": 0.1,
"otel.service_name": "canary-token-generator",
"canary.base_url": defaultCanaryBaseURL,
"canary.manage_url": defaultCanaryBaseURL,
"turnstile.secret_key": "",
"turnstile.site_key": "",
"mysql.enabled": false,
"mysql.addr": "0.0.0.0:3306",
"mysql.public_host": "localhost",
"mysql.public_port": 3306,
"notify.dedup_ttl": "15m",
"notify.send_timeout": "30s",
"notify.max_tries": 3,
"notify.max_elapsed": "30s",
"notify.initial_interval": "500ms",
"notify.retention_interval": "1h",
"notify.retention_limit": 100,
"notify.webhook_hmac_secret": "",
"notify.telegram_api_base": "https://api.telegram.org",
"notify.fingerprint_window": "30s",
"operator.token": "",
"geoip.path": "/data/GeoLite2-City.mmdb",
}
for key, value := range defaults {
if err := k.Set(key, value); err != nil {
return fmt.Errorf("set default %s: %w", key, err)
}
}
return nil
}
var envKeyMap = map[string]string{
"DATABASE_URL": "database.url",
"REDIS_URL": "redis.url",
"APP_ENVIRONMENT": "app.environment",
"HOST": "server.host",
"PORT": "server.port",
"LOG_LEVEL": "log.level",
"LOG_FORMAT": "log.format",
"RATE_LIMIT_REQUESTS": "rate_limit.requests",
"RATE_LIMIT_WINDOW": "rate_limit.window",
"RATE_LIMIT_BURST": "rate_limit.burst",
"OTEL_ENDPOINT": "otel.endpoint",
"OTEL_EXPORTER_OTLP_ENDPOINT": "otel.endpoint",
"OTEL_SERVICE_NAME": "otel.service_name",
"OTEL_ENABLED": "otel.enabled",
"OTEL_INSECURE": "otel.insecure",
"OTEL_SAMPLE_RATE": "otel.sample_rate",
"CANARY_BASE_URL": "canary.base_url",
"PUBLIC_BASE_URL": "canary.base_url",
"CANARY_MANAGE_URL": "canary.manage_url",
"TURNSTILE_SECRET_KEY": "turnstile.secret_key",
"TURNSTILE_SITE_KEY": "turnstile.site_key",
"MYSQL_ENABLED": "mysql.enabled",
"MYSQL_ADDR": "mysql.addr",
"MYSQL_PUBLIC_HOST": "mysql.public_host",
"MYSQL_PUBLIC_PORT": "mysql.public_port",
"RATE_LIMIT_CREATE_MIN_RATE": "rate_limit.create_min_rate",
"RATE_LIMIT_CREATE_MIN_BURST": "rate_limit.create_min_burst",
"RATE_LIMIT_CREATE_HOUR_RATE": "rate_limit.create_hour_rate",
"RATE_LIMIT_CREATE_HOUR_BURST": "rate_limit.create_hour_burst",
"NOTIFY_DEDUP_TTL": "notify.dedup_ttl",
"NOTIFY_SEND_TIMEOUT": "notify.send_timeout",
"NOTIFY_MAX_TRIES": "notify.max_tries",
"NOTIFY_MAX_ELAPSED": "notify.max_elapsed",
"NOTIFY_INITIAL_INTERVAL": "notify.initial_interval",
"NOTIFY_RETENTION_INTERVAL": "notify.retention_interval",
"NOTIFY_RETENTION_LIMIT": "notify.retention_limit",
"WEBHOOK_HMAC_SECRET": "notify.webhook_hmac_secret",
"NOTIFY_TELEGRAM_API_BASE": "notify.telegram_api_base",
"NOTIFY_FINGERPRINT_WINDOW": "notify.fingerprint_window",
"OPERATOR_TOKEN": "operator.token",
"GEOLITE_PATH": "geoip.path",
}
func envKeyReplacer(s string) string {
if mapped, ok := envKeyMap[s]; ok {
return mapped
}
return ""
}
func validate(c *Config) error {
if c.Database.URL == "" {
return fmt.Errorf("DATABASE_URL is required")
}
if c.Redis.URL == "" {
return fmt.Errorf("REDIS_URL is required")
}
if c.CORS.AllowCredentials {
for _, origin := range c.CORS.AllowedOrigins {
if origin == "*" {
return fmt.Errorf(
"CORS wildcard '*' cannot be used with AllowCredentials",
)
}
}
}
if c.App.Environment == "production" {
if c.Otel.Enabled && c.Otel.Insecure {
return fmt.Errorf("OTEL_INSECURE must be false in production")
}
}
if c.Server.ReadTimeout <= 0 {
return fmt.Errorf("server.read_timeout must be positive")
}
if c.Server.WriteTimeout <= 0 {
return fmt.Errorf("server.write_timeout must be positive")
}
return nil
}
func (c *Config) IsProduction() bool {
return c.App.Environment == "production"
}
func (c *Config) IsDevelopment() bool {
return c.App.Environment == "development"
}
func (s *ServerConfig) Address() string {
return fmt.Sprintf("%s:%d", s.Host, s.Port)
}

View File

@ -0,0 +1,173 @@
// ©AngelaMos | 2026
// database.go
package core
import (
"context"
crand "crypto/rand"
"database/sql"
"fmt"
"log/slog"
"math/big"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/jmoiron/sqlx"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/config"
)
type Database struct {
DB *sqlx.DB
}
func (d *Database) SQLDB() *sql.DB {
return d.DB.DB
}
func NewDatabase(
ctx context.Context,
cfg config.DatabaseConfig,
) (*Database, error) {
db, err := sqlx.ConnectContext(ctx, "pgx", cfg.URL)
if err != nil {
return nil, fmt.Errorf("connect to database: %w", err)
}
db.SetMaxOpenConns(cfg.MaxOpenConns)
db.SetMaxIdleConns(cfg.MaxIdleConns)
db.SetConnMaxLifetime(jitteredDuration(cfg.ConnMaxLifetime))
db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime)
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if pingErr := db.PingContext(pingCtx); pingErr != nil {
if closeErr := db.Close(); closeErr != nil {
return nil, fmt.Errorf(
"ping database: %w (close also failed: %w)",
pingErr,
closeErr,
)
}
return nil, fmt.Errorf("ping database: %w", pingErr)
}
return &Database{DB: db}, nil
}
func (d *Database) Close() error {
if d.DB != nil {
return d.DB.Close()
}
return nil
}
func (d *Database) Ping(ctx context.Context) error {
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := d.DB.PingContext(pingCtx); err != nil {
return fmt.Errorf("database ping failed: %w", err)
}
return nil
}
func (d *Database) Stats() sql.DBStats {
return d.DB.Stats()
}
type DBTX interface {
sqlx.ExtContext
sqlx.ExecerContext
GetContext(ctx context.Context, dest any, query string, args ...any) error
SelectContext(
ctx context.Context,
dest any,
query string,
args ...any,
) error
}
func InTx(ctx context.Context, db *sqlx.DB, fn func(tx *sqlx.Tx) error) error {
tx, err := db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer func() {
if p := recover(); p != nil {
if rbErr := tx.Rollback(); rbErr != nil {
slog.Error(
"rollback failed during panic recovery",
"error", rbErr,
)
}
panic(p)
}
}()
if err := fn(tx); err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
return fmt.Errorf("rollback failed: %w (original: %w)", rbErr, err)
}
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}
func InTxWithOptions(
ctx context.Context,
db *sqlx.DB,
opts *sql.TxOptions,
fn func(tx *sqlx.Tx) error,
) error {
tx, err := db.BeginTxx(ctx, opts)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer func() {
if p := recover(); p != nil {
if rbErr := tx.Rollback(); rbErr != nil {
slog.Error(
"rollback failed during panic recovery",
"error", rbErr,
)
}
panic(p)
}
}()
if err := fn(tx); err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
return fmt.Errorf("rollback failed: %w (original: %w)", rbErr, err)
}
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}
func jitteredDuration(base time.Duration) time.Duration {
maxJitter := int64(base / 7)
if maxJitter <= 0 {
return base
}
jitter, err := crand.Int(crand.Reader, big.NewInt(maxJitter))
if err != nil {
return base
}
return base + time.Duration(jitter.Int64())
}

View File

@ -0,0 +1,113 @@
// ©AngelaMos | 2026
// errors.go
package core
import (
"errors"
"fmt"
"net/http"
)
var (
ErrNotFound = errors.New("resource not found")
ErrDuplicateKey = errors.New("duplicate key violation")
ErrForeignKey = errors.New("foreign key violation")
ErrInvalidInput = errors.New("invalid input")
ErrInternal = errors.New("internal server error")
ErrConflict = errors.New("resource conflict")
ErrRateLimited = errors.New("rate limit exceeded")
)
type AppError struct {
Err error `json:"-"`
Message string `json:"message"`
StatusCode int `json:"-"`
Code string `json:"code"`
}
func (e *AppError) Error() string {
if e.Message != "" {
return e.Message
}
if e.Err != nil {
return e.Err.Error()
}
return "unknown error"
}
func (e *AppError) Unwrap() error {
return e.Err
}
func NewAppError(
err error,
message string,
statusCode int,
code string,
) *AppError {
return &AppError{
Err: err,
Message: message,
StatusCode: statusCode,
Code: code,
}
}
func NotFoundError(resource string) *AppError {
return &AppError{
Err: ErrNotFound,
Message: fmt.Sprintf("%s not found", resource),
StatusCode: http.StatusNotFound,
Code: "NOT_FOUND",
}
}
func DuplicateError(field string) *AppError {
return &AppError{
Err: ErrDuplicateKey,
Message: fmt.Sprintf("%s already exists", field),
StatusCode: http.StatusConflict,
Code: "DUPLICATE",
}
}
func ValidationError(message string) *AppError {
return &AppError{
Err: ErrInvalidInput,
Message: message,
StatusCode: http.StatusBadRequest,
Code: "VALIDATION_ERROR",
}
}
func InternalError(err error) *AppError {
return &AppError{
Err: err,
Message: "internal server error",
StatusCode: http.StatusInternalServerError,
Code: "INTERNAL_ERROR",
}
}
func RateLimitError() *AppError {
return &AppError{
Err: ErrRateLimited,
Message: "too many requests",
StatusCode: http.StatusTooManyRequests,
Code: "RATE_LIMITED",
}
}
func IsAppError(err error) bool {
var appErr *AppError
return errors.As(err, &appErr)
}
func GetAppError(err error) *AppError {
var appErr *AppError
if errors.As(err, &appErr) {
return appErr
}
return InternalError(err)
}

View File

@ -0,0 +1,33 @@
// ©AngelaMos | 2026
// migrations.go
package core
import (
"database/sql"
"embed"
"fmt"
"sync"
"github.com/pressly/goose/v3"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
var gooseMu sync.Mutex
func RunMigrations(db *sql.DB) error {
gooseMu.Lock()
defer gooseMu.Unlock()
goose.SetBaseFS(migrationsFS)
if err := goose.SetDialect("postgres"); err != nil {
return fmt.Errorf("goose set dialect: %w", err)
}
if err := goose.Up(db, "migrations"); err != nil {
return fmt.Errorf("goose up: %w", err)
}
return nil
}

View File

@ -0,0 +1,42 @@
-- ©AngelaMos | 2026
-- 0001_create_tokens.sql
-- +goose Up
-- +goose StatementBegin
CREATE TABLE tokens (
id VARCHAR(12) PRIMARY KEY,
manage_id UUID UNIQUE NOT NULL,
type VARCHAR(32) NOT NULL,
memo TEXT NOT NULL DEFAULT '',
filename TEXT,
alert_channel VARCHAR(16) NOT NULL,
telegram_bot TEXT,
telegram_chat TEXT,
webhook_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_ip INET NOT NULL,
created_fp CHAR(16) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
trigger_count BIGINT NOT NULL DEFAULT 0,
last_triggered TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
CONSTRAINT chk_type CHECK (type IN
('webbug', 'slowredirect', 'docx', 'pdf', 'kubeconfig', 'envfile', 'mysql')),
CONSTRAINT chk_channel CHECK (alert_channel IN ('telegram', 'webhook')),
CONSTRAINT chk_telegram_complete CHECK (
alert_channel <> 'telegram' OR
(telegram_bot IS NOT NULL AND telegram_chat IS NOT NULL)
),
CONSTRAINT chk_webhook_complete CHECK (
alert_channel <> 'webhook' OR webhook_url IS NOT NULL
)
);
-- +goose StatementEnd
-- +goose Down
DROP TABLE IF EXISTS tokens;

View File

@ -0,0 +1,33 @@
-- ©AngelaMos | 2026
-- 0002_create_events.sql
-- +goose Up
-- +goose StatementBegin
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
token_id VARCHAR(12) NOT NULL REFERENCES tokens(id) ON DELETE CASCADE,
triggered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source_ip INET NOT NULL,
user_agent TEXT,
referer TEXT,
geo_country CHAR(2),
geo_region VARCHAR(64),
geo_city VARCHAR(64),
geo_asn INT,
geo_asn_org VARCHAR(128),
extra JSONB NOT NULL DEFAULT '{}'::jsonb,
notify_status VARCHAR(16) NOT NULL DEFAULT 'pending',
notified_at TIMESTAMPTZ,
CONSTRAINT chk_notify_status CHECK (
notify_status IN ('pending', 'sent', 'failed', 'deduped')
)
);
-- +goose StatementEnd
-- +goose Down
DROP TABLE IF EXISTS events;

View File

@ -0,0 +1,29 @@
-- ©AngelaMos | 2026
-- 0003_indexes.sql
-- +goose Up
-- +goose StatementBegin
CREATE INDEX idx_tokens_created_ip ON tokens(created_ip);
CREATE INDEX idx_tokens_created_fp ON tokens(created_fp);
CREATE INDEX idx_tokens_created_at ON tokens(created_at DESC);
CREATE INDEX idx_tokens_type ON tokens(type);
CREATE INDEX idx_tokens_trigger_count
ON tokens(trigger_count DESC) WHERE trigger_count > 0;
CREATE INDEX idx_events_token_triggered ON events(token_id, triggered_at DESC);
CREATE INDEX idx_events_source_ip ON events(source_ip);
CREATE INDEX idx_events_notify_pending
ON events(notify_status) WHERE notify_status = 'pending';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS idx_events_notify_pending;
DROP INDEX IF EXISTS idx_events_source_ip;
DROP INDEX IF EXISTS idx_events_token_triggered;
DROP INDEX IF EXISTS idx_tokens_trigger_count;
DROP INDEX IF EXISTS idx_tokens_type;
DROP INDEX IF EXISTS idx_tokens_created_at;
DROP INDEX IF EXISTS idx_tokens_created_fp;
DROP INDEX IF EXISTS idx_tokens_created_ip;
-- +goose StatementEnd

View File

@ -0,0 +1,63 @@
// ©AngelaMos | 2026
// redis.go
package core
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/config"
)
type Redis struct {
Client *redis.Client
}
func NewRedis(ctx context.Context, cfg config.RedisConfig) (*Redis, error) {
opts, err := redis.ParseURL(cfg.URL)
if err != nil {
return nil, fmt.Errorf("parse redis url: %w", err)
}
opts.PoolSize = cfg.PoolSize
opts.MinIdleConns = cfg.MinIdleConns
opts.PoolTimeout = 30 * time.Second
opts.ConnMaxIdleTime = 5 * time.Minute
client := redis.NewClient(opts)
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := client.Ping(pingCtx).Err(); err != nil {
return nil, fmt.Errorf("ping redis: %w", err)
}
return &Redis{Client: client}, nil
}
func (r *Redis) Close() error {
if r.Client != nil {
return r.Client.Close()
}
return nil
}
func (r *Redis) Ping(ctx context.Context) error {
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := r.Client.Ping(pingCtx).Err(); err != nil {
return fmt.Errorf("redis ping failed: %w", err)
}
return nil
}
func (r *Redis) PoolStats() *redis.PoolStats {
return r.Client.PoolStats()
}

View File

@ -0,0 +1,112 @@
// ©AngelaMos | 2026
// response.go
package core
import (
"encoding/json"
"log/slog"
"net/http"
)
type Response struct {
Success bool `json:"success"`
Data any `json:"data,omitempty"`
Error *Error `json:"error,omitempty"`
Meta *Meta `json:"meta,omitempty"`
}
type Error struct {
Code string `json:"code"`
Message string `json:"message"`
}
type Meta struct {
Page int `json:"page,omitempty"`
PageSize int `json:"page_size,omitempty"`
Total int `json:"total,omitempty"`
TotalPages int `json:"total_pages,omitempty"`
}
func JSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
response := Response{
Success: status >= 200 && status < 300,
Data: data,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode response", "error", err)
}
}
func JSONWithMeta(w http.ResponseWriter, status int, data any, meta *Meta) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
response := Response{
Success: true,
Data: data,
Meta: meta,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode response", "error", err)
}
}
func JSONError(w http.ResponseWriter, err error) {
appErr := GetAppError(err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(appErr.StatusCode)
response := Response{
Success: false,
Error: &Error{
Code: appErr.Code,
Message: appErr.Message,
},
}
if encErr := json.NewEncoder(w).Encode(response); encErr != nil {
slog.Error("failed to encode error response", "error", encErr)
}
}
func Created(w http.ResponseWriter, data any) {
JSON(w, http.StatusCreated, data)
}
func OK(w http.ResponseWriter, data any) {
JSON(w, http.StatusOK, data)
}
func NoContent(w http.ResponseWriter) {
w.WriteHeader(http.StatusNoContent)
}
func BadRequest(w http.ResponseWriter, message string) {
JSONError(w, ValidationError(message))
}
func NotFound(w http.ResponseWriter, resource string) {
JSONError(w, NotFoundError(resource))
}
func InternalServerError(w http.ResponseWriter, err error) {
slog.Error("internal server error", "error", err)
JSONError(w, InternalError(err))
}
func Paginated(w http.ResponseWriter, data any, page, pageSize, total int) {
totalPages := (total + pageSize - 1) / pageSize
JSONWithMeta(w, http.StatusOK, data, &Meta{
Page: page,
PageSize: pageSize,
Total: total,
TotalPages: totalPages,
})
}

View File

@ -0,0 +1,147 @@
// ©AngelaMos | 2026
// telemetry.go
package core
import (
"context"
"fmt"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
"go.opentelemetry.io/otel/trace"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/config"
)
type Telemetry struct {
TracerProvider *sdktrace.TracerProvider
Tracer trace.Tracer
}
func NewTelemetry(
ctx context.Context,
otelCfg config.OtelConfig,
appCfg config.AppConfig,
) (*Telemetry, error) {
if !otelCfg.Enabled || otelCfg.Endpoint == "" {
noopProvider := sdktrace.NewTracerProvider()
return &Telemetry{
TracerProvider: noopProvider,
Tracer: noopProvider.Tracer(otelCfg.ServiceName),
}, nil
}
opts := []otlptracegrpc.Option{
otlptracegrpc.WithEndpoint(otelCfg.Endpoint),
otlptracegrpc.WithTimeout(5 * time.Second),
}
if otelCfg.Insecure {
opts = append(
opts,
otlptracegrpc.WithTLSCredentials(insecure.NewCredentials()),
)
} else {
opts = append(
opts,
otlptracegrpc.WithTLSCredentials(
credentials.NewClientTLSFromCert(nil, ""),
),
)
}
exporter, err := otlptracegrpc.New(ctx, opts...)
if err != nil {
return nil, fmt.Errorf("create otlp exporter: %w", err)
}
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName(otelCfg.ServiceName),
semconv.ServiceVersion(appCfg.Version),
attribute.String("environment", appCfg.Environment),
),
resource.WithHost(),
resource.WithProcess(),
)
if err != nil {
return nil, fmt.Errorf("create resource: %w", err)
}
sampleRate := otelCfg.SampleRate
if sampleRate <= 0 || sampleRate > 1 {
sampleRate = 0.1
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter,
sdktrace.WithBatchTimeout(5*time.Second),
sdktrace.WithMaxExportBatchSize(512),
),
sdktrace.WithResource(res),
sdktrace.WithSampler(sdktrace.ParentBased(
sdktrace.TraceIDRatioBased(sampleRate),
)),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
return &Telemetry{
TracerProvider: tp,
Tracer: tp.Tracer(otelCfg.ServiceName),
}, nil
}
func (t *Telemetry) Shutdown(ctx context.Context) error {
if t.TracerProvider == nil {
return nil
}
shutdownCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := t.TracerProvider.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown tracer provider: %w", err)
}
return nil
}
func SpanFromContext(ctx context.Context) trace.Span {
return trace.SpanFromContext(ctx)
}
func TraceIDFromContext(ctx context.Context) string {
span := trace.SpanFromContext(ctx)
if span.SpanContext().IsValid() {
return span.SpanContext().TraceID().String()
}
return ""
}
func AddSpanEvent(
ctx context.Context,
name string,
attrs ...attribute.KeyValue,
) {
span := trace.SpanFromContext(ctx)
span.AddEvent(name, trace.WithAttributes(attrs...))
}
func SetSpanError(ctx context.Context, err error) {
span := trace.SpanFromContext(ctx)
span.RecordError(err)
}

View File

@ -0,0 +1,42 @@
// ©AngelaMos | 2026
// validation.go
package core
import (
"errors"
"strings"
"github.com/go-playground/validator/v10"
)
func FormatValidationError(err error) string {
var ve validator.ValidationErrors
if errors.As(err, &ve) {
messages := make([]string, 0, len(ve))
for _, fe := range ve {
messages = append(messages, FormatFieldError(fe))
}
return strings.Join(messages, "; ")
}
return "validation failed"
}
func FormatFieldError(fe validator.FieldError) string {
field := strings.ToLower(fe.Field())
switch fe.Tag() {
case "required":
return field + " is required"
case "email":
return field + " must be a valid email"
case "min":
return field + " must be at least " + fe.Param() + " characters"
case "max":
return field + " must be at most " + fe.Param() + " characters"
case "oneof":
return field + " must be one of: " + fe.Param()
default:
return field + " is invalid"
}
}

View File

@ -0,0 +1,39 @@
// ©AngelaMos | 2026
// contract.go
package event
import (
"context"
"time"
)
type NotifyInfo struct {
TokenID string
ManageID string
Type string
Memo string
AlertChannel string
TelegramBot string
TelegramChat string
WebhookURL string
}
type Notifier interface {
Notify(info NotifyInfo, evt *Event)
}
type TokenIncrementer interface {
IncrementTriggerCount(ctx context.Context, id string) error
}
type Store interface {
Insert(ctx context.Context, e *Event) error
UpdateNotifyStatus(
ctx context.Context,
eventID int64,
status NotifyStatus,
sentAt *time.Time,
) error
PruneToLimit(ctx context.Context, perTokenLimit int) (int64, error)
}

View File

@ -0,0 +1,49 @@
// ©AngelaMos | 2026
// dto.go
package event
import (
"encoding/json"
"time"
)
type GeoView struct {
Country *string `json:"country"`
Region *string `json:"region"`
City *string `json:"city"`
ASN *int `json:"asn"`
ASNOrg *string `json:"asn_org"`
}
type Response struct {
ID int64 `json:"id"`
TriggeredAt time.Time `json:"triggered_at"`
SourceIP string `json:"source_ip"`
UserAgent *string `json:"user_agent"`
Referer *string `json:"referer"`
Geo GeoView `json:"geo"`
Extra json.RawMessage `json:"extra"`
NotifyStatus NotifyStatus `json:"notify_status"`
NotifiedAt *time.Time `json:"notified_at"`
}
func (e *Event) ToResponse() Response {
return Response{
ID: e.ID,
TriggeredAt: e.TriggeredAt,
SourceIP: e.SourceIP,
UserAgent: e.UserAgent,
Referer: e.Referer,
Geo: GeoView{
Country: e.GeoCountry,
Region: e.GeoRegion,
City: e.GeoCity,
ASN: e.GeoASN,
ASNOrg: e.GeoASNOrg,
},
Extra: e.Extra,
NotifyStatus: e.NotifyStatus,
NotifiedAt: e.NotifiedAt,
}
}

View File

@ -0,0 +1,65 @@
// ©AngelaMos | 2026
// entity.go
package event
import (
"encoding/json"
"time"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/geoip"
)
type NotifyStatus string
const (
NotifyPending NotifyStatus = "pending"
NotifySent NotifyStatus = "sent"
NotifyFailed NotifyStatus = "failed"
NotifyDeduped NotifyStatus = "deduped"
)
func (s NotifyStatus) Valid() bool {
switch s {
case NotifyPending, NotifySent, NotifyFailed, NotifyDeduped:
return true
}
return false
}
type Event struct {
ID int64 `db:"id" json:"id"`
TokenID string `db:"token_id" json:"token_id"`
TriggeredAt time.Time `db:"triggered_at" json:"triggered_at"`
SourceIP string `db:"source_ip" json:"source_ip"`
UserAgent *string `db:"user_agent" json:"user_agent"`
Referer *string `db:"referer" json:"referer"`
GeoCountry *string `db:"geo_country" json:"geo_country"`
GeoRegion *string `db:"geo_region" json:"geo_region"`
GeoCity *string `db:"geo_city" json:"geo_city"`
GeoASN *int `db:"geo_asn" json:"geo_asn"`
GeoASNOrg *string `db:"geo_asn_org" json:"geo_asn_org"`
Extra json.RawMessage `db:"extra" json:"extra"`
NotifyStatus NotifyStatus `db:"notify_status" json:"notify_status"`
NotifiedAt *time.Time `db:"notified_at" json:"notified_at"`
}
func (e *Event) AttachGeoIP(l geoip.Lookup) {
e.GeoCountry = nonEmptyPtr(l.Country)
e.GeoRegion = nonEmptyPtr(l.Region)
e.GeoCity = nonEmptyPtr(l.City)
e.GeoASNOrg = nonEmptyPtr(l.ASNOrg)
if l.ASN > 0 {
asn := l.ASN
e.GeoASN = &asn
return
}
e.GeoASN = nil
}
func nonEmptyPtr(s string) *string {
if s == "" {
return nil
}
return &s
}

View File

@ -0,0 +1,115 @@
// ©AngelaMos | 2026
// entity_test.go
package event_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/geoip"
)
func TestEvent_AttachGeoIP_PopulatesAllFields(t *testing.T) {
t.Parallel()
e := &event.Event{}
e.AttachGeoIP(geoip.Lookup{
Country: "US",
Region: "California",
City: "Mountain View",
ASN: 15169,
ASNOrg: "Google LLC",
})
require.NotNil(t, e.GeoCountry)
require.Equal(t, "US", *e.GeoCountry)
require.NotNil(t, e.GeoRegion)
require.Equal(t, "California", *e.GeoRegion)
require.NotNil(t, e.GeoCity)
require.Equal(t, "Mountain View", *e.GeoCity)
require.NotNil(t, e.GeoASN)
require.Equal(t, 15169, *e.GeoASN)
require.NotNil(t, e.GeoASNOrg)
require.Equal(t, "Google LLC", *e.GeoASNOrg)
}
func TestEvent_AttachGeoIP_EmptyLookupLeavesAllNil(t *testing.T) {
t.Parallel()
e := &event.Event{}
e.AttachGeoIP(geoip.Lookup{})
require.Nil(t, e.GeoCountry)
require.Nil(t, e.GeoRegion)
require.Nil(t, e.GeoCity)
require.Nil(t, e.GeoASN)
require.Nil(t, e.GeoASNOrg)
}
func TestEvent_AttachGeoIP_PartialFieldsPopulated(t *testing.T) {
t.Parallel()
e := &event.Event{}
e.AttachGeoIP(geoip.Lookup{Country: "JP", City: "Tokyo"})
require.NotNil(t, e.GeoCountry)
require.Equal(t, "JP", *e.GeoCountry)
require.Nil(t, e.GeoRegion)
require.NotNil(t, e.GeoCity)
require.Equal(t, "Tokyo", *e.GeoCity)
require.Nil(t, e.GeoASN)
require.Nil(t, e.GeoASNOrg)
}
func TestEvent_AttachGeoIP_ZeroASNStaysNil(t *testing.T) {
t.Parallel()
e := &event.Event{}
e.AttachGeoIP(geoip.Lookup{Country: "FR", ASN: 0, ASNOrg: ""})
require.Nil(t, e.GeoASN,
"ASN=0 (sentinel for missing) must not produce a pointer")
}
func TestEvent_AttachGeoIP_NegativeASNStaysNil(t *testing.T) {
t.Parallel()
e := &event.Event{}
e.AttachGeoIP(geoip.Lookup{ASN: -1})
require.Nil(t, e.GeoASN,
"defensive: negative ASN is invalid and must not produce a pointer")
}
func TestEvent_AttachGeoIP_OverwritesPriorValues(t *testing.T) {
t.Parallel()
prior := "OLD"
priorASN := 99
e := &event.Event{
GeoCountry: &prior,
GeoRegion: &prior,
GeoCity: &prior,
GeoASNOrg: &prior,
GeoASN: &priorASN,
}
e.AttachGeoIP(geoip.Lookup{Country: "DE", City: "Berlin"})
require.NotNil(t, e.GeoCountry)
require.Equal(t, "DE", *e.GeoCountry)
require.Nil(t, e.GeoRegion,
"AttachGeoIP must overwrite a prior value with nil when "+
"the new lookup is empty")
require.NotNil(t, e.GeoCity)
require.Equal(t, "Berlin", *e.GeoCity)
require.Nil(t, e.GeoASN)
require.Nil(t, e.GeoASNOrg)
}
func TestEvent_AttachGeoIP_AddressableASNPointer(t *testing.T) {
t.Parallel()
e := &event.Event{}
e.AttachGeoIP(geoip.Lookup{ASN: 64512})
require.NotNil(t, e.GeoASN)
require.Equal(t, 64512, *e.GeoASN)
*e.GeoASN = 1
require.Equal(t, 1, *e.GeoASN,
"ASN pointer must be independently mutable, "+
"not aliasing the input Lookup")
}

View File

@ -0,0 +1,676 @@
// ©AngelaMos | 2026
// integration_test.go
//go:build integration
package event_test
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/go-chi/chi/v5"
"github.com/jmoiron/sqlx"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/geoip"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/notify"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/testutil"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/registry"
)
const (
intgManageURL = "https://canary.example.com"
intgBaseURL = "https://canary.example.com"
intgTokenMemo = "integration-test-memo"
)
type capturingSender struct {
mu sync.Mutex
calls []captureCall
}
type captureCall struct {
info event.NotifyInfo
evt *event.Event
}
func (c *capturingSender) Channel() string { return "telegram" }
func (c *capturingSender) Send(
_ context.Context,
info event.NotifyInfo,
evt *event.Event,
) error {
c.mu.Lock()
defer c.mu.Unlock()
c.calls = append(c.calls, captureCall{info, evt})
return nil
}
func (c *capturingSender) callCount() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.calls)
}
type intgStack struct {
router chi.Router
notifySvc *notify.Service
sender *capturingSender
eventRepo *event.Repository
tokenRepo *token.Repository
mr *miniredis.Miniredis
}
func setupIntgStack(t *testing.T) *intgStack {
t.Helper()
db := sqlx.NewDb(testutil.NewTestDB(t), "pgx")
mr, err := miniredis.Run()
require.NoError(t, err)
t.Cleanup(mr.Close)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() {
if cErr := rdb.Close(); cErr != nil {
t.Logf("redis close: %v", cErr)
}
})
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
tokenRepo := token.NewRepository(db)
eventRepo := event.NewRepository(db)
sender := &capturingSender{}
notifySvc := notify.NewService(eventRepo,
notify.WithLogger(logger),
notify.WithSendTimeout(2*time.Second),
)
notifySvc.Register(sender)
eventSvc := event.NewService(eventRepo, tokenRepo, rdb, notifySvc, event.ServiceConfig{
DedupTTL: 15 * time.Minute,
Logger: logger,
})
genRegistry := registry.Build(registry.Config{
BaseURL: intgBaseURL,
MySQLPublicHost: "localhost",
MySQLPublicPort: 3306,
})
tokenSvc := token.NewService(
tokenRepo,
intgRegistryAdapter{r: genRegistry},
token.ServiceConfig{
BaseURL: intgBaseURL,
ManageURL: intgManageURL,
},
)
tokenH := token.NewHandler(
tokenSvc,
intgRecorderAdapter{svc: eventSvc},
nil,
eventRepo,
eventSvc,
logger,
false,
)
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Recovery(logger))
tokenH.RegisterTriggerRoutes(r)
r.Route("/api", func(api chi.Router) {
api.Get("/tokens/types", tokenH.GetTypes)
api.Post("/tokens", tokenH.CreateToken)
tokenH.RegisterManageRoutes(api)
})
return &intgStack{
router: r,
notifySvc: notifySvc,
sender: sender,
eventRepo: eventRepo,
tokenRepo: tokenRepo,
mr: mr,
}
}
type intgRegistryAdapter struct{ r registry.Registry }
func (a intgRegistryAdapter) Get(t token.Type) (token.Generator, bool) {
g, ok := a.r[t]
return g, ok
}
type intgRecorderAdapter struct{ svc *event.Service }
func (a intgRecorderAdapter) Record(
ctx context.Context,
t *token.Token,
evt *event.Event,
) error {
return a.svc.Record(ctx, t.NotifyInfo(), evt)
}
func TestIntegration_FullCreateAndTriggerFlow(t *testing.T) {
t.Parallel()
st := setupIntgStack(t)
createBody := `{
"type": "webbug",
"memo": "` + intgTokenMemo + `",
"alert_channel": "telegram",
"telegram_bot": "111:AAA",
"telegram_chat": "12345"
}`
req := httptest.NewRequest(http.MethodPost, "/api/tokens",
strings.NewReader(createBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
st.router.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code, "body=%s", w.Body.String())
var resp struct {
Success bool `json:"success"`
Data struct {
Token struct {
ID string `json:"id"`
ManageID string `json:"manage_id"`
TriggerURL string `json:"trigger_url"`
ManageURL string `json:"manage_url"`
} `json:"token"`
} `json:"data"`
}
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
require.True(t, resp.Success)
require.NotEmpty(t, resp.Data.Token.ID)
require.Contains(t, resp.Data.Token.TriggerURL, "/c/"+resp.Data.Token.ID)
require.Contains(t, resp.Data.Token.ManageURL, "/m/"+resp.Data.Token.ManageID)
tokenID := resp.Data.Token.ID
for range 2 {
trigReq := httptest.NewRequest(http.MethodGet, "/c/"+tokenID, nil)
trigReq.Header.Set("CF-Connecting-IP", "203.0.113.99")
trigReq.Header.Set("User-Agent", "IntegrationTest/1.0")
tw := httptest.NewRecorder()
st.router.ServeHTTP(tw, trigReq)
require.Equal(t, http.StatusOK, tw.Code)
require.Equal(t, "image/gif", tw.Header().Get("Content-Type"))
}
require.Eventually(t, func() bool {
return st.sender.callCount() == 1
}, 2*time.Second, 10*time.Millisecond,
"only first trigger should fire a notification")
st.notifySvc.Wait()
count, err := st.eventRepo.CountByToken(context.Background(), tokenID)
require.NoError(t, err)
require.Equal(t, int64(2), count, "both events recorded")
list, err := st.eventRepo.ListByToken(context.Background(), tokenID, event.ListOptions{Limit: 10})
require.NoError(t, err)
require.Len(t, list.Events, 2)
statuses := []event.NotifyStatus{list.Events[0].NotifyStatus, list.Events[1].NotifyStatus}
var sent, deduped int
for _, s := range statuses {
switch s {
case event.NotifySent:
sent++
case event.NotifyDeduped:
deduped++
}
}
require.Equal(t, 1, sent, "first event eventually marked sent")
require.Equal(t, 1, deduped, "duplicate event marked deduped")
dedupKey := event.DedupKey(tokenID, "203.0.113.99")
val, err := st.mr.Get(dedupKey)
require.NoError(t, err)
require.Equal(t, "2", val, "dedup INCR ran on duplicate")
tok, err := st.tokenRepo.GetByID(context.Background(), tokenID)
require.NoError(t, err)
require.Equal(t, int64(2), tok.TriggerCount, "trigger count incremented twice")
require.NotNil(t, tok.LastTriggered)
}
func TestIntegration_DifferentIPsBothNotify(t *testing.T) {
t.Parallel()
st := setupIntgStack(t)
createBody := `{
"type": "webbug",
"memo": "` + intgTokenMemo + `",
"alert_channel": "telegram",
"telegram_bot": "111:AAA",
"telegram_chat": "12345"
}`
req := httptest.NewRequest(http.MethodPost, "/api/tokens",
strings.NewReader(createBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
st.router.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code)
var resp struct {
Data struct {
Token struct {
ID string `json:"id"`
} `json:"token"`
} `json:"data"`
}
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
tokenID := resp.Data.Token.ID
ips := []string{"203.0.113.10", "203.0.113.11", "203.0.113.12"}
for _, ip := range ips {
trigReq := httptest.NewRequest(http.MethodGet, "/c/"+tokenID, nil)
trigReq.Header.Set("CF-Connecting-IP", ip)
tw := httptest.NewRecorder()
st.router.ServeHTTP(tw, trigReq)
require.Equal(t, http.StatusOK, tw.Code)
}
require.Eventually(t, func() bool {
return st.sender.callCount() == len(ips)
}, 2*time.Second, 10*time.Millisecond)
st.notifySvc.Wait()
}
func TestIntegration_DedupTTLExpiry(t *testing.T) {
t.Parallel()
db := sqlx.NewDb(testutil.NewTestDB(t), "pgx")
mr, err := miniredis.Run()
require.NoError(t, err)
t.Cleanup(mr.Close)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = rdb.Close() })
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
tokenRepo := token.NewRepository(db)
eventRepo := event.NewRepository(db)
sender := &capturingSender{}
notifySvc := notify.NewService(eventRepo,
notify.WithLogger(logger),
notify.WithSendTimeout(2*time.Second),
)
notifySvc.Register(sender)
eventSvc := event.NewService(eventRepo, tokenRepo, rdb, notifySvc, event.ServiceConfig{
DedupTTL: 50 * time.Millisecond,
Logger: logger,
})
tok := &token.Token{
ID: "intgttl00001",
ManageID: "11111111-1111-1111-1111-111111111111",
Type: token.TypeWebbug,
Memo: "ttl-test",
AlertChannel: token.ChannelTelegram,
TelegramBot: testutil.Ptr("111:AAA"),
TelegramChat: testutil.Ptr("12345"),
CreatedIP: "203.0.113.1",
CreatedFP: "fp",
Metadata: json.RawMessage(`{}`),
Enabled: true,
}
require.NoError(t, tokenRepo.Insert(context.Background(), tok))
rec := func(ip string) {
evt := &event.Event{TokenID: tok.ID, SourceIP: ip}
require.NoError(t, eventSvc.Record(context.Background(), tok.NotifyInfo(), evt))
}
rec("203.0.113.1")
rec("203.0.113.1")
notifySvc.Wait()
require.Equal(t, 1, sender.callCount(), "second within TTL is deduped")
mr.FastForward(100 * time.Millisecond)
rec("203.0.113.1")
notifySvc.Wait()
require.Equal(t, 2, sender.callCount(), "after TTL expiry next trigger notifies again")
}
func TestIntegration_ManagePageReturnsTokenAndEventsAndSilencedCount(t *testing.T) {
t.Parallel()
st := setupIntgStack(t)
createBody := `{
"type": "webbug",
"memo": "manage-flow",
"alert_channel": "telegram",
"telegram_bot": "111:AAA",
"telegram_chat": "12345"
}`
req := httptest.NewRequest(http.MethodPost, "/api/tokens",
strings.NewReader(createBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
st.router.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code, "body=%s", w.Body.String())
var createResp struct {
Data struct {
Token struct {
ID string `json:"id"`
ManageID string `json:"manage_id"`
} `json:"token"`
} `json:"data"`
}
require.NoError(t, json.NewDecoder(w.Body).Decode(&createResp))
tokenID := createResp.Data.Token.ID
manageID := createResp.Data.Token.ManageID
for range 3 {
trigReq := httptest.NewRequest(http.MethodGet, "/c/"+tokenID, nil)
trigReq.Header.Set("CF-Connecting-IP", "203.0.113.50")
tw := httptest.NewRecorder()
st.router.ServeHTTP(tw, trigReq)
require.Equal(t, http.StatusOK, tw.Code)
}
st.notifySvc.Wait()
mw := httptest.NewRecorder()
st.router.ServeHTTP(mw, httptest.NewRequest(http.MethodGet,
"/api/m/"+manageID, nil))
require.Equal(t, http.StatusOK, mw.Code, "body=%s", mw.Body.String())
var manageResp struct {
Success bool `json:"success"`
Data token.ManageResponse `json:"data"`
}
require.NoError(t, json.NewDecoder(mw.Body).Decode(&manageResp))
require.True(t, manageResp.Success)
require.Equal(t, tokenID, manageResp.Data.Token.ID)
require.Equal(t, "https://canary.example.com/c/"+tokenID,
manageResp.Data.Token.TriggerURL)
require.Equal(t, int64(3), manageResp.Data.Token.TriggerCount)
require.Equal(t, int64(3), manageResp.Data.EventsTotal,
"all 3 events recorded (one sent + two deduped)")
require.Equal(t, int64(1), manageResp.Data.EventsSilencedActive,
"one unique IP silenced (same source for both dedup hits)")
require.Len(t, manageResp.Data.Events, 3, "events page payload")
require.False(t, manageResp.Data.Page.HasMore,
"3 events fits in a 20-page; no next cursor")
statuses := []event.NotifyStatus{}
for _, e := range manageResp.Data.Events {
statuses = append(statuses, e.NotifyStatus)
}
var sent, deduped int
for _, s := range statuses {
switch s {
case event.NotifySent:
sent++
case event.NotifyDeduped:
deduped++
}
}
require.Equal(t, 1, sent)
require.Equal(t, 2, deduped)
}
func TestIntegration_ManagePageReturns404OnUnknownManageID(t *testing.T) {
t.Parallel()
st := setupIntgStack(t)
w := httptest.NewRecorder()
st.router.ServeHTTP(w, httptest.NewRequest(http.MethodGet,
"/api/m/00000000-0000-0000-0000-000000000000", nil))
require.Equal(t, http.StatusNotFound, w.Code)
}
func TestIntegration_ManageDeleteCascadesEvents(t *testing.T) {
t.Parallel()
st := setupIntgStack(t)
createBody := `{
"type": "webbug",
"memo": "delete-flow",
"alert_channel": "telegram",
"telegram_bot": "111:AAA",
"telegram_chat": "12345"
}`
req := httptest.NewRequest(http.MethodPost, "/api/tokens",
strings.NewReader(createBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
st.router.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code)
var createResp struct {
Data struct {
Token struct {
ID string `json:"id"`
ManageID string `json:"manage_id"`
} `json:"token"`
} `json:"data"`
}
require.NoError(t, json.NewDecoder(w.Body).Decode(&createResp))
for i := range 2 {
trigReq := httptest.NewRequest(http.MethodGet, "/c/"+createResp.Data.Token.ID, nil)
ip := "203.0.113." + string(rune('1'+i))
trigReq.Header.Set("CF-Connecting-IP", ip)
tw := httptest.NewRecorder()
st.router.ServeHTTP(tw, trigReq)
require.Equal(t, http.StatusOK, tw.Code)
}
st.notifySvc.Wait()
count, err := st.eventRepo.CountByToken(context.Background(), createResp.Data.Token.ID)
require.NoError(t, err)
require.Equal(t, int64(2), count)
dw := httptest.NewRecorder()
st.router.ServeHTTP(dw, httptest.NewRequest(http.MethodDelete,
"/api/m/"+createResp.Data.Token.ManageID, nil))
require.Equal(t, http.StatusNoContent, dw.Code)
count, err = st.eventRepo.CountByToken(context.Background(), createResp.Data.Token.ID)
require.NoError(t, err)
require.Equal(t, int64(0), count, "FK cascade removes events")
tok, err := st.tokenRepo.GetByID(context.Background(), createResp.Data.Token.ID)
require.Nil(t, tok)
require.ErrorIs(t, err, token.ErrNotFound)
}
func TestIntegration_RetentionLoopPrunes(t *testing.T) {
t.Parallel()
db := sqlx.NewDb(testutil.NewTestDB(t), "pgx")
tokenRepo := token.NewRepository(db)
eventRepo := event.NewRepository(db)
mr, err := miniredis.Run()
require.NoError(t, err)
t.Cleanup(mr.Close)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = rdb.Close() })
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
eventSvc := event.NewService(eventRepo, tokenRepo, rdb, nil, event.ServiceConfig{
DedupTTL: 15 * time.Minute,
Logger: logger,
})
tok := &token.Token{
ID: "intgret00001",
ManageID: "22222222-2222-2222-2222-222222222222",
Type: token.TypeWebbug,
Memo: "ret",
AlertChannel: token.ChannelTelegram,
TelegramBot: testutil.Ptr("111:AAA"),
TelegramChat: testutil.Ptr("12345"),
CreatedIP: "203.0.113.1",
CreatedFP: "fp",
Metadata: json.RawMessage(`{}`),
Enabled: true,
}
require.NoError(t, tokenRepo.Insert(context.Background(), tok))
for range 7 {
require.NoError(t, eventRepo.Insert(context.Background(), &event.Event{
TokenID: tok.ID, SourceIP: "203.0.113.99",
}))
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
eventSvc.RunRetentionLoop(ctx, 25*time.Millisecond, 3)
close(done)
}()
require.Eventually(t, func() bool {
n, err := eventRepo.CountByToken(context.Background(), tok.ID)
return err == nil && n == 3
}, 2*time.Second, 25*time.Millisecond, "retention should prune to 3")
cancel()
<-done
}
type stubLookuper struct{ result geoip.Lookup }
func (s stubLookuper) Lookup(string) geoip.Lookup { return s.result }
func TestIntegration_GeoEnrichmentPopulatesColumns(t *testing.T) {
t.Parallel()
db := sqlx.NewDb(testutil.NewTestDB(t), "pgx")
mr, err := miniredis.Run()
require.NoError(t, err)
t.Cleanup(mr.Close)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = rdb.Close() })
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
tokenRepo := token.NewRepository(db)
eventRepo := event.NewRepository(db)
geo := stubLookuper{result: geoip.Lookup{
Country: "US",
Region: "California",
City: "Mountain View",
ASN: 15169,
ASNOrg: "Google LLC",
}}
eventSvc := event.NewService(eventRepo, tokenRepo, rdb, nil, event.ServiceConfig{
DedupTTL: 15 * time.Minute,
Logger: logger,
GeoIP: geo,
})
tok := &token.Token{
ID: "intggeo00001",
ManageID: "33333333-3333-3333-3333-333333333333",
Type: token.TypeWebbug,
Memo: "geo-test",
AlertChannel: token.ChannelTelegram,
TelegramBot: testutil.Ptr("111:AAA"),
TelegramChat: testutil.Ptr("12345"),
CreatedIP: "203.0.113.1",
CreatedFP: "fp",
Metadata: json.RawMessage(`{}`),
Enabled: true,
}
require.NoError(t, tokenRepo.Insert(context.Background(), tok))
evt := &event.Event{TokenID: tok.ID, SourceIP: "203.0.113.99"}
require.NoError(t, eventSvc.Record(context.Background(), tok.NotifyInfo(), evt))
list, err := eventRepo.ListByToken(context.Background(), tok.ID,
event.ListOptions{Limit: 1})
require.NoError(t, err)
require.Len(t, list.Events, 1)
row := list.Events[0]
require.NotNil(t, row.GeoCountry)
require.Equal(t, "US", *row.GeoCountry)
require.NotNil(t, row.GeoRegion)
require.Equal(t, "California", *row.GeoRegion)
require.NotNil(t, row.GeoCity)
require.Equal(t, "Mountain View", *row.GeoCity)
require.NotNil(t, row.GeoASN)
require.Equal(t, 15169, *row.GeoASN)
require.NotNil(t, row.GeoASNOrg)
require.Equal(t, "Google LLC", *row.GeoASNOrg)
}
func TestIntegration_GeoEnrichmentLeavesColumnsNullWhenLookupEmpty(t *testing.T) {
t.Parallel()
db := sqlx.NewDb(testutil.NewTestDB(t), "pgx")
mr, err := miniredis.Run()
require.NoError(t, err)
t.Cleanup(mr.Close)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = rdb.Close() })
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
tokenRepo := token.NewRepository(db)
eventRepo := event.NewRepository(db)
eventSvc := event.NewService(eventRepo, tokenRepo, rdb, nil, event.ServiceConfig{
DedupTTL: 15 * time.Minute,
Logger: logger,
GeoIP: geoip.NopService(),
})
tok := &token.Token{
ID: "intggeo00002",
ManageID: "44444444-4444-4444-4444-444444444444",
Type: token.TypeWebbug,
Memo: "geo-nop",
AlertChannel: token.ChannelTelegram,
TelegramBot: testutil.Ptr("111:AAA"),
TelegramChat: testutil.Ptr("12345"),
CreatedIP: "203.0.113.1",
CreatedFP: "fp",
Metadata: json.RawMessage(`{}`),
Enabled: true,
}
require.NoError(t, tokenRepo.Insert(context.Background(), tok))
evt := &event.Event{TokenID: tok.ID, SourceIP: "203.0.113.42"}
require.NoError(t, eventSvc.Record(context.Background(), tok.NotifyInfo(), evt))
list, err := eventRepo.ListByToken(context.Background(), tok.ID,
event.ListOptions{Limit: 1})
require.NoError(t, err)
require.Len(t, list.Events, 1)
row := list.Events[0]
require.Nil(t, row.GeoCountry)
require.Nil(t, row.GeoRegion)
require.Nil(t, row.GeoCity)
require.Nil(t, row.GeoASN)
require.Nil(t, row.GeoASNOrg)
}

View File

@ -0,0 +1,249 @@
// ©AngelaMos | 2026
// repository.go
package event
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"
"github.com/jmoiron/sqlx"
)
var ErrNotFound = errors.New("event not found")
const defaultListLimit = 20
type Repository struct {
db *sqlx.DB
}
func NewRepository(db *sqlx.DB) *Repository {
return &Repository{db: db}
}
const insertSQL = `
INSERT INTO events (
token_id, source_ip, user_agent, referer,
geo_country, geo_region, geo_city, geo_asn, geo_asn_org,
extra, notify_status
) VALUES (
:token_id, :source_ip, :user_agent, :referer,
:geo_country, :geo_region, :geo_city, :geo_asn, :geo_asn_org,
:extra, :notify_status
)
RETURNING id, triggered_at`
func (r *Repository) Insert(ctx context.Context, e *Event) error {
if e.NotifyStatus == "" {
e.NotifyStatus = NotifyPending
}
if len(e.Extra) == 0 {
e.Extra = json.RawMessage(`{}`)
}
stmt, err := r.db.PrepareNamedContext(ctx, insertSQL)
if err != nil {
return fmt.Errorf("prepare insert event: %w", err)
}
defer func() {
if cerr := stmt.Close(); cerr != nil {
slog.WarnContext(ctx, "close prepared stmt",
"op", "insert_event", "error", cerr)
}
}()
if err := stmt.GetContext(ctx, e, e); err != nil {
return fmt.Errorf("insert event: %w", err)
}
return nil
}
const selectColumns = `
id, token_id, triggered_at,
source_ip, user_agent, referer,
geo_country, geo_region, geo_city, geo_asn, geo_asn_org,
extra, notify_status, notified_at`
type ListOptions struct {
Cursor int64
Limit int
}
type ListResult struct {
Events []Event
NextCursor int64
HasMore bool
}
func (r *Repository) ListByToken(
ctx context.Context, tokenID string, opts ListOptions,
) (ListResult, error) {
if opts.Limit <= 0 {
opts.Limit = defaultListLimit
}
q := `SELECT ` + selectColumns + `
FROM events
WHERE token_id = $1
AND ($2 = 0 OR id < $2)
ORDER BY id DESC
LIMIT $3`
var events []Event
err := r.db.SelectContext(
ctx,
&events,
q,
tokenID,
opts.Cursor,
opts.Limit+1,
)
if err != nil {
return ListResult{}, fmt.Errorf("list events: %w", err)
}
hasMore := len(events) > opts.Limit
if hasMore {
events = events[:opts.Limit]
}
var next int64
if hasMore && len(events) > 0 {
next = events[len(events)-1].ID
}
return ListResult{
Events: events,
NextCursor: next,
HasMore: hasMore,
}, nil
}
func (r *Repository) CountByToken(
ctx context.Context,
tokenID string,
) (int64, error) {
var n int64
err := r.db.GetContext(ctx, &n,
`SELECT COUNT(*) FROM events WHERE token_id = $1`, tokenID)
if err != nil {
return 0, fmt.Errorf("count events: %w", err)
}
return n, nil
}
func (r *Repository) CountAll(ctx context.Context) (int64, error) {
var n int64
if err := r.db.GetContext(
ctx,
&n,
`SELECT COUNT(*) FROM events`,
); err != nil {
return 0, fmt.Errorf("count events: %w", err)
}
return n, nil
}
func (r *Repository) AttachFingerprint(
ctx context.Context,
tokenID, sourceIP string,
fingerprint json.RawMessage,
window time.Duration,
) error {
q := `
UPDATE events
SET extra = extra || $3::jsonb
WHERE id = (
SELECT id FROM events
WHERE token_id = $1
AND source_ip = $2::inet
AND triggered_at >= NOW() - $4::interval
ORDER BY id DESC
LIMIT 1
)`
res, err := r.db.ExecContext(
ctx,
q,
tokenID,
sourceIP,
[]byte(fingerprint),
fmt.Sprintf("%d milliseconds", window.Milliseconds()),
)
if err != nil {
return fmt.Errorf("attach fingerprint: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return ErrNotFound
}
return nil
}
func (r *Repository) UpdateNotifyStatus(
ctx context.Context, eventID int64, status NotifyStatus, sentAt *time.Time,
) error {
q := `
UPDATE events
SET notify_status = $2, notified_at = $3
WHERE id = $1`
res, err := r.db.ExecContext(ctx, q, eventID, status, sentAt)
if err != nil {
return fmt.Errorf("update notify status: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return ErrNotFound
}
return nil
}
func (r *Repository) PruneToLimit(
ctx context.Context,
perTokenLimit int,
) (int64, error) {
if perTokenLimit <= 0 {
return 0, errors.New("perTokenLimit must be positive")
}
q := `
WITH ranked AS (
SELECT id,
row_number() OVER (PARTITION BY token_id ORDER BY triggered_at DESC) AS rn
FROM events
)
DELETE FROM events
WHERE id IN (SELECT id FROM ranked WHERE rn > $1)`
res, err := r.db.ExecContext(ctx, q, perTokenLimit)
if err != nil {
return 0, fmt.Errorf("prune events: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("rows affected: %w", err)
}
return n, nil
}
func (r *Repository) GetByID(ctx context.Context, id int64) (*Event, error) {
var e Event
q := `SELECT ` + selectColumns + ` FROM events WHERE id = $1`
err := r.db.GetContext(ctx, &e, q, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get event by id: %w", err)
}
return &e, nil
}

View File

@ -0,0 +1,273 @@
// ©AngelaMos | 2026
// repository_test.go
//go:build integration
package event_test
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/testutil"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
)
func newRepos(t *testing.T) (*sqlx.DB, *token.Repository, *event.Repository) {
t.Helper()
db := sqlx.NewDb(testutil.NewTestDB(t), "pgx")
return db, token.NewRepository(db), event.NewRepository(db)
}
func seedToken(t *testing.T, repo *token.Repository, id string) *token.Token {
t.Helper()
tok := &token.Token{
ID: id,
ManageID: uuid.New().String(),
Type: token.TypeWebbug,
Memo: "event-test",
AlertChannel: token.ChannelWebhook,
WebhookURL: testutil.Ptr("https://example.com/hook"),
CreatedIP: "203.0.113.1",
CreatedFP: "abcdef0123456789",
Metadata: json.RawMessage(`{}`),
Enabled: true,
}
require.NoError(t, repo.Insert(context.Background(), tok))
return tok
}
func TestRepository_InsertAndCount(t *testing.T) {
t.Parallel()
_, tokRepo, evtRepo := newRepos(t)
ctx := context.Background()
tok := seedToken(t, tokRepo, "evtinsert001")
for range 3 {
e := &event.Event{
TokenID: tok.ID,
SourceIP: "203.0.113.45",
}
require.NoError(t, evtRepo.Insert(ctx, e))
require.NotZero(t, e.ID)
require.False(t, e.TriggeredAt.IsZero())
require.Equal(t, event.NotifyPending, e.NotifyStatus)
}
count, err := evtRepo.CountByToken(ctx, tok.ID)
require.NoError(t, err)
require.Equal(t, int64(3), count)
}
func TestRepository_ListByToken_CursorPagination(t *testing.T) {
t.Parallel()
_, tokRepo, evtRepo := newRepos(t)
ctx := context.Background()
tok := seedToken(t, tokRepo, "evtcursor001")
for i := range 5 {
e := &event.Event{
TokenID: tok.ID,
SourceIP: "203.0.113.45",
UserAgent: testutil.Ptr(string(rune('A' + i))),
}
require.NoError(t, evtRepo.Insert(ctx, e))
}
page1, err := evtRepo.ListByToken(ctx, tok.ID, event.ListOptions{Limit: 2})
require.NoError(t, err)
require.Len(t, page1.Events, 2)
require.True(t, page1.HasMore)
require.NotZero(t, page1.NextCursor)
require.Equal(t, "E", *page1.Events[0].UserAgent, "newest first")
page2, err := evtRepo.ListByToken(ctx, tok.ID, event.ListOptions{
Cursor: page1.NextCursor, Limit: 2,
})
require.NoError(t, err)
require.Len(t, page2.Events, 2)
require.True(t, page2.HasMore)
page3, err := evtRepo.ListByToken(ctx, tok.ID, event.ListOptions{
Cursor: page2.NextCursor, Limit: 2,
})
require.NoError(t, err)
require.Len(t, page3.Events, 1)
require.False(t, page3.HasMore)
require.Zero(t, page3.NextCursor)
}
func TestRepository_FKCascade(t *testing.T) {
t.Parallel()
_, tokRepo, evtRepo := newRepos(t)
ctx := context.Background()
tok := seedToken(t, tokRepo, "evtcascade01")
for range 3 {
e := &event.Event{TokenID: tok.ID, SourceIP: "203.0.113.45"}
require.NoError(t, evtRepo.Insert(ctx, e))
}
require.NoError(t, tokRepo.DeleteByManageID(ctx, tok.ManageID))
count, err := evtRepo.CountByToken(ctx, tok.ID)
require.NoError(t, err)
require.Equal(t, int64(0), count, "cascade delete should remove all events")
}
func TestRepository_CountAll(t *testing.T) {
t.Parallel()
_, tokRepo, evtRepo := newRepos(t)
ctx := context.Background()
tok := seedToken(t, tokRepo, "evtcntallall")
base, err := evtRepo.CountAll(ctx)
require.NoError(t, err)
for range 4 {
require.NoError(t, evtRepo.Insert(ctx, &event.Event{
TokenID: tok.ID,
SourceIP: "203.0.113.99",
}))
}
got, err := evtRepo.CountAll(ctx)
require.NoError(t, err)
require.Equal(t, base+4, got)
}
func TestRepository_AttachFingerprint(t *testing.T) {
t.Parallel()
_, tokRepo, evtRepo := newRepos(t)
ctx := context.Background()
tok := seedToken(t, tokRepo, "evtfprint001")
e := &event.Event{
TokenID: tok.ID,
SourceIP: "203.0.113.45",
Extra: json.RawMessage(`{"initial":"value"}`),
}
require.NoError(t, evtRepo.Insert(ctx, e))
fp := json.RawMessage(`{"screen":"1920x1080","tz":"America/Los_Angeles"}`)
require.NoError(t, evtRepo.AttachFingerprint(ctx, tok.ID, "203.0.113.45", fp, 30*time.Second))
got, err := evtRepo.GetByID(ctx, e.ID)
require.NoError(t, err)
var merged map[string]any
require.NoError(t, json.Unmarshal(got.Extra, &merged))
require.Equal(t, "value", merged["initial"])
require.Equal(t, "1920x1080", merged["screen"])
require.Equal(t, "America/Los_Angeles", merged["tz"])
}
func TestRepository_AttachFingerprint_NoMatch(t *testing.T) {
t.Parallel()
_, tokRepo, evtRepo := newRepos(t)
ctx := context.Background()
tok := seedToken(t, tokRepo, "evtfpnomtch1")
fp := json.RawMessage(`{"screen":"1024x768"}`)
err := evtRepo.AttachFingerprint(ctx, tok.ID, "203.0.113.45", fp, 30*time.Second)
require.ErrorIs(t, err, event.ErrNotFound)
}
func TestRepository_UpdateNotifyStatus(t *testing.T) {
t.Parallel()
_, tokRepo, evtRepo := newRepos(t)
ctx := context.Background()
tok := seedToken(t, tokRepo, "evtnotify001")
e := &event.Event{TokenID: tok.ID, SourceIP: "203.0.113.45"}
require.NoError(t, evtRepo.Insert(ctx, e))
require.Equal(t, event.NotifyPending, e.NotifyStatus)
now := time.Now()
require.NoError(t, evtRepo.UpdateNotifyStatus(ctx, e.ID, event.NotifySent, &now))
got, err := evtRepo.GetByID(ctx, e.ID)
require.NoError(t, err)
require.Equal(t, event.NotifySent, got.NotifyStatus)
require.NotNil(t, got.NotifiedAt)
}
func TestRepository_PruneToLimit(t *testing.T) {
t.Parallel()
_, tokRepo, evtRepo := newRepos(t)
ctx := context.Background()
tokA := seedToken(t, tokRepo, "evtpruneaaa1")
tokB := seedToken(t, tokRepo, "evtprunebbb1")
for range 5 {
require.NoError(t, evtRepo.Insert(ctx, &event.Event{TokenID: tokA.ID, SourceIP: "203.0.113.10"}))
}
for range 7 {
require.NoError(t, evtRepo.Insert(ctx, &event.Event{TokenID: tokB.ID, SourceIP: "203.0.113.20"}))
}
deleted, err := evtRepo.PruneToLimit(ctx, 3)
require.NoError(t, err)
require.Equal(t, int64((5-3)+(7-3)), deleted, "should delete 2 from A and 4 from B")
cntA, err := evtRepo.CountByToken(ctx, tokA.ID)
require.NoError(t, err)
require.Equal(t, int64(3), cntA)
cntB, err := evtRepo.CountByToken(ctx, tokB.ID)
require.NoError(t, err)
require.Equal(t, int64(3), cntB)
}
func TestRepository_PruneToLimit_KeepsNewest(t *testing.T) {
t.Parallel()
_, tokRepo, evtRepo := newRepos(t)
ctx := context.Background()
tok := seedToken(t, tokRepo, "evtprunekeep")
var ids []int64
for i := range 5 {
e := &event.Event{TokenID: tok.ID, SourceIP: "203.0.113.45", UserAgent: testutil.Ptr(string(rune('A' + i)))}
require.NoError(t, evtRepo.Insert(ctx, e))
ids = append(ids, e.ID)
time.Sleep(2 * time.Millisecond)
}
_, err := evtRepo.PruneToLimit(ctx, 2)
require.NoError(t, err)
cnt, err := evtRepo.CountByToken(ctx, tok.ID)
require.NoError(t, err)
require.Equal(t, int64(2), cnt)
_, err = evtRepo.GetByID(ctx, ids[4])
require.NoError(t, err, "newest should be kept")
_, err = evtRepo.GetByID(ctx, ids[3])
require.NoError(t, err, "second-newest should be kept")
_, err = evtRepo.GetByID(ctx, ids[0])
require.ErrorIs(t, err, event.ErrNotFound, "oldest should be deleted")
}
func TestRepository_PruneToLimit_RejectsZero(t *testing.T) {
t.Parallel()
_, _, evtRepo := newRepos(t)
_, err := evtRepo.PruneToLimit(context.Background(), 0)
require.Error(t, err)
}

View File

@ -0,0 +1,199 @@
// ©AngelaMos | 2026
// service.go
package event
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/redis/go-redis/v9"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/geoip"
)
const (
dedupKeyPrefix = "dedup:trigger:"
dedupActivePrefix = "dedup:active:"
defaultDedupTTL = 15 * time.Minute
)
type Service struct {
repo Store
tokens TokenIncrementer
rdb *redis.Client
notifier Notifier
geo geoip.Lookuper
dedupTTL time.Duration
logger *slog.Logger
}
type ServiceConfig struct {
DedupTTL time.Duration
Logger *slog.Logger
GeoIP geoip.Lookuper
}
func NewService(
repo Store,
tokens TokenIncrementer,
rdb *redis.Client,
notifier Notifier,
cfg ServiceConfig,
) *Service {
if cfg.DedupTTL <= 0 {
cfg.DedupTTL = defaultDedupTTL
}
if cfg.Logger == nil {
cfg.Logger = slog.Default()
}
return &Service{
repo: repo,
tokens: tokens,
rdb: rdb,
notifier: notifier,
geo: cfg.GeoIP,
dedupTTL: cfg.DedupTTL,
logger: cfg.Logger,
}
}
func DedupKey(tokenID, sourceIP string) string {
return dedupKeyPrefix + tokenID + ":" + sourceIP
}
func (s *Service) Record(
ctx context.Context,
info NotifyInfo,
evt *Event,
) error {
s.enrichGeo(evt)
if err := s.repo.Insert(ctx, evt); err != nil {
return fmt.Errorf("insert event: %w", err)
}
if s.tokens != nil {
if err := s.tokens.IncrementTriggerCount(
ctx,
info.TokenID,
); err != nil {
s.logger.WarnContext(ctx, "increment trigger count",
"error", err, "token_id", info.TokenID)
}
}
first := s.dedupGate(ctx, info.TokenID, evt.SourceIP)
if !first {
if err := s.repo.UpdateNotifyStatus(
ctx, evt.ID, NotifyDeduped, nil,
); err != nil {
s.logger.WarnContext(ctx, "update notify status deduped",
"error", err, "event_id", evt.ID)
}
return nil
}
if s.notifier != nil {
s.notifier.Notify(info, evt)
}
return nil
}
func (s *Service) enrichGeo(evt *Event) {
if s.geo == nil || evt == nil || evt.SourceIP == "" {
return
}
evt.AttachGeoIP(s.geo.Lookup(evt.SourceIP))
}
func (s *Service) dedupGate(
ctx context.Context,
tokenID, sourceIP string,
) bool {
if s.rdb == nil {
return true
}
key := DedupKey(tokenID, sourceIP)
set, err := s.rdb.SetNX(ctx, key, 1, s.dedupTTL).Result()
if err != nil {
s.logger.WarnContext(ctx, "dedup setnx failed (fail-open)",
"error", err, "key", key)
return true
}
if set {
return true
}
if _, iErr := s.rdb.Incr(ctx, key).Result(); iErr != nil {
s.logger.WarnContext(ctx, "dedup incr failed",
"error", iErr, "key", key)
}
trackKey := dedupActivePrefix + tokenID
if _, sErr := s.rdb.SAdd(ctx, trackKey, sourceIP).Result(); sErr != nil {
s.logger.WarnContext(ctx, "dedup track add",
"error", sErr, "key", trackKey)
}
if _, eErr := s.rdb.Expire(
ctx, trackKey, s.dedupTTL,
).Result(); eErr != nil {
s.logger.WarnContext(ctx, "dedup track expire",
"error", eErr, "key", trackKey)
}
return false
}
func (s *Service) CountActiveDedup(
ctx context.Context,
tokenID string,
) (int64, error) {
if s.rdb == nil {
return 0, nil
}
n, err := s.rdb.SCard(ctx, dedupActivePrefix+tokenID).Result()
if errors.Is(err, redis.Nil) {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("dedup count: %w", err)
}
return n, nil
}
func (s *Service) RunRetentionLoop(
ctx context.Context,
interval time.Duration,
perTokenLimit int,
) {
if interval <= 0 || perTokenLimit <= 0 {
s.logger.WarnContext(ctx, "retention loop disabled (invalid config)",
"interval", interval, "limit", perTokenLimit)
return
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
s.logger.InfoContext(ctx, "retention loop started",
"interval", interval, "per_token_limit", perTokenLimit)
for {
select {
case <-ctx.Done():
s.logger.InfoContext(ctx, "retention loop stopped")
return
case <-ticker.C:
n, err := s.repo.PruneToLimit(ctx, perTokenLimit)
if err != nil {
s.logger.WarnContext(ctx, "retention loop: prune failed",
"error", err, "per_token_limit", perTokenLimit)
continue
}
if n > 0 {
s.logger.InfoContext(ctx, "retention loop: pruned events",
"deleted", n, "per_token_limit", perTokenLimit)
}
}
}
}

View File

@ -0,0 +1,822 @@
// ©AngelaMos | 2026
// service_test.go
package event_test
import (
"context"
"errors"
"log/slog"
"strconv"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/geoip"
)
const testTokenID = "tokevtsvc001"
type fakeStore struct {
mu sync.Mutex
inserted []*event.Event
insertErr error
statusUpdates []statusUpdate
statusErr error
pruneCount int64
pruneErr error
pruneLastLimit int
}
type statusUpdate struct {
id int64
status event.NotifyStatus
sentAt *time.Time
}
func (f *fakeStore) Insert(_ context.Context, e *event.Event) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.insertErr != nil {
return f.insertErr
}
e.ID = int64(len(f.inserted) + 1)
if e.TriggeredAt.IsZero() {
e.TriggeredAt = time.Now().UTC()
}
if e.NotifyStatus == "" {
e.NotifyStatus = event.NotifyPending
}
f.inserted = append(f.inserted, e)
return nil
}
func (f *fakeStore) UpdateNotifyStatus(
_ context.Context,
id int64,
status event.NotifyStatus,
sentAt *time.Time,
) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.statusErr != nil {
return f.statusErr
}
f.statusUpdates = append(f.statusUpdates, statusUpdate{id, status, sentAt})
return nil
}
func (f *fakeStore) PruneToLimit(
_ context.Context,
perTokenLimit int,
) (int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.pruneLastLimit = perTokenLimit
if f.pruneErr != nil {
return 0, f.pruneErr
}
return f.pruneCount, nil
}
func (f *fakeStore) snapshot() ([]*event.Event, []statusUpdate) {
f.mu.Lock()
defer f.mu.Unlock()
ev := make([]*event.Event, len(f.inserted))
copy(ev, f.inserted)
su := make([]statusUpdate, len(f.statusUpdates))
copy(su, f.statusUpdates)
return ev, su
}
type fakeIncrementer struct {
mu sync.Mutex
calls []string
err error
}
func (f *fakeIncrementer) IncrementTriggerCount(
_ context.Context,
id string,
) error {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, id)
return f.err
}
func (f *fakeIncrementer) callCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.calls)
}
type fakeLookuper struct {
mu sync.Mutex
called []string
result geoip.Lookup
}
func (f *fakeLookuper) Lookup(ip string) geoip.Lookup {
f.mu.Lock()
defer f.mu.Unlock()
f.called = append(f.called, ip)
return f.result
}
func (f *fakeLookuper) calls() []string {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]string, len(f.called))
copy(out, f.called)
return out
}
type fakeNotifier struct {
mu sync.Mutex
calls []notifyCall
}
type notifyCall struct {
info event.NotifyInfo
evt *event.Event
}
func (f *fakeNotifier) Notify(info event.NotifyInfo, evt *event.Event) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, notifyCall{info, evt})
}
func (f *fakeNotifier) callCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.calls)
}
func setupRedis(t *testing.T) (*redis.Client, *miniredis.Miniredis) {
t.Helper()
mr, err := miniredis.Run()
require.NoError(t, err)
t.Cleanup(mr.Close)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() {
if cErr := rdb.Close(); cErr != nil {
t.Logf("redis close: %v", cErr)
}
})
return rdb, mr
}
func sampleInfo() event.NotifyInfo {
return event.NotifyInfo{
TokenID: testTokenID,
ManageID: "abcd-1234",
Type: "webbug",
Memo: "test",
AlertChannel: "telegram",
TelegramBot: "bot",
TelegramChat: "chat",
}
}
func sampleEvent(ip string) *event.Event {
return &event.Event{TokenID: testTokenID, SourceIP: ip}
}
func newSvc(
t *testing.T,
store event.Store,
tokens event.TokenIncrementer,
rdb *redis.Client,
notifier event.Notifier,
) *event.Service {
t.Helper()
return event.NewService(store, tokens, rdb, notifier, event.ServiceConfig{
DedupTTL: 15 * time.Minute,
Logger: slog.New(slog.NewTextHandler(testWriter{t}, nil)),
})
}
func newSvcWithGeo(
t *testing.T,
store event.Store,
tokens event.TokenIncrementer,
rdb *redis.Client,
geo geoip.Lookuper,
) *event.Service {
t.Helper()
return event.NewService(store, tokens, rdb, nil, event.ServiceConfig{
DedupTTL: 15 * time.Minute,
Logger: slog.New(slog.NewTextHandler(testWriter{t}, nil)),
GeoIP: geo,
})
}
type testWriter struct{ t *testing.T }
func (w testWriter) Write(
p []byte,
) (int, error) {
w.t.Log(string(p))
return len(p), nil
}
func TestService_Record_InsertsEvent(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, _ := setupRedis(t)
notifier := &fakeNotifier{}
svc := newSvc(t, store, inc, rdb, notifier)
evt := sampleEvent("203.0.113.1")
require.NoError(t, svc.Record(context.Background(), sampleInfo(), evt))
inserted, _ := store.snapshot()
require.Len(t, inserted, 1)
require.Equal(t, "203.0.113.1", inserted[0].SourceIP)
require.NotZero(t, evt.ID, "Insert assigns ID")
}
func TestService_Record_IncrementsTriggerCount(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, _ := setupRedis(t)
svc := newSvc(t, store, inc, rdb, nil)
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
),
)
require.Equal(t, 1, inc.callCount())
}
func TestService_Record_FirstTriggerNotifies(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, _ := setupRedis(t)
notifier := &fakeNotifier{}
svc := newSvc(t, store, inc, rdb, notifier)
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
),
)
require.Equal(t, 1, notifier.callCount())
_, statusUpdates := store.snapshot()
require.Empty(
t,
statusUpdates,
"first trigger should not write 'deduped' status; notify.Service handles sent/failed writeback async",
)
}
func TestService_Record_DuplicateMarksDeduped(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, mr := setupRedis(t)
notifier := &fakeNotifier{}
svc := newSvc(t, store, inc, rdb, notifier)
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
),
)
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
),
)
require.Equal(t, 1, notifier.callCount(), "duplicate must not notify")
inserted, statusUpdates := store.snapshot()
require.Len(t, inserted, 2, "both events still recorded")
require.Len(t, statusUpdates, 1, "duplicate writes deduped status")
require.Equal(t, event.NotifyDeduped, statusUpdates[0].status)
require.Equal(t, inserted[1].ID, statusUpdates[0].id)
dedupKey := "dedup:trigger:" + testTokenID + ":203.0.113.1"
val, err := mr.Get(dedupKey)
require.NoError(t, err)
require.Equal(t, "2", val, "INCR bumps counter")
}
func TestService_Record_DifferentIPsBothNotify(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, _ := setupRedis(t)
notifier := &fakeNotifier{}
svc := newSvc(t, store, inc, rdb, notifier)
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
),
)
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.2"),
),
)
require.Equal(t, 2, notifier.callCount(), "different IPs each notify")
}
func TestService_Record_DedupTTLExpiry(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, mr := setupRedis(t)
notifier := &fakeNotifier{}
svc := event.NewService(store, inc, rdb, notifier, event.ServiceConfig{
DedupTTL: 1 * time.Second,
Logger: slog.New(slog.NewTextHandler(testWriter{t}, nil)),
})
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
),
)
mr.FastForward(2 * time.Second)
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
),
)
require.Equal(
t,
2,
notifier.callCount(),
"after TTL expiry second trigger notifies again",
)
}
func TestService_Record_DedupKeyShape(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, mr := setupRedis(t)
svc := newSvc(t, store, inc, rdb, nil)
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.99"),
),
)
keys := mr.Keys()
require.Contains(t, keys, "dedup:trigger:"+testTokenID+":203.0.113.99")
}
func TestService_Record_RedisDownFailsOpen(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, mr := setupRedis(t)
notifier := &fakeNotifier{}
svc := newSvc(t, store, inc, rdb, notifier)
mr.Close()
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
),
)
require.Equal(t, 1, notifier.callCount(),
"redis down → fail open → still notify so we don't miss alerts")
}
func TestService_Record_InsertErrorReturns(t *testing.T) {
t.Parallel()
store := &fakeStore{insertErr: errors.New("db down")}
inc := &fakeIncrementer{}
rdb, _ := setupRedis(t)
notifier := &fakeNotifier{}
svc := newSvc(t, store, inc, rdb, notifier)
err := svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
)
require.Error(t, err)
require.Equal(
t,
0,
notifier.callCount(),
"insert failure prevents notify — we don't have an event id to write back to",
)
}
func TestService_Record_IncrementErrorDoesNotPropagate(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{err: errors.New("update failed")}
rdb, _ := setupRedis(t)
notifier := &fakeNotifier{}
svc := newSvc(t, store, inc, rdb, notifier)
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
),
"increment failure is best-effort; record should still succeed",
)
require.Equal(t, 1, notifier.callCount())
}
func TestService_Record_NilNotifierNoCrash(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, _ := setupRedis(t)
svc := newSvc(t, store, inc, rdb, nil)
require.NotPanics(t, func() {
if err := svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
); err != nil {
t.Logf("record: %v", err)
}
})
}
func TestService_Record_ConcurrentSafe(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, _ := setupRedis(t)
notifier := &fakeNotifier{}
svc := newSvc(t, store, inc, rdb, notifier)
const n = 20
var wg sync.WaitGroup
var notified atomic.Int32
for i := range n {
wg.Add(1)
go func(i int) {
defer wg.Done()
ip := "203.0.113." + strconv.Itoa(i+1)
if err := svc.Record(
context.Background(),
sampleInfo(),
sampleEvent(ip),
); err == nil {
notified.Add(1)
}
}(i)
}
wg.Wait()
require.Equal(t, int32(n), notified.Load())
require.Equal(t, n, notifier.callCount())
}
func TestService_CountActiveDedup_NoKeysReturnsZero(t *testing.T) {
t.Parallel()
rdb, _ := setupRedis(t)
svc := newSvc(t, &fakeStore{}, &fakeIncrementer{}, rdb, nil)
n, err := svc.CountActiveDedup(context.Background(), testTokenID)
require.NoError(t, err)
require.Equal(t, int64(0), n)
}
func TestService_CountActiveDedup_FirstTriggerOnlyCountsZero(t *testing.T) {
t.Parallel()
rdb, _ := setupRedis(t)
svc := newSvc(t, &fakeStore{}, &fakeIncrementer{}, rdb, nil)
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
),
)
n, err := svc.CountActiveDedup(context.Background(), testTokenID)
require.NoError(t, err)
require.Equal(t, int64(0), n,
"first trigger fires the notification; nothing silenced yet")
}
func TestService_CountActiveDedup_CountsSilencedAcrossIPs(t *testing.T) {
t.Parallel()
rdb, _ := setupRedis(t)
svc := newSvc(t, &fakeStore{}, &fakeIncrementer{}, rdb, nil)
for range 3 {
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
),
)
}
for range 5 {
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.2"),
),
)
}
n, err := svc.CountActiveDedup(context.Background(), testTokenID)
require.NoError(t, err)
require.Equal(t, int64(2), n,
"two distinct IPs were silenced (203.0.113.1 and 203.0.113.2)")
}
func TestService_CountActiveDedup_IgnoresOtherTokens(t *testing.T) {
t.Parallel()
rdb, _ := setupRedis(t)
svc := newSvc(t, &fakeStore{}, &fakeIncrementer{}, rdb, nil)
for range 3 {
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
),
)
}
otherInfo := sampleInfo()
otherInfo.TokenID = "tokother0001"
for range 4 {
require.NoError(
t,
svc.Record(
context.Background(),
otherInfo,
&event.Event{TokenID: "tokother0001", SourceIP: "203.0.113.5"},
),
)
}
n, err := svc.CountActiveDedup(context.Background(), testTokenID)
require.NoError(t, err)
require.Equal(t, int64(1), n,
"only this token's silenced IPs counted (one distinct IP)")
}
func TestService_CountActiveDedup_NilRedisReturnsZero(t *testing.T) {
t.Parallel()
svc := event.NewService(
&fakeStore{},
&fakeIncrementer{},
nil,
nil,
event.ServiceConfig{
DedupTTL: 15 * time.Minute,
Logger: slog.New(slog.NewTextHandler(testWriter{t}, nil)),
},
)
n, err := svc.CountActiveDedup(context.Background(), testTokenID)
require.NoError(t, err)
require.Equal(t, int64(0), n)
}
func TestService_RunRetentionLoop_PrunesAtInterval(t *testing.T) {
t.Parallel()
store := &fakeStore{pruneCount: 5}
rdb, _ := setupRedis(t)
svc := newSvc(t, store, &fakeIncrementer{}, rdb, nil)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
svc.RunRetentionLoop(ctx, 25*time.Millisecond, 100)
close(done)
}()
require.Eventually(t, func() bool {
store.mu.Lock()
defer store.mu.Unlock()
return store.pruneLastLimit == 100
}, 1*time.Second, 5*time.Millisecond)
cancel()
<-done
}
func TestService_RunRetentionLoop_StopsOnContextCancel(t *testing.T) {
t.Parallel()
store := &fakeStore{}
rdb, _ := setupRedis(t)
svc := newSvc(t, store, &fakeIncrementer{}, rdb, nil)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
svc.RunRetentionLoop(ctx, 10*time.Millisecond, 50)
close(done)
}()
time.Sleep(20 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(500 * time.Millisecond):
t.Fatal("retention loop did not stop on cancel")
}
}
func TestService_RunRetentionLoop_ContinuesOnPruneError(t *testing.T) {
t.Parallel()
store := &fakeStore{pruneErr: errors.New("db down")}
rdb, _ := setupRedis(t)
svc := newSvc(t, store, &fakeIncrementer{}, rdb, nil)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
svc.RunRetentionLoop(ctx, 10*time.Millisecond, 50)
close(done)
}()
time.Sleep(60 * time.Millisecond)
cancel()
<-done
}
func TestService_RunRetentionLoop_DisabledOnInvalidConfig(t *testing.T) {
t.Parallel()
store := &fakeStore{}
rdb, _ := setupRedis(t)
svc := newSvc(t, store, &fakeIncrementer{}, rdb, nil)
ctx := context.Background()
done := make(chan struct{})
go func() {
svc.RunRetentionLoop(ctx, 0, 100)
close(done)
}()
select {
case <-done:
case <-time.After(500 * time.Millisecond):
t.Fatal(
"retention loop should have returned immediately on invalid interval",
)
}
require.Equal(t, 0, store.pruneLastLimit)
}
func TestService_Record_EnrichesGeoBeforeInsert(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, _ := setupRedis(t)
geo := &fakeLookuper{result: geoip.Lookup{
Country: "US", Region: "California", City: "Mountain View",
ASN: 15169, ASNOrg: "Google LLC",
}}
svc := newSvcWithGeo(t, store, inc, rdb, geo)
evt := sampleEvent("203.0.113.1")
require.NoError(t, svc.Record(context.Background(), sampleInfo(), evt))
require.Equal(t, []string{"203.0.113.1"}, geo.calls(),
"Record must invoke Lookup exactly once with the event's source IP")
inserted, _ := store.snapshot()
require.Len(t, inserted, 1)
got := inserted[0]
require.NotNil(t, got.GeoCountry)
require.Equal(t, "US", *got.GeoCountry)
require.NotNil(t, got.GeoCity)
require.Equal(t, "Mountain View", *got.GeoCity)
require.NotNil(t, got.GeoASN)
require.Equal(t, 15169, *got.GeoASN)
}
func TestService_Record_NoGeoConfigured_LeavesGeoNil(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, _ := setupRedis(t)
svc := newSvc(t, store, inc, rdb, nil)
evt := sampleEvent("203.0.113.1")
require.NoError(t, svc.Record(context.Background(), sampleInfo(), evt))
inserted, _ := store.snapshot()
require.Len(t, inserted, 1)
require.Nil(t, inserted[0].GeoCountry)
require.Nil(t, inserted[0].GeoCity)
require.Nil(t, inserted[0].GeoASN)
}
func TestService_Record_EmptySourceIP_SkipsLookup(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, _ := setupRedis(t)
geo := &fakeLookuper{result: geoip.Lookup{Country: "ZZ"}}
svc := newSvcWithGeo(t, store, inc, rdb, geo)
evt := &event.Event{TokenID: testTokenID}
require.NoError(t, svc.Record(context.Background(), sampleInfo(), evt))
require.Empty(t, geo.calls(),
"empty source IP must short-circuit the geo lookup "+
"(no useful enrichment possible)")
inserted, _ := store.snapshot()
require.Nil(t, inserted[0].GeoCountry)
}
func TestService_Record_NopLookuper_LeavesGeoNil(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, _ := setupRedis(t)
svc := newSvcWithGeo(t, store, inc, rdb, geoip.NopService())
evt := sampleEvent("203.0.113.1")
require.NoError(t, svc.Record(context.Background(), sampleInfo(), evt))
inserted, _ := store.snapshot()
require.Nil(t, inserted[0].GeoCountry,
"NopService returns empty Lookup; AttachGeoIP leaves all fields nil")
}
func TestService_Record_GeoEnrichmentBestEffort_InsertErrorBubbles(
t *testing.T,
) {
t.Parallel()
store := &fakeStore{insertErr: errors.New("db down")}
inc := &fakeIncrementer{}
rdb, _ := setupRedis(t)
geo := &fakeLookuper{result: geoip.Lookup{Country: "US"}}
svc := newSvcWithGeo(t, store, inc, rdb, geo)
err := svc.Record(context.Background(), sampleInfo(),
sampleEvent("203.0.113.1"))
require.Error(t, err, "insert error path is unchanged by geo enrichment")
require.Equal(t, []string{"203.0.113.1"}, geo.calls(),
"enrichment runs even when insert later fails "+
"(no upstream side-effect)")
}

View File

@ -0,0 +1,99 @@
// ©AngelaMos | 2026
// service.go
package geoip
import (
"errors"
"fmt"
"net/netip"
"github.com/oschwald/geoip2-golang/v2"
)
type Lookup struct {
Country string
Region string
City string
ASNOrg string
ASN int
}
type Lookuper interface {
Lookup(ip string) Lookup
}
type cityReader interface {
City(netip.Addr) (*geoip2.City, error)
Close() error
}
type Service struct {
reader cityReader
}
func Open(path string) (*Service, error) {
if path == "" {
return nil, errors.New("geoip: path is empty")
}
r, err := geoip2.Open(path)
if err != nil {
return nil, fmt.Errorf("geoip open %q: %w", path, err)
}
return &Service{reader: r}, nil
}
func (s *Service) Lookup(ip string) Lookup {
if s == nil || s.reader == nil || ip == "" {
return Lookup{}
}
addr, err := netip.ParseAddr(ip)
if err != nil {
return Lookup{}
}
rec, err := s.reader.City(addr)
if err != nil || rec == nil || !rec.HasData() {
return Lookup{}
}
return extractLookup(rec)
}
func (s *Service) Close() error {
if s == nil || s.reader == nil {
return nil
}
return s.reader.Close()
}
type nopService struct{}
func NopService() Lookuper {
return nopService{}
}
func (nopService) Lookup(string) Lookup {
return Lookup{}
}
func extractLookup(rec *geoip2.City) Lookup {
if rec == nil {
return Lookup{}
}
return Lookup{
Country: rec.Country.ISOCode,
Region: firstSubdivisionName(rec.Subdivisions),
City: rec.City.Names.English,
}
}
func firstSubdivisionName(subs []geoip2.CitySubdivision) string {
for _, s := range subs {
if s.Names.English != "" {
return s.Names.English
}
if s.ISOCode != "" {
return s.ISOCode
}
}
return ""
}

View File

@ -0,0 +1,252 @@
// ©AngelaMos | 2026
// service_test.go
package geoip
import (
"errors"
"net/netip"
"path/filepath"
"testing"
"github.com/oschwald/geoip2-golang/v2"
"github.com/stretchr/testify/require"
)
type fakeCityReader struct {
rec *geoip2.City
err error
calls int
closeCalls int
closeErr error
}
func (f *fakeCityReader) City(netip.Addr) (*geoip2.City, error) {
f.calls++
return f.rec, f.err
}
func (f *fakeCityReader) Close() error {
f.closeCalls++
return f.closeErr
}
func newServiceWithFake(r *fakeCityReader) *Service {
return &Service{reader: r}
}
func TestNopService_AlwaysReturnsEmpty(t *testing.T) {
t.Parallel()
n := NopService()
require.Equal(t, Lookup{}, n.Lookup("203.0.113.1"))
require.Equal(t, Lookup{}, n.Lookup(""))
require.Equal(t, Lookup{}, n.Lookup("not-an-ip"))
}
func TestNopService_SatisfiesLookuper(t *testing.T) {
t.Parallel()
var _ Lookuper = NopService()
}
func TestServiceImplementsLookuper(t *testing.T) {
t.Parallel()
var _ Lookuper = (*Service)(nil)
}
func TestOpen_EmptyPathReturnsError(t *testing.T) {
t.Parallel()
s, err := Open("")
require.Error(t, err)
require.Nil(t, s)
}
func TestOpen_NonexistentPathReturnsError(t *testing.T) {
t.Parallel()
missing := filepath.Join(t.TempDir(), "nonexistent.mmdb")
s, err := Open(missing)
require.Error(t, err)
require.Nil(t, s)
}
func TestService_Lookup_NilReceiverReturnsEmpty(t *testing.T) {
t.Parallel()
var s *Service
require.Equal(t, Lookup{}, s.Lookup("203.0.113.1"))
}
func TestService_Lookup_NilReaderReturnsEmpty(t *testing.T) {
t.Parallel()
s := &Service{}
require.Equal(t, Lookup{}, s.Lookup("203.0.113.1"))
}
func TestService_Lookup_EmptyIPReturnsEmpty(t *testing.T) {
t.Parallel()
fake := &fakeCityReader{}
s := newServiceWithFake(fake)
require.Equal(t, Lookup{}, s.Lookup(""))
require.Equal(t, 0, fake.calls,
"empty IP must short-circuit before reader call")
}
func TestService_Lookup_MalformedIPReturnsEmpty(t *testing.T) {
t.Parallel()
fake := &fakeCityReader{}
s := newServiceWithFake(fake)
for _, bad := range []string{"not-an-ip", "999.999.999.999", "::xyz", " "} {
require.Equal(t, Lookup{}, s.Lookup(bad), "input=%q", bad)
}
require.Equal(t, 0, fake.calls,
"malformed IP must short-circuit before reader call")
}
func TestService_Lookup_ReaderErrorReturnsEmpty(t *testing.T) {
t.Parallel()
fake := &fakeCityReader{err: errors.New("boom")}
s := newServiceWithFake(fake)
require.Equal(t, Lookup{}, s.Lookup("203.0.113.1"))
require.Equal(t, 1, fake.calls)
}
func TestService_Lookup_NoDataReturnsEmpty(t *testing.T) {
t.Parallel()
fake := &fakeCityReader{rec: &geoip2.City{}}
s := newServiceWithFake(fake)
require.Equal(t, Lookup{}, s.Lookup("203.0.113.1"))
}
func TestService_Lookup_NilRecordReturnsEmpty(t *testing.T) {
t.Parallel()
fake := &fakeCityReader{rec: nil}
s := newServiceWithFake(fake)
require.Equal(t, Lookup{}, s.Lookup("203.0.113.1"))
}
func TestService_Lookup_PopulatesAllAvailableFields(t *testing.T) {
t.Parallel()
rec := &geoip2.City{}
rec.Country.ISOCode = "US"
rec.Country.Names.English = "United States"
rec.City.Names.English = "Mountain View"
rec.Subdivisions = []geoip2.CitySubdivision{
{Names: geoip2.Names{English: "California"}, ISOCode: "CA"},
}
fake := &fakeCityReader{rec: rec}
s := newServiceWithFake(fake)
got := s.Lookup("203.0.113.1")
require.Equal(t, "US", got.Country)
require.Equal(t, "California", got.Region)
require.Equal(t, "Mountain View", got.City)
require.Equal(t, 0, got.ASN,
"City db carries no ASN; field is reserved for ASN-db extension")
require.Empty(t, got.ASNOrg,
"City db carries no ASNOrg; field is reserved for ASN-db extension")
}
func TestService_Lookup_AcceptsIPv6(t *testing.T) {
t.Parallel()
rec := &geoip2.City{}
rec.Country.ISOCode = "DE"
rec.City.Names.English = "Berlin"
fake := &fakeCityReader{rec: rec}
s := newServiceWithFake(fake)
got := s.Lookup("2001:db8::1")
require.Equal(t, "DE", got.Country)
require.Equal(t, "Berlin", got.City)
}
func TestExtractLookup_NilReturnsEmpty(t *testing.T) {
t.Parallel()
require.Equal(t, Lookup{}, extractLookup(nil))
}
func TestExtractLookup_PicksFirstSubdivisionEnglishName(t *testing.T) {
t.Parallel()
rec := &geoip2.City{}
rec.Subdivisions = []geoip2.CitySubdivision{
{Names: geoip2.Names{English: "England"}, ISOCode: "ENG"},
{Names: geoip2.Names{English: "Oxfordshire"}, ISOCode: "OXF"},
}
require.Equal(t, "England", extractLookup(rec).Region)
}
func TestExtractLookup_FallsBackToISOCodeWhenEnglishMissing(t *testing.T) {
t.Parallel()
rec := &geoip2.City{}
rec.Subdivisions = []geoip2.CitySubdivision{
{ISOCode: "CA"},
}
require.Equal(t, "CA", extractLookup(rec).Region)
}
func TestExtractLookup_NoSubdivisionsLeavesRegionEmpty(t *testing.T) {
t.Parallel()
rec := &geoip2.City{}
rec.Country.ISOCode = "JP"
got := extractLookup(rec)
require.Empty(t, got.Region)
require.Equal(t, "JP", got.Country)
}
func TestService_Close_NilReceiverIsNoOp(t *testing.T) {
t.Parallel()
var s *Service
require.NoError(t, s.Close())
}
func TestService_Close_NilReaderIsNoOp(t *testing.T) {
t.Parallel()
require.NoError(t, (&Service{}).Close())
}
func TestService_Close_DelegatesToReader(t *testing.T) {
t.Parallel()
fake := &fakeCityReader{}
require.NoError(t, newServiceWithFake(fake).Close())
require.Equal(t, 1, fake.closeCalls)
}
func TestService_Close_PropagatesReaderError(t *testing.T) {
t.Parallel()
wantErr := errors.New("close failed")
fake := &fakeCityReader{closeErr: wantErr}
require.ErrorIs(t, newServiceWithFake(fake).Close(), wantErr)
}
func TestFirstSubdivisionName_EmptySliceReturnsEmpty(t *testing.T) {
t.Parallel()
require.Empty(t, firstSubdivisionName(nil))
require.Empty(t, firstSubdivisionName([]geoip2.CitySubdivision{}))
}
func TestFirstSubdivisionName_PrefersEnglishName(t *testing.T) {
t.Parallel()
subs := []geoip2.CitySubdivision{{
Names: geoip2.Names{English: "California"},
ISOCode: "CA",
}}
require.Equal(t, "California", firstSubdivisionName(subs))
}
func TestFirstSubdivisionName_FallsBackToISOCode(t *testing.T) {
t.Parallel()
subs := []geoip2.CitySubdivision{{ISOCode: "CA"}}
require.Equal(t, "CA", firstSubdivisionName(subs))
}
func TestFirstSubdivisionName_SkipsEntirelyEmptyEntries(t *testing.T) {
t.Parallel()
subs := []geoip2.CitySubdivision{
{},
{Names: geoip2.Names{English: "Oxfordshire"}},
}
require.Equal(t, "Oxfordshire", firstSubdivisionName(subs))
}
func TestFirstSubdivisionName_AllEmptyEntriesReturnsEmpty(t *testing.T) {
t.Parallel()
subs := []geoip2.CitySubdivision{{}, {}, {}}
require.Empty(t, firstSubdivisionName(subs))
}

View File

@ -0,0 +1,197 @@
// ©AngelaMos | 2026
// handler.go
package health
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/go-chi/chi/v5"
)
type Checker interface {
Ping(ctx context.Context) error
}
type Handler struct {
db Checker
redis Checker
ready atomic.Bool
shutdown atomic.Bool
}
func NewHandler(db, redis Checker) *Handler {
h := &Handler{
db: db,
redis: redis,
}
h.ready.Store(true)
return h
}
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Get("/healthz", h.Liveness)
r.Get("/livez", h.Liveness)
r.Get("/readyz", h.Readiness)
}
func (h *Handler) Liveness(w http.ResponseWriter, r *http.Request) {
if h.shutdown.Load() {
h.writeStatus(w, http.StatusServiceUnavailable, StatusResponse{
Status: "shutting_down",
})
return
}
h.writeStatus(w, http.StatusOK, StatusResponse{
Status: "ok",
})
}
func (h *Handler) Readiness(w http.ResponseWriter, r *http.Request) {
if h.shutdown.Load() {
h.writeStatus(w, http.StatusServiceUnavailable, StatusResponse{
Status: "shutting_down",
})
return
}
if !h.ready.Load() {
h.writeStatus(w, http.StatusServiceUnavailable, StatusResponse{
Status: "not_ready",
})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
checks := h.runHealthChecks(ctx)
allHealthy := true
for _, check := range checks {
if !check.Healthy {
allHealthy = false
break
}
}
status := "ok"
statusCode := http.StatusOK
if !allHealthy {
status = "degraded"
statusCode = http.StatusServiceUnavailable
}
h.writeStatus(w, statusCode, ReadinessResponse{
Status: status,
Checks: checks,
})
}
func (h *Handler) runHealthChecks(ctx context.Context) []HealthCheck {
var wg sync.WaitGroup
checks := make([]HealthCheck, 2)
wg.Add(2)
go func() {
defer wg.Done()
checks[0] = h.checkDatabase(ctx)
}()
go func() {
defer wg.Done()
checks[1] = h.checkRedis(ctx)
}()
wg.Wait()
return checks
}
func (h *Handler) checkDatabase(ctx context.Context) HealthCheck {
check := HealthCheck{
Name: "database",
Healthy: true,
}
if h.db == nil {
check.Healthy = false
check.Message = "database checker not configured"
return check
}
start := time.Now()
err := h.db.Ping(ctx)
check.Latency = time.Since(start).String()
if err != nil {
check.Healthy = false
check.Message = "ping failed"
}
return check
}
func (h *Handler) checkRedis(ctx context.Context) HealthCheck {
check := HealthCheck{
Name: "redis",
Healthy: true,
}
if h.redis == nil {
check.Healthy = false
check.Message = "redis checker not configured"
return check
}
start := time.Now()
err := h.redis.Ping(ctx)
check.Latency = time.Since(start).String()
if err != nil {
check.Healthy = false
check.Message = "ping failed"
}
return check
}
func (h *Handler) SetReady(ready bool) {
h.ready.Store(ready)
}
func (h *Handler) SetShutdown(shutdown bool) {
h.shutdown.Store(shutdown)
}
func (h *Handler) writeStatus(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(data); err != nil {
slog.Error("failed to encode health response", "error", err)
}
}
type StatusResponse struct {
Status string `json:"status"`
}
type ReadinessResponse struct {
Status string `json:"status"`
Checks []HealthCheck `json:"checks"`
}
type HealthCheck struct {
Name string `json:"name"`
Healthy bool `json:"healthy"`
Latency string `json:"latency,omitempty"`
Message string `json:"message,omitempty"`
}

View File

@ -0,0 +1,26 @@
// ©AngelaMos | 2026
// fingerprint.go
package middleware
import (
"crypto/sha256"
"encoding/hex"
"net/http"
)
const (
fingerprintHexLen = 16
rateLimitKeyByFingerprintPrefix = "ratelimit:fp:"
)
func ExtractFingerprint(r *http.Request) string {
raw := RealIP(r) + "|" + r.UserAgent()
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])[:fingerprintHexLen]
}
func KeyByFingerprint(r *http.Request) string {
return rateLimitKeyByFingerprintPrefix + ExtractFingerprint(r)
}

View File

@ -0,0 +1,187 @@
// ©AngelaMos | 2026
// fingerprint_test.go
package middleware_test
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware"
)
func newRequest(remote string, headers map[string]string) *http.Request {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = remote
for k, v := range headers {
r.Header.Set(k, v)
}
return r
}
func TestRealIP_Precedence(t *testing.T) {
cases := []struct {
name string
headers map[string]string
remote string
want string
}{
{
"CF wins",
map[string]string{
"CF-Connecting-IP": "203.0.113.10",
"X-Forwarded-For": "198.51.100.1",
"X-Real-IP": "192.0.2.99",
},
"127.0.0.1:9",
"203.0.113.10",
},
{
"XFF leftmost no CF",
map[string]string{
"X-Forwarded-For": "198.51.100.1, 198.51.100.7",
"X-Real-IP": "192.0.2.99",
},
"127.0.0.1:9",
"198.51.100.1",
},
{
"XFF trailing comma falls through",
map[string]string{
"X-Forwarded-For": "198.51.100.1, ",
"X-Real-IP": "192.0.2.99",
},
"127.0.0.1:9",
"198.51.100.1",
},
{
"XFF empty entries fall to XRI",
map[string]string{
"X-Forwarded-For": ", ,",
"X-Real-IP": "192.0.2.99",
},
"127.0.0.1:9",
"192.0.2.99",
},
{
"XRI when no CF or XFF",
map[string]string{"X-Real-IP": "192.0.2.99"},
"127.0.0.1:9",
"192.0.2.99",
},
{"RemoteAddr IPv4 strips port", nil, "127.0.0.1:9999", "127.0.0.1"},
{
"RemoteAddr IPv6 strips brackets",
nil,
"[2001:db8::1]:54321",
"2001:db8::1",
},
{"RemoteAddr loopback IPv6", nil, "[::1]:9", "::1"},
{"RemoteAddr no port fallback", nil, "127.0.0.1", "127.0.0.1"},
{
"XFF mixed IPv4+IPv6 leftmost",
map[string]string{
"X-Forwarded-For": "198.51.100.1, 2001:db8::dead",
},
"127.0.0.1:9",
"198.51.100.1",
},
{
"CF trimmed",
map[string]string{"CF-Connecting-IP": " 203.0.113.10 "},
"127.0.0.1:9",
"203.0.113.10",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require.Equal(
t,
tc.want,
middleware.RealIP(newRequest(tc.remote, tc.headers)),
)
})
}
}
func TestRealIP_UntrustedRemoteIgnoresClientHeaders(t *testing.T) {
require.NoError(t, middleware.SetTrustedProxyCIDRs(
[]string{"127.0.0.1/32", "::1/128"},
))
t.Cleanup(middleware.ClearTrustedProxyCIDRs)
r := newRequest("198.51.100.50:443", map[string]string{
"CF-Connecting-IP": "evil-spoofed",
"X-Forwarded-For": "evil-spoofed",
"X-Real-IP": "evil-spoofed",
})
require.Equal(t, "198.51.100.50", middleware.RealIP(r),
"requests from outside the trusted-proxy set must ignore "+
"client-supplied forwarding headers (audit F5)")
}
func TestRealIP_TrustedRemoteHonorsClientHeaders(t *testing.T) {
require.NoError(t, middleware.SetTrustedProxyCIDRs(
[]string{"127.0.0.1/32"},
))
t.Cleanup(middleware.ClearTrustedProxyCIDRs)
r := newRequest("127.0.0.1:443", map[string]string{
"CF-Connecting-IP": "203.0.113.10",
})
require.Equal(t, "203.0.113.10", middleware.RealIP(r))
}
func TestOptionalHeader(t *testing.T) {
require.Nil(t, middleware.OptionalHeader(""))
require.Nil(t, middleware.OptionalHeader(" "))
v := middleware.OptionalHeader(" hello ")
require.NotNil(t, v)
require.Equal(t, "hello", *v)
}
func TestExtractFingerprint_DeterministicAndLength(t *testing.T) {
r1 := newRequest("127.0.0.1:9", map[string]string{
"CF-Connecting-IP": "203.0.113.10",
"User-Agent": "Mozilla/5.0",
})
r2 := newRequest("10.0.0.1:9", map[string]string{
"CF-Connecting-IP": "203.0.113.10",
"User-Agent": "Mozilla/5.0",
})
fp1 := middleware.ExtractFingerprint(r1)
fp2 := middleware.ExtractFingerprint(r2)
require.Equal(t, fp1, fp2, "same realIP + UA must yield same fingerprint")
require.Len(t, fp1, 16)
require.Regexp(t, `^[0-9a-f]{16}$`, fp1)
}
func TestExtractFingerprint_DifferentIPsDifferentFingerprints(t *testing.T) {
a := middleware.ExtractFingerprint(newRequest(
"127.0.0.1:9",
map[string]string{
"CF-Connecting-IP": "203.0.113.1",
"User-Agent": "X",
},
))
b := middleware.ExtractFingerprint(newRequest(
"127.0.0.1:9",
map[string]string{
"CF-Connecting-IP": "203.0.113.2",
"User-Agent": "X",
},
))
require.NotEqual(t, a, b)
}
func TestKeyByFingerprint_HasPrefix(t *testing.T) {
key := middleware.KeyByFingerprint(newRequest("127.0.0.1:9", nil))
require.True(t, strings.HasPrefix(key, "ratelimit:fp:"))
require.Len(t, key, len("ratelimit:fp:")+16)
}

View File

@ -0,0 +1,104 @@
// ©AngelaMos | 2026
// headers.go
package middleware
import (
"net/http"
"strconv"
"strings"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/config"
)
func SecurityHeaders(isProduction bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("X-XSS-Protection", "1; mode=block")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set(
"Permissions-Policy",
"geolocation=(), microphone=(), camera=()",
)
if isProduction {
h.Set(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
)
}
h.Set("Content-Security-Policy", buildCSP(isProduction))
next.ServeHTTP(w, r)
})
}
}
func buildCSP(isProduction bool) string {
directives := []string{
"default-src 'self'",
"script-src 'self' https://challenges.cloudflare.com",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' https://challenges.cloudflare.com",
"frame-src https://challenges.cloudflare.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
}
if !isProduction {
directives[1] = "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com"
}
return strings.Join(directives, "; ")
}
func CORS(cfg config.CORSConfig) func(http.Handler) http.Handler {
allowedOrigins := make(map[string]struct{}, len(cfg.AllowedOrigins))
for _, origin := range cfg.AllowedOrigins {
allowedOrigins[origin] = struct{}{}
}
methodsStr := strings.Join(cfg.AllowedMethods, ", ")
headersStr := strings.Join(cfg.AllowedHeaders, ", ")
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" {
if _, ok := allowedOrigins[origin]; ok {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
if cfg.AllowCredentials {
w.Header().
Set("Access-Control-Allow-Credentials", "true")
}
}
}
if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Allow-Methods", methodsStr)
w.Header().Set("Access-Control-Allow-Headers", headersStr)
if cfg.MaxAge > 0 {
w.Header().
Set("Access-Control-Max-Age", strconv.Itoa(cfg.MaxAge))
}
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@ -0,0 +1,99 @@
// ©AngelaMos | 2026
// logging.go
package middleware
import (
"context"
"log/slog"
"net/http"
"time"
"go.opentelemetry.io/otel/trace"
)
type loggerKey struct{}
func Logger(baseLogger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
requestID := GetRequestID(r.Context())
reqLogger := baseLogger.With(
slog.String("request_id", requestID),
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.String("remote_addr", r.RemoteAddr),
)
if span := trace.SpanFromContext(r.Context()); span.SpanContext().
IsValid() {
reqLogger = reqLogger.With(
slog.String(
"trace_id",
span.SpanContext().TraceID().String(),
),
slog.String(
"span_id",
span.SpanContext().SpanID().String(),
),
)
}
ctx := context.WithValue(r.Context(), loggerKey{}, reqLogger)
ww := &responseWriter{
ResponseWriter: w,
status: http.StatusOK,
}
next.ServeHTTP(ww, r.WithContext(ctx))
latency := time.Since(start)
logLevel := slog.LevelInfo
if ww.status >= 500 {
logLevel = slog.LevelError
} else if ww.status >= 400 {
logLevel = slog.LevelWarn
}
reqLogger.Log(r.Context(), logLevel, "request completed",
slog.Int("status", ww.status),
slog.Int("bytes", ww.bytes),
slog.Duration("latency", latency),
slog.String("user_agent", r.UserAgent()),
)
})
}
}
func GetLogger(ctx context.Context) *slog.Logger {
if logger, ok := ctx.Value(loggerKey{}).(*slog.Logger); ok {
return logger
}
return slog.Default()
}
type responseWriter struct {
http.ResponseWriter
status int
bytes int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.status = code
rw.ResponseWriter.WriteHeader(code)
}
func (rw *responseWriter) Write(b []byte) (int, error) {
n, err := rw.ResponseWriter.Write(b)
rw.bytes += n
return n, err
}
func (rw *responseWriter) Unwrap() http.ResponseWriter {
return rw.ResponseWriter
}

View File

@ -0,0 +1,35 @@
// ©AngelaMos | 2026
// operator_bearer.go
package middleware
import (
"crypto/subtle"
"net/http"
)
const bearerPrefix = "Bearer "
func OperatorBearer(token string) func(http.Handler) http.Handler {
expected := []byte(token)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if len(expected) == 0 {
http.NotFound(w, r)
return
}
auth := r.Header.Get("Authorization")
if len(auth) <= len(bearerPrefix) ||
auth[:len(bearerPrefix)] != bearerPrefix {
http.NotFound(w, r)
return
}
supplied := []byte(auth[len(bearerPrefix):])
if subtle.ConstantTimeCompare(supplied, expected) != 1 {
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@ -0,0 +1,185 @@
// ©AngelaMos | 2026
// operator_bearer_test.go
package middleware_test
import (
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware"
)
const (
opTokenCorrect = "s3cr3t-op-token-xyz"
opTokenWrong = "s3cr3t-op-token-XYZ"
)
func opNextHandler(calls *atomic.Int32) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusTeapot)
if _, err := w.Write([]byte("ok")); err != nil {
return
}
})
}
func TestOperatorBearer_TableDriven(t *testing.T) {
tests := []struct {
name string
configuredTok string
authHeader string
setHeader bool
wantStatus int
wantBodyPrefix string
wantNextCalled bool
}{
{
name: "missing Authorization header returns 404",
configuredTok: opTokenCorrect,
setHeader: false,
wantStatus: http.StatusNotFound,
wantBodyPrefix: "404",
wantNextCalled: false,
},
{
name: "empty Authorization header returns 404",
configuredTok: opTokenCorrect,
authHeader: "",
setHeader: true,
wantStatus: http.StatusNotFound,
wantNextCalled: false,
},
{
name: "wrong scheme Basic returns 404",
configuredTok: opTokenCorrect,
authHeader: "Basic " + opTokenCorrect,
setHeader: true,
wantStatus: http.StatusNotFound,
wantNextCalled: false,
},
{
name: "lowercase bearer returns 404 (scheme is case-sensitive)",
configuredTok: opTokenCorrect,
authHeader: "bearer " + opTokenCorrect,
setHeader: true,
wantStatus: http.StatusNotFound,
wantNextCalled: false,
},
{
name: "Bearer prefix with no token returns 404",
configuredTok: opTokenCorrect,
authHeader: "Bearer ",
setHeader: true,
wantStatus: http.StatusNotFound,
wantNextCalled: false,
},
{
name: "Bearer with no trailing space returns 404",
configuredTok: opTokenCorrect,
authHeader: "Bearer" + opTokenCorrect,
setHeader: true,
wantStatus: http.StatusNotFound,
wantNextCalled: false,
},
{
name: "wrong token of equal length returns 404",
configuredTok: opTokenCorrect,
authHeader: "Bearer " + opTokenWrong,
setHeader: true,
wantStatus: http.StatusNotFound,
wantNextCalled: false,
},
{
name: "wrong token of different length returns 404",
configuredTok: opTokenCorrect,
authHeader: "Bearer nope",
setHeader: true,
wantStatus: http.StatusNotFound,
wantNextCalled: false,
},
{
name: "correct token passes through to next",
configuredTok: opTokenCorrect,
authHeader: "Bearer " + opTokenCorrect,
setHeader: true,
wantStatus: http.StatusTeapot,
wantBodyPrefix: "ok",
wantNextCalled: true,
},
{
name: "empty configured token rejects all requests including matching empty",
configuredTok: "",
authHeader: "Bearer ",
setHeader: true,
wantStatus: http.StatusNotFound,
wantNextCalled: false,
},
{
name: "empty configured token rejects even a Bearer-with-payload",
configuredTok: "",
authHeader: "Bearer anything",
setHeader: true,
wantStatus: http.StatusNotFound,
wantNextCalled: false,
},
{
name: "extra whitespace inside token is not trimmed",
configuredTok: opTokenCorrect,
authHeader: "Bearer " + opTokenCorrect,
setHeader: true,
wantStatus: http.StatusNotFound,
wantNextCalled: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var calls atomic.Int32
h := middleware.OperatorBearer(
tc.configuredTok,
)(
opNextHandler(&calls),
)
r := httptest.NewRequest(http.MethodGet, "/admin/x", nil)
if tc.setHeader {
r.Header.Set("Authorization", tc.authHeader)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
require.Equal(t, tc.wantStatus, w.Code)
if tc.wantNextCalled {
require.Equal(t, int32(1), calls.Load())
} else {
require.Equal(t, int32(0), calls.Load(),
"next handler must not be invoked on auth failure")
}
if tc.wantBodyPrefix != "" {
require.Contains(t, w.Body.String(), tc.wantBodyPrefix)
}
})
}
}
func TestOperatorBearer_NoWWWAuthenticateHeader(t *testing.T) {
var calls atomic.Int32
h := middleware.OperatorBearer(opTokenCorrect)(opNextHandler(&calls))
r := httptest.NewRequest(http.MethodGet, "/admin/x", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
require.Equal(t, http.StatusNotFound, w.Code)
require.Empty(
t,
w.Header().Get("WWW-Authenticate"),
"404 hides endpoint existence; WWW-Authenticate would leak the auth scheme",
)
}

View File

@ -0,0 +1,257 @@
// ©AngelaMos | 2026
// ratelimit.go
package middleware
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"sync"
"sync/atomic"
"time"
redis_rate "github.com/go-redis/redis_rate/v10"
"github.com/redis/go-redis/v9"
"golang.org/x/time/rate"
)
type RateLimitConfig struct {
Limit redis_rate.Limit
KeyFunc func(*http.Request) string
FailOpen bool
BypassFunc func(*http.Request) bool
OnLimited func(http.ResponseWriter, *http.Request, *redis_rate.Result)
}
type RateLimiter struct {
limiter *redis_rate.Limiter
fallback *localLimiter
config RateLimitConfig
}
func NewRateLimiter(rdb *redis.Client, cfg RateLimitConfig) *RateLimiter {
if cfg.KeyFunc == nil {
cfg.KeyFunc = KeyByIP
}
return &RateLimiter{
limiter: redis_rate.NewLimiter(rdb),
fallback: newLocalLimiter(),
config: cfg,
}
}
func (rl *RateLimiter) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if rl.config.BypassFunc != nil && rl.config.BypassFunc(r) {
next.ServeHTTP(w, r)
return
}
key := rl.config.KeyFunc(r)
res, err := rl.allow(r.Context(), key)
if err != nil {
if rl.config.FailOpen {
slog.Warn("rate limiter error, failing open",
"error", err,
"key", key,
)
next.ServeHTTP(w, r)
return
}
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
return
}
setRateLimitHeaders(w, res, rl.config.Limit)
if res.Allowed == 0 {
if rl.config.OnLimited != nil {
rl.config.OnLimited(w, r, res)
return
}
writeRateLimitExceeded(w, res)
return
}
next.ServeHTTP(w, r)
})
}
func (rl *RateLimiter) allow(
ctx context.Context,
key string,
) (*redis_rate.Result, error) {
res, err := rl.limiter.Allow(ctx, key, rl.config.Limit)
if err != nil {
return rl.fallback.allow(key, rl.config.Limit)
}
return res, nil
}
func KeyByIP(r *http.Request) string {
return "ratelimit:ip:" + RealIP(r)
}
func setRateLimitHeaders(
w http.ResponseWriter,
res *redis_rate.Result,
limit redis_rate.Limit,
) {
h := w.Header()
h.Set("X-RateLimit-Limit", strconv.Itoa(limit.Rate))
h.Set("X-RateLimit-Remaining", strconv.Itoa(res.Remaining))
h.Set("X-RateLimit-Reset", strconv.FormatInt(
time.Now().Add(res.ResetAfter).Unix(), 10))
windowSecs := int(limit.Period.Seconds())
h.Set("RateLimit-Policy", fmt.Sprintf(`%d;w=%d`, limit.Rate, windowSecs))
h.Set(
"RateLimit",
fmt.Sprintf(`%d;t=%d`, res.Remaining, int(res.ResetAfter.Seconds())),
)
}
func writeRateLimitExceeded(w http.ResponseWriter, res *redis_rate.Result) {
retryAfter := int(res.RetryAfter.Seconds())
if retryAfter < 1 {
retryAfter = 1
}
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
response := map[string]any{
"success": false,
"error": map[string]any{
"code": "RATE_LIMITED",
"message": fmt.Sprintf(
"Rate limit exceeded. Retry after %d seconds.",
retryAfter,
),
},
}
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode rate-limit response", "error", err)
}
}
type limiterEntry struct {
limiter *rate.Limiter
lastAccess atomic.Int64
}
type localLimiter struct {
limiters sync.Map
}
const (
cleanupInterval = 5 * time.Minute
entryTTL = 10 * time.Minute
)
func newLocalLimiter() *localLimiter {
l := &localLimiter{}
go l.cleanup()
return l
}
func (l *localLimiter) cleanup() {
ticker := time.NewTicker(cleanupInterval)
defer ticker.Stop()
for range ticker.C {
cutoff := time.Now().Add(-entryTTL).Unix()
l.limiters.Range(func(key, value any) bool {
entry, ok := value.(*limiterEntry)
if ok && entry.lastAccess.Load() < cutoff {
l.limiters.Delete(key)
}
return true
})
}
}
func (l *localLimiter) allow(
key string,
limit redis_rate.Limit,
) (*redis_rate.Result, error) {
ratePerSec := float64(limit.Rate) / limit.Period.Seconds()
now := time.Now().Unix()
entryI, loaded := l.limiters.Load(key)
if !loaded {
newEntry := &limiterEntry{
limiter: rate.NewLimiter(
rate.Limit(ratePerSec),
limit.Burst,
),
}
newEntry.lastAccess.Store(now)
entryI, _ = l.limiters.LoadOrStore(key, newEntry)
}
entry, ok := entryI.(*limiterEntry)
if !ok {
return nil, fmt.Errorf("invalid limiter entry type")
}
entry.lastAccess.Store(now)
allowed := entry.limiter.Allow()
remaining := int(entry.limiter.Tokens())
if remaining < 0 {
remaining = 0
}
var retryAfter time.Duration
if !allowed {
retryAfter = time.Duration(float64(time.Second) / ratePerSec)
} else {
retryAfter = -1
}
allowedInt := 0
if allowed {
allowedInt = 1
}
return &redis_rate.Result{
Limit: limit,
Allowed: allowedInt,
Remaining: remaining,
RetryAfter: retryAfter,
ResetAfter: time.Duration(float64(time.Second) / ratePerSec),
}, nil
}
func PerMinute(rate, burst int) redis_rate.Limit {
return redis_rate.Limit{
Rate: rate,
Burst: burst,
Period: time.Minute,
}
}
func PerSecond(rate, burst int) redis_rate.Limit {
return redis_rate.Limit{
Rate: rate,
Burst: burst,
Period: time.Second,
}
}
func PerHour(rate, burst int) redis_rate.Limit {
return redis_rate.Limit{
Rate: rate,
Burst: burst,
Period: time.Hour,
}
}

View File

@ -0,0 +1,108 @@
// ©AngelaMos | 2026
// realip.go
package middleware
import (
"fmt"
"net"
"net/http"
"strings"
"sync/atomic"
)
const (
headerCFConnectingIP = "CF-Connecting-IP"
headerXForwardedFor = "X-Forwarded-For"
headerXRealIP = "X-Real-IP"
)
type proxyTrustState struct {
cidrs []*net.IPNet
}
var trustedProxies atomic.Pointer[proxyTrustState]
func SetTrustedProxyCIDRs(cidrs []string) error {
parsed := make([]*net.IPNet, 0, len(cidrs))
for _, c := range cidrs {
trimmed := strings.TrimSpace(c)
if trimmed == "" {
continue
}
_, n, err := net.ParseCIDR(trimmed)
if err != nil {
return fmt.Errorf("trusted proxy cidr %q: %w", c, err)
}
parsed = append(parsed, n)
}
trustedProxies.Store(&proxyTrustState{cidrs: parsed})
return nil
}
func ClearTrustedProxyCIDRs() {
trustedProxies.Store(nil)
}
func isTrustedProxy(remoteAddr string) bool {
state := trustedProxies.Load()
if state == nil {
return true
}
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
host = remoteAddr
}
ip := net.ParseIP(host)
if ip == nil {
return false
}
for _, n := range state.cidrs {
if n.Contains(ip) {
return true
}
}
return false
}
func RealIP(r *http.Request) string {
if isTrustedProxy(r.RemoteAddr) {
if v := strings.TrimSpace(
r.Header.Get(headerCFConnectingIP),
); v != "" {
return v
}
if v := firstNonEmptyXFF(
r.Header.Get(headerXForwardedFor),
); v != "" {
return v
}
if v := strings.TrimSpace(r.Header.Get(headerXRealIP)); v != "" {
return v
}
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
func OptionalHeader(v string) *string {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
return &v
}
func firstNonEmptyXFF(header string) string {
if header == "" {
return ""
}
for _, p := range strings.Split(header, ",") {
if v := strings.TrimSpace(p); v != "" {
return v
}
}
return ""
}

View File

@ -0,0 +1,46 @@
// ©AngelaMos | 2026
// recovery.go
package middleware
import (
"log/slog"
"net/http"
"runtime/debug"
)
const (
recoveryContentType = "application/json"
recoveryBody = `{"success":false,"error":{"code":"INTERNAL_ERROR","message":"internal server error"}}`
)
func Recovery(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
rec := recover()
if rec == nil {
return
}
requestID := ""
if rid, ok := r.Context().Value(RequestIDKey).(string); ok {
requestID = rid
}
logger.ErrorContext(r.Context(), "handler panic",
"panic", rec,
"request_id", requestID,
"path", r.URL.Path,
"method", r.Method,
"stack", string(debug.Stack()),
)
w.Header().Set("Content-Type", recoveryContentType)
w.WriteHeader(http.StatusInternalServerError)
if _, err := w.Write([]byte(recoveryBody)); err != nil {
logger.WarnContext(r.Context(), "recovery write",
"error", err)
}
}()
next.ServeHTTP(w, r)
})
}
}

View File

@ -0,0 +1,84 @@
// ©AngelaMos | 2026
// recovery_test.go
package middleware_test
import (
"bytes"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware"
)
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func TestRecovery_PanicReturns500(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, nil))
h := middleware.Recovery(
logger,
)(
http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
panic("boom")
}),
)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/x", nil)
h.ServeHTTP(w, r)
require.Equal(t, http.StatusInternalServerError, w.Code)
require.Contains(t, w.Body.String(), `"INTERNAL_ERROR"`)
require.Contains(t, buf.String(), "handler panic")
require.Contains(t, buf.String(), "boom")
}
func TestRecovery_NoPanicPassesThrough(t *testing.T) {
h := middleware.Recovery(
quietLogger(),
)(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusTeapot)
if _, err := w.Write([]byte("ok")); err != nil {
t.Fatalf("write: %v", err)
}
}),
)
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
require.Equal(t, http.StatusTeapot, w.Code)
require.Equal(t, "ok", w.Body.String())
}
func TestRecovery_LogsRequestID(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, nil))
chain := middleware.RequestID(
middleware.Recovery(
logger,
)(
http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
panic("boom")
}),
),
)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/x", nil)
r.Header.Set("X-Request-ID", "test-rid-123")
chain.ServeHTTP(w, r)
require.Contains(t, buf.String(), "test-rid-123")
}

View File

@ -0,0 +1,40 @@
// ©AngelaMos | 2026
// request_id.go
package middleware
import (
"context"
"net/http"
"github.com/google/uuid"
)
type contextKey string
const RequestIDKey contextKey = "request_id"
const RequestIDHeader = "X-Request-ID"
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := r.Header.Get(RequestIDHeader)
if requestID == "" {
requestID = uuid.New().String()
}
ctx := context.WithValue(r.Context(), RequestIDKey, requestID)
w.Header().Set(RequestIDHeader, requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func GetRequestID(ctx context.Context) string {
if id, ok := ctx.Value(RequestIDKey).(string); ok {
return id
}
return ""
}

View File

@ -0,0 +1,67 @@
// ©AngelaMos | 2026
// turnstile.go
package middleware
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
)
const (
turnstileHeaderName = "CF-Turnstile-Response"
turnstileBodyFieldName = "cf_turnstile_response"
turnstileMaxBodyBytes = 1 * 1024 * 1024
turnstileErrorContentType = "application/json"
turnstileErrorBody = `{"success":false,"error":{"code":"TURNSTILE_FAILED","message":"turnstile verification failed"}}`
)
type TurnstileVerifier interface {
Verify(ctx context.Context, token, fingerprint string) error
}
func TurnstileVerify(v TurnstileVerifier) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenStr := extractTurnstileToken(r)
fp := ExtractFingerprint(r)
if err := v.Verify(r.Context(), tokenStr, fp); err != nil {
w.Header().Set("Content-Type", turnstileErrorContentType)
w.WriteHeader(http.StatusBadRequest)
if _, wErr := w.Write([]byte(turnstileErrorBody)); wErr != nil {
return
}
return
}
next.ServeHTTP(w, r)
})
}
}
func extractTurnstileToken(r *http.Request) string {
if v := r.Header.Get(turnstileHeaderName); v != "" {
return v
}
if r.Body == nil {
return ""
}
limited := io.LimitReader(r.Body, turnstileMaxBodyBytes)
body, err := io.ReadAll(limited)
if err != nil {
return ""
}
r.Body = io.NopCloser(bytes.NewReader(body))
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
return ""
}
if s, ok := m[turnstileBodyFieldName].(string); ok {
return s
}
return ""
}

View File

@ -0,0 +1,125 @@
// ©AngelaMos | 2026
// turnstile_test.go
package middleware_test
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware"
)
type fakeVerifier struct {
calls atomic.Int32
lastToken atomic.Value
lastFP atomic.Value
returnError error
}
func (f *fakeVerifier) Verify(_ context.Context, token, fp string) error {
f.calls.Add(1)
f.lastToken.Store(token)
f.lastFP.Store(fp)
return f.returnError
}
func okHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
if _, err := w.Write(body); err != nil {
return
}
})
}
func TestTurnstileVerify_HeaderTokenPasses(t *testing.T) {
v := &fakeVerifier{}
h := middleware.TurnstileVerify(v)(okHandler())
r := httptest.NewRequest(http.MethodPost, "/", nil)
r.Header.Set("CF-Turnstile-Response", "header-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, int32(1), v.calls.Load())
require.Equal(t, "header-token", v.lastToken.Load())
}
func TestTurnstileVerify_BodyTokenPasses(t *testing.T) {
v := &fakeVerifier{}
h := middleware.TurnstileVerify(v)(okHandler())
r := httptest.NewRequest(http.MethodPost, "/",
strings.NewReader(`{"cf_turnstile_response":"body-token","other":1}`))
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, "body-token", v.lastToken.Load())
}
func TestTurnstileVerify_BodyPreservedForDownstream(t *testing.T) {
v := &fakeVerifier{}
h := middleware.TurnstileVerify(v)(okHandler())
body := `{"cf_turnstile_response":"t","foo":"bar"}`
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
require.Equal(t, body, w.Body.String(),
"downstream handler must still read the original body")
}
func TestTurnstileVerify_FailureReturns400(t *testing.T) {
v := &fakeVerifier{returnError: errors.New("bad")}
h := middleware.TurnstileVerify(v)(okHandler())
r := httptest.NewRequest(http.MethodPost, "/", nil)
r.Header.Set("CF-Turnstile-Response", "tok")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
require.Equal(t, http.StatusBadRequest, w.Code)
require.Contains(t, w.Body.String(), `"TURNSTILE_FAILED"`)
}
func TestTurnstileVerify_HeaderWinsOverBody(t *testing.T) {
v := &fakeVerifier{}
h := middleware.TurnstileVerify(v)(okHandler())
r := httptest.NewRequest(http.MethodPost, "/",
strings.NewReader(`{"cf_turnstile_response":"body-token"}`))
r.Header.Set("CF-Turnstile-Response", "header-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
require.Equal(t, "header-token", v.lastToken.Load())
}
func TestTurnstileVerify_NoTokenStillCallsVerifier(t *testing.T) {
v := &fakeVerifier{}
h := middleware.TurnstileVerify(v)(okHandler())
r := httptest.NewRequest(http.MethodPost, "/", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
require.Equal(t, int32(1), v.calls.Load())
require.Empty(t, v.lastToken.Load())
}

View File

@ -0,0 +1,195 @@
// ©AngelaMos | 2026
// service.go
package notify
import (
"context"
"log/slog"
"sync"
"time"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
)
const (
defaultSendTimeout = 30 * time.Second
defaultWorkers = 8
defaultQueueSize = 256
)
type Service struct {
senders map[string]Sender
status StatusWriter
logger *slog.Logger
sendTimeout time.Duration
workers int
queue chan dispatchJob
workerWg sync.WaitGroup
jobWg sync.WaitGroup
closeOnce sync.Once
}
type dispatchJob struct {
info event.NotifyInfo
evt *event.Event
}
type Option func(*Service)
func WithLogger(l *slog.Logger) Option {
return func(s *Service) { s.logger = l }
}
func WithSendTimeout(d time.Duration) Option {
return func(s *Service) { s.sendTimeout = d }
}
func WithMaxConcurrent(n int) Option {
return func(s *Service) {
if n > 0 {
s.workers = n
}
}
}
func WithQueueSize(n int) Option {
return func(s *Service) {
if n > 0 {
s.queue = make(chan dispatchJob, n)
}
}
}
func NewService(status StatusWriter, opts ...Option) *Service {
s := &Service{
senders: make(map[string]Sender),
status: status,
logger: slog.Default(),
sendTimeout: defaultSendTimeout,
workers: defaultWorkers,
}
for _, o := range opts {
o(s)
}
if s.queue == nil {
s.queue = make(chan dispatchJob, defaultQueueSize)
}
for range s.workers {
s.workerWg.Add(1)
go s.worker()
}
return s
}
func (s *Service) Register(senders ...Sender) {
for _, sender := range senders {
if sender == nil {
continue
}
s.senders[sender.Channel()] = sender
}
}
func (s *Service) Notify(info event.NotifyInfo, evt *event.Event) {
s.jobWg.Add(1)
select {
case s.queue <- dispatchJob{info: info, evt: evt}:
default:
s.jobWg.Done()
s.logger.Warn("notify: queue full, dropping",
"event_id", evt.ID,
"token_id", info.TokenID,
"channel", info.AlertChannel,
)
s.markStatus(
context.Background(),
evt.ID,
event.NotifyFailed,
nil,
)
}
}
func (s *Service) Wait() {
s.jobWg.Wait()
}
func (s *Service) Shutdown(ctx context.Context) error {
s.closeOnce.Do(func() { close(s.queue) })
done := make(chan struct{})
go func() {
s.workerWg.Wait()
close(done)
}()
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Service) worker() {
defer s.workerWg.Done()
for job := range s.queue {
s.dispatch(job.info, job.evt)
s.jobWg.Done()
}
}
func (s *Service) dispatch(info event.NotifyInfo, evt *event.Event) {
ctx, cancel := context.WithTimeout(
context.Background(),
s.sendTimeout,
)
defer cancel()
sender, ok := s.senders[info.AlertChannel]
if !ok {
s.logger.WarnContext(ctx, "notify: no sender registered",
"channel", info.AlertChannel,
"event_id", evt.ID,
"token_id", info.TokenID,
)
s.markStatus(ctx, evt.ID, event.NotifyFailed, nil)
return
}
if err := sender.Send(ctx, info, evt); err != nil {
s.logger.WarnContext(ctx, "notify: send failed",
"channel", info.AlertChannel,
"event_id", evt.ID,
"token_id", info.TokenID,
"error", err,
)
s.markStatus(ctx, evt.ID, event.NotifyFailed, nil)
return
}
now := time.Now().UTC()
s.markStatus(ctx, evt.ID, event.NotifySent, &now)
}
func (s *Service) markStatus(
ctx context.Context,
eventID int64,
status event.NotifyStatus,
sentAt *time.Time,
) {
if s.status == nil {
return
}
if err := s.status.UpdateNotifyStatus(
ctx,
eventID,
status,
sentAt,
); err != nil {
s.logger.WarnContext(ctx, "notify: status writeback failed",
"event_id", eventID,
"status", status,
"error", err,
)
}
}

View File

@ -0,0 +1,256 @@
// ©AngelaMos | 2026
// service_test.go
package notify_test
import (
"context"
"errors"
"log/slog"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/notify"
)
type fakeSender struct {
channel string
calls atomic.Int32
returnErr error
lastInfo atomic.Value
delay time.Duration
respectCtx bool
}
func (f *fakeSender) Channel() string { return f.channel }
func (f *fakeSender) Send(
ctx context.Context,
info event.NotifyInfo,
_ *event.Event,
) error {
f.calls.Add(1)
f.lastInfo.Store(info)
if f.delay > 0 {
if f.respectCtx {
select {
case <-time.After(f.delay):
case <-ctx.Done():
return ctx.Err()
}
} else {
time.Sleep(f.delay)
}
}
return f.returnErr
}
type fakeStatusWriter struct {
mu sync.Mutex
updates []statusUpdate
err error
}
type statusUpdate struct {
eventID int64
status event.NotifyStatus
sentAt *time.Time
}
func (f *fakeStatusWriter) UpdateNotifyStatus(
_ context.Context,
id int64,
status event.NotifyStatus,
sentAt *time.Time,
) error {
f.mu.Lock()
defer f.mu.Unlock()
f.updates = append(
f.updates,
statusUpdate{eventID: id, status: status, sentAt: sentAt},
)
return f.err
}
func (f *fakeStatusWriter) snapshot() []statusUpdate {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]statusUpdate, len(f.updates))
copy(out, f.updates)
return out
}
func sampleEvent(id int64) *event.Event {
return &event.Event{
ID: id,
TokenID: "tokfoo000001",
TriggeredAt: time.Now().UTC(),
SourceIP: "203.0.113.1",
}
}
func sampleInfo(channel string) event.NotifyInfo {
return event.NotifyInfo{
TokenID: "tokfoo000001",
ManageID: "abcd",
Type: "webbug",
Memo: "test",
AlertChannel: channel,
TelegramBot: "bot",
TelegramChat: "chat",
WebhookURL: "https://example.com/h",
}
}
func newService(
t *testing.T,
status notify.StatusWriter,
senders ...notify.Sender,
) *notify.Service {
t.Helper()
svc := notify.NewService(status,
notify.WithLogger(slog.New(slog.NewTextHandler(testWriter{t}, nil))),
notify.WithSendTimeout(2*time.Second),
)
svc.Register(senders...)
return svc
}
type testWriter struct{ t *testing.T }
func (w testWriter) Write(
p []byte,
) (int, error) {
w.t.Log(string(p))
return len(p), nil
}
func TestService_RoutesByChannel(t *testing.T) {
t.Parallel()
tg := &fakeSender{channel: "telegram"}
wh := &fakeSender{channel: "webhook"}
status := &fakeStatusWriter{}
svc := newService(t, status, tg, wh)
svc.Notify(sampleInfo("telegram"), sampleEvent(1))
svc.Notify(sampleInfo("webhook"), sampleEvent(2))
svc.Wait()
require.Equal(t, int32(1), tg.calls.Load())
require.Equal(t, int32(1), wh.calls.Load())
}
func TestService_MarksSentOnSuccess(t *testing.T) {
t.Parallel()
tg := &fakeSender{channel: "telegram"}
status := &fakeStatusWriter{}
svc := newService(t, status, tg)
svc.Notify(sampleInfo("telegram"), sampleEvent(1))
svc.Wait()
updates := status.snapshot()
require.Len(t, updates, 1)
require.Equal(t, int64(1), updates[0].eventID)
require.Equal(t, event.NotifySent, updates[0].status)
require.NotNil(t, updates[0].sentAt)
}
func TestService_MarksFailedOnSenderError(t *testing.T) {
t.Parallel()
tg := &fakeSender{channel: "telegram", returnErr: errors.New("api blew up")}
status := &fakeStatusWriter{}
svc := newService(t, status, tg)
svc.Notify(sampleInfo("telegram"), sampleEvent(7))
svc.Wait()
updates := status.snapshot()
require.Len(t, updates, 1)
require.Equal(t, int64(7), updates[0].eventID)
require.Equal(t, event.NotifyFailed, updates[0].status)
require.Nil(t, updates[0].sentAt)
}
func TestService_MarksFailedWhenChannelUnknown(t *testing.T) {
t.Parallel()
tg := &fakeSender{channel: "telegram"}
status := &fakeStatusWriter{}
svc := newService(t, status, tg)
svc.Notify(sampleInfo("smoke-signal"), sampleEvent(99))
svc.Wait()
require.Equal(t, int32(0), tg.calls.Load())
updates := status.snapshot()
require.Len(t, updates, 1)
require.Equal(t, event.NotifyFailed, updates[0].status)
}
func TestService_NotifyIsAsync(t *testing.T) {
t.Parallel()
tg := &fakeSender{channel: "telegram", delay: 100 * time.Millisecond}
status := &fakeStatusWriter{}
svc := newService(t, status, tg)
start := time.Now()
svc.Notify(sampleInfo("telegram"), sampleEvent(1))
require.Less(t, time.Since(start), 50*time.Millisecond,
"Notify should return immediately, not wait for the send")
svc.Wait()
require.Equal(t, int32(1), tg.calls.Load())
}
func TestService_DispatchTimeoutBoundsSender(t *testing.T) {
t.Parallel()
tg := &fakeSender{
channel: "telegram",
delay: 2 * time.Second,
respectCtx: true,
}
status := &fakeStatusWriter{}
svc := notify.NewService(status,
notify.WithSendTimeout(50*time.Millisecond),
)
svc.Register(tg)
svc.Notify(sampleInfo("telegram"), sampleEvent(1))
svc.Wait()
updates := status.snapshot()
require.Len(t, updates, 1)
require.Equal(t, event.NotifyFailed, updates[0].status,
"timeout should mark event failed")
}
func TestService_StatusWriterErrorDoesNotPanic(t *testing.T) {
t.Parallel()
tg := &fakeSender{channel: "telegram"}
status := &fakeStatusWriter{err: errors.New("db down")}
svc := newService(t, status, tg)
require.NotPanics(t, func() {
svc.Notify(sampleInfo("telegram"), sampleEvent(1))
svc.Wait()
})
}
func TestService_ConcurrentNotifyAllComplete(t *testing.T) {
t.Parallel()
tg := &fakeSender{channel: "telegram", delay: 10 * time.Millisecond}
status := &fakeStatusWriter{}
svc := newService(t, status, tg)
const n = 50
for i := range n {
svc.Notify(sampleInfo("telegram"), sampleEvent(int64(i+1)))
}
svc.Wait()
require.Equal(t, int32(n), tg.calls.Load())
require.Len(t, status.snapshot(), n)
}

View File

@ -0,0 +1,298 @@
// ©AngelaMos | 2026
// sender.go
package telegram
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"strings"
"time"
"github.com/cenkalti/backoff/v5"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
)
const (
Channel = "telegram"
defaultAPIBase = "https://api.telegram.org"
defaultMaxTries = 3
defaultMaxElapsed = 30 * time.Second
defaultInitialInterval = 500 * time.Millisecond
defaultOverallTimeout = 10 * time.Second
defaultDialTimeout = 5 * time.Second
uaTruncateRunes = 80
parseModeMarkdownV2 = "MarkdownV2"
contentTypeJSON = "application/json"
v2SpecialChars = "_*[]()~`>#+-=|{}.!"
)
var (
ErrChannelNotConfigured = errors.New(
"telegram: bot token or chat id not configured",
)
ErrTelegramAPI = errors.New("telegram: api error")
)
type Config struct {
APIBase string
ManageURL string
HTTPClient *http.Client
MaxTries uint
MaxElapsed time.Duration
InitialInterval time.Duration
}
type Option func(*Config)
func WithMaxTries(n uint) Option {
return func(c *Config) { c.MaxTries = n }
}
func WithMaxElapsed(d time.Duration) Option {
return func(c *Config) { c.MaxElapsed = d }
}
func WithInitialInterval(d time.Duration) Option {
return func(c *Config) { c.InitialInterval = d }
}
func WithHTTPClient(client *http.Client) Option {
return func(c *Config) { c.HTTPClient = client }
}
type Sender struct {
apiBase string
manageURL string
httpClient *http.Client
maxTries uint
maxElapsed time.Duration
initialInterval time.Duration
}
func NewSender(cfg Config, opts ...Option) *Sender {
for _, o := range opts {
o(&cfg)
}
if cfg.APIBase == "" {
cfg.APIBase = defaultAPIBase
}
if cfg.HTTPClient == nil {
cfg.HTTPClient = defaultHTTPClient()
}
if cfg.MaxTries == 0 {
cfg.MaxTries = defaultMaxTries
}
if cfg.MaxElapsed == 0 {
cfg.MaxElapsed = defaultMaxElapsed
}
if cfg.InitialInterval == 0 {
cfg.InitialInterval = defaultInitialInterval
}
return &Sender{
apiBase: strings.TrimRight(cfg.APIBase, "/"),
manageURL: strings.TrimRight(cfg.ManageURL, "/"),
httpClient: cfg.HTTPClient,
maxTries: cfg.MaxTries,
maxElapsed: cfg.MaxElapsed,
initialInterval: cfg.InitialInterval,
}
}
func defaultHTTPClient() *http.Client {
dialer := &net.Dialer{Timeout: defaultDialTimeout}
return &http.Client{
Timeout: defaultOverallTimeout,
Transport: &http.Transport{
DialContext: dialer.DialContext,
TLSHandshakeTimeout: defaultDialTimeout,
ResponseHeaderTimeout: defaultOverallTimeout,
ExpectContinueTimeout: time.Second,
IdleConnTimeout: 30 * time.Second,
},
}
}
func (s *Sender) Channel() string { return Channel }
func (s *Sender) Send(
ctx context.Context,
info event.NotifyInfo,
evt *event.Event,
) error {
if info.TelegramBot == "" || info.TelegramChat == "" {
return ErrChannelNotConfigured
}
endpoint := s.apiBase + "/bot" + info.TelegramBot + "/sendMessage"
body, err := json.Marshal(map[string]string{
"chat_id": info.TelegramChat,
"text": buildMessage(info, evt, s.manageURL),
"parse_mode": parseModeMarkdownV2,
})
if err != nil {
return fmt.Errorf("telegram: marshal body: %w", err)
}
expBackoff := backoff.NewExponentialBackOff()
expBackoff.InitialInterval = s.initialInterval
expBackoff.MaxInterval = 5 * time.Second
_, err = backoff.Retry(
ctx,
func() (struct{}, error) {
return struct{}{}, s.doRequest(ctx, endpoint, body)
},
backoff.WithBackOff(expBackoff),
backoff.WithMaxTries(s.maxTries),
backoff.WithMaxElapsedTime(s.maxElapsed),
)
return err
}
func (s *Sender) doRequest(
ctx context.Context,
endpoint string,
body []byte,
) error {
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
endpoint,
bytes.NewReader(body),
)
if err != nil {
return backoff.Permanent(
fmt.Errorf("telegram: build request: %w", err),
)
}
req.Header.Set("Content-Type", contentTypeJSON)
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("telegram: do request: %w", err)
}
defer func() {
if cErr := resp.Body.Close(); cErr != nil {
slog.WarnContext(ctx, "telegram: close body",
"error", cErr)
}
}()
respBody, rErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
if rErr != nil {
slog.WarnContext(ctx, "telegram: read body", "error", rErr)
}
switch {
case resp.StatusCode >= 200 && resp.StatusCode < 300:
return nil
case resp.StatusCode >= 400 && resp.StatusCode < 500:
return backoff.Permanent(fmt.Errorf(
"%w: status=%d body=%s",
ErrTelegramAPI, resp.StatusCode, string(respBody),
))
default:
return fmt.Errorf(
"%w: status=%d body=%s",
ErrTelegramAPI, resp.StatusCode, string(respBody),
)
}
}
func buildMessage(
info event.NotifyInfo,
evt *event.Event,
manageURL string,
) string {
var b strings.Builder
b.WriteString("🚨 *Canary triggered:* ")
b.WriteString(EscapeMD(info.Memo))
b.WriteString("\n\n*Type:* ")
b.WriteString(EscapeMD(info.Type))
b.WriteString("\n*From:* ")
b.WriteString(EscapeMD(evt.SourceIP))
if loc := formatGeo(evt); loc != "" {
b.WriteString(" ")
b.WriteString(loc)
}
b.WriteString("\n*Time:* ")
b.WriteString(EscapeMD(evt.TriggeredAt.UTC().Format(time.RFC3339)))
if evt.UserAgent != nil && *evt.UserAgent != "" {
b.WriteString("\n*UA:* ")
b.WriteString(EscapeMD(truncateRunes(*evt.UserAgent, uaTruncateRunes)))
}
if manageURL != "" && info.ManageID != "" {
b.WriteString("\n\n[View full event timeline](")
b.WriteString(manageURL + "/m/" + info.ManageID)
b.WriteString(")")
}
return b.String()
}
func formatGeo(evt *event.Event) string {
city := derefStr(evt.GeoCity)
country := derefStr(evt.GeoCountry)
asnOrg := derefStr(evt.GeoASNOrg)
var parens string
switch {
case city != "" && country != "":
parens = `\(` + EscapeMD(city) + ", " + EscapeMD(country) + `\)`
case country != "":
parens = `\(` + EscapeMD(country) + `\)`
case city != "":
parens = `\(` + EscapeMD(city) + `\)`
}
if parens == "" && asnOrg == "" {
return ""
}
if asnOrg == "" {
return parens
}
if parens == "" {
return "— " + EscapeMD(asnOrg)
}
return parens + " — " + EscapeMD(asnOrg)
}
func derefStr(s *string) string {
if s == nil {
return ""
}
return *s
}
func truncateRunes(s string, max int) string {
if max <= 0 {
return ""
}
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max])
}
func EscapeMD(s string) string {
var b strings.Builder
b.Grow(len(s) + 8)
for _, r := range s {
if strings.ContainsRune(v2SpecialChars, r) {
b.WriteRune('\\')
}
b.WriteRune(r)
}
return b.String()
}

View File

@ -0,0 +1,391 @@
// ©AngelaMos | 2026
// sender_test.go
package telegram_test
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/notify/telegram"
)
const (
testBotToken = "111222:ABCDEFG"
testChatID = "98765"
testMemo = "prod-db-creds"
testTokenID = "tokabc123def"
testManageID = "11111111-2222-3333-4444-555555555555"
)
func sampleInfo() event.NotifyInfo {
return event.NotifyInfo{
TokenID: testTokenID,
ManageID: testManageID,
Type: "envfile",
Memo: testMemo,
AlertChannel: "telegram",
TelegramBot: testBotToken,
TelegramChat: testChatID,
}
}
func sampleEvent() *event.Event {
ua := "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
city := "Toronto"
country := "CA"
asnOrg := "Cloudflare, Inc."
return &event.Event{
ID: 42,
TokenID: testTokenID,
TriggeredAt: time.Date(2026, 5, 14, 12, 30, 0, 0, time.UTC),
SourceIP: "203.0.113.45",
UserAgent: &ua,
GeoCity: &city,
GeoCountry: &country,
GeoASNOrg: &asnOrg,
}
}
type capture struct {
calls atomic.Int32
lastURL atomic.Value
lastBody atomic.Value
}
func writeOK(t *testing.T, w http.ResponseWriter) {
t.Helper()
w.WriteHeader(http.StatusOK)
if _, err := w.Write([]byte(`{"ok":true}`)); err != nil {
t.Logf("write: %v", err)
}
}
func newCaptureServer(
t *testing.T,
handler http.HandlerFunc,
) (*httptest.Server, *capture) {
t.Helper()
c := &capture{}
srv := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c.calls.Add(1)
c.lastURL.Store(r.URL.String())
body, err := io.ReadAll(r.Body)
if err != nil {
t.Logf("read body: %v", err)
return
}
c.lastBody.Store(body)
handler(w, r)
}),
)
t.Cleanup(srv.Close)
return srv, c
}
func loadBody(t *testing.T, c *capture) []byte {
t.Helper()
raw, ok := c.lastBody.Load().([]byte)
require.True(t, ok, "no body captured")
return raw
}
func newSender(
t *testing.T,
apiBase string,
opts ...telegram.Option,
) *telegram.Sender {
t.Helper()
cfg := telegram.Config{
APIBase: apiBase,
ManageURL: "https://canary.example.com",
}
for _, o := range opts {
o(&cfg)
}
return telegram.NewSender(cfg)
}
func TestSender_Channel(t *testing.T) {
t.Parallel()
s := newSender(t, "https://api.telegram.org")
require.Equal(t, "telegram", s.Channel())
}
func TestSender_Send_PostsToCorrectURL(t *testing.T) {
t.Parallel()
srv, cap := newCaptureServer(
t,
func(w http.ResponseWriter, _ *http.Request) { writeOK(t, w) },
)
s := newSender(t, srv.URL)
require.NoError(
t,
s.Send(context.Background(), sampleInfo(), sampleEvent()),
)
require.Equal(t, int32(1), cap.calls.Load())
require.Equal(t, "/bot"+testBotToken+"/sendMessage", cap.lastURL.Load())
}
func TestSender_Send_BodyShape(t *testing.T) {
t.Parallel()
srv, cap := newCaptureServer(
t,
func(w http.ResponseWriter, _ *http.Request) { writeOK(t, w) },
)
s := newSender(t, srv.URL)
require.NoError(
t,
s.Send(context.Background(), sampleInfo(), sampleEvent()),
)
var body map[string]string
require.NoError(t, json.Unmarshal(loadBody(t, cap), &body))
require.Equal(t, testChatID, body["chat_id"])
require.Equal(t, "MarkdownV2", body["parse_mode"])
require.NotEmpty(t, body["text"])
}
func TestSender_Send_MessageContainsKeyFields(t *testing.T) {
t.Parallel()
srv, cap := newCaptureServer(
t,
func(w http.ResponseWriter, _ *http.Request) { writeOK(t, w) },
)
s := newSender(t, srv.URL)
require.NoError(
t,
s.Send(context.Background(), sampleInfo(), sampleEvent()),
)
var body map[string]string
require.NoError(t, json.Unmarshal(loadBody(t, cap), &body))
text := body["text"]
require.Contains(t, text, "Canary triggered")
require.Contains(
t,
text,
`prod\-db\-creds`,
"memo escaped (- is V2 special)",
)
require.Contains(t, text, "envfile")
require.Contains(t, text, `203\.0\.113\.45`, "IP dots escaped")
require.Contains(t,
text,
`\(Toronto, CA\)`,
"geo wrapping parens escaped (V2 reserved chars)",
)
require.Contains(t, text, `Cloudflare, Inc\.`, "asn_org . escaped")
require.Contains(t, text, "View full event timeline", "manage link present")
require.Contains(t,
text,
"https://canary.example.com/m/"+testManageID,
"manage URL present in link",
)
}
func TestSender_Send_TruncatesUserAgentTo80Chars(t *testing.T) {
t.Parallel()
srv, cap := newCaptureServer(
t,
func(w http.ResponseWriter, _ *http.Request) { writeOK(t, w) },
)
s := newSender(t, srv.URL)
longUA := strings.Repeat("X", 200)
evt := sampleEvent()
evt.UserAgent = &longUA
require.NoError(t, s.Send(context.Background(), sampleInfo(), evt))
var body map[string]string
require.NoError(t, json.Unmarshal(loadBody(t, cap), &body))
require.Contains(t, body["text"], strings.Repeat("X", 80))
require.NotContains(t, body["text"], strings.Repeat("X", 81),
"user agent should be truncated to 80 chars")
}
func TestSender_Send_HandlesNilGeoAndUA(t *testing.T) {
t.Parallel()
srv, cap := newCaptureServer(
t,
func(w http.ResponseWriter, _ *http.Request) { writeOK(t, w) },
)
s := newSender(t, srv.URL)
evt := &event.Event{
ID: 1,
TokenID: testTokenID,
TriggeredAt: time.Date(2026, 5, 14, 12, 30, 0, 0, time.UTC),
SourceIP: "203.0.113.45",
}
require.NoError(t, s.Send(context.Background(), sampleInfo(), evt))
var body map[string]string
require.NoError(t, json.Unmarshal(loadBody(t, cap), &body))
require.Contains(t, body["text"], `203\.0\.113\.45`)
}
func TestSender_Send_EmptyBotReturnsConfigErr(t *testing.T) {
t.Parallel()
s := newSender(t, "http://unused.test")
info := sampleInfo()
info.TelegramBot = ""
err := s.Send(context.Background(), info, sampleEvent())
require.ErrorIs(t, err, telegram.ErrChannelNotConfigured)
}
func TestSender_Send_EmptyChatReturnsConfigErr(t *testing.T) {
t.Parallel()
s := newSender(t, "http://unused.test")
info := sampleInfo()
info.TelegramChat = ""
err := s.Send(context.Background(), info, sampleEvent())
require.ErrorIs(t, err, telegram.ErrChannelNotConfigured)
}
func TestSender_Send_RetriesOn5xxThenSucceeds(t *testing.T) {
t.Parallel()
var attempts atomic.Int32
srv := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
n := attempts.Add(1)
if n == 1 {
w.WriteHeader(http.StatusInternalServerError)
return
}
writeOK(t, w)
}),
)
t.Cleanup(srv.Close)
s := newSender(t, srv.URL,
telegram.WithMaxTries(3),
telegram.WithMaxElapsed(2*time.Second),
telegram.WithInitialInterval(5*time.Millisecond),
)
require.NoError(
t,
s.Send(context.Background(), sampleInfo(), sampleEvent()),
)
require.Equal(t, int32(2), attempts.Load())
}
func TestSender_Send_PermanentOn4xx(t *testing.T) {
t.Parallel()
var attempts atomic.Int32
srv := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
attempts.Add(1)
w.WriteHeader(http.StatusBadRequest)
if _, err := w.Write(
[]byte(`{"ok":false,"description":"chat not found"}`),
); err != nil {
t.Logf("write: %v", err)
}
}),
)
t.Cleanup(srv.Close)
s := newSender(t, srv.URL,
telegram.WithMaxTries(5),
telegram.WithInitialInterval(5*time.Millisecond),
)
err := s.Send(context.Background(), sampleInfo(), sampleEvent())
require.Error(t, err)
require.Equal(t, int32(1), attempts.Load(), "no retry on 4xx")
}
func TestSender_Send_AbortsAfterMaxTries(t *testing.T) {
t.Parallel()
var attempts atomic.Int32
srv := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
attempts.Add(1)
w.WriteHeader(http.StatusServiceUnavailable)
}),
)
t.Cleanup(srv.Close)
s := newSender(t, srv.URL,
telegram.WithMaxTries(3),
telegram.WithMaxElapsed(5*time.Second),
telegram.WithInitialInterval(2*time.Millisecond),
)
err := s.Send(context.Background(), sampleInfo(), sampleEvent())
require.Error(t, err)
require.Equal(t, int32(3), attempts.Load())
}
func TestSender_Send_RespectsContextCancel(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(
http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
time.Sleep(2 * time.Second)
}),
)
t.Cleanup(srv.Close)
s := newSender(t, srv.URL,
telegram.WithMaxTries(3),
telegram.WithInitialInterval(5*time.Millisecond),
)
ctx, cancel := context.WithTimeout(
context.Background(),
100*time.Millisecond,
)
defer cancel()
err := s.Send(ctx, sampleInfo(), sampleEvent())
require.Error(t, err)
require.True(t,
errors.Is(err, context.DeadlineExceeded) ||
errors.Is(err, context.Canceled),
"expected context error, got %v", err,
)
}
func TestEscapeMD(t *testing.T) {
t.Parallel()
cases := []struct {
in, want string
}{
{"plain", "plain"},
{"a.b", `a\.b`},
{"a-b", `a\-b`},
{"file.txt", `file\.txt`},
{"203.0.113.45", `203\.0\.113\.45`},
{"hello!", `hello\!`},
{"a_b*c", `a\_b\*c`},
{"(parens)", `\(parens\)`},
{"[bracket]", `\[bracket\]`},
{"~tilde~", `\~tilde\~`},
{"`code`", "\\`code\\`"},
{"a>b<c", `a\>b<c`},
{"a#b", `a\#b`},
{"a+b=c", `a\+b\=c`},
{"a|b{c}d", `a\|b\{c\}d`},
{"unicode—em", "unicode—em"},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
require.Equal(t, tc.want, telegram.EscapeMD(tc.in))
})
}
}

View File

@ -0,0 +1,29 @@
// ©AngelaMos | 2026
// types.go
package notify
import (
"context"
"time"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
)
type Sender interface {
Channel() string
Send(
ctx context.Context,
info event.NotifyInfo,
evt *event.Event,
) error
}
type StatusWriter interface {
UpdateNotifyStatus(
ctx context.Context,
eventID int64,
status event.NotifyStatus,
sentAt *time.Time,
) error
}

View File

@ -0,0 +1,438 @@
// ©AngelaMos | 2026
// sender.go
package webhook
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/cenkalti/backoff/v5"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
)
const (
Channel = "webhook"
envelopeVersion = "1"
envelopeEvent = "canary.triggered"
defaultMaxTries = 3
defaultMaxElapsed = 30 * time.Second
defaultInitialInterval = 500 * time.Millisecond
defaultOverallTimeout = 10 * time.Second
defaultDialTimeout = 5 * time.Second
contentTypeJSON = "application/json"
signatureHeaderName = "X-Canary-Signature"
signaturePrefix = "sha256="
)
var (
ErrChannelNotConfigured = errors.New(
"webhook: webhook URL not configured",
)
ErrInvalidWebhookURL = errors.New("webhook: invalid url")
ErrBlockedHost = errors.New(
"webhook: host resolves to a blocked address range",
)
ErrWebhookAPI = errors.New("webhook: api error")
)
type Config struct {
ManageURL string
HMACSecret string
HTTPClient *http.Client
MaxTries uint
MaxElapsed time.Duration
InitialInterval time.Duration
AllowPrivateHosts bool
}
type Option func(*Config)
func WithMaxTries(n uint) Option { return func(c *Config) { c.MaxTries = n } }
func WithMaxElapsed(d time.Duration) Option {
return func(c *Config) { c.MaxElapsed = d }
}
func WithInitialInterval(d time.Duration) Option {
return func(c *Config) { c.InitialInterval = d }
}
func WithHTTPClient(client *http.Client) Option {
return func(c *Config) { c.HTTPClient = client }
}
func WithAllowPrivateHosts(allow bool) Option {
return func(c *Config) { c.AllowPrivateHosts = allow }
}
type Sender struct {
manageURL string
hmacSecret string
httpClient *http.Client
maxTries uint
maxElapsed time.Duration
initialInterval time.Duration
allowPrivateHosts bool
}
func NewSender(cfg Config, opts ...Option) *Sender {
for _, o := range opts {
o(&cfg)
}
if cfg.HTTPClient == nil {
cfg.HTTPClient = defaultHTTPClient(cfg.AllowPrivateHosts)
}
if cfg.MaxTries == 0 {
cfg.MaxTries = defaultMaxTries
}
if cfg.MaxElapsed == 0 {
cfg.MaxElapsed = defaultMaxElapsed
}
if cfg.InitialInterval == 0 {
cfg.InitialInterval = defaultInitialInterval
}
return &Sender{
manageURL: strings.TrimRight(cfg.ManageURL, "/"),
hmacSecret: cfg.HMACSecret,
httpClient: cfg.HTTPClient,
maxTries: cfg.MaxTries,
maxElapsed: cfg.MaxElapsed,
initialInterval: cfg.InitialInterval,
allowPrivateHosts: cfg.AllowPrivateHosts,
}
}
func defaultHTTPClient(allowPrivateHosts bool) *http.Client {
base := &net.Dialer{Timeout: defaultDialTimeout}
dialFn := base.DialContext
if !allowPrivateHosts {
dialFn = func(
ctx context.Context,
network, addr string,
) (net.Conn, error) {
if err := preDialIPCheck(addr); err != nil {
return nil, err
}
conn, err := base.DialContext(ctx, network, addr)
if err != nil {
return nil, err
}
if err := postDialIPCheck(conn); err != nil {
if cErr := conn.Close(); cErr != nil {
slog.Warn("webhook: close blocked conn",
"error", cErr)
}
return nil, err
}
return conn, nil
}
}
return &http.Client{
Timeout: defaultOverallTimeout,
Transport: &http.Transport{
DialContext: dialFn,
TLSHandshakeTimeout: defaultDialTimeout,
ResponseHeaderTimeout: defaultOverallTimeout,
ExpectContinueTimeout: time.Second,
IdleConnTimeout: 30 * time.Second,
},
}
}
func preDialIPCheck(addr string) error {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("webhook: split host port: %w", err)
}
ip := net.ParseIP(host)
if ip == nil {
return nil
}
if isBlockedIP(ip) {
return fmt.Errorf("%w: dial blocked %s", ErrBlockedHost, ip)
}
return nil
}
func postDialIPCheck(conn net.Conn) error {
tcpAddr, ok := conn.RemoteAddr().(*net.TCPAddr)
if !ok {
return nil
}
if isBlockedIP(tcpAddr.IP) {
return fmt.Errorf("%w: dial blocked %s", ErrBlockedHost, tcpAddr.IP)
}
return nil
}
func (s *Sender) Channel() string { return Channel }
func (s *Sender) Validate(raw string) error {
return validateURL(raw, s.allowPrivateHosts)
}
func (s *Sender) Send(
ctx context.Context,
info event.NotifyInfo,
evt *event.Event,
) error {
if strings.TrimSpace(info.WebhookURL) == "" {
return ErrChannelNotConfigured
}
if err := validateURL(info.WebhookURL, s.allowPrivateHosts); err != nil {
return err
}
body, err := json.Marshal(buildEnvelope(info, evt, s.manageURL))
if err != nil {
return fmt.Errorf("webhook: marshal envelope: %w", err)
}
expBackoff := backoff.NewExponentialBackOff()
expBackoff.InitialInterval = s.initialInterval
expBackoff.MaxInterval = 5 * time.Second
_, err = backoff.Retry(
ctx,
func() (struct{}, error) {
return struct{}{}, s.doRequest(ctx, info.WebhookURL, body)
},
backoff.WithBackOff(expBackoff),
backoff.WithMaxTries(s.maxTries),
backoff.WithMaxElapsedTime(s.maxElapsed),
)
return err
}
func (s *Sender) doRequest(
ctx context.Context,
endpoint string,
body []byte,
) error {
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
endpoint,
bytes.NewReader(body),
)
if err != nil {
return backoff.Permanent(
fmt.Errorf("webhook: build request: %w", err),
)
}
req.Header.Set("Content-Type", contentTypeJSON)
if s.hmacSecret != "" {
req.Header.Set(
signatureHeaderName,
computeSignature(s.hmacSecret, body),
)
}
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("webhook: do request: %w", err)
}
defer func() {
if cErr := resp.Body.Close(); cErr != nil {
slog.WarnContext(ctx, "webhook: close body",
"error", cErr)
}
}()
respBody, rErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
if rErr != nil {
slog.WarnContext(ctx, "webhook: read body", "error", rErr)
}
switch {
case resp.StatusCode >= 200 && resp.StatusCode < 300:
return nil
case resp.StatusCode >= 400 && resp.StatusCode < 500:
return backoff.Permanent(fmt.Errorf(
"%w: status=%d body=%s",
ErrWebhookAPI, resp.StatusCode, string(respBody),
))
default:
return fmt.Errorf(
"%w: status=%d body=%s",
ErrWebhookAPI, resp.StatusCode, string(respBody),
)
}
}
func validateURL(raw string, allowPrivateHosts bool) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("%w: parse: %w", ErrInvalidWebhookURL, err)
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return fmt.Errorf(
"%w: scheme must be http or https, got %q",
ErrInvalidWebhookURL, u.Scheme,
)
}
if u.Host == "" {
return fmt.Errorf("%w: missing host", ErrInvalidWebhookURL)
}
if u.User != nil {
return fmt.Errorf("%w: userinfo not allowed", ErrInvalidWebhookURL)
}
if allowPrivateHosts {
return nil
}
host := u.Hostname()
if host == "" {
return fmt.Errorf("%w: missing hostname", ErrInvalidWebhookURL)
}
if ip := net.ParseIP(host); ip != nil {
if isBlockedIP(ip) {
return fmt.Errorf("%w: %s", ErrBlockedHost, ip)
}
return nil
}
ips, lookupErr := net.LookupIP(host)
if lookupErr != nil {
return fmt.Errorf(
"%w: lookup %s: %w",
ErrInvalidWebhookURL, host, lookupErr,
)
}
if len(ips) == 0 {
return fmt.Errorf("%w: no IPs for %s", ErrInvalidWebhookURL, host)
}
for _, ip := range ips {
if isBlockedIP(ip) {
return fmt.Errorf("%w: %s -> %s", ErrBlockedHost, host, ip)
}
}
return nil
}
func isBlockedIP(ip net.IP) bool {
if ip == nil {
return true
}
if ip.IsLoopback() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsMulticast() ||
ip.IsUnspecified() {
return true
}
if ip4 := ip.To4(); ip4 != nil {
switch {
case ip4[0] == 10:
return true
case ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31:
return true
case ip4[0] == 192 && ip4[1] == 168:
return true
case ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127:
return true
case ip4[0] == 169 && ip4[1] == 254:
return true
}
return false
}
if len(ip) == net.IPv6len && ip[0]&0xfe == 0xfc {
return true
}
return false
}
type envelope struct {
Version string `json:"version"`
Event string `json:"event"`
Token tokenSection `json:"token"`
Trigger triggerSection `json:"trigger"`
}
type tokenSection struct {
ID string `json:"id"`
Type string `json:"type"`
Memo string `json:"memo"`
ManageURL string `json:"manage_url"`
}
type triggerSection struct {
TriggeredAt time.Time `json:"triggered_at"`
SourceIP string `json:"source_ip"`
UserAgent string `json:"user_agent"`
Geo geoSection `json:"geo"`
Extra json.RawMessage `json:"extra"`
}
type geoSection struct {
Country string `json:"country"`
City string `json:"city"`
ASNOrg string `json:"asn_org"`
}
func buildEnvelope(
info event.NotifyInfo,
evt *event.Event,
manageURL string,
) envelope {
extra := evt.Extra
if len(extra) == 0 {
extra = json.RawMessage(`{}`)
}
return envelope{
Version: envelopeVersion,
Event: envelopeEvent,
Token: tokenSection{
ID: info.TokenID,
Type: info.Type,
Memo: info.Memo,
ManageURL: buildManageURL(manageURL, info.ManageID),
},
Trigger: triggerSection{
TriggeredAt: evt.TriggeredAt.UTC(),
SourceIP: evt.SourceIP,
UserAgent: derefStr(evt.UserAgent),
Geo: geoSection{
Country: derefStr(evt.GeoCountry),
City: derefStr(evt.GeoCity),
ASNOrg: derefStr(evt.GeoASNOrg),
},
Extra: extra,
},
}
}
func buildManageURL(base, id string) string {
if base == "" || id == "" {
return ""
}
return base + "/m/" + id
}
func derefStr(s *string) string {
if s == nil {
return ""
}
return *s
}
func computeSignature(secret string, body []byte) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
return signaturePrefix + hex.EncodeToString(mac.Sum(nil))
}

View File

@ -0,0 +1,435 @@
// ©AngelaMos | 2026
// sender_test.go
package webhook_test
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/notify/webhook"
)
const (
testTokenID = "tokwh01abcde"
testManageID = "abcd1111-2222-3333-4444-555555555555"
)
func sampleEvent() *event.Event {
ua := "TestUA/1.0"
city := "Toronto"
country := "CA"
asnOrg := "Test, Inc."
asn := 12345
return &event.Event{
ID: 7,
TokenID: testTokenID,
TriggeredAt: time.Date(2026, 5, 14, 12, 30, 0, 0, time.UTC),
SourceIP: "203.0.113.45",
UserAgent: &ua,
GeoCity: &city,
GeoCountry: &country,
GeoASN: &asn,
GeoASNOrg: &asnOrg,
Extra: json.RawMessage(`{"custom":"value"}`),
}
}
func sampleInfo(webhookURL string) event.NotifyInfo {
return event.NotifyInfo{
TokenID: testTokenID,
ManageID: testManageID,
Type: "envfile",
Memo: "prod-creds",
AlertChannel: "webhook",
WebhookURL: webhookURL,
}
}
type capture struct {
calls atomic.Int32
lastBody atomic.Value
lastSig atomic.Value
}
func newCaptureServer(
t *testing.T,
handler http.HandlerFunc,
) (*httptest.Server, *capture) {
t.Helper()
c := &capture{}
srv := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c.calls.Add(1)
body, err := io.ReadAll(r.Body)
if err != nil {
t.Logf("read body: %v", err)
return
}
c.lastBody.Store(body)
c.lastSig.Store(r.Header.Get("X-Canary-Signature"))
handler(w, r)
}),
)
t.Cleanup(srv.Close)
return srv, c
}
func loadBody(t *testing.T, c *capture) []byte {
t.Helper()
raw, ok := c.lastBody.Load().([]byte)
require.True(t, ok, "no body captured")
return raw
}
func newSender(t *testing.T, opts ...webhook.Option) *webhook.Sender {
t.Helper()
return webhook.NewSender(webhook.Config{
ManageURL: "https://canary.example.com",
AllowPrivateHosts: true,
}, opts...)
}
func newStrictSender(t *testing.T) *webhook.Sender {
t.Helper()
return webhook.NewSender(webhook.Config{
ManageURL: "https://canary.example.com",
})
}
func TestSender_Channel(t *testing.T) {
t.Parallel()
require.Equal(t, "webhook", newSender(t).Channel())
}
func TestSender_Send_PostsToProvidedURL(t *testing.T) {
t.Parallel()
srv, cap := newCaptureServer(
t,
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
},
)
s := newSender(t)
require.NoError(
t,
s.Send(context.Background(), sampleInfo(srv.URL), sampleEvent()),
)
require.Equal(t, int32(1), cap.calls.Load())
}
func TestSender_Send_BodyEnvelopeShape(t *testing.T) {
t.Parallel()
srv, cap := newCaptureServer(
t,
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
)
s := newSender(t)
require.NoError(
t,
s.Send(context.Background(), sampleInfo(srv.URL), sampleEvent()),
)
var env map[string]any
require.NoError(t, json.Unmarshal(loadBody(t, cap), &env))
require.Equal(t, "1", env["version"])
require.Equal(t, "canary.triggered", env["event"])
tok, ok := env["token"].(map[string]any)
require.True(t, ok)
require.Equal(t, testTokenID, tok["id"])
require.Equal(t, "envfile", tok["type"])
require.Equal(t, "prod-creds", tok["memo"])
require.Equal(
t,
"https://canary.example.com/m/"+testManageID,
tok["manage_url"],
)
trig, ok := env["trigger"].(map[string]any)
require.True(t, ok)
require.Equal(t, "203.0.113.45", trig["source_ip"])
require.Equal(t, "TestUA/1.0", trig["user_agent"])
require.NotEmpty(t, trig["triggered_at"])
geo, ok := trig["geo"].(map[string]any)
require.True(t, ok)
require.Equal(t, "CA", geo["country"])
require.Equal(t, "Toronto", geo["city"])
require.Equal(t, "Test, Inc.", geo["asn_org"])
extra, ok := trig["extra"].(map[string]any)
require.True(t, ok)
require.Equal(t, "value", extra["custom"])
}
func TestSender_Send_NoSignatureWithoutSecret(t *testing.T) {
t.Parallel()
srv, cap := newCaptureServer(
t,
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
)
s := newSender(t)
require.NoError(
t,
s.Send(context.Background(), sampleInfo(srv.URL), sampleEvent()),
)
require.Empty(t, cap.lastSig.Load())
}
func TestSender_Send_HMACSignatureWhenSecretSet(t *testing.T) {
t.Parallel()
const secret = "topsecret"
srv, cap := newCaptureServer(
t,
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
)
s := webhook.NewSender(webhook.Config{
ManageURL: "https://canary.example.com",
HMACSecret: secret,
AllowPrivateHosts: true,
})
require.NoError(
t,
s.Send(context.Background(), sampleInfo(srv.URL), sampleEvent()),
)
body := loadBody(t, cap)
mac := hmac.New(sha256.New, []byte(secret))
if _, err := mac.Write(body); err != nil {
t.Fatal(err)
}
want := "sha256=" + hex.EncodeToString(mac.Sum(nil))
require.Equal(t, want, cap.lastSig.Load())
}
func TestSender_Send_EmptyURLReturnsConfigErr(t *testing.T) {
t.Parallel()
s := newSender(t)
err := s.Send(context.Background(), sampleInfo(""), sampleEvent())
require.ErrorIs(t, err, webhook.ErrChannelNotConfigured)
}
func TestSender_Send_RejectsNonHTTPScheme(t *testing.T) {
t.Parallel()
cases := []string{
"ftp://example.com/hook",
"file:///etc/passwd",
"javascript:alert(1)",
"not-a-url",
}
for _, u := range cases {
t.Run(u, func(t *testing.T) {
s := newSender(t)
err := s.Send(context.Background(), sampleInfo(u), sampleEvent())
require.ErrorIs(t, err, webhook.ErrInvalidWebhookURL)
})
}
}
func TestSender_Send_RejectsURLWithoutHost(t *testing.T) {
t.Parallel()
s := newSender(t)
err := s.Send(
context.Background(),
sampleInfo("http:///nohost"),
sampleEvent(),
)
require.ErrorIs(t, err, webhook.ErrInvalidWebhookURL)
}
func TestSender_Send_RejectsURLWithUserInfo(t *testing.T) {
t.Parallel()
s := newSender(t)
err := s.Send(
context.Background(),
sampleInfo("https://user:pass@example.com/h"),
sampleEvent(),
)
require.ErrorIs(t, err, webhook.ErrInvalidWebhookURL)
}
func TestSender_Send_RetriesOn5xxThenSucceeds(t *testing.T) {
t.Parallel()
var attempts atomic.Int32
srv := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
n := attempts.Add(1)
if n == 1 {
w.WriteHeader(http.StatusBadGateway)
return
}
w.WriteHeader(http.StatusOK)
}),
)
t.Cleanup(srv.Close)
s := newSender(t,
webhook.WithMaxTries(3),
webhook.WithMaxElapsed(2*time.Second),
webhook.WithInitialInterval(5*time.Millisecond),
)
require.NoError(
t,
s.Send(context.Background(), sampleInfo(srv.URL), sampleEvent()),
)
require.Equal(t, int32(2), attempts.Load())
}
func TestSender_Send_PermanentOn4xx(t *testing.T) {
t.Parallel()
var attempts atomic.Int32
srv := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
attempts.Add(1)
w.WriteHeader(http.StatusForbidden)
}),
)
t.Cleanup(srv.Close)
s := newSender(t,
webhook.WithMaxTries(5),
webhook.WithInitialInterval(5*time.Millisecond),
)
err := s.Send(context.Background(), sampleInfo(srv.URL), sampleEvent())
require.Error(t, err)
require.Equal(t, int32(1), attempts.Load())
}
func TestSender_Send_AbortsAfterMaxTries(t *testing.T) {
t.Parallel()
var attempts atomic.Int32
srv := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
attempts.Add(1)
w.WriteHeader(http.StatusServiceUnavailable)
}),
)
t.Cleanup(srv.Close)
s := newSender(t,
webhook.WithMaxTries(3),
webhook.WithMaxElapsed(5*time.Second),
webhook.WithInitialInterval(2*time.Millisecond),
)
err := s.Send(context.Background(), sampleInfo(srv.URL), sampleEvent())
require.Error(t, err)
require.Equal(t, int32(3), attempts.Load())
}
func TestSender_Validate_BlocksPrivateHosts(t *testing.T) {
t.Parallel()
s := newStrictSender(t)
cases := []string{
"http://127.0.0.1/",
"http://127.0.0.1:6379/",
"http://10.0.0.1/",
"http://10.255.255.255/",
"http://172.16.0.1/",
"http://172.31.255.255/",
"http://192.168.1.1/",
"http://169.254.169.254/latest/meta-data/",
"http://100.64.0.1/",
"http://0.0.0.0/",
"http://[::1]/",
"http://[fd00::1]/",
"http://[fe80::1]/",
}
for _, raw := range cases {
raw := raw
t.Run(raw, func(t *testing.T) {
t.Parallel()
err := s.Validate(raw)
require.ErrorIsf(
t,
err,
webhook.ErrBlockedHost,
"expected ErrBlockedHost for %q",
raw,
)
})
}
}
func TestSender_Validate_AllowsPublicLiteralIPs(t *testing.T) {
t.Parallel()
s := newStrictSender(t)
cases := []string{
"https://8.8.8.8/",
"https://1.1.1.1/",
"http://203.0.113.45:8080/hook",
"https://[2001:db8::1]/",
}
for _, raw := range cases {
raw := raw
t.Run(raw, func(t *testing.T) {
t.Parallel()
require.NoError(t, s.Validate(raw))
})
}
}
func TestSender_Send_BlocksPrivateHostByDefault(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}),
)
t.Cleanup(srv.Close)
s := newStrictSender(t)
err := s.Send(context.Background(), sampleInfo(srv.URL), sampleEvent())
require.ErrorIs(
t,
err,
webhook.ErrBlockedHost,
"strict sender must refuse to send to loopback URL",
)
}
func TestSender_Send_RespectsContextCancel(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(
http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
time.Sleep(2 * time.Second)
}),
)
t.Cleanup(srv.Close)
s := newSender(t,
webhook.WithMaxTries(3),
webhook.WithInitialInterval(5*time.Millisecond),
)
ctx, cancel := context.WithTimeout(
context.Background(),
100*time.Millisecond,
)
defer cancel()
err := s.Send(ctx, sampleInfo(srv.URL), sampleEvent())
require.Error(t, err)
require.True(
t,
errors.Is(err, context.DeadlineExceeded) ||
errors.Is(err, context.Canceled),
"expected context error, got %v",
err,
)
}

View File

@ -0,0 +1,469 @@
// ©AngelaMos | 2026
// e2e_test.go
//go:build integration
package server_test
import (
"archive/zip"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"sync"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/go-chi/chi/v5"
"github.com/jmoiron/sqlx"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/notify"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/testutil"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/registry"
)
const (
e2eBaseURL = "https://canary.example.com"
e2eManageURL = "https://canary.example.com"
e2eTelegramBot = "111:AAA"
e2eTelegramChat = "12345"
e2eMySQLPubHost = "localhost"
e2eMySQLPubPort = 3306
e2eClientIP = "203.0.113.10"
e2eClientUA = "E2E/1.0"
e2eEventualWait = 3 * time.Second
e2eEventualTick = 20 * time.Millisecond
e2eSendTimeout = 2 * time.Second
e2eDedupTTL = 15 * time.Minute
)
type e2eSender struct {
mu sync.Mutex
calls []event.NotifyInfo
}
func (s *e2eSender) Channel() string { return "telegram" }
func (s *e2eSender) Send(
_ context.Context,
info event.NotifyInfo,
_ *event.Event,
) error {
s.mu.Lock()
defer s.mu.Unlock()
s.calls = append(s.calls, info)
return nil
}
func (s *e2eSender) count() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.calls)
}
type e2eRegistryAdapter struct{ r registry.Registry }
func (a e2eRegistryAdapter) Get(
t token.Type,
) (token.Generator, bool) {
g, ok := a.r[t]
return g, ok
}
type e2eRecorderAdapter struct{ svc *event.Service }
func (a e2eRecorderAdapter) Record(
ctx context.Context,
t *token.Token,
evt *event.Event,
) error {
return a.svc.Record(ctx, t.NotifyInfo(), evt)
}
type e2eStack struct {
router chi.Router
notifySvc *notify.Service
sender *e2eSender
eventRepo *event.Repository
tokenRepo *token.Repository
}
func setupE2EStack(t *testing.T) *e2eStack {
t.Helper()
db := sqlx.NewDb(testutil.NewTestDB(t), "pgx")
mr, err := miniredis.Run()
require.NoError(t, err)
t.Cleanup(mr.Close)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() {
if cErr := rdb.Close(); cErr != nil {
t.Logf("redis close: %v", cErr)
}
})
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
tokenRepo := token.NewRepository(db)
eventRepo := event.NewRepository(db)
sender := &e2eSender{}
notifySvc := notify.NewService(eventRepo,
notify.WithLogger(logger),
notify.WithSendTimeout(e2eSendTimeout),
)
notifySvc.Register(sender)
eventSvc := event.NewService(
eventRepo,
tokenRepo,
rdb,
notifySvc,
event.ServiceConfig{
DedupTTL: e2eDedupTTL,
Logger: logger,
},
)
genReg := registry.Build(registry.Config{
BaseURL: e2eBaseURL,
MySQLPublicHost: e2eMySQLPubHost,
MySQLPublicPort: e2eMySQLPubPort,
})
tokenSvc := token.NewService(
tokenRepo,
e2eRegistryAdapter{r: genReg},
token.ServiceConfig{
BaseURL: e2eBaseURL,
ManageURL: e2eManageURL,
},
)
tokenH := token.NewHandler(
tokenSvc,
e2eRecorderAdapter{svc: eventSvc},
nil,
eventRepo,
eventSvc,
logger,
true,
)
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Recovery(logger))
tokenH.RegisterTriggerRoutes(r)
r.Route("/api", func(api chi.Router) {
api.Get("/tokens/types", tokenH.GetTypes)
api.Post("/tokens", tokenH.CreateToken)
tokenH.RegisterManageRoutes(api)
})
return &e2eStack{
router: r,
notifySvc: notifySvc,
sender: sender,
eventRepo: eventRepo,
tokenRepo: tokenRepo,
}
}
type e2eCreateResponse struct {
Success bool `json:"success"`
Data struct {
Token token.Response `json:"token"`
Artifact token.ArtifactJSON `json:"artifact"`
} `json:"data"`
}
func createE2EToken(
t *testing.T,
st *e2eStack,
kind token.Type,
metadata string,
) e2eCreateResponse {
t.Helper()
body := `{
"type": "` + string(kind) + `",
"memo": "e2e ` + string(kind) + `",
"alert_channel": "telegram",
"telegram_bot": "` + e2eTelegramBot + `",
"telegram_chat": "` + e2eTelegramChat + `"`
if metadata != "" {
body += `, "metadata": ` + metadata
}
body += `}`
req := httptest.NewRequest(
http.MethodPost,
"/api/tokens",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
st.router.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code, "body=%s", w.Body.String())
var resp e2eCreateResponse
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
require.True(t, resp.Success)
require.NotEmpty(t, resp.Data.Token.ID)
return resp
}
func extractEmbeddedTriggerURL(
t *testing.T,
kind token.Type,
art token.ArtifactJSON,
tokenID string,
) string {
t.Helper()
switch art.Kind {
case "url":
require.NotEmpty(t, art.URL)
return art.URL
case "file":
raw, err := base64.StdEncoding.DecodeString(art.ContentB64)
require.NoError(t, err)
if kind == token.TypeDocx {
return matchTriggerURLInZip(t, raw, tokenID)
}
return matchTriggerURL(t, raw, tokenID)
case "text":
return matchTriggerURL(t, []byte(art.Content), tokenID)
default:
t.Fatalf("unhandled artifact kind for HTTP trigger: %s", art.Kind)
return ""
}
}
func matchTriggerURLInZip(
t *testing.T,
raw []byte,
tokenID string,
) string {
t.Helper()
r, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
require.NoError(t, err, "docx artifact must be a valid zip")
needle := []byte("/c/" + tokenID)
for _, f := range r.File {
rc, oErr := f.Open()
if oErr != nil {
continue
}
content, rErr := io.ReadAll(rc)
_ = rc.Close()
if rErr != nil {
continue
}
if bytes.Contains(content, needle) {
return matchTriggerURL(t, content, tokenID)
}
}
t.Fatalf(
"docx: no zip member contains /c/%s (members=%d)",
tokenID, len(r.File),
)
return ""
}
func matchTriggerURL(t *testing.T, raw []byte, tokenID string) string {
t.Helper()
pattern := regexp.MustCompile(
`https?://[A-Za-z0-9.\-:]+/(?:c|k)/` +
regexp.QuoteMeta(tokenID) +
`(?:\?[A-Za-z0-9_=&\-%.]*)?`,
)
match := pattern.Find(raw)
require.NotNilf(
t,
match,
"embedded trigger URL for token %s not found in artifact (len=%d)",
tokenID, len(raw),
)
return string(match)
}
func TestE2E_AllTokenTypes_CreateTriggerNotifyRecord(t *testing.T) {
st := setupE2EStack(t)
cases := []struct {
name string
kind token.Type
metadata string
httpable bool
}{
{
name: "webbug",
kind: token.TypeWebbug,
metadata: "",
httpable: true,
},
{
name: "slowredirect",
kind: token.TypeSlowRedirect,
metadata: `{"destination_url":"https://example.com/out"}`,
httpable: true,
},
{
name: "docx",
kind: token.TypeDocx,
metadata: "",
httpable: true,
},
{
name: "pdf",
kind: token.TypePDF,
metadata: "",
httpable: true,
},
{
name: "kubeconfig",
kind: token.TypeKubeconfig,
metadata: "",
httpable: true,
},
{
name: "envfile",
kind: token.TypeEnvfile,
metadata: `{"include_keys":["aws"]}`,
httpable: true,
},
{
name: "mysql",
kind: token.TypeMySQL,
metadata: "",
httpable: false,
},
}
var expectedNotifications int
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
resp := createE2EToken(t, st, c.kind, c.metadata)
tokenID := resp.Data.Token.ID
if c.kind == token.TypePDF {
raw, err := base64.StdEncoding.DecodeString(
resp.Data.Artifact.ContentB64,
)
require.NoError(t, err)
idx := bytes.Index(raw, []byte("/c/"+tokenID))
require.GreaterOrEqualf(
t,
idx,
0,
"PDF artifact must embed /c/<id> (id=%s)",
tokenID,
)
after := raw[idx+len("/c/"+tokenID)]
require.NotEqualf(
t,
byte('_'),
after,
"audit finding F2 regression: the byte immediately after "+
"the PDF-embedded /c/<id> must not be '_' — otherwise "+
"Acrobat fetches /c/<id>____ and the canary silently "+
"no-ops on lookup (id=%s, after=%q)",
tokenID, after,
)
}
if !c.httpable {
require.Equal(t, "connection_string", resp.Data.Artifact.Kind)
require.NotEmpty(t, resp.Data.Artifact.ConnectionString)
return
}
triggerURL := extractEmbeddedTriggerURL(
t,
c.kind,
resp.Data.Artifact,
tokenID,
)
require.NotEmpty(t, triggerURL)
trigReq := httptest.NewRequest(http.MethodGet, triggerURL, nil)
trigReq.Header.Set("CF-Connecting-IP", e2eClientIP)
trigReq.Header.Set("User-Agent", e2eClientUA)
tw := httptest.NewRecorder()
st.router.ServeHTTP(tw, trigReq)
require.Truef(
t,
tw.Code == http.StatusOK ||
tw.Code == http.StatusFound ||
tw.Code == http.StatusForbidden,
"trigger expected 200/302/403 (kubeconfig camouflages "+
"as K8s 403), got %d for %s (url=%s)",
tw.Code, c.kind, triggerURL,
)
expectedNotifications++
require.Eventuallyf(
t,
func() bool {
return st.sender.count() >= expectedNotifications
},
e2eEventualWait,
e2eEventualTick,
"notifier should fire for %s (got=%d want>=%d)",
c.kind, st.sender.count(), expectedNotifications,
)
count, err := st.eventRepo.CountByToken(
context.Background(),
tokenID,
)
require.NoError(t, err)
require.GreaterOrEqualf(
t,
count,
int64(1),
"at least one event must be recorded for %s",
c.kind,
)
manageReq := httptest.NewRequest(
http.MethodGet,
"/api/m/"+resp.Data.Token.ManageID,
nil,
)
mw := httptest.NewRecorder()
st.router.ServeHTTP(mw, manageReq)
require.Equal(t, http.StatusOK, mw.Code)
var manage struct {
Success bool `json:"success"`
Data struct {
EventsTotal int64 `json:"events_total"`
} `json:"data"`
}
require.NoError(t, json.NewDecoder(mw.Body).Decode(&manage))
require.True(t, manage.Success)
require.GreaterOrEqual(
t,
manage.Data.EventsTotal,
int64(1),
"manage view must show at least one event for "+c.name,
)
})
}
st.notifySvc.Wait()
}

View File

@ -0,0 +1,108 @@
// ©AngelaMos | 2026
// server.go
package server
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/config"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/health"
)
type Server struct {
httpServer *http.Server
router *chi.Mux
config config.ServerConfig
healthHandler *health.Handler
logger *slog.Logger
}
type Config struct {
ServerConfig config.ServerConfig
HealthHandler *health.Handler
Logger *slog.Logger
}
func New(cfg Config) *Server {
router := chi.NewRouter()
router.Use(chimw.CleanPath)
router.Use(chimw.StripSlashes)
return &Server{
httpServer: &http.Server{
Addr: cfg.ServerConfig.Address(),
Handler: router,
ReadTimeout: cfg.ServerConfig.ReadTimeout,
WriteTimeout: cfg.ServerConfig.WriteTimeout,
IdleTimeout: cfg.ServerConfig.IdleTimeout,
},
router: router,
config: cfg.ServerConfig,
healthHandler: cfg.HealthHandler,
logger: cfg.Logger,
}
}
func (s *Server) Router() *chi.Mux {
return s.router
}
func (s *Server) Start() error {
s.logger.Info("starting HTTP server",
"addr", s.config.Address(),
"read_timeout", s.config.ReadTimeout,
"write_timeout", s.config.WriteTimeout,
"idle_timeout", s.config.IdleTimeout,
)
if err := s.httpServer.ListenAndServe(); err != nil &&
!errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("http server error: %w", err)
}
return nil
}
func (s *Server) Shutdown(ctx context.Context, drainDelay time.Duration) error {
s.logger.Info("initiating graceful shutdown")
s.logger.Info("marking server as not ready")
if s.healthHandler != nil {
s.healthHandler.SetReady(false)
s.healthHandler.SetShutdown(true)
}
s.logger.Info("waiting for load balancer to drain",
"delay", drainDelay,
)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(drainDelay):
}
s.logger.Info("stopping HTTP server")
shutdownCtx, cancel := context.WithTimeout(ctx, s.config.ShutdownTimeout)
defer cancel()
if err := s.httpServer.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("http server shutdown: %w", err)
}
s.logger.Info("HTTP server stopped gracefully")
return nil
}
func (s *Server) Address() string {
return s.httpServer.Addr
}

View File

@ -0,0 +1,62 @@
// ©AngelaMos | 2026
// postgres.go
package testutil
import (
"context"
"database/sql"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/core"
)
func NewTestDB(t *testing.T) *sql.DB {
t.Helper()
ctx := context.Background()
pgContainer, err := tcpostgres.Run(ctx, "postgres:18-alpine",
tcpostgres.WithDatabase("canary_test"),
tcpostgres.WithUsername("test"),
tcpostgres.WithPassword("test"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(60*time.Second),
),
)
require.NoError(t, err)
t.Cleanup(func() {
if termErr := pgContainer.Terminate(
context.Background(),
); termErr != nil {
t.Logf("postgres container terminate: %v", termErr)
}
})
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err)
db, err := sql.Open("pgx", connStr)
require.NoError(t, err)
t.Cleanup(func() {
if closeErr := db.Close(); closeErr != nil {
t.Logf("db close: %v", closeErr)
}
})
require.NoError(t, db.Ping())
require.NoError(t, core.RunMigrations(db))
return db
}

View File

@ -0,0 +1,6 @@
// ©AngelaMos | 2026
// ptr.go
package testutil
func Ptr[T any](v T) *T { return &v }

View File

@ -0,0 +1,72 @@
// ©AngelaMos | 2026
// contract.go
package token
import (
"context"
"net/http"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
)
type ArtifactKind string
const (
KindURL ArtifactKind = "url"
KindFile ArtifactKind = "file"
KindText ArtifactKind = "text"
KindConnectionString ArtifactKind = "connection_string"
)
type Artifact struct {
Kind ArtifactKind
URL string
Filename string
Content []byte
ContentType string
ConnectionString string
DestinationURL string
}
type TriggerResponse struct {
StatusCode int
ContentType string
Body []byte
RedirectURL string
ExtraHeaders map[string]string
}
type Generator interface {
Type() Type
Generate(
ctx context.Context,
t *Token,
baseURL string,
) (Artifact, error)
Trigger(
ctx context.Context,
t *Token,
r *http.Request,
) (*event.Event, *TriggerResponse, error)
}
func (t *Token) NotifyInfo() event.NotifyInfo {
return event.NotifyInfo{
TokenID: t.ID,
ManageID: t.ManageID,
Type: string(t.Type),
Memo: t.Memo,
AlertChannel: string(t.AlertChannel),
TelegramBot: derefString(t.TelegramBot),
TelegramChat: derefString(t.TelegramChat),
WebhookURL: derefString(t.WebhookURL),
}
}
func derefString(s *string) string {
if s == nil {
return ""
}
return *s
}

View File

@ -0,0 +1,98 @@
// ©AngelaMos | 2026
// dto.go
package token
import (
"encoding/json"
"time"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
)
type CreateRequest struct {
Type Type `json:"type" validate:"required,oneof=webbug slowredirect docx pdf kubeconfig envfile mysql"`
Memo string `json:"memo" validate:"max=256"`
Filename string `json:"filename" validate:"max=128"`
AlertChannel AlertChannel `json:"alert_channel" validate:"required,oneof=telegram webhook"`
TelegramBot string `json:"telegram_bot" validate:"required_if=AlertChannel telegram"`
TelegramChat string `json:"telegram_chat" validate:"required_if=AlertChannel telegram"`
WebhookURL string `json:"webhook_url" validate:"required_if=AlertChannel webhook,omitempty,url"`
Metadata json.RawMessage `json:"metadata"`
TurnstileResp string `json:"cf_turnstile_response"`
}
type Response struct {
ID string `json:"id"`
ManageID string `json:"manage_id"`
Type Type `json:"type"`
Memo string `json:"memo"`
Filename *string `json:"filename"`
AlertChannel AlertChannel `json:"alert_channel"`
CreatedAt time.Time `json:"created_at"`
TriggerCount int64 `json:"trigger_count"`
LastTriggered *time.Time `json:"last_triggered"`
Enabled bool `json:"enabled"`
TriggerURL string `json:"trigger_url"`
ManageURL string `json:"manage_url"`
Metadata json.RawMessage `json:"metadata,omitempty"`
}
func (t *Token) ToResponse(triggerURL, manageURL string) Response {
return Response{
ID: t.ID,
ManageID: t.ManageID,
Type: t.Type,
Memo: t.Memo,
Filename: t.Filename,
AlertChannel: t.AlertChannel,
CreatedAt: t.CreatedAt,
TriggerCount: t.TriggerCount,
LastTriggered: t.LastTriggered,
Enabled: t.Enabled,
TriggerURL: triggerURL,
ManageURL: manageURL,
Metadata: t.Metadata,
}
}
type ManageTokenView struct {
ID string `json:"id"`
Type Type `json:"type"`
Memo string `json:"memo"`
Filename *string `json:"filename"`
AlertChannel AlertChannel `json:"alert_channel"`
CreatedAt time.Time `json:"created_at"`
TriggerCount int64 `json:"trigger_count"`
LastTriggered *time.Time `json:"last_triggered"`
Enabled bool `json:"enabled"`
TriggerURL string `json:"trigger_url"`
}
type ManagePage struct {
NextCursor string `json:"next_cursor"`
HasMore bool `json:"has_more"`
}
type ManageResponse struct {
Token ManageTokenView `json:"token"`
Events []event.Response `json:"events"`
EventsTotal int64 `json:"events_total"`
EventsSilencedActive int64 `json:"events_silenced_active"`
Page ManagePage `json:"page"`
}
func (t *Token) ToManageView(triggerURL string) ManageTokenView {
return ManageTokenView{
ID: t.ID,
Type: t.Type,
Memo: t.Memo,
Filename: t.Filename,
AlertChannel: t.AlertChannel,
CreatedAt: t.CreatedAt,
TriggerCount: t.TriggerCount,
LastTriggered: t.LastTriggered,
Enabled: t.Enabled,
TriggerURL: triggerURL,
}
}

View File

@ -0,0 +1,64 @@
// ©AngelaMos | 2026
// entity.go
package token
import (
"encoding/json"
"time"
)
type Type string
const (
TypeWebbug Type = "webbug"
TypeSlowRedirect Type = "slowredirect"
TypeDocx Type = "docx"
TypePDF Type = "pdf"
TypeKubeconfig Type = "kubeconfig"
TypeEnvfile Type = "envfile"
TypeMySQL Type = "mysql"
)
func (t Type) Valid() bool {
switch t {
case TypeWebbug, TypeSlowRedirect, TypeDocx, TypePDF,
TypeKubeconfig, TypeEnvfile, TypeMySQL:
return true
}
return false
}
type AlertChannel string
const (
ChannelTelegram AlertChannel = "telegram"
ChannelWebhook AlertChannel = "webhook"
)
func (c AlertChannel) Valid() bool {
switch c {
case ChannelTelegram, ChannelWebhook:
return true
}
return false
}
type Token struct {
ID string `db:"id" json:"id"`
ManageID string `db:"manage_id" json:"manage_id"`
Type Type `db:"type" json:"type"`
Memo string `db:"memo" json:"memo"`
Filename *string `db:"filename" json:"filename"`
AlertChannel AlertChannel `db:"alert_channel" json:"alert_channel"`
TelegramBot *string `db:"telegram_bot" json:"-"`
TelegramChat *string `db:"telegram_chat" json:"-"`
WebhookURL *string `db:"webhook_url" json:"-"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
CreatedIP string `db:"created_ip" json:"-"`
CreatedFP string `db:"created_fp" json:"-"`
Enabled bool `db:"enabled" json:"enabled"`
TriggerCount int64 `db:"trigger_count" json:"trigger_count"`
LastTriggered *time.Time `db:"last_triggered" json:"last_triggered"`
Metadata json.RawMessage `db:"metadata" json:"metadata"`
}

View File

@ -0,0 +1,152 @@
// ©AngelaMos | 2026
// generator.go
package docx
import (
"archive/zip"
"bytes"
"context"
_ "embed"
"fmt"
"io"
"net/http"
"strings"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/pixel"
)
const (
headerReferer = "Referer"
headerCacheControl = "Cache-Control"
headerPragma = "Pragma"
cacheControlNoStore = "no-store, no-cache, must-revalidate, max-age=0"
pragmaNoCache = "no-cache"
triggerPathPrefix = "/c/"
placeholder = "HONEY_TRACK_URL"
footerEntry = "word/footer2.xml"
contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
defaultFilename = "Document.docx"
)
//go:embed template/template.docx
var docxTemplate []byte
type Generator struct{}
func New() *Generator { return &Generator{} }
func (g *Generator) Type() token.Type { return token.TypeDocx }
func (g *Generator) Generate(
_ context.Context,
t *token.Token,
baseURL string,
) (generators.Artifact, error) {
triggerURL := strings.TrimRight(baseURL, "/") + triggerPathPrefix + t.ID
patched, err := patchTemplate(docxTemplate, triggerURL)
if err != nil {
return generators.Artifact{}, err
}
return generators.Artifact{
Kind: generators.KindFile,
Filename: resolveFilename(t.Filename),
Content: patched,
ContentType: contentType,
}, nil
}
func (g *Generator) Trigger(
_ context.Context,
t *token.Token,
r *http.Request,
) (*event.Event, *generators.TriggerResponse, error) {
resp := &generators.TriggerResponse{
StatusCode: http.StatusOK,
ContentType: pixel.ContentType,
Body: pixel.Clone(),
ExtraHeaders: map[string]string{
headerCacheControl: cacheControlNoStore,
headerPragma: pragmaNoCache,
},
}
if t == nil {
return nil, resp, nil
}
evt := &event.Event{
TokenID: t.ID,
SourceIP: middleware.RealIP(r),
UserAgent: middleware.OptionalHeader(r.UserAgent()),
Referer: middleware.OptionalHeader(r.Header.Get(headerReferer)),
}
return evt, resp, nil
}
func resolveFilename(name *string) string {
if name == nil {
return defaultFilename
}
trimmed := strings.TrimSpace(*name)
if trimmed == "" {
return defaultFilename
}
return trimmed
}
func patchTemplate(template []byte, triggerURL string) ([]byte, error) {
in, err := zip.NewReader(bytes.NewReader(template), int64(len(template)))
if err != nil {
return nil, fmt.Errorf("docx: parse template: %w", err)
}
var out bytes.Buffer
w := zip.NewWriter(&out)
for _, f := range in.File {
rc, oErr := f.Open()
if oErr != nil {
return nil, fmt.Errorf("docx: open %s: %w", f.Name, oErr)
}
body, rErr := io.ReadAll(rc)
cErr := rc.Close()
if rErr != nil {
return nil, fmt.Errorf("docx: read %s: %w", f.Name, rErr)
}
if cErr != nil {
return nil, fmt.Errorf("docx: close %s: %w", f.Name, cErr)
}
if f.Name == footerEntry {
body = bytes.Replace(
body,
[]byte(placeholder),
[]byte(triggerURL),
1,
)
}
hdr := &zip.FileHeader{Name: f.Name, Method: f.Method}
fw, hErr := w.CreateHeader(hdr)
if hErr != nil {
return nil, fmt.Errorf("docx: create %s: %w", f.Name, hErr)
}
if _, wErr := fw.Write(body); wErr != nil {
return nil, fmt.Errorf("docx: write %s: %w", f.Name, wErr)
}
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("docx: close zip writer: %w", err)
}
return out.Bytes(), nil
}

View File

@ -0,0 +1,591 @@
// ©AngelaMos | 2026
// generator_test.go
package docx_test
import (
"archive/zip"
"bytes"
"context"
_ "embed"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/docx"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/pixel"
)
//go:embed template/template.docx
var rawTemplate []byte
const (
testBaseURL = "https://canary.example.com"
placeholderLiteral = "HONEY_TRACK_URL"
footerEntryName = "word/footer2.xml"
docxContentTypeMIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
defaultFilename = "Document.docx"
cacheControlNoStoreValue = "no-store, no-cache, must-revalidate, max-age=0"
pragmaNoCacheValue = "no-cache"
gifByteLength = 43
)
func newDocxToken(id string) *token.Token {
return &token.Token{
ID: id,
ManageID: "manage-" + id,
Type: token.TypeDocx,
Memo: "unit test docx",
AlertChannel: token.ChannelWebhook,
Enabled: true,
}
}
func newDocxTokenWithFilename(id, filename string) *token.Token {
tok := newDocxToken(id)
tok.Filename = &filename
return tok
}
func readFooterXML(t *testing.T, archive []byte) []byte {
t.Helper()
r, err := zip.NewReader(
bytes.NewReader(archive),
int64(len(archive)),
)
require.NoError(t, err)
for _, f := range r.File {
if f.Name != footerEntryName {
continue
}
rc, oErr := f.Open()
require.NoError(t, oErr)
body, rErr := io.ReadAll(rc)
require.NoError(t, rc.Close())
require.NoError(t, rErr)
return body
}
t.Fatalf("entry %q not found in zip", footerEntryName)
return nil
}
func zipEntryBodies(t *testing.T, archive []byte) map[string][]byte {
t.Helper()
r, err := zip.NewReader(
bytes.NewReader(archive),
int64(len(archive)),
)
require.NoError(t, err)
out := make(map[string][]byte, len(r.File))
for _, f := range r.File {
rc, oErr := f.Open()
require.NoError(t, oErr)
body, rErr := io.ReadAll(rc)
require.NoError(t, rc.Close())
require.NoError(t, rErr)
out[f.Name] = body
}
return out
}
func zipEntryMethods(t *testing.T, archive []byte) map[string]uint16 {
t.Helper()
r, err := zip.NewReader(
bytes.NewReader(archive),
int64(len(archive)),
)
require.NoError(t, err)
out := make(map[string]uint16, len(r.File))
for _, f := range r.File {
out[f.Name] = f.Method
}
return out
}
func TestGenerator_TypeIsDocx(t *testing.T) {
g := docx.New()
require.Equal(t, token.TypeDocx, g.Type())
}
func TestGenerate_ArtifactKindIsFile(t *testing.T) {
g := docx.New()
art, err := g.Generate(
context.Background(),
newDocxToken("abc"),
testBaseURL,
)
require.NoError(t, err)
require.Equal(t, generators.KindFile, art.Kind)
}
func TestGenerate_ContentTypeIsDocxMIME(t *testing.T) {
g := docx.New()
art, err := g.Generate(
context.Background(),
newDocxToken("abc"),
testBaseURL,
)
require.NoError(t, err)
require.Equal(t, docxContentTypeMIME, art.ContentType)
}
func TestGenerate_Filename(t *testing.T) {
g := docx.New()
t.Run("nil Filename defaults to Document.docx", func(t *testing.T) {
art, err := g.Generate(
context.Background(),
newDocxToken("abc"),
testBaseURL,
)
require.NoError(t, err)
require.Equal(t, defaultFilename, art.Filename)
})
t.Run(
"empty Filename pointer defaults to Document.docx",
func(t *testing.T) {
art, err := g.Generate(
context.Background(),
newDocxTokenWithFilename("abc", ""),
testBaseURL,
)
require.NoError(t, err)
require.Equal(t, defaultFilename, art.Filename)
},
)
t.Run(
"whitespace-only Filename defaults to Document.docx",
func(t *testing.T) {
art, err := g.Generate(
context.Background(),
newDocxTokenWithFilename("abc", " "),
testBaseURL,
)
require.NoError(t, err)
require.Equal(t, defaultFilename, art.Filename)
},
)
t.Run("set Filename is preserved (trimmed)", func(t *testing.T) {
art, err := g.Generate(
context.Background(),
newDocxTokenWithFilename("abc", " Q4-Plan.docx "),
testBaseURL,
)
require.NoError(t, err)
require.Equal(t, "Q4-Plan.docx", art.Filename)
})
}
func TestGenerate_TriggerURL(t *testing.T) {
g := docx.New()
t.Run("base URL trailing slash trimmed", func(t *testing.T) {
artA, err := g.Generate(
context.Background(),
newDocxToken("tk1"),
"https://canary.example.com",
)
require.NoError(t, err)
artB, err := g.Generate(
context.Background(),
newDocxToken("tk1"),
"https://canary.example.com/",
)
require.NoError(t, err)
bodyA := readFooterXML(t, artA.Content)
bodyB := readFooterXML(t, artB.Content)
require.Contains(t, string(bodyA), "https://canary.example.com/c/tk1")
require.Contains(t, string(bodyB), "https://canary.example.com/c/tk1")
})
t.Run("base URL subpath preserved", func(t *testing.T) {
art, err := g.Generate(
context.Background(),
newDocxToken("tk2"),
"https://example.com/canary",
)
require.NoError(t, err)
body := readFooterXML(t, art.Content)
require.Contains(t, string(body), "https://example.com/canary/c/tk2")
})
t.Run("different token ids produce distinct outputs", func(t *testing.T) {
artA, err := g.Generate(
context.Background(),
newDocxToken("aaa"),
testBaseURL,
)
require.NoError(t, err)
artB, err := g.Generate(
context.Background(),
newDocxToken("bbb"),
testBaseURL,
)
require.NoError(t, err)
require.NotEqual(t, artA.Content, artB.Content)
})
}
func TestGenerate_OutputIsValidZip(t *testing.T) {
g := docx.New()
art, err := g.Generate(
context.Background(),
newDocxToken("abc"),
testBaseURL,
)
require.NoError(t, err)
r, zErr := zip.NewReader(
bytes.NewReader(art.Content),
int64(len(art.Content)),
)
require.NoError(t, zErr, "generated docx must parse as a zip archive")
require.NotEmpty(t, r.File, "zip must contain at least one entry")
}
func TestGenerate_FooterContainsTriggerURL(t *testing.T) {
g := docx.New()
art, err := g.Generate(
context.Background(),
newDocxToken("token42"),
testBaseURL,
)
require.NoError(t, err)
body := readFooterXML(t, art.Content)
require.Contains(
t,
string(body),
"https://canary.example.com/c/token42",
"footer must reference the canary trigger URL after substitution",
)
}
func TestGenerate_FooterDoesNotContainPlaceholder(t *testing.T) {
g := docx.New()
art, err := g.Generate(
context.Background(),
newDocxToken("xyz"),
testBaseURL,
)
require.NoError(t, err)
body := readFooterXML(t, art.Content)
require.NotContains(
t,
string(body),
placeholderLiteral,
"placeholder must be fully substituted in footer2.xml",
)
}
func TestGenerate_OtherEntriesUnchanged(t *testing.T) {
g := docx.New()
art, err := g.Generate(
context.Background(),
newDocxToken("abc"),
testBaseURL,
)
require.NoError(t, err)
templateBodies := zipEntryBodies(t, rawTemplate)
outputBodies := zipEntryBodies(t, art.Content)
require.Len(
t,
outputBodies,
len(templateBodies),
"output must have the same entry count as the template",
)
for name, tmplBody := range templateBodies {
outBody, ok := outputBodies[name]
require.True(t, ok, "output missing template entry %q", name)
if name == footerEntryName {
require.NotEqual(
t,
tmplBody,
outBody,
"footer2.xml must change after substitution",
)
continue
}
require.Equal(
t,
tmplBody,
outBody,
"non-footer entry %q must be byte-identical to template",
name,
)
}
}
func TestGenerate_PreservesCompressionMethods(t *testing.T) {
g := docx.New()
art, err := g.Generate(
context.Background(),
newDocxToken("abc"),
testBaseURL,
)
require.NoError(t, err)
templateMethods := zipEntryMethods(t, rawTemplate)
outputMethods := zipEntryMethods(t, art.Content)
hasStore, hasDeflate := false, false
for _, m := range templateMethods {
switch m {
case zip.Store:
hasStore = true
case zip.Deflate:
hasDeflate = true
}
}
require.True(
t,
hasStore && hasDeflate,
"template must mix STORE and DEFLATE for this test to be a real regression guard",
)
for name, tmplMethod := range templateMethods {
outMethod, ok := outputMethods[name]
require.True(t, ok, "output missing template entry %q", name)
require.Equal(
t,
tmplMethod,
outMethod,
"entry %q method mismatch (template=%d, output=%d)",
name,
tmplMethod,
outMethod,
)
}
}
func TestTrigger_ReturnsGIFLikeWebbug(t *testing.T) {
g := docx.New()
tok := newDocxToken("abc")
r := httptest.NewRequest(http.MethodGet, "/c/abc", nil)
_, resp, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, pixel.ContentType, resp.ContentType)
require.Len(t, resp.Body, gifByteLength)
require.Equal(t, pixel.Clone(), resp.Body)
require.Equal(
t,
cacheControlNoStoreValue,
resp.ExtraHeaders["Cache-Control"],
)
require.Equal(t, pragmaNoCacheValue, resp.ExtraHeaders["Pragma"])
}
func TestTrigger_RecordsEventWithRequestMetadata(t *testing.T) {
g := docx.New()
tok := newDocxToken("token1")
t.Run(
"captures token id, source ip, user agent, referer",
func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/c/token1", nil)
r.Header.Set("CF-Connecting-IP", "203.0.113.50")
r.Header.Set("User-Agent", "LibreOffice/24.2")
r.Header.Set("Referer", "https://victim.example.com/inbox")
evt, _, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
require.NotNil(t, evt)
require.Equal(t, "token1", evt.TokenID)
require.Equal(t, "203.0.113.50", evt.SourceIP)
require.NotNil(t, evt.UserAgent)
require.Equal(t, "LibreOffice/24.2", *evt.UserAgent)
require.NotNil(t, evt.Referer)
require.Equal(t, "https://victim.example.com/inbox", *evt.Referer)
},
)
t.Run("source ip precedence", func(t *testing.T) {
cases := []struct {
name string
headers map[string]string
remote string
wantIP string
}{
{
name: "CF wins over XFF and XRI",
headers: map[string]string{
"CF-Connecting-IP": "203.0.113.10",
"X-Forwarded-For": "198.51.100.1, 198.51.100.2",
"X-Real-IP": "192.0.2.99",
},
remote: "127.0.0.1:9999",
wantIP: "203.0.113.10",
},
{
name: "XFF leftmost wins over XRI when no CF",
headers: map[string]string{
"X-Forwarded-For": "198.51.100.1, 198.51.100.7",
"X-Real-IP": "192.0.2.99",
},
remote: "127.0.0.1:9999",
wantIP: "198.51.100.1",
},
{
name: "XFF trailing-comma falls through to last non-empty",
headers: map[string]string{
"X-Forwarded-For": "198.51.100.1, ",
"X-Real-IP": "192.0.2.99",
},
remote: "127.0.0.1:9999",
wantIP: "198.51.100.1",
},
{
name: "XFF entirely empty entries fall through to XRI",
headers: map[string]string{
"X-Forwarded-For": ", ,",
"X-Real-IP": "192.0.2.99",
},
remote: "127.0.0.1:9999",
wantIP: "192.0.2.99",
},
{
name: "XRI when no CF or XFF",
headers: map[string]string{
"X-Real-IP": "192.0.2.99",
},
remote: "127.0.0.1:9999",
wantIP: "192.0.2.99",
},
{
name: "RemoteAddr IPv4 strips port",
headers: nil,
remote: "127.0.0.1:9999",
wantIP: "127.0.0.1",
},
{
name: "RemoteAddr IPv6 strips brackets and port",
headers: nil,
remote: "[2001:db8::1]:54321",
wantIP: "2001:db8::1",
},
{
name: "RemoteAddr loopback IPv6 strips brackets and port",
headers: nil,
remote: "[::1]:9999",
wantIP: "::1",
},
{
name: "RemoteAddr without port falls back to raw value",
headers: nil,
remote: "127.0.0.1",
wantIP: "127.0.0.1",
},
{
name: "XFF mixed IPv4+IPv6 leftmost",
headers: map[string]string{
"X-Forwarded-For": "198.51.100.1, 2001:db8::dead",
},
remote: "127.0.0.1:9999",
wantIP: "198.51.100.1",
},
{
name: "CF value is trimmed of whitespace",
headers: map[string]string{
"CF-Connecting-IP": " 203.0.113.10 ",
},
remote: "127.0.0.1:9999",
wantIP: "203.0.113.10",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/c/token1", nil)
for k, v := range tc.headers {
r.Header.Set(k, v)
}
r.RemoteAddr = tc.remote
evt, _, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
require.NotNil(t, evt)
require.Equal(t, tc.wantIP, evt.SourceIP)
})
}
})
t.Run(
"missing user agent and referer record as nil pointers",
func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/c/token1", nil)
r.Header.Del("User-Agent")
r.Header.Del("Referer")
r.Header.Set("CF-Connecting-IP", "203.0.113.5")
evt, _, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
require.NotNil(t, evt)
require.Nil(
t,
evt.UserAgent,
"absent user agent must map to nil, not empty string",
)
require.Nil(
t,
evt.Referer,
"absent referer must map to nil, not empty string",
)
},
)
}
func TestTrigger_ResponseBodyIsIndependentCopyPerCall(t *testing.T) {
g := docx.New()
tok := newDocxToken("abc")
r := httptest.NewRequest(http.MethodGet, "/c/abc", nil)
_, resp1, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
_, resp2, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
resp1.Body[0] = 0x00
require.Equal(
t,
byte(0x47),
resp2.Body[0],
"each Trigger call must produce an independent body slice",
)
}
func TestTrigger_TokenNotFound_StillReturnsGIF(t *testing.T) {
g := docx.New()
r := httptest.NewRequest(http.MethodGet, "/c/does-not-exist", nil)
r.Header.Set("CF-Connecting-IP", "203.0.113.100")
r.Header.Set("User-Agent", "curl/8.0.0")
evt, resp, err := g.Trigger(context.Background(), nil, r)
require.NoError(
t,
err,
"nil-token path must not error (spec §8.5 defense-in-depth)",
)
require.NotNil(t, resp, "nil-token path must still return GIF response")
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, pixel.ContentType, resp.ContentType)
require.Equal(t, pixel.Clone(), resp.Body)
require.Nil(
t,
evt,
"nil-token path returns nil event so the handler cannot persist a row with empty TokenID (FK violation)",
)
}

View File

@ -0,0 +1,204 @@
// ©AngelaMos | 2026
// generator.go
package envfile
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"fmt"
"math/big"
"net/http"
"strings"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/envfile/recipes"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/pixel"
)
const (
headerReferer = "Referer"
headerCacheControl = "Cache-Control"
headerPragma = "Pragma"
cacheControlNoStore = "no-store, no-cache, must-revalidate, max-age=0"
pragmaNoCache = "no-cache"
triggerPathPrefix = "/c/"
metadataIncludeKey = "include_keys"
contentType = "text/plain; charset=utf-8"
defaultFilename = ".env"
canaryComment = "Internal monitoring (Datadog-style integration)"
canaryEndpointKey = "INTERNAL_METRICS_ENDPOINT"
canaryTokenKey = "INTERNAL_METRICS_TOKEN"
canaryTokenPrefix = "tok_live_"
canaryTokenLength = 32
envfileHeader = "# Production environment\n" +
"NODE_ENV=production\n" +
"PORT=8080\n\n"
)
var defaultIncludeKeys = []string{"aws", "db"}
type Generator struct{}
func New() *Generator { return &Generator{} }
func (g *Generator) Type() token.Type { return token.TypeEnvfile }
func (g *Generator) Generate(
_ context.Context,
t *token.Token,
baseURL string,
) (generators.Artifact, error) {
keys := extractIncludeKeys(t.Metadata)
triggerURL := strings.TrimRight(baseURL, "/") + triggerPathPrefix + t.ID
sections := buildSections(keys, triggerURL)
if err := shuffleSections(sections); err != nil {
return generators.Artifact{}, fmt.Errorf(
"envfile: shuffle sections: %w",
err,
)
}
body := renderSections(sections)
return generators.Artifact{
Kind: generators.KindText,
Filename: resolveFilename(t.Filename),
Content: body,
ContentType: contentType,
}, nil
}
func (g *Generator) Trigger(
_ context.Context,
t *token.Token,
r *http.Request,
) (*event.Event, *generators.TriggerResponse, error) {
resp := &generators.TriggerResponse{
StatusCode: http.StatusOK,
ContentType: pixel.ContentType,
Body: pixel.Clone(),
ExtraHeaders: map[string]string{
headerCacheControl: cacheControlNoStore,
headerPragma: pragmaNoCache,
},
}
if t == nil {
return nil, resp, nil
}
evt := &event.Event{
TokenID: t.ID,
SourceIP: middleware.RealIP(r),
UserAgent: middleware.OptionalHeader(r.UserAgent()),
Referer: middleware.OptionalHeader(r.Header.Get(headerReferer)),
}
return evt, resp, nil
}
func buildSections(keys []string, triggerURL string) [][]recipes.EnvLine {
sections := make([][]recipes.EnvLine, 0, len(keys)+1)
for _, k := range keys {
if r, ok := recipes.Get(k); ok {
sections = append(sections, r.Generate())
}
}
sections = append(sections, []recipes.EnvLine{
{Comment: canaryComment},
{Key: canaryEndpointKey, Value: triggerURL},
{
Key: canaryTokenKey,
Value: canaryTokenPrefix +
recipes.RandomAlnumMixed(canaryTokenLength),
},
})
return sections
}
func shuffleSections(sections [][]recipes.EnvLine) error {
for i := len(sections) - 1; i > 0; i-- {
jBig, err := rand.Int(rand.Reader, big.NewInt(int64(i+1)))
if err != nil {
return fmt.Errorf("crypto/rand.Int: %w", err)
}
j := int(jBig.Int64())
sections[i], sections[j] = sections[j], sections[i]
}
return nil
}
func renderSections(sections [][]recipes.EnvLine) []byte {
var buf bytes.Buffer
buf.WriteString(envfileHeader)
for _, s := range sections {
for _, l := range s {
if l.Comment != "" {
fmt.Fprintf(&buf, "# %s\n", l.Comment)
}
if l.Key != "" {
fmt.Fprintf(&buf, "%s=%s\n", l.Key, l.Value)
}
}
buf.WriteString("\n")
}
return buf.Bytes()
}
func extractIncludeKeys(metadata json.RawMessage) []string {
if len(metadata) == 0 {
return cloneStrings(defaultIncludeKeys)
}
var m map[string]json.RawMessage
if err := json.Unmarshal(metadata, &m); err != nil {
return cloneStrings(defaultIncludeKeys)
}
raw, ok := m[metadataIncludeKey]
if !ok {
return cloneStrings(defaultIncludeKeys)
}
var keys []string
if err := json.Unmarshal(raw, &keys); err != nil || len(keys) == 0 {
return cloneStrings(defaultIncludeKeys)
}
out := make([]string, 0, len(keys))
for _, k := range keys {
k = strings.TrimSpace(k)
if k == "" {
continue
}
out = append(out, k)
}
if len(out) == 0 {
return cloneStrings(defaultIncludeKeys)
}
return out
}
func cloneStrings(in []string) []string {
out := make([]string, len(in))
copy(out, in)
return out
}
func resolveFilename(name *string) string {
if name == nil {
return defaultFilename
}
trimmed := strings.TrimSpace(*name)
if trimmed == "" {
return defaultFilename
}
return trimmed
}

View File

@ -0,0 +1,594 @@
// ©AngelaMos | 2026
// generator_test.go
package envfile_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/envfile"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/pixel"
)
const (
testBaseURL = "https://canary.example.com"
envfileContentTypeMIME = "text/plain; charset=utf-8"
defaultFilenameValue = ".env"
cacheControlNoStoreValue = "no-store, no-cache, must-revalidate, max-age=0"
pragmaNoCacheValue = "no-cache"
gifByteLength = 43
)
func newEnvfileToken(id string) *token.Token {
return &token.Token{
ID: id,
ManageID: "manage-" + id,
Type: token.TypeEnvfile,
Memo: "unit test envfile",
AlertChannel: token.ChannelWebhook,
Enabled: true,
}
}
func newEnvfileTokenWithFilename(id, filename string) *token.Token {
tok := newEnvfileToken(id)
tok.Filename = &filename
return tok
}
func newEnvfileTokenWithIncludeKeys(keys []string) *token.Token {
tok := newEnvfileToken("abc")
raw, err := json.Marshal(map[string]any{"include_keys": keys})
if err != nil {
panic(err)
}
tok.Metadata = raw
return tok
}
func newEnvfileTokenWithRawMetadata(metadata string) *token.Token {
tok := newEnvfileToken("abc")
tok.Metadata = json.RawMessage(metadata)
return tok
}
func envLines(content []byte) []string {
return strings.Split(string(content), "\n")
}
func TestGenerator_TypeIsEnvfile(t *testing.T) {
g := envfile.New()
require.Equal(t, token.TypeEnvfile, g.Type())
}
func TestGenerate_ArtifactKindIsText(t *testing.T) {
g := envfile.New()
art, err := g.Generate(
context.Background(),
newEnvfileToken("abc"),
testBaseURL,
)
require.NoError(t, err)
require.Equal(t, generators.KindText, art.Kind)
}
func TestGenerate_ContentTypeIsTextPlain(t *testing.T) {
g := envfile.New()
art, err := g.Generate(
context.Background(),
newEnvfileToken("abc"),
testBaseURL,
)
require.NoError(t, err)
require.Equal(t, envfileContentTypeMIME, art.ContentType)
}
func TestGenerate_Filename(t *testing.T) {
g := envfile.New()
t.Run("nil Filename defaults to .env", func(t *testing.T) {
art, err := g.Generate(
context.Background(),
newEnvfileToken("abc"),
testBaseURL,
)
require.NoError(t, err)
require.Equal(t, defaultFilenameValue, art.Filename)
})
t.Run("empty Filename pointer defaults to .env", func(t *testing.T) {
art, err := g.Generate(
context.Background(),
newEnvfileTokenWithFilename("abc", ""),
testBaseURL,
)
require.NoError(t, err)
require.Equal(t, defaultFilenameValue, art.Filename)
})
t.Run("whitespace Filename defaults to .env", func(t *testing.T) {
art, err := g.Generate(
context.Background(),
newEnvfileTokenWithFilename("abc", " "),
testBaseURL,
)
require.NoError(t, err)
require.Equal(t, defaultFilenameValue, art.Filename)
})
t.Run("set Filename is preserved (trimmed)", func(t *testing.T) {
art, err := g.Generate(
context.Background(),
newEnvfileTokenWithFilename("abc", " .env.production "),
testBaseURL,
)
require.NoError(t, err)
require.Equal(t, ".env.production", art.Filename)
})
}
func TestGenerate_ContainsHeader(t *testing.T) {
g := envfile.New()
art, err := g.Generate(
context.Background(),
newEnvfileToken("abc"),
testBaseURL,
)
require.NoError(t, err)
body := string(art.Content)
require.Contains(t, body, "# Production environment")
require.Contains(t, body, "NODE_ENV=production")
require.Contains(t, body, "PORT=8080")
}
func TestGenerate_EmbedsCanaryURL(t *testing.T) {
g := envfile.New()
art, err := g.Generate(
context.Background(),
newEnvfileToken("token42"),
testBaseURL,
)
require.NoError(t, err)
body := string(art.Content)
require.Contains(
t,
body,
"INTERNAL_METRICS_ENDPOINT=https://canary.example.com/c/token42",
"envfile must contain the canary URL as the INTERNAL_METRICS_ENDPOINT line",
)
}
func TestGenerate_EmbedsCanaryToken(t *testing.T) {
g := envfile.New()
art, err := g.Generate(
context.Background(),
newEnvfileToken("abc"),
testBaseURL,
)
require.NoError(t, err)
body := string(art.Content)
require.Regexp(
t,
`INTERNAL_METRICS_TOKEN=tok_live_[A-Za-z0-9]{32}`,
body,
"INTERNAL_METRICS_TOKEN must follow the tok_live_ + 32-alnum format",
)
}
func TestGenerate_TriggerURLTrailingSlashTrim(t *testing.T) {
g := envfile.New()
artA, err := g.Generate(
context.Background(),
newEnvfileToken("tk1"),
"https://canary.example.com",
)
require.NoError(t, err)
artB, err := g.Generate(
context.Background(),
newEnvfileToken("tk1"),
"https://canary.example.com/",
)
require.NoError(t, err)
require.Contains(
t,
string(artA.Content),
"INTERNAL_METRICS_ENDPOINT=https://canary.example.com/c/tk1",
)
require.Contains(
t,
string(artB.Content),
"INTERNAL_METRICS_ENDPOINT=https://canary.example.com/c/tk1",
)
}
func TestGenerate_DefaultIncludeKeysAreAWSAndDB(t *testing.T) {
g := envfile.New()
art, err := g.Generate(
context.Background(),
newEnvfileToken("abc"),
testBaseURL,
)
require.NoError(t, err)
body := string(art.Content)
require.Contains(
t,
body,
"AWS_ACCESS_KEY_ID=",
"default include_keys should include aws",
)
require.Contains(
t,
body,
"DATABASE_URL=postgres://",
"default include_keys should include db",
)
require.NotContains(
t,
body,
"STRIPE_SECRET_KEY=",
"default include_keys must NOT include stripe",
)
require.NotContains(
t,
body,
"GITHUB_TOKEN=",
"default include_keys must NOT include github",
)
}
func TestGenerate_IncludeKeysFromMetadata(t *testing.T) {
g := envfile.New()
tok := newEnvfileTokenWithIncludeKeys([]string{"stripe", "github"})
art, err := g.Generate(context.Background(), tok, testBaseURL)
require.NoError(t, err)
body := string(art.Content)
require.Contains(t, body, "STRIPE_SECRET_KEY=")
require.Contains(t, body, "GITHUB_TOKEN=")
require.NotContains(t, body, "AWS_ACCESS_KEY_ID=")
require.NotContains(t, body, "DATABASE_URL=postgres://")
}
func TestGenerate_UnknownKeyInMetadataSkipped(t *testing.T) {
g := envfile.New()
tok := newEnvfileTokenWithIncludeKeys(
[]string{"aws", "nonexistent", "stripe"},
)
art, err := g.Generate(context.Background(), tok, testBaseURL)
require.NoError(t, err)
body := string(art.Content)
require.Contains(t, body, "AWS_ACCESS_KEY_ID=")
require.Contains(t, body, "STRIPE_SECRET_KEY=")
require.NotContains(t, body, "nonexistent")
}
func TestGenerate_MalformedMetadataFallsBackToDefaults(t *testing.T) {
g := envfile.New()
cases := []struct {
name string
metadata string
}{
{"empty raw", ""},
{"invalid json", "{not valid"},
{"include_keys is not array", `{"include_keys": "aws"}`},
{"include_keys is null", `{"include_keys": null}`},
{"include_keys is empty array", `{"include_keys": []}`},
{"missing include_keys field", `{"other_field": "value"}`},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
tok := newEnvfileTokenWithRawMetadata(tc.metadata)
art, err := g.Generate(context.Background(), tok, testBaseURL)
require.NoError(t, err)
body := string(art.Content)
require.Contains(
t,
body,
"AWS_ACCESS_KEY_ID=",
"malformed metadata must fall back to aws default",
)
require.Contains(
t,
body,
"DATABASE_URL=postgres://",
"malformed metadata must fall back to db default",
)
})
}
}
func TestGenerate_IncludeKeysFiltersEmptyStrings(t *testing.T) {
g := envfile.New()
tok := newEnvfileTokenWithIncludeKeys(
[]string{"", " ", "aws"},
)
art, err := g.Generate(context.Background(), tok, testBaseURL)
require.NoError(t, err)
require.Contains(t, string(art.Content), "AWS_ACCESS_KEY_ID=")
}
func TestGenerate_IncludeKeysAllWhitespaceFallsBackToDefaults(t *testing.T) {
g := envfile.New()
tok := newEnvfileTokenWithIncludeKeys([]string{"", " "})
art, err := g.Generate(context.Background(), tok, testBaseURL)
require.NoError(t, err)
body := string(art.Content)
require.Contains(t, body, "AWS_ACCESS_KEY_ID=")
require.Contains(t, body, "DATABASE_URL=postgres://")
}
func TestGenerate_SectionShuffleProducesVariableOrder(t *testing.T) {
g := envfile.New()
tok := newEnvfileTokenWithIncludeKeys(
[]string{"aws", "stripe", "github", "db"},
)
canaryPositions := make(map[int]struct{})
for range 30 {
art, err := g.Generate(context.Background(), tok, testBaseURL)
require.NoError(t, err)
lines := envLines(art.Content)
for i, line := range lines {
if strings.HasPrefix(line, "INTERNAL_METRICS_ENDPOINT=") {
canaryPositions[i] = struct{}{}
break
}
}
}
require.Greater(
t,
len(canaryPositions),
1,
"shuffle must place the canary section at varying positions across 30 invocations",
)
}
func TestGenerate_DistinctInvocationsProduceDistinctOutputs(t *testing.T) {
g := envfile.New()
seen := make(map[string]struct{})
for range 10 {
art, err := g.Generate(
context.Background(),
newEnvfileToken("abc"),
testBaseURL,
)
require.NoError(t, err)
seen[string(art.Content)] = struct{}{}
}
require.Greater(
t,
len(seen),
8,
"random bait + random canary token + shuffle should produce near-unique outputs",
)
}
func TestGenerate_TokenIDDoesNotLeakOutsideCanaryLine(t *testing.T) {
g := envfile.New()
art, err := g.Generate(
context.Background(),
newEnvfileToken("uniqueprobe"),
testBaseURL,
)
require.NoError(t, err)
body := string(art.Content)
require.Equal(
t,
1,
strings.Count(body, "uniqueprobe"),
"token id must appear exactly once (in the canary URL), never in bait lines",
)
}
func TestTrigger_ReturnsGIFLikeWebbug(t *testing.T) {
g := envfile.New()
tok := newEnvfileToken("abc")
r := httptest.NewRequest(http.MethodGet, "/c/abc", nil)
_, resp, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, pixel.ContentType, resp.ContentType)
require.Len(t, resp.Body, gifByteLength)
require.Equal(t, pixel.Clone(), resp.Body)
require.Equal(
t,
cacheControlNoStoreValue,
resp.ExtraHeaders["Cache-Control"],
)
require.Equal(t, pragmaNoCacheValue, resp.ExtraHeaders["Pragma"])
}
func TestTrigger_RecordsEventWithRequestMetadata(t *testing.T) {
g := envfile.New()
tok := newEnvfileToken("token1")
t.Run(
"captures token id, source ip, user agent, referer",
func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/c/token1", nil)
r.Header.Set("CF-Connecting-IP", "203.0.113.50")
r.Header.Set("User-Agent", "curl/8.0.0")
r.Header.Set("Referer", "https://victim.example.com/")
evt, _, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
require.NotNil(t, evt)
require.Equal(t, "token1", evt.TokenID)
require.Equal(t, "203.0.113.50", evt.SourceIP)
require.NotNil(t, evt.UserAgent)
require.Equal(t, "curl/8.0.0", *evt.UserAgent)
require.NotNil(t, evt.Referer)
require.Equal(t, "https://victim.example.com/", *evt.Referer)
},
)
t.Run("source ip precedence", func(t *testing.T) {
cases := []struct {
name string
headers map[string]string
remote string
wantIP string
}{
{
name: "CF wins over XFF and XRI",
headers: map[string]string{
"CF-Connecting-IP": "203.0.113.10",
"X-Forwarded-For": "198.51.100.1, 198.51.100.2",
"X-Real-IP": "192.0.2.99",
},
remote: "127.0.0.1:9999",
wantIP: "203.0.113.10",
},
{
name: "XFF leftmost wins over XRI when no CF",
headers: map[string]string{
"X-Forwarded-For": "198.51.100.1, 198.51.100.7",
"X-Real-IP": "192.0.2.99",
},
remote: "127.0.0.1:9999",
wantIP: "198.51.100.1",
},
{
name: "XFF trailing-comma falls through to last non-empty",
headers: map[string]string{
"X-Forwarded-For": "198.51.100.1, ",
"X-Real-IP": "192.0.2.99",
},
remote: "127.0.0.1:9999",
wantIP: "198.51.100.1",
},
{
name: "XFF entirely empty entries fall through to XRI",
headers: map[string]string{
"X-Forwarded-For": ", ,",
"X-Real-IP": "192.0.2.99",
},
remote: "127.0.0.1:9999",
wantIP: "192.0.2.99",
},
{
name: "XRI when no CF or XFF",
headers: map[string]string{
"X-Real-IP": "192.0.2.99",
},
remote: "127.0.0.1:9999",
wantIP: "192.0.2.99",
},
{
name: "RemoteAddr IPv4 strips port",
headers: nil,
remote: "127.0.0.1:9999",
wantIP: "127.0.0.1",
},
{
name: "RemoteAddr IPv6 strips brackets and port",
headers: nil,
remote: "[2001:db8::1]:54321",
wantIP: "2001:db8::1",
},
{
name: "RemoteAddr loopback IPv6 strips brackets and port",
headers: nil,
remote: "[::1]:9999",
wantIP: "::1",
},
{
name: "RemoteAddr without port falls back to raw value",
headers: nil,
remote: "127.0.0.1",
wantIP: "127.0.0.1",
},
{
name: "XFF mixed IPv4+IPv6 leftmost",
headers: map[string]string{
"X-Forwarded-For": "198.51.100.1, 2001:db8::dead",
},
remote: "127.0.0.1:9999",
wantIP: "198.51.100.1",
},
{
name: "CF value is trimmed of whitespace",
headers: map[string]string{
"CF-Connecting-IP": " 203.0.113.10 ",
},
remote: "127.0.0.1:9999",
wantIP: "203.0.113.10",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/c/token1", nil)
for k, v := range tc.headers {
r.Header.Set(k, v)
}
r.RemoteAddr = tc.remote
evt, _, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
require.NotNil(t, evt)
require.Equal(t, tc.wantIP, evt.SourceIP)
})
}
})
t.Run(
"missing user agent and referer record as nil pointers",
func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/c/token1", nil)
r.Header.Del("User-Agent")
r.Header.Del("Referer")
r.Header.Set("CF-Connecting-IP", "203.0.113.5")
evt, _, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
require.NotNil(t, evt)
require.Nil(t, evt.UserAgent)
require.Nil(t, evt.Referer)
},
)
}
func TestTrigger_ResponseBodyIsIndependentCopyPerCall(t *testing.T) {
g := envfile.New()
tok := newEnvfileToken("abc")
r := httptest.NewRequest(http.MethodGet, "/c/abc", nil)
_, resp1, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
_, resp2, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
resp1.Body[0] = 0x00
require.Equal(t, byte(0x47), resp2.Body[0])
}
func TestTrigger_TokenNotFound_StillReturnsGIF(t *testing.T) {
g := envfile.New()
r := httptest.NewRequest(http.MethodGet, "/c/does-not-exist", nil)
r.Header.Set("CF-Connecting-IP", "203.0.113.100")
r.Header.Set("User-Agent", "curl/8.0.0")
evt, resp, err := g.Trigger(context.Background(), nil, r)
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, pixel.ContentType, resp.ContentType)
require.Equal(t, pixel.Clone(), resp.Body)
require.Nil(t, evt)
}

View File

@ -0,0 +1,48 @@
// ©AngelaMos | 2026
// aws.go
package recipes
const (
awsAccessKeyPrefix = "AKIA"
awsAccessKeyBodyLen = 16
awsSecretBytes = 30
awsBucketName = "prod-data-backups"
)
var awsRegions = []string{
"us-east-1",
"us-east-2",
"us-west-2",
"eu-west-1",
"eu-central-1",
"ap-southeast-1",
"ap-northeast-1",
}
type AWS struct{}
func (AWS) Name() string { return keyAWS }
func (AWS) Generate() []EnvLine {
return []EnvLine{
{Comment: "AWS S3 production bucket (" + awsBucketName + ")"},
{
Key: "AWS_ACCESS_KEY_ID",
Value: awsAccessKeyPrefix + RandomAlnumUpper(awsAccessKeyBodyLen),
},
{
Key: "AWS_SECRET_ACCESS_KEY",
Value: RandomBase64(awsSecretBytes),
},
{
Key: "AWS_REGION",
Value: RandomChoice(awsRegions),
},
{
Key: "AWS_S3_BUCKET",
Value: awsBucketName,
},
}
}

View File

@ -0,0 +1,110 @@
// ©AngelaMos | 2026
// aws_test.go
package recipes_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/envfile/recipes"
)
func linesByKey(
t *testing.T,
lines []recipes.EnvLine,
) map[string]recipes.EnvLine {
t.Helper()
out := make(map[string]recipes.EnvLine, len(lines))
for _, l := range lines {
if l.Key != "" {
out[l.Key] = l
}
}
return out
}
func TestAWSRecipe_Name(t *testing.T) {
require.Equal(t, "aws", recipes.AWS{}.Name())
}
func TestAWSRecipe_GeneratesExpectedKeys(t *testing.T) {
lines := recipes.AWS{}.Generate()
byKey := linesByKey(t, lines)
for _, key := range []string{
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_REGION",
"AWS_S3_BUCKET",
} {
_, ok := byKey[key]
require.True(t, ok, "AWS recipe must emit %s", key)
}
}
func TestAWSRecipe_AccessKeyMatchesAKIAFormat(t *testing.T) {
lines := recipes.AWS{}.Generate()
byKey := linesByKey(t, lines)
require.Regexp(
t,
`^AKIA[A-Z0-9]{16}$`,
byKey["AWS_ACCESS_KEY_ID"].Value,
"AWS_ACCESS_KEY_ID must match the AKIA + 16 base32-upper-alnum format gitleaks expects",
)
}
func TestAWSRecipe_SecretAccessKeyIsBase64Like(t *testing.T) {
lines := recipes.AWS{}.Generate()
byKey := linesByKey(t, lines)
require.Regexp(
t,
`^[A-Za-z0-9+/]+={0,2}$`,
byKey["AWS_SECRET_ACCESS_KEY"].Value,
)
require.GreaterOrEqual(
t,
len(byKey["AWS_SECRET_ACCESS_KEY"].Value),
40,
"AWS_SECRET_ACCESS_KEY must be at least 40 chars to match the real-key length floor",
)
}
func TestAWSRecipe_RegionIsAValidAWSRegion(t *testing.T) {
lines := recipes.AWS{}.Generate()
byKey := linesByKey(t, lines)
region := byKey["AWS_REGION"].Value
require.Regexp(
t,
`^[a-z]{2}-[a-z]+-\d+$`,
region,
"AWS_REGION must match the canonical AWS region pattern (xx-name-n)",
)
}
func TestAWSRecipe_HasLeadingComment(t *testing.T) {
lines := recipes.AWS{}.Generate()
require.NotEmpty(t, lines)
require.NotEmpty(
t,
lines[0].Comment,
"AWS recipe must begin with a comment for bait realism",
)
require.Empty(t, lines[0].Key, "comment line must not carry a Key/Value")
}
func TestAWSRecipe_DistinctInvocationsProduceDistinctSecrets(t *testing.T) {
seen := make(map[string]struct{})
for range 20 {
lines := recipes.AWS{}.Generate()
byKey := linesByKey(t, lines)
seen[byKey["AWS_ACCESS_KEY_ID"].Value] = struct{}{}
}
require.Greater(
t,
len(seen),
18,
"20 invocations should produce near-20 distinct access keys",
)
}

View File

@ -0,0 +1,56 @@
// ©AngelaMos | 2026
// db.go
package recipes
import "fmt"
const (
dbPostgresUser = "app_writer"
dbPostgresHost = "db.internal"
dbPostgresPort = 5432
dbPostgresName = "app_prod"
dbRedisUser = "default"
dbRedisHost = "cache-prod.internal"
dbRedisPort = 6379
dbRedisDB = 0
dbPostgresPassLen = 24
dbRedisPassLen = 32
dbPostgresURLFmt = "postgres://%s:%s@%s:%d/%s?sslmode=require"
dbRedisURLFmt = "redis://%s:%s@%s:%d/%d"
)
type DB struct{}
func (DB) Name() string { return keyDB }
func (DB) Generate() []EnvLine {
pgPass := RandomAlnumMixed(dbPostgresPassLen)
redisPass := RandomAlnumMixed(dbRedisPassLen)
pgURL := fmt.Sprintf(
dbPostgresURLFmt,
dbPostgresUser,
pgPass,
dbPostgresHost,
dbPostgresPort,
dbPostgresName,
)
redisURL := fmt.Sprintf(
dbRedisURLFmt,
dbRedisUser,
redisPass,
dbRedisHost,
dbRedisPort,
dbRedisDB,
)
return []EnvLine{
{Comment: "Primary datastore + cache"},
{Key: "DATABASE_URL", Value: pgURL},
{Key: "REDIS_URL", Value: redisURL},
}
}

View File

@ -0,0 +1,75 @@
// ©AngelaMos | 2026
// db_test.go
package recipes_test
import (
"net/url"
"testing"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/envfile/recipes"
)
func TestDBRecipe_Name(t *testing.T) {
require.Equal(t, "db", recipes.DB{}.Name())
}
func TestDBRecipe_GeneratesExpectedKeys(t *testing.T) {
lines := recipes.DB{}.Generate()
byKey := linesByKey(t, lines)
for _, key := range []string{"DATABASE_URL", "REDIS_URL"} {
_, ok := byKey[key]
require.True(t, ok, "DB recipe must emit %s", key)
}
}
func TestDBRecipe_DatabaseURLParsesAsPostgres(t *testing.T) {
lines := recipes.DB{}.Generate()
byKey := linesByKey(t, lines)
parsed, err := url.Parse(byKey["DATABASE_URL"].Value)
require.NoError(t, err)
require.Equal(t, "postgres", parsed.Scheme)
require.Equal(t, "db.internal:5432", parsed.Host)
require.Equal(t, "/app_prod", parsed.Path)
require.Equal(t, "app_writer", parsed.User.Username())
pw, ok := parsed.User.Password()
require.True(t, ok, "DATABASE_URL must carry a password")
require.Regexp(t, `^[A-Za-z0-9]{24}$`, pw)
require.Equal(t, "require", parsed.Query().Get("sslmode"))
}
func TestDBRecipe_RedisURLParsesAsRedis(t *testing.T) {
lines := recipes.DB{}.Generate()
byKey := linesByKey(t, lines)
parsed, err := url.Parse(byKey["REDIS_URL"].Value)
require.NoError(t, err)
require.Equal(t, "redis", parsed.Scheme)
require.Equal(t, "cache-prod.internal:6379", parsed.Host)
require.Equal(t, "/0", parsed.Path)
require.Equal(t, "default", parsed.User.Username())
pw, ok := parsed.User.Password()
require.True(t, ok, "REDIS_URL must carry a password")
require.Regexp(t, `^[A-Za-z0-9]{32}$`, pw)
}
func TestDBRecipe_HasLeadingComment(t *testing.T) {
lines := recipes.DB{}.Generate()
require.NotEmpty(t, lines)
require.NotEmpty(t, lines[0].Comment)
require.Empty(t, lines[0].Key)
}
func TestDBRecipe_DistinctInvocationsProduceDistinctPasswords(t *testing.T) {
seen := make(map[string]struct{})
for range 20 {
lines := recipes.DB{}.Generate()
byKey := linesByKey(t, lines)
seen[byKey["DATABASE_URL"].Value] = struct{}{}
}
require.Greater(t, len(seen), 18)
}

View File

@ -0,0 +1,42 @@
// ©AngelaMos | 2026
// github.go
package recipes
const (
githubTokenPrefix = "ghp_"
githubTokenBodyLen = 36
githubChecksumLen = 6
githubDeployKeyBytes = 32
githubOwnerName = "acme-corp"
githubRepoName = "internal-platform"
)
type GitHub struct{}
func (GitHub) Name() string { return keyGitHub }
func (GitHub) Generate() []EnvLine {
body := RandomAlnumMixed(githubTokenBodyLen)
checksum := RandomAlnumMixed(githubChecksumLen)
return []EnvLine{
{Comment: "GitHub deploy + automation tokens"},
{
Key: "GITHUB_TOKEN",
Value: githubTokenPrefix + body + checksum,
},
{
Key: "GITHUB_DEPLOY_KEY",
Value: RandomBase64(githubDeployKeyBytes),
},
{
Key: "GITHUB_OWNER",
Value: githubOwnerName,
},
{
Key: "GITHUB_REPO",
Value: githubRepoName,
},
}
}

View File

@ -0,0 +1,66 @@
// ©AngelaMos | 2026
// github_test.go
package recipes_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/envfile/recipes"
)
func TestGitHubRecipe_Name(t *testing.T) {
require.Equal(t, "github", recipes.GitHub{}.Name())
}
func TestGitHubRecipe_GeneratesExpectedKeys(t *testing.T) {
lines := recipes.GitHub{}.Generate()
byKey := linesByKey(t, lines)
for _, key := range []string{
"GITHUB_TOKEN",
"GITHUB_DEPLOY_KEY",
"GITHUB_OWNER",
"GITHUB_REPO",
} {
_, ok := byKey[key]
require.True(t, ok, "GitHub recipe must emit %s", key)
}
}
func TestGitHubRecipe_TokenMatchesGhpFormat(t *testing.T) {
lines := recipes.GitHub{}.Generate()
byKey := linesByKey(t, lines)
require.Regexp(
t,
`^ghp_[A-Za-z0-9]{42}$`,
byKey["GITHUB_TOKEN"].Value,
"GITHUB_TOKEN must match ghp_ + 36 base62 body + 6 base62 checksum = 42 trailing chars",
)
}
func TestGitHubRecipe_DeployKeyIsBase64Like(t *testing.T) {
lines := recipes.GitHub{}.Generate()
byKey := linesByKey(t, lines)
require.Regexp(
t,
`^[A-Za-z0-9+/]+={0,2}$`,
byKey["GITHUB_DEPLOY_KEY"].Value,
)
}
func TestGitHubRecipe_OwnerAndRepoAreStable(t *testing.T) {
lines := recipes.GitHub{}.Generate()
byKey := linesByKey(t, lines)
require.Equal(t, "acme-corp", byKey["GITHUB_OWNER"].Value)
require.Equal(t, "internal-platform", byKey["GITHUB_REPO"].Value)
}
func TestGitHubRecipe_HasLeadingComment(t *testing.T) {
lines := recipes.GitHub{}.Generate()
require.NotEmpty(t, lines)
require.NotEmpty(t, lines[0].Comment)
require.Empty(t, lines[0].Key)
}

View File

@ -0,0 +1,105 @@
// ©AngelaMos | 2026
// recipes.go
package recipes
import (
"crypto/rand"
"encoding/base64"
"math/big"
"sort"
)
const (
alphaUpperAlnum = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
alphaMixedAlnum = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
alphaHexLower = "0123456789abcdef"
keyAWS = "aws"
keyStripe = "stripe"
keyGitHub = "github"
keyDB = "db"
)
type EnvLine struct {
Comment string
Key string
Value string
}
type Recipe interface {
Name() string
Generate() []EnvLine
}
var registry = map[string]Recipe{
keyAWS: AWS{},
keyStripe: Stripe{},
keyGitHub: GitHub{},
keyDB: DB{},
}
func Get(key string) (Recipe, bool) {
r, ok := registry[key]
return r, ok
}
func AvailableKeys() []string {
keys := make([]string, 0, len(registry))
for k := range registry {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func RandomAlnumUpper(length int) string {
return randomString(alphaUpperAlnum, length)
}
func RandomAlnumMixed(length int) string {
return randomString(alphaMixedAlnum, length)
}
func RandomHexLower(length int) string {
return randomString(alphaHexLower, length)
}
func RandomBase64(byteCount int) string {
if byteCount <= 0 {
return ""
}
buf := make([]byte, byteCount)
if _, err := rand.Read(buf); err != nil {
return ""
}
return base64.StdEncoding.EncodeToString(buf)
}
func RandomChoice(choices []string) string {
if len(choices) == 0 {
return ""
}
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(choices))))
if err != nil {
return choices[0]
}
return choices[idx.Int64()]
}
func randomString(alphabet string, length int) string {
if length <= 0 || alphabet == "" {
return ""
}
out := make([]byte, length)
bigLen := big.NewInt(int64(len(alphabet)))
for i := range out {
idx, err := rand.Int(rand.Reader, bigLen)
if err != nil {
out[i] = alphabet[0]
continue
}
out[i] = alphabet[idx.Int64()]
}
return string(out)
}

View File

@ -0,0 +1,99 @@
// ©AngelaMos | 2026
// recipes_test.go
package recipes_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/envfile/recipes"
)
func TestGet_KnownKeysReturnRecipes(t *testing.T) {
for _, name := range []string{"aws", "stripe", "github", "db"} {
r, ok := recipes.Get(name)
require.True(t, ok, "Get(%q) must return a recipe", name)
require.NotNil(t, r)
require.Equal(t, name, r.Name())
}
}
func TestGet_UnknownKeyReturnsFalse(t *testing.T) {
r, ok := recipes.Get("nonexistent")
require.False(t, ok)
require.Nil(t, r)
}
func TestAvailableKeys_ReturnsSortedSnapshot(t *testing.T) {
keys := recipes.AvailableKeys()
require.Equal(t, []string{"aws", "db", "github", "stripe"}, keys)
}
func TestRandomAlnumUpper_LengthAndAlphabet(t *testing.T) {
got := recipes.RandomAlnumUpper(20)
require.Len(t, got, 20)
require.Regexp(t, `^[A-Z0-9]{20}$`, got)
}
func TestRandomAlnumUpper_ZeroAndNegativeReturnEmpty(t *testing.T) {
require.Empty(t, recipes.RandomAlnumUpper(0))
require.Empty(t, recipes.RandomAlnumUpper(-1))
}
func TestRandomAlnumMixed_LengthAndAlphabet(t *testing.T) {
got := recipes.RandomAlnumMixed(50)
require.Len(t, got, 50)
require.Regexp(t, `^[A-Za-z0-9]{50}$`, got)
}
func TestRandomHexLower_LengthAndAlphabet(t *testing.T) {
got := recipes.RandomHexLower(32)
require.Len(t, got, 32)
require.Regexp(t, `^[0-9a-f]{32}$`, got)
}
func TestRandomBase64_DecodesToRequestedBytes(t *testing.T) {
got := recipes.RandomBase64(30)
require.NotEmpty(t, got)
require.Regexp(t, `^[A-Za-z0-9+/]+={0,2}$`, got)
}
func TestRandomBase64_ZeroReturnsEmpty(t *testing.T) {
require.Empty(t, recipes.RandomBase64(0))
require.Empty(t, recipes.RandomBase64(-5))
}
func TestRandomChoice_ReturnsOneOf(t *testing.T) {
choices := []string{"a", "b", "c", "d", "e"}
for range 50 {
got := recipes.RandomChoice(choices)
require.Contains(t, choices, got)
}
}
func TestRandomChoice_EmptyReturnsEmpty(t *testing.T) {
require.Empty(t, recipes.RandomChoice(nil))
require.Empty(t, recipes.RandomChoice([]string{}))
}
func TestRandomChoice_SingleElementAlwaysReturnsIt(t *testing.T) {
choices := []string{"only"}
for range 20 {
require.Equal(t, "only", recipes.RandomChoice(choices))
}
}
func TestRandomness_RepeatedCallsProduceDistinctValues(t *testing.T) {
seen := make(map[string]struct{})
for range 100 {
seen[recipes.RandomAlnumMixed(20)] = struct{}{}
}
require.Greater(
t,
len(seen),
95,
"crypto/rand should produce near-100 distinct 20-char alnum strings out of 100",
)
}

View File

@ -0,0 +1,35 @@
// ©AngelaMos | 2026
// stripe.go
package recipes
const (
stripeSecretPrefix = "sk_live_"
stripePublishablePrefix = "pk_live_"
stripeWebhookPrefix = "whsec_"
stripeKeyBodyLen = 24
stripeWebhookBodyLen = 32
)
type Stripe struct{}
func (Stripe) Name() string { return keyStripe }
func (Stripe) Generate() []EnvLine {
return []EnvLine{
{Comment: "Stripe production keys"},
{
Key: "STRIPE_SECRET_KEY",
Value: stripeSecretPrefix + RandomAlnumMixed(stripeKeyBodyLen),
},
{
Key: "STRIPE_PUBLISHABLE_KEY",
Value: stripePublishablePrefix + RandomAlnumMixed(stripeKeyBodyLen),
},
{
Key: "STRIPE_WEBHOOK_SECRET",
Value: stripeWebhookPrefix + RandomAlnumMixed(stripeWebhookBodyLen),
},
}
}

View File

@ -0,0 +1,78 @@
// ©AngelaMos | 2026
// stripe_test.go
package recipes_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/envfile/recipes"
)
func TestStripeRecipe_Name(t *testing.T) {
require.Equal(t, "stripe", recipes.Stripe{}.Name())
}
func TestStripeRecipe_GeneratesExpectedKeys(t *testing.T) {
lines := recipes.Stripe{}.Generate()
byKey := linesByKey(t, lines)
for _, key := range []string{
"STRIPE_SECRET_KEY",
"STRIPE_PUBLISHABLE_KEY",
"STRIPE_WEBHOOK_SECRET",
} {
_, ok := byKey[key]
require.True(t, ok, "Stripe recipe must emit %s", key)
}
}
func TestStripeRecipe_SecretKeyMatchesLiveFormat(t *testing.T) {
lines := recipes.Stripe{}.Generate()
byKey := linesByKey(t, lines)
require.Regexp(
t,
`^sk_live_[A-Za-z0-9]{24}$`,
byKey["STRIPE_SECRET_KEY"].Value,
)
}
func TestStripeRecipe_PublishableKeyMatchesLiveFormat(t *testing.T) {
lines := recipes.Stripe{}.Generate()
byKey := linesByKey(t, lines)
require.Regexp(
t,
`^pk_live_[A-Za-z0-9]{24}$`,
byKey["STRIPE_PUBLISHABLE_KEY"].Value,
)
}
func TestStripeRecipe_WebhookSecretMatchesFormat(t *testing.T) {
lines := recipes.Stripe{}.Generate()
byKey := linesByKey(t, lines)
require.Regexp(
t,
`^whsec_[A-Za-z0-9]{32}$`,
byKey["STRIPE_WEBHOOK_SECRET"].Value,
)
}
func TestStripeRecipe_HasLeadingComment(t *testing.T) {
lines := recipes.Stripe{}.Generate()
require.NotEmpty(t, lines)
require.NotEmpty(t, lines[0].Comment)
require.Empty(t, lines[0].Key)
}
func TestStripeRecipe_KeysAreDistinct(t *testing.T) {
lines := recipes.Stripe{}.Generate()
byKey := linesByKey(t, lines)
require.NotEqual(
t,
byKey["STRIPE_SECRET_KEY"].Value,
byKey["STRIPE_PUBLISHABLE_KEY"].Value,
"secret and publishable keys must have different random bodies",
)
}

View File

@ -0,0 +1,23 @@
// ©AngelaMos | 2026
// generator.go
package generators
import (
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
)
type ArtifactKind = token.ArtifactKind
const (
KindURL = token.KindURL
KindFile = token.KindFile
KindText = token.KindText
KindConnectionString = token.KindConnectionString
)
type Artifact = token.Artifact
type TriggerResponse = token.TriggerResponse
type Generator = token.Generator

View File

@ -0,0 +1,89 @@
// ©AngelaMos | 2026
// generator.go
package kubeconfig
import (
"bytes"
"context"
_ "embed"
"fmt"
"strings"
"text/template"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators"
)
const (
triggerPathPrefix = "/k/"
contentTypeYAML = "application/yaml"
defaultFilename = "kubeconfig"
defaultClusterName = "prod-cluster"
defaultUserName = "svc-backup-reader"
templateName = "kubeconfig"
)
//go:embed template.yaml.tmpl
var templateYAML string
var kubeconfigTemplate = template.Must(
template.New(templateName).Parse(templateYAML),
)
type kubeconfigData struct {
APIServerURL string
Token string
ClusterName string
UserName string
}
type Generator struct{}
func New() *Generator { return &Generator{} }
func (g *Generator) Type() token.Type { return token.TypeKubeconfig }
func (g *Generator) Generate(
_ context.Context,
t *token.Token,
baseURL string,
) (generators.Artifact, error) {
apiServerURL := strings.TrimRight(baseURL, "/") +
triggerPathPrefix + t.ID
data := kubeconfigData{
APIServerURL: apiServerURL,
Token: t.ID,
ClusterName: defaultClusterName,
UserName: defaultUserName,
}
var buf bytes.Buffer
if err := kubeconfigTemplate.Execute(&buf, data); err != nil {
return generators.Artifact{}, fmt.Errorf(
"kubeconfig: render template: %w",
err,
)
}
return generators.Artifact{
Kind: generators.KindText,
Filename: resolveFilename(t.Filename),
Content: buf.Bytes(),
ContentType: contentTypeYAML,
}, nil
}
func resolveFilename(name *string) string {
if name == nil {
return defaultFilename
}
trimmed := strings.TrimSpace(*name)
if trimmed == "" {
return defaultFilename
}
return trimmed
}

View File

@ -0,0 +1,292 @@
// ©AngelaMos | 2026
// generator_test.go
package kubeconfig_test
import (
"context"
"strings"
"testing"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/kubeconfig"
)
const (
testBaseURL = "https://canary.example.com"
expectedKubeconfigMIME = "application/yaml"
defaultFilenameValue = "kubeconfig"
defaultClusterNameValue = "prod-cluster"
defaultUserNameValue = "svc-backup-reader"
)
type kubeconfigSchema struct {
APIVersion string `yaml:"apiVersion"`
Kind string `yaml:"kind"`
CurrentContext string `yaml:"current-context"`
Clusters []struct {
Name string `yaml:"name"`
Cluster struct {
Server string `yaml:"server"`
} `yaml:"cluster"`
} `yaml:"clusters"`
Contexts []struct {
Name string `yaml:"name"`
Context struct {
Cluster string `yaml:"cluster"`
User string `yaml:"user"`
} `yaml:"context"`
} `yaml:"contexts"`
Users []struct {
Name string `yaml:"name"`
User struct {
Token string `yaml:"token"`
} `yaml:"user"`
} `yaml:"users"`
}
func newKubeconfigToken(id string) *token.Token {
return &token.Token{
ID: id,
ManageID: "manage-" + id,
Type: token.TypeKubeconfig,
Memo: "unit test kubeconfig",
AlertChannel: token.ChannelWebhook,
Enabled: true,
}
}
func newKubeconfigTokenWithFilename(id, filename string) *token.Token {
tok := newKubeconfigToken(id)
tok.Filename = &filename
return tok
}
func parseKubeconfig(t *testing.T, content []byte) kubeconfigSchema {
t.Helper()
var kc kubeconfigSchema
require.NoError(
t,
yaml.Unmarshal(content, &kc),
"generated kubeconfig must parse as YAML",
)
return kc
}
func TestGenerator_TypeIsKubeconfig(t *testing.T) {
g := kubeconfig.New()
require.Equal(t, token.TypeKubeconfig, g.Type())
}
func TestGenerate_ArtifactKindIsText(t *testing.T) {
g := kubeconfig.New()
art, err := g.Generate(
context.Background(),
newKubeconfigToken("abc"),
testBaseURL,
)
require.NoError(t, err)
require.Equal(t, generators.KindText, art.Kind)
}
func TestGenerate_ContentTypeIsYAML(t *testing.T) {
g := kubeconfig.New()
art, err := g.Generate(
context.Background(),
newKubeconfigToken("abc"),
testBaseURL,
)
require.NoError(t, err)
require.Equal(t, expectedKubeconfigMIME, art.ContentType)
}
func TestGenerate_Filename(t *testing.T) {
g := kubeconfig.New()
t.Run("nil Filename defaults to kubeconfig", func(t *testing.T) {
art, err := g.Generate(
context.Background(),
newKubeconfigToken("abc"),
testBaseURL,
)
require.NoError(t, err)
require.Equal(t, defaultFilenameValue, art.Filename)
})
t.Run(
"empty Filename pointer defaults to kubeconfig",
func(t *testing.T) {
art, err := g.Generate(
context.Background(),
newKubeconfigTokenWithFilename("abc", ""),
testBaseURL,
)
require.NoError(t, err)
require.Equal(t, defaultFilenameValue, art.Filename)
},
)
t.Run(
"whitespace-only Filename defaults to kubeconfig",
func(t *testing.T) {
art, err := g.Generate(
context.Background(),
newKubeconfigTokenWithFilename("abc", " "),
testBaseURL,
)
require.NoError(t, err)
require.Equal(t, defaultFilenameValue, art.Filename)
},
)
t.Run("set Filename is preserved (trimmed)", func(t *testing.T) {
art, err := g.Generate(
context.Background(),
newKubeconfigTokenWithFilename("abc", " prod-kubeconfig "),
testBaseURL,
)
require.NoError(t, err)
require.Equal(t, "prod-kubeconfig", art.Filename)
})
}
func TestGenerate_APIServerURL(t *testing.T) {
g := kubeconfig.New()
t.Run("base URL trailing slash trimmed", func(t *testing.T) {
artA, err := g.Generate(
context.Background(),
newKubeconfigToken("tk1"),
"https://canary.example.com",
)
require.NoError(t, err)
artB, err := g.Generate(
context.Background(),
newKubeconfigToken("tk1"),
"https://canary.example.com/",
)
require.NoError(t, err)
kcA := parseKubeconfig(t, artA.Content)
kcB := parseKubeconfig(t, artB.Content)
require.Len(t, kcA.Clusters, 1)
require.Len(t, kcB.Clusters, 1)
require.Equal(
t,
"https://canary.example.com/k/tk1",
kcA.Clusters[0].Cluster.Server,
)
require.Equal(
t,
"https://canary.example.com/k/tk1",
kcB.Clusters[0].Cluster.Server,
)
})
t.Run("base URL subpath preserved", func(t *testing.T) {
art, err := g.Generate(
context.Background(),
newKubeconfigToken("tk2"),
"https://example.com/canary",
)
require.NoError(t, err)
kc := parseKubeconfig(t, art.Content)
require.Len(t, kc.Clusters, 1)
require.Equal(
t,
"https://example.com/canary/k/tk2",
kc.Clusters[0].Cluster.Server,
)
})
t.Run(
"different token ids produce distinct outputs",
func(t *testing.T) {
artA, err := g.Generate(
context.Background(),
newKubeconfigToken("aaa"),
testBaseURL,
)
require.NoError(t, err)
artB, err := g.Generate(
context.Background(),
newKubeconfigToken("bbb"),
testBaseURL,
)
require.NoError(t, err)
require.NotEqual(t, artA.Content, artB.Content)
},
)
}
func TestGenerate_YAMLParsesAsKubeconfig(t *testing.T) {
g := kubeconfig.New()
art, err := g.Generate(
context.Background(),
newKubeconfigToken("abc123"),
testBaseURL,
)
require.NoError(t, err)
kc := parseKubeconfig(t, art.Content)
require.Equal(t, "v1", kc.APIVersion)
require.Equal(t, "Config", kc.Kind)
require.Equal(t, defaultClusterNameValue, kc.CurrentContext)
require.Len(t, kc.Clusters, 1)
require.Equal(t, defaultClusterNameValue, kc.Clusters[0].Name)
require.Equal(
t,
"https://canary.example.com/k/abc123",
kc.Clusters[0].Cluster.Server,
)
require.Len(t, kc.Contexts, 1)
require.Equal(t, defaultClusterNameValue, kc.Contexts[0].Name)
require.Equal(
t,
defaultClusterNameValue,
kc.Contexts[0].Context.Cluster,
)
require.Equal(t, defaultUserNameValue, kc.Contexts[0].Context.User)
require.Len(t, kc.Users, 1)
require.Equal(t, defaultUserNameValue, kc.Users[0].Name)
require.Equal(t, "abc123", kc.Users[0].User.Token)
}
func TestGenerate_TokenIDEmbeddedAsBearer(t *testing.T) {
g := kubeconfig.New()
art, err := g.Generate(
context.Background(),
newKubeconfigToken("super-secret-bearer-id"),
testBaseURL,
)
require.NoError(t, err)
require.Contains(
t,
string(art.Content),
"super-secret-bearer-id",
"kubeconfig must embed the token ID as the bearer token",
)
}
func TestGenerate_ContentEndsWithNewline(t *testing.T) {
g := kubeconfig.New()
art, err := g.Generate(
context.Background(),
newKubeconfigToken("abc"),
testBaseURL,
)
require.NoError(t, err)
require.True(
t,
strings.HasSuffix(string(art.Content), "\n"),
"generated YAML must end with a newline (POSIX convention)",
)
}

View File

@ -0,0 +1,160 @@
// ©AngelaMos | 2026
// handler.go
package kubeconfig
import (
"context"
"encoding/json"
"fmt"
"net/http"
"path"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators"
)
const (
headerReferer = "Referer"
headerUserAgent = "User-Agent"
headerCacheControl = "Cache-Control"
headerPragma = "Pragma"
cacheControlNoStore = "no-store, no-cache, must-revalidate, max-age=0"
pragmaNoCache = "no-cache"
contentTypeJSON = "application/json"
statusKind = "Status"
statusAPIVersion = "v1"
statusFailure = "Failure"
statusReason = "Forbidden"
statusMessageFmt = `%s is forbidden: User "system:anonymous" cannot %s resource "%s" in API group "" in the namespace "default"`
defaultResource = "resource"
verbList = "list"
verbCreate = "create"
verbUpdate = "update"
verbPatch = "patch"
verbDelete = "delete"
extraKubectlPath = "kubectl_path"
extraKubectlMethod = "kubectl_method"
extraKubectlQuery = "kubectl_query"
extraKubectlUA = "kubectl_ua"
)
type kubernetesStatus struct {
Kind string `json:"kind"`
APIVersion string `json:"apiVersion"`
Metadata statusMetadata `json:"metadata"`
Status string `json:"status"`
Message string `json:"message"`
Reason string `json:"reason"`
Code int `json:"code"`
}
type statusMetadata struct{}
func (g *Generator) Trigger(
_ context.Context,
t *token.Token,
r *http.Request,
) (*event.Event, *generators.TriggerResponse, error) {
resource := resourceFromPath(r.URL.Path)
verb := verbFromMethod(r.Method)
body, err := buildForbiddenBody(resource, verb)
if err != nil {
return nil, nil, fmt.Errorf(
"kubeconfig: build forbidden response: %w",
err,
)
}
resp := &generators.TriggerResponse{
StatusCode: http.StatusForbidden,
ContentType: contentTypeJSON,
Body: body,
ExtraHeaders: map[string]string{
headerCacheControl: cacheControlNoStore,
headerPragma: pragmaNoCache,
},
}
if t == nil {
return nil, resp, nil
}
extra, err := buildKubectlExtra(r)
if err != nil {
return nil, nil, fmt.Errorf(
"kubeconfig: build event extra: %w",
err,
)
}
evt := &event.Event{
TokenID: t.ID,
SourceIP: middleware.RealIP(r),
UserAgent: middleware.OptionalHeader(r.UserAgent()),
Referer: middleware.OptionalHeader(r.Header.Get(headerReferer)),
Extra: extra,
}
return evt, resp, nil
}
func buildForbiddenBody(resource, verb string) ([]byte, error) {
s := kubernetesStatus{
Kind: statusKind,
APIVersion: statusAPIVersion,
Status: statusFailure,
Message: fmt.Sprintf(statusMessageFmt, resource, verb, resource),
Reason: statusReason,
Code: http.StatusForbidden,
}
body, err := json.Marshal(s)
if err != nil {
return nil, fmt.Errorf("marshal kubernetes status: %w", err)
}
return body, nil
}
func buildKubectlExtra(r *http.Request) (json.RawMessage, error) {
extra := map[string]string{
extraKubectlPath: r.URL.Path,
extraKubectlMethod: r.Method,
extraKubectlQuery: r.URL.RawQuery,
extraKubectlUA: r.Header.Get(headerUserAgent),
}
body, err := json.Marshal(extra)
if err != nil {
return nil, fmt.Errorf("marshal kubectl extra: %w", err)
}
return body, nil
}
func resourceFromPath(urlPath string) string {
last := path.Base(urlPath)
if last == "" || last == "/" || last == "." {
return defaultResource
}
return last
}
func verbFromMethod(method string) string {
switch method {
case http.MethodPost:
return verbCreate
case http.MethodPut:
return verbUpdate
case http.MethodPatch:
return verbPatch
case http.MethodDelete:
return verbDelete
default:
return verbList
}
}

View File

@ -0,0 +1,518 @@
// ©AngelaMos | 2026
// handler_test.go
package kubeconfig_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/kubeconfig"
)
const (
cacheControlNoStoreValue = "no-store, no-cache, must-revalidate, max-age=0"
pragmaNoCacheValue = "no-cache"
expectedResponseMIME = "application/json"
)
type k8sStatusResponse struct {
Kind string `json:"kind"`
APIVersion string `json:"apiVersion"`
Metadata map[string]interface{} `json:"metadata"`
Status string `json:"status"`
Message string `json:"message"`
Reason string `json:"reason"`
Code int `json:"code"`
}
type kubectlExtra struct {
KubectlPath string `json:"kubectl_path"`
KubectlMethod string `json:"kubectl_method"`
KubectlQuery string `json:"kubectl_query"`
KubectlUA string `json:"kubectl_ua"`
}
func parseStatus(t *testing.T, body []byte) k8sStatusResponse {
t.Helper()
var s k8sStatusResponse
require.NoError(
t,
json.Unmarshal(body, &s),
"response body must be valid Kubernetes Status JSON",
)
return s
}
func parseKubectlExtra(t *testing.T, raw json.RawMessage) kubectlExtra {
t.Helper()
var k kubectlExtra
require.NoError(t, json.Unmarshal(raw, &k))
return k
}
func TestTrigger_ResponseStatusCodeIs403(t *testing.T) {
g := kubeconfig.New()
tok := newKubeconfigToken("abc")
r := httptest.NewRequest(http.MethodGet, "/k/abc/api/v1/pods", nil)
_, resp, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
}
func TestTrigger_ResponseContentTypeIsJSON(t *testing.T) {
g := kubeconfig.New()
tok := newKubeconfigToken("abc")
r := httptest.NewRequest(http.MethodGet, "/k/abc/api/v1/pods", nil)
_, resp, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
require.Equal(t, expectedResponseMIME, resp.ContentType)
}
func TestTrigger_CacheHeadersSet(t *testing.T) {
g := kubeconfig.New()
tok := newKubeconfigToken("abc")
r := httptest.NewRequest(http.MethodGet, "/k/abc/api/v1/pods", nil)
_, resp, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
require.Equal(
t,
cacheControlNoStoreValue,
resp.ExtraHeaders["Cache-Control"],
)
require.Equal(t, pragmaNoCacheValue, resp.ExtraHeaders["Pragma"])
}
func TestTrigger_ResponseIsValidKubernetesStatus(t *testing.T) {
g := kubeconfig.New()
tok := newKubeconfigToken("abc")
r := httptest.NewRequest(http.MethodGet, "/k/abc/api/v1/pods", nil)
_, resp, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
s := parseStatus(t, resp.Body)
require.Equal(t, "Status", s.Kind)
require.Equal(t, "v1", s.APIVersion)
require.NotNil(t, s.Metadata)
require.Empty(t, s.Metadata, "metadata must be empty object {}")
require.Equal(t, "Failure", s.Status)
require.Equal(t, "Forbidden", s.Reason)
require.Equal(t, http.StatusForbidden, s.Code)
}
func TestTrigger_MessageUsesPathLastSegmentAsResource(t *testing.T) {
g := kubeconfig.New()
tok := newKubeconfigToken("abc")
cases := []struct {
name string
path string
wantWord string
}{
{
name: "pods endpoint",
path: "/k/abc/api/v1/namespaces/default/pods",
wantWord: "pods",
},
{
name: "secrets endpoint",
path: "/k/abc/api/v1/secrets",
wantWord: "secrets",
},
{
name: "single-resource get",
path: "/k/abc/api/v1/namespaces/default/pods/web-1",
wantWord: "web-1",
},
{
name: "api version probe",
path: "/k/abc/api/v1",
wantWord: "v1",
},
{
name: "trailing slash path falls back to default",
path: "/k/abc/",
wantWord: "abc",
},
{
name: "non-resource healthz probe",
path: "/k/abc/healthz",
wantWord: "healthz",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, tc.path, nil)
_, resp, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
s := parseStatus(t, resp.Body)
require.Contains(
t,
s.Message,
tc.wantWord+` is forbidden`,
"message must lead with the resource name",
)
require.Contains(
t,
s.Message,
`resource "`+tc.wantWord+`"`,
"message must repeat the resource name in the resource clause",
)
})
}
}
func TestTrigger_MessageVerbDerivedFromHTTPMethod(t *testing.T) {
g := kubeconfig.New()
tok := newKubeconfigToken("abc")
cases := []struct {
method string
wantVerb string
}{
{http.MethodGet, "list"},
{http.MethodHead, "list"},
{http.MethodPost, "create"},
{http.MethodPut, "update"},
{http.MethodPatch, "patch"},
{http.MethodDelete, "delete"},
{http.MethodOptions, "list"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.method, func(t *testing.T) {
r := httptest.NewRequest(
tc.method,
"/k/abc/api/v1/pods",
nil,
)
_, resp, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
s := parseStatus(t, resp.Body)
require.Contains(
t,
s.Message,
`cannot `+tc.wantVerb+` resource`,
"message must use the verb mapped from the HTTP method",
)
})
}
}
func TestTrigger_MessageImpersonatesAnonymousUser(t *testing.T) {
g := kubeconfig.New()
tok := newKubeconfigToken("abc")
r := httptest.NewRequest(http.MethodGet, "/k/abc/api/v1/pods", nil)
_, resp, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
s := parseStatus(t, resp.Body)
require.Contains(
t,
s.Message,
`User "system:anonymous"`,
"message must impersonate the anonymous user for verisimilitude",
)
}
func TestTrigger_RecordsEventWithRequestMetadata(t *testing.T) {
g := kubeconfig.New()
tok := newKubeconfigToken("token1")
t.Run(
"captures token id, source ip, user agent, referer",
func(t *testing.T) {
r := httptest.NewRequest(
http.MethodGet,
"/k/token1/api/v1/pods?watch=true",
nil,
)
r.Header.Set("CF-Connecting-IP", "203.0.113.50")
r.Header.Set(
"User-Agent",
"kubectl/v1.30.0 (linux/amd64) kubernetes/9b7f2dd",
)
r.Header.Set("Referer", "https://victim.example.com/dashboard")
evt, _, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
require.NotNil(t, evt)
require.Equal(t, "token1", evt.TokenID)
require.Equal(t, "203.0.113.50", evt.SourceIP)
require.NotNil(t, evt.UserAgent)
require.Equal(
t,
"kubectl/v1.30.0 (linux/amd64) kubernetes/9b7f2dd",
*evt.UserAgent,
)
require.NotNil(t, evt.Referer)
require.Equal(
t,
"https://victim.example.com/dashboard",
*evt.Referer,
)
},
)
t.Run("kubectl_* extra captured", func(t *testing.T) {
r := httptest.NewRequest(
http.MethodPost,
"/k/token1/api/v1/namespaces/default/pods?dryRun=All",
nil,
)
r.Header.Set("CF-Connecting-IP", "203.0.113.50")
r.Header.Set("User-Agent", "kubectl/v1.30.0")
evt, _, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
require.NotNil(t, evt)
extra := parseKubectlExtra(t, evt.Extra)
require.Equal(
t,
"/k/token1/api/v1/namespaces/default/pods",
extra.KubectlPath,
)
require.Equal(t, http.MethodPost, extra.KubectlMethod)
require.Equal(t, "dryRun=All", extra.KubectlQuery)
require.Equal(t, "kubectl/v1.30.0", extra.KubectlUA)
})
t.Run("missing query is empty string in extra", func(t *testing.T) {
r := httptest.NewRequest(
http.MethodGet,
"/k/token1/api/v1/pods",
nil,
)
evt, _, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
extra := parseKubectlExtra(t, evt.Extra)
require.Empty(t, extra.KubectlQuery)
})
t.Run("source ip precedence", func(t *testing.T) {
cases := []struct {
name string
headers map[string]string
remote string
wantIP string
}{
{
name: "CF wins over XFF and XRI",
headers: map[string]string{
"CF-Connecting-IP": "203.0.113.10",
"X-Forwarded-For": "198.51.100.1, 198.51.100.2",
"X-Real-IP": "192.0.2.99",
},
remote: "127.0.0.1:9999",
wantIP: "203.0.113.10",
},
{
name: "XFF leftmost wins over XRI when no CF",
headers: map[string]string{
"X-Forwarded-For": "198.51.100.1, 198.51.100.7",
"X-Real-IP": "192.0.2.99",
},
remote: "127.0.0.1:9999",
wantIP: "198.51.100.1",
},
{
name: "XFF trailing-comma falls through to last non-empty",
headers: map[string]string{
"X-Forwarded-For": "198.51.100.1, ",
"X-Real-IP": "192.0.2.99",
},
remote: "127.0.0.1:9999",
wantIP: "198.51.100.1",
},
{
name: "XFF entirely empty entries fall through to XRI",
headers: map[string]string{
"X-Forwarded-For": ", ,",
"X-Real-IP": "192.0.2.99",
},
remote: "127.0.0.1:9999",
wantIP: "192.0.2.99",
},
{
name: "XRI when no CF or XFF",
headers: map[string]string{
"X-Real-IP": "192.0.2.99",
},
remote: "127.0.0.1:9999",
wantIP: "192.0.2.99",
},
{
name: "RemoteAddr IPv4 strips port",
headers: nil,
remote: "127.0.0.1:9999",
wantIP: "127.0.0.1",
},
{
name: "RemoteAddr IPv6 strips brackets and port",
headers: nil,
remote: "[2001:db8::1]:54321",
wantIP: "2001:db8::1",
},
{
name: "RemoteAddr loopback IPv6 strips brackets and port",
headers: nil,
remote: "[::1]:9999",
wantIP: "::1",
},
{
name: "RemoteAddr without port falls back to raw value",
headers: nil,
remote: "127.0.0.1",
wantIP: "127.0.0.1",
},
{
name: "XFF mixed IPv4+IPv6 leftmost",
headers: map[string]string{
"X-Forwarded-For": "198.51.100.1, 2001:db8::dead",
},
remote: "127.0.0.1:9999",
wantIP: "198.51.100.1",
},
{
name: "CF value is trimmed of whitespace",
headers: map[string]string{
"CF-Connecting-IP": " 203.0.113.10 ",
},
remote: "127.0.0.1:9999",
wantIP: "203.0.113.10",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(
http.MethodGet,
"/k/token1/api/v1/pods",
nil,
)
for k, v := range tc.headers {
r.Header.Set(k, v)
}
r.RemoteAddr = tc.remote
evt, _, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
require.NotNil(t, evt)
require.Equal(t, tc.wantIP, evt.SourceIP)
})
}
})
t.Run(
"missing user agent and referer record as nil pointers",
func(t *testing.T) {
r := httptest.NewRequest(
http.MethodGet,
"/k/token1/api/v1/pods",
nil,
)
r.Header.Del("User-Agent")
r.Header.Del("Referer")
r.Header.Set("CF-Connecting-IP", "203.0.113.5")
evt, _, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
require.NotNil(t, evt)
require.Nil(
t,
evt.UserAgent,
"absent user agent must map to nil, not empty string",
)
require.Nil(
t,
evt.Referer,
"absent referer must map to nil, not empty string",
)
},
)
}
func TestTrigger_TokenNotFound_StillReturns403(t *testing.T) {
g := kubeconfig.New()
r := httptest.NewRequest(
http.MethodGet,
"/k/does-not-exist/api/v1/pods",
nil,
)
r.Header.Set("CF-Connecting-IP", "203.0.113.100")
r.Header.Set("User-Agent", "kubectl/v1.30.0")
evt, resp, err := g.Trigger(context.Background(), nil, r)
require.NoError(
t,
err,
"nil-token path must not error (spec §8.5 defense-in-depth)",
)
require.NotNil(t, resp)
require.Equal(
t,
http.StatusForbidden,
resp.StatusCode,
"nil-token still returns 403 so attackers cannot distinguish valid vs invalid tokens",
)
require.Equal(t, expectedResponseMIME, resp.ContentType)
require.NotEmpty(t, resp.Body)
s := parseStatus(t, resp.Body)
require.Equal(t, "Status", s.Kind)
require.Equal(t, "Failure", s.Status)
require.Nil(
t,
evt,
"nil-token path returns nil event so the handler cannot persist a row with empty TokenID (FK violation)",
)
}
func TestTrigger_NilTokenResponseShapeMatchesValidToken(t *testing.T) {
g := kubeconfig.New()
tok := newKubeconfigToken("real-token")
r1 := httptest.NewRequest(
http.MethodGet,
"/k/real-token/api/v1/pods",
nil,
)
r2 := httptest.NewRequest(
http.MethodGet,
"/k/does-not-exist/api/v1/pods",
nil,
)
_, respValid, err := g.Trigger(context.Background(), tok, r1)
require.NoError(t, err)
_, respMissing, err := g.Trigger(context.Background(), nil, r2)
require.NoError(t, err)
sValid := parseStatus(t, respValid.Body)
sMissing := parseStatus(t, respMissing.Body)
require.Equal(t, sValid.Kind, sMissing.Kind)
require.Equal(t, sValid.APIVersion, sMissing.APIVersion)
require.Equal(t, sValid.Status, sMissing.Status)
require.Equal(t, sValid.Reason, sMissing.Reason)
require.Equal(t, sValid.Code, sMissing.Code)
require.Equal(
t,
respValid.StatusCode,
respMissing.StatusCode,
"HTTP status code must be identical so attackers cannot probe token validity by response code",
)
}

View File

@ -0,0 +1,18 @@
# ©AngelaMos | 2026
# template.yaml.tmpl
apiVersion: v1
kind: Config
current-context: {{.ClusterName}}
clusters:
- name: {{.ClusterName}}
cluster:
server: {{.APIServerURL}}
contexts:
- name: {{.ClusterName}}
context:
cluster: {{.ClusterName}}
user: {{.UserName}}
users:
- name: {{.UserName}}
user:
token: {{.Token}}

View File

@ -0,0 +1,103 @@
// ©AngelaMos | 2026
// generator.go
package mysql
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators"
)
const (
defaultPublicHost = "localhost"
defaultPublicPort = 3306
defaultDatabase = "internal_db"
connectionStringFmt = "mysql://%s@%s:%d/%s"
)
type Generator struct {
publicHost string
publicPort int
database string
}
func New() *Generator {
return NewWithAddress(defaultPublicHost, defaultPublicPort)
}
func NewWithAddress(host string, port int) *Generator {
return &Generator{
publicHost: host,
publicPort: port,
database: defaultDatabase,
}
}
func (g *Generator) Type() token.Type { return token.TypeMySQL }
func (g *Generator) Generate(
_ context.Context,
t *token.Token,
_ string,
) (generators.Artifact, error) {
username := mysqlUsernamePrefix + t.ID
newMeta, err := setMySQLUsername(t.Metadata, username)
if err != nil {
return generators.Artifact{}, fmt.Errorf(
"mysql: persist username: %w",
err,
)
}
t.Metadata = newMeta
connStr := fmt.Sprintf(
connectionStringFmt,
username,
g.publicHost,
g.publicPort,
g.database,
)
return generators.Artifact{
Kind: generators.KindConnectionString,
ConnectionString: connStr,
}, nil
}
func (g *Generator) Trigger(
_ context.Context,
_ *token.Token,
_ *http.Request,
) (*event.Event, *generators.TriggerResponse, error) {
return nil, nil, ErrHTTPTriggerNotSupported
}
func setMySQLUsername(
metadata json.RawMessage,
username string,
) (json.RawMessage, error) {
m := make(map[string]json.RawMessage)
if len(metadata) > 0 {
if err := json.Unmarshal(metadata, &m); err != nil {
m = make(map[string]json.RawMessage)
}
}
val, err := json.Marshal(username)
if err != nil {
return nil, fmt.Errorf("marshal mysql_username: %w", err)
}
m[extraMySQLUsername] = val
out, err := json.Marshal(m)
if err != nil {
return nil, fmt.Errorf("marshal merged metadata: %w", err)
}
return out, nil
}

View File

@ -0,0 +1,191 @@
// ©AngelaMos | 2026
// generator_test.go
package mysql_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/mysql"
)
func newMySQLToken(id string) *token.Token {
return &token.Token{
ID: id,
ManageID: "manage-" + id,
Type: token.TypeMySQL,
Memo: "unit test mysql",
AlertChannel: token.ChannelWebhook,
Enabled: true,
}
}
func TestGenerator_TypeIsMySQL(t *testing.T) {
require.Equal(t, token.TypeMySQL, mysql.New().Type())
}
func TestGenerate_ArtifactKindIsConnectionString(t *testing.T) {
g := mysql.New()
art, err := g.Generate(context.Background(), newMySQLToken("abc"), "")
require.NoError(t, err)
require.Equal(t, generators.KindConnectionString, art.Kind)
}
func TestGenerate_ConnectionStringDefaultLocalhost(t *testing.T) {
g := mysql.New()
art, err := g.Generate(context.Background(), newMySQLToken("abc"), "")
require.NoError(t, err)
require.Equal(
t,
"mysql://canary_abc@localhost:3306/internal_db",
art.ConnectionString,
)
}
func TestGenerate_ConnectionStringCustomAddress(t *testing.T) {
g := mysql.NewWithAddress("canary.example.com", 13306)
art, err := g.Generate(context.Background(), newMySQLToken("xyz"), "")
require.NoError(t, err)
require.Equal(
t,
"mysql://canary_xyz@canary.example.com:13306/internal_db",
art.ConnectionString,
)
}
func TestGenerate_UsernamePrefixIsCanary_(t *testing.T) {
g := mysql.New()
art, err := g.Generate(context.Background(), newMySQLToken("probeid"), "")
require.NoError(t, err)
require.Contains(
t,
art.ConnectionString,
"canary_probeid@",
"username must be canary_+token.ID for TCP-side lookup parity",
)
}
func TestGenerate_PersistsMySQLUsernameInMetadata(t *testing.T) {
g := mysql.New()
tok := newMySQLToken("abc")
require.Empty(t, tok.Metadata, "fresh token has no metadata yet")
_, err := g.Generate(context.Background(), tok, "")
require.NoError(t, err)
require.NotEmpty(
t,
tok.Metadata,
"Generate must persist mysql_username into token metadata",
)
var m map[string]any
require.NoError(t, json.Unmarshal(tok.Metadata, &m))
require.Equal(t, "canary_abc", m["mysql_username"])
}
func TestGenerate_PreservesExistingMetadataFields(t *testing.T) {
g := mysql.New()
tok := newMySQLToken("abc")
tok.Metadata = json.RawMessage(`{"existing_field":"keep_me","other":42}`)
_, err := g.Generate(context.Background(), tok, "")
require.NoError(t, err)
var m map[string]any
require.NoError(t, json.Unmarshal(tok.Metadata, &m))
require.Equal(t, "keep_me", m["existing_field"])
other, ok := m["other"].(float64)
require.True(t, ok)
require.Equal(t, 42, int(other))
require.Equal(t, "canary_abc", m["mysql_username"])
}
func TestGenerate_MalformedMetadataIsReplaced(t *testing.T) {
g := mysql.New()
tok := newMySQLToken("abc")
tok.Metadata = json.RawMessage(`{not valid json`)
_, err := g.Generate(context.Background(), tok, "")
require.NoError(
t,
err,
"malformed metadata must not block generation — replace with a fresh map",
)
var m map[string]any
require.NoError(t, json.Unmarshal(tok.Metadata, &m))
require.Equal(t, "canary_abc", m["mysql_username"])
}
func TestGenerate_OverwritesPriorMySQLUsername(t *testing.T) {
g := mysql.New()
tok := newMySQLToken("abc")
tok.Metadata = json.RawMessage(`{"mysql_username":"stale_value"}`)
_, err := g.Generate(context.Background(), tok, "")
require.NoError(t, err)
var m map[string]any
require.NoError(t, json.Unmarshal(tok.Metadata, &m))
require.Equal(
t,
"canary_abc",
m["mysql_username"],
"regeneration must overwrite stale mysql_username with the current token's",
)
}
func TestGenerate_BaseURLIgnored(t *testing.T) {
g := mysql.New()
art1, err := g.Generate(
context.Background(),
newMySQLToken("abc"),
"https://canary.example.com",
)
require.NoError(t, err)
art2, err := g.Generate(
context.Background(),
newMySQLToken("abc"),
"https://different.example.com/sub",
)
require.NoError(t, err)
require.Equal(
t,
art1.ConnectionString,
art2.ConnectionString,
"baseURL is irrelevant to mysql connection strings",
)
}
func TestTrigger_ReturnsHTTPNotSupportedError(t *testing.T) {
g := mysql.New()
r := httptest.NewRequest(http.MethodGet, "/c/abc", nil)
evt, resp, err := g.Trigger(
context.Background(),
newMySQLToken("abc"),
r,
)
require.Error(t, err)
require.ErrorIs(t, err, mysql.ErrHTTPTriggerNotSupported)
require.Nil(t, evt, "no event when HTTP-triggered (mysql uses TCP)")
require.Nil(t, resp, "no response when HTTP-triggered (mysql uses TCP)")
}
func TestTrigger_NilTokenAlsoReturnsHTTPNotSupportedError(t *testing.T) {
g := mysql.New()
r := httptest.NewRequest(http.MethodGet, "/c/abc", nil)
_, _, err := g.Trigger(context.Background(), nil, r)
require.ErrorIs(t, err, mysql.ErrHTTPTriggerNotSupported)
}

View File

@ -0,0 +1,186 @@
// ©AngelaMos | 2026
// handler.go
package mysql
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net"
"strings"
"time"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
)
const (
connectionDeadline = 10 * time.Second
mysqlUsernamePrefix = "canary_"
extraMySQLUsername = "mysql_username"
extraMySQLCapabilities = "mysql_client_capabilities"
extraMySQLCharset = "mysql_client_charset"
capabilitiesFormat = "0x%08x"
)
type TokenLookup interface {
GetByID(ctx context.Context, id string) (*token.Token, error)
}
type EventRecorder interface {
Record(ctx context.Context, t *token.Token, evt *event.Event) error
}
type Handler struct {
tokens TokenLookup
events EventRecorder
}
func NewHandler(tokens TokenLookup, events EventRecorder) *Handler {
return &Handler{tokens: tokens, events: events}
}
func (h *Handler) HandleConnection(ctx context.Context, conn net.Conn) {
defer func() {
if err := conn.Close(); err != nil {
slog.WarnContext(ctx, "mysql: close connection", "error", err)
}
}()
if err := conn.SetDeadline(time.Now().Add(connectionDeadline)); err != nil {
slog.WarnContext(ctx, "mysql: set deadline", "error", err)
return
}
if err := h.writeHandshake(conn); err != nil {
return
}
auth, err := ReadClientAuth(conn)
if err != nil {
return
}
if !strings.HasPrefix(auth.Username, mysqlUsernamePrefix) {
return
}
tokenID := strings.TrimPrefix(auth.Username, mysqlUsernamePrefix)
tok, err := h.tokens.GetByID(ctx, tokenID)
if err != nil || tok == nil {
return
}
sourceHost := remoteHost(conn)
if h.events != nil {
if recErr := h.recordEvent(ctx, tok, sourceHost, auth); recErr != nil {
slog.WarnContext(
ctx,
"mysql: record event",
"error", recErr,
"token_id", tok.ID,
)
}
}
if wErr := h.writeAccessDenied(
conn,
auth.Username,
sourceHost,
); wErr != nil {
slog.WarnContext(
ctx,
"mysql: write err packet",
"error", wErr,
"token_id", tok.ID,
)
}
}
func (h *Handler) writeHandshake(conn net.Conn) error {
connID, err := NewRandomConnectionID()
if err != nil {
return fmt.Errorf("connection id: %w", err)
}
authData, err := NewRandomAuthData()
if err != nil {
return fmt.Errorf("auth data: %w", err)
}
pkt, err := BuildHandshakeV10(connID, authData)
if err != nil {
return fmt.Errorf("build handshake: %w", err)
}
if _, err := conn.Write(pkt); err != nil {
return fmt.Errorf("write handshake: %w", err)
}
return nil
}
func (h *Handler) writeAccessDenied(
conn net.Conn,
username, sourceHost string,
) error {
pkt, err := BuildAccessDeniedErr(username, sourceHost)
if err != nil {
return fmt.Errorf("build err packet: %w", err)
}
if _, err := conn.Write(pkt); err != nil {
return fmt.Errorf("write err packet: %w", err)
}
return nil
}
func (h *Handler) recordEvent(
ctx context.Context,
tok *token.Token,
sourceHost string,
auth *ClientAuth,
) error {
extra, err := buildMySQLExtra(auth)
if err != nil {
return fmt.Errorf("build extra: %w", err)
}
evt := &event.Event{
TokenID: tok.ID,
SourceIP: sourceHost,
Extra: extra,
}
if err := h.events.Record(ctx, tok, evt); err != nil {
return fmt.Errorf("record: %w", err)
}
return nil
}
func buildMySQLExtra(auth *ClientAuth) (json.RawMessage, error) {
extra := map[string]any{
extraMySQLUsername: auth.Username,
extraMySQLCapabilities: fmt.Sprintf(
capabilitiesFormat,
auth.Capabilities,
),
extraMySQLCharset: auth.Charset,
}
body, err := json.Marshal(extra)
if err != nil {
return nil, fmt.Errorf("marshal mysql extra: %w", err)
}
return body, nil
}
func remoteHost(conn net.Conn) string {
addr := conn.RemoteAddr()
if addr == nil {
return ""
}
host, _, err := net.SplitHostPort(addr.String())
if err != nil {
return addr.String()
}
return host
}

View File

@ -0,0 +1,389 @@
// ©AngelaMos | 2026
// handler_test.go
package mysql_test
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"io"
"net"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/mysql"
)
func closeQuietly(t *testing.T, closers ...io.Closer) {
t.Helper()
for _, c := range closers {
if err := c.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
t.Logf("close: %v", err)
}
}
}
type fakeTokenLookup struct {
tokens map[string]*token.Token
err error
calls int32
}
func (f *fakeTokenLookup) GetByID(
_ context.Context,
id string,
) (*token.Token, error) {
atomic.AddInt32(&f.calls, 1)
if f.err != nil {
return nil, f.err
}
tok, ok := f.tokens[id]
if !ok {
return nil, nil
}
return tok, nil
}
type fakeEventRecorder struct {
mu sync.Mutex
events []*event.Event
err error
}
func (f *fakeEventRecorder) Record(
_ context.Context,
_ *token.Token,
evt *event.Event,
) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.err != nil {
return f.err
}
f.events = append(f.events, evt)
return nil
}
func (f *fakeEventRecorder) snapshot() []*event.Event {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]*event.Event, len(f.events))
copy(out, f.events)
return out
}
func newConnPair(t *testing.T) (clientSide, serverSide net.Conn) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer func() {
require.NoError(t, listener.Close())
}()
type accepted struct {
conn net.Conn
err error
}
ch := make(chan accepted, 1)
go func() {
c, aErr := listener.Accept()
ch <- accepted{conn: c, err: aErr}
}()
dialed, err := net.Dial("tcp", listener.Addr().String())
require.NoError(t, err)
a := <-ch
require.NoError(t, a.err)
return dialed, a.conn
}
func buildHandshakeResponse41ForHandler(t *testing.T, username string) []byte {
t.Helper()
var payload bytes.Buffer
var caps [4]byte
binary.LittleEndian.PutUint32(caps[:], 0x0001a285)
payload.Write(caps[:])
var maxPkt [4]byte
binary.LittleEndian.PutUint32(maxPkt[:], 0x01000000)
payload.Write(maxPkt[:])
payload.WriteByte(0x21)
var filler [23]byte
payload.Write(filler[:])
payload.WriteString(username)
payload.WriteByte(0x00)
body := payload.Bytes()
out := make([]byte, 4+len(body))
n := len(body)
out[0] = byte(n & 0xff)
out[1] = byte((n >> 8) & 0xff)
out[2] = byte((n >> 16) & 0xff)
out[3] = 0x01
copy(out[4:], body)
return out
}
func readAllAvailable(t *testing.T, conn net.Conn, want int) []byte {
t.Helper()
require.NoError(t, conn.SetReadDeadline(time.Now().Add(2*time.Second)))
buf := make([]byte, want)
total := 0
for total < want {
n, err := conn.Read(buf[total:])
if err != nil {
break
}
total += n
}
return buf[:total]
}
func TestHandleConnection_WritesHandshakeFirst(t *testing.T) {
client, server := newConnPair(t)
t.Cleanup(func() { closeQuietly(t, client, server) })
h := mysql.NewHandler(
&fakeTokenLookup{tokens: map[string]*token.Token{}},
&fakeEventRecorder{},
)
done := make(chan struct{})
go func() {
h.HandleConnection(context.Background(), server)
close(done)
}()
got := readAllAvailable(t, client, 80)
require.NotEmpty(t, got)
require.Equal(t, byte(0x00), got[3], "handshake sequence ID 0")
require.Equal(t, byte(0x0a), got[4], "protocol version 10")
require.Contains(t, string(got), "5.7.40-canary")
require.NoError(t, client.Close())
<-done
}
func TestHandleConnection_NonCanaryUsernameDropsSilently(t *testing.T) {
client, server := newConnPair(t)
t.Cleanup(func() { closeQuietly(t, client, server) })
tok := &token.Token{ID: "abc", Type: token.TypeMySQL}
lookup := &fakeTokenLookup{tokens: map[string]*token.Token{"abc": tok}}
rec := &fakeEventRecorder{}
h := mysql.NewHandler(lookup, rec)
done := make(chan struct{})
go func() {
h.HandleConnection(context.Background(), server)
close(done)
}()
_ = readAllAvailable(t, client, 80)
pkt := buildHandshakeResponse41ForHandler(t, "regular_user")
_, err := client.Write(pkt)
require.NoError(t, err)
<-done
require.Empty(
t,
rec.snapshot(),
"non-canary_-prefixed username must not produce an event",
)
}
func TestHandleConnection_KnownTokenRecordsEventAndSendsErr(t *testing.T) {
client, server := newConnPair(t)
t.Cleanup(func() { closeQuietly(t, client, server) })
tok := &token.Token{ID: "mytoken", Type: token.TypeMySQL}
lookup := &fakeTokenLookup{
tokens: map[string]*token.Token{"mytoken": tok},
}
rec := &fakeEventRecorder{}
h := mysql.NewHandler(lookup, rec)
done := make(chan struct{})
go func() {
h.HandleConnection(context.Background(), server)
close(done)
}()
_ = readAllAvailable(t, client, 80)
pkt := buildHandshakeResponse41ForHandler(t, "canary_mytoken")
_, err := client.Write(pkt)
require.NoError(t, err)
errPkt := readAllAvailable(t, client, 200)
require.NotEmpty(t, errPkt)
require.Contains(
t,
string(errPkt),
`Access denied for user 'canary_mytoken'`,
)
require.Contains(t, string(errPkt), "28000")
<-done
events := rec.snapshot()
require.Len(t, events, 1)
require.Equal(t, "mytoken", events[0].TokenID)
require.NotEmpty(
t,
events[0].SourceIP,
"source IP must be captured from RemoteAddr",
)
}
func TestHandleConnection_EventExtraContainsKubectlEquivalentFields(
t *testing.T,
) {
client, server := newConnPair(t)
t.Cleanup(func() { closeQuietly(t, client, server) })
tok := &token.Token{ID: "extra-probe", Type: token.TypeMySQL}
lookup := &fakeTokenLookup{
tokens: map[string]*token.Token{"extra-probe": tok},
}
rec := &fakeEventRecorder{}
h := mysql.NewHandler(lookup, rec)
done := make(chan struct{})
go func() {
h.HandleConnection(context.Background(), server)
close(done)
}()
_ = readAllAvailable(t, client, 80)
_, err := client.Write(
buildHandshakeResponse41ForHandler(t, "canary_extra-probe"),
)
require.NoError(t, err)
_ = readAllAvailable(t, client, 200)
<-done
events := rec.snapshot()
require.Len(t, events, 1)
require.NotEmpty(t, events[0].Extra)
var extra map[string]any
require.NoError(t, json.Unmarshal(events[0].Extra, &extra))
require.Equal(t, "canary_extra-probe", extra["mysql_username"])
require.Equal(
t,
"0x0001a285",
extra["mysql_client_capabilities"],
"capabilities formatted as 0x + 8 hex digits",
)
charset, ok := extra["mysql_client_charset"].(float64)
require.True(t, ok, "charset must decode as a JSON number")
require.Equal(t, 0x21, int(charset))
}
func TestHandleConnection_UnknownTokenDoesNotRecordOrErrAdvertise(
t *testing.T,
) {
client, server := newConnPair(t)
t.Cleanup(func() { closeQuietly(t, client, server) })
lookup := &fakeTokenLookup{tokens: map[string]*token.Token{}}
rec := &fakeEventRecorder{}
h := mysql.NewHandler(lookup, rec)
done := make(chan struct{})
go func() {
h.HandleConnection(context.Background(), server)
close(done)
}()
_ = readAllAvailable(t, client, 80)
_, err := client.Write(
buildHandshakeResponse41ForHandler(t, "canary_does-not-exist"),
)
require.NoError(t, err)
<-done
require.Empty(t, rec.snapshot(), "unknown token must not record an event")
}
func TestHandleConnection_LookupErrorIsSilent(t *testing.T) {
client, server := newConnPair(t)
t.Cleanup(func() { closeQuietly(t, client, server) })
lookup := &fakeTokenLookup{err: errors.New("db down")}
rec := &fakeEventRecorder{}
h := mysql.NewHandler(lookup, rec)
done := make(chan struct{})
go func() {
h.HandleConnection(context.Background(), server)
close(done)
}()
_ = readAllAvailable(t, client, 80)
_, err := client.Write(
buildHandshakeResponse41ForHandler(t, "canary_anything"),
)
require.NoError(t, err)
<-done
require.Empty(t, rec.snapshot())
}
func TestHandleConnection_NilEventRecorderTolerated(t *testing.T) {
client, server := newConnPair(t)
t.Cleanup(func() { closeQuietly(t, client, server) })
tok := &token.Token{ID: "noevents", Type: token.TypeMySQL}
lookup := &fakeTokenLookup{tokens: map[string]*token.Token{"noevents": tok}}
h := mysql.NewHandler(lookup, nil)
done := make(chan struct{})
go func() {
h.HandleConnection(context.Background(), server)
close(done)
}()
_ = readAllAvailable(t, client, 80)
_, err := client.Write(
buildHandshakeResponse41ForHandler(t, "canary_noevents"),
)
require.NoError(t, err)
errPkt := readAllAvailable(t, client, 200)
require.NotEmpty(
t,
errPkt,
"ERR packet still sent even without recorder wired",
)
<-done
}
func TestHandleConnection_BadAuthPacketDropsSilently(t *testing.T) {
client, server := newConnPair(t)
t.Cleanup(func() { closeQuietly(t, client, server) })
rec := &fakeEventRecorder{}
h := mysql.NewHandler(&fakeTokenLookup{}, rec)
done := make(chan struct{})
go func() {
h.HandleConnection(context.Background(), server)
close(done)
}()
_ = readAllAvailable(t, client, 80)
_, err := client.Write([]byte{0x00, 0x00, 0x00, 0x01})
require.NoError(t, err)
<-done
require.Empty(t, rec.snapshot())
}

View File

@ -0,0 +1,220 @@
// ©AngelaMos | 2026
// protocol.go
package mysql
import (
"bytes"
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"io"
)
const (
packetHeaderSize = 4
protocolVersion = 0x0a
serverVersionString = "5.7.40-canary"
capabilityFlagsLower = uint16(0xf7ff)
capabilityFlagsUpper = uint16(0x81ff)
characterSetUtf8MB4 = 0x21
statusFlags = uint16(0x0002)
authPluginDataLen = byte(0x15)
authPluginName = "mysql_native_password"
errPacketHeader = 0xff
errCodeAccessDenied = uint16(1045)
sqlStateMarker = '#'
sqlStateAccessDenied = "28000"
seqIDServerHandshake byte = 0x00
seqIDServerErr byte = 0x02
maxPacketSize = 0x00ffffff
handshakeResponseMaxBytes = 64 * 1024
handshakeResponseFillerLen = 23
handshakeResponseHeaderBytes = 4 + 4 + 1 + handshakeResponseFillerLen
authPluginDataTotalLen = 20
authPluginDataPart1Len = 8
authPluginDataPart2Len = 12
)
var (
ErrShortPacket = errors.New("mysql: short packet header")
ErrPacketTooLarge = errors.New("mysql: packet exceeds max size")
ErrInvalidPayload = errors.New("mysql: invalid payload structure")
ErrUsernameMissing = errors.New(
"mysql: username missing or unterminated",
)
ErrHTTPTriggerNotSupported = errors.New(
"mysql: Trigger via HTTP is not applicable (use the TCP listener)",
)
)
type ClientAuth struct {
Capabilities uint32
MaxPacketSize uint32
Charset uint8
Username string
}
func BuildHandshakeV10(
connID uint32,
authData [authPluginDataTotalLen]byte,
) ([]byte, error) {
var payload bytes.Buffer
payload.WriteByte(protocolVersion)
payload.WriteString(serverVersionString)
payload.WriteByte(0x00)
var connIDBytes [4]byte
binary.LittleEndian.PutUint32(connIDBytes[:], connID)
payload.Write(connIDBytes[:])
payload.Write(authData[:authPluginDataPart1Len])
payload.WriteByte(0x00)
var capLower [2]byte
binary.LittleEndian.PutUint16(capLower[:], capabilityFlagsLower)
payload.Write(capLower[:])
payload.WriteByte(characterSetUtf8MB4)
var statusBytes [2]byte
binary.LittleEndian.PutUint16(statusBytes[:], statusFlags)
payload.Write(statusBytes[:])
var capUpper [2]byte
binary.LittleEndian.PutUint16(capUpper[:], capabilityFlagsUpper)
payload.Write(capUpper[:])
payload.WriteByte(authPluginDataLen)
var reserved [10]byte
payload.Write(reserved[:])
payload.Write(authData[authPluginDataPart1Len:])
payload.WriteByte(0x00)
payload.WriteString(authPluginName)
payload.WriteByte(0x00)
return wrapPacket(payload.Bytes(), seqIDServerHandshake)
}
func ReadClientAuth(r io.Reader) (*ClientAuth, error) {
payload, err := readPacket(r)
if err != nil {
return nil, err
}
if len(payload) < handshakeResponseHeaderBytes+1 {
return nil, fmt.Errorf(
"%w: have %d bytes, need at least %d",
ErrInvalidPayload,
len(payload),
handshakeResponseHeaderBytes+1,
)
}
auth := &ClientAuth{
Capabilities: binary.LittleEndian.Uint32(payload[0:4]),
MaxPacketSize: binary.LittleEndian.Uint32(payload[4:8]),
Charset: payload[8],
}
usernameStart := handshakeResponseHeaderBytes
rel := bytes.IndexByte(payload[usernameStart:], 0x00)
if rel < 0 {
return nil, ErrUsernameMissing
}
auth.Username = string(payload[usernameStart : usernameStart+rel])
return auth, nil
}
func BuildAccessDeniedErr(username, sourceHost string) ([]byte, error) {
msg := fmt.Sprintf(
`Access denied for user '%s'@'%s' (using password: YES)`,
username,
sourceHost,
)
var payload bytes.Buffer
payload.WriteByte(errPacketHeader)
var code [2]byte
binary.LittleEndian.PutUint16(code[:], errCodeAccessDenied)
payload.Write(code[:])
payload.WriteByte(sqlStateMarker)
payload.WriteString(sqlStateAccessDenied)
payload.WriteString(msg)
return wrapPacket(payload.Bytes(), seqIDServerErr)
}
func wrapPacket(payload []byte, seqID byte) ([]byte, error) {
if len(payload) > maxPacketSize {
return nil, fmt.Errorf(
"%w: payload %d > max %d",
ErrPacketTooLarge,
len(payload),
maxPacketSize,
)
}
out := make([]byte, packetHeaderSize+len(payload))
n := len(payload)
out[0] = byte(n & 0xff)
out[1] = byte((n >> 8) & 0xff)
out[2] = byte((n >> 16) & 0xff)
out[3] = seqID
copy(out[packetHeaderSize:], payload)
return out, nil
}
func readPacket(r io.Reader) ([]byte, error) {
var hdr [packetHeaderSize]byte
if _, err := io.ReadFull(r, hdr[:]); err != nil {
return nil, fmt.Errorf("%w: %w", ErrShortPacket, err)
}
payloadLen := uint32(hdr[0]) |
uint32(hdr[1])<<8 |
uint32(hdr[2])<<16
if payloadLen > handshakeResponseMaxBytes {
return nil, fmt.Errorf(
"%w: payload %d > limit %d",
ErrPacketTooLarge,
payloadLen,
handshakeResponseMaxBytes,
)
}
if payloadLen == 0 {
return nil, nil
}
payload := make([]byte, payloadLen)
if _, err := io.ReadFull(r, payload); err != nil {
return nil, fmt.Errorf("mysql: read payload: %w", err)
}
return payload, nil
}
func NewRandomAuthData() ([authPluginDataTotalLen]byte, error) {
var data [authPluginDataTotalLen]byte
if _, err := rand.Read(data[:]); err != nil {
return data, fmt.Errorf("mysql: random auth data: %w", err)
}
return data, nil
}
func NewRandomConnectionID() (uint32, error) {
var b [4]byte
if _, err := rand.Read(b[:]); err != nil {
return 0, fmt.Errorf("mysql: random connection id: %w", err)
}
return binary.LittleEndian.Uint32(b[:]), nil
}

View File

@ -0,0 +1,329 @@
// ©AngelaMos | 2026
// protocol_test.go
package mysql_test
import (
"bytes"
"encoding/binary"
"io"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/mysql"
)
const (
packetHeaderSize = 4
expectedProtoVersion = 0x0a
expectedCharset = 0x21
)
func buildHandshakeResponse41(t *testing.T, username string) []byte {
t.Helper()
var payload bytes.Buffer
var caps [4]byte
binary.LittleEndian.PutUint32(caps[:], 0x0001a285)
payload.Write(caps[:])
var maxPkt [4]byte
binary.LittleEndian.PutUint32(maxPkt[:], 0x01000000)
payload.Write(maxPkt[:])
payload.WriteByte(expectedCharset)
var filler [23]byte
payload.Write(filler[:])
payload.WriteString(username)
payload.WriteByte(0x00)
body := payload.Bytes()
out := make([]byte, packetHeaderSize+len(body))
n := len(body)
out[0] = byte(n & 0xff)
out[1] = byte((n >> 8) & 0xff)
out[2] = byte((n >> 16) & 0xff)
out[3] = 0x01
copy(out[packetHeaderSize:], body)
return out
}
func TestBuildHandshakeV10_HasCorrectPacketHeader(t *testing.T) {
auth := [20]byte{
1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
}
pkt, err := mysql.BuildHandshakeV10(0xdeadbeef, auth)
require.NoError(t, err)
require.Greater(t, len(pkt), packetHeaderSize)
payloadLen := int(pkt[0]) |
int(pkt[1])<<8 |
int(pkt[2])<<16
require.Equal(t, len(pkt)-packetHeaderSize, payloadLen)
require.Equal(t, byte(0x00), pkt[3], "server handshake sequence ID is 0")
}
func TestBuildHandshakeV10_PayloadStartsWithProtocolVersion(t *testing.T) {
var auth [20]byte
pkt, err := mysql.BuildHandshakeV10(0x1234, auth)
require.NoError(t, err)
require.Equal(t, byte(expectedProtoVersion), pkt[packetHeaderSize])
}
func TestBuildHandshakeV10_ContainsServerVersionString(t *testing.T) {
var auth [20]byte
pkt, err := mysql.BuildHandshakeV10(0x1234, auth)
require.NoError(t, err)
require.Contains(
t,
string(pkt),
"5.7.40-canary",
"handshake must advertise the canary-server version string for verisimilitude",
)
}
func TestBuildHandshakeV10_AdvertisesMySQLNativePassword(t *testing.T) {
var auth [20]byte
pkt, err := mysql.BuildHandshakeV10(0x1234, auth)
require.NoError(t, err)
require.Contains(t, string(pkt), "mysql_native_password")
}
func TestBuildHandshakeV10_EmbedsConnectionIDLittleEndian(t *testing.T) {
var auth [20]byte
connID := uint32(0xdeadbeef)
pkt, err := mysql.BuildHandshakeV10(connID, auth)
require.NoError(t, err)
versionEnd := bytes.IndexByte(
pkt[packetHeaderSize+1:],
0x00,
) + packetHeaderSize + 1
require.Greater(t, versionEnd, packetHeaderSize+1)
connIDStart := versionEnd + 1
got := binary.LittleEndian.Uint32(pkt[connIDStart : connIDStart+4])
require.Equal(t, connID, got)
}
func TestBuildHandshakeV10_EmbedsAuthDataInTwoParts(t *testing.T) {
auth := [20]byte{
10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
20, 21, 22, 23, 24, 25, 26, 27, 28, 29,
}
pkt, err := mysql.BuildHandshakeV10(0xcafef00d, auth)
require.NoError(t, err)
require.Contains(
t,
string(pkt),
string(auth[:8]),
"first 8 bytes of auth data must appear in handshake",
)
require.Contains(
t,
string(pkt),
string(auth[8:]),
"last 12 bytes of auth data must appear in handshake (after part-1 + filler + caps + ...)",
)
}
func TestReadClientAuth_ExtractsUsername(t *testing.T) {
pkt := buildHandshakeResponse41(t, "canary_abc123def456")
auth, err := mysql.ReadClientAuth(bytes.NewReader(pkt))
require.NoError(t, err)
require.NotNil(t, auth)
require.Equal(t, "canary_abc123def456", auth.Username)
}
func TestReadClientAuth_CapturesCapabilities(t *testing.T) {
pkt := buildHandshakeResponse41(t, "anyone")
auth, err := mysql.ReadClientAuth(bytes.NewReader(pkt))
require.NoError(t, err)
require.Equal(t, uint32(0x0001a285), auth.Capabilities)
require.Equal(t, uint32(0x01000000), auth.MaxPacketSize)
require.Equal(t, uint8(expectedCharset), auth.Charset)
}
func TestReadClientAuth_EmptyUsernameStillParses(t *testing.T) {
pkt := buildHandshakeResponse41(t, "")
auth, err := mysql.ReadClientAuth(bytes.NewReader(pkt))
require.NoError(t, err)
require.Empty(t, auth.Username)
}
func TestReadClientAuth_ShortPayloadReturnsError(t *testing.T) {
short := make([]byte, packetHeaderSize+5)
short[0] = 0x05
_, err := mysql.ReadClientAuth(bytes.NewReader(short))
require.Error(t, err)
require.ErrorIs(t, err, mysql.ErrInvalidPayload)
}
func TestReadClientAuth_NoUsernameTerminatorReturnsError(t *testing.T) {
var payload bytes.Buffer
var caps [4]byte
payload.Write(caps[:])
var maxPkt [4]byte
payload.Write(maxPkt[:])
payload.WriteByte(expectedCharset)
var filler [23]byte
payload.Write(filler[:])
payload.WriteString("nocan_terminator_here_no_null")
body := payload.Bytes()
out := make([]byte, packetHeaderSize+len(body))
n := len(body)
out[0] = byte(n & 0xff)
out[1] = byte((n >> 8) & 0xff)
out[2] = byte((n >> 16) & 0xff)
out[3] = 0x01
copy(out[packetHeaderSize:], body)
_, err := mysql.ReadClientAuth(bytes.NewReader(out))
require.Error(t, err)
require.ErrorIs(t, err, mysql.ErrUsernameMissing)
}
func TestReadClientAuth_EmptyReaderReturnsShortPacket(t *testing.T) {
_, err := mysql.ReadClientAuth(bytes.NewReader(nil))
require.Error(t, err)
require.ErrorIs(t, err, mysql.ErrShortPacket)
}
func TestReadClientAuth_OversizePacketRejected(t *testing.T) {
var hdr [packetHeaderSize]byte
hdr[0] = 0x01
hdr[1] = 0x00
hdr[2] = 0x10
hdr[3] = 0x01
r := io.MultiReader(
bytes.NewReader(hdr[:]),
strings.NewReader(strings.Repeat("X", 70000)),
)
_, err := mysql.ReadClientAuth(r)
require.Error(t, err)
require.ErrorIs(t, err, mysql.ErrPacketTooLarge)
}
func TestBuildAccessDeniedErr_HasCorrectMarkerAndCode(t *testing.T) {
pkt, err := mysql.BuildAccessDeniedErr("canary_xyz", "203.0.113.50")
require.NoError(t, err)
require.Greater(t, len(pkt), packetHeaderSize)
require.Equal(
t,
byte(0xff),
pkt[packetHeaderSize],
"ERR packet header byte",
)
code := binary.LittleEndian.Uint16(
pkt[packetHeaderSize+1 : packetHeaderSize+3],
)
require.Equal(
t,
uint16(1045),
code,
"MySQL error code 1045 (access denied)",
)
}
func TestBuildAccessDeniedErr_HasSQLStateMarkerAnd28000(t *testing.T) {
pkt, err := mysql.BuildAccessDeniedErr("canary_xyz", "203.0.113.50")
require.NoError(t, err)
require.Equal(
t,
byte('#'),
pkt[packetHeaderSize+3],
"SQL state marker must be '#'",
)
require.Equal(
t,
"28000",
string(pkt[packetHeaderSize+4:packetHeaderSize+9]),
"SQL state 28000 (invalid authorization)",
)
}
func TestBuildAccessDeniedErr_MessageContainsUserAndHost(t *testing.T) {
pkt, err := mysql.BuildAccessDeniedErr("canary_abc", "203.0.113.7")
require.NoError(t, err)
require.Contains(
t,
string(pkt),
`Access denied for user 'canary_abc'@'203.0.113.7' (using password: YES)`,
)
}
func TestBuildAccessDeniedErr_SequenceIDIs2(t *testing.T) {
pkt, err := mysql.BuildAccessDeniedErr("u", "h")
require.NoError(t, err)
require.Equal(
t,
byte(0x02),
pkt[3],
"ERR packet sequence ID is 2 (after handshake=0 and client auth=1)",
)
}
func TestRoundTrip_BuildAndParseUsername(t *testing.T) {
cases := []string{
"canary_abcdef0123",
"canary_xyz",
"root",
"",
"app_user",
}
for _, name := range cases {
name := name
t.Run("username="+name, func(t *testing.T) {
pkt := buildHandshakeResponse41(t, name)
auth, err := mysql.ReadClientAuth(bytes.NewReader(pkt))
require.NoError(t, err)
require.Equal(t, name, auth.Username)
})
}
}
func TestNewRandomAuthData_ReturnsTwentyBytes(t *testing.T) {
d, err := mysql.NewRandomAuthData()
require.NoError(t, err)
require.Len(t, d, 20)
}
func TestNewRandomAuthData_DistinctCallsProduceDistinctOutputs(t *testing.T) {
seen := make(map[[20]byte]struct{})
for range 50 {
d, err := mysql.NewRandomAuthData()
require.NoError(t, err)
seen[d] = struct{}{}
}
require.Greater(
t,
len(seen),
45,
"50 calls to NewRandomAuthData should produce near-50 unique values",
)
}
func TestNewRandomConnectionID_DistinctCallsProduceDistinctOutputs(
t *testing.T,
) {
seen := make(map[uint32]struct{})
for range 50 {
id, err := mysql.NewRandomConnectionID()
require.NoError(t, err)
seen[id] = struct{}{}
}
require.Greater(t, len(seen), 45)
}
func TestErrors_AreDistinctSentinels(t *testing.T) {
require.NotErrorIs(t, mysql.ErrShortPacket, mysql.ErrPacketTooLarge)
require.NotErrorIs(t, mysql.ErrPacketTooLarge, mysql.ErrInvalidPayload)
require.NotErrorIs(t, mysql.ErrInvalidPayload, mysql.ErrUsernameMissing)
}

View File

@ -0,0 +1,84 @@
// ©AngelaMos | 2026
// server.go
package mysql
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"sync"
"time"
)
const (
acceptShutdownGrace = 2 * time.Second
tcpNetwork = "tcp"
)
type ConnectionHandler interface {
HandleConnection(ctx context.Context, conn net.Conn)
}
func Run(
ctx context.Context,
addr string,
h ConnectionHandler,
) error {
if h == nil {
return fmt.Errorf("mysql: nil connection handler")
}
var lc net.ListenConfig
listener, err := lc.Listen(ctx, tcpNetwork, addr)
if err != nil {
return fmt.Errorf("mysql: listen %s: %w", addr, err)
}
defer func() {
if cErr := listener.Close(); cErr != nil &&
!errors.Is(cErr, net.ErrClosed) {
slog.WarnContext(ctx, "mysql: close listener", "error", cErr)
}
}()
slog.InfoContext(
ctx,
"mysql: listener started",
"addr",
listener.Addr().String(),
)
go func() {
<-ctx.Done()
if cErr := listener.Close(); cErr != nil &&
!errors.Is(cErr, net.ErrClosed) {
slog.WarnContext(
ctx,
"mysql: shutdown close listener",
"error",
cErr,
)
}
}()
var wg sync.WaitGroup
for {
conn, aErr := listener.Accept()
if aErr != nil {
if errors.Is(ctx.Err(), context.Canceled) ||
errors.Is(aErr, net.ErrClosed) {
wg.Wait()
return nil
}
slog.WarnContext(ctx, "mysql: accept", "error", aErr)
continue
}
wg.Add(1)
go func(c net.Conn) {
defer wg.Done()
h.HandleConnection(ctx, c)
}(conn)
}
}

View File

@ -0,0 +1,157 @@
// ©AngelaMos | 2026
// server_test.go
package mysql_test
import (
"context"
"errors"
"net"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/mysql"
)
type stubHandler struct {
called atomic.Int32
lastCloseErr atomic.Value
closed chan struct{}
once sync.Once
}
func newStubHandler() *stubHandler {
return &stubHandler{closed: make(chan struct{})}
}
func (s *stubHandler) HandleConnection(_ context.Context, conn net.Conn) {
s.called.Add(1)
if err := conn.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
s.lastCloseErr.Store(err)
}
s.once.Do(func() { close(s.closed) })
}
func dialOnce(addr string) error {
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err != nil {
return err
}
if cErr := conn.Close(); cErr != nil && !errors.Is(cErr, net.ErrClosed) {
return cErr
}
return nil
}
func runServer(
t *testing.T,
ctx context.Context,
h mysql.ConnectionHandler,
) (string, <-chan error) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
addr := listener.Addr().String()
require.NoError(t, listener.Close())
done := make(chan error, 1)
go func() {
done <- mysql.Run(ctx, addr, h)
}()
require.Eventually(t, func() bool {
return dialOnce(addr) == nil
}, 2*time.Second, 25*time.Millisecond, "server must accept on %s", addr)
return addr, done
}
func TestRun_NilHandlerReturnsError(t *testing.T) {
err := mysql.Run(context.Background(), "127.0.0.1:0", nil)
require.Error(t, err)
}
func TestRun_AcceptsConnectionAndDispatchesToHandler(t *testing.T) {
h := newStubHandler()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
addr, done := runServer(t, ctx, h)
before := h.called.Load()
require.NoError(t, dialOnce(addr))
require.Eventually(t, func() bool {
return h.called.Load() > before
}, 2*time.Second, 25*time.Millisecond,
"single dial must dispatch to handler")
cancel()
require.NoError(t, <-done)
}
func TestRun_HandlesMultipleConcurrentConnections(t *testing.T) {
h := newStubHandler()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
addr, done := runServer(t, ctx, h)
before := h.called.Load()
const n = 10
results := make(chan error, n)
var wg sync.WaitGroup
for range n {
wg.Add(1)
go func() {
defer wg.Done()
results <- dialOnce(addr)
}()
}
wg.Wait()
close(results)
for err := range results {
require.NoError(t, err)
}
require.Eventually(t, func() bool {
return h.called.Load() >= before+int32(n)
}, 2*time.Second, 25*time.Millisecond,
"all %d connections must be dispatched", n)
cancel()
require.NoError(t, <-done)
}
func TestRun_ContextCancellationStopsListenerCleanly(t *testing.T) {
h := newStubHandler()
ctx, cancel := context.WithCancel(context.Background())
addr, done := runServer(t, ctx, h)
require.NoError(t, dialOnce(addr))
cancel()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(2 * time.Second):
t.Fatal("Run did not return within 2s after context cancellation")
}
_, err := net.DialTimeout("tcp", addr, 100*time.Millisecond)
require.Error(t, err, "listener must be closed after context cancellation")
}
func TestRun_InvalidAddressReturnsError(t *testing.T) {
err := mysql.Run(
context.Background(),
"not-a-valid-addr:not-a-port",
newStubHandler(),
)
require.Error(t, err)
}

View File

@ -0,0 +1,156 @@
// ©AngelaMos | 2026
// generator.go
package pdf
import (
"bytes"
"context"
_ "embed"
"errors"
"fmt"
"net/http"
"strings"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/pixel"
)
const (
headerReferer = "Referer"
headerCacheControl = "Cache-Control"
headerPragma = "Pragma"
cacheControlNoStore = "no-store, no-cache, must-revalidate, max-age=0"
pragmaNoCache = "no-cache"
triggerPathPrefix = "/c/"
placeholderRoot = "HONEY_TRACK_URL_PADDED_TO_FIXED_WIDTH"
PlaceholderLength = 76
padChar = "_"
queryPadPrefix = "?p="
contentType = "application/pdf"
defaultFilename = "Document.pdf"
)
var ErrTriggerURLTooLong = errors.New(
"pdf: trigger URL exceeds placeholder length",
)
//go:embed template/template.pdf
var pdfTemplate []byte
var placeholder = placeholderRoot +
strings.Repeat(padChar, PlaceholderLength-len(placeholderRoot))
type Generator struct{}
func New() *Generator { return &Generator{} }
func (g *Generator) Type() token.Type { return token.TypePDF }
func (g *Generator) Generate(
_ context.Context,
t *token.Token,
baseURL string,
) (generators.Artifact, error) {
triggerURL := strings.TrimRight(baseURL, "/") + triggerPathPrefix + t.ID
if len(triggerURL) > PlaceholderLength {
return generators.Artifact{}, fmt.Errorf(
"%w: url=%d max=%d",
ErrTriggerURLTooLong,
len(triggerURL),
PlaceholderLength,
)
}
padded := padTriggerURL(triggerURL)
out := bytes.Replace(
pdfTemplate,
[]byte(placeholder),
[]byte(padded),
1,
)
if len(out) != len(pdfTemplate) {
return generators.Artifact{}, fmt.Errorf(
"pdf: substitution changed byte length (was %d, now %d)",
len(pdfTemplate),
len(out),
)
}
if !bytes.Contains(out, []byte(triggerURL)) {
return generators.Artifact{}, fmt.Errorf(
"pdf: substitution did not embed trigger URL",
)
}
return generators.Artifact{
Kind: generators.KindFile,
Filename: resolveFilename(t.Filename),
Content: out,
ContentType: contentType,
}, nil
}
func (g *Generator) Trigger(
_ context.Context,
t *token.Token,
r *http.Request,
) (*event.Event, *generators.TriggerResponse, error) {
resp := &generators.TriggerResponse{
StatusCode: http.StatusOK,
ContentType: pixel.ContentType,
Body: pixel.Clone(),
ExtraHeaders: map[string]string{
headerCacheControl: cacheControlNoStore,
headerPragma: pragmaNoCache,
},
}
if t == nil {
return nil, resp, nil
}
evt := &event.Event{
TokenID: t.ID,
SourceIP: middleware.RealIP(r),
UserAgent: middleware.OptionalHeader(r.UserAgent()),
Referer: middleware.OptionalHeader(r.Header.Get(headerReferer)),
}
return evt, resp, nil
}
func resolveFilename(name *string) string {
if name == nil {
return defaultFilename
}
trimmed := strings.TrimSpace(*name)
if trimmed == "" {
return defaultFilename
}
return trimmed
}
func padTriggerURL(triggerURL string) string {
needed := PlaceholderLength - len(triggerURL)
switch {
case needed == 0:
return triggerURL
case needed >= len(queryPadPrefix):
return triggerURL + queryPadPrefix +
strings.Repeat(padChar, needed-len(queryPadPrefix))
default:
return triggerURL + strings.Repeat(padChar, needed)
}
}

View File

@ -0,0 +1,656 @@
// ©AngelaMos | 2026
// generator_test.go
package pdf_test
import (
"bytes"
"context"
_ "embed"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/pdfcpu/pdfcpu/pkg/api"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/pdf"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/pixel"
)
//go:embed template/template.pdf
var rawTemplate []byte
const (
testBaseURL = "https://canary.example.com"
placeholderRootLiteral = "HONEY_TRACK_URL_PADDED_TO_FIXED_WIDTH"
pdfContentTypeMIME = "application/pdf"
defaultFilename = "Document.pdf"
cacheControlNoStoreValue = "no-store, no-cache, must-revalidate, max-age=0"
pragmaNoCacheValue = "no-cache"
gifByteLength = 43
pdfHeaderPrefix = "%PDF-"
pdfTrailer = "%%EOF\n"
)
func fullPlaceholder() string {
return placeholderRootLiteral +
strings.Repeat("_", pdf.PlaceholderLength-len(placeholderRootLiteral))
}
func newPDFToken(id string) *token.Token {
return &token.Token{
ID: id,
ManageID: "manage-" + id,
Type: token.TypePDF,
Memo: "unit test pdf",
AlertChannel: token.ChannelWebhook,
Enabled: true,
}
}
func newPDFTokenWithFilename(id, filename string) *token.Token {
tok := newPDFToken(id)
tok.Filename = &filename
return tok
}
func TestTemplate_PlaceholderRootIsByteLocatableExactlyOnce(t *testing.T) {
require.Equal(
t,
1,
bytes.Count(rawTemplate, []byte(placeholderRootLiteral)),
"template must contain the placeholder root exactly once as literal bytes",
)
}
func TestTemplate_FullPlaceholderIsByteLocatableExactlyOnce(t *testing.T) {
full := fullPlaceholder()
require.Len(
t,
full,
pdf.PlaceholderLength,
"derived placeholder must be %d bytes",
pdf.PlaceholderLength,
)
require.Equal(
t,
1,
bytes.Count(rawTemplate, []byte(full)),
"template must contain the full %d-byte placeholder exactly once as literal bytes (not encoded inside a Flate stream)",
pdf.PlaceholderLength,
)
}
func TestTemplate_StartsWithPDFHeader(t *testing.T) {
require.True(
t,
bytes.HasPrefix(rawTemplate, []byte(pdfHeaderPrefix)),
"template must start with %q",
pdfHeaderPrefix,
)
}
func TestTemplate_EndsWithEOFMarker(t *testing.T) {
require.True(
t,
bytes.HasSuffix(rawTemplate, []byte(pdfTrailer)),
"template must end with %q",
pdfTrailer,
)
}
func TestTemplate_PdfcpuValidates(t *testing.T) {
require.NoError(
t,
api.Validate(bytes.NewReader(rawTemplate), nil),
"template must pass pdfcpu validation",
)
}
func TestGenerator_TypeIsPDF(t *testing.T) {
g := pdf.New()
require.Equal(t, token.TypePDF, g.Type())
}
func TestGenerate_ArtifactKindIsFile(t *testing.T) {
g := pdf.New()
art, err := g.Generate(
context.Background(),
newPDFToken("abc"),
testBaseURL,
)
require.NoError(t, err)
require.Equal(t, generators.KindFile, art.Kind)
}
func TestGenerate_ContentTypeIsPDFMIME(t *testing.T) {
g := pdf.New()
art, err := g.Generate(
context.Background(),
newPDFToken("abc"),
testBaseURL,
)
require.NoError(t, err)
require.Equal(t, pdfContentTypeMIME, art.ContentType)
}
func TestGenerate_Filename(t *testing.T) {
g := pdf.New()
t.Run("nil Filename defaults to Document.pdf", func(t *testing.T) {
art, err := g.Generate(
context.Background(),
newPDFToken("abc"),
testBaseURL,
)
require.NoError(t, err)
require.Equal(t, defaultFilename, art.Filename)
})
t.Run(
"empty Filename pointer defaults to Document.pdf",
func(t *testing.T) {
art, err := g.Generate(
context.Background(),
newPDFTokenWithFilename("abc", ""),
testBaseURL,
)
require.NoError(t, err)
require.Equal(t, defaultFilename, art.Filename)
},
)
t.Run(
"whitespace-only Filename defaults to Document.pdf",
func(t *testing.T) {
art, err := g.Generate(
context.Background(),
newPDFTokenWithFilename("abc", " "),
testBaseURL,
)
require.NoError(t, err)
require.Equal(t, defaultFilename, art.Filename)
},
)
t.Run("set Filename is preserved (trimmed)", func(t *testing.T) {
art, err := g.Generate(
context.Background(),
newPDFTokenWithFilename("abc", " Q4-Plan.pdf "),
testBaseURL,
)
require.NoError(t, err)
require.Equal(t, "Q4-Plan.pdf", art.Filename)
})
}
func TestGenerate_TriggerURL(t *testing.T) {
g := pdf.New()
t.Run("base URL trailing slash trimmed", func(t *testing.T) {
artA, err := g.Generate(
context.Background(),
newPDFToken("tk1"),
"https://canary.example.com",
)
require.NoError(t, err)
artB, err := g.Generate(
context.Background(),
newPDFToken("tk1"),
"https://canary.example.com/",
)
require.NoError(t, err)
require.Contains(
t,
string(artA.Content),
"https://canary.example.com/c/tk1",
)
require.Contains(
t,
string(artB.Content),
"https://canary.example.com/c/tk1",
)
})
t.Run("base URL subpath preserved", func(t *testing.T) {
art, err := g.Generate(
context.Background(),
newPDFToken("tk2"),
"https://example.com/canary",
)
require.NoError(t, err)
require.Contains(
t,
string(art.Content),
"https://example.com/canary/c/tk2",
)
})
t.Run(
"different token ids produce distinct outputs",
func(t *testing.T) {
artA, err := g.Generate(
context.Background(),
newPDFToken("aaa"),
testBaseURL,
)
require.NoError(t, err)
artB, err := g.Generate(
context.Background(),
newPDFToken("bbb"),
testBaseURL,
)
require.NoError(t, err)
require.NotEqual(t, artA.Content, artB.Content)
},
)
}
func TestGenerate_LengthUnchanged(t *testing.T) {
g := pdf.New()
art, err := g.Generate(
context.Background(),
newPDFToken("abc"),
testBaseURL,
)
require.NoError(t, err)
require.Len(
t,
art.Content,
len(rawTemplate),
"output byte length must equal template byte length so xref offsets remain valid",
)
}
func TestGenerate_ContainsTriggerURL(t *testing.T) {
g := pdf.New()
art, err := g.Generate(
context.Background(),
newPDFToken("token42"),
testBaseURL,
)
require.NoError(t, err)
require.True(
t,
bytes.Contains(
art.Content,
[]byte("https://canary.example.com/c/token42"),
),
"output must contain the canary trigger URL after substitution",
)
}
func TestGenerate_ByteAfterTokenIDIsNotUnderscore(t *testing.T) {
g := pdf.New()
art, err := g.Generate(
context.Background(),
newPDFToken("token42"),
testBaseURL,
)
require.NoError(t, err)
urlPrefix := testBaseURL + "/c/token42"
idx := bytes.Index(art.Content, []byte(urlPrefix))
require.GreaterOrEqual(
t,
idx,
0,
"trigger URL prefix must be present in PDF output",
)
after := art.Content[idx+len(urlPrefix)]
require.NotEqual(
t,
byte('_'),
after,
"byte immediately after the token id must not be underscore — "+
"otherwise Acrobat fetches /c/token42____ and the canary silently "+
"no-ops on lookup (audit finding F2)",
)
}
func TestGenerate_PlaceholderRemoved(t *testing.T) {
g := pdf.New()
art, err := g.Generate(
context.Background(),
newPDFToken("xyz"),
testBaseURL,
)
require.NoError(t, err)
require.False(
t,
bytes.Contains(art.Content, []byte(placeholderRootLiteral)),
"placeholder root must be fully substituted out of the output",
)
}
func TestGenerate_TooLongURL_ReturnsError(t *testing.T) {
g := pdf.New()
overLongID := strings.Repeat("x", pdf.PlaceholderLength)
_, err := g.Generate(
context.Background(),
newPDFToken(overLongID),
testBaseURL,
)
require.Error(t, err)
require.ErrorIs(t, err, pdf.ErrTriggerURLTooLong)
}
func TestGenerate_BoundaryURL_AtPlaceholderLength_Succeeds(t *testing.T) {
g := pdf.New()
base := "http://x"
prefix := base + "/c/"
idLen := pdf.PlaceholderLength - len(prefix)
require.Positive(
t,
idLen,
"test base+/c/ must leave room for at least one id char",
)
id := strings.Repeat("a", idLen)
art, err := g.Generate(
context.Background(),
newPDFToken(id),
base,
)
require.NoError(
t,
err,
"URL whose length exactly equals placeholder length must succeed (no padding needed)",
)
require.True(
t,
bytes.Contains(art.Content, []byte(prefix+id)),
"boundary URL must be present in output",
)
}
func TestGenerate_PdfcpuValidates(t *testing.T) {
g := pdf.New()
art, err := g.Generate(
context.Background(),
newPDFToken("validate-me"),
testBaseURL,
)
require.NoError(t, err)
require.NoError(
t,
api.Validate(bytes.NewReader(art.Content), nil),
"output PDF must remain valid after placeholder substitution",
)
}
func TestGenerate_OutputDifferentFromTemplate(t *testing.T) {
g := pdf.New()
art, err := g.Generate(
context.Background(),
newPDFToken("abc"),
testBaseURL,
)
require.NoError(t, err)
require.NotEqual(
t,
rawTemplate,
art.Content,
"output must differ from template after substitution",
)
}
func TestGenerate_SubstitutionAtSamePlaceholderPosition(t *testing.T) {
g := pdf.New()
art, err := g.Generate(
context.Background(),
newPDFToken("position-check"),
testBaseURL,
)
require.NoError(t, err)
placeholderOffset := bytes.Index(
rawTemplate,
[]byte(placeholderRootLiteral),
)
require.GreaterOrEqual(t, placeholderOffset, 0)
urlOffset := bytes.Index(
art.Content,
[]byte("https://canary.example.com/c/position-check"),
)
require.Equal(
t,
placeholderOffset,
urlOffset,
"trigger URL must occupy the same byte offset the placeholder did",
)
}
func TestTrigger_ReturnsGIFLikeWebbug(t *testing.T) {
g := pdf.New()
tok := newPDFToken("abc")
r := httptest.NewRequest(http.MethodGet, "/c/abc", nil)
_, resp, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, pixel.ContentType, resp.ContentType)
require.Len(t, resp.Body, gifByteLength)
require.Equal(t, pixel.Clone(), resp.Body)
require.Equal(
t,
cacheControlNoStoreValue,
resp.ExtraHeaders["Cache-Control"],
)
require.Equal(t, pragmaNoCacheValue, resp.ExtraHeaders["Pragma"])
}
func TestTrigger_RecordsEventWithRequestMetadata(t *testing.T) {
g := pdf.New()
tok := newPDFToken("token1")
t.Run(
"captures token id, source ip, user agent, referer",
func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/c/token1", nil)
r.Header.Set("CF-Connecting-IP", "203.0.113.50")
r.Header.Set("User-Agent", "AcrobatReader/2024.001")
r.Header.Set("Referer", "https://victim.example.com/share")
evt, _, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
require.NotNil(t, evt)
require.Equal(t, "token1", evt.TokenID)
require.Equal(t, "203.0.113.50", evt.SourceIP)
require.NotNil(t, evt.UserAgent)
require.Equal(t, "AcrobatReader/2024.001", *evt.UserAgent)
require.NotNil(t, evt.Referer)
require.Equal(
t,
"https://victim.example.com/share",
*evt.Referer,
)
},
)
t.Run("source ip precedence", func(t *testing.T) {
cases := []struct {
name string
headers map[string]string
remote string
wantIP string
}{
{
name: "CF wins over XFF and XRI",
headers: map[string]string{
"CF-Connecting-IP": "203.0.113.10",
"X-Forwarded-For": "198.51.100.1, 198.51.100.2",
"X-Real-IP": "192.0.2.99",
},
remote: "127.0.0.1:9999",
wantIP: "203.0.113.10",
},
{
name: "XFF leftmost wins over XRI when no CF",
headers: map[string]string{
"X-Forwarded-For": "198.51.100.1, 198.51.100.7",
"X-Real-IP": "192.0.2.99",
},
remote: "127.0.0.1:9999",
wantIP: "198.51.100.1",
},
{
name: "XFF trailing-comma falls through to last non-empty",
headers: map[string]string{
"X-Forwarded-For": "198.51.100.1, ",
"X-Real-IP": "192.0.2.99",
},
remote: "127.0.0.1:9999",
wantIP: "198.51.100.1",
},
{
name: "XFF entirely empty entries fall through to XRI",
headers: map[string]string{
"X-Forwarded-For": ", ,",
"X-Real-IP": "192.0.2.99",
},
remote: "127.0.0.1:9999",
wantIP: "192.0.2.99",
},
{
name: "XRI when no CF or XFF",
headers: map[string]string{
"X-Real-IP": "192.0.2.99",
},
remote: "127.0.0.1:9999",
wantIP: "192.0.2.99",
},
{
name: "RemoteAddr IPv4 strips port",
headers: nil,
remote: "127.0.0.1:9999",
wantIP: "127.0.0.1",
},
{
name: "RemoteAddr IPv6 strips brackets and port",
headers: nil,
remote: "[2001:db8::1]:54321",
wantIP: "2001:db8::1",
},
{
name: "RemoteAddr loopback IPv6 strips brackets and port",
headers: nil,
remote: "[::1]:9999",
wantIP: "::1",
},
{
name: "RemoteAddr without port falls back to raw value",
headers: nil,
remote: "127.0.0.1",
wantIP: "127.0.0.1",
},
{
name: "XFF mixed IPv4+IPv6 leftmost",
headers: map[string]string{
"X-Forwarded-For": "198.51.100.1, 2001:db8::dead",
},
remote: "127.0.0.1:9999",
wantIP: "198.51.100.1",
},
{
name: "CF value is trimmed of whitespace",
headers: map[string]string{
"CF-Connecting-IP": " 203.0.113.10 ",
},
remote: "127.0.0.1:9999",
wantIP: "203.0.113.10",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/c/token1", nil)
for k, v := range tc.headers {
r.Header.Set(k, v)
}
r.RemoteAddr = tc.remote
evt, _, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
require.NotNil(t, evt)
require.Equal(t, tc.wantIP, evt.SourceIP)
})
}
})
t.Run(
"missing user agent and referer record as nil pointers",
func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/c/token1", nil)
r.Header.Del("User-Agent")
r.Header.Del("Referer")
r.Header.Set("CF-Connecting-IP", "203.0.113.5")
evt, _, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
require.NotNil(t, evt)
require.Nil(
t,
evt.UserAgent,
"absent user agent must map to nil, not empty string",
)
require.Nil(
t,
evt.Referer,
"absent referer must map to nil, not empty string",
)
},
)
}
func TestTrigger_ResponseBodyIsIndependentCopyPerCall(t *testing.T) {
g := pdf.New()
tok := newPDFToken("abc")
r := httptest.NewRequest(http.MethodGet, "/c/abc", nil)
_, resp1, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
_, resp2, err := g.Trigger(context.Background(), tok, r)
require.NoError(t, err)
resp1.Body[0] = 0x00
require.Equal(
t,
byte(0x47),
resp2.Body[0],
"each Trigger call must produce an independent body slice",
)
}
func TestTrigger_TokenNotFound_StillReturnsGIF(t *testing.T) {
g := pdf.New()
r := httptest.NewRequest(http.MethodGet, "/c/does-not-exist", nil)
r.Header.Set("CF-Connecting-IP", "203.0.113.100")
r.Header.Set("User-Agent", "AcrobatReader/2024")
evt, resp, err := g.Trigger(context.Background(), nil, r)
require.NoError(
t,
err,
"nil-token path must not error (spec §8.5 defense-in-depth)",
)
require.NotNil(t, resp, "nil-token path must still return GIF response")
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, pixel.ContentType, resp.ContentType)
require.Equal(t, pixel.Clone(), resp.Body)
require.Nil(
t,
evt,
"nil-token path returns nil event so the handler cannot persist a row with empty TokenID (FK violation)",
)
}

View File

@ -0,0 +1,28 @@
%PDF-1.4
%âãÏÓ
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<<
/Type /Page
/Parent 2 0 R
/MediaBox [0 0 612 792]
/Resources << >>
/AA << /O << /Type /Action /S /URI /URI (HONEY_TRACK_URL_PADDED_TO_FIXED_WIDTH_______________________________________) >> >>
>>
endobj
xref
0 4
0000000000 65535 f
0000000015 00000 n
0000000064 00000 n
0000000121 00000 n
trailer
<< /Size 4 /Root 1 0 R >>
startxref
334
%%EOF

View File

@ -0,0 +1,23 @@
// ©AngelaMos | 2026
// pixel.go
package pixel
import "slices"
var transparentGIF = []byte{
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00,
0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0x21, 0xf9, 0x04, 0x01, 0x00,
0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00,
0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3b,
}
const ContentType = "image/gif"
func Clone() []byte {
return slices.Clone(transparentGIF)
}
func Len() int {
return len(transparentGIF)
}

Some files were not shown because too many files have changed in this diff Show More