chore(canary): phase B audit fixes + frontend asset rename + gitignore

Audit Phase B (backend): F5 trusted-proxy gate wiring, F7 ErrValidation
sentinel, F8 dedup unique-IP semantic via SCard, F9 notify worker pool +
graceful Shutdown, F12 token ID collision retry.

Frontend: public/asset/ → public/assets/ rename + index.html paths.

Housekeeping: .npmrc strict-dep-builds=false (pnpm fix), .gitignore now
excludes all .env.* except .env.example.
This commit is contained in:
CarterPerez-dev 2026-05-17 23:34:42 -04:00
parent d96de872b0
commit 9655120ebe
26 changed files with 290 additions and 81 deletions

View File

@ -20,7 +20,7 @@ NGINX_HOST_PORT=22784
PUBLIC_BASE_URL=https://canary.your.domain PUBLIC_BASE_URL=https://canary.your.domain
# Frontend build-time vars (baked into the bundle by Vite) # Frontend build-time vars (baked into the bundle by Vite)
VITE_APP_TITLE=Canary Token Generator VITE_APP_TITLE="Canary Token Generator"
VITE_API_URL=/api VITE_API_URL=/api
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -10,8 +10,7 @@ docs/
# Environment files (secrets) # Environment files (secrets)
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
.env .env
.env.local .env.*
.env.*.local
!.env.example !.env.example
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------

View File

@ -127,7 +127,14 @@ func run(configPath string) error {
shutdownErr := gracefulShutdown(cfg, logger, srv, telemetry, rdb, db) shutdownErr := gracefulShutdown(cfg, logger, srv, telemetry, rdb, db)
logger.Info("waiting for in-flight notifications") logger.Info("waiting for in-flight notifications")
notifySvc.Wait() 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() wg.Wait()
return shutdownErr return shutdownErr
} }

View File

@ -128,6 +128,7 @@ func setupIntgStack(t *testing.T) *intgStack {
eventRepo, eventRepo,
eventSvc, eventSvc,
logger, logger,
false,
) )
r := chi.NewRouter() r := chi.NewRouter()
@ -412,8 +413,8 @@ func TestIntegration_ManagePageReturnsTokenAndEventsAndSilencedCount(t *testing.
require.Equal(t, int64(3), manageResp.Data.EventsTotal, require.Equal(t, int64(3), manageResp.Data.EventsTotal,
"all 3 events recorded (one sent + two deduped)") "all 3 events recorded (one sent + two deduped)")
require.Equal(t, int64(2), manageResp.Data.EventsSilencedActive, require.Equal(t, int64(1), manageResp.Data.EventsSilencedActive,
"two triggers deduped within 15-min window") "one unique IP silenced (same source for both dedup hits)")
require.Len(t, manageResp.Data.Events, 3, "events page payload") require.Len(t, manageResp.Data.Events, 3, "events page payload")
require.False(t, manageResp.Data.Page.HasMore, require.False(t, manageResp.Data.Page.HasMore,

View File

@ -8,7 +8,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"strconv"
"time" "time"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
@ -17,9 +16,9 @@ import (
) )
const ( const (
dedupKeyPrefix = "dedup:trigger:" dedupKeyPrefix = "dedup:trigger:"
defaultDedupTTL = 15 * time.Minute dedupActivePrefix = "dedup:active:"
dedupScanBatch = 100 defaultDedupTTL = 15 * time.Minute
) )
type Service struct { type Service struct {
@ -132,6 +131,17 @@ func (s *Service) dedupGate(
s.logger.WarnContext(ctx, "dedup incr failed", s.logger.WarnContext(ctx, "dedup incr failed",
"error", iErr, "key", key) "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 return false
} }
@ -142,40 +152,14 @@ func (s *Service) CountActiveDedup(
if s.rdb == nil { if s.rdb == nil {
return 0, nil return 0, nil
} }
pattern := dedupKeyPrefix + tokenID + ":*" n, err := s.rdb.SCard(ctx, dedupActivePrefix+tokenID).Result()
var total int64 if errors.Is(err, redis.Nil) {
var cursor uint64 return 0, nil
for {
keys, next, err := s.rdb.Scan(
ctx, cursor, pattern, dedupScanBatch,
).Result()
if err != nil {
return 0, fmt.Errorf("dedup scan: %w", err)
}
for _, key := range keys {
v, gErr := s.rdb.Get(ctx, key).Result()
if errors.Is(gErr, redis.Nil) {
continue
}
if gErr != nil {
s.logger.WarnContext(ctx, "dedup count: get key",
"error", gErr, "key", key)
continue
}
n, pErr := strconv.ParseInt(v, 10, 64)
if pErr != nil {
continue
}
if n > 1 {
total += n - 1
}
}
if next == 0 {
break
}
cursor = next
} }
return total, nil if err != nil {
return 0, fmt.Errorf("dedup count: %w", err)
}
return n, nil
} }
func (s *Service) RunRetentionLoop( func (s *Service) RunRetentionLoop(

View File

@ -582,8 +582,8 @@ func TestService_CountActiveDedup_CountsSilencedAcrossIPs(t *testing.T) {
n, err := svc.CountActiveDedup(context.Background(), testTokenID) n, err := svc.CountActiveDedup(context.Background(), testTokenID)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, int64(2+4), n, require.Equal(t, int64(2), n,
"key1=3 (silenced 2) + key2=5 (silenced 4)") "two distinct IPs were silenced (203.0.113.1 and 203.0.113.2)")
} }
func TestService_CountActiveDedup_IgnoresOtherTokens(t *testing.T) { func TestService_CountActiveDedup_IgnoresOtherTokens(t *testing.T) {
@ -616,7 +616,8 @@ func TestService_CountActiveDedup_IgnoresOtherTokens(t *testing.T) {
n, err := svc.CountActiveDedup(context.Background(), testTokenID) n, err := svc.CountActiveDedup(context.Background(), testTokenID)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, int64(2), n, "only this token's keys counted") require.Equal(t, int64(1), n,
"only this token's silenced IPs counted (one distinct IP)")
} }
func TestService_CountActiveDedup_NilRedisReturnsZero(t *testing.T) { func TestService_CountActiveDedup_NilRedisReturnsZero(t *testing.T) {

View File

@ -12,14 +12,27 @@ import (
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event" "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
) )
const defaultSendTimeout = 30 * time.Second const (
defaultSendTimeout = 30 * time.Second
defaultWorkers = 8
defaultQueueSize = 256
)
type Service struct { type Service struct {
senders map[string]Sender senders map[string]Sender
status StatusWriter status StatusWriter
logger *slog.Logger logger *slog.Logger
sendTimeout time.Duration sendTimeout time.Duration
wg sync.WaitGroup 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) type Option func(*Service)
@ -32,16 +45,40 @@ func WithSendTimeout(d time.Duration) Option {
return func(s *Service) { s.sendTimeout = d } 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 { func NewService(status StatusWriter, opts ...Option) *Service {
s := &Service{ s := &Service{
senders: make(map[string]Sender), senders: make(map[string]Sender),
status: status, status: status,
logger: slog.Default(), logger: slog.Default(),
sendTimeout: defaultSendTimeout, sendTimeout: defaultSendTimeout,
workers: defaultWorkers,
} }
for _, o := range opts { for _, o := range opts {
o(s) 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 return s
} }
@ -55,15 +92,50 @@ func (s *Service) Register(senders ...Sender) {
} }
func (s *Service) Notify(info event.NotifyInfo, evt *event.Event) { func (s *Service) Notify(info event.NotifyInfo, evt *event.Event) {
s.wg.Add(1) s.jobWg.Add(1)
go func() { select {
defer s.wg.Done() case s.queue <- dispatchJob{info: info, evt: evt}:
s.dispatch(info, 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() { func (s *Service) Wait() {
s.wg.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) { func (s *Service) dispatch(info event.NotifyInfo, evt *event.Event) {

View File

@ -14,6 +14,7 @@ import (
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
) )
const ( const (
@ -22,6 +23,9 @@ const (
metadataDestinationURL = "destination_url" metadataDestinationURL = "destination_url"
metadataIncludeKeys = "include_keys" metadataIncludeKeys = "include_keys"
pgUniqueViolationCode = "23505"
maxTokenIDAttempts = 5
) )
var ( var (
@ -35,6 +39,7 @@ var (
"token: no generator registered for this type", "token: no generator registered for this type",
) )
ErrGenerateFailed = errors.New("token: artifact generation failed") ErrGenerateFailed = errors.New("token: artifact generation failed")
ErrValidation = errors.New("token: request validation failed")
) )
var allowedIncludeKeys = map[string]struct{}{ var allowedIncludeKeys = map[string]struct{}{
@ -94,7 +99,7 @@ func (s *Service) Create(
) (*Token, Artifact, error) { ) (*Token, Artifact, error) {
if err := s.validate.Struct(req); err != nil { if err := s.validate.Struct(req); err != nil {
return nil, Artifact{}, fmt.Errorf( return nil, Artifact{}, fmt.Errorf(
"validate request: %w", err, "%w: %w", ErrValidation, err,
) )
} }
if err := validateTypeMetadata(req.Type, req.Metadata); err != nil { if err := validateTypeMetadata(req.Type, req.Metadata); err != nil {
@ -108,16 +113,8 @@ func (s *Service) Create(
) )
} }
id, err := generateTokenID()
if err != nil {
return nil, Artifact{}, fmt.Errorf(
"generate id: %w", err,
)
}
manageID := uuid.NewString() manageID := uuid.NewString()
tok := &Token{ tok := &Token{
ID: id,
ManageID: manageID, ManageID: manageID,
Type: req.Type, Type: req.Type,
Memo: req.Memo, Memo: req.Memo,
@ -132,19 +129,45 @@ func (s *Service) Create(
Metadata: normalizeMetadata(req.Metadata), Metadata: normalizeMetadata(req.Metadata),
} }
art, err := gen.Generate(ctx, tok, s.baseURL) var (
if err != nil { art Artifact
return nil, Artifact{}, fmt.Errorf( insertErr error
"%w: %w", ErrGenerateFailed, err, )
) for attempt := range maxTokenIDAttempts {
} id, err := generateTokenID()
if err != nil {
return nil, Artifact{}, fmt.Errorf(
"generate id (attempt %d): %w", attempt, err,
)
}
tok.ID = id
if err := s.repo.Insert(ctx, tok); err != nil { art, err = gen.Generate(ctx, tok, s.baseURL)
return nil, Artifact{}, fmt.Errorf( if err != nil {
"persist token: %w", err, return nil, Artifact{}, fmt.Errorf(
) "%w: %w", ErrGenerateFailed, err,
)
}
insertErr = s.repo.Insert(ctx, tok)
if insertErr == nil {
return tok, art, nil
}
if !isUniqueViolation(insertErr) {
return nil, Artifact{}, fmt.Errorf(
"persist token: %w", insertErr,
)
}
} }
return tok, art, nil return nil, Artifact{}, fmt.Errorf(
"persist token after %d id-collision retries: %w",
maxTokenIDAttempts, insertErr,
)
}
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == pgUniqueViolationCode
} }
func (s *Service) GetByID( func (s *Service) GetByID(

View File

@ -12,6 +12,7 @@ import (
"sync/atomic" "sync/atomic"
"testing" "testing"
"github.com/jackc/pgx/v5/pgconn"
"github.com/stretchr/testify/require" "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/event"
@ -358,6 +359,119 @@ func TestService_Create_DistinctIDsAcrossCalls(t *testing.T) {
) )
} }
type collisionRepo struct {
mu sync.Mutex
collisions int
remaining int
inserted []*token.Token
byID map[string]*token.Token
}
func newCollisionRepo(collisions int) *collisionRepo {
return &collisionRepo{
remaining: collisions,
byID: map[string]*token.Token{},
}
}
func (r *collisionRepo) Insert(_ context.Context, t *token.Token) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.remaining > 0 {
r.remaining--
r.collisions++
return &pgconn.PgError{Code: "23505"}
}
r.inserted = append(r.inserted, t)
r.byID[t.ID] = t
return nil
}
func (r *collisionRepo) GetByID(
_ context.Context, id string,
) (*token.Token, error) {
r.mu.Lock()
defer r.mu.Unlock()
t, ok := r.byID[id]
if !ok {
return nil, token.ErrNotFound
}
return t, nil
}
func (r *collisionRepo) GetByManageID(
_ context.Context, _ string,
) (*token.Token, error) {
return nil, token.ErrNotFound
}
func (r *collisionRepo) IncrementTriggerCount(
_ context.Context, _ string,
) error {
return nil
}
func (r *collisionRepo) DeleteByManageID(
_ context.Context, _ string,
) error {
return token.ErrNotFound
}
func TestService_Create_RetriesOnTokenIDCollision(t *testing.T) {
repo := newCollisionRepo(2)
gen := &fakeGenerator{
tokenType: token.TypeWebbug,
artifact: generators.Artifact{
Kind: generators.KindURL,
URL: "https://canary.example.com/c/x",
},
}
svc := token.NewService(
repo,
token.MapRegistry{token.TypeWebbug: gen},
token.ServiceConfig{BaseURL: "https://canary.example.com"},
)
tok, _, err := svc.Create(context.Background(), token.CreateRequest{
Type: token.TypeWebbug,
Memo: "x",
AlertChannel: token.ChannelWebhook,
WebhookURL: "https://example.com/h",
}, "fp", "ip")
require.NoError(t, err)
require.NotNil(t, tok)
require.Equal(t, 2, repo.collisions,
"repo must have rejected exactly two prior IDs")
require.Len(t, repo.inserted, 1, "exactly one token persisted")
require.Equal(t, int32(3), gen.calls.Load(),
"generator called once per attempt (regenerates artifact per id)")
}
func TestService_Create_GivesUpAfterMaxCollisions(t *testing.T) {
repo := newCollisionRepo(10)
gen := &fakeGenerator{
tokenType: token.TypeWebbug,
artifact: generators.Artifact{
Kind: generators.KindURL,
URL: "https://canary.example.com/c/x",
},
}
svc := token.NewService(
repo,
token.MapRegistry{token.TypeWebbug: gen},
token.ServiceConfig{BaseURL: "https://canary.example.com"},
)
_, _, err := svc.Create(context.Background(), token.CreateRequest{
Type: token.TypeWebbug,
Memo: "x",
AlertChannel: token.ChannelWebhook,
WebhookURL: "https://example.com/h",
}, "fp", "ip")
require.Error(t, err)
require.Contains(t, err.Error(), "id-collision retries")
}
func TestService_GetByID_NotFoundReturnsNilNil(t *testing.T) { func TestService_GetByID_NotFoundReturnsNilNil(t *testing.T) {
repo := newFakeRepo() repo := newFakeRepo()
svc := token.NewService(repo, token.MapRegistry{}, svc := token.NewService(repo, token.MapRegistry{},

View File

@ -0,0 +1,3 @@
# ©AngelaMos | 2026
# .npmrc
strict-dep-builds=false

View File

@ -9,20 +9,20 @@
<link <link
rel="icon" rel="icon"
type="image/x-icon" type="image/x-icon"
href="/asset/favicon.ico" href="/assets/favicon.ico"
/> />
<link <link
rel="apple-touch-icon" rel="apple-touch-icon"
type="image/png" type="image/png"
href="/asset/apple-touch-icon.png" href="/assets/apple-touch-icon.png"
/> />
<link <link
rel="manifest" rel="manifest"
href="/asset/site.webmanifest" href="/assets/site.webmanifest"
/> />
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0" content="width=device-width, initial-scale=1.0, maximum-scale=1"
/> />
<title>Canary Token Generator</title> <title>Canary Token Generator</title>
<meta <meta

View File

@ -45,6 +45,11 @@
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"vite": "npm:rolldown-vite@7.2.5" "vite": "npm:rolldown-vite@7.2.5"
} },
"onlyBuiltDependencies": [
"@parcel/watcher",
"core-js",
"esbuild"
]
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 823 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -9,11 +9,11 @@
FROM node:22-slim FROM node:22-slim
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable && corepack prepare pnpm@10.29.1 --activate
WORKDIR /app WORKDIR /app
COPY package.json pnpm-lock.yaml* ./ COPY package.json pnpm-lock.yaml* .npmrc* ./
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile pnpm install --frozen-lockfile