470 lines
10 KiB
Go
470 lines
10 KiB
Go
// ©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()
|
|
}
|