Merge pull request #235 from CarterPerez-dev/project/canary-token-generator
Project/canary token generator
This commit is contained in:
commit
79ca7006b3
|
|
@ -0,0 +1,106 @@
|
|||
# =============================================================================
|
||||
# ©AngelaMos | 2026
|
||||
# .env.example
|
||||
# =============================================================================
|
||||
# Copy this file to .env and fill in values. .env is gitignored.
|
||||
# Run `just init` to auto-generate POSTGRES_PASSWORD + OPERATOR_TOKEN if empty.
|
||||
# =============================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public-facing
|
||||
# ---------------------------------------------------------------------------
|
||||
APP_NAME=canary-token-generator
|
||||
|
||||
# Production nginx host port (compose.yml). Dev compose has its own default
|
||||
# of 58495 in dev.compose.yml; do not override NGINX_HOST_PORT in shared .env.
|
||||
NGINX_HOST_PORT=22784
|
||||
|
||||
# Used to construct trigger URLs embedded in artifacts (e.g. canary docs).
|
||||
# Must be the externally-reachable URL of the deployed service.
|
||||
PUBLIC_BASE_URL=https://canary.your.domain
|
||||
|
||||
# Frontend build-time vars (baked into the bundle by Vite)
|
||||
VITE_APP_TITLE="Canary Token Generator"
|
||||
VITE_API_URL=/api
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cloudflare Turnstile (free anti-bot)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Get keys from https://dash.cloudflare.com/?to=/:account/turnstile
|
||||
# Leave blank to disable Turnstile in development.
|
||||
TURNSTILE_SITE_KEY=
|
||||
TURNSTILE_SECRET=
|
||||
# Mirror of TURNSTILE_SITE_KEY for frontend (must be the same value)
|
||||
VITE_TURNSTILE_SITE_KEY=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Operator (you) — for /api/admin/* endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-generated by scripts/init.sh if blank.
|
||||
# Send as `Authorization: Bearer <token>` to access admin endpoints.
|
||||
OPERATOR_TOKEN=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Postgres
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-generated by scripts/init.sh if blank.
|
||||
POSTGRES_PASSWORD=
|
||||
|
||||
# Dev-only Postgres host port (preserves randomized assignment across projects)
|
||||
POSTGRES_DEV_PORT=5447
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Redis (dev host port)
|
||||
# ---------------------------------------------------------------------------
|
||||
REDIS_DEV_PORT=6022
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GeoIP (optional — geolocation enrichment of triggered events)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Register free at https://www.maxmind.com/en/geolite2/signup
|
||||
# scripts/init.sh fetches GeoLite2-City.mmdb when both vars are set.
|
||||
MAXMIND_ACCOUNT_ID=
|
||||
MAXMIND_LICENSE_KEY=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Webhooks (optional)
|
||||
# ---------------------------------------------------------------------------
|
||||
# When set, webhook payloads are signed with HMAC-SHA256 and an
|
||||
# X-Canary-Signature header is added.
|
||||
WEBHOOK_HMAC_SECRET=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake MySQL TCP server (optional)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cloudflare Tunnel does not carry raw TCP — only enable when the canary
|
||||
# container's port 3306 is reachable directly (e.g. on a VPS).
|
||||
MYSQL_FAKE_ENABLED=false
|
||||
MYSQL_HOST_PORT=33606
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging
|
||||
# ---------------------------------------------------------------------------
|
||||
LOG_LEVEL=info
|
||||
LOG_FORMAT=json
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenTelemetry (optional — dev compose runs Jaeger)
|
||||
# ---------------------------------------------------------------------------
|
||||
OTEL_ENABLED=false
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||
|
||||
# Dev port overrides for jaeger (preserve randomized values)
|
||||
JAEGER_UI_PORT=16686
|
||||
JAEGER_OTLP_GRPC_PORT=4317
|
||||
JAEGER_OTLP_HTTP_PORT=4318
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Frontend dev (vite host port)
|
||||
# ---------------------------------------------------------------------------
|
||||
FRONTEND_HOST_PORT=15723
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cloudflare Tunnel (overlay)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Required if using cloudflared.compose.yml; obtain from Cloudflare Zero Trust dashboard.
|
||||
CLOUDFLARE_TUNNEL_TOKEN=
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
# ©AngelaMos | 2026
|
||||
# .gitignore
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Dev-only documentation (never committed)
|
||||
# ----------------------------------------------------------------------------
|
||||
docs/
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Environment files (secrets)
|
||||
# ----------------------------------------------------------------------------
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Backend (Go)
|
||||
# ----------------------------------------------------------------------------
|
||||
backend/bin/
|
||||
backend/tmp/
|
||||
backend/*.test
|
||||
backend/coverage.out
|
||||
backend/coverage.html
|
||||
backend/.golangci-cache/
|
||||
backend/data/
|
||||
backend/keys/
|
||||
# stray binaries from `go build ./cmd/<name>` run in backend/
|
||||
backend/canary
|
||||
backend/healthcheck
|
||||
backend/buildpdftemplate
|
||||
backend/builddocxtemplate
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Frontend (Node / Vite)
|
||||
# ----------------------------------------------------------------------------
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
frontend/.vite-temp/
|
||||
frontend/.tmp/
|
||||
frontend/.biome_cache/
|
||||
frontend/.turbo/
|
||||
frontend/coverage/
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Local data volumes (when bind-mounted)
|
||||
# ----------------------------------------------------------------------------
|
||||
data/
|
||||
*.mmdb
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# OS / Editor
|
||||
# ----------------------------------------------------------------------------
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Logs
|
||||
# ----------------------------------------------------------------------------
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Generic build artifacts
|
||||
# ----------------------------------------------------------------------------
|
||||
dist/
|
||||
build/
|
||||
bin/
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Linter / pre-commit caches
|
||||
# ----------------------------------------------------------------------------
|
||||
.cache/
|
||||
.pre-commit-cache/
|
||||
.ruff_cache/
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
```yaml
|
||||
██████╗ █████╗ ███╗ ██╗ █████╗ ██████╗ ██╗ ██╗
|
||||
██╔════╝██╔══██╗████╗ ██║██╔══██╗██╔══██╗╚██╗ ██╔╝
|
||||
██║ ███████║██╔██╗ ██║███████║██████╔╝ ╚████╔╝
|
||||
██║ ██╔══██║██║╚██╗██║██╔══██║██╔══██╗ ╚██╔╝
|
||||
╚██████╗██║ ██║██║ ╚████║██║ ██║██║ ██║ ██║
|
||||
╚═════╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝
|
||||
```
|
||||
|
||||
[](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/beginner/canary-token-generator)
|
||||
[](https://go.dev)
|
||||
[](https://react.dev)
|
||||
[](https://www.postgresql.org)
|
||||
[](https://redis.io)
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
[](https://www.docker.com)
|
||||
[](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
|
||||
|
|
@ -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
|
||||
|
|
@ -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*
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
// ©AngelaMos | 2026
|
||||
// main.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
contentTypesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
<Override PartName="/word/footer2.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>
|
||||
</Types>
|
||||
`
|
||||
|
||||
packageRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||
</Relationships>
|
||||
`
|
||||
|
||||
documentXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<w:body>
|
||||
<w:p><w:r><w:t xml:space="preserve">Internal document - confidential. Do not redistribute.</w:t></w:r></w:p>
|
||||
<w:sectPr>
|
||||
<w:footerReference w:type="default" r:id="rIdFooter"/>
|
||||
<w:pgSz w:w="12240" w:h="15840"/>
|
||||
<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>
|
||||
</w:sectPr>
|
||||
</w:body>
|
||||
</w:document>
|
||||
`
|
||||
|
||||
documentRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rIdFooter" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer2.xml"/>
|
||||
</Relationships>
|
||||
`
|
||||
|
||||
footer2XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:p>
|
||||
<w:r><w:fldChar w:fldCharType="begin"/></w:r>
|
||||
<w:r><w:instrText xml:space="preserve"> INCLUDEPICTURE "HONEY_TRACK_URL" \d \* MERGEFORMAT </w:instrText></w:r>
|
||||
<w:r><w:fldChar w:fldCharType="end"/></w:r>
|
||||
</w:p>
|
||||
</w:ftr>
|
||||
`
|
||||
|
||||
pathContentTypes = "[Content_Types].xml"
|
||||
pathPackageRels = "_rels/.rels"
|
||||
pathDocument = "word/document.xml"
|
||||
pathDocumentRels = "word/_rels/document.xml.rels"
|
||||
pathFooter2 = "word/footer2.xml"
|
||||
|
||||
dirPerm os.FileMode = 0o755
|
||||
filePerm os.FileMode = 0o644
|
||||
)
|
||||
|
||||
type entry struct {
|
||||
name string
|
||||
body string
|
||||
method uint16
|
||||
}
|
||||
|
||||
func entries() []entry {
|
||||
return []entry{
|
||||
{name: pathContentTypes, body: contentTypesXML, method: zip.Store},
|
||||
{name: pathPackageRels, body: packageRelsXML, method: zip.Deflate},
|
||||
{name: pathDocument, body: documentXML, method: zip.Deflate},
|
||||
{name: pathDocumentRels, body: documentRelsXML, method: zip.Store},
|
||||
{name: pathFooter2, body: footer2XML, method: zip.Deflate},
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
out := flag.String("out", "", "output path for template.docx")
|
||||
flag.Parse()
|
||||
|
||||
if *out == "" {
|
||||
log.Fatal("usage: builddocxtemplate -out <path>")
|
||||
}
|
||||
|
||||
if err := buildTemplate(*out); err != nil {
|
||||
log.Fatalf("build docx template: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("wrote %s\n", *out)
|
||||
}
|
||||
|
||||
func buildTemplate(out string) (err error) {
|
||||
cleaned := filepath.Clean(out)
|
||||
if mkErr := os.MkdirAll(filepath.Dir(cleaned), dirPerm); mkErr != nil {
|
||||
return fmt.Errorf("mkdir parent: %w", mkErr)
|
||||
}
|
||||
|
||||
f, oErr := os.OpenFile(
|
||||
cleaned,
|
||||
os.O_CREATE|os.O_TRUNC|os.O_WRONLY,
|
||||
filePerm,
|
||||
)
|
||||
if oErr != nil {
|
||||
return fmt.Errorf("open output: %w", oErr)
|
||||
}
|
||||
defer func() {
|
||||
if cerr := f.Close(); cerr != nil && err == nil {
|
||||
err = fmt.Errorf("close output: %w", cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
w := zip.NewWriter(f)
|
||||
for _, e := range entries() {
|
||||
hdr := &zip.FileHeader{Name: e.name, Method: e.method}
|
||||
fw, cErr := w.CreateHeader(hdr)
|
||||
if cErr != nil {
|
||||
return fmt.Errorf("create %s: %w", e.name, cErr)
|
||||
}
|
||||
if _, wErr := fw.Write([]byte(e.body)); wErr != nil {
|
||||
return fmt.Errorf("write %s: %w", e.name, wErr)
|
||||
}
|
||||
}
|
||||
if zErr := w.Close(); zErr != nil {
|
||||
return fmt.Errorf("close zip writer: %w", zErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
// ©AngelaMos | 2026
|
||||
// main.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/pdfcpu/pdfcpu/pkg/api"
|
||||
)
|
||||
|
||||
const (
|
||||
placeholderRoot = "HONEY_TRACK_URL_PADDED_TO_FIXED_WIDTH"
|
||||
placeholderLength = 76
|
||||
|
||||
pdfHeader = "%PDF-1.4\n%\xe2\xe3\xcf\xd3\n"
|
||||
|
||||
objCatalog = "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"
|
||||
objPages = "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"
|
||||
|
||||
pageDictFmt = "3 0 obj\n" +
|
||||
"<<\n" +
|
||||
"/Type /Page\n" +
|
||||
"/Parent 2 0 R\n" +
|
||||
"/MediaBox [0 0 612 792]\n" +
|
||||
"/Resources << >>\n" +
|
||||
"/AA << /O << /Type /Action /S /URI /URI (%s) >> >>\n" +
|
||||
">>\nendobj\n"
|
||||
|
||||
xrefHeader = "xref\n0 4\n"
|
||||
xrefFreeRecord = "0000000000 65535 f \n"
|
||||
xrefRecordFmt = "%010d 00000 n \n"
|
||||
|
||||
trailerFmt = "trailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n"
|
||||
|
||||
dirPerm os.FileMode = 0o755
|
||||
filePerm os.FileMode = 0o644
|
||||
)
|
||||
|
||||
func placeholder() string {
|
||||
return placeholderRoot +
|
||||
strings.Repeat("_", placeholderLength-len(placeholderRoot))
|
||||
}
|
||||
|
||||
func main() {
|
||||
out := flag.String("out", "", "output path for template.pdf")
|
||||
flag.Parse()
|
||||
|
||||
if *out == "" {
|
||||
log.Fatal("usage: buildpdftemplate -out <path>")
|
||||
}
|
||||
|
||||
if err := buildTemplate(*out); err != nil {
|
||||
log.Fatalf("build pdf template: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("wrote %s\n", *out)
|
||||
}
|
||||
|
||||
func buildTemplate(out string) (err error) {
|
||||
cleaned := filepath.Clean(out)
|
||||
if mkErr := os.MkdirAll(filepath.Dir(cleaned), dirPerm); mkErr != nil {
|
||||
return fmt.Errorf("mkdir parent: %w", mkErr)
|
||||
}
|
||||
|
||||
pdfBytes := assemblePDF()
|
||||
|
||||
if vErr := api.Validate(
|
||||
bytes.NewReader(pdfBytes),
|
||||
nil,
|
||||
); vErr != nil {
|
||||
return fmt.Errorf("pdfcpu validate: %w", vErr)
|
||||
}
|
||||
|
||||
full := placeholder()
|
||||
if c := bytes.Count(pdfBytes, []byte(full)); c != 1 {
|
||||
return fmt.Errorf(
|
||||
"full placeholder appears %d times, expected exactly 1",
|
||||
c,
|
||||
)
|
||||
}
|
||||
if c := bytes.Count(pdfBytes, []byte(placeholderRoot)); c != 1 {
|
||||
return fmt.Errorf(
|
||||
"placeholder root appears %d times, expected exactly 1",
|
||||
c,
|
||||
)
|
||||
}
|
||||
if len(full) != placeholderLength {
|
||||
return fmt.Errorf(
|
||||
"placeholder length is %d, expected %d",
|
||||
len(full),
|
||||
placeholderLength,
|
||||
)
|
||||
}
|
||||
|
||||
f, oErr := os.OpenFile(
|
||||
cleaned,
|
||||
os.O_CREATE|os.O_TRUNC|os.O_WRONLY,
|
||||
filePerm,
|
||||
)
|
||||
if oErr != nil {
|
||||
return fmt.Errorf("open output: %w", oErr)
|
||||
}
|
||||
defer func() {
|
||||
if cerr := f.Close(); cerr != nil && err == nil {
|
||||
err = fmt.Errorf("close output: %w", cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, wErr := f.Write(pdfBytes); wErr != nil {
|
||||
return fmt.Errorf("write pdf: %w", wErr)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(pdfBytes)
|
||||
fmt.Printf("sha256: %x\n", sum)
|
||||
fmt.Printf("size: %d bytes\n", len(pdfBytes))
|
||||
return nil
|
||||
}
|
||||
|
||||
func assemblePDF() []byte {
|
||||
pageObj := fmt.Sprintf(pageDictFmt, placeholder())
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(pdfHeader)
|
||||
|
||||
offsets := make([]int, 4)
|
||||
|
||||
offsets[1] = buf.Len()
|
||||
buf.WriteString(objCatalog)
|
||||
|
||||
offsets[2] = buf.Len()
|
||||
buf.WriteString(objPages)
|
||||
|
||||
offsets[3] = buf.Len()
|
||||
buf.WriteString(pageObj)
|
||||
|
||||
xrefOffset := buf.Len()
|
||||
buf.WriteString(xrefHeader)
|
||||
buf.WriteString(xrefFreeRecord)
|
||||
for i := 1; i <= 3; i++ {
|
||||
fmt.Fprintf(&buf, xrefRecordFmt, offsets[i])
|
||||
}
|
||||
|
||||
fmt.Fprintf(&buf, trailerFmt, xrefOffset)
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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=
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"`)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)")
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -0,0 +1,391 @@
|
|||
// ©AngelaMos | 2026
|
||||
// sender_test.go
|
||||
|
||||
package telegram_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/notify/telegram"
|
||||
)
|
||||
|
||||
const (
|
||||
testBotToken = "111222:ABCDEFG"
|
||||
testChatID = "98765"
|
||||
testMemo = "prod-db-creds"
|
||||
testTokenID = "tokabc123def"
|
||||
testManageID = "11111111-2222-3333-4444-555555555555"
|
||||
)
|
||||
|
||||
func sampleInfo() event.NotifyInfo {
|
||||
return event.NotifyInfo{
|
||||
TokenID: testTokenID,
|
||||
ManageID: testManageID,
|
||||
Type: "envfile",
|
||||
Memo: testMemo,
|
||||
AlertChannel: "telegram",
|
||||
TelegramBot: testBotToken,
|
||||
TelegramChat: testChatID,
|
||||
}
|
||||
}
|
||||
|
||||
func sampleEvent() *event.Event {
|
||||
ua := "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
city := "Toronto"
|
||||
country := "CA"
|
||||
asnOrg := "Cloudflare, Inc."
|
||||
return &event.Event{
|
||||
ID: 42,
|
||||
TokenID: testTokenID,
|
||||
TriggeredAt: time.Date(2026, 5, 14, 12, 30, 0, 0, time.UTC),
|
||||
SourceIP: "203.0.113.45",
|
||||
UserAgent: &ua,
|
||||
GeoCity: &city,
|
||||
GeoCountry: &country,
|
||||
GeoASNOrg: &asnOrg,
|
||||
}
|
||||
}
|
||||
|
||||
type capture struct {
|
||||
calls atomic.Int32
|
||||
lastURL atomic.Value
|
||||
lastBody atomic.Value
|
||||
}
|
||||
|
||||
func writeOK(t *testing.T, w http.ResponseWriter) {
|
||||
t.Helper()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write([]byte(`{"ok":true}`)); err != nil {
|
||||
t.Logf("write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newCaptureServer(
|
||||
t *testing.T,
|
||||
handler http.HandlerFunc,
|
||||
) (*httptest.Server, *capture) {
|
||||
t.Helper()
|
||||
c := &capture{}
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.calls.Add(1)
|
||||
c.lastURL.Store(r.URL.String())
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Logf("read body: %v", err)
|
||||
return
|
||||
}
|
||||
c.lastBody.Store(body)
|
||||
handler(w, r)
|
||||
}),
|
||||
)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, c
|
||||
}
|
||||
|
||||
func loadBody(t *testing.T, c *capture) []byte {
|
||||
t.Helper()
|
||||
raw, ok := c.lastBody.Load().([]byte)
|
||||
require.True(t, ok, "no body captured")
|
||||
return raw
|
||||
}
|
||||
|
||||
func newSender(
|
||||
t *testing.T,
|
||||
apiBase string,
|
||||
opts ...telegram.Option,
|
||||
) *telegram.Sender {
|
||||
t.Helper()
|
||||
cfg := telegram.Config{
|
||||
APIBase: apiBase,
|
||||
ManageURL: "https://canary.example.com",
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(&cfg)
|
||||
}
|
||||
return telegram.NewSender(cfg)
|
||||
}
|
||||
|
||||
func TestSender_Channel(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newSender(t, "https://api.telegram.org")
|
||||
require.Equal(t, "telegram", s.Channel())
|
||||
}
|
||||
|
||||
func TestSender_Send_PostsToCorrectURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, cap := newCaptureServer(
|
||||
t,
|
||||
func(w http.ResponseWriter, _ *http.Request) { writeOK(t, w) },
|
||||
)
|
||||
s := newSender(t, srv.URL)
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
s.Send(context.Background(), sampleInfo(), sampleEvent()),
|
||||
)
|
||||
require.Equal(t, int32(1), cap.calls.Load())
|
||||
require.Equal(t, "/bot"+testBotToken+"/sendMessage", cap.lastURL.Load())
|
||||
}
|
||||
|
||||
func TestSender_Send_BodyShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, cap := newCaptureServer(
|
||||
t,
|
||||
func(w http.ResponseWriter, _ *http.Request) { writeOK(t, w) },
|
||||
)
|
||||
s := newSender(t, srv.URL)
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
s.Send(context.Background(), sampleInfo(), sampleEvent()),
|
||||
)
|
||||
|
||||
var body map[string]string
|
||||
require.NoError(t, json.Unmarshal(loadBody(t, cap), &body))
|
||||
require.Equal(t, testChatID, body["chat_id"])
|
||||
require.Equal(t, "MarkdownV2", body["parse_mode"])
|
||||
require.NotEmpty(t, body["text"])
|
||||
}
|
||||
|
||||
func TestSender_Send_MessageContainsKeyFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, cap := newCaptureServer(
|
||||
t,
|
||||
func(w http.ResponseWriter, _ *http.Request) { writeOK(t, w) },
|
||||
)
|
||||
s := newSender(t, srv.URL)
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
s.Send(context.Background(), sampleInfo(), sampleEvent()),
|
||||
)
|
||||
|
||||
var body map[string]string
|
||||
require.NoError(t, json.Unmarshal(loadBody(t, cap), &body))
|
||||
text := body["text"]
|
||||
|
||||
require.Contains(t, text, "Canary triggered")
|
||||
require.Contains(
|
||||
t,
|
||||
text,
|
||||
`prod\-db\-creds`,
|
||||
"memo escaped (- is V2 special)",
|
||||
)
|
||||
require.Contains(t, text, "envfile")
|
||||
require.Contains(t, text, `203\.0\.113\.45`, "IP dots escaped")
|
||||
require.Contains(t,
|
||||
text,
|
||||
`\(Toronto, CA\)`,
|
||||
"geo wrapping parens escaped (V2 reserved chars)",
|
||||
)
|
||||
require.Contains(t, text, `Cloudflare, Inc\.`, "asn_org . escaped")
|
||||
require.Contains(t, text, "View full event timeline", "manage link present")
|
||||
require.Contains(t,
|
||||
text,
|
||||
"https://canary.example.com/m/"+testManageID,
|
||||
"manage URL present in link",
|
||||
)
|
||||
}
|
||||
|
||||
func TestSender_Send_TruncatesUserAgentTo80Chars(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, cap := newCaptureServer(
|
||||
t,
|
||||
func(w http.ResponseWriter, _ *http.Request) { writeOK(t, w) },
|
||||
)
|
||||
s := newSender(t, srv.URL)
|
||||
|
||||
longUA := strings.Repeat("X", 200)
|
||||
evt := sampleEvent()
|
||||
evt.UserAgent = &longUA
|
||||
|
||||
require.NoError(t, s.Send(context.Background(), sampleInfo(), evt))
|
||||
|
||||
var body map[string]string
|
||||
require.NoError(t, json.Unmarshal(loadBody(t, cap), &body))
|
||||
require.Contains(t, body["text"], strings.Repeat("X", 80))
|
||||
require.NotContains(t, body["text"], strings.Repeat("X", 81),
|
||||
"user agent should be truncated to 80 chars")
|
||||
}
|
||||
|
||||
func TestSender_Send_HandlesNilGeoAndUA(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, cap := newCaptureServer(
|
||||
t,
|
||||
func(w http.ResponseWriter, _ *http.Request) { writeOK(t, w) },
|
||||
)
|
||||
s := newSender(t, srv.URL)
|
||||
|
||||
evt := &event.Event{
|
||||
ID: 1,
|
||||
TokenID: testTokenID,
|
||||
TriggeredAt: time.Date(2026, 5, 14, 12, 30, 0, 0, time.UTC),
|
||||
SourceIP: "203.0.113.45",
|
||||
}
|
||||
require.NoError(t, s.Send(context.Background(), sampleInfo(), evt))
|
||||
|
||||
var body map[string]string
|
||||
require.NoError(t, json.Unmarshal(loadBody(t, cap), &body))
|
||||
require.Contains(t, body["text"], `203\.0\.113\.45`)
|
||||
}
|
||||
|
||||
func TestSender_Send_EmptyBotReturnsConfigErr(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newSender(t, "http://unused.test")
|
||||
info := sampleInfo()
|
||||
info.TelegramBot = ""
|
||||
err := s.Send(context.Background(), info, sampleEvent())
|
||||
require.ErrorIs(t, err, telegram.ErrChannelNotConfigured)
|
||||
}
|
||||
|
||||
func TestSender_Send_EmptyChatReturnsConfigErr(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newSender(t, "http://unused.test")
|
||||
info := sampleInfo()
|
||||
info.TelegramChat = ""
|
||||
err := s.Send(context.Background(), info, sampleEvent())
|
||||
require.ErrorIs(t, err, telegram.ErrChannelNotConfigured)
|
||||
}
|
||||
|
||||
func TestSender_Send_RetriesOn5xxThenSucceeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
var attempts atomic.Int32
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
n := attempts.Add(1)
|
||||
if n == 1 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeOK(t, w)
|
||||
}),
|
||||
)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
s := newSender(t, srv.URL,
|
||||
telegram.WithMaxTries(3),
|
||||
telegram.WithMaxElapsed(2*time.Second),
|
||||
telegram.WithInitialInterval(5*time.Millisecond),
|
||||
)
|
||||
require.NoError(
|
||||
t,
|
||||
s.Send(context.Background(), sampleInfo(), sampleEvent()),
|
||||
)
|
||||
require.Equal(t, int32(2), attempts.Load())
|
||||
}
|
||||
|
||||
func TestSender_Send_PermanentOn4xx(t *testing.T) {
|
||||
t.Parallel()
|
||||
var attempts atomic.Int32
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
attempts.Add(1)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
if _, err := w.Write(
|
||||
[]byte(`{"ok":false,"description":"chat not found"}`),
|
||||
); err != nil {
|
||||
t.Logf("write: %v", err)
|
||||
}
|
||||
}),
|
||||
)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
s := newSender(t, srv.URL,
|
||||
telegram.WithMaxTries(5),
|
||||
telegram.WithInitialInterval(5*time.Millisecond),
|
||||
)
|
||||
err := s.Send(context.Background(), sampleInfo(), sampleEvent())
|
||||
require.Error(t, err)
|
||||
require.Equal(t, int32(1), attempts.Load(), "no retry on 4xx")
|
||||
}
|
||||
|
||||
func TestSender_Send_AbortsAfterMaxTries(t *testing.T) {
|
||||
t.Parallel()
|
||||
var attempts atomic.Int32
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
attempts.Add(1)
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}),
|
||||
)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
s := newSender(t, srv.URL,
|
||||
telegram.WithMaxTries(3),
|
||||
telegram.WithMaxElapsed(5*time.Second),
|
||||
telegram.WithInitialInterval(2*time.Millisecond),
|
||||
)
|
||||
err := s.Send(context.Background(), sampleInfo(), sampleEvent())
|
||||
require.Error(t, err)
|
||||
require.Equal(t, int32(3), attempts.Load())
|
||||
}
|
||||
|
||||
func TestSender_Send_RespectsContextCancel(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(2 * time.Second)
|
||||
}),
|
||||
)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
s := newSender(t, srv.URL,
|
||||
telegram.WithMaxTries(3),
|
||||
telegram.WithInitialInterval(5*time.Millisecond),
|
||||
)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
100*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
err := s.Send(ctx, sampleInfo(), sampleEvent())
|
||||
require.Error(t, err)
|
||||
require.True(t,
|
||||
errors.Is(err, context.DeadlineExceeded) ||
|
||||
errors.Is(err, context.Canceled),
|
||||
"expected context error, got %v", err,
|
||||
)
|
||||
}
|
||||
|
||||
func TestEscapeMD(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"plain", "plain"},
|
||||
{"a.b", `a\.b`},
|
||||
{"a-b", `a\-b`},
|
||||
{"file.txt", `file\.txt`},
|
||||
{"203.0.113.45", `203\.0\.113\.45`},
|
||||
{"hello!", `hello\!`},
|
||||
{"a_b*c", `a\_b\*c`},
|
||||
{"(parens)", `\(parens\)`},
|
||||
{"[bracket]", `\[bracket\]`},
|
||||
{"~tilde~", `\~tilde\~`},
|
||||
{"`code`", "\\`code\\`"},
|
||||
{"a>b<c", `a\>b<c`},
|
||||
{"a#b", `a\#b`},
|
||||
{"a+b=c", `a\+b\=c`},
|
||||
{"a|b{c}d", `a\|b\{c\}d`},
|
||||
{"unicode—em", "unicode—em"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
require.Equal(t, tc.want, telegram.EscapeMD(tc.in))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
// ©AngelaMos | 2026
|
||||
// types.go
|
||||
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
|
||||
)
|
||||
|
||||
type Sender interface {
|
||||
Channel() string
|
||||
Send(
|
||||
ctx context.Context,
|
||||
info event.NotifyInfo,
|
||||
evt *event.Event,
|
||||
) error
|
||||
}
|
||||
|
||||
type StatusWriter interface {
|
||||
UpdateNotifyStatus(
|
||||
ctx context.Context,
|
||||
eventID int64,
|
||||
status event.NotifyStatus,
|
||||
sentAt *time.Time,
|
||||
) error
|
||||
}
|
||||
|
|
@ -0,0 +1,438 @@
|
|||
// ©AngelaMos | 2026
|
||||
// sender.go
|
||||
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v5"
|
||||
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
|
||||
)
|
||||
|
||||
const (
|
||||
Channel = "webhook"
|
||||
|
||||
envelopeVersion = "1"
|
||||
envelopeEvent = "canary.triggered"
|
||||
|
||||
defaultMaxTries = 3
|
||||
defaultMaxElapsed = 30 * time.Second
|
||||
defaultInitialInterval = 500 * time.Millisecond
|
||||
defaultOverallTimeout = 10 * time.Second
|
||||
defaultDialTimeout = 5 * time.Second
|
||||
|
||||
contentTypeJSON = "application/json"
|
||||
signatureHeaderName = "X-Canary-Signature"
|
||||
signaturePrefix = "sha256="
|
||||
)
|
||||
|
||||
var (
|
||||
ErrChannelNotConfigured = errors.New(
|
||||
"webhook: webhook URL not configured",
|
||||
)
|
||||
ErrInvalidWebhookURL = errors.New("webhook: invalid url")
|
||||
ErrBlockedHost = errors.New(
|
||||
"webhook: host resolves to a blocked address range",
|
||||
)
|
||||
ErrWebhookAPI = errors.New("webhook: api error")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ManageURL string
|
||||
HMACSecret string
|
||||
HTTPClient *http.Client
|
||||
MaxTries uint
|
||||
MaxElapsed time.Duration
|
||||
InitialInterval time.Duration
|
||||
AllowPrivateHosts bool
|
||||
}
|
||||
|
||||
type Option func(*Config)
|
||||
|
||||
func WithMaxTries(n uint) Option { return func(c *Config) { c.MaxTries = n } }
|
||||
|
||||
func WithMaxElapsed(d time.Duration) Option {
|
||||
return func(c *Config) { c.MaxElapsed = d }
|
||||
}
|
||||
|
||||
func WithInitialInterval(d time.Duration) Option {
|
||||
return func(c *Config) { c.InitialInterval = d }
|
||||
}
|
||||
|
||||
func WithHTTPClient(client *http.Client) Option {
|
||||
return func(c *Config) { c.HTTPClient = client }
|
||||
}
|
||||
|
||||
func WithAllowPrivateHosts(allow bool) Option {
|
||||
return func(c *Config) { c.AllowPrivateHosts = allow }
|
||||
}
|
||||
|
||||
type Sender struct {
|
||||
manageURL string
|
||||
hmacSecret string
|
||||
httpClient *http.Client
|
||||
maxTries uint
|
||||
maxElapsed time.Duration
|
||||
initialInterval time.Duration
|
||||
allowPrivateHosts bool
|
||||
}
|
||||
|
||||
func NewSender(cfg Config, opts ...Option) *Sender {
|
||||
for _, o := range opts {
|
||||
o(&cfg)
|
||||
}
|
||||
if cfg.HTTPClient == nil {
|
||||
cfg.HTTPClient = defaultHTTPClient(cfg.AllowPrivateHosts)
|
||||
}
|
||||
if cfg.MaxTries == 0 {
|
||||
cfg.MaxTries = defaultMaxTries
|
||||
}
|
||||
if cfg.MaxElapsed == 0 {
|
||||
cfg.MaxElapsed = defaultMaxElapsed
|
||||
}
|
||||
if cfg.InitialInterval == 0 {
|
||||
cfg.InitialInterval = defaultInitialInterval
|
||||
}
|
||||
return &Sender{
|
||||
manageURL: strings.TrimRight(cfg.ManageURL, "/"),
|
||||
hmacSecret: cfg.HMACSecret,
|
||||
httpClient: cfg.HTTPClient,
|
||||
maxTries: cfg.MaxTries,
|
||||
maxElapsed: cfg.MaxElapsed,
|
||||
initialInterval: cfg.InitialInterval,
|
||||
allowPrivateHosts: cfg.AllowPrivateHosts,
|
||||
}
|
||||
}
|
||||
|
||||
func defaultHTTPClient(allowPrivateHosts bool) *http.Client {
|
||||
base := &net.Dialer{Timeout: defaultDialTimeout}
|
||||
dialFn := base.DialContext
|
||||
if !allowPrivateHosts {
|
||||
dialFn = func(
|
||||
ctx context.Context,
|
||||
network, addr string,
|
||||
) (net.Conn, error) {
|
||||
if err := preDialIPCheck(addr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn, err := base.DialContext(ctx, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := postDialIPCheck(conn); err != nil {
|
||||
if cErr := conn.Close(); cErr != nil {
|
||||
slog.Warn("webhook: close blocked conn",
|
||||
"error", cErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: defaultOverallTimeout,
|
||||
Transport: &http.Transport{
|
||||
DialContext: dialFn,
|
||||
TLSHandshakeTimeout: defaultDialTimeout,
|
||||
ResponseHeaderTimeout: defaultOverallTimeout,
|
||||
ExpectContinueTimeout: time.Second,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func preDialIPCheck(addr string) error {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webhook: split host port: %w", err)
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return nil
|
||||
}
|
||||
if isBlockedIP(ip) {
|
||||
return fmt.Errorf("%w: dial blocked %s", ErrBlockedHost, ip)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func postDialIPCheck(conn net.Conn) error {
|
||||
tcpAddr, ok := conn.RemoteAddr().(*net.TCPAddr)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if isBlockedIP(tcpAddr.IP) {
|
||||
return fmt.Errorf("%w: dial blocked %s", ErrBlockedHost, tcpAddr.IP)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Sender) Channel() string { return Channel }
|
||||
|
||||
func (s *Sender) Validate(raw string) error {
|
||||
return validateURL(raw, s.allowPrivateHosts)
|
||||
}
|
||||
|
||||
func (s *Sender) Send(
|
||||
ctx context.Context,
|
||||
info event.NotifyInfo,
|
||||
evt *event.Event,
|
||||
) error {
|
||||
if strings.TrimSpace(info.WebhookURL) == "" {
|
||||
return ErrChannelNotConfigured
|
||||
}
|
||||
if err := validateURL(info.WebhookURL, s.allowPrivateHosts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := json.Marshal(buildEnvelope(info, evt, s.manageURL))
|
||||
if err != nil {
|
||||
return fmt.Errorf("webhook: marshal envelope: %w", err)
|
||||
}
|
||||
|
||||
expBackoff := backoff.NewExponentialBackOff()
|
||||
expBackoff.InitialInterval = s.initialInterval
|
||||
expBackoff.MaxInterval = 5 * time.Second
|
||||
|
||||
_, err = backoff.Retry(
|
||||
ctx,
|
||||
func() (struct{}, error) {
|
||||
return struct{}{}, s.doRequest(ctx, info.WebhookURL, body)
|
||||
},
|
||||
backoff.WithBackOff(expBackoff),
|
||||
backoff.WithMaxTries(s.maxTries),
|
||||
backoff.WithMaxElapsedTime(s.maxElapsed),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Sender) doRequest(
|
||||
ctx context.Context,
|
||||
endpoint string,
|
||||
body []byte,
|
||||
) error {
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
endpoint,
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
return backoff.Permanent(
|
||||
fmt.Errorf("webhook: build request: %w", err),
|
||||
)
|
||||
}
|
||||
req.Header.Set("Content-Type", contentTypeJSON)
|
||||
if s.hmacSecret != "" {
|
||||
req.Header.Set(
|
||||
signatureHeaderName,
|
||||
computeSignature(s.hmacSecret, body),
|
||||
)
|
||||
}
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webhook: do request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if cErr := resp.Body.Close(); cErr != nil {
|
||||
slog.WarnContext(ctx, "webhook: close body",
|
||||
"error", cErr)
|
||||
}
|
||||
}()
|
||||
|
||||
respBody, rErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if rErr != nil {
|
||||
slog.WarnContext(ctx, "webhook: read body", "error", rErr)
|
||||
}
|
||||
|
||||
switch {
|
||||
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||
return nil
|
||||
case resp.StatusCode >= 400 && resp.StatusCode < 500:
|
||||
return backoff.Permanent(fmt.Errorf(
|
||||
"%w: status=%d body=%s",
|
||||
ErrWebhookAPI, resp.StatusCode, string(respBody),
|
||||
))
|
||||
default:
|
||||
return fmt.Errorf(
|
||||
"%w: status=%d body=%s",
|
||||
ErrWebhookAPI, resp.StatusCode, string(respBody),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func validateURL(raw string, allowPrivateHosts bool) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: parse: %w", ErrInvalidWebhookURL, err)
|
||||
}
|
||||
scheme := strings.ToLower(u.Scheme)
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return fmt.Errorf(
|
||||
"%w: scheme must be http or https, got %q",
|
||||
ErrInvalidWebhookURL, u.Scheme,
|
||||
)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return fmt.Errorf("%w: missing host", ErrInvalidWebhookURL)
|
||||
}
|
||||
if u.User != nil {
|
||||
return fmt.Errorf("%w: userinfo not allowed", ErrInvalidWebhookURL)
|
||||
}
|
||||
if allowPrivateHosts {
|
||||
return nil
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
return fmt.Errorf("%w: missing hostname", ErrInvalidWebhookURL)
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if isBlockedIP(ip) {
|
||||
return fmt.Errorf("%w: %s", ErrBlockedHost, ip)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ips, lookupErr := net.LookupIP(host)
|
||||
if lookupErr != nil {
|
||||
return fmt.Errorf(
|
||||
"%w: lookup %s: %w",
|
||||
ErrInvalidWebhookURL, host, lookupErr,
|
||||
)
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return fmt.Errorf("%w: no IPs for %s", ErrInvalidWebhookURL, host)
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if isBlockedIP(ip) {
|
||||
return fmt.Errorf("%w: %s -> %s", ErrBlockedHost, host, ip)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isBlockedIP(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return true
|
||||
}
|
||||
if ip.IsLoopback() || ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() || ip.IsMulticast() ||
|
||||
ip.IsUnspecified() {
|
||||
return true
|
||||
}
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
switch {
|
||||
case ip4[0] == 10:
|
||||
return true
|
||||
case ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31:
|
||||
return true
|
||||
case ip4[0] == 192 && ip4[1] == 168:
|
||||
return true
|
||||
case ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127:
|
||||
return true
|
||||
case ip4[0] == 169 && ip4[1] == 254:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if len(ip) == net.IPv6len && ip[0]&0xfe == 0xfc {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type envelope struct {
|
||||
Version string `json:"version"`
|
||||
Event string `json:"event"`
|
||||
Token tokenSection `json:"token"`
|
||||
Trigger triggerSection `json:"trigger"`
|
||||
}
|
||||
|
||||
type tokenSection struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Memo string `json:"memo"`
|
||||
ManageURL string `json:"manage_url"`
|
||||
}
|
||||
|
||||
type triggerSection struct {
|
||||
TriggeredAt time.Time `json:"triggered_at"`
|
||||
SourceIP string `json:"source_ip"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Geo geoSection `json:"geo"`
|
||||
Extra json.RawMessage `json:"extra"`
|
||||
}
|
||||
|
||||
type geoSection struct {
|
||||
Country string `json:"country"`
|
||||
City string `json:"city"`
|
||||
ASNOrg string `json:"asn_org"`
|
||||
}
|
||||
|
||||
func buildEnvelope(
|
||||
info event.NotifyInfo,
|
||||
evt *event.Event,
|
||||
manageURL string,
|
||||
) envelope {
|
||||
extra := evt.Extra
|
||||
if len(extra) == 0 {
|
||||
extra = json.RawMessage(`{}`)
|
||||
}
|
||||
return envelope{
|
||||
Version: envelopeVersion,
|
||||
Event: envelopeEvent,
|
||||
Token: tokenSection{
|
||||
ID: info.TokenID,
|
||||
Type: info.Type,
|
||||
Memo: info.Memo,
|
||||
ManageURL: buildManageURL(manageURL, info.ManageID),
|
||||
},
|
||||
Trigger: triggerSection{
|
||||
TriggeredAt: evt.TriggeredAt.UTC(),
|
||||
SourceIP: evt.SourceIP,
|
||||
UserAgent: derefStr(evt.UserAgent),
|
||||
Geo: geoSection{
|
||||
Country: derefStr(evt.GeoCountry),
|
||||
City: derefStr(evt.GeoCity),
|
||||
ASNOrg: derefStr(evt.GeoASNOrg),
|
||||
},
|
||||
Extra: extra,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildManageURL(base, id string) string {
|
||||
if base == "" || id == "" {
|
||||
return ""
|
||||
}
|
||||
return base + "/m/" + id
|
||||
}
|
||||
|
||||
func derefStr(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func computeSignature(secret string, body []byte) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(body)
|
||||
return signaturePrefix + hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,469 @@
|
|||
// ©AngelaMos | 2026
|
||||
// e2e_test.go
|
||||
|
||||
//go:build integration
|
||||
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/notify"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/testutil"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/registry"
|
||||
)
|
||||
|
||||
const (
|
||||
e2eBaseURL = "https://canary.example.com"
|
||||
e2eManageURL = "https://canary.example.com"
|
||||
e2eTelegramBot = "111:AAA"
|
||||
e2eTelegramChat = "12345"
|
||||
e2eMySQLPubHost = "localhost"
|
||||
e2eMySQLPubPort = 3306
|
||||
e2eClientIP = "203.0.113.10"
|
||||
e2eClientUA = "E2E/1.0"
|
||||
e2eEventualWait = 3 * time.Second
|
||||
e2eEventualTick = 20 * time.Millisecond
|
||||
e2eSendTimeout = 2 * time.Second
|
||||
e2eDedupTTL = 15 * time.Minute
|
||||
)
|
||||
|
||||
type e2eSender struct {
|
||||
mu sync.Mutex
|
||||
calls []event.NotifyInfo
|
||||
}
|
||||
|
||||
func (s *e2eSender) Channel() string { return "telegram" }
|
||||
|
||||
func (s *e2eSender) Send(
|
||||
_ context.Context,
|
||||
info event.NotifyInfo,
|
||||
_ *event.Event,
|
||||
) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.calls = append(s.calls, info)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *e2eSender) count() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.calls)
|
||||
}
|
||||
|
||||
type e2eRegistryAdapter struct{ r registry.Registry }
|
||||
|
||||
func (a e2eRegistryAdapter) Get(
|
||||
t token.Type,
|
||||
) (token.Generator, bool) {
|
||||
g, ok := a.r[t]
|
||||
return g, ok
|
||||
}
|
||||
|
||||
type e2eRecorderAdapter struct{ svc *event.Service }
|
||||
|
||||
func (a e2eRecorderAdapter) Record(
|
||||
ctx context.Context,
|
||||
t *token.Token,
|
||||
evt *event.Event,
|
||||
) error {
|
||||
return a.svc.Record(ctx, t.NotifyInfo(), evt)
|
||||
}
|
||||
|
||||
type e2eStack struct {
|
||||
router chi.Router
|
||||
notifySvc *notify.Service
|
||||
sender *e2eSender
|
||||
eventRepo *event.Repository
|
||||
tokenRepo *token.Repository
|
||||
}
|
||||
|
||||
func setupE2EStack(t *testing.T) *e2eStack {
|
||||
t.Helper()
|
||||
|
||||
db := sqlx.NewDb(testutil.NewTestDB(t), "pgx")
|
||||
|
||||
mr, err := miniredis.Run()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(mr.Close)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() {
|
||||
if cErr := rdb.Close(); cErr != nil {
|
||||
t.Logf("redis close: %v", cErr)
|
||||
}
|
||||
})
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
tokenRepo := token.NewRepository(db)
|
||||
eventRepo := event.NewRepository(db)
|
||||
sender := &e2eSender{}
|
||||
|
||||
notifySvc := notify.NewService(eventRepo,
|
||||
notify.WithLogger(logger),
|
||||
notify.WithSendTimeout(e2eSendTimeout),
|
||||
)
|
||||
notifySvc.Register(sender)
|
||||
|
||||
eventSvc := event.NewService(
|
||||
eventRepo,
|
||||
tokenRepo,
|
||||
rdb,
|
||||
notifySvc,
|
||||
event.ServiceConfig{
|
||||
DedupTTL: e2eDedupTTL,
|
||||
Logger: logger,
|
||||
},
|
||||
)
|
||||
|
||||
genReg := registry.Build(registry.Config{
|
||||
BaseURL: e2eBaseURL,
|
||||
MySQLPublicHost: e2eMySQLPubHost,
|
||||
MySQLPublicPort: e2eMySQLPubPort,
|
||||
})
|
||||
tokenSvc := token.NewService(
|
||||
tokenRepo,
|
||||
e2eRegistryAdapter{r: genReg},
|
||||
token.ServiceConfig{
|
||||
BaseURL: e2eBaseURL,
|
||||
ManageURL: e2eManageURL,
|
||||
},
|
||||
)
|
||||
|
||||
tokenH := token.NewHandler(
|
||||
tokenSvc,
|
||||
e2eRecorderAdapter{svc: eventSvc},
|
||||
nil,
|
||||
eventRepo,
|
||||
eventSvc,
|
||||
logger,
|
||||
true,
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.Recovery(logger))
|
||||
tokenH.RegisterTriggerRoutes(r)
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
api.Get("/tokens/types", tokenH.GetTypes)
|
||||
api.Post("/tokens", tokenH.CreateToken)
|
||||
tokenH.RegisterManageRoutes(api)
|
||||
})
|
||||
|
||||
return &e2eStack{
|
||||
router: r,
|
||||
notifySvc: notifySvc,
|
||||
sender: sender,
|
||||
eventRepo: eventRepo,
|
||||
tokenRepo: tokenRepo,
|
||||
}
|
||||
}
|
||||
|
||||
type e2eCreateResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Token token.Response `json:"token"`
|
||||
Artifact token.ArtifactJSON `json:"artifact"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func createE2EToken(
|
||||
t *testing.T,
|
||||
st *e2eStack,
|
||||
kind token.Type,
|
||||
metadata string,
|
||||
) e2eCreateResponse {
|
||||
t.Helper()
|
||||
|
||||
body := `{
|
||||
"type": "` + string(kind) + `",
|
||||
"memo": "e2e ` + string(kind) + `",
|
||||
"alert_channel": "telegram",
|
||||
"telegram_bot": "` + e2eTelegramBot + `",
|
||||
"telegram_chat": "` + e2eTelegramChat + `"`
|
||||
if metadata != "" {
|
||||
body += `, "metadata": ` + metadata
|
||||
}
|
||||
body += `}`
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/tokens",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
st.router.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusCreated, w.Code, "body=%s", w.Body.String())
|
||||
|
||||
var resp e2eCreateResponse
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
|
||||
require.True(t, resp.Success)
|
||||
require.NotEmpty(t, resp.Data.Token.ID)
|
||||
return resp
|
||||
}
|
||||
|
||||
func extractEmbeddedTriggerURL(
|
||||
t *testing.T,
|
||||
kind token.Type,
|
||||
art token.ArtifactJSON,
|
||||
tokenID string,
|
||||
) string {
|
||||
t.Helper()
|
||||
switch art.Kind {
|
||||
case "url":
|
||||
require.NotEmpty(t, art.URL)
|
||||
return art.URL
|
||||
case "file":
|
||||
raw, err := base64.StdEncoding.DecodeString(art.ContentB64)
|
||||
require.NoError(t, err)
|
||||
if kind == token.TypeDocx {
|
||||
return matchTriggerURLInZip(t, raw, tokenID)
|
||||
}
|
||||
return matchTriggerURL(t, raw, tokenID)
|
||||
case "text":
|
||||
return matchTriggerURL(t, []byte(art.Content), tokenID)
|
||||
default:
|
||||
t.Fatalf("unhandled artifact kind for HTTP trigger: %s", art.Kind)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func matchTriggerURLInZip(
|
||||
t *testing.T,
|
||||
raw []byte,
|
||||
tokenID string,
|
||||
) string {
|
||||
t.Helper()
|
||||
r, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
||||
require.NoError(t, err, "docx artifact must be a valid zip")
|
||||
needle := []byte("/c/" + tokenID)
|
||||
for _, f := range r.File {
|
||||
rc, oErr := f.Open()
|
||||
if oErr != nil {
|
||||
continue
|
||||
}
|
||||
content, rErr := io.ReadAll(rc)
|
||||
_ = rc.Close()
|
||||
if rErr != nil {
|
||||
continue
|
||||
}
|
||||
if bytes.Contains(content, needle) {
|
||||
return matchTriggerURL(t, content, tokenID)
|
||||
}
|
||||
}
|
||||
t.Fatalf(
|
||||
"docx: no zip member contains /c/%s (members=%d)",
|
||||
tokenID, len(r.File),
|
||||
)
|
||||
return ""
|
||||
}
|
||||
|
||||
func matchTriggerURL(t *testing.T, raw []byte, tokenID string) string {
|
||||
t.Helper()
|
||||
pattern := regexp.MustCompile(
|
||||
`https?://[A-Za-z0-9.\-:]+/(?:c|k)/` +
|
||||
regexp.QuoteMeta(tokenID) +
|
||||
`(?:\?[A-Za-z0-9_=&\-%.]*)?`,
|
||||
)
|
||||
match := pattern.Find(raw)
|
||||
require.NotNilf(
|
||||
t,
|
||||
match,
|
||||
"embedded trigger URL for token %s not found in artifact (len=%d)",
|
||||
tokenID, len(raw),
|
||||
)
|
||||
return string(match)
|
||||
}
|
||||
|
||||
func TestE2E_AllTokenTypes_CreateTriggerNotifyRecord(t *testing.T) {
|
||||
st := setupE2EStack(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
kind token.Type
|
||||
metadata string
|
||||
httpable bool
|
||||
}{
|
||||
{
|
||||
name: "webbug",
|
||||
kind: token.TypeWebbug,
|
||||
metadata: "",
|
||||
httpable: true,
|
||||
},
|
||||
{
|
||||
name: "slowredirect",
|
||||
kind: token.TypeSlowRedirect,
|
||||
metadata: `{"destination_url":"https://example.com/out"}`,
|
||||
httpable: true,
|
||||
},
|
||||
{
|
||||
name: "docx",
|
||||
kind: token.TypeDocx,
|
||||
metadata: "",
|
||||
httpable: true,
|
||||
},
|
||||
{
|
||||
name: "pdf",
|
||||
kind: token.TypePDF,
|
||||
metadata: "",
|
||||
httpable: true,
|
||||
},
|
||||
{
|
||||
name: "kubeconfig",
|
||||
kind: token.TypeKubeconfig,
|
||||
metadata: "",
|
||||
httpable: true,
|
||||
},
|
||||
{
|
||||
name: "envfile",
|
||||
kind: token.TypeEnvfile,
|
||||
metadata: `{"include_keys":["aws"]}`,
|
||||
httpable: true,
|
||||
},
|
||||
{
|
||||
name: "mysql",
|
||||
kind: token.TypeMySQL,
|
||||
metadata: "",
|
||||
httpable: false,
|
||||
},
|
||||
}
|
||||
|
||||
var expectedNotifications int
|
||||
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
resp := createE2EToken(t, st, c.kind, c.metadata)
|
||||
tokenID := resp.Data.Token.ID
|
||||
|
||||
if c.kind == token.TypePDF {
|
||||
raw, err := base64.StdEncoding.DecodeString(
|
||||
resp.Data.Artifact.ContentB64,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
idx := bytes.Index(raw, []byte("/c/"+tokenID))
|
||||
require.GreaterOrEqualf(
|
||||
t,
|
||||
idx,
|
||||
0,
|
||||
"PDF artifact must embed /c/<id> (id=%s)",
|
||||
tokenID,
|
||||
)
|
||||
after := raw[idx+len("/c/"+tokenID)]
|
||||
require.NotEqualf(
|
||||
t,
|
||||
byte('_'),
|
||||
after,
|
||||
"audit finding F2 regression: the byte immediately after "+
|
||||
"the PDF-embedded /c/<id> must not be '_' — otherwise "+
|
||||
"Acrobat fetches /c/<id>____ and the canary silently "+
|
||||
"no-ops on lookup (id=%s, after=%q)",
|
||||
tokenID, after,
|
||||
)
|
||||
}
|
||||
|
||||
if !c.httpable {
|
||||
require.Equal(t, "connection_string", resp.Data.Artifact.Kind)
|
||||
require.NotEmpty(t, resp.Data.Artifact.ConnectionString)
|
||||
return
|
||||
}
|
||||
|
||||
triggerURL := extractEmbeddedTriggerURL(
|
||||
t,
|
||||
c.kind,
|
||||
resp.Data.Artifact,
|
||||
tokenID,
|
||||
)
|
||||
require.NotEmpty(t, triggerURL)
|
||||
|
||||
trigReq := httptest.NewRequest(http.MethodGet, triggerURL, nil)
|
||||
trigReq.Header.Set("CF-Connecting-IP", e2eClientIP)
|
||||
trigReq.Header.Set("User-Agent", e2eClientUA)
|
||||
tw := httptest.NewRecorder()
|
||||
st.router.ServeHTTP(tw, trigReq)
|
||||
require.Truef(
|
||||
t,
|
||||
tw.Code == http.StatusOK ||
|
||||
tw.Code == http.StatusFound ||
|
||||
tw.Code == http.StatusForbidden,
|
||||
"trigger expected 200/302/403 (kubeconfig camouflages "+
|
||||
"as K8s 403), got %d for %s (url=%s)",
|
||||
tw.Code, c.kind, triggerURL,
|
||||
)
|
||||
|
||||
expectedNotifications++
|
||||
require.Eventuallyf(
|
||||
t,
|
||||
func() bool {
|
||||
return st.sender.count() >= expectedNotifications
|
||||
},
|
||||
e2eEventualWait,
|
||||
e2eEventualTick,
|
||||
"notifier should fire for %s (got=%d want>=%d)",
|
||||
c.kind, st.sender.count(), expectedNotifications,
|
||||
)
|
||||
|
||||
count, err := st.eventRepo.CountByToken(
|
||||
context.Background(),
|
||||
tokenID,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.GreaterOrEqualf(
|
||||
t,
|
||||
count,
|
||||
int64(1),
|
||||
"at least one event must be recorded for %s",
|
||||
c.kind,
|
||||
)
|
||||
|
||||
manageReq := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/m/"+resp.Data.Token.ManageID,
|
||||
nil,
|
||||
)
|
||||
mw := httptest.NewRecorder()
|
||||
st.router.ServeHTTP(mw, manageReq)
|
||||
require.Equal(t, http.StatusOK, mw.Code)
|
||||
var manage struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
EventsTotal int64 `json:"events_total"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(mw.Body).Decode(&manage))
|
||||
require.True(t, manage.Success)
|
||||
require.GreaterOrEqual(
|
||||
t,
|
||||
manage.Data.EventsTotal,
|
||||
int64(1),
|
||||
"manage view must show at least one event for "+c.name,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
st.notifySvc.Wait()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// ©AngelaMos | 2026
|
||||
// ptr.go
|
||||
|
||||
package testutil
|
||||
|
||||
func Ptr[T any](v T) *T { return &v }
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)",
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
@ -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},
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)",
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)",
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// ©AngelaMos | 2026
|
||||
// pixel.go
|
||||
|
||||
package pixel
|
||||
|
||||
import "slices"
|
||||
|
||||
var transparentGIF = []byte{
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0x21, 0xf9, 0x04, 0x01, 0x00,
|
||||
0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00,
|
||||
0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3b,
|
||||
}
|
||||
|
||||
const ContentType = "image/gif"
|
||||
|
||||
func Clone() []byte {
|
||||
return slices.Clone(transparentGIF)
|
||||
}
|
||||
|
||||
func Len() int {
|
||||
return len(transparentGIF)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue