diff --git a/PROJECTS/beginner/canary-token-generator/.env.example b/PROJECTS/beginner/canary-token-generator/.env.example new file mode 100644 index 00000000..e7aa0c74 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/.env.example @@ -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 ` 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= diff --git a/PROJECTS/beginner/canary-token-generator/.gitignore b/PROJECTS/beginner/canary-token-generator/.gitignore new file mode 100644 index 00000000..b20c3a8d --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/.gitignore @@ -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/` 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/ diff --git a/PROJECTS/beginner/canary-token-generator/README.md b/PROJECTS/beginner/canary-token-generator/README.md new file mode 100644 index 00000000..86983dbf --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/README.md @@ -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 diff --git a/PROJECTS/beginner/canary-token-generator/backend/.air.toml b/PROJECTS/beginner/canary-token-generator/backend/.air.toml new file mode 100644 index 00000000..14654197 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/.air.toml @@ -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 diff --git a/PROJECTS/beginner/canary-token-generator/backend/.gitignore b/PROJECTS/beginner/canary-token-generator/backend/.gitignore new file mode 100644 index 00000000..ba808b80 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/.gitignore @@ -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* diff --git a/PROJECTS/beginner/canary-token-generator/backend/.golangci.yml b/PROJECTS/beginner/canary-token-generator/backend/.golangci.yml new file mode 100644 index 00000000..b1e1ef48 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/.golangci.yml @@ -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 diff --git a/PROJECTS/beginner/canary-token-generator/backend/cmd/builddocxtemplate/main.go b/PROJECTS/beginner/canary-token-generator/backend/cmd/builddocxtemplate/main.go new file mode 100644 index 00000000..23aa207b --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/cmd/builddocxtemplate/main.go @@ -0,0 +1,136 @@ +// ©AngelaMos | 2026 +// main.go + +package main + +import ( + "archive/zip" + "flag" + "fmt" + "log" + "os" + "path/filepath" +) + +const ( + contentTypesXML = ` + + + + + + +` + + packageRelsXML = ` + + + +` + + documentXML = ` + + + Internal document - confidential. Do not redistribute. + + + + + + + +` + + documentRelsXML = ` + + + +` + + footer2XML = ` + + + + INCLUDEPICTURE "HONEY_TRACK_URL" \d \* MERGEFORMAT + + + +` + + 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 ") + } + + 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 +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/cmd/buildpdftemplate/main.go b/PROJECTS/beginner/canary-token-generator/backend/cmd/buildpdftemplate/main.go new file mode 100644 index 00000000..fdc604bb --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/cmd/buildpdftemplate/main.go @@ -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 ") + } + + 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() +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/cmd/canary/main.go b/PROJECTS/beginner/canary-token-generator/backend/cmd/canary/main.go new file mode 100644 index 00000000..920383a6 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/cmd/canary/main.go @@ -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) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/cmd/healthcheck/main.go b/PROJECTS/beginner/canary-token-generator/backend/cmd/healthcheck/main.go new file mode 100644 index 00000000..7ec82cf2 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/cmd/healthcheck/main.go @@ -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) + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/config.yaml b/PROJECTS/beginner/canary-token-generator/backend/config.yaml new file mode 100644 index 00000000..1b1fd5fe --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/config.yaml @@ -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 diff --git a/PROJECTS/beginner/canary-token-generator/backend/go.mod b/PROJECTS/beginner/canary-token-generator/backend/go.mod new file mode 100644 index 00000000..f15a419e --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/go.mod @@ -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 +) diff --git a/PROJECTS/beginner/canary-token-generator/backend/go.sum b/PROJECTS/beginner/canary-token-generator/backend/go.sum new file mode 100644 index 00000000..4610b1b9 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/go.sum @@ -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= diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/admin/dto.go b/PROJECTS/beginner/canary-token-generator/backend/internal/admin/dto.go new file mode 100644 index 00000000..20f6ffa3 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/admin/dto.go @@ -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"` +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/admin/handler.go b/PROJECTS/beginner/canary-token-generator/backend/internal/admin/handler.go new file mode 100644 index 00000000..4a96574a --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/admin/handler.go @@ -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, + }, + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/admin/handler_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/admin/handler_test.go new file mode 100644 index 00000000..9876efa2 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/admin/handler_test.go @@ -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) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/admin/integration_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/admin/integration_test.go new file mode 100644 index 00000000..331a6f19 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/admin/integration_test.go @@ -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"`) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go b/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go new file mode 100644 index 00000000..a1b9096e --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go @@ -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) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/core/database.go b/PROJECTS/beginner/canary-token-generator/backend/internal/core/database.go new file mode 100644 index 00000000..00d3642b --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/core/database.go @@ -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()) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/core/errors.go b/PROJECTS/beginner/canary-token-generator/backend/internal/core/errors.go new file mode 100644 index 00000000..a95e2c91 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/core/errors.go @@ -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) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/core/migrations.go b/PROJECTS/beginner/canary-token-generator/backend/internal/core/migrations.go new file mode 100644 index 00000000..57384167 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/core/migrations.go @@ -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 +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/core/migrations/0001_create_tokens.sql b/PROJECTS/beginner/canary-token-generator/backend/internal/core/migrations/0001_create_tokens.sql new file mode 100644 index 00000000..854430e0 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/core/migrations/0001_create_tokens.sql @@ -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; diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/core/migrations/0002_create_events.sql b/PROJECTS/beginner/canary-token-generator/backend/internal/core/migrations/0002_create_events.sql new file mode 100644 index 00000000..7a6c395b --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/core/migrations/0002_create_events.sql @@ -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; diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/core/migrations/0003_indexes.sql b/PROJECTS/beginner/canary-token-generator/backend/internal/core/migrations/0003_indexes.sql new file mode 100644 index 00000000..b2d7e8de --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/core/migrations/0003_indexes.sql @@ -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 diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/core/redis.go b/PROJECTS/beginner/canary-token-generator/backend/internal/core/redis.go new file mode 100644 index 00000000..6349fcd5 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/core/redis.go @@ -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() +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/core/response.go b/PROJECTS/beginner/canary-token-generator/backend/internal/core/response.go new file mode 100644 index 00000000..a6e29eb3 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/core/response.go @@ -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, + }) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/core/telemetry.go b/PROJECTS/beginner/canary-token-generator/backend/internal/core/telemetry.go new file mode 100644 index 00000000..8b40e71b --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/core/telemetry.go @@ -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) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/core/validation.go b/PROJECTS/beginner/canary-token-generator/backend/internal/core/validation.go new file mode 100644 index 00000000..72cd326d --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/core/validation.go @@ -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" + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/event/contract.go b/PROJECTS/beginner/canary-token-generator/backend/internal/event/contract.go new file mode 100644 index 00000000..265d55a6 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/event/contract.go @@ -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) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/event/dto.go b/PROJECTS/beginner/canary-token-generator/backend/internal/event/dto.go new file mode 100644 index 00000000..5f716870 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/event/dto.go @@ -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, + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/event/entity.go b/PROJECTS/beginner/canary-token-generator/backend/internal/event/entity.go new file mode 100644 index 00000000..15ea6c58 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/event/entity.go @@ -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 +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/event/entity_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/event/entity_test.go new file mode 100644 index 00000000..a1abbd08 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/event/entity_test.go @@ -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") +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/event/integration_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/event/integration_test.go new file mode 100644 index 00000000..be1187c1 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/event/integration_test.go @@ -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) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/event/repository.go b/PROJECTS/beginner/canary-token-generator/backend/internal/event/repository.go new file mode 100644 index 00000000..b6405346 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/event/repository.go @@ -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 +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/event/repository_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/event/repository_test.go new file mode 100644 index 00000000..3ec40bd5 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/event/repository_test.go @@ -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) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/event/service.go b/PROJECTS/beginner/canary-token-generator/backend/internal/event/service.go new file mode 100644 index 00000000..b07f07b2 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/event/service.go @@ -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) + } + } + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/event/service_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/event/service_test.go new file mode 100644 index 00000000..6109e5c2 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/event/service_test.go @@ -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)") +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/geoip/service.go b/PROJECTS/beginner/canary-token-generator/backend/internal/geoip/service.go new file mode 100644 index 00000000..37e43ca0 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/geoip/service.go @@ -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 "" +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/geoip/service_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/geoip/service_test.go new file mode 100644 index 00000000..e1d4f112 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/geoip/service_test.go @@ -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)) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/health/handler.go b/PROJECTS/beginner/canary-token-generator/backend/internal/health/handler.go new file mode 100644 index 00000000..c95c2b8f --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/health/handler.go @@ -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"` +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/fingerprint.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/fingerprint.go new file mode 100644 index 00000000..da7a77dd --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/fingerprint.go @@ -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) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/fingerprint_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/fingerprint_test.go new file mode 100644 index 00000000..47e5d725 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/fingerprint_test.go @@ -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) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/headers.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/headers.go new file mode 100644 index 00000000..18014237 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/headers.go @@ -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) + }) + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/logging.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/logging.go new file mode 100644 index 00000000..13935694 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/logging.go @@ -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 +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/operator_bearer.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/operator_bearer.go new file mode 100644 index 00000000..458fd07e --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/operator_bearer.go @@ -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) + }) + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/operator_bearer_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/operator_bearer_test.go new file mode 100644 index 00000000..e201df23 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/operator_bearer_test.go @@ -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", + ) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/ratelimit.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/ratelimit.go new file mode 100644 index 00000000..ec02d560 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/ratelimit.go @@ -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, + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/realip.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/realip.go new file mode 100644 index 00000000..f0db7e53 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/realip.go @@ -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 "" +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/recovery.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/recovery.go new file mode 100644 index 00000000..b900c3b5 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/recovery.go @@ -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) + }) + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/recovery_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/recovery_test.go new file mode 100644 index 00000000..c280e4cf --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/recovery_test.go @@ -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") +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/request_id.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/request_id.go new file mode 100644 index 00000000..5c645fea --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/request_id.go @@ -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 "" +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/turnstile.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/turnstile.go new file mode 100644 index 00000000..1af60d7c --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/turnstile.go @@ -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 "" +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/turnstile_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/turnstile_test.go new file mode 100644 index 00000000..2540f091 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/turnstile_test.go @@ -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()) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/notify/service.go b/PROJECTS/beginner/canary-token-generator/backend/internal/notify/service.go new file mode 100644 index 00000000..3dc887f2 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/notify/service.go @@ -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, + ) + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/notify/service_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/notify/service_test.go new file mode 100644 index 00000000..8c8b601a --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/notify/service_test.go @@ -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) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/notify/telegram/sender.go b/PROJECTS/beginner/canary-token-generator/backend/internal/notify/telegram/sender.go new file mode 100644 index 00000000..49de6952 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/notify/telegram/sender.go @@ -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() +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/notify/telegram/sender_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/notify/telegram/sender_test.go new file mode 100644 index 00000000..02411320 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/notify/telegram/sender_test.go @@ -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>bb= 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)) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/notify/webhook/sender_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/notify/webhook/sender_test.go new file mode 100644 index 00000000..f2d53e7c --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/notify/webhook/sender_test.go @@ -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, + ) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/server/e2e_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/server/e2e_test.go new file mode 100644 index 00000000..f652139d --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/server/e2e_test.go @@ -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=%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/ must not be '_' — otherwise "+ + "Acrobat fetches /c/____ 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() +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/server/server.go b/PROJECTS/beginner/canary-token-generator/backend/internal/server/server.go new file mode 100644 index 00000000..860e760d --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/server/server.go @@ -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 +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/testutil/postgres.go b/PROJECTS/beginner/canary-token-generator/backend/internal/testutil/postgres.go new file mode 100644 index 00000000..52fc1af6 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/testutil/postgres.go @@ -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 +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/testutil/ptr.go b/PROJECTS/beginner/canary-token-generator/backend/internal/testutil/ptr.go new file mode 100644 index 00000000..e7830ca0 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/testutil/ptr.go @@ -0,0 +1,6 @@ +// ©AngelaMos | 2026 +// ptr.go + +package testutil + +func Ptr[T any](v T) *T { return &v } diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/contract.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/contract.go new file mode 100644 index 00000000..2683d586 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/contract.go @@ -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 +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/dto.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/dto.go new file mode 100644 index 00000000..9165c3d2 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/dto.go @@ -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, + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/entity.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/entity.go new file mode 100644 index 00000000..c85e20b6 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/entity.go @@ -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"` +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/docx/generator.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/docx/generator.go new file mode 100644 index 00000000..3b8ed5b4 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/docx/generator.go @@ -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 +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/docx/generator_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/docx/generator_test.go new file mode 100644 index 00000000..6c24bd73 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/docx/generator_test.go @@ -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)", + ) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/docx/template/template.docx b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/docx/template/template.docx new file mode 100644 index 00000000..be7405d1 Binary files /dev/null and b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/docx/template/template.docx differ diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/generator.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/generator.go new file mode 100644 index 00000000..c3d0255a --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/generator.go @@ -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 +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/generator_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/generator_test.go new file mode 100644 index 00000000..43ae75aa --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/generator_test.go @@ -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) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/aws.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/aws.go new file mode 100644 index 00000000..c48b467b --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/aws.go @@ -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, + }, + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/aws_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/aws_test.go new file mode 100644 index 00000000..7e4a076c --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/aws_test.go @@ -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", + ) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/db.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/db.go new file mode 100644 index 00000000..81ff86f5 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/db.go @@ -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}, + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/db_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/db_test.go new file mode 100644 index 00000000..2bbdbb7d --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/db_test.go @@ -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) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/github.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/github.go new file mode 100644 index 00000000..2bcf2574 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/github.go @@ -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, + }, + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/github_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/github_test.go new file mode 100644 index 00000000..7199a857 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/github_test.go @@ -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) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/recipes.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/recipes.go new file mode 100644 index 00000000..a37cb0d5 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/recipes.go @@ -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) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/recipes_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/recipes_test.go new file mode 100644 index 00000000..7542b978 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/recipes_test.go @@ -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", + ) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/stripe.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/stripe.go new file mode 100644 index 00000000..3255101b --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/stripe.go @@ -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), + }, + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/stripe_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/stripe_test.go new file mode 100644 index 00000000..feb939c6 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/recipes/stripe_test.go @@ -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", + ) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/generator.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/generator.go new file mode 100644 index 00000000..604e436d --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/generator.go @@ -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 diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/generator.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/generator.go new file mode 100644 index 00000000..fc63f5bc --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/generator.go @@ -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 +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/generator_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/generator_test.go new file mode 100644 index 00000000..a8db3b46 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/generator_test.go @@ -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)", + ) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/handler.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/handler.go new file mode 100644 index 00000000..0d974032 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/handler.go @@ -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 + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/handler_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/handler_test.go new file mode 100644 index 00000000..91f957c4 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/handler_test.go @@ -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", + ) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/template.yaml.tmpl b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/template.yaml.tmpl new file mode 100644 index 00000000..a9f27864 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/template.yaml.tmpl @@ -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}} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/generator.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/generator.go new file mode 100644 index 00000000..5f9fa9a4 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/generator.go @@ -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 +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/generator_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/generator_test.go new file mode 100644 index 00000000..c0cc926c --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/generator_test.go @@ -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) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/handler.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/handler.go new file mode 100644 index 00000000..9a89e940 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/handler.go @@ -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 +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/handler_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/handler_test.go new file mode 100644 index 00000000..52d128c7 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/handler_test.go @@ -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()) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/protocol.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/protocol.go new file mode 100644 index 00000000..35415b6b --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/protocol.go @@ -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 +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/protocol_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/protocol_test.go new file mode 100644 index 00000000..7a0b8f2f --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/protocol_test.go @@ -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) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/server.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/server.go new file mode 100644 index 00000000..869711dd --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/server.go @@ -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) + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/server_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/server_test.go new file mode 100644 index 00000000..a60dbbd9 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/server_test.go @@ -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) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pdf/generator.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pdf/generator.go new file mode 100644 index 00000000..11a98f44 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pdf/generator.go @@ -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) + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pdf/generator_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pdf/generator_test.go new file mode 100644 index 00000000..dc2e965d --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pdf/generator_test.go @@ -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)", + ) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pdf/template/template.pdf b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pdf/template/template.pdf new file mode 100644 index 00000000..71ff8c5e --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pdf/template/template.pdf @@ -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 diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pixel/pixel.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pixel/pixel.go new file mode 100644 index 00000000..dec64e3e --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pixel/pixel.go @@ -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) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pixel/pixel_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pixel/pixel_test.go new file mode 100644 index 00000000..7edc0839 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pixel/pixel_test.go @@ -0,0 +1,83 @@ +// ©AngelaMos | 2026 +// pixel_test.go + +package pixel_test + +import ( + "bytes" + "image/gif" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/pixel" +) + +const ( + expectedLength = 43 + expectedWidth = 1 + expectedHeight = 1 +) + +var ( + gif89aMagic = []byte{0x47, 0x49, 0x46, 0x38, 0x39, 0x61} + gifTrailer = byte(0x3b) +) + +func TestLen_Is43(t *testing.T) { + require.Equal(t, expectedLength, pixel.Len()) +} + +func TestClone_Length(t *testing.T) { + require.Len(t, pixel.Clone(), expectedLength) +} + +func TestClone_MagicBytes(t *testing.T) { + g := pixel.Clone() + require.True( + t, + bytes.HasPrefix(g, gif89aMagic), + "expected GIF89a magic prefix, got % x", + g[:len(gif89aMagic)], + ) + require.Equal( + t, + gifTrailer, + g[len(g)-1], + "expected trailing GIF terminator 0x3B", + ) +} + +func TestClone_DecodesAsImageGIF(t *testing.T) { + img, err := gif.Decode(bytes.NewReader(pixel.Clone())) + require.NoError(t, err) + require.NotNil(t, img) + + bounds := img.Bounds() + require.Equal(t, expectedWidth, bounds.Dx()) + require.Equal(t, expectedHeight, bounds.Dy()) +} + +func TestClone_ReturnsIndependentCopy(t *testing.T) { + a := pixel.Clone() + b := pixel.Clone() + require.Equal(t, a, b, "two clones must be byte-equal") + + a[0] = 0x00 + require.Equal( + t, + byte(0x47), + b[0], + "mutating one clone must not affect another", + ) + require.Equal( + t, + byte(0x47), + pixel.Clone()[0], + "mutating one clone must not affect the package-internal source", + ) +} + +func TestContentType_IsImageGIF(t *testing.T) { + require.Equal(t, "image/gif", pixel.ContentType) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/registry/registry.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/registry/registry.go new file mode 100644 index 00000000..e4801823 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/registry/registry.go @@ -0,0 +1,44 @@ +// ©AngelaMos | 2026 +// registry.go + +package registry + +import ( + "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/envfile" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/kubeconfig" + "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/pdf" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/slowredirect" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/webbug" +) + +type Config struct { + BaseURL string + MySQLPublicHost string + MySQLPublicPort int +} + +type Registry map[token.Type]generators.Generator + +func Build(cfg Config) Registry { + host := cfg.MySQLPublicHost + port := cfg.MySQLPublicPort + var mysqlGen *mysql.Generator + if host == "" || port == 0 { + mysqlGen = mysql.New() + } else { + mysqlGen = mysql.NewWithAddress(host, port) + } + return Registry{ + token.TypeWebbug: webbug.New(), + token.TypeSlowRedirect: slowredirect.New(), + token.TypeDocx: docx.New(), + token.TypePDF: pdf.New(), + token.TypeKubeconfig: kubeconfig.New(), + token.TypeEnvfile: envfile.New(), + token.TypeMySQL: mysqlGen, + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/registry/registry_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/registry/registry_test.go new file mode 100644 index 00000000..84dd1a53 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/registry/registry_test.go @@ -0,0 +1,122 @@ +// ©AngelaMos | 2026 +// registry_test.go + +package registry_test + +import ( + "context" + "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/registry" +) + +const testBaseURL = "https://canary.example.com" + +func TestBuild_RegistersWebbug(t *testing.T) { + reg := registry.Build(registry.Config{BaseURL: testBaseURL}) + g, ok := reg[token.TypeWebbug] + require.True(t, ok, "expected webbug generator registered") + require.NotNil(t, g) + require.Equal(t, token.TypeWebbug, g.Type()) +} + +func TestBuild_RegistersSlowRedirect(t *testing.T) { + reg := registry.Build(registry.Config{BaseURL: testBaseURL}) + g, ok := reg[token.TypeSlowRedirect] + require.True(t, ok, "expected slowredirect generator registered") + require.NotNil(t, g) + require.Equal(t, token.TypeSlowRedirect, g.Type()) +} + +func TestBuild_RegistersDocx(t *testing.T) { + reg := registry.Build(registry.Config{BaseURL: testBaseURL}) + g, ok := reg[token.TypeDocx] + require.True(t, ok, "expected docx generator registered") + require.NotNil(t, g) + require.Equal(t, token.TypeDocx, g.Type()) +} + +func TestBuild_RegistersPDF(t *testing.T) { + reg := registry.Build(registry.Config{BaseURL: testBaseURL}) + g, ok := reg[token.TypePDF] + require.True(t, ok, "expected pdf generator registered") + require.NotNil(t, g) + require.Equal(t, token.TypePDF, g.Type()) +} + +func TestBuild_RegistersKubeconfig(t *testing.T) { + reg := registry.Build(registry.Config{BaseURL: testBaseURL}) + g, ok := reg[token.TypeKubeconfig] + require.True(t, ok, "expected kubeconfig generator registered") + require.NotNil(t, g) + require.Equal(t, token.TypeKubeconfig, g.Type()) +} + +func TestBuild_RegistersEnvfile(t *testing.T) { + reg := registry.Build(registry.Config{BaseURL: testBaseURL}) + g, ok := reg[token.TypeEnvfile] + require.True(t, ok, "expected envfile generator registered") + require.NotNil(t, g) + require.Equal(t, token.TypeEnvfile, g.Type()) +} + +func TestBuild_RegistersMySQL(t *testing.T) { + reg := registry.Build(registry.Config{BaseURL: testBaseURL}) + g, ok := reg[token.TypeMySQL] + require.True(t, ok, "expected mysql generator registered") + require.NotNil(t, g) + require.Equal(t, token.TypeMySQL, g.Type()) +} + +func TestBuild_UnknownTypeReturnsZeroValue(t *testing.T) { + reg := registry.Build(registry.Config{BaseURL: testBaseURL}) + g, ok := reg["nonexistent-type"] + require.False(t, ok, "unknown type must not be present") + require.Nil(t, g, "map zero value for missing key must be nil interface") +} + +func TestBuild_MySQLUsesConfiguredPublicAddress(t *testing.T) { + reg := registry.Build(registry.Config{ + BaseURL: testBaseURL, + MySQLPublicHost: "canary.example.com", + MySQLPublicPort: 13306, + }) + g, ok := reg[token.TypeMySQL] + require.True(t, ok) + + tok := &token.Token{ID: "abc", Type: token.TypeMySQL} + art, err := g.Generate(context.Background(), tok, testBaseURL) + require.NoError(t, err) + require.Equal( + t, + "mysql://canary_abc@canary.example.com:13306/internal_db", + art.ConnectionString, + "mysql connection string must reflect configured public host:port", + ) +} + +func TestBuild_AllSevenGeneratorsRegistered(t *testing.T) { + reg := registry.Build(registry.Config{BaseURL: testBaseURL}) + require.Len( + t, + reg, + 7, + "Phase 8 closes the generator set with all 7 types registered (webbug, slowredirect, docx, pdf, kubeconfig, envfile, mysql)", + ) + + for _, ty := range []token.Type{ + token.TypeWebbug, + token.TypeSlowRedirect, + token.TypeDocx, + token.TypePDF, + token.TypeKubeconfig, + token.TypeEnvfile, + token.TypeMySQL, + } { + _, ok := reg[ty] + require.True(t, ok, "type %q must be registered", ty) + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/fingerprint_handler.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/fingerprint_handler.go new file mode 100644 index 00000000..4ec322b4 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/fingerprint_handler.go @@ -0,0 +1,87 @@ +// ©AngelaMos | 2026 +// fingerprint_handler.go + +package slowredirect + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware" +) + +const ( + fingerprintWindow = 30 * time.Second + fingerprintMaxBytes = 64 * 1024 + urlParamTokenID = "id" + headerContentType = "Content-Type" + contentTypeJSONPrefix = "application/json" +) + +type FingerprintAttacher interface { + AttachFingerprint( + ctx context.Context, + tokenID, sourceIP string, + fingerprint json.RawMessage, + window time.Duration, + ) error +} + +type FingerprintHandler struct { + attacher FingerprintAttacher + window time.Duration +} + +func NewFingerprintHandler(a FingerprintAttacher) *FingerprintHandler { + return &FingerprintHandler{ + attacher: a, + window: fingerprintWindow, + } +} + +func (h *FingerprintHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + tokenID := chi.URLParam(r, urlParamTokenID) + if tokenID == "" { + w.WriteHeader(http.StatusNoContent) + return + } + + if !strings.HasPrefix( + strings.ToLower(r.Header.Get(headerContentType)), + contentTypeJSONPrefix, + ) { + w.WriteHeader(http.StatusNoContent) + return + } + + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, fingerprintMaxBytes)) + if err != nil || len(body) == 0 || !json.Valid(body) { + w.WriteHeader(http.StatusNoContent) + return + } + + if err := h.attacher.AttachFingerprint( + ctx, + tokenID, + middleware.RealIP(r), + json.RawMessage(body), + h.window, + ); err != nil && !errors.Is(err, event.ErrNotFound) { + slog.WarnContext(ctx, "attach fingerprint failed", + "token_id", tokenID, + "error", err, + ) + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/fingerprint_handler_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/fingerprint_handler_test.go new file mode 100644 index 00000000..95e154ef --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/fingerprint_handler_test.go @@ -0,0 +1,293 @@ +// ©AngelaMos | 2026 +// fingerprint_handler_test.go + +//go:build integration + +package slowredirect_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "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/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" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/slowredirect" +) + +const ( + fingerprintRoute = "/c/{id}/fingerprint" + clientIP = "203.0.113.45" + jsonContentType = "application/json" +) + +func newSlowredirectRepos( + t *testing.T, +) (*token.Repository, *event.Repository) { + t.Helper() + db := sqlx.NewDb(testutil.NewTestDB(t), "pgx") + return token.NewRepository(db), event.NewRepository(db) +} + +func seedSlowredirectToken( + t *testing.T, + repo *token.Repository, + id, destination string, +) *token.Token { + t.Helper() + metaJSON, err := json.Marshal(map[string]string{ + "destination_url": destination, + }) + require.NoError(t, err) + tok := &token.Token{ + ID: id, + ManageID: uuid.New().String(), + Type: token.TypeSlowRedirect, + Memo: "integration-fp", + AlertChannel: token.ChannelWebhook, + WebhookURL: testutil.Ptr("https://example.com/hook"), + CreatedIP: clientIP, + CreatedFP: "abcdef0123456789", + Metadata: json.RawMessage(metaJSON), + Enabled: true, + } + require.NoError(t, repo.Insert(context.Background(), tok)) + return tok +} + +func seedSlowredirectEvent( + t *testing.T, + evtRepo *event.Repository, + tokenID string, +) *event.Event { + t.Helper() + e := &event.Event{ + TokenID: tokenID, + SourceIP: clientIP, + Extra: json.RawMessage(`{"initial":"value"}`), + } + require.NoError(t, evtRepo.Insert(context.Background(), e)) + return e +} + +func mountFingerprintRouter(h http.Handler) http.Handler { + r := chi.NewRouter() + r.Post(fingerprintRoute, h.ServeHTTP) + return r +} + +func postFingerprint( + router http.Handler, + tokenID, contentType string, + body []byte, +) *httptest.ResponseRecorder { + req := httptest.NewRequest( + http.MethodPost, + "/c/"+tokenID+"/fingerprint", + bytes.NewReader(body), + ) + req.Header.Set("CF-Connecting-IP", clientIP) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + return rr +} + +func TestFingerprintHandler_AttachesToRecentEvent(t *testing.T) { + t.Parallel() + tokRepo, evtRepo := newSlowredirectRepos(t) + ctx := context.Background() + + tok := seedSlowredirectToken(t, tokRepo, "fpattach0001", + "https://news.example.com") + evt := seedSlowredirectEvent(t, evtRepo, tok.ID) + + router := mountFingerprintRouter(slowredirect.NewFingerprintHandler(evtRepo)) + + fpBody := []byte( + `{"screen":{"w":1920,"h":1080},"timezone":"America/Los_Angeles"}`, + ) + rr := postFingerprint(router, tok.ID, jsonContentType, fpBody) + require.Equal(t, http.StatusNoContent, rr.Code) + + got, err := evtRepo.GetByID(ctx, evt.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, "America/Los_Angeles", merged["timezone"]) + screen, ok := merged["screen"].(map[string]any) + require.True(t, ok, "screen sub-object must round-trip as nested map") + require.EqualValues(t, 1920, screen["w"]) + require.EqualValues(t, 1080, screen["h"]) +} + +func TestFingerprintHandler_NoMatchingEvent_Returns204(t *testing.T) { + t.Parallel() + tokRepo, evtRepo := newSlowredirectRepos(t) + + tok := seedSlowredirectToken(t, tokRepo, "fpnomatch001", + "https://news.example.com") + + router := mountFingerprintRouter(slowredirect.NewFingerprintHandler(evtRepo)) + + rr := postFingerprint( + router, tok.ID, jsonContentType, + []byte(`{"screen":{"w":800,"h":600}}`), + ) + require.Equal( + t, + http.StatusNoContent, + rr.Code, + "no matching event must still return 204 — fingerprint is enrichment, not the trigger", + ) +} + +func TestFingerprintHandler_InvalidJSON_Returns204AndDoesNotTouchEvent( + t *testing.T, +) { + t.Parallel() + tokRepo, evtRepo := newSlowredirectRepos(t) + ctx := context.Background() + + tok := seedSlowredirectToken(t, tokRepo, "fpinvjson001", + "https://news.example.com") + evt := seedSlowredirectEvent(t, evtRepo, tok.ID) + + router := mountFingerprintRouter(slowredirect.NewFingerprintHandler(evtRepo)) + + rr := postFingerprint( + router, tok.ID, jsonContentType, + []byte("not-json-at-all"), + ) + require.Equal(t, http.StatusNoContent, rr.Code) + + got, err := evtRepo.GetByID(ctx, evt.ID) + require.NoError(t, err) + + var stored map[string]any + require.NoError(t, json.Unmarshal(got.Extra, &stored)) + require.Equal( + t, + "value", + stored["initial"], + "invalid JSON must not corrupt the stored Extra JSONB", + ) +} + +func TestFingerprintHandler_WrongContentType_Returns204AndDoesNotTouchEvent( + t *testing.T, +) { + t.Parallel() + tokRepo, evtRepo := newSlowredirectRepos(t) + ctx := context.Background() + + tok := seedSlowredirectToken(t, tokRepo, "fpctype00001", + "https://news.example.com") + evt := seedSlowredirectEvent(t, evtRepo, tok.ID) + + router := mountFingerprintRouter(slowredirect.NewFingerprintHandler(evtRepo)) + + rr := postFingerprint( + router, tok.ID, + "text/plain", + []byte(`{"screen":{"w":1024,"h":768}}`), + ) + require.Equal( + t, + http.StatusNoContent, + rr.Code, + "non-JSON content-type must be ignored — handler only processes the embedded template's POST", + ) + + got, err := evtRepo.GetByID(ctx, evt.ID) + require.NoError(t, err) + + var stored map[string]any + require.NoError(t, json.Unmarshal(got.Extra, &stored)) + require.Equal(t, "value", stored["initial"]) + _, mergedAnyway := stored["screen"] + require.False( + t, + mergedAnyway, + "event Extra must not absorb a payload whose content-type was rejected", + ) +} + +func TestFingerprintHandler_OversizeBody_Returns204AndDoesNotTouchEvent( + t *testing.T, +) { + t.Parallel() + tokRepo, evtRepo := newSlowredirectRepos(t) + ctx := context.Background() + + tok := seedSlowredirectToken(t, tokRepo, "fpoversz0001", + "https://news.example.com") + evt := seedSlowredirectEvent(t, evtRepo, tok.ID) + + router := mountFingerprintRouter(slowredirect.NewFingerprintHandler(evtRepo)) + + const oversizeBytes = 128 * 1024 + junk := strings.Repeat("A", oversizeBytes) + body := []byte(`{"junk":"` + junk + `"}`) + require.Greater(t, len(body), 64*1024) + + rr := postFingerprint(router, tok.ID, jsonContentType, body) + require.Equal( + t, + http.StatusNoContent, + rr.Code, + "body over the 64 KiB cap must still return 204 — no error surfaces to the client", + ) + + got, err := evtRepo.GetByID(ctx, evt.ID) + require.NoError(t, err) + + var stored map[string]any + require.NoError(t, json.Unmarshal(got.Extra, &stored)) + require.Equal(t, "value", stored["initial"]) + _, mergedAnyway := stored["junk"] + require.False( + t, + mergedAnyway, + "oversize body must not be merged into Extra — MaxBytesReader cuts before AttachFingerprint", + ) +} + +func TestFingerprintHandler_EmptyTokenID_Returns204(t *testing.T) { + t.Parallel() + _, evtRepo := newSlowredirectRepos(t) + + handler := slowredirect.NewFingerprintHandler(evtRepo) + r := chi.NewRouter() + r.Post("/c/{id}/fingerprint", handler.ServeHTTP) + + req := httptest.NewRequest( + http.MethodPost, + "/c//fingerprint", + bytes.NewReader([]byte(`{}`)), + ) + req.Header.Set("Content-Type", jsonContentType) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + require.Contains( + t, + []int{http.StatusNoContent, http.StatusNotFound, http.StatusMovedPermanently}, + rr.Code, + "empty token id must not produce a 5xx — handler's guard returns 204, chi may also redirect or 404 at the routing layer", + ) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/generator.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/generator.go new file mode 100644 index 00000000..b25c9645 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/generator.go @@ -0,0 +1,181 @@ +// ©AngelaMos | 2026 +// generator.go + +package slowredirect + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "html/template" + "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" +) + +const ( + headerReferer = "Referer" + headerCSP = "Content-Security-Policy" + headerCacheControl = "Cache-Control" + headerPragma = "Pragma" + + cspOverride = "default-src 'none'; script-src 'unsafe-inline'; connect-src 'self'" + cacheControlNoStore = "no-store, no-cache, must-revalidate, max-age=0" + pragmaNoCache = "no-cache" + contentTypeHTML = "text/html; charset=utf-8" + + triggerPathPrefix = "/c/" + fingerprintPathSuffix = "/fingerprint" + metadataDestKey = "destination_url" + + schemeHTTP = "http://" + schemeHTTPS = "https://" + + enumerationDecoyDestination = "/" +) + +var ( + ErrMissingDestination = errors.New( + "slowredirect: destination_url missing from token metadata", + ) + ErrInvalidDestinationScheme = errors.New( + "slowredirect: destination_url must use http:// or https:// scheme", + ) +) + +//go:embed template.html +var templateHTML string + +var pageTemplate = template.Must( + template.New("slowredirect").Parse(templateHTML), +) + +type pageData struct { + Destination string + FingerprintURL string +} + +type Generator struct{} + +func New() *Generator { return &Generator{} } + +func (g *Generator) Type() token.Type { return token.TypeSlowRedirect } + +func (g *Generator) Generate( + _ context.Context, + t *token.Token, + baseURL string, +) (generators.Artifact, error) { + dest, err := extractDestination(t.Metadata) + if err != nil { + return generators.Artifact{}, err + } + url := strings.TrimRight(baseURL, "/") + triggerPathPrefix + t.ID + return generators.Artifact{ + Kind: generators.KindURL, + URL: url, + DestinationURL: dest, + }, nil +} + +func (g *Generator) Trigger( + _ context.Context, + t *token.Token, + r *http.Request, +) (*event.Event, *generators.TriggerResponse, error) { + if t == nil { + resp, err := renderDecoyResponse() + if err != nil { + return nil, nil, err + } + return nil, resp, nil + } + + dest, err := extractDestination(t.Metadata) + if err != nil { + return nil, nil, err + } + + resp, err := renderResponse(dest, t.ID) + if err != nil { + return nil, nil, err + } + + 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 renderResponse( + destination, tokenID string, +) (*generators.TriggerResponse, error) { + return renderWith(pageData{ + Destination: destination, + FingerprintURL: triggerPathPrefix + tokenID + fingerprintPathSuffix, + }) +} + +func renderDecoyResponse() (*generators.TriggerResponse, error) { + return renderWith(pageData{ + Destination: enumerationDecoyDestination, + FingerprintURL: enumerationDecoyDestination, + }) +} + +func renderWith( + data pageData, +) (*generators.TriggerResponse, error) { + var body bytes.Buffer + if err := pageTemplate.Execute(&body, data); err != nil { + return nil, fmt.Errorf("render slowredirect template: %w", err) + } + return &generators.TriggerResponse{ + StatusCode: http.StatusOK, + ContentType: contentTypeHTML, + Body: body.Bytes(), + ExtraHeaders: map[string]string{ + headerCSP: cspOverride, + headerCacheControl: cacheControlNoStore, + headerPragma: pragmaNoCache, + }, + }, nil +} + +func extractDestination(metadata json.RawMessage) (string, error) { + if len(metadata) == 0 { + return "", ErrMissingDestination + } + var m map[string]json.RawMessage + if err := json.Unmarshal(metadata, &m); err != nil { + return "", fmt.Errorf("parse token metadata: %w", err) + } + raw, ok := m[metadataDestKey] + if !ok { + return "", ErrMissingDestination + } + var dest string + if err := json.Unmarshal(raw, &dest); err != nil { + return "", fmt.Errorf("parse destination_url: %w", err) + } + dest = strings.TrimSpace(dest) + if dest == "" { + return "", ErrMissingDestination + } + lower := strings.ToLower(dest) + if !strings.HasPrefix(lower, schemeHTTP) && + !strings.HasPrefix(lower, schemeHTTPS) { + return "", ErrInvalidDestinationScheme + } + return dest, nil +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/generator_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/generator_test.go new file mode 100644 index 00000000..0dfcad35 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/slowredirect/generator_test.go @@ -0,0 +1,477 @@ +// ©AngelaMos | 2026 +// generator_test.go + +package slowredirect_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/slowredirect" +) + +const ( + cspOverride = "default-src 'none'; script-src 'unsafe-inline'; connect-src 'self'" + cacheControlNoStore = "no-store, no-cache, must-revalidate, max-age=0" + pragmaNoCache = "no-cache" + contentTypeHTML = "text/html; charset=utf-8" +) + +func newSlowRedirectToken(t testing.TB, id, destination string) *token.Token { + t.Helper() + metadata := json.RawMessage(`{}`) + if destination != "" { + raw, err := json.Marshal(map[string]string{ + "destination_url": destination, + }) + require.NoError(t, err) + metadata = raw + } + return &token.Token{ + ID: id, + ManageID: "manage-" + id, + Type: token.TypeSlowRedirect, + Memo: "unit test slowredirect", + AlertChannel: token.ChannelWebhook, + Enabled: true, + Metadata: metadata, + } +} + +func TestGenerator_TypeIsSlowRedirect(t *testing.T) { + g := slowredirect.New() + require.Equal(t, token.TypeSlowRedirect, g.Type()) +} + +func TestGenerate_PersistsDestinationOnToken(t *testing.T) { + cases := []struct { + name string + baseURL string + id string + destination string + wantURL string + }{ + { + name: "plain destination", + baseURL: "https://canary.example.com", + id: "abc123", + destination: "https://news.example.com/article/42", + wantURL: "https://canary.example.com/c/abc123", + }, + { + name: "trailing slash trimmed on base URL", + baseURL: "https://canary.example.com/", + id: "xyz999", + destination: "https://news.example.com", + wantURL: "https://canary.example.com/c/xyz999", + }, + { + name: "subpath base URL preserved", + baseURL: "https://example.com/canary", + id: "p7", + destination: "https://other.example.com", + wantURL: "https://example.com/canary/c/p7", + }, + } + + g := slowredirect.New() + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + tok := newSlowRedirectToken(t, tc.id, tc.destination) + art, err := g.Generate( + context.Background(), + tok, + tc.baseURL, + ) + require.NoError(t, err) + require.Equal(t, generators.KindURL, art.Kind) + require.Equal(t, tc.wantURL, art.URL) + require.Equal( + t, + tc.destination, + art.DestinationURL, + "artifact must surface destination_url that was persisted on the token metadata", + ) + }) + } +} + +func TestGenerate_RejectsMissingDestination(t *testing.T) { + cases := []struct { + name string + metadata json.RawMessage + wantErr error + }{ + { + name: "empty metadata", + metadata: json.RawMessage(``), + wantErr: slowredirect.ErrMissingDestination, + }, + { + name: "empty object", + metadata: json.RawMessage(`{}`), + wantErr: slowredirect.ErrMissingDestination, + }, + { + name: "empty destination string", + metadata: json.RawMessage(`{"destination_url":""}`), + wantErr: slowredirect.ErrMissingDestination, + }, + { + name: "whitespace destination", + metadata: json.RawMessage(`{"destination_url":" "}`), + wantErr: slowredirect.ErrMissingDestination, + }, + { + name: "wrong key", + metadata: json.RawMessage(`{"redirect":"https://example.com"}`), + wantErr: slowredirect.ErrMissingDestination, + }, + } + + g := slowredirect.New() + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + tok := &token.Token{ + ID: "missingdest", + Type: token.TypeSlowRedirect, + Metadata: tc.metadata, + } + _, err := g.Generate( + context.Background(), + tok, + "https://canary.example.com", + ) + require.ErrorIs(t, err, tc.wantErr) + }) + } +} + +func TestGenerate_RejectsDangerousDestinationSchemes(t *testing.T) { + cases := []struct { + name string + destination string + }{ + {name: "javascript: scheme", destination: "javascript:alert(1)"}, + { + name: "javascript: scheme with mixed case", + destination: "JavaScript:alert(1)", + }, + { + name: "data: URI", + destination: "data:text/html;base64,PHNjcmlwdD4=", + }, + {name: "file: scheme", destination: "file:///etc/passwd"}, + {name: "vbscript: scheme", destination: "vbscript:msgbox(1)"}, + {name: "no scheme", destination: "example.com/path"}, + {name: "relative path", destination: "/path"}, + {name: "protocol-relative", destination: "//example.com"}, + } + + g := slowredirect.New() + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + tok := newSlowRedirectToken(t, "schemecheck1", tc.destination) + _, err := g.Generate( + context.Background(), + tok, + "https://canary.example.com", + ) + require.ErrorIs( + t, + err, + slowredirect.ErrInvalidDestinationScheme, + "destination scheme %q must be rejected to keep the noscript meta-refresh from following dangerous URIs when JS is disabled", + tc.destination, + ) + }) + } +} + +func TestGenerate_AcceptsHTTPAndHTTPSSchemes(t *testing.T) { + g := slowredirect.New() + cases := []string{ + "http://news.example.com", + "https://news.example.com", + "HTTP://news.example.com", + "HtTpS://news.example.com", + } + for _, dest := range cases { + dest := dest + t.Run(dest, func(t *testing.T) { + tok := newSlowRedirectToken(t, "schemeok0001", dest) + art, err := g.Generate( + context.Background(), + tok, + "https://canary.example.com", + ) + require.NoError(t, err) + require.Equal(t, dest, art.DestinationURL) + }) + } +} + +func TestTrigger_RendersHTMLWithEscapedDestination(t *testing.T) { + g := slowredirect.New() + + t.Run("plain destination renders into JS and noscript", func(t *testing.T) { + dest := "https://news.example.com/article" + tok := newSlowRedirectToken(t, "plainok01", dest) + r := httptest.NewRequest(http.MethodGet, "/c/plainok01", nil) + r.Header.Set("CF-Connecting-IP", "203.0.113.7") + + evt, resp, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + require.NotNil(t, evt) + require.NotNil(t, resp) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, contentTypeHTML, resp.ContentType) + + body := string(resp.Body) + require.Contains( + t, + body, + `url=https://news.example.com/article`, + "noscript meta refresh must point at the destination", + ) + require.Contains( + t, + body, + "https://news.example.com/article", + "JS body must include the destination for window.location.replace", + ) + require.Contains( + t, + body, + "/c/plainok01/fingerprint", + "JS body must reference the per-token fingerprint endpoint", + ) + }) + + t.Run("XSS attempt in destination is neutralized", func(t *testing.T) { + hostile := `https://x.example.com/" />` + tok := newSlowRedirectToken(t, "xssa001", hostile) + r := httptest.NewRequest(http.MethodGet, "/c/xssa001", nil) + + _, resp, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + body := string(resp.Body) + + require.NotContains( + t, + body, + "", + "raw script injection must not appear unescaped in any context", + ) + require.NotContains( + t, + body, + `/>"), + "only the trusted inline script closing tag may appear", + ) + }) + + t.Run("script-closing injection is neutralized", func(t *testing.T) { + hostile := `https://x.example.com/` + tok := newSlowRedirectToken(t, "xssb001", hostile) + r := httptest.NewRequest(http.MethodGet, "/c/xssb001", nil) + + _, resp, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + body := string(resp.Body) + + require.NotContains( + t, + body, + "", + "decoy body must keep the noscript+script structure so a scanner cannot fingerprint token presence by body shape", + ) + require.Contains(t, string(decoyResp.Body), " + + diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/webbug/asset/pixel.jpg b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/webbug/asset/pixel.jpg new file mode 100644 index 00000000..4cc263f1 Binary files /dev/null and b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/webbug/asset/pixel.jpg differ diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/webbug/generator.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/webbug/generator.go new file mode 100644 index 00000000..2e1e5a6f --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/webbug/generator.go @@ -0,0 +1,78 @@ +// ©AngelaMos | 2026 +// generator.go + +package webbug + +import ( + "bytes" + "context" + _ "embed" + "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" +) + +const ( + headerReferer = "Referer" + headerCacheControl = "Cache-Control" + headerPragma = "Pragma" + + cacheControlNoStore = "no-store, no-cache, must-revalidate, max-age=0" + pragmaNoCache = "no-cache" + + triggerPathPrefix = "/c/" + + pixelContentType = "image/jpeg" +) + +//go:embed asset/pixel.jpg +var pixelBytes []byte + +type Generator struct{} + +func New() *Generator { return &Generator{} } + +func (g *Generator) Type() token.Type { return token.TypeWebbug } + +func (g *Generator) Generate( + _ context.Context, + t *token.Token, + baseURL string, +) (generators.Artifact, error) { + return generators.Artifact{ + Kind: generators.KindURL, + URL: strings.TrimRight(baseURL, "/") + triggerPathPrefix + t.ID, + }, 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: pixelContentType, + Body: bytes.Clone(pixelBytes), + 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 +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/webbug/generator_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/webbug/generator_test.go new file mode 100644 index 00000000..b9bf21fd --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/webbug/generator_test.go @@ -0,0 +1,304 @@ +// ©AngelaMos | 2026 +// generator_test.go + +package webbug_test + +import ( + "context" + "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/webbug" +) + +const ( + cacheControlNoStore = "no-store, no-cache, must-revalidate, max-age=0" + pragmaNoCache = "no-cache" + pixelContentType = "image/jpeg" + jpegSOI0 = 0xff + jpegSOI1 = 0xd8 +) + +func newWebbugToken(id string) *token.Token { + return &token.Token{ + ID: id, + ManageID: "manage-" + id, + Type: token.TypeWebbug, + Memo: "unit test webbug", + AlertChannel: token.ChannelWebhook, + Enabled: true, + } +} + +func TestGenerator_TypeIsWebbug(t *testing.T) { + g := webbug.New() + require.Equal(t, token.TypeWebbug, g.Type()) +} + +func TestGenerate_ReturnsURLArtifact(t *testing.T) { + cases := []struct { + name string + baseURL string + id string + wantURL string + }{ + { + name: "no trailing slash", + baseURL: "https://canary.example.com", + id: "abc123", + wantURL: "https://canary.example.com/c/abc123", + }, + { + name: "trailing slash trimmed", + baseURL: "https://canary.example.com/", + id: "xyz999", + wantURL: "https://canary.example.com/c/xyz999", + }, + { + name: "subpath base URL preserved", + baseURL: "https://example.com/canary", + id: "p7", + wantURL: "https://example.com/canary/c/p7", + }, + } + + g := webbug.New() + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + art, err := g.Generate( + context.Background(), + newWebbugToken(tc.id), + tc.baseURL, + ) + require.NoError(t, err) + require.Equal(t, generators.KindURL, art.Kind) + require.Equal(t, tc.wantURL, art.URL) + }) + } +} + +func TestTrigger_RecordsEventWithRequestMetadata(t *testing.T) { + g := webbug.New() + tok := newWebbugToken("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", "Mozilla/5.0 (X11; Linux x86_64)") + 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, "Mozilla/5.0 (X11; Linux x86_64)", *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 XRI", + 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_ResponseIsEmbeddedJPEG(t *testing.T) { + g := webbug.New() + tok := newWebbugToken("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, pixelContentType, resp.ContentType) + require.NotEmpty(t, resp.Body, "embedded pixel.jpg must not be empty") + require.Equal(t, jpegSOI0, int(resp.Body[0]), + "body must begin with JPEG SOI marker 0xff 0xd8") + require.Equal(t, jpegSOI1, int(resp.Body[1]), + "body must begin with JPEG SOI marker 0xff 0xd8") + require.Equal(t, cacheControlNoStore, resp.ExtraHeaders["Cache-Control"]) + require.Equal(t, pragmaNoCache, resp.ExtraHeaders["Pragma"]) +} + +func TestTrigger_ResponseBodyIsIndependentCopyPerCall(t *testing.T) { + g := webbug.New() + tok := newWebbugToken("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) + + original := resp2.Body[0] + resp1.Body[0] = 0x00 + require.Equal( + t, + original, + resp2.Body[0], + "each Trigger call must produce an independent body slice", + ) +} + +func TestTrigger_TokenNotFound_StillReturnsImage(t *testing.T) { + g := webbug.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 image response") + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, pixelContentType, resp.ContentType) + require.NotEmpty(t, resp.Body) + require.Nil( + t, + evt, + "nil-token path returns nil event so the handler cannot accidentally persist a row with empty TokenID (FK violation)", + ) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/handler.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/handler.go new file mode 100644 index 00000000..a4e125d6 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/handler.go @@ -0,0 +1,515 @@ +// ©AngelaMos | 2026 +// handler.go + +package token + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware" +) + +const ( + urlParamTokenID = "id" + urlParamManageID = "manage_id" + + headerContentType = "Content-Type" + headerLocation = "Location" + contentTypeJSON = "application/json" + + errorCodeValidation = "VALIDATION_ERROR" + errorCodeBadJSON = "BAD_JSON" + errorCodeInternalError = "INTERNAL_ERROR" + errorCodeUnknownType = "UNKNOWN_TYPE" + errorCodeGenerateFailed = "GENERATE_FAILED" + errorCodeNotFound = "NOT_FOUND" + errorCodeBadCursor = "BAD_CURSOR" + + respMessageValidation = "request validation failed" + respMessageBadJSON = "invalid JSON body" + respMessageInternalError = "internal server error" + respMessageGenerateFailed = "artifact generation failed" + respMessageUnknownType = "unknown token type" + respMessageNotFound = "not found" + respMessageBadCursor = "invalid cursor" + + kubeconfigPathPrefix = "/k/" + + createTokenBodyMaxBytes = 64 * 1024 + fingerprintBodyMaxBytes = 64 * 1024 + + manageDefaultPageSize = 20 + manageMaxPageSize = 100 + + queryParamCursor = "cursor" + queryParamLimit = "limit" +) + +type EventRecorder interface { + Record(ctx context.Context, t *Token, evt *event.Event) error +} + +type FingerprintRecorder interface { + AttachFingerprint( + ctx context.Context, + tokenID, sourceIP string, + fingerprint json.RawMessage, + ) error +} + +type EventQuery interface { + ListByToken( + ctx context.Context, + tokenID string, + opts event.ListOptions, + ) (event.ListResult, error) + CountByToken(ctx context.Context, tokenID string) (int64, error) +} + +type DedupCounter interface { + CountActiveDedup(ctx context.Context, tokenID string) (int64, error) +} + +type Handler struct { + svc *Service + events EventRecorder + fingerprintRecorder FingerprintRecorder + eventQuery EventQuery + dedupCounter DedupCounter + logger *slog.Logger + mysqlEnabled bool +} + +func NewHandler( + svc *Service, + events EventRecorder, + fingerprint FingerprintRecorder, + eventQuery EventQuery, + dedupCounter DedupCounter, + logger *slog.Logger, + mysqlEnabled bool, +) *Handler { + if logger == nil { + logger = slog.Default() + } + return &Handler{ + svc: svc, + events: events, + fingerprintRecorder: fingerprint, + eventQuery: eventQuery, + dedupCounter: dedupCounter, + logger: logger, + mysqlEnabled: mysqlEnabled, + } +} + +func (h *Handler) RegisterAPIRoutes(r chi.Router) { + r.Get("/tokens/types", h.GetTypes) + r.Post("/tokens", h.CreateToken) +} + +func (h *Handler) RegisterManageRoutes(r chi.Router) { + r.Get("/m/{"+urlParamManageID+"}", h.GetManage) + r.Delete("/m/{"+urlParamManageID+"}", h.DeleteManage) +} + +func (h *Handler) RegisterTriggerRoutes(r chi.Router) { + r.Get("/c/{"+urlParamTokenID+"}", h.HandleTrigger) + r.Post("/c/{"+urlParamTokenID+"}/fingerprint", h.HandleFingerprint) + r.HandleFunc("/k/{"+urlParamTokenID+"}", h.HandleTrigger) + r.HandleFunc("/k/{"+urlParamTokenID+"}/*", h.HandleTrigger) +} + +func (h *Handler) GetTypes(w http.ResponseWriter, _ *http.Request) { + h.writeJSON(w, http.StatusOK, envelopeData(TypeDescriptors(h.mysqlEnabled))) +} + +func (h *Handler) CreateToken(w http.ResponseWriter, r *http.Request) { + limited := http.MaxBytesReader(w, r.Body, createTokenBodyMaxBytes) + var req CreateRequest + if err := json.NewDecoder(limited).Decode(&req); err != nil { + h.writeJSON(w, http.StatusBadRequest, envelopeError( + errorCodeBadJSON, respMessageBadJSON, + )) + return + } + + fp := middleware.ExtractFingerprint(r) + ip := middleware.RealIP(r) + + tok, art, err := h.svc.Create(r.Context(), req, fp, ip) + if err != nil { + h.writeCreateError(w, r, err) + return + } + + resp := tok.ToResponse( + h.svc.TriggerURL(tok.ID), + h.svc.ManageURL(tok.ManageID), + ) + h.writeJSON(w, http.StatusCreated, envelopeData(map[string]any{ + "token": resp, + "artifact": artifactToJSON(art), + })) +} + +func (h *Handler) HandleTrigger(w http.ResponseWriter, r *http.Request) { + id := strings.TrimRight(chi.URLParam(r, urlParamTokenID), "_") + if id == "" { + http.NotFound(w, r) + return + } + + tok, err := h.svc.GetByID(r.Context(), id) + if err != nil { + h.logger.WarnContext(r.Context(), "trigger lookup failed", + "token_id", id, "error", err) + } + if tok != nil && !tok.Enabled { + tok = nil + } + + gen, ok := h.resolveGenerator(tok, r) + if !ok { + http.NotFound(w, r) + return + } + + evt, resp, gErr := gen.Trigger(r.Context(), tok, r) + if gErr != nil { + h.logger.WarnContext(r.Context(), "trigger generator failed", + "error", gErr, "token_id", id) + http.Error(w, respMessageInternalError, http.StatusInternalServerError) + return + } + if resp == nil { + http.NotFound(w, r) + return + } + + if tok != nil && evt != nil && h.events != nil { + if recErr := h.events.Record(r.Context(), tok, evt); recErr != nil { + h.logger.WarnContext(r.Context(), "record event failed", + "error", recErr, "token_id", id) + } + } + + h.writeTriggerResponse(w, r, resp) +} + +func (h *Handler) GetManage(w http.ResponseWriter, r *http.Request) { + manageID := chi.URLParam(r, urlParamManageID) + if manageID == "" { + h.writeJSON(w, http.StatusNotFound, envelopeError( + errorCodeNotFound, respMessageNotFound, + )) + return + } + + cursor, err := parseCursor(r.URL.Query().Get(queryParamCursor)) + if err != nil { + h.writeJSON(w, http.StatusBadRequest, envelopeError( + errorCodeBadCursor, respMessageBadCursor, + )) + return + } + limit := parseLimit(r.URL.Query().Get(queryParamLimit)) + + tok, err := h.svc.GetByManageID(r.Context(), manageID) + if err != nil { + h.logger.ErrorContext(r.Context(), "manage: get by manage id", + "error", err, "manage_id", manageID) + h.writeJSON(w, http.StatusInternalServerError, envelopeError( + errorCodeInternalError, respMessageInternalError, + )) + return + } + if tok == nil { + h.writeJSON(w, http.StatusNotFound, envelopeError( + errorCodeNotFound, respMessageNotFound, + )) + return + } + + events, page, total, silenced := h.gatherManageData( + r, + tok.ID, + cursor, + limit, + ) + + resp := ManageResponse{ + Token: tok.ToManageView(h.svc.TriggerURL(tok.ID)), + Events: events, + EventsTotal: total, + EventsSilencedActive: silenced, + Page: page, + } + h.writeJSON(w, http.StatusOK, envelopeData(resp)) +} + +func (h *Handler) DeleteManage(w http.ResponseWriter, r *http.Request) { + manageID := chi.URLParam(r, urlParamManageID) + if manageID == "" { + h.writeJSON(w, http.StatusNotFound, envelopeError( + errorCodeNotFound, respMessageNotFound, + )) + return + } + + if err := h.svc.DeleteByManageID(r.Context(), manageID); err != nil { + if errors.Is(err, ErrNotFound) { + h.writeJSON(w, http.StatusNotFound, envelopeError( + errorCodeNotFound, respMessageNotFound, + )) + return + } + h.logger.ErrorContext(r.Context(), "manage: delete", + "error", err, "manage_id", manageID) + h.writeJSON(w, http.StatusInternalServerError, envelopeError( + errorCodeInternalError, respMessageInternalError, + )) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) gatherManageData( + r *http.Request, + tokenID string, + cursor int64, + limit int, +) (events []event.Response, page ManagePage, total, silenced int64) { + if h.eventQuery == nil { + return nil, ManagePage{}, 0, 0 + } + list, err := h.eventQuery.ListByToken( + r.Context(), tokenID, event.ListOptions{Cursor: cursor, Limit: limit}, + ) + if err != nil { + h.logger.ErrorContext(r.Context(), "manage: list events", + "error", err, "token_id", tokenID) + } + for i := range list.Events { + events = append(events, list.Events[i].ToResponse()) + } + if list.HasMore { + page = ManagePage{ + NextCursor: strconv.FormatInt(list.NextCursor, 10), + HasMore: true, + } + } + + if total, err = h.eventQuery.CountByToken( + r.Context(), + tokenID, + ); err != nil { + h.logger.ErrorContext(r.Context(), "manage: count events", + "error", err, "token_id", tokenID) + total = 0 + } + + if h.dedupCounter != nil { + var dErr error + if silenced, dErr = h.dedupCounter.CountActiveDedup( + r.Context(), tokenID, + ); dErr != nil { + h.logger.WarnContext(r.Context(), "manage: count active dedup", + "error", dErr, "token_id", tokenID) + silenced = 0 + } + } + return events, page, total, silenced +} + +func parseCursor(raw string) (int64, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0, nil + } + v, err := strconv.ParseInt(raw, 10, 64) + if err != nil || v < 0 { + return 0, fmt.Errorf("invalid cursor: %q", raw) + } + return v, nil +} + +func parseLimit(raw string) int { + raw = strings.TrimSpace(raw) + if raw == "" { + return manageDefaultPageSize + } + v, err := strconv.Atoi(raw) + if err != nil || v <= 0 { + return manageDefaultPageSize + } + if v > manageMaxPageSize { + return manageMaxPageSize + } + return v +} + +func (h *Handler) HandleFingerprint(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, urlParamTokenID) + if id == "" || h.fingerprintRecorder == nil { + w.WriteHeader(http.StatusNoContent) + return + } + + limited := http.MaxBytesReader(w, r.Body, fingerprintBodyMaxBytes) + var raw json.RawMessage + if err := json.NewDecoder(limited).Decode(&raw); err != nil { + w.WriteHeader(http.StatusNoContent) + return + } + + if recErr := h.fingerprintRecorder.AttachFingerprint( + r.Context(), + id, + middleware.RealIP(r), + raw, + ); recErr != nil { + h.logger.WarnContext(r.Context(), "attach fingerprint failed", + "token_id", id, "error", recErr) + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) resolveGenerator( + tok *Token, + r *http.Request, +) (Generator, bool) { + if tok != nil { + return h.svc.Generator(tok.Type) + } + if strings.HasPrefix(r.URL.Path, kubeconfigPathPrefix) { + return h.svc.Generator(TypeKubeconfig) + } + return h.svc.Generator(TypeWebbug) +} + +func (h *Handler) writeCreateError( + w http.ResponseWriter, + r *http.Request, + err error, +) { + switch { + case errors.Is(err, ErrUnknownGeneratorType): + h.writeJSON(w, http.StatusBadRequest, envelopeError( + errorCodeUnknownType, respMessageUnknownType, + )) + case errors.Is(err, ErrInvalidDestinationURL), + errors.Is(err, ErrInvalidIncludeKeys): + h.writeJSON(w, http.StatusBadRequest, envelopeError( + errorCodeValidation, err.Error(), + )) + case errors.Is(err, ErrGenerateFailed): + h.logger.ErrorContext(r.Context(), "create token: generator", + "error", err) + h.writeJSON(w, http.StatusInternalServerError, envelopeError( + errorCodeGenerateFailed, respMessageGenerateFailed, + )) + case errors.Is(err, ErrValidation): + h.writeJSON(w, http.StatusBadRequest, envelopeError( + errorCodeValidation, respMessageValidation, + )) + default: + h.logger.ErrorContext(r.Context(), "create token", "error", err) + h.writeJSON(w, http.StatusInternalServerError, envelopeError( + errorCodeInternalError, respMessageInternalError, + )) + } +} + +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) writeTriggerResponse( + w http.ResponseWriter, + r *http.Request, + resp *TriggerResponse, +) { + for k, v := range resp.ExtraHeaders { + w.Header().Set(k, v) + } + if resp.ContentType != "" { + w.Header().Set(headerContentType, resp.ContentType) + } + if resp.RedirectURL != "" { + w.Header().Set(headerLocation, resp.RedirectURL) + w.WriteHeader(resp.StatusCode) + return + } + w.WriteHeader(resp.StatusCode) + if len(resp.Body) > 0 { + if _, err := w.Write(resp.Body); err != nil { + h.logger.WarnContext(r.Context(), "write trigger body", + "error", err) + } + } +} + +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, + }, + } +} + +type ArtifactJSON struct { + Kind string `json:"kind"` + URL string `json:"url,omitempty"` + DestinationURL string `json:"destination_url,omitempty"` + Filename string `json:"filename,omitempty"` + ContentType string `json:"content_type,omitempty"` + ContentB64 string `json:"content_b64,omitempty"` + Content string `json:"content,omitempty"` + ConnectionString string `json:"connection_string,omitempty"` +} + +func artifactToJSON(a Artifact) ArtifactJSON { + out := ArtifactJSON{Kind: string(a.Kind)} + switch a.Kind { + case KindURL: + out.URL = a.URL + out.DestinationURL = a.DestinationURL + case KindFile: + out.Filename = a.Filename + out.ContentType = a.ContentType + out.ContentB64 = base64.StdEncoding.EncodeToString(a.Content) + case KindText: + out.Filename = a.Filename + out.ContentType = a.ContentType + out.Content = string(a.Content) + case KindConnectionString: + out.ConnectionString = a.ConnectionString + } + return out +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/handler_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/handler_test.go new file mode 100644 index 00000000..53a8bb85 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/handler_test.go @@ -0,0 +1,687 @@ +// ©AngelaMos | 2026 +// handler_test.go + +package token_test + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "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" +) + +type triggerGen struct { + tokenType token.Type + artifact generators.Artifact + resp *generators.TriggerResponse + evt *event.Event +} + +func (g *triggerGen) Type() token.Type { return g.tokenType } + +func (g *triggerGen) Generate( + _ context.Context, + _ *token.Token, + _ string, +) (generators.Artifact, error) { + return g.artifact, nil +} + +func (g *triggerGen) Trigger( + _ context.Context, + _ *token.Token, + _ *http.Request, +) (*event.Event, *generators.TriggerResponse, error) { + return g.evt, g.resp, nil +} + +type recordingEvents struct { + events []*event.Event +} + +func (r *recordingEvents) Record( + _ context.Context, + _ *token.Token, + e *event.Event, +) error { + r.events = append(r.events, e) + return nil +} + +func quietHandlerLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func newWebbugHandler( + t *testing.T, + gen token.Generator, +) (*token.Handler, *fakeRepo, *recordingEvents) { + t.Helper() + repo := newFakeRepo() + rec := &recordingEvents{} + svc := token.NewService( + repo, + token.MapRegistry{token.TypeWebbug: gen}, + token.ServiceConfig{ + BaseURL: "https://canary.example.com", + ManageURL: "https://canary.example.com", + }, + ) + return token.NewHandler( + svc, + rec, + nil, + nil, + nil, + quietHandlerLogger(), + false, + ), repo, rec +} + +func TestGetTypes_Returns7Types(t *testing.T) { + svc := token.NewService(newFakeRepo(), token.MapRegistry{}, + token.ServiceConfig{BaseURL: "https://x.test"}) + h := token.NewHandler(svc, nil, nil, nil, nil, quietHandlerLogger(), false) + + r := chi.NewRouter() + h.RegisterAPIRoutes(r) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/tokens/types", nil)) + + require.Equal(t, http.StatusOK, w.Code) + var body struct { + Success bool `json:"success"` + Data []token.TypeDescriptor `json:"data"` + } + require.NoError(t, json.NewDecoder(w.Body).Decode(&body)) + require.True(t, body.Success) + require.Len(t, body.Data, 7) +} + +func TestCreateToken_HappyPath(t *testing.T) { + gen := &triggerGen{ + tokenType: token.TypeWebbug, + artifact: generators.Artifact{ + Kind: generators.KindURL, URL: "https://canary.example.com/c/x", + }, + } + h, _, _ := newWebbugHandler(t, gen) + + r := chi.NewRouter() + h.RegisterAPIRoutes(r) + + body := strings.NewReader(`{ + "type":"webbug","memo":"m","alert_channel":"webhook", + "webhook_url":"https://example.com/h","cf_turnstile_response":"t", + "metadata":{} + }`) + req := httptest.NewRequest(http.MethodPost, "/tokens", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusCreated, w.Code) + + var resp struct { + Success bool `json:"success"` + Data struct { + Token token.Response `json:"token"` + Artifact token.ArtifactJSON `json:"artifact"` + } `json:"data"` + } + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + require.True(t, resp.Success) + require.Equal(t, token.TypeWebbug, resp.Data.Token.Type) + require.NotEmpty(t, resp.Data.Token.ID) + require.Contains(t, resp.Data.Token.TriggerURL, "/c/") + require.Contains(t, resp.Data.Token.ManageURL, "/m/") + require.Equal(t, "url", resp.Data.Artifact.Kind) +} + +func TestCreateToken_BadJSON(t *testing.T) { + gen := &triggerGen{tokenType: token.TypeWebbug} + h, _, _ := newWebbugHandler(t, gen) + + r := chi.NewRouter() + h.RegisterAPIRoutes(r) + + req := httptest.NewRequest( + http.MethodPost, + "/tokens", + strings.NewReader(`{not json`), + ) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + require.Contains(t, w.Body.String(), "BAD_JSON") +} + +func TestCreateToken_ValidationFailure(t *testing.T) { + gen := &triggerGen{tokenType: token.TypeWebbug} + h, _, _ := newWebbugHandler(t, gen) + + r := chi.NewRouter() + h.RegisterAPIRoutes(r) + + body := strings.NewReader( + `{"type":"webbug","memo":"m","cf_turnstile_response":"t"}`, + ) + req := httptest.NewRequest(http.MethodPost, "/tokens", body) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + require.Contains(t, w.Body.String(), "VALIDATION_ERROR") +} + +func TestHandleTrigger_KnownTokenReturnsResponseAndRecordsEvent(t *testing.T) { + gen := &triggerGen{ + tokenType: token.TypeWebbug, + artifact: generators.Artifact{Kind: generators.KindURL}, + resp: &generators.TriggerResponse{ + StatusCode: 200, + ContentType: "image/gif", + Body: []byte{0x47, 0x49, 0x46, 0x38}, + }, + evt: &event.Event{SourceIP: "1.2.3.4"}, + } + h, repo, rec := newWebbugHandler(t, gen) + + tok := &token.Token{ + ID: "abcdef012345", ManageID: "m", Type: token.TypeWebbug, + AlertChannel: token.ChannelWebhook, Enabled: true, + Metadata: json.RawMessage(`{}`), + } + require.NoError(t, repo.Insert(context.Background(), tok)) + + r := chi.NewRouter() + h.RegisterTriggerRoutes(r) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/c/abcdef012345", nil) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "image/gif", w.Header().Get("Content-Type")) + require.Equal(t, []byte{0x47, 0x49, 0x46, 0x38}, w.Body.Bytes()) + require.Len(t, rec.events, 1) +} + +func TestHandleTrigger_TrimsTrailingUnderscoresFromPaddedID(t *testing.T) { + gen := &triggerGen{ + tokenType: token.TypeWebbug, + artifact: generators.Artifact{Kind: generators.KindURL}, + resp: &generators.TriggerResponse{ + StatusCode: 200, + ContentType: "image/gif", + Body: []byte{0x47}, + }, + evt: &event.Event{SourceIP: "1.2.3.4"}, + } + h, repo, rec := newWebbugHandler(t, gen) + + tok := &token.Token{ + ID: "abcdef012345", ManageID: "m", Type: token.TypeWebbug, + AlertChannel: token.ChannelWebhook, Enabled: true, + Metadata: json.RawMessage(`{}`), + } + require.NoError(t, repo.Insert(context.Background(), tok)) + + r := chi.NewRouter() + h.RegisterTriggerRoutes(r) + + w := httptest.NewRecorder() + padded := "/c/abcdef012345____________________" + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, padded, nil)) + + require.Equal(t, http.StatusOK, w.Code) + require.Len(t, rec.events, 1, + "trailing-underscore padded id must still resolve to the real token "+ + "so PDFs already in the field (carrying padded URLs) record events "+ + "instead of silently no-opping (audit finding F2)") +} + +func TestHandleTrigger_UnknownTokenStillReturnsArtifactShape(t *testing.T) { + gen := &triggerGen{ + tokenType: token.TypeWebbug, + resp: &generators.TriggerResponse{ + StatusCode: 200, + ContentType: "image/gif", + Body: []byte{0x47, 0x49, 0x46}, + }, + } + h, _, rec := newWebbugHandler(t, gen) + + r := chi.NewRouter() + h.RegisterTriggerRoutes(r) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/c/nonexistent1", nil)) + + require.Equal(t, http.StatusOK, w.Code, + "unknown tokens still return artifact shape (defense in depth)") + require.Empty(t, rec.events, "no event recorded for unknown token") +} + +func TestHandleTrigger_DisabledTokenIsTreatedAsUnknown(t *testing.T) { + gen := &triggerGen{ + tokenType: token.TypeWebbug, + resp: &generators.TriggerResponse{ + StatusCode: 200, + ContentType: "image/gif", + Body: []byte{0x47}, + }, + evt: &event.Event{SourceIP: "1.2.3.4"}, + } + h, repo, rec := newWebbugHandler(t, gen) + + tok := &token.Token{ + ID: "disabled1234", ManageID: "m", Type: token.TypeWebbug, + AlertChannel: token.ChannelWebhook, Enabled: false, + Metadata: json.RawMessage(`{}`), + } + require.NoError(t, repo.Insert(context.Background(), tok)) + + r := chi.NewRouter() + h.RegisterTriggerRoutes(r) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/c/disabled1234", nil)) + + require.Equal(t, http.StatusOK, w.Code) + require.Empty(t, rec.events, "disabled token must not record events") +} + +func TestHandleFingerprint_Returns204WithNoRecorder(t *testing.T) { + gen := &triggerGen{tokenType: token.TypeWebbug} + h, _, _ := newWebbugHandler(t, gen) + + r := chi.NewRouter() + h.RegisterTriggerRoutes(r) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, + "/c/anything/fingerprint", strings.NewReader(`{"x":1}`))) + + require.Equal(t, http.StatusNoContent, w.Code) +} + +func TestArtifactToJSON_Kinds(t *testing.T) { + cases := []struct { + name string + in generators.Artifact + want token.ArtifactJSON + }{ + { + "url", + generators.Artifact{ + Kind: generators.KindURL, + URL: "u", + DestinationURL: "d", + }, + token.ArtifactJSON{Kind: "url", URL: "u", DestinationURL: "d"}, + }, + { + "file", + generators.Artifact{ + Kind: generators.KindFile, + Filename: "f.docx", + ContentType: "x", + Content: []byte("hi"), + }, + token.ArtifactJSON{ + Kind: "file", + Filename: "f.docx", + ContentType: "x", + ContentB64: "aGk=", + }, + }, + { + "text", + generators.Artifact{ + Kind: generators.KindText, + Filename: ".env", + ContentType: "text/plain", + Content: []byte("KEY=v"), + }, + token.ArtifactJSON{ + Kind: "text", + Filename: ".env", + ContentType: "text/plain", + Content: "KEY=v", + }, + }, + { + "conn", + generators.Artifact{ + Kind: generators.KindConnectionString, + ConnectionString: "mysql://x", + }, + token.ArtifactJSON{ + Kind: "connection_string", + ConnectionString: "mysql://x", + }, + }, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + require.Equal(t, c.want, exposeArtifactToJSON(c.in)) + }) + } +} + +func exposeArtifactToJSON(a generators.Artifact) token.ArtifactJSON { + gen := &triggerGen{tokenType: token.TypeWebbug, artifact: a} + repo := newFakeRepo() + svc := token.NewService(repo, token.MapRegistry{token.TypeWebbug: gen}, + token.ServiceConfig{BaseURL: "https://x"}) + h := token.NewHandler(svc, nil, nil, nil, nil, quietHandlerLogger(), false) + + r := chi.NewRouter() + h.RegisterAPIRoutes(r) + + body := strings.NewReader( + `{"type":"webbug","alert_channel":"webhook","webhook_url":"https://x/h","cf_turnstile_response":"t","metadata":{}}`, + ) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/tokens", body)) + + var resp struct { + Data struct { + Artifact token.ArtifactJSON `json:"artifact"` + } `json:"data"` + } + if jsonErr := json.NewDecoder(w.Body).Decode(&resp); jsonErr != nil { + panic(jsonErr) + } + return resp.Data.Artifact +} + +type fakeEventQuery struct { + listResult event.ListResult + listErr error + countN int64 + countErr error + lastTokID string + lastOpts event.ListOptions +} + +func (f *fakeEventQuery) ListByToken( + _ context.Context, + tokenID string, + opts event.ListOptions, +) (event.ListResult, error) { + f.lastTokID = tokenID + f.lastOpts = opts + return f.listResult, f.listErr +} + +func (f *fakeEventQuery) CountByToken( + _ context.Context, + _ string, +) (int64, error) { + return f.countN, f.countErr +} + +type fakeDedupCounter struct { + n int64 + err error +} + +func (f *fakeDedupCounter) CountActiveDedup( + _ context.Context, + _ string, +) (int64, error) { + return f.n, f.err +} + +func newManageHandler( + t *testing.T, + eq *fakeEventQuery, + dc *fakeDedupCounter, +) (*token.Handler, *fakeRepo) { + t.Helper() + repo := newFakeRepo() + svc := token.NewService(repo, token.MapRegistry{}, + token.ServiceConfig{ + BaseURL: "https://canary.example.com", + ManageURL: "https://canary.example.com", + }) + return token.NewHandler( + svc, + nil, + nil, + eq, + dc, + quietHandlerLogger(), + false, + ), + repo +} + +func seedToken(t *testing.T, repo *fakeRepo, manageID string) *token.Token { + t.Helper() + tok := &token.Token{ + ID: "tok" + manageID[:8], + ManageID: manageID, + Type: token.TypeWebbug, + Memo: "manage-test", + AlertChannel: token.ChannelWebhook, + WebhookURL: strPtr("https://x/h"), + CreatedIP: "1.1.1.1", + CreatedFP: "fp", + Metadata: json.RawMessage(`{}`), + Enabled: true, + TriggerCount: 5, + } + require.NoError(t, repo.Insert(context.Background(), tok)) + return tok +} + +func strPtr(s string) *string { return &s } + +func decodeManage(t *testing.T, body []byte) struct { + Success bool `json:"success"` + Data token.ManageResponse `json:"data"` +} { + t.Helper() + var resp struct { + Success bool `json:"success"` + Data token.ManageResponse `json:"data"` + } + require.NoError(t, json.Unmarshal(body, &resp)) + return resp +} + +func TestGetManage_HappyPath(t *testing.T) { + t.Parallel() + eq := &fakeEventQuery{ + countN: 17, + listResult: event.ListResult{ + Events: []event.Event{ + {ID: 42, TokenID: "x", SourceIP: "1.2.3.4"}, + {ID: 41, TokenID: "x", SourceIP: "5.6.7.8"}, + }, + HasMore: false, + }, + } + dc := &fakeDedupCounter{n: 3} + h, repo := newManageHandler(t, eq, dc) + tok := seedToken(t, repo, "11111111-1111-1111-1111-111111111111") + + r := chi.NewRouter() + h.RegisterManageRoutes(r) + + req := httptest.NewRequest(http.MethodGet, "/m/"+tok.ManageID, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) + resp := decodeManage(t, w.Body.Bytes()) + require.True(t, resp.Success) + require.Equal(t, tok.ID, resp.Data.Token.ID) + require.Equal( + t, + "https://canary.example.com/c/"+tok.ID, + resp.Data.Token.TriggerURL, + ) + require.Equal(t, int64(5), resp.Data.Token.TriggerCount) + require.Len(t, resp.Data.Events, 2) + require.Equal(t, int64(17), resp.Data.EventsTotal) + require.Equal(t, int64(3), resp.Data.EventsSilencedActive) +} + +func TestGetManage_404OnUnknownManageID(t *testing.T) { + t.Parallel() + h, _ := newManageHandler(t, &fakeEventQuery{}, &fakeDedupCounter{}) + + r := chi.NewRouter() + h.RegisterManageRoutes(r) + + w := httptest.NewRecorder() + r.ServeHTTP( + w, + httptest.NewRequest(http.MethodGet, "/m/does-not-exist", nil), + ) + require.Equal(t, http.StatusNotFound, w.Code) + require.Contains(t, w.Body.String(), "NOT_FOUND") +} + +func TestGetManage_400OnBadCursor(t *testing.T) { + t.Parallel() + h, repo := newManageHandler(t, &fakeEventQuery{}, &fakeDedupCounter{}) + tok := seedToken(t, repo, "22222222-2222-2222-2222-222222222222") + + r := chi.NewRouter() + h.RegisterManageRoutes(r) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, + "/m/"+tok.ManageID+"?cursor=notanumber", nil)) + require.Equal(t, http.StatusBadRequest, w.Code) + require.Contains(t, w.Body.String(), "BAD_CURSOR") +} + +func TestGetManage_400OnNegativeCursor(t *testing.T) { + t.Parallel() + h, repo := newManageHandler(t, &fakeEventQuery{}, &fakeDedupCounter{}) + tok := seedToken(t, repo, "33333333-3333-3333-3333-333333333333") + + r := chi.NewRouter() + h.RegisterManageRoutes(r) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, + "/m/"+tok.ManageID+"?cursor=-1", nil)) + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestGetManage_PaginationCursorAndHasMore(t *testing.T) { + t.Parallel() + full := []event.Event{} + for i := 20; i > 0; i-- { + full = append(full, event.Event{ID: int64(i), SourceIP: "1.1.1.1"}) + } + eq := &fakeEventQuery{ + listResult: event.ListResult{ + Events: full, + HasMore: true, + NextCursor: 1, + }, + countN: 50, + } + h, repo := newManageHandler(t, eq, &fakeDedupCounter{}) + tok := seedToken(t, repo, "44444444-4444-4444-4444-444444444444") + + r := chi.NewRouter() + h.RegisterManageRoutes(r) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/m/"+tok.ManageID, nil)) + require.Equal(t, http.StatusOK, w.Code) + + resp := decodeManage(t, w.Body.Bytes()) + require.Len(t, resp.Data.Events, 20) + require.True(t, resp.Data.Page.HasMore) + require.Equal(t, "1", resp.Data.Page.NextCursor, + "cursor is the ID of the last event returned") +} + +func TestGetManage_LimitParamRespected(t *testing.T) { + t.Parallel() + eq := &fakeEventQuery{} + h, repo := newManageHandler(t, eq, &fakeDedupCounter{}) + tok := seedToken(t, repo, "55555555-5555-5555-5555-555555555555") + + r := chi.NewRouter() + h.RegisterManageRoutes(r) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, + "/m/"+tok.ManageID+"?limit=5", nil)) + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, 5, eq.lastOpts.Limit) +} + +func TestGetManage_LimitCappedAtMax(t *testing.T) { + t.Parallel() + eq := &fakeEventQuery{} + h, repo := newManageHandler(t, eq, &fakeDedupCounter{}) + tok := seedToken(t, repo, "66666666-6666-6666-6666-666666666666") + + r := chi.NewRouter() + h.RegisterManageRoutes(r) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, + "/m/"+tok.ManageID+"?limit=999", nil)) + require.Equal(t, http.StatusOK, w.Code) + require.LessOrEqual(t, eq.lastOpts.Limit, 100, + "limit should be capped at manageMaxPageSize") +} + +func TestDeleteManage_HappyPath(t *testing.T) { + t.Parallel() + h, repo := newManageHandler(t, &fakeEventQuery{}, &fakeDedupCounter{}) + tok := seedToken(t, repo, "77777777-7777-7777-7777-777777777777") + + r := chi.NewRouter() + h.RegisterManageRoutes(r) + + w := httptest.NewRecorder() + r.ServeHTTP( + w, + httptest.NewRequest(http.MethodDelete, "/m/"+tok.ManageID, nil), + ) + require.Equal(t, http.StatusNoContent, w.Code) +} + +func TestDeleteManage_404OnUnknownManageID(t *testing.T) { + t.Parallel() + h, _ := newManageHandler(t, &fakeEventQuery{}, &fakeDedupCounter{}) + + r := chi.NewRouter() + h.RegisterManageRoutes(r) + + w := httptest.NewRecorder() + r.ServeHTTP( + w, + httptest.NewRequest(http.MethodDelete, "/m/does-not-exist", nil), + ) + require.Equal(t, http.StatusNotFound, w.Code) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/repository.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/repository.go new file mode 100644 index 00000000..7303653b --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/repository.go @@ -0,0 +1,223 @@ +// ©AngelaMos | 2026 +// repository.go + +package token + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + + "github.com/jmoiron/sqlx" +) + +var ErrNotFound = errors.New("token not found") + +const defaultListLimit = 50 + +type Repository struct { + db *sqlx.DB +} + +func NewRepository(db *sqlx.DB) *Repository { + return &Repository{db: db} +} + +const insertSQL = ` +INSERT INTO tokens ( + id, manage_id, type, memo, filename, + alert_channel, telegram_bot, telegram_chat, webhook_url, + created_ip, created_fp, metadata, enabled +) VALUES ( + :id, :manage_id, :type, :memo, :filename, + :alert_channel, :telegram_bot, :telegram_chat, :webhook_url, + :created_ip, :created_fp, :metadata, :enabled +) +RETURNING created_at, trigger_count, last_triggered` + +func (r *Repository) Insert(ctx context.Context, t *Token) error { + stmt, err := r.db.PrepareNamedContext(ctx, insertSQL) + if err != nil { + return fmt.Errorf("prepare insert token: %w", err) + } + defer func() { + if cerr := stmt.Close(); cerr != nil { + slog.WarnContext(ctx, "close prepared stmt", + "op", "insert_token", "error", cerr) + } + }() + + if err := stmt.GetContext(ctx, t, t); err != nil { + return fmt.Errorf("insert token: %w", err) + } + return nil +} + +const selectColumns = ` + id, manage_id, type, memo, filename, + alert_channel, telegram_bot, telegram_chat, webhook_url, + created_at, created_ip, created_fp, enabled, + trigger_count, last_triggered, metadata` + +func (r *Repository) GetByID(ctx context.Context, id string) (*Token, error) { + var t Token + q := `SELECT ` + selectColumns + ` FROM tokens WHERE id = $1` + err := r.db.GetContext(ctx, &t, q, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get token by id: %w", err) + } + return &t, nil +} + +func (r *Repository) GetByManageID( + ctx context.Context, + manageID string, +) (*Token, error) { + var t Token + q := `SELECT ` + selectColumns + ` FROM tokens WHERE manage_id = $1` + err := r.db.GetContext(ctx, &t, q, manageID) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get token by manage_id: %w", err) + } + return &t, nil +} + +func (r *Repository) DeleteByManageID( + ctx context.Context, + manageID string, +) error { + res, err := r.db.ExecContext(ctx, + `DELETE FROM tokens WHERE manage_id = $1`, manageID) + if err != nil { + return fmt.Errorf("delete token: %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) IncrementTriggerCount( + ctx context.Context, + id string, +) error { + _, err := r.db.ExecContext(ctx, ` + UPDATE tokens + SET trigger_count = trigger_count + 1, + last_triggered = NOW() + WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("increment trigger count: %w", err) + } + return nil +} + +func (r *Repository) SetEnabled( + ctx context.Context, + id string, + enabled bool, +) error { + res, err := r.db.ExecContext(ctx, + `UPDATE tokens SET enabled = $2 WHERE id = $1`, id, enabled) + if err != nil { + return fmt.Errorf("set enabled: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected: %w", err) + } + if n == 0 { + return ErrNotFound + } + return nil +} + +type ListOptions struct { + Limit int + Offset int +} + +func (r *Repository) ListAll( + ctx context.Context, + opts ListOptions, +) ([]Token, error) { + if opts.Limit <= 0 { + opts.Limit = defaultListLimit + } + q := `SELECT ` + selectColumns + ` FROM tokens + ORDER BY created_at DESC + LIMIT $1 OFFSET $2` + var tokens []Token + if err := r.db.SelectContext( + ctx, + &tokens, + q, + opts.Limit, + opts.Offset, + ); err != nil { + return nil, fmt.Errorf("list all tokens: %w", err) + } + return tokens, nil +} + +func (r *Repository) CountAll(ctx context.Context) (int64, error) { + var n int64 + if err := r.db.GetContext( + ctx, + &n, + `SELECT COUNT(*) FROM tokens`, + ); err != nil { + return 0, fmt.Errorf("count tokens: %w", err) + } + return n, nil +} + +type TypeCount struct { + Type Type `db:"type" json:"type"` + Count int64 `db:"count" json:"count"` +} + +type ChannelCount struct { + Channel AlertChannel `db:"alert_channel" json:"alert_channel"` + Count int64 `db:"count" json:"count"` +} + +func (r *Repository) CountByType( + ctx context.Context, +) ([]TypeCount, error) { + rows := []TypeCount{} + q := `SELECT type, COUNT(*) AS count + FROM tokens + GROUP BY type + ORDER BY type` + if err := r.db.SelectContext(ctx, &rows, q); err != nil { + return nil, fmt.Errorf("count tokens by type: %w", err) + } + return rows, nil +} + +func (r *Repository) CountByAlertChannel( + ctx context.Context, +) ([]ChannelCount, error) { + rows := []ChannelCount{} + q := `SELECT alert_channel, COUNT(*) AS count + FROM tokens + GROUP BY alert_channel + ORDER BY alert_channel` + if err := r.db.SelectContext(ctx, &rows, q); err != nil { + return nil, fmt.Errorf("count tokens by channel: %w", err) + } + return rows, nil +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/repository_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/repository_test.go new file mode 100644 index 00000000..b0f9707e --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/repository_test.go @@ -0,0 +1,262 @@ +// ©AngelaMos | 2026 +// repository_test.go + +//go:build integration + +package token_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" + + "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 newRepo(t *testing.T) *token.Repository { + t.Helper() + db := sqlx.NewDb(testutil.NewTestDB(t), "pgx") + return token.NewRepository(db) +} + +func sampleWebhookToken(id string) *token.Token { + return &token.Token{ + ID: id, + ManageID: uuid.New().String(), + Type: token.TypeWebbug, + Memo: "test webbug", + AlertChannel: token.ChannelWebhook, + WebhookURL: testutil.Ptr("https://example.com/hook"), + CreatedIP: "203.0.113.10", + CreatedFP: "0123456789abcdef", + Metadata: json.RawMessage(`{}`), + Enabled: true, + } +} + +func sampleTelegramToken(id string) *token.Token { + return &token.Token{ + ID: id, + ManageID: uuid.New().String(), + Type: token.TypeDocx, + Memo: "Q4 bonuses", + Filename: testutil.Ptr("Q4_Bonuses_2024.docx"), + AlertChannel: token.ChannelTelegram, + TelegramBot: testutil.Ptr("123456:ABCDEF"), + TelegramChat: testutil.Ptr("-1001234567890"), + CreatedIP: "198.51.100.5", + CreatedFP: "fedcba9876543210", + Metadata: json.RawMessage(`{"include_keys":["aws"]}`), + Enabled: true, + } +} + +func TestRepository_InsertAndGetByID(t *testing.T) { + t.Parallel() + repo := newRepo(t) + ctx := context.Background() + + tok := sampleWebhookToken("abcdef0123ko") + require.NoError(t, repo.Insert(ctx, tok)) + require.False(t, tok.CreatedAt.IsZero(), "Insert should populate created_at via RETURNING") + + got, err := repo.GetByID(ctx, tok.ID) + require.NoError(t, err) + + require.Equal(t, tok.ID, got.ID) + require.Equal(t, tok.ManageID, got.ManageID) + require.Equal(t, tok.Type, got.Type) + require.Equal(t, tok.AlertChannel, got.AlertChannel) + require.Equal(t, tok.Memo, got.Memo) + require.NotNil(t, got.WebhookURL) + require.Equal(t, "https://example.com/hook", *got.WebhookURL) + require.True(t, got.Enabled) + require.Equal(t, int64(0), got.TriggerCount) +} + +func TestRepository_GetByID_NotFound(t *testing.T) { + t.Parallel() + repo := newRepo(t) + + _, err := repo.GetByID(context.Background(), "doesnotexist") + require.ErrorIs(t, err, token.ErrNotFound) +} + +func TestRepository_GetByManageID(t *testing.T) { + t.Parallel() + repo := newRepo(t) + ctx := context.Background() + + tok := sampleTelegramToken("mngabcdef012") + require.NoError(t, repo.Insert(ctx, tok)) + + got, err := repo.GetByManageID(ctx, tok.ManageID) + require.NoError(t, err) + + require.Equal(t, tok.ID, got.ID) + require.Equal(t, token.ChannelTelegram, got.AlertChannel) + require.NotNil(t, got.TelegramBot) + require.Equal(t, "123456:ABCDEF", *got.TelegramBot) +} + +func TestRepository_GetByManageID_NotFound(t *testing.T) { + t.Parallel() + repo := newRepo(t) + + _, err := repo.GetByManageID(context.Background(), uuid.New().String()) + require.ErrorIs(t, err, token.ErrNotFound) +} + +func TestRepository_DeleteByManageID(t *testing.T) { + t.Parallel() + repo := newRepo(t) + ctx := context.Background() + + tok := sampleWebhookToken("delok0123abc") + require.NoError(t, repo.Insert(ctx, tok)) + + require.NoError(t, repo.DeleteByManageID(ctx, tok.ManageID)) + + _, err := repo.GetByID(ctx, tok.ID) + require.ErrorIs(t, err, token.ErrNotFound) +} + +func TestRepository_DeleteByManageID_NotFound(t *testing.T) { + t.Parallel() + repo := newRepo(t) + + err := repo.DeleteByManageID(context.Background(), uuid.New().String()) + require.True(t, errors.Is(err, token.ErrNotFound)) +} + +func TestRepository_IncrementTriggerCount(t *testing.T) { + t.Parallel() + repo := newRepo(t) + ctx := context.Background() + + tok := sampleWebhookToken("trgcount001a") + require.NoError(t, repo.Insert(ctx, tok)) + + require.NoError(t, repo.IncrementTriggerCount(ctx, tok.ID)) + require.NoError(t, repo.IncrementTriggerCount(ctx, tok.ID)) + + got, err := repo.GetByID(ctx, tok.ID) + require.NoError(t, err) + require.Equal(t, int64(2), got.TriggerCount) + require.NotNil(t, got.LastTriggered) +} + +func TestRepository_SetEnabled(t *testing.T) { + t.Parallel() + repo := newRepo(t) + ctx := context.Background() + + tok := sampleWebhookToken("setenab0001a") + require.NoError(t, repo.Insert(ctx, tok)) + + require.NoError(t, repo.SetEnabled(ctx, tok.ID, false)) + + got, err := repo.GetByID(ctx, tok.ID) + require.NoError(t, err) + require.False(t, got.Enabled) + + require.NoError(t, repo.SetEnabled(ctx, tok.ID, true)) + got, err = repo.GetByID(ctx, tok.ID) + require.NoError(t, err) + require.True(t, got.Enabled) +} + +func TestRepository_SetEnabled_NotFound(t *testing.T) { + t.Parallel() + repo := newRepo(t) + + err := repo.SetEnabled(context.Background(), "nonexistent", false) + require.ErrorIs(t, err, token.ErrNotFound) +} + +func TestRepository_ListAll(t *testing.T) { + t.Parallel() + repo := newRepo(t) + ctx := context.Background() + + for _, id := range []string{"lstaaaaaaaa1", "lstbbbbbbbb2", "lstcccccccc3"} { + tok := sampleWebhookToken(id) + tok.Memo = "list-test-" + id + require.NoError(t, repo.Insert(ctx, tok)) + } + + got, err := repo.ListAll(ctx, token.ListOptions{Limit: 10}) + require.NoError(t, err) + require.Len(t, got, 3) + + count, err := repo.CountAll(ctx) + require.NoError(t, err) + require.Equal(t, int64(3), count) +} + +func TestRepository_CountByType(t *testing.T) { + t.Parallel() + repo := newRepo(t) + ctx := context.Background() + + webbug := sampleWebhookToken("cntbytype01a") + webbug.Type = token.TypeWebbug + require.NoError(t, repo.Insert(ctx, webbug)) + + webbug2 := sampleWebhookToken("cntbytype01b") + webbug2.Type = token.TypeWebbug + require.NoError(t, repo.Insert(ctx, webbug2)) + + docx := sampleTelegramToken("cntbytype02a") + docx.Type = token.TypeDocx + require.NoError(t, repo.Insert(ctx, docx)) + + rows, err := repo.CountByType(ctx) + require.NoError(t, err) + + got := map[token.Type]int64{} + for _, r := range rows { + got[r.Type] = r.Count + } + require.GreaterOrEqual(t, got[token.TypeWebbug], int64(2)) + require.GreaterOrEqual(t, got[token.TypeDocx], int64(1)) +} + +func TestRepository_CountByAlertChannel(t *testing.T) { + t.Parallel() + repo := newRepo(t) + ctx := context.Background() + + wh := sampleWebhookToken("cntbychan01a") + require.NoError(t, repo.Insert(ctx, wh)) + + tg := sampleTelegramToken("cntbychan02a") + require.NoError(t, repo.Insert(ctx, tg)) + + rows, err := repo.CountByAlertChannel(ctx) + require.NoError(t, err) + + got := map[token.AlertChannel]int64{} + for _, r := range rows { + got[r.Channel] = r.Count + } + require.GreaterOrEqual(t, got[token.ChannelWebhook], int64(1)) + require.GreaterOrEqual(t, got[token.ChannelTelegram], int64(1)) +} + +func TestRepository_TypeAndChannelValidation(t *testing.T) { + t.Parallel() + repo := newRepo(t) + + bad := sampleWebhookToken("badtypee0001") + bad.Type = "invalid_type" + + err := repo.Insert(context.Background(), bad) + require.Error(t, err, "DB CHECK should reject invalid type") +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/service.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/service.go new file mode 100644 index 00000000..e0301f6b --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/service.go @@ -0,0 +1,328 @@ +// ©AngelaMos | 2026 +// service.go + +package token + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "math/big" + "strings" + + "github.com/go-playground/validator/v10" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" +) + +const ( + tokenIDAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789" + tokenIDLength = 12 + + metadataDestinationURL = "destination_url" + metadataIncludeKeys = "include_keys" + + pgUniqueViolationCode = "23505" + maxTokenIDAttempts = 5 +) + +var ( + ErrInvalidDestinationURL = errors.New( + "token: slowredirect requires metadata.destination_url", + ) + ErrInvalidIncludeKeys = errors.New( + "token: envfile metadata.include_keys must be a subset of {aws,stripe,github,db}", + ) + ErrUnknownGeneratorType = errors.New( + "token: no generator registered for this type", + ) + ErrGenerateFailed = errors.New("token: artifact generation failed") + ErrValidation = errors.New("token: request validation failed") +) + +var allowedIncludeKeys = map[string]struct{}{ + "aws": {}, "stripe": {}, "github": {}, "db": {}, +} + +type ServiceRepository interface { + Insert(ctx context.Context, t *Token) error + GetByID(ctx context.Context, id string) (*Token, error) + GetByManageID(ctx context.Context, manageID string) (*Token, error) + DeleteByManageID(ctx context.Context, manageID string) error + IncrementTriggerCount(ctx context.Context, id string) error +} + +type Registry interface { + Get(t Type) (Generator, bool) +} + +type MapRegistry map[Type]Generator + +func (m MapRegistry) Get(t Type) (Generator, bool) { + g, ok := m[t] + return g, ok +} + +type ServiceConfig struct { + BaseURL string + ManageURL string +} + +type Service struct { + repo ServiceRepository + registry Registry + validate *validator.Validate + baseURL string + manageURL string +} + +func NewService( + repo ServiceRepository, + reg Registry, + cfg ServiceConfig, +) *Service { + return &Service{ + repo: repo, + registry: reg, + validate: validator.New(), + baseURL: strings.TrimRight(cfg.BaseURL, "/"), + manageURL: strings.TrimRight(cfg.ManageURL, "/"), + } +} + +func (s *Service) Create( + ctx context.Context, + req CreateRequest, + createdFP, createdIP string, +) (*Token, Artifact, error) { + if err := s.validate.Struct(req); err != nil { + return nil, Artifact{}, fmt.Errorf( + "%w: %w", ErrValidation, err, + ) + } + if err := validateTypeMetadata(req.Type, req.Metadata); err != nil { + return nil, Artifact{}, err + } + + gen, ok := s.registry.Get(req.Type) + if !ok { + return nil, Artifact{}, fmt.Errorf( + "%w: %s", ErrUnknownGeneratorType, req.Type, + ) + } + + manageID := uuid.NewString() + tok := &Token{ + ManageID: manageID, + Type: req.Type, + Memo: req.Memo, + Filename: filenamePointer(req.Filename), + AlertChannel: req.AlertChannel, + TelegramBot: optionalString(req.TelegramBot), + TelegramChat: optionalString(req.TelegramChat), + WebhookURL: optionalString(req.WebhookURL), + CreatedIP: createdIP, + CreatedFP: createdFP, + Enabled: true, + Metadata: normalizeMetadata(req.Metadata), + } + + var ( + art Artifact + insertErr error + ) + for attempt := range maxTokenIDAttempts { + id, err := generateTokenID() + if err != nil { + return nil, Artifact{}, fmt.Errorf( + "generate id (attempt %d): %w", attempt, err, + ) + } + tok.ID = id + + art, err = gen.Generate(ctx, tok, s.baseURL) + if err != nil { + return nil, Artifact{}, fmt.Errorf( + "%w: %w", ErrGenerateFailed, err, + ) + } + + insertErr = s.repo.Insert(ctx, tok) + if insertErr == nil { + return tok, art, nil + } + if !isUniqueViolation(insertErr) { + return nil, Artifact{}, fmt.Errorf( + "persist token: %w", insertErr, + ) + } + } + return nil, Artifact{}, fmt.Errorf( + "persist token after %d id-collision retries: %w", + maxTokenIDAttempts, insertErr, + ) +} + +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == pgUniqueViolationCode +} + +func (s *Service) GetByID( + ctx context.Context, + id string, +) (*Token, error) { + tok, err := s.repo.GetByID(ctx, id) + if errors.Is(err, ErrNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return tok, nil +} + +func (s *Service) GetByManageID( + ctx context.Context, + manageID string, +) (*Token, error) { + tok, err := s.repo.GetByManageID(ctx, manageID) + if errors.Is(err, ErrNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return tok, nil +} + +func (s *Service) IncrementTriggerCount( + ctx context.Context, + id string, +) error { + return s.repo.IncrementTriggerCount(ctx, id) +} + +func (s *Service) DeleteByManageID( + ctx context.Context, + manageID string, +) error { + return s.repo.DeleteByManageID(ctx, manageID) +} + +func (s *Service) TriggerURL(id string) string { + return s.baseURL + "/c/" + id +} + +func (s *Service) ManageURL(manageID string) string { + return s.manageURL + "/m/" + manageID +} + +func (s *Service) Generator(t Type) (Generator, bool) { + return s.registry.Get(t) +} + +func validateTypeMetadata(t Type, metadata json.RawMessage) error { + switch t { + case TypeSlowRedirect: + return validateSlowredirectMetadata(metadata) + case TypeEnvfile: + return validateEnvfileMetadata(metadata) + case TypeWebbug, TypeDocx, TypePDF, TypeKubeconfig, TypeMySQL: + return nil + } + return nil +} + +func validateSlowredirectMetadata(metadata json.RawMessage) error { + if len(metadata) == 0 { + return ErrInvalidDestinationURL + } + var m map[string]json.RawMessage + if err := json.Unmarshal(metadata, &m); err != nil { + return fmt.Errorf( + "parse metadata: %w (slowredirect: %w)", + err, ErrInvalidDestinationURL, + ) + } + raw, ok := m[metadataDestinationURL] + if !ok { + return ErrInvalidDestinationURL + } + var dest string + if err := json.Unmarshal(raw, &dest); err != nil { + return ErrInvalidDestinationURL + } + dest = strings.TrimSpace(dest) + if dest == "" { + return ErrInvalidDestinationURL + } + low := strings.ToLower(dest) + if !strings.HasPrefix(low, "http://") && + !strings.HasPrefix(low, "https://") { + return ErrInvalidDestinationURL + } + return nil +} + +func validateEnvfileMetadata(metadata json.RawMessage) error { + if len(metadata) == 0 { + return nil + } + var m map[string]json.RawMessage + if err := json.Unmarshal(metadata, &m); err != nil { + return fmt.Errorf("envfile metadata: %w", err) + } + raw, ok := m[metadataIncludeKeys] + if !ok { + return nil + } + var keys []string + if err := json.Unmarshal(raw, &keys); err != nil { + return ErrInvalidIncludeKeys + } + for _, k := range keys { + if _, ok := allowedIncludeKeys[k]; !ok { + return fmt.Errorf("%w: %q", ErrInvalidIncludeKeys, k) + } + } + return nil +} + +func generateTokenID() (string, error) { + out := make([]byte, tokenIDLength) + bigLen := big.NewInt(int64(len(tokenIDAlphabet))) + for i := range out { + idx, err := rand.Int(rand.Reader, bigLen) + if err != nil { + return "", fmt.Errorf("rand: %w", err) + } + out[i] = tokenIDAlphabet[idx.Int64()] + } + return string(out), nil +} + +func filenamePointer(s string) *string { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + return &s +} + +func optionalString(s string) *string { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + return &s +} + +func normalizeMetadata(metadata json.RawMessage) json.RawMessage { + if len(metadata) == 0 { + return json.RawMessage(`{}`) + } + return metadata +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/service_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/service_test.go new file mode 100644 index 00000000..34dd5dbd --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/service_test.go @@ -0,0 +1,495 @@ +// ©AngelaMos | 2026 +// service_test.go + +package token_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "sync" + "sync/atomic" + "testing" + + "github.com/jackc/pgx/v5/pgconn" + "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" +) + +type fakeRepo struct { + mu sync.Mutex + inserted []*token.Token + byID map[string]*token.Token + byManage map[string]*token.Token + insertErr error +} + +func newFakeRepo() *fakeRepo { + return &fakeRepo{ + byID: map[string]*token.Token{}, + byManage: map[string]*token.Token{}, + } +} + +func (f *fakeRepo) Insert(_ context.Context, t *token.Token) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.insertErr != nil { + return f.insertErr + } + f.inserted = append(f.inserted, t) + f.byID[t.ID] = t + f.byManage[t.ManageID] = t + return nil +} + +func (f *fakeRepo) GetByID(_ context.Context, id string) (*token.Token, error) { + f.mu.Lock() + defer f.mu.Unlock() + t, ok := f.byID[id] + if !ok { + return nil, token.ErrNotFound + } + return t, nil +} + +func (f *fakeRepo) GetByManageID( + _ context.Context, + manageID string, +) (*token.Token, error) { + f.mu.Lock() + defer f.mu.Unlock() + t, ok := f.byManage[manageID] + if !ok { + return nil, token.ErrNotFound + } + return t, nil +} + +func (f *fakeRepo) IncrementTriggerCount(_ context.Context, _ string) error { + return nil +} + +func (f *fakeRepo) DeleteByManageID( + _ context.Context, + manageID string, +) error { + f.mu.Lock() + defer f.mu.Unlock() + t, ok := f.byManage[manageID] + if !ok { + return token.ErrNotFound + } + delete(f.byManage, manageID) + delete(f.byID, t.ID) + return nil +} + +type fakeGenerator struct { + tokenType token.Type + artifact generators.Artifact + generateErr error + calls atomic.Int32 +} + +func (g *fakeGenerator) Type() token.Type { return g.tokenType } + +func (g *fakeGenerator) Generate( + _ context.Context, + _ *token.Token, + _ string, +) (generators.Artifact, error) { + g.calls.Add(1) + if g.generateErr != nil { + return generators.Artifact{}, g.generateErr + } + return g.artifact, nil +} + +func (g *fakeGenerator) Trigger( + _ context.Context, + _ *token.Token, + _ *http.Request, +) (*event.Event, *generators.TriggerResponse, error) { + return nil, nil, nil +} + +func newWebbugReq() token.CreateRequest { + return token.CreateRequest{ + Type: token.TypeWebbug, + Memo: "test", + AlertChannel: token.ChannelWebhook, + WebhookURL: "https://example.com/hook", + TurnstileResp: "stub-token", + } +} + +func TestService_Create_GeneratesValidIDAndManageID(t *testing.T) { + repo := newFakeRepo() + gen := &fakeGenerator{ + tokenType: token.TypeWebbug, + artifact: generators.Artifact{Kind: generators.KindURL, URL: "x"}, + } + svc := token.NewService( + repo, + token.MapRegistry{token.TypeWebbug: gen}, + token.ServiceConfig{ + BaseURL: "https://canary.example.com", + ManageURL: "https://canary.example.com", + }, + ) + + tok, _, err := svc.Create( + context.Background(), + newWebbugReq(), + "fp", + "1.2.3.4", + ) + require.NoError(t, err) + require.NotNil(t, tok) + + require.Regexp(t, `^[a-z0-9]{12}$`, tok.ID) + require.Regexp( + t, + `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, + tok.ManageID, + ) +} + +func TestService_Create_PersistsToRepo(t *testing.T) { + repo := newFakeRepo() + gen := &fakeGenerator{ + tokenType: token.TypeWebbug, + artifact: generators.Artifact{Kind: generators.KindURL}, + } + svc := token.NewService(repo, token.MapRegistry{token.TypeWebbug: gen}, + token.ServiceConfig{BaseURL: "https://canary.example.com"}) + + tok, _, err := svc.Create( + context.Background(), + newWebbugReq(), + "fp", + "1.2.3.4", + ) + require.NoError(t, err) + require.Len(t, repo.inserted, 1) + require.Equal(t, tok.ID, repo.inserted[0].ID) + require.Equal(t, "1.2.3.4", repo.inserted[0].CreatedIP) + require.Equal(t, "fp", repo.inserted[0].CreatedFP) + require.True(t, repo.inserted[0].Enabled) +} + +func TestService_Create_CallsGeneratorWithBaseURL(t *testing.T) { + repo := newFakeRepo() + gen := &fakeGenerator{ + tokenType: token.TypeWebbug, + artifact: generators.Artifact{Kind: generators.KindURL, URL: "x"}, + } + svc := token.NewService(repo, token.MapRegistry{token.TypeWebbug: gen}, + token.ServiceConfig{BaseURL: "https://canary.example.com"}) + + _, _, err := svc.Create( + context.Background(), + newWebbugReq(), + "fp", + "1.2.3.4", + ) + require.NoError(t, err) + require.Equal(t, int32(1), gen.calls.Load()) +} + +func TestService_Create_UnknownTypeReturnsError(t *testing.T) { + repo := newFakeRepo() + svc := token.NewService(repo, token.MapRegistry{}, + token.ServiceConfig{BaseURL: "https://canary.example.com"}) + + _, _, err := svc.Create( + context.Background(), + newWebbugReq(), + "fp", + "1.2.3.4", + ) + require.Error(t, err) + require.ErrorIs(t, err, token.ErrUnknownGeneratorType) +} + +func TestService_Create_ValidationFails(t *testing.T) { + repo := newFakeRepo() + gen := &fakeGenerator{tokenType: token.TypeWebbug} + svc := token.NewService(repo, token.MapRegistry{token.TypeWebbug: gen}, + token.ServiceConfig{BaseURL: "https://canary.example.com"}) + + req := newWebbugReq() + req.AlertChannel = "" + _, _, err := svc.Create(context.Background(), req, "fp", "1.2.3.4") + require.Error(t, err) +} + +func TestService_Create_SlowredirectMissingDestinationFails(t *testing.T) { + repo := newFakeRepo() + gen := &fakeGenerator{tokenType: token.TypeSlowRedirect} + svc := token.NewService( + repo, + token.MapRegistry{token.TypeSlowRedirect: gen}, + token.ServiceConfig{BaseURL: "https://canary.example.com"}, + ) + + req := newWebbugReq() + req.Type = token.TypeSlowRedirect + _, _, err := svc.Create(context.Background(), req, "fp", "1.2.3.4") + require.Error(t, err) + require.ErrorIs(t, err, token.ErrInvalidDestinationURL) +} + +func TestService_Create_SlowredirectInvalidSchemeFails(t *testing.T) { + repo := newFakeRepo() + gen := &fakeGenerator{tokenType: token.TypeSlowRedirect} + svc := token.NewService( + repo, + token.MapRegistry{token.TypeSlowRedirect: gen}, + token.ServiceConfig{BaseURL: "https://canary.example.com"}, + ) + + req := newWebbugReq() + req.Type = token.TypeSlowRedirect + req.Metadata = json.RawMessage(`{"destination_url":"javascript:alert(1)"}`) + _, _, err := svc.Create(context.Background(), req, "fp", "1.2.3.4") + require.ErrorIs(t, err, token.ErrInvalidDestinationURL) +} + +func TestService_Create_SlowredirectValidURLSucceeds(t *testing.T) { + repo := newFakeRepo() + gen := &fakeGenerator{ + tokenType: token.TypeSlowRedirect, + artifact: generators.Artifact{Kind: generators.KindURL}, + } + svc := token.NewService( + repo, + token.MapRegistry{token.TypeSlowRedirect: gen}, + token.ServiceConfig{BaseURL: "https://canary.example.com"}, + ) + + req := newWebbugReq() + req.Type = token.TypeSlowRedirect + req.Metadata = json.RawMessage(`{"destination_url":"https://example.com"}`) + _, _, err := svc.Create(context.Background(), req, "fp", "1.2.3.4") + require.NoError(t, err) +} + +func TestService_Create_EnvfileInvalidKeyFails(t *testing.T) { + repo := newFakeRepo() + gen := &fakeGenerator{ + tokenType: token.TypeEnvfile, + artifact: generators.Artifact{Kind: generators.KindText}, + } + svc := token.NewService(repo, token.MapRegistry{token.TypeEnvfile: gen}, + token.ServiceConfig{BaseURL: "https://canary.example.com"}) + + req := newWebbugReq() + req.Type = token.TypeEnvfile + req.Metadata = json.RawMessage(`{"include_keys":["aws","nonexistent"]}`) + _, _, err := svc.Create(context.Background(), req, "fp", "1.2.3.4") + require.ErrorIs(t, err, token.ErrInvalidIncludeKeys) +} + +func TestService_Create_EnvfileValidKeysSucceeds(t *testing.T) { + repo := newFakeRepo() + gen := &fakeGenerator{ + tokenType: token.TypeEnvfile, + artifact: generators.Artifact{Kind: generators.KindText}, + } + svc := token.NewService(repo, token.MapRegistry{token.TypeEnvfile: gen}, + token.ServiceConfig{BaseURL: "https://canary.example.com"}) + + req := newWebbugReq() + req.Type = token.TypeEnvfile + req.Metadata = json.RawMessage(`{"include_keys":["aws","stripe"]}`) + _, _, err := svc.Create(context.Background(), req, "fp", "1.2.3.4") + require.NoError(t, err) +} + +func TestService_Create_GeneratorErrorPropagates(t *testing.T) { + repo := newFakeRepo() + gen := &fakeGenerator{ + tokenType: token.TypeWebbug, + generateErr: errors.New("generate failed"), + } + svc := token.NewService(repo, token.MapRegistry{token.TypeWebbug: gen}, + token.ServiceConfig{BaseURL: "https://canary.example.com"}) + + _, _, err := svc.Create( + context.Background(), + newWebbugReq(), + "fp", + "1.2.3.4", + ) + require.ErrorIs(t, err, token.ErrGenerateFailed) + require.Empty(t, repo.inserted, "must not persist if generator failed") +} + +func TestService_Create_DistinctIDsAcrossCalls(t *testing.T) { + repo := newFakeRepo() + gen := &fakeGenerator{ + tokenType: token.TypeWebbug, + artifact: generators.Artifact{Kind: generators.KindURL}, + } + svc := token.NewService(repo, token.MapRegistry{token.TypeWebbug: gen}, + token.ServiceConfig{BaseURL: "https://canary.example.com"}) + + seen := make(map[string]struct{}) + for range 30 { + tok, _, err := svc.Create( + context.Background(), + newWebbugReq(), + "fp", + "1.2.3.4", + ) + require.NoError(t, err) + seen[tok.ID] = struct{}{} + } + require.Greater( + t, + len(seen), + 28, + "ID generation must be sufficiently random", + ) +} + +type collisionRepo struct { + mu sync.Mutex + collisions int + remaining int + inserted []*token.Token + byID map[string]*token.Token +} + +func newCollisionRepo(collisions int) *collisionRepo { + return &collisionRepo{ + remaining: collisions, + byID: map[string]*token.Token{}, + } +} + +func (r *collisionRepo) Insert(_ context.Context, t *token.Token) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.remaining > 0 { + r.remaining-- + r.collisions++ + return &pgconn.PgError{Code: "23505"} + } + r.inserted = append(r.inserted, t) + r.byID[t.ID] = t + return nil +} + +func (r *collisionRepo) GetByID( + _ context.Context, id string, +) (*token.Token, error) { + r.mu.Lock() + defer r.mu.Unlock() + t, ok := r.byID[id] + if !ok { + return nil, token.ErrNotFound + } + return t, nil +} + +func (r *collisionRepo) GetByManageID( + _ context.Context, _ string, +) (*token.Token, error) { + return nil, token.ErrNotFound +} + +func (r *collisionRepo) IncrementTriggerCount( + _ context.Context, _ string, +) error { + return nil +} + +func (r *collisionRepo) DeleteByManageID( + _ context.Context, _ string, +) error { + return token.ErrNotFound +} + +func TestService_Create_RetriesOnTokenIDCollision(t *testing.T) { + repo := newCollisionRepo(2) + gen := &fakeGenerator{ + tokenType: token.TypeWebbug, + artifact: generators.Artifact{ + Kind: generators.KindURL, + URL: "https://canary.example.com/c/x", + }, + } + svc := token.NewService( + repo, + token.MapRegistry{token.TypeWebbug: gen}, + token.ServiceConfig{BaseURL: "https://canary.example.com"}, + ) + + tok, _, err := svc.Create(context.Background(), token.CreateRequest{ + Type: token.TypeWebbug, + Memo: "x", + AlertChannel: token.ChannelWebhook, + WebhookURL: "https://example.com/h", + }, "fp", "ip") + require.NoError(t, err) + require.NotNil(t, tok) + require.Equal(t, 2, repo.collisions, + "repo must have rejected exactly two prior IDs") + require.Len(t, repo.inserted, 1, "exactly one token persisted") + require.Equal(t, int32(3), gen.calls.Load(), + "generator called once per attempt (regenerates artifact per id)") +} + +func TestService_Create_GivesUpAfterMaxCollisions(t *testing.T) { + repo := newCollisionRepo(10) + gen := &fakeGenerator{ + tokenType: token.TypeWebbug, + artifact: generators.Artifact{ + Kind: generators.KindURL, + URL: "https://canary.example.com/c/x", + }, + } + svc := token.NewService( + repo, + token.MapRegistry{token.TypeWebbug: gen}, + token.ServiceConfig{BaseURL: "https://canary.example.com"}, + ) + + _, _, err := svc.Create(context.Background(), token.CreateRequest{ + Type: token.TypeWebbug, + Memo: "x", + AlertChannel: token.ChannelWebhook, + WebhookURL: "https://example.com/h", + }, "fp", "ip") + require.Error(t, err) + require.Contains(t, err.Error(), "id-collision retries") +} + +func TestService_GetByID_NotFoundReturnsNilNil(t *testing.T) { + repo := newFakeRepo() + svc := token.NewService(repo, token.MapRegistry{}, + token.ServiceConfig{BaseURL: "https://canary.example.com"}) + + tok, err := svc.GetByID(context.Background(), "nope") + require.NoError(t, err) + require.Nil(t, tok) +} + +func TestService_TriggerURL(t *testing.T) { + svc := token.NewService(newFakeRepo(), token.MapRegistry{}, + token.ServiceConfig{BaseURL: "https://canary.example.com/"}) + require.Equal(t, "https://canary.example.com/c/abc", svc.TriggerURL("abc")) +} + +func TestService_ManageURL(t *testing.T) { + svc := token.NewService(newFakeRepo(), token.MapRegistry{}, + token.ServiceConfig{ManageURL: "https://canary.example.com/"}) + require.Equal(t, "https://canary.example.com/m/uuid", svc.ManageURL("uuid")) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/types.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/types.go new file mode 100644 index 00000000..c7ba6af0 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/types.go @@ -0,0 +1,73 @@ +// ©AngelaMos | 2026 +// types.go + +package token + +type TypeDescriptor struct { + Type Type `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + ArtifactKind string `json:"artifact_kind"` + Enabled bool `json:"enabled"` + DisabledReason string `json:"disabled_reason,omitempty"` +} + +const mysqlDisabledReason = "Requires direct TCP exposure (port 3306). Not reachable via Cloudflare Tunnel — only enable on a VPS with raw TCP access." + +func TypeDescriptors(mysqlEnabled bool) []TypeDescriptor { + descriptors := []TypeDescriptor{ + { + Type: TypeWebbug, + Name: "Web Bug Pixel", + Description: "1x1 transparent GIF that fires when fetched. Embed in HTML emails or web pages.", + ArtifactKind: string(KindURL), + Enabled: true, + }, + { + Type: TypeSlowRedirect, + Name: "Slow Redirect", + Description: "Browser-fingerprinting page that redirects to a destination URL after collecting client metadata.", + ArtifactKind: string(KindURL), + Enabled: true, + }, + { + Type: TypeDocx, + Name: "Microsoft Word Document", + Description: "DOCX with an embedded INCLUDEPICTURE field that calls home when the document opens.", + ArtifactKind: string(KindFile), + Enabled: true, + }, + { + Type: TypePDF, + Name: "PDF Document", + Description: "PDF with an /AA open-action URI that fires in Adobe Acrobat Reader.", + ArtifactKind: string(KindFile), + Enabled: true, + }, + { + Type: TypeKubeconfig, + Name: "Kubernetes Config", + Description: "kubeconfig pointing kubectl at a fake K8s API server that records every request.", + ArtifactKind: string(KindText), + Enabled: true, + }, + { + Type: TypeEnvfile, + Name: ".env File", + Description: "Plausible production .env with shuffled bait credentials and an embedded canary URL.", + ArtifactKind: string(KindText), + Enabled: true, + }, + { + Type: TypeMySQL, + Name: "MySQL Connection String", + Description: "Fake MySQL endpoint that records any authentication attempt.", + ArtifactKind: string(KindConnectionString), + Enabled: mysqlEnabled, + }, + } + if !mysqlEnabled { + descriptors[len(descriptors)-1].DisabledReason = mysqlDisabledReason + } + return descriptors +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/turnstile/verifier.go b/PROJECTS/beginner/canary-token-generator/backend/internal/turnstile/verifier.go new file mode 100644 index 00000000..3e63724d --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/turnstile/verifier.go @@ -0,0 +1,148 @@ +// ©AngelaMos | 2026 +// verifier.go + +package turnstile + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + "github.com/redis/go-redis/v9" +) + +const ( + DefaultVerifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify" + + cacheKeyPrefix = "turnstile:fp:" + cacheTTL = 5 * time.Minute + verifyTimeout = 10 * time.Second + cachedSuccessFlag = "ok" + + contentTypeForm = "application/x-www-form-urlencoded" +) + +var ( + ErrVerifyFailed = errors.New("turnstile: verification failed") + ErrEmptyToken = errors.New("turnstile: empty response token") +) + +type RedisClient interface { + Get(ctx context.Context, key string) *redis.StringCmd + SetEx( + ctx context.Context, + key string, + value any, + expiration time.Duration, + ) *redis.StatusCmd +} + +type Config struct { + SecretKey string + VerifyURL string + Client *http.Client +} + +type Verifier struct { + secret string + verifyURL string + client *http.Client + rdb RedisClient +} + +func NewVerifier(cfg Config, rdb RedisClient) *Verifier { + client := cfg.Client + if client == nil { + client = &http.Client{Timeout: verifyTimeout} + } + verifyURL := cfg.VerifyURL + if verifyURL == "" { + verifyURL = DefaultVerifyURL + } + return &Verifier{ + secret: cfg.SecretKey, + verifyURL: verifyURL, + client: client, + rdb: rdb, + } +} + +func (v *Verifier) Verify( + ctx context.Context, + token, fingerprint string, +) error { + if v.secret == "" { + return nil + } + if strings.TrimSpace(token) == "" { + return ErrEmptyToken + } + + cacheKey := cacheKeyPrefix + fingerprint + if v.rdb != nil { + if cached, err := v.rdb.Get(ctx, cacheKey).Result(); err == nil && + cached == cachedSuccessFlag { + return nil + } + } + + if err := v.callSiteverify(ctx, token); err != nil { + return err + } + + if v.rdb != nil { + if cErr := v.rdb.SetEx( + ctx, + cacheKey, + cachedSuccessFlag, + cacheTTL, + ).Err(); cErr != nil { + slog.WarnContext(ctx, "turnstile: cache set", + "error", cErr, "fingerprint", fingerprint) + } + } + return nil +} + +func (v *Verifier) callSiteverify(ctx context.Context, token string) error { + form := url.Values{"secret": {v.secret}, "response": {token}} + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + v.verifyURL, + strings.NewReader(form.Encode()), + ) + if err != nil { + return fmt.Errorf("turnstile: build request: %w", err) + } + req.Header.Set("Content-Type", contentTypeForm) + + resp, err := v.client.Do(req) + if err != nil { + return fmt.Errorf("turnstile: call siteverify: %w", err) + } + defer func() { + if cErr := resp.Body.Close(); cErr != nil { + slog.WarnContext(ctx, "turnstile: close body", + "error", cErr) + } + }() + + var body struct { + Success bool `json:"success"` + ErrorCodes []string `json:"error-codes"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return fmt.Errorf("turnstile: decode response: %w", err) + } + if !body.Success { + return fmt.Errorf("%w: %v", ErrVerifyFailed, body.ErrorCodes) + } + return nil +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/turnstile/verifier_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/turnstile/verifier_test.go new file mode 100644 index 00000000..730a4800 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/turnstile/verifier_test.go @@ -0,0 +1,161 @@ +// ©AngelaMos | 2026 +// verifier_test.go + +package turnstile_test + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/turnstile" +) + +type fakeRedis struct { + store map[string]string +} + +func (f *fakeRedis) Get(_ context.Context, key string) *redis.StringCmd { + cmd := redis.NewStringCmd(context.Background()) + if v, ok := f.store[key]; ok { + cmd.SetVal(v) + } else { + cmd.SetErr(redis.Nil) + } + return cmd +} + +func (f *fakeRedis) SetEx( + _ context.Context, + key string, + value any, + _ time.Duration, +) *redis.StatusCmd { + cmd := redis.NewStatusCmd(context.Background()) + if f.store == nil { + f.store = make(map[string]string) + } + if s, ok := value.(string); ok { + f.store[key] = s + } + cmd.SetVal("OK") + return cmd +} + +func startMockSiteverify( + t *testing.T, + success bool, + errorCodes []string, +) (string, *atomic.Int32) { + t.Helper() + var hits atomic.Int32 + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.Header().Set("Content-Type", "application/json") + var body string + if success { + body = `{"success":true}` + } else { + body = `{"success":false,"error-codes":[` + for i, c := range errorCodes { + if i > 0 { + body += "," + } + body += `"` + c + `"` + } + body += `]}` + } + if _, err := w.Write([]byte(body)); err != nil { + t.Errorf("write mock: %v", err) + } + }), + ) + t.Cleanup(srv.Close) + return srv.URL, &hits +} + +func TestVerifier_EmptySecretBypasses(t *testing.T) { + v := turnstile.NewVerifier(turnstile.Config{}, &fakeRedis{}) + require.NoError(t, v.Verify(context.Background(), "irrelevant", "fp")) +} + +func TestVerifier_EmptyTokenReturnsError(t *testing.T) { + v := turnstile.NewVerifier(turnstile.Config{SecretKey: "s"}, &fakeRedis{}) + err := v.Verify(context.Background(), " ", "fp") + require.ErrorIs(t, err, turnstile.ErrEmptyToken) +} + +func TestVerifier_SuccessCachesByFingerprint(t *testing.T) { + url, hits := startMockSiteverify(t, true, nil) + r := &fakeRedis{} + v := turnstile.NewVerifier(turnstile.Config{ + SecretKey: "secret", + VerifyURL: url, + }, r) + + require.NoError(t, v.Verify(context.Background(), "tok", "fp1")) + require.Equal(t, int32(1), hits.Load()) + + require.NoError(t, v.Verify(context.Background(), "tok", "fp1")) + require.Equal( + t, + int32(1), + hits.Load(), + "cache hit must skip siteverify call", + ) +} + +func TestVerifier_CacheMissForDifferentFingerprint(t *testing.T) { + url, hits := startMockSiteverify(t, true, nil) + v := turnstile.NewVerifier(turnstile.Config{ + SecretKey: "secret", + VerifyURL: url, + }, &fakeRedis{}) + + require.NoError(t, v.Verify(context.Background(), "tok", "fp1")) + require.NoError(t, v.Verify(context.Background(), "tok", "fp2")) + require.Equal(t, int32(2), hits.Load()) +} + +func TestVerifier_FailedSiteverifyReturnsErrVerifyFailed(t *testing.T) { + url, _ := startMockSiteverify(t, false, []string{"invalid-input-response"}) + v := turnstile.NewVerifier(turnstile.Config{ + SecretKey: "secret", + VerifyURL: url, + }, &fakeRedis{}) + + err := v.Verify(context.Background(), "tok", "fp") + require.Error(t, err) + require.ErrorIs(t, err, turnstile.ErrVerifyFailed) +} + +func TestVerifier_NilRedisStillWorks(t *testing.T) { + url, hits := startMockSiteverify(t, true, nil) + v := turnstile.NewVerifier(turnstile.Config{ + SecretKey: "secret", + VerifyURL: url, + }, nil) + + require.NoError(t, v.Verify(context.Background(), "tok", "fp")) + require.NoError(t, v.Verify(context.Background(), "tok", "fp")) + require.Equal(t, int32(2), hits.Load(), "without redis there is no caching") +} + +func TestVerifier_NetworkErrorIsWrapped(t *testing.T) { + v := turnstile.NewVerifier(turnstile.Config{ + SecretKey: "secret", + VerifyURL: "http://127.0.0.1:1/never", + Client: &http.Client{Timeout: 100 * time.Millisecond}, + }, &fakeRedis{}) + + err := v.Verify(context.Background(), "tok", "fp") + require.Error(t, err) + require.NotErrorIs(t, err, turnstile.ErrVerifyFailed) +} diff --git a/PROJECTS/beginner/canary-token-generator/cloudflared.compose.yml b/PROJECTS/beginner/canary-token-generator/cloudflared.compose.yml new file mode 100644 index 00000000..15da674d --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/cloudflared.compose.yml @@ -0,0 +1,27 @@ +# ============================================================================= +# ©AngelaMos | 2026 +# cloudflared.compose.yml +# ============================================================================= +# Cloudflare Tunnel for production remote access +# Usage: docker compose -f compose.yml -f cloudflared.compose.yml up -d +# ============================================================================= + +services: + cloudflared: + image: cloudflare/cloudflared:latest + container_name: ${APP_NAME:-canary-token-generator}-tunnel + command: tunnel run --token ${CLOUDFLARE_TUNNEL_TOKEN} + networks: + - app + depends_on: + nginx: + condition: service_started + deploy: + resources: + limits: + cpus: '0.5' + memory: 128M + reservations: + cpus: '0.1' + memory: 32M + restart: unless-stopped diff --git a/PROJECTS/beginner/canary-token-generator/compose.yml b/PROJECTS/beginner/canary-token-generator/compose.yml new file mode 100644 index 00000000..80b5b70e --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/compose.yml @@ -0,0 +1,131 @@ +# ============================================================================= +# ©AngelaMos | 2026 +# compose.yml +# ============================================================================= +# Production compose: nginx → canary backend → postgres + redis +# Cloudflare tunnel overlay: +# docker compose -f compose.yml -f cloudflared.compose.yml up -d +# ============================================================================= + +name: ${APP_NAME:-canary-token-generator} + +services: + nginx: + build: + context: . + dockerfile: infra/docker/vite.prod + args: + - VITE_API_URL=${VITE_API_URL:-/api} + - VITE_APP_TITLE=${VITE_APP_TITLE:-Canary Token Generator} + - VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY:-} + container_name: ${APP_NAME:-canary-token-generator}-nginx + ports: + - "${NGINX_HOST_PORT:-22784}:80" + networks: + - app + depends_on: + canary: + condition: service_healthy + deploy: + resources: + limits: + cpus: '1.0' + memory: 256M + reservations: + cpus: '0.25' + memory: 64M + restart: unless-stopped + + canary: + build: + context: ./backend + dockerfile: ../infra/docker/canary.prod + container_name: ${APP_NAME:-canary-token-generator}-canary + environment: + - APP_ENVIRONMENT=production + - DATABASE_URL=postgres://canary:${POSTGRES_PASSWORD}@postgres:5432/canary?sslmode=disable + - REDIS_URL=redis://redis:6379/0 + - PUBLIC_BASE_URL=${PUBLIC_BASE_URL} + - TURNSTILE_SECRET=${TURNSTILE_SECRET} + - OPERATOR_TOKEN=${OPERATOR_TOKEN} + - GEOLITE_PATH=/data/GeoLite2-City.mmdb + - WEBHOOK_HMAC_SECRET=${WEBHOOK_HMAC_SECRET:-} + - MYSQL_FAKE_ENABLED=${MYSQL_FAKE_ENABLED:-false} + - LOG_LEVEL=${LOG_LEVEL:-info} + - LOG_FORMAT=${LOG_FORMAT:-json} + - OTEL_ENABLED=${OTEL_ENABLED:-false} + - OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-} + volumes: + - geolite:/data:ro + networks: + - app + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "/healthcheck"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 15s + deploy: + resources: + limits: + cpus: '1.0' + memory: 256M + reservations: + cpus: '0.25' + memory: 64M + restart: unless-stopped + + postgres: + image: postgres:18-alpine + container_name: ${APP_NAME:-canary-token-generator}-postgres + environment: + POSTGRES_USER: canary + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: canary + volumes: + - pgdata:/var/lib/postgresql/data + networks: + - app + healthcheck: + test: ["CMD-SHELL", "pg_isready -U canary -d canary"] + interval: 5s + timeout: 5s + retries: 5 + deploy: + resources: + limits: + memory: 512M + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: ${APP_NAME:-canary-token-generator}-redis + command: redis-server --appendonly yes + volumes: + - redisdata:/data + networks: + - app + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + deploy: + resources: + limits: + memory: 128M + restart: unless-stopped + +networks: + app: + driver: bridge + +volumes: + pgdata: + redisdata: + geolite: diff --git a/PROJECTS/beginner/canary-token-generator/dev.compose.yml b/PROJECTS/beginner/canary-token-generator/dev.compose.yml new file mode 100644 index 00000000..a4054b44 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/dev.compose.yml @@ -0,0 +1,146 @@ +# ============================================================================= +# ©AngelaMos | 2026 +# dev.compose.yml +# ============================================================================= +# Development compose: nginx → vite (HMR) + canary (Air hot-reload) + Postgres + Redis + Jaeger +# Uses .env.development +# ============================================================================= + +name: ${APP_NAME:-canary-token-generator}-dev + +services: + nginx: + image: nginx:1.27-alpine + container_name: ${APP_NAME:-canary-token-generator}-nginx-dev + ports: + - "${NGINX_HOST_PORT:-22784}:80" + volumes: + - ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./infra/nginx/dev.nginx:/etc/nginx/conf.d/default.conf:ro + depends_on: + frontend: + condition: service_started + canary: + condition: service_healthy + networks: + - app + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: ../infra/docker/vite.dev + container_name: ${APP_NAME:-canary-token-generator}-frontend-dev + ports: + - "${FRONTEND_HOST_PORT:-15723}:5173" + volumes: + - ./frontend:/app + - frontend_modules:/app/node_modules + environment: + - VITE_API_URL=${VITE_API_URL:-/api} + - VITE_API_TARGET=${VITE_API_TARGET:-http://canary:8080} + - VITE_APP_TITLE=${VITE_APP_TITLE:-Canary Token Generator} + - VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY:-} + networks: + - app + restart: unless-stopped + + canary: + build: + context: ./backend + dockerfile: ../infra/docker/canary.dev + container_name: ${APP_NAME:-canary-token-generator}-canary-dev + volumes: + - ./backend:/app + - canary_gocache:/root/.cache/go-build + - canary_gomodcache:/go/pkg/mod + environment: + - APP_ENVIRONMENT=development + - DATABASE_URL=postgres://canary:canary@postgres:5432/canary?sslmode=disable + - REDIS_URL=redis://redis:6379/0 + - PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://localhost:58495} + - TURNSTILE_SECRET=${TURNSTILE_SECRET:-} + - OPERATOR_TOKEN=${OPERATOR_TOKEN:-dev-operator-token} + - GEOLITE_PATH=${GEOLITE_PATH:-/app/testdata/GeoLite2-City.mmdb} + - WEBHOOK_HMAC_SECRET=${WEBHOOK_HMAC_SECRET:-} + - MYSQL_FAKE_ENABLED=${MYSQL_FAKE_ENABLED:-false} + - LOG_LEVEL=debug + - LOG_FORMAT=text + - OTEL_ENABLED=${OTEL_ENABLED:-true} + - OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317 + - OTEL_INSECURE=true + networks: + - app + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped + + postgres: + image: postgres:18-alpine + container_name: ${APP_NAME:-canary-token-generator}-postgres-dev + ports: + - "${POSTGRES_DEV_PORT:-5447}:5432" + environment: + POSTGRES_USER: canary + POSTGRES_PASSWORD: canary + POSTGRES_DB: canary + volumes: + - pgdata_dev:/var/lib/postgresql/data + networks: + - app + healthcheck: + test: ["CMD-SHELL", "pg_isready -U canary -d canary"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: ${APP_NAME:-canary-token-generator}-redis-dev + ports: + - "${REDIS_DEV_PORT:-6022}:6379" + command: redis-server --appendonly yes + volumes: + - redisdata_dev:/data + networks: + - app + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + + jaeger: + image: jaegertracing/all-in-one:1.54 + container_name: ${APP_NAME:-canary-token-generator}-jaeger-dev + ports: + - "${JAEGER_UI_PORT:-16686}:16686" + - "${JAEGER_OTLP_GRPC_PORT:-4317}:4317" + - "${JAEGER_OTLP_HTTP_PORT:-4318}:4318" + environment: + COLLECTOR_OTLP_ENABLED: "true" + networks: + - app + restart: unless-stopped + +networks: + app: + driver: bridge + +volumes: + frontend_modules: + pgdata_dev: + redisdata_dev: + canary_gocache: + canary_gomodcache: diff --git a/PROJECTS/beginner/canary-token-generator/frontend/.dockerignore b/PROJECTS/beginner/canary-token-generator/frontend/.dockerignore new file mode 100644 index 00000000..a0256ec7 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/.dockerignore @@ -0,0 +1,15 @@ +node_modules +build +dist +.git +.gitignore +*.md +.env* +.vscode +.idea +*.log +npm-debug.log* +pnpm-debug.log* +.DS_Store +coverage +.nyc_output diff --git a/PROJECTS/beginner/canary-token-generator/frontend/.gitignore b/PROJECTS/beginner/canary-token-generator/frontend/.gitignore new file mode 100644 index 00000000..61cb0c2e --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +.vite + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/PROJECTS/beginner/canary-token-generator/frontend/.npmrc b/PROJECTS/beginner/canary-token-generator/frontend/.npmrc new file mode 100644 index 00000000..1f80f77c --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/.npmrc @@ -0,0 +1,3 @@ +# ©AngelaMos | 2026 +# .npmrc +strict-dep-builds=false diff --git a/PROJECTS/beginner/canary-token-generator/frontend/.stylelintignore b/PROJECTS/beginner/canary-token-generator/frontend/.stylelintignore new file mode 100644 index 00000000..1ae0ac7c --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/.stylelintignore @@ -0,0 +1,22 @@ +# ©AngelaMos | 2026 +# .stylelintignore + +# Dependencies +node_modules/ + +# Production builds +dist/ +build/ +out/ + +# JS/TS files +**/*.js +**/*.jsx +**/*.ts +**/*.tsx + +# Generated files +*.min.css + +# Error system styles - ignore from linting +src/core/app/_toastStyles.scss diff --git a/PROJECTS/beginner/canary-token-generator/frontend/biome.json b/PROJECTS/beginner/canary-token-generator/frontend/biome.json new file mode 100644 index 00000000..7f29029a --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/biome.json @@ -0,0 +1,94 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 82, + "lineEnding": "lf" + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "jsxQuoteStyle": "double", + "semicolons": "asNeeded", + "trailingCommas": "es5", + "arrowParentheses": "always" + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "complexity": { + "noExcessiveCognitiveComplexity": { + "level": "error", + "options": { "maxAllowedComplexity": 25 } + }, + "noForEach": "off", + "useLiteralKeys": "off" + }, + "correctness": { + "noUnusedVariables": "error", + "noUnusedImports": "error", + "useExhaustiveDependencies": "warn", + "useHookAtTopLevel": "error", + "noUndeclaredVariables": "error" + }, + "style": { + "useImportType": "error", + "useConst": "error", + "useTemplate": "error", + "useSelfClosingElements": "error", + "useFragmentSyntax": "error", + "noNonNullAssertion": "error", + "useConsistentArrayType": { + "level": "error", + "options": { "syntax": "shorthand" } + }, + "useNamingConvention": "off" + }, + "suspicious": { + "noExplicitAny": "error", + "noDebugger": "error", + "noConsole": "warn", + "noArrayIndexKey": "warn", + "noAssignInExpressions": "error", + "noDoubleEquals": "error", + "noRedeclare": "error", + "noVar": "error" + }, + "security": { + "noDangerouslySetInnerHtml": "error" + }, + "a11y": { + "useAltText": "error", + "useAnchorContent": "error", + "useKeyWithClickEvents": "error", + "noStaticElementInteractions": "error", + "useButtonType": "error", + "useValidAnchor": "error" + } + } + }, + "overrides": [ + { + "includes": ["src/main.tsx"], + "linter": { + "rules": { + "style": { + "noNonNullAssertion": "off" + } + } + } + } + ] +} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/index.html b/PROJECTS/beginner/canary-token-generator/frontend/index.html new file mode 100644 index 00000000..db375a7c --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/index.html @@ -0,0 +1,57 @@ + + + + + + + + + + Canary Token Generator + + + + + + + +
+ + + diff --git a/PROJECTS/beginner/canary-token-generator/frontend/package.json b/PROJECTS/beginner/canary-token-generator/frontend/package.json new file mode 100644 index 00000000..3558eea7 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/package.json @@ -0,0 +1,55 @@ +{ + "name": "canary-token-generator", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write .", + "typecheck": "tsc -b", + "lint:scss": "stylelint '**/*.scss'", + "lint:scss:fix": "stylelint '**/*.scss' --fix" + }, + "dependencies": { + "@tanstack/react-query": "^5.90.12", + "axios": "^1.13.0", + "react": "^19.2.1", + "react-dom": "^19.2.0", + "react-error-boundary": "^6.0.0", + "react-icon": "^1.0.0", + "react-icons": "^5.5.0", + "react-router-dom": "^7.1.1", + "sonner": "^2.0.7", + "zod": "^4.1.13", + "zustand": "^5.0.9" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.8", + "@tanstack/react-query-devtools": "^5.91.1", + "@types/node": "^24.10.2", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "sass": "^1.95.0", + "stylelint": "^16.26.1", + "stylelint-config-prettier-scss": "^1.0.0", + "stylelint-config-standard-scss": "^16.0.0", + "typescript": "~5.9.3", + "vite": "npm:rolldown-vite@7.2.5", + "vite-tsconfig-paths": "^5.1.0" + }, + "pnpm": { + "overrides": { + "vite": "npm:rolldown-vite@7.2.5" + }, + "onlyBuiltDependencies": [ + "@parcel/watcher", + "core-js", + "esbuild" + ] + } +} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/pnpm-lock.yaml b/PROJECTS/beginner/canary-token-generator/frontend/pnpm-lock.yaml new file mode 100644 index 00000000..d04a7163 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/pnpm-lock.yaml @@ -0,0 +1,2603 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + vite: npm:rolldown-vite@7.2.5 + +importers: + + .: + dependencies: + '@tanstack/react-query': + specifier: ^5.90.12 + version: 5.90.12(react@19.2.1) + axios: + specifier: ^1.13.0 + version: 1.13.2 + react: + specifier: ^19.2.1 + version: 19.2.1 + react-dom: + specifier: ^19.2.0 + version: 19.2.1(react@19.2.1) + react-error-boundary: + specifier: ^6.0.0 + version: 6.0.0(react@19.2.1) + react-icon: + specifier: ^1.0.0 + version: 1.0.0(babel-runtime@5.8.38)(react@19.2.1) + react-icons: + specifier: ^5.5.0 + version: 5.5.0(react@19.2.1) + react-router-dom: + specifier: ^7.1.1 + version: 7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + zod: + specifier: ^4.1.13 + version: 4.1.13 + zustand: + specifier: ^5.0.9 + version: 5.0.9(@types/react@19.2.7)(react@19.2.1) + devDependencies: + '@biomejs/biome': + specifier: ^2.3.8 + version: 2.3.8 + '@tanstack/react-query-devtools': + specifier: ^5.91.1 + version: 5.91.1(@tanstack/react-query@5.90.12(react@19.2.1))(react@19.2.1) + '@types/node': + specifier: ^24.10.2 + version: 24.10.2 + '@types/react': + specifier: ^19.2.7 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.7) + '@vitejs/plugin-react': + specifier: ^5.1.1 + version: 5.1.2(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0)) + sass: + specifier: ^1.95.0 + version: 1.95.0 + stylelint: + specifier: ^16.26.1 + version: 16.26.1(typescript@5.9.3) + stylelint-config-prettier-scss: + specifier: ^1.0.0 + version: 1.0.0(stylelint@16.26.1(typescript@5.9.3)) + stylelint-config-standard-scss: + specifier: ^16.0.0 + version: 16.0.0(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + vite: + specifier: npm:rolldown-vite@7.2.5 + version: rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0) + vite-tsconfig-paths: + specifier: ^5.1.0 + version: 5.1.4(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0))(typescript@5.9.3) + +packages: + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.5': + resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.5': + resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.5': + resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + + '@biomejs/biome@2.3.8': + resolution: {integrity: sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.3.8': + resolution: {integrity: sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.3.8': + resolution: {integrity: sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.3.8': + resolution: {integrity: sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.3.8': + resolution: {integrity: sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.3.8': + resolution: {integrity: sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.3.8': + resolution: {integrity: sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.3.8': + resolution: {integrity: sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.3.8': + resolution: {integrity: sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@cacheable/memory@2.0.6': + resolution: {integrity: sha512-7e8SScMocHxcAb8YhtkbMhGG+EKLRIficb1F5sjvhSYsWTZGxvg4KIDp8kgxnV2PUJ3ddPe6J9QESjKvBWRDkg==} + + '@cacheable/utils@2.3.2': + resolution: {integrity: sha512-8kGE2P+HjfY8FglaOiW+y8qxcaQAfAhVML+i66XJR3YX5FtyDqn6Txctr3K2FrbxLKixRRYYBWMbuGciOhYNDg==} + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.20': + resolution: {integrity: sha512-8BHsjXfSciZxjmHQOuVdW2b8WLUPts9a+mfL13/PzEviufUEW2xnvQuOlKs9dRBHgRqJ53SF/DUoK9+MZk72oQ==} + engines: {node: '>=18'} + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@csstools/media-query-list-parser@4.0.3': + resolution: {integrity: sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/selector-specificity@5.0.0': + resolution: {integrity: sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==} + engines: {node: '>=18'} + peerDependencies: + postcss-selector-parser: ^7.0.0 + + '@dual-bundle/import-meta-resolve@4.2.1': + resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==} + + '@emnapi/core@1.7.1': + resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} + + '@emnapi/runtime@1.7.1': + resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@keyv/bigmap@1.3.0': + resolution: {integrity: sha512-KT01GjzV6AQD5+IYrcpoYLkCu1Jod3nau1Z7EsEuViO3TZGRacSbO9MfHmbJ1WaOXFtWLxPVj169cn2WNKPkIg==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.5.4 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@napi-rs/wasm-runtime@1.1.0': + resolution: {integrity: sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oxc-project/runtime@0.97.0': + resolution: {integrity: sha512-yH0zw7z+jEws4dZ4IUKoix5Lh3yhqIJWF9Dc8PWvhpo7U7O+lJrv7ZZL4BeRO0la8LBQFwcCewtLBnVV7hPe/w==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@oxc-project/types@0.97.0': + resolution: {integrity: sha512-lxmZK4xFrdvU0yZiDwgVQTCvh2gHWBJCBk5ALsrtsBWhs0uDIi+FTOnXRQeQfs304imdvTdaakT/lqwQ8hkOXQ==} + + '@parcel/watcher-android-arm64@2.5.1': + resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.1': + resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.1': + resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.1': + resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.1': + resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm-musl@2.5.1': + resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-arm64-musl@2.5.1': + resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-x64-glibc@2.5.1': + resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-linux-x64-musl@2.5.1': + resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-win32-arm64@2.5.1': + resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.1': + resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.1': + resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.1': + resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} + engines: {node: '>= 10.0.0'} + + '@rolldown/binding-android-arm64@1.0.0-beta.50': + resolution: {integrity: sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-beta.50': + resolution: {integrity: sha512-+JRqKJhoFlt5r9q+DecAGPLZ5PxeLva+wCMtAuoFMWPoZzgcYrr599KQ+Ix0jwll4B4HGP43avu9My8KtSOR+w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-beta.50': + resolution: {integrity: sha512-fFXDjXnuX7/gQZQm/1FoivVtRcyAzdjSik7Eo+9iwPQ9EgtA5/nB2+jmbzaKtMGG3q+BnZbdKHCtOacmNrkIDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-beta.50': + resolution: {integrity: sha512-F1b6vARy49tjmT/hbloplzgJS7GIvwWZqt+tAHEstCh0JIh9sa8FAMVqEmYxDviqKBaAI8iVvUREm/Kh/PD26Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.50': + resolution: {integrity: sha512-U6cR76N8T8M6lHj7EZrQ3xunLPxSvYYxA8vJsBKZiFZkT8YV4kjgCO3KwMJL0NOjQCPGKyiXO07U+KmJzdPGRw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.50': + resolution: {integrity: sha512-ONgyjofCrrE3bnh5GZb8EINSFyR/hmwTzZ7oVuyUB170lboza1VMCnb8jgE6MsyyRgHYmN8Lb59i3NKGrxrYjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.50': + resolution: {integrity: sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.50': + resolution: {integrity: sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.50': + resolution: {integrity: sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.50': + resolution: {integrity: sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.50': + resolution: {integrity: sha512-nmCN0nIdeUnmgeDXiQ+2HU6FT162o+rxnF7WMkBm4M5Ds8qTU7Dzv2Wrf22bo4ftnlrb2hKK6FSwAJSAe2FWLg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.50': + resolution: {integrity: sha512-7kcNLi7Ua59JTTLvbe1dYb028QEPaJPJQHqkmSZ5q3tJueUeb6yjRtx8mw4uIqgWZcnQHAR3PrLN4XRJxvgIkA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.50': + resolution: {integrity: sha512-lL70VTNvSCdSZkDPPVMwWn/M2yQiYvSoXw9hTLgdIWdUfC3g72UaruezusR6ceRuwHCY1Ayu2LtKqXkBO5LIwg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.50': + resolution: {integrity: sha512-4qU4x5DXWB4JPjyTne/wBNPqkbQU8J45bl21geERBKtEittleonioACBL1R0PsBu0Aq21SwMK5a9zdBkWSlQtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-beta.50': + resolution: {integrity: sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==} + + '@rolldown/pluginutils@1.0.0-beta.53': + resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} + + '@tanstack/query-core@5.90.12': + resolution: {integrity: sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==} + + '@tanstack/query-devtools@5.91.1': + resolution: {integrity: sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg==} + + '@tanstack/react-query-devtools@5.91.1': + resolution: {integrity: sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ==} + peerDependencies: + '@tanstack/react-query': ^5.90.10 + react: ^18 || ^19 + + '@tanstack/react-query@5.90.12': + resolution: {integrity: sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==} + peerDependencies: + react: ^18 || ^19 + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/node@24.10.2': + resolution: {integrity: sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.7': + resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + + '@vitejs/plugin-react@5.1.2': + resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + + babel-runtime@5.8.38: + resolution: {integrity: sha512-KpgoA8VE/pMmNCrnEeeXqFG24TIH11Z3ZaimIhJWsin8EbfZy3WzFKUTIan10ZIDgRVvi9EkLbruJElJC9dRlg==} + + balanced-match@2.0.0: + resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==} + + baseline-browser-mapping@2.9.5: + resolution: {integrity: sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==} + hasBin: true + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + cacheable@2.3.0: + resolution: {integrity: sha512-HHiAvOBmlcR2f3SQ7kdlYD8+AUJG+wlFZ/Ze8tl1Vzvz0MdOh8IYA/EFU4ve8t1/sZ0j4MGi7ST5MoTwHessQA==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001759: + resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + core-js@1.2.7: + resolution: {integrity: sha512-ZiPp9pZlgxpWRu0M+YWbm6+aQ84XEfH1JRXvfOc/fILWI0VKhLC2LX13X1NYq4fULzLMq7Hfh43CSo2/aIaUPA==} + deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js. + + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + css-functions-list@3.2.3: + resolution: {integrity: sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==} + engines: {node: '>=12 || >=16'} + + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.267: + resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@11.1.1: + resolution: {integrity: sha512-TPVFSDE7q91Dlk1xpFLvFllf8r0HyOMOlnWy7Z2HBku5H3KhIeOGInexrIeg2D64DosVB/JXkrrk6N/7Wriq4A==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + flat-cache@6.1.19: + resolution: {integrity: sha512-l/K33newPTZMTGAnnzaiqSl6NnH7Namh8jBNjrgjprWxGmZUuxx/sJNIRaijOh3n7q7ESbhNZC+pvVZMFdeU4A==} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + global-modules@2.0.0: + resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} + engines: {node: '>=6'} + + global-prefix@3.0.0: + resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} + engines: {node: '>=6'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + globjoin@0.1.4: + resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hashery@1.3.0: + resolution: {integrity: sha512-fWltioiy5zsSAs9ouEnvhsVJeAXRybGCNNv0lvzpzNOSDbULXRy7ivFWwCCv4I5Am6kSo75hmbsCduOoc2/K4w==} + engines: {node: '>=20'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hookified@1.14.0: + resolution: {integrity: sha512-pi1ynXIMFx/uIIwpWJ/5CEtOHLGtnUB0WhGeeYT+fKcQ+WCQbm3/rrkAXnpfph++PgepNqPdTC2WTj8A6k6zoQ==} + + html-tags@3.3.1: + resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} + engines: {node: '>=8'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + immutable@5.1.4: + resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@5.5.5: + resolution: {integrity: sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + known-css-properties@0.37.0: + resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} + + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mathml-tag-names@2.1.3: + resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==} + + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + + mdn-data@2.25.0: + resolution: {integrity: sha512-T2LPsjgUE/tgMmRXREVmwsux89DwWfNjiynOeXuLd2mX6jphGQ2YE3Ukz7LQ2VOFKiVZU/Ee1GqzHiipZCjymw==} + + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss-media-query-parser@0.2.3: + resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==} + + postcss-resolve-nested-selector@0.1.6: + resolution: {integrity: sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==} + + postcss-safe-parser@7.0.1: + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.4.31 + + postcss-scss@4.0.9: + resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.4.29 + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + qified@0.5.3: + resolution: {integrity: sha512-kXuQdQTB6oN3KhI6V4acnBSZx8D2I4xzZvn9+wFLLFCoBNQY/sFnCW6c43OL7pOQ2HvGV4lnWIXNmgfp7cTWhQ==} + engines: {node: '>=20'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@19.2.1: + resolution: {integrity: sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==} + peerDependencies: + react: ^19.2.1 + + react-error-boundary@6.0.0: + resolution: {integrity: sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==} + peerDependencies: + react: '>=16.13.1' + + react-icon@1.0.0: + resolution: {integrity: sha512-VzSlpBHnLanVw79mOxyq98hWDi6DlxK9qPiZ1bAK6bLurMBCaxO/jjyYUrRx9+JGLc/NbnwOmyE/W5Qglbb2QA==} + peerDependencies: + babel-runtime: ^5.3.3 + react: '>=0.12.0' + + react-icons@5.5.0: + resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==} + peerDependencies: + react: '*' + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react-router-dom@7.10.1: + resolution: {integrity: sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.10.1: + resolution: {integrity: sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.1: + resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==} + engines: {node: '>=0.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rolldown-vite@7.2.5: + resolution: {integrity: sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + esbuild: ^0.25.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + rolldown@1.0.0-beta.50: + resolution: {integrity: sha512-JFULvCNl/anKn99eKjOSEubi0lLmNqQDAjyEMME2T4CwezUDL0i6t1O9xZsu2OMehPnV2caNefWpGF+8TnzB6A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + sass@1.95.0: + resolution: {integrity: sha512-9QMjhLq+UkOg/4bb8Lt8A+hJZvY3t+9xeZMKSBtBEgxrXA3ed5Ts4NDreUkYgJP1BTmrscQE/xYhf7iShow6lw==} + engines: {node: '>=14.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + stylelint-config-prettier-scss@1.0.0: + resolution: {integrity: sha512-Gr2qLiyvJGKeDk0E/+awNTrZB/UtNVPLqCDOr07na/sLekZwm26Br6yYIeBYz3ulsEcQgs5j+2IIMXCC+wsaQA==} + engines: {node: 14.* || 16.* || >= 18} + hasBin: true + peerDependencies: + stylelint: '>=15.0.0' + + stylelint-config-recommended-scss@16.0.2: + resolution: {integrity: sha512-aUTHhPPWCvFyWaxtckJlCPaXTDFsp4pKO8evXNCsW9OwsaUWyMd6jvcUhSmfGWPrTddvzNqK4rS/UuSLcbVGdQ==} + engines: {node: '>=20'} + peerDependencies: + postcss: ^8.3.3 + stylelint: ^16.24.0 + peerDependenciesMeta: + postcss: + optional: true + + stylelint-config-recommended@17.0.0: + resolution: {integrity: sha512-WaMSdEiPfZTSFVoYmJbxorJfA610O0tlYuU2aEwY33UQhSPgFbClrVJYWvy3jGJx+XW37O+LyNLiZOEXhKhJmA==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.23.0 + + stylelint-config-standard-scss@16.0.0: + resolution: {integrity: sha512-/FHECLUu+med/e6OaPFpprG86ShC4SYT7Tzb2PTVdDjJsehhFBOioSlWqYFqJxmGPIwO3AMBxNo+kY3dxrbczA==} + engines: {node: '>=20'} + peerDependencies: + postcss: ^8.3.3 + stylelint: ^16.23.1 + peerDependenciesMeta: + postcss: + optional: true + + stylelint-config-standard@39.0.1: + resolution: {integrity: sha512-b7Fja59EYHRNOTa3aXiuWnhUWXFU2Nfg6h61bLfAb5GS5fX3LMUD0U5t4S8N/4tpHQg3Acs2UVPR9jy2l1g/3A==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.23.0 + + stylelint-scss@6.13.0: + resolution: {integrity: sha512-kZPwFUJkfup2gP1enlrS2h9U5+T5wFoqzJ1n/56AlpwSj28kmFe7ww/QFydvPsg5gLjWchAwWWBLtterynZrOw==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.8.2 + + stylelint@16.26.1: + resolution: {integrity: sha512-v20V59/crfc8sVTAtge0mdafI3AdnzQ2KsWe6v523L4OA1bJO02S7MO2oyXDCS6iWb9ckIPnqAFVItqSBQr7jw==} + engines: {node: '>=18.12.0'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-hyperlinks@3.2.0: + resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} + engines: {node: '>=14.18'} + + svg-tags@1.0.0: + resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} + + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tsconfck@3.1.6: + resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + update-browserslist-db@1.2.2: + resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite-tsconfig-paths@5.1.4: + resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + zod@4.1.13: + resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==} + + zustand@5.0.9: + resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.5': {} + + '@babel/core@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.5 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/runtime@7.28.4': {} + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@biomejs/biome@2.3.8': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.3.8 + '@biomejs/cli-darwin-x64': 2.3.8 + '@biomejs/cli-linux-arm64': 2.3.8 + '@biomejs/cli-linux-arm64-musl': 2.3.8 + '@biomejs/cli-linux-x64': 2.3.8 + '@biomejs/cli-linux-x64-musl': 2.3.8 + '@biomejs/cli-win32-arm64': 2.3.8 + '@biomejs/cli-win32-x64': 2.3.8 + + '@biomejs/cli-darwin-arm64@2.3.8': + optional: true + + '@biomejs/cli-darwin-x64@2.3.8': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.3.8': + optional: true + + '@biomejs/cli-linux-arm64@2.3.8': + optional: true + + '@biomejs/cli-linux-x64-musl@2.3.8': + optional: true + + '@biomejs/cli-linux-x64@2.3.8': + optional: true + + '@biomejs/cli-win32-arm64@2.3.8': + optional: true + + '@biomejs/cli-win32-x64@2.3.8': + optional: true + + '@cacheable/memory@2.0.6': + dependencies: + '@cacheable/utils': 2.3.2 + '@keyv/bigmap': 1.3.0(keyv@5.5.5) + hookified: 1.14.0 + keyv: 5.5.5 + + '@cacheable/utils@2.3.2': + dependencies: + hashery: 1.3.0 + keyv: 5.5.5 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.20': {} + + '@csstools/css-tokenizer@3.0.4': {} + + '@csstools/media-query-list-parser@4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@dual-bundle/import-meta-resolve@4.2.1': {} + + '@emnapi/core@1.7.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.7.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@keyv/bigmap@1.3.0(keyv@5.5.5)': + dependencies: + hashery: 1.3.0 + hookified: 1.14.0 + keyv: 5.5.5 + + '@keyv/serialize@1.1.1': {} + + '@napi-rs/wasm-runtime@1.1.0': + dependencies: + '@emnapi/core': 1.7.1 + '@emnapi/runtime': 1.7.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@oxc-project/runtime@0.97.0': {} + + '@oxc-project/types@0.97.0': {} + + '@parcel/watcher-android-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-x64@2.5.1': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.1': + optional: true + + '@parcel/watcher-win32-arm64@2.5.1': + optional: true + + '@parcel/watcher-win32-ia32@2.5.1': + optional: true + + '@parcel/watcher-win32-x64@2.5.1': + optional: true + + '@parcel/watcher@2.5.1': + dependencies: + detect-libc: 1.0.3 + is-glob: 4.0.3 + micromatch: 4.0.8 + node-addon-api: 7.1.1 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.1 + '@parcel/watcher-darwin-arm64': 2.5.1 + '@parcel/watcher-darwin-x64': 2.5.1 + '@parcel/watcher-freebsd-x64': 2.5.1 + '@parcel/watcher-linux-arm-glibc': 2.5.1 + '@parcel/watcher-linux-arm-musl': 2.5.1 + '@parcel/watcher-linux-arm64-glibc': 2.5.1 + '@parcel/watcher-linux-arm64-musl': 2.5.1 + '@parcel/watcher-linux-x64-glibc': 2.5.1 + '@parcel/watcher-linux-x64-musl': 2.5.1 + '@parcel/watcher-win32-arm64': 2.5.1 + '@parcel/watcher-win32-ia32': 2.5.1 + '@parcel/watcher-win32-x64': 2.5.1 + optional: true + + '@rolldown/binding-android-arm64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.50': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.50': + dependencies: + '@napi-rs/wasm-runtime': 1.1.0 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.50': + optional: true + + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.50': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.50': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.50': {} + + '@rolldown/pluginutils@1.0.0-beta.53': {} + + '@tanstack/query-core@5.90.12': {} + + '@tanstack/query-devtools@5.91.1': {} + + '@tanstack/react-query-devtools@5.91.1(@tanstack/react-query@5.90.12(react@19.2.1))(react@19.2.1)': + dependencies: + '@tanstack/query-devtools': 5.91.1 + '@tanstack/react-query': 5.90.12(react@19.2.1) + react: 19.2.1 + + '@tanstack/react-query@5.90.12(react@19.2.1)': + dependencies: + '@tanstack/query-core': 5.90.12 + react: 19.2.1 + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/node@24.10.2': + dependencies: + undici-types: 7.16.0 + + '@types/react-dom@19.2.3(@types/react@19.2.7)': + dependencies: + '@types/react': 19.2.7 + + '@types/react@19.2.7': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@5.1.2(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0))': + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) + '@rolldown/pluginutils': 1.0.0-beta.53 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0) + transitivePeerDependencies: + - supports-color + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + array-union@2.1.0: {} + + astral-regex@2.0.0: {} + + asynckit@0.4.0: {} + + axios@1.13.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + babel-runtime@5.8.38: + dependencies: + core-js: 1.2.7 + + balanced-match@2.0.0: {} + + baseline-browser-mapping@2.9.5: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.5 + caniuse-lite: 1.0.30001759 + electron-to-chromium: 1.5.267 + node-releases: 2.0.27 + update-browserslist-db: 1.2.2(browserslist@4.28.1) + + cacheable@2.3.0: + dependencies: + '@cacheable/memory': 2.0.6 + '@cacheable/utils': 2.3.2 + hookified: 1.14.0 + keyv: 5.5.5 + qified: 0.5.3 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001759: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colord@2.9.3: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + core-js@1.2.7: {} + + cosmiconfig@9.0.0(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 + + css-functions-list@3.2.3: {} + + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + delayed-stream@1.0.0: {} + + detect-libc@1.0.3: + optional: true + + detect-libc@2.1.2: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.267: {} + + emoji-regex@8.0.0: {} + + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + escalade@3.2.0: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-uri@3.1.0: {} + + fastest-levenshtein@1.0.16: {} + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-entry-cache@11.1.1: + dependencies: + flat-cache: 6.1.19 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + flat-cache@6.1.19: + dependencies: + cacheable: 2.3.0 + flatted: 3.3.3 + hookified: 1.14.0 + + flatted@3.3.3: {} + + follow-redirects@1.15.11: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + global-modules@2.0.0: + dependencies: + global-prefix: 3.0.0 + + global-prefix@3.0.0: + dependencies: + ini: 1.3.8 + kind-of: 6.0.3 + which: 1.3.1 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + globjoin@0.1.4: {} + + globrex@0.1.2: {} + + gopd@1.2.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hashery@1.3.0: + dependencies: + hookified: 1.14.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hookified@1.14.0: {} + + html-tags@3.3.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + immutable@5.1.4: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + ini@1.3.8: {} + + is-arrayish@0.2.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-plain-object@5.0.0: {} + + isexe@2.0.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@1.0.0: {} + + json5@2.2.3: {} + + keyv@5.5.5: + dependencies: + '@keyv/serialize': 1.1.1 + + kind-of@6.0.3: {} + + known-css-properties@0.37.0: {} + + lightningcss-android-arm64@1.30.2: + optional: true + + lightningcss-darwin-arm64@1.30.2: + optional: true + + lightningcss-darwin-x64@1.30.2: + optional: true + + lightningcss-freebsd-x64@1.30.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.2: + optional: true + + lightningcss-linux-arm64-gnu@1.30.2: + optional: true + + lightningcss-linux-arm64-musl@1.30.2: + optional: true + + lightningcss-linux-x64-gnu@1.30.2: + optional: true + + lightningcss-linux-x64-musl@1.30.2: + optional: true + + lightningcss-win32-arm64-msvc@1.30.2: + optional: true + + lightningcss-win32-x64-msvc@1.30.2: + optional: true + + lightningcss@1.30.2: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 + + lines-and-columns@1.2.4: {} + + lodash.truncate@4.4.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + math-intrinsics@1.1.0: {} + + mathml-tag-names@2.1.3: {} + + mdn-data@2.12.2: {} + + mdn-data@2.25.0: {} + + meow@13.2.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + node-addon-api@7.1.1: + optional: true + + node-releases@2.0.27: {} + + normalize-path@3.0.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-type@4.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + postcss-media-query-parser@0.2.3: {} + + postcss-resolve-nested-selector@0.1.6: {} + + postcss-safe-parser@7.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-scss@4.0.9(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-from-env@1.1.0: {} + + qified@0.5.3: + dependencies: + hookified: 1.14.0 + + queue-microtask@1.2.3: {} + + react-dom@19.2.1(react@19.2.1): + dependencies: + react: 19.2.1 + scheduler: 0.27.0 + + react-error-boundary@6.0.0(react@19.2.1): + dependencies: + '@babel/runtime': 7.28.4 + react: 19.2.1 + + react-icon@1.0.0(babel-runtime@5.8.38)(react@19.2.1): + dependencies: + babel-runtime: 5.8.38 + react: 19.2.1 + + react-icons@5.5.0(react@19.2.1): + dependencies: + react: 19.2.1 + + react-refresh@0.18.0: {} + + react-router-dom@7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + react-router: 7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + + react-router@7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + cookie: 1.1.1 + react: 19.2.1 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.1(react@19.2.1) + + react@19.2.1: {} + + readdirp@4.1.2: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + reusify@1.1.0: {} + + rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0): + dependencies: + '@oxc-project/runtime': 0.97.0 + fdir: 6.5.0(picomatch@4.0.3) + lightningcss: 1.30.2 + picomatch: 4.0.3 + postcss: 8.5.6 + rolldown: 1.0.0-beta.50 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.10.2 + fsevents: 2.3.3 + sass: 1.95.0 + + rolldown@1.0.0-beta.50: + dependencies: + '@oxc-project/types': 0.97.0 + '@rolldown/pluginutils': 1.0.0-beta.50 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-beta.50 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.50 + '@rolldown/binding-darwin-x64': 1.0.0-beta.50 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.50 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.50 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.50 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.50 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.50 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.50 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.50 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.50 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.50 + '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.50 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.50 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + sass@1.95.0: + dependencies: + chokidar: 4.0.3 + immutable: 5.1.4 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.5.1 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + set-cookie-parser@2.7.2: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + sonner@2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + + source-map-js@1.2.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + stylelint-config-prettier-scss@1.0.0(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + + stylelint-config-recommended-scss@16.0.2(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + postcss-scss: 4.0.9(postcss@8.5.6) + stylelint: 16.26.1(typescript@5.9.3) + stylelint-config-recommended: 17.0.0(stylelint@16.26.1(typescript@5.9.3)) + stylelint-scss: 6.13.0(stylelint@16.26.1(typescript@5.9.3)) + optionalDependencies: + postcss: 8.5.6 + + stylelint-config-recommended@17.0.0(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + + stylelint-config-standard-scss@16.0.0(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + stylelint-config-recommended-scss: 16.0.2(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)) + stylelint-config-standard: 39.0.1(stylelint@16.26.1(typescript@5.9.3)) + optionalDependencies: + postcss: 8.5.6 + + stylelint-config-standard@39.0.1(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + stylelint-config-recommended: 17.0.0(stylelint@16.26.1(typescript@5.9.3)) + + stylelint-scss@6.13.0(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + css-tree: 3.1.0 + is-plain-object: 5.0.0 + known-css-properties: 0.37.0 + mdn-data: 2.25.0 + postcss-media-query-parser: 0.2.3 + postcss-resolve-nested-selector: 0.1.6 + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + stylelint: 16.26.1(typescript@5.9.3) + + stylelint@16.26.1(typescript@5.9.3): + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-syntax-patches-for-csstree': 1.0.20 + '@csstools/css-tokenizer': 3.0.4 + '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1) + '@dual-bundle/import-meta-resolve': 4.2.1 + balanced-match: 2.0.0 + colord: 2.9.3 + cosmiconfig: 9.0.0(typescript@5.9.3) + css-functions-list: 3.2.3 + css-tree: 3.1.0 + debug: 4.4.3 + fast-glob: 3.3.3 + fastest-levenshtein: 1.0.16 + file-entry-cache: 11.1.1 + global-modules: 2.0.0 + globby: 11.1.0 + globjoin: 0.1.4 + html-tags: 3.3.1 + ignore: 7.0.5 + imurmurhash: 0.1.4 + is-plain-object: 5.0.0 + known-css-properties: 0.37.0 + mathml-tag-names: 2.1.3 + meow: 13.2.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-resolve-nested-selector: 0.1.6 + postcss-safe-parser: 7.0.1(postcss@8.5.6) + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + resolve-from: 5.0.0 + string-width: 4.2.3 + supports-hyperlinks: 3.2.0 + svg-tags: 1.0.0 + table: 6.9.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + - typescript + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@3.2.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + svg-tags@1.0.0: {} + + table@6.9.0: + dependencies: + ajv: 8.17.1 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tsconfck@3.1.6(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + tslib@2.8.1: + optional: true + + typescript@5.9.3: {} + + undici-types@7.16.0: {} + + update-browserslist-db@1.2.2(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + + vite-tsconfig-paths@5.1.4(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0))(typescript@5.9.3): + dependencies: + debug: 4.4.3 + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@5.9.3) + optionalDependencies: + vite: rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0) + transitivePeerDependencies: + - supports-color + - typescript + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + + yallist@3.1.1: {} + + zod@4.1.13: {} + + zustand@5.0.9(@types/react@19.2.7)(react@19.2.1): + optionalDependencies: + '@types/react': 19.2.7 + react: 19.2.1 diff --git a/PROJECTS/beginner/canary-token-generator/frontend/public/assets/android-chrome-192x192.png b/PROJECTS/beginner/canary-token-generator/frontend/public/assets/android-chrome-192x192.png new file mode 100644 index 00000000..89759eeb Binary files /dev/null and b/PROJECTS/beginner/canary-token-generator/frontend/public/assets/android-chrome-192x192.png differ diff --git a/PROJECTS/beginner/canary-token-generator/frontend/public/assets/android-chrome-512x512.png b/PROJECTS/beginner/canary-token-generator/frontend/public/assets/android-chrome-512x512.png new file mode 100644 index 00000000..a4dab0c2 Binary files /dev/null and b/PROJECTS/beginner/canary-token-generator/frontend/public/assets/android-chrome-512x512.png differ diff --git a/PROJECTS/beginner/canary-token-generator/frontend/public/assets/apple-touch-icon.png b/PROJECTS/beginner/canary-token-generator/frontend/public/assets/apple-touch-icon.png new file mode 100644 index 00000000..640755ad Binary files /dev/null and b/PROJECTS/beginner/canary-token-generator/frontend/public/assets/apple-touch-icon.png differ diff --git a/PROJECTS/beginner/canary-token-generator/frontend/public/assets/favicon-16x16.png b/PROJECTS/beginner/canary-token-generator/frontend/public/assets/favicon-16x16.png new file mode 100644 index 00000000..65e96982 Binary files /dev/null and b/PROJECTS/beginner/canary-token-generator/frontend/public/assets/favicon-16x16.png differ diff --git a/PROJECTS/beginner/canary-token-generator/frontend/public/assets/favicon-32x32.png b/PROJECTS/beginner/canary-token-generator/frontend/public/assets/favicon-32x32.png new file mode 100644 index 00000000..323476cd Binary files /dev/null and b/PROJECTS/beginner/canary-token-generator/frontend/public/assets/favicon-32x32.png differ diff --git a/PROJECTS/beginner/canary-token-generator/frontend/public/assets/favicon.ico b/PROJECTS/beginner/canary-token-generator/frontend/public/assets/favicon.ico new file mode 100644 index 00000000..a648b87c Binary files /dev/null and b/PROJECTS/beginner/canary-token-generator/frontend/public/assets/favicon.ico differ diff --git a/PROJECTS/beginner/canary-token-generator/frontend/public/assets/site.webmanifest b/PROJECTS/beginner/canary-token-generator/frontend/public/assets/site.webmanifest new file mode 100644 index 00000000..1dd91123 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/public/assets/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/App.tsx b/PROJECTS/beginner/canary-token-generator/frontend/src/App.tsx new file mode 100644 index 00000000..bf6a61a7 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/App.tsx @@ -0,0 +1,21 @@ +// =================== +// ©AngelaMos | 2026 +// App.tsx +// =================== + +import { QueryClientProvider } from '@tanstack/react-query' +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' +import { RouterProvider } from 'react-router-dom' +import { Toaster } from 'sonner' +import { queryClient } from '@/core/api' +import { router } from '@/core/app/routers' + +export default function App(): React.ReactElement { + return ( + + + + + + ) +} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/api/client.ts b/PROJECTS/beginner/canary-token-generator/frontend/src/api/client.ts new file mode 100644 index 00000000..1ef24a24 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/api/client.ts @@ -0,0 +1,136 @@ +// =================== +// ©AngelaMos | 2026 +// client.ts +// =================== + +import axios, { + type AxiosError, + type AxiosInstance, + type InternalAxiosRequestConfig, +} from 'axios' +import type { ZodType } from 'zod' +import { ApiError, ApiErrorCode } from '@/core/api' +import { apiErrorEnvelopeSchema, successEnvelope } from './types/error' + +const REQUEST_TIMEOUT_MS = 15000 +const TURNSTILE_HEADER_NAME = 'CF-Turnstile-Response' + +const STATUS_FALLBACK_CODE: Readonly> = { + 400: ApiErrorCode.VALIDATION_ERROR, + 404: ApiErrorCode.NOT_FOUND, + 410: ApiErrorCode.NOT_FOUND, + 429: ApiErrorCode.RATE_LIMITED, + 500: ApiErrorCode.INTERNAL_ERROR, + 502: ApiErrorCode.SERVICE_UNAVAILABLE, + 503: ApiErrorCode.SERVICE_UNAVAILABLE, + 504: ApiErrorCode.SERVICE_UNAVAILABLE, +} + +const resolveBaseURL = (): string => { + const fromEnv = import.meta.env.VITE_API_URL + if (typeof fromEnv === 'string' && fromEnv.length > 0) { + return fromEnv + } + return '/api' +} + +export const apiClient: AxiosInstance = axios.create({ + baseURL: resolveBaseURL(), + timeout: REQUEST_TIMEOUT_MS, + headers: { 'Content-Type': 'application/json' }, +}) + +export type TurnstileTokenProvider = () => string | null | undefined + +let turnstileProvider: TurnstileTokenProvider | null = null + +export function setTurnstileTokenProvider( + provider: TurnstileTokenProvider | null +): void { + turnstileProvider = provider +} + +function transformAxiosError(error: AxiosError): ApiError { + if (!error.response) { + return new ApiError('Network error', ApiErrorCode.NETWORK_ERROR, 0) + } + const { status, data } = error.response + + const parsed = apiErrorEnvelopeSchema.safeParse(data) + if (parsed.success) { + return new ApiError( + parsed.data.error.message, + parsed.data.error.code, + status, + parsed.data.error.fields + ) + } + + const fallback = STATUS_FALLBACK_CODE[status] ?? ApiErrorCode.UNKNOWN_ERROR + return new ApiError('Request failed', fallback, status) +} + +apiClient.interceptors.request.use( + (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => { + const token = turnstileProvider?.() + if (typeof token === 'string' && token.length > 0) { + config.headers.set(TURNSTILE_HEADER_NAME, token) + } + return config + }, + (error: unknown) => Promise.reject(error) +) + +apiClient.interceptors.response.use( + (response) => response, + (error: unknown) => { + if (axios.isAxiosError(error)) { + return Promise.reject(transformAxiosError(error)) + } + return Promise.reject(error) + } +) + +type ParseIssue = { + path: readonly PropertyKey[] + message: string +} + +function describeParseFailure(issues: readonly ParseIssue[]): string { + const first = issues[0] + if (!first) { + return 'unknown' + } + const path = first.path.length > 0 ? first.path.join('.') : '' + return `${path}: ${first.message}` +} + +function unwrapEnvelope(data: unknown, schema: ZodType, status: number): T { + const parsed = successEnvelope(schema).safeParse(data) + if (!parsed.success) { + throw new ApiError( + `response shape mismatch (${describeParseFailure(parsed.error.issues)})`, + ApiErrorCode.PARSE_ERROR, + status + ) + } + return parsed.data.data +} + +export async function apiGet(path: string, schema: ZodType): Promise { + const response = await apiClient.get(path) + return unwrapEnvelope(response.data, schema, response.status) +} + +export async function apiPost( + path: string, + body: unknown, + schema: ZodType +): Promise { + const response = await apiClient.post(path, body) + return unwrapEnvelope(response.data, schema, response.status) +} + +export async function apiDelete(path: string): Promise { + await apiClient.delete(path) +} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/api/hooks/index.ts b/PROJECTS/beginner/canary-token-generator/frontend/src/api/hooks/index.ts new file mode 100644 index 00000000..90327613 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/api/hooks/index.ts @@ -0,0 +1,9 @@ +// =================== +// ©AngelaMos | 2026 +// index.ts +// =================== + +export * from './useCreateToken' +export * from './useDeleteToken' +export * from './useManageToken' +export * from './useTokenTypes' diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/api/hooks/useCreateToken.ts b/PROJECTS/beginner/canary-token-generator/frontend/src/api/hooks/useCreateToken.ts new file mode 100644 index 00000000..3d4f67fd --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/api/hooks/useCreateToken.ts @@ -0,0 +1,28 @@ +// =================== +// ©AngelaMos | 2026 +// useCreateToken.ts +// =================== + +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { apiPost } from '../client' +import { + type CreateTokenInput, + type CreateTokenResponse, + createTokenResponseSchema, +} from '../types/token' + +const CREATE_TOKEN_PATH = '/tokens' +const MANAGE_KEY_PREFIX = ['canary', 'manage'] as const + +export function useCreateToken() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (body) => + apiPost(CREATE_TOKEN_PATH, body, createTokenResponseSchema), + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: [...MANAGE_KEY_PREFIX, data.token.manage_id], + }) + }, + }) +} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/api/hooks/useDeleteToken.ts b/PROJECTS/beginner/canary-token-generator/frontend/src/api/hooks/useDeleteToken.ts new file mode 100644 index 00000000..8858a529 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/api/hooks/useDeleteToken.ts @@ -0,0 +1,23 @@ +// =================== +// ©AngelaMos | 2026 +// useDeleteToken.ts +// =================== + +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { apiDelete } from '../client' + +const MANAGE_PATH_PREFIX = '/m/' +const MANAGE_KEY_PREFIX = ['canary', 'manage'] as const + +export function useDeleteToken() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (manageId) => + apiDelete(`${MANAGE_PATH_PREFIX}${encodeURIComponent(manageId)}`), + onSuccess: (_data, manageId) => { + queryClient.removeQueries({ + queryKey: [...MANAGE_KEY_PREFIX, manageId], + }) + }, + }) +} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/api/hooks/useManageToken.ts b/PROJECTS/beginner/canary-token-generator/frontend/src/api/hooks/useManageToken.ts new file mode 100644 index 00000000..a7a275f0 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/api/hooks/useManageToken.ts @@ -0,0 +1,48 @@ +// =================== +// ©AngelaMos | 2026 +// useManageToken.ts +// =================== + +import { useQuery } from '@tanstack/react-query' +import { QUERY_STRATEGIES } from '@/core/api' +import { apiGet } from '../client' +import { manageResponseSchema } from '../types/event' + +const MANAGE_PATH_PREFIX = '/m/' +const MANAGE_KEY_PREFIX = ['canary', 'manage'] as const + +export type UseManageTokenParams = { + cursor?: string + limit?: number +} + +function buildSearch(params: UseManageTokenParams | undefined): string { + if (!params) { + return '' + } + const sp = new URLSearchParams() + if (params.cursor !== undefined && params.cursor.length > 0) { + sp.set('cursor', params.cursor) + } + if (params.limit !== undefined) { + sp.set('limit', String(params.limit)) + } + const query = sp.toString() + return query.length > 0 ? `?${query}` : '' +} + +export function useManageToken(manageId: string, params?: UseManageTokenParams) { + const search = buildSearch(params) + const path = `${MANAGE_PATH_PREFIX}${encodeURIComponent(manageId)}${search}` + return useQuery({ + queryKey: [ + ...MANAGE_KEY_PREFIX, + manageId, + params?.cursor ?? null, + params?.limit ?? null, + ], + queryFn: () => apiGet(path, manageResponseSchema), + enabled: manageId.length > 0, + ...QUERY_STRATEGIES.frequent, + }) +} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/api/hooks/useTokenTypes.ts b/PROJECTS/beginner/canary-token-generator/frontend/src/api/hooks/useTokenTypes.ts new file mode 100644 index 00000000..a36500dd --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/api/hooks/useTokenTypes.ts @@ -0,0 +1,20 @@ +// =================== +// ©AngelaMos | 2026 +// useTokenTypes.ts +// =================== + +import { useQuery } from '@tanstack/react-query' +import { QUERY_STRATEGIES } from '@/core/api' +import { apiGet } from '../client' +import { typeDescriptorListSchema } from '../types/token' + +const TOKEN_TYPES_PATH = '/tokens/types' +const TOKEN_TYPES_KEY = ['canary', 'token-types'] as const + +export function useTokenTypes() { + return useQuery({ + queryKey: TOKEN_TYPES_KEY, + queryFn: () => apiGet(TOKEN_TYPES_PATH, typeDescriptorListSchema), + ...QUERY_STRATEGIES.static, + }) +} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/api/index.ts b/PROJECTS/beginner/canary-token-generator/frontend/src/api/index.ts new file mode 100644 index 00000000..d29a44f9 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/api/index.ts @@ -0,0 +1,8 @@ +// =================== +// ©AngelaMos | 2026 +// index.ts +// =================== + +export * from './client' +export * from './hooks' +export * from './types' diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/api/types/error.ts b/PROJECTS/beginner/canary-token-generator/frontend/src/api/types/error.ts new file mode 100644 index 00000000..db125a94 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/api/types/error.ts @@ -0,0 +1,32 @@ +// =================== +// ©AngelaMos | 2026 +// error.ts +// =================== + +import { type ZodType, z } from 'zod' + +export const apiErrorPayloadSchema = z.object({ + code: z.string(), + message: z.string(), + fields: z.record(z.string(), z.string()).optional(), +}) + +export type ApiErrorPayload = z.infer + +export const apiErrorEnvelopeSchema = z.object({ + success: z.literal(false), + error: apiErrorPayloadSchema, +}) + +export type ApiErrorEnvelope = z.infer + +export const successEnvelope = (data: T) => + z.object({ + success: z.literal(true), + data, + }) + +export type SuccessEnvelope = { + success: true + data: T +} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/api/types/event.ts b/PROJECTS/beginner/canary-token-generator/frontend/src/api/types/event.ts new file mode 100644 index 00000000..22b18c45 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/api/types/event.ts @@ -0,0 +1,59 @@ +// =================== +// ©AngelaMos | 2026 +// event.ts +// =================== + +import { z } from 'zod' +import { manageTokenViewSchema } from './token' + +export const notifyStatusSchema = z.enum(['pending', 'sent', 'failed', 'deduped']) + +export type NotifyStatus = z.infer + +export const geoViewSchema = z.object({ + country: z.string().nullable(), + region: z.string().nullable(), + city: z.string().nullable(), + asn: z.number().int().nullable(), + asn_org: z.string().nullable(), +}) + +export type GeoView = z.infer + +export const eventExtraSchema = z.record(z.string(), z.unknown()) + +export type EventExtra = z.infer + +export const eventResponseSchema = z.object({ + id: z.number().int().nonnegative(), + triggered_at: z.iso.datetime(), + source_ip: z.string(), + user_agent: z.string().nullable(), + referer: z.string().nullable(), + geo: geoViewSchema, + extra: eventExtraSchema, + notify_status: notifyStatusSchema, + notified_at: z.iso.datetime().nullable(), +}) + +export type EventResponse = z.infer + +export const managePageSchema = z.object({ + next_cursor: z.string(), + has_more: z.boolean(), +}) + +export type ManagePage = z.infer + +export const manageResponseSchema = z.object({ + token: manageTokenViewSchema, + events: z + .array(eventResponseSchema) + .nullish() + .transform((v) => v ?? []), + events_total: z.number().int().nonnegative(), + events_silenced_active: z.number().int().nonnegative(), + page: managePageSchema, +}) + +export type ManageResponse = z.infer diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/api/types/index.ts b/PROJECTS/beginner/canary-token-generator/frontend/src/api/types/index.ts new file mode 100644 index 00000000..bebc57e2 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/api/types/index.ts @@ -0,0 +1,8 @@ +// =================== +// ©AngelaMos | 2026 +// index.ts +// =================== + +export * from './error' +export * from './event' +export * from './token' diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/api/types/token.ts b/PROJECTS/beginner/canary-token-generator/frontend/src/api/types/token.ts new file mode 100644 index 00000000..c8c8569a --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/api/types/token.ts @@ -0,0 +1,180 @@ +// =================== +// ©AngelaMos | 2026 +// token.ts +// =================== + +import { z } from 'zod' + +export const tokenTypeSchema = z.enum([ + 'webbug', + 'slowredirect', + 'docx', + 'pdf', + 'kubeconfig', + 'envfile', + 'mysql', +]) + +export type TokenType = z.infer + +export const alertChannelSchema = z.enum(['telegram', 'webhook']) + +export type AlertChannel = z.infer + +export const artifactKindSchema = z.enum([ + 'url', + 'file', + 'text', + 'connection_string', +]) + +export type ArtifactKind = z.infer + +export const tokenResponseSchema = z.object({ + id: z.string(), + manage_id: z.string(), + type: tokenTypeSchema, + memo: z.string(), + filename: z.string().nullable(), + alert_channel: alertChannelSchema, + created_at: z.iso.datetime(), + trigger_count: z.number().int().nonnegative(), + last_triggered: z.iso.datetime().nullable(), + enabled: z.boolean(), + trigger_url: z.string(), + manage_url: z.string(), + metadata: z.unknown().optional(), +}) + +export type TokenResponse = z.infer + +export const manageTokenViewSchema = z.object({ + id: z.string(), + type: tokenTypeSchema, + memo: z.string(), + filename: z.string().nullable(), + alert_channel: alertChannelSchema, + created_at: z.iso.datetime(), + trigger_count: z.number().int().nonnegative(), + last_triggered: z.iso.datetime().nullable(), + enabled: z.boolean(), + trigger_url: z.string(), +}) + +export type ManageTokenView = z.infer + +const artifactUrlSchema = z.object({ + kind: z.literal('url'), + url: z.string().optional(), + destination_url: z.string().optional(), +}) + +const artifactFileSchema = z.object({ + kind: z.literal('file'), + filename: z.string().optional(), + content_type: z.string().optional(), + content_b64: z.string().optional(), +}) + +const artifactTextSchema = z.object({ + kind: z.literal('text'), + filename: z.string().optional(), + content_type: z.string().optional(), + content: z.string().optional(), +}) + +const artifactConnectionStringSchema = z.object({ + kind: z.literal('connection_string'), + connection_string: z.string().optional(), +}) + +export const artifactSchema = z.discriminatedUnion('kind', [ + artifactUrlSchema, + artifactFileSchema, + artifactTextSchema, + artifactConnectionStringSchema, +]) + +export type Artifact = z.infer + +export const typeDescriptorSchema = z.object({ + type: tokenTypeSchema, + name: z.string(), + description: z.string(), + artifact_kind: artifactKindSchema, + enabled: z.boolean().default(true), + disabled_reason: z.string().optional(), +}) + +export type TypeDescriptor = z.infer + +export const typeDescriptorListSchema = z.array(typeDescriptorSchema) + +export type TypeDescriptorList = z.infer + +const MEMO_MAX_LENGTH = 256 +const FILENAME_MAX_LENGTH = 128 + +export const createTokenRequestSchema = z + .object({ + type: tokenTypeSchema, + memo: z.string().max(MEMO_MAX_LENGTH).default(''), + filename: z.string().max(FILENAME_MAX_LENGTH).optional(), + alert_channel: alertChannelSchema, + telegram_bot: z.string().optional(), + telegram_chat: z.string().optional(), + webhook_url: z.url().optional(), + metadata: z.unknown().optional(), + cf_turnstile_response: z.string().optional(), + }) + .superRefine((value, ctx) => { + if (value.alert_channel === 'telegram') { + if (!value.telegram_bot) { + ctx.addIssue({ + code: 'custom', + path: ['telegram_bot'], + message: 'required when alert_channel is telegram', + }) + } + if (!value.telegram_chat) { + ctx.addIssue({ + code: 'custom', + path: ['telegram_chat'], + message: 'required when alert_channel is telegram', + }) + } + } + if (value.alert_channel === 'webhook' && !value.webhook_url) { + ctx.addIssue({ + code: 'custom', + path: ['webhook_url'], + message: 'required when alert_channel is webhook', + }) + } + }) + +export type CreateTokenRequest = z.infer +export type CreateTokenInput = z.input + +export const createTokenResponseSchema = z.object({ + token: tokenResponseSchema, + artifact: artifactSchema, +}) + +export type CreateTokenResponse = z.infer + +export const slowredirectMetadataSchema = z.object({ + destination_url: z.url(), +}) + +export type SlowRedirectMetadata = z.infer + +export const envfileIncludeKeySchema = z.enum(['aws', 'stripe', 'github', 'db']) + +export type EnvfileIncludeKey = z.infer + +export const envfileMetadataSchema = z.object({ + include_keys: z.array(envfileIncludeKeySchema), +}) + +export type EnvfileMetadata = z.infer diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/components/Button/Button.module.scss b/PROJECTS/beginner/canary-token-generator/frontend/src/components/Button/Button.module.scss new file mode 100644 index 00000000..bc6d2ba9 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/components/Button/Button.module.scss @@ -0,0 +1,89 @@ +// =================== +// ©AngelaMos | 2026 +// Button.module.scss +// =================== + +@use '../../styles/tokens' as *; +@use '../../styles/fonts' as *; + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + border: $hairline solid $ink; + background-color: $ink; + color: $paper; + font-family: $font-mono; + letter-spacing: $tracking-wider; + text-transform: uppercase; + cursor: pointer; + transition: background-color $duration-fast $ease-out, + color $duration-fast $ease-out; + + &[data-size='sm'] { + padding-block: $space-1-5; + padding-inline: $space-3; + font-size: $font-size-3xs; + } + + &[data-size='md'] { + padding-block: $space-2-5; + padding-inline: $space-5; + font-size: $font-size-2xs; + } + + &[data-size='lg'] { + padding-block: $space-3-5; + padding-inline: $space-7; + font-size: $font-size-xs; + } + + &[data-full='true'] { + inline-size: 100%; + } + + &:hover:not(:disabled) { + background-color: $paper; + color: $ink; + } + + &[data-variant='ghost'] { + background-color: transparent; + color: $ink; + + &:hover:not(:disabled) { + background-color: $ink; + color: $paper; + } + } + + &[data-variant='alarm'] { + background-color: $paper; + color: $alarm-deep; + border-color: $alarm; + + &:hover:not(:disabled) { + background-color: $alarm; + color: $paper; + } + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + + &[data-busy='true'] { + cursor: progress; + + .label::after { + content: ' …'; + } + } +} + +.label { + display: inline-flex; + align-items: center; + gap: $space-2; +} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/components/Button/index.tsx b/PROJECTS/beginner/canary-token-generator/frontend/src/components/Button/index.tsx new file mode 100644 index 00000000..8af6e6d7 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/components/Button/index.tsx @@ -0,0 +1,50 @@ +// =================== +// ©AngelaMos | 2026 +// index.tsx +// =================== + +import type { ButtonHTMLAttributes, PropsWithChildren } from 'react' +import { forwardRef } from 'react' +import styles from './Button.module.scss' + +type ButtonVariant = 'primary' | 'ghost' | 'alarm' +type ButtonSize = 'sm' | 'md' | 'lg' + +type ButtonProps = PropsWithChildren< + Omit, 'className'> & { + variant?: ButtonVariant + size?: ButtonSize + fullWidth?: boolean + busy?: boolean + } +> + +export const Button = forwardRef( + function Button(props, ref) { + const { + variant = 'primary', + size = 'md', + fullWidth = false, + busy = false, + type = 'button', + children, + disabled, + ...rest + } = props + return ( + + ) + } +) diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/components/CopyField/CopyField.module.scss b/PROJECTS/beginner/canary-token-generator/frontend/src/components/CopyField/CopyField.module.scss new file mode 100644 index 00000000..b8655cb7 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/components/CopyField/CopyField.module.scss @@ -0,0 +1,63 @@ +// =================== +// ©AngelaMos | 2026 +// CopyField.module.scss +// =================== + +@use '../../styles/tokens' as *; +@use '../../styles/fonts' as *; + +.field { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: stretch; + border: $hairline solid $rule; + background-color: $paper; + + &[data-full='true'] { + inline-size: 100%; + } +} + +.label { + display: inline-flex; + align-items: center; + padding-inline: $space-3; + background-color: $ink; + color: $paper; + font-family: $font-mono; + font-size: $font-size-3xs; + letter-spacing: $tracking-wider; + text-transform: uppercase; +} + +.value { + display: inline-flex; + align-items: center; + padding-block: $space-2; + padding-inline: $space-3; + font-family: $font-mono; + font-size: $font-size-xs; + letter-spacing: $tracking-wide; + color: $ink; + overflow-x: auto; + white-space: nowrap; +} + +.copy { + padding-block: $space-2; + padding-inline: $space-3; + border-inline-start: $hairline solid $rule; + background-color: $paper-deep; + font-family: $font-mono; + font-size: $font-size-3xs; + letter-spacing: $tracking-wider; + text-transform: uppercase; + color: $ink; + cursor: pointer; + transition: background-color $duration-fast $ease-out; + + &:hover { + background-color: $ink; + color: $paper; + } +} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/components/CopyField/index.tsx b/PROJECTS/beginner/canary-token-generator/frontend/src/components/CopyField/index.tsx new file mode 100644 index 00000000..10369444 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/components/CopyField/index.tsx @@ -0,0 +1,49 @@ +// =================== +// ©AngelaMos | 2026 +// index.tsx +// =================== + +import { useState } from 'react' +import { toast } from 'sonner' +import styles from './CopyField.module.scss' + +type CopyFieldProps = { + value: string + label?: string + fullWidth?: boolean +} + +const COPY_FEEDBACK_MS = 1200 + +export function CopyField({ + value, + label, + fullWidth = false, +}: CopyFieldProps): React.ReactElement { + const [copied, setCopied] = useState(false) + + async function handleCopy(): Promise { + try { + await navigator.clipboard.writeText(value) + setCopied(true) + setTimeout(() => setCopied(false), COPY_FEEDBACK_MS) + } catch (_err) { + toast.error('Copy failed — your browser blocked clipboard access') + } + } + + return ( +
+ {label ? {label} : null} + {value} + +
+ ) +} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/components/DataRow/DataRow.module.scss b/PROJECTS/beginner/canary-token-generator/frontend/src/components/DataRow/DataRow.module.scss new file mode 100644 index 00000000..b839dc30 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/components/DataRow/DataRow.module.scss @@ -0,0 +1,60 @@ +// =================== +// ©AngelaMos | 2026 +// DataRow.module.scss +// =================== + +@use '../../styles/tokens' as *; +@use '../../styles/fonts' as *; + +.row { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: baseline; + gap: $space-2; + font-size: $font-size-sm; + line-height: $line-height-snug; + + &[data-alarm='true'] { + color: $alarm-deep; + } +} + +.label { + font-family: $font-mono; + font-size: $font-size-3xs; + letter-spacing: $tracking-wider; + text-transform: uppercase; + color: $ink-mute; + white-space: nowrap; +} + +.leader { + align-self: end; + block-size: $hairline; + background-image: linear-gradient( + to right, + $rule-soft 0, + $rule-soft 4px, + transparent 4px, + transparent 8px + ); + background-size: 8px $hairline; + transform: translateY(-3px); + min-inline-size: $space-4; +} + +.value { + color: inherit; + text-align: right; + overflow-wrap: anywhere; + + &[data-mono='true'] { + font-family: $font-mono; + font-size: $font-size-xs; + letter-spacing: $tracking-wide; + } + + &[data-emphasize='true'] { + font-weight: $font-weight-semibold; + } +} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/components/DataRow/index.tsx b/PROJECTS/beginner/canary-token-generator/frontend/src/components/DataRow/index.tsx new file mode 100644 index 00000000..67bbe503 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/components/DataRow/index.tsx @@ -0,0 +1,32 @@ +// =================== +// ©AngelaMos | 2026 +// index.tsx +// =================== + +import type { PropsWithChildren, ReactNode } from 'react' +import styles from './DataRow.module.scss' + +type DataRowProps = PropsWithChildren<{ + label: ReactNode + mono?: boolean + emphasize?: boolean + alarm?: boolean +}> + +export function DataRow({ + label, + children, + mono = false, + emphasize = false, + alarm = false, +}: DataRowProps): React.ReactElement { + return ( +
+ {label} +
+ ) +} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/components/Field/Field.module.scss b/PROJECTS/beginner/canary-token-generator/frontend/src/components/Field/Field.module.scss new file mode 100644 index 00000000..4a16cb5a --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/components/Field/Field.module.scss @@ -0,0 +1,108 @@ +// =================== +// ©AngelaMos | 2026 +// Field.module.scss +// =================== + +@use '../../styles/tokens' as *; +@use '../../styles/fonts' as *; + +.field { + display: flex; + flex-direction: column; + gap: $space-2; +} + +.head { + display: flex; + align-items: center; + gap: $space-2; +} + +.index { + font-family: $font-mono; + font-size: $font-size-3xs; + letter-spacing: $tracking-wider; + text-transform: uppercase; + color: $ink-mute; + white-space: nowrap; +} + +.label { + font-family: $font-mono; + font-size: $font-size-3xs; + letter-spacing: $tracking-wider; + text-transform: uppercase; + color: $ink; + font-weight: $font-weight-semibold; + white-space: nowrap; +} + +.required { + color: $alarm; +} + +.rule { + flex: 1 1 auto; + block-size: $hairline; + background-color: $rule-hair; +} + +.input, +.textarea, +.select { + inline-size: 100%; + padding-block: $space-2-5; + padding-inline: $space-3; + border: $hairline solid $rule; + background-color: $paper; + font-family: $font-sans; + font-size: $font-size-base; + color: $ink; + appearance: none; + border-radius: 0; + transition: border-color $duration-fast $ease-out; + + &::placeholder { + color: $ink-faint; + } + + &:focus { + outline: none; + border-color: $ink; + box-shadow: inset 0 0 0 $hairline $ink; + } + + .field[data-invalid='true'] & { + border-color: $alarm; + } +} + +.textarea { + min-block-size: 5lh; + resize: vertical; + font-family: $font-sans; + line-height: $line-height-normal; +} + +.select { + padding-inline-end: $space-8; + background-image: linear-gradient(45deg, transparent 50%, $ink 50%), + linear-gradient(135deg, $ink 50%, transparent 50%); + background-position: calc(100% - 18px) 50%, calc(100% - 13px) 50%; + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; +} + +.hint { + font-size: $font-size-xs; + color: $ink-mute; + font-family: $font-sans; +} + +.error { + font-family: $font-mono; + font-size: $font-size-3xs; + letter-spacing: $tracking-wider; + text-transform: uppercase; + color: $alarm; +} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/components/Field/index.tsx b/PROJECTS/beginner/canary-token-generator/frontend/src/components/Field/index.tsx new file mode 100644 index 00000000..54a265de --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/components/Field/index.tsx @@ -0,0 +1,163 @@ +// =================== +// ©AngelaMos | 2026 +// index.tsx +// =================== + +import type { + InputHTMLAttributes, + PropsWithChildren, + ReactNode, + SelectHTMLAttributes, + TextareaHTMLAttributes, +} from 'react' +import { forwardRef, useId } from 'react' +import styles from './Field.module.scss' + +type FieldWrapProps = PropsWithChildren<{ + label: ReactNode + hint?: ReactNode + error?: ReactNode + index?: string + required?: boolean + htmlFor?: string +}> + +export function FieldWrap({ + label, + hint, + error, + index, + required, + htmlFor, + children, +}: FieldWrapProps): React.ReactElement { + return ( +
+
+ {index ? {index} : null} + +
+ {children} + {hint && !error ?

{hint}

: null} + {error ? ( +

+ {error} +

+ ) : null} +
+ ) +} + +type TextFieldProps = Omit< + InputHTMLAttributes, + 'className' | 'id' +> & { + label: ReactNode + hint?: ReactNode + error?: ReactNode + index?: string +} + +export const TextField = forwardRef( + function TextField(props, ref) { + const { label, hint, error, index, required, ...rest } = props + const fallbackId = useId() + const id = rest.name ? `f-${rest.name}` : fallbackId + return ( + + + + ) + } +) + +type TextareaFieldProps = Omit< + TextareaHTMLAttributes, + 'className' | 'id' +> & { + label: ReactNode + hint?: ReactNode + error?: ReactNode + index?: string +} + +export const TextareaField = forwardRef( + function TextareaField(props, ref) { + const { label, hint, error, index, required, ...rest } = props + const fallbackId = useId() + const id = rest.name ? `f-${rest.name}` : fallbackId + return ( + +