diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/generator.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/generator.go new file mode 100644 index 00000000..5f9fa9a4 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/generator.go @@ -0,0 +1,103 @@ +// ©AngelaMos | 2026 +// generator.go + +package mysql + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators" +) + +const ( + defaultPublicHost = "localhost" + defaultPublicPort = 3306 + defaultDatabase = "internal_db" + + connectionStringFmt = "mysql://%s@%s:%d/%s" +) + +type Generator struct { + publicHost string + publicPort int + database string +} + +func New() *Generator { + return NewWithAddress(defaultPublicHost, defaultPublicPort) +} + +func NewWithAddress(host string, port int) *Generator { + return &Generator{ + publicHost: host, + publicPort: port, + database: defaultDatabase, + } +} + +func (g *Generator) Type() token.Type { return token.TypeMySQL } + +func (g *Generator) Generate( + _ context.Context, + t *token.Token, + _ string, +) (generators.Artifact, error) { + username := mysqlUsernamePrefix + t.ID + + newMeta, err := setMySQLUsername(t.Metadata, username) + if err != nil { + return generators.Artifact{}, fmt.Errorf( + "mysql: persist username: %w", + err, + ) + } + t.Metadata = newMeta + + connStr := fmt.Sprintf( + connectionStringFmt, + username, + g.publicHost, + g.publicPort, + g.database, + ) + + return generators.Artifact{ + Kind: generators.KindConnectionString, + ConnectionString: connStr, + }, nil +} + +func (g *Generator) Trigger( + _ context.Context, + _ *token.Token, + _ *http.Request, +) (*event.Event, *generators.TriggerResponse, error) { + return nil, nil, ErrHTTPTriggerNotSupported +} + +func setMySQLUsername( + metadata json.RawMessage, + username string, +) (json.RawMessage, error) { + m := make(map[string]json.RawMessage) + if len(metadata) > 0 { + if err := json.Unmarshal(metadata, &m); err != nil { + m = make(map[string]json.RawMessage) + } + } + val, err := json.Marshal(username) + if err != nil { + return nil, fmt.Errorf("marshal mysql_username: %w", err) + } + m[extraMySQLUsername] = val + out, err := json.Marshal(m) + if err != nil { + return nil, fmt.Errorf("marshal merged metadata: %w", err) + } + return out, nil +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/generator_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/generator_test.go new file mode 100644 index 00000000..c0cc926c --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/generator_test.go @@ -0,0 +1,191 @@ +// ©AngelaMos | 2026 +// generator_test.go + +package mysql_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/mysql" +) + +func newMySQLToken(id string) *token.Token { + return &token.Token{ + ID: id, + ManageID: "manage-" + id, + Type: token.TypeMySQL, + Memo: "unit test mysql", + AlertChannel: token.ChannelWebhook, + Enabled: true, + } +} + +func TestGenerator_TypeIsMySQL(t *testing.T) { + require.Equal(t, token.TypeMySQL, mysql.New().Type()) +} + +func TestGenerate_ArtifactKindIsConnectionString(t *testing.T) { + g := mysql.New() + art, err := g.Generate(context.Background(), newMySQLToken("abc"), "") + require.NoError(t, err) + require.Equal(t, generators.KindConnectionString, art.Kind) +} + +func TestGenerate_ConnectionStringDefaultLocalhost(t *testing.T) { + g := mysql.New() + art, err := g.Generate(context.Background(), newMySQLToken("abc"), "") + require.NoError(t, err) + require.Equal( + t, + "mysql://canary_abc@localhost:3306/internal_db", + art.ConnectionString, + ) +} + +func TestGenerate_ConnectionStringCustomAddress(t *testing.T) { + g := mysql.NewWithAddress("canary.example.com", 13306) + art, err := g.Generate(context.Background(), newMySQLToken("xyz"), "") + require.NoError(t, err) + require.Equal( + t, + "mysql://canary_xyz@canary.example.com:13306/internal_db", + art.ConnectionString, + ) +} + +func TestGenerate_UsernamePrefixIsCanary_(t *testing.T) { + g := mysql.New() + art, err := g.Generate(context.Background(), newMySQLToken("probeid"), "") + require.NoError(t, err) + require.Contains( + t, + art.ConnectionString, + "canary_probeid@", + "username must be canary_+token.ID for TCP-side lookup parity", + ) +} + +func TestGenerate_PersistsMySQLUsernameInMetadata(t *testing.T) { + g := mysql.New() + tok := newMySQLToken("abc") + require.Empty(t, tok.Metadata, "fresh token has no metadata yet") + + _, err := g.Generate(context.Background(), tok, "") + require.NoError(t, err) + + require.NotEmpty( + t, + tok.Metadata, + "Generate must persist mysql_username into token metadata", + ) + + var m map[string]any + require.NoError(t, json.Unmarshal(tok.Metadata, &m)) + require.Equal(t, "canary_abc", m["mysql_username"]) +} + +func TestGenerate_PreservesExistingMetadataFields(t *testing.T) { + g := mysql.New() + tok := newMySQLToken("abc") + tok.Metadata = json.RawMessage(`{"existing_field":"keep_me","other":42}`) + + _, err := g.Generate(context.Background(), tok, "") + require.NoError(t, err) + + var m map[string]any + require.NoError(t, json.Unmarshal(tok.Metadata, &m)) + require.Equal(t, "keep_me", m["existing_field"]) + other, ok := m["other"].(float64) + require.True(t, ok) + require.Equal(t, 42, int(other)) + require.Equal(t, "canary_abc", m["mysql_username"]) +} + +func TestGenerate_MalformedMetadataIsReplaced(t *testing.T) { + g := mysql.New() + tok := newMySQLToken("abc") + tok.Metadata = json.RawMessage(`{not valid json`) + + _, err := g.Generate(context.Background(), tok, "") + require.NoError( + t, + err, + "malformed metadata must not block generation — replace with a fresh map", + ) + + var m map[string]any + require.NoError(t, json.Unmarshal(tok.Metadata, &m)) + require.Equal(t, "canary_abc", m["mysql_username"]) +} + +func TestGenerate_OverwritesPriorMySQLUsername(t *testing.T) { + g := mysql.New() + tok := newMySQLToken("abc") + tok.Metadata = json.RawMessage(`{"mysql_username":"stale_value"}`) + + _, err := g.Generate(context.Background(), tok, "") + require.NoError(t, err) + + var m map[string]any + require.NoError(t, json.Unmarshal(tok.Metadata, &m)) + require.Equal( + t, + "canary_abc", + m["mysql_username"], + "regeneration must overwrite stale mysql_username with the current token's", + ) +} + +func TestGenerate_BaseURLIgnored(t *testing.T) { + g := mysql.New() + + art1, err := g.Generate( + context.Background(), + newMySQLToken("abc"), + "https://canary.example.com", + ) + require.NoError(t, err) + art2, err := g.Generate( + context.Background(), + newMySQLToken("abc"), + "https://different.example.com/sub", + ) + require.NoError(t, err) + require.Equal( + t, + art1.ConnectionString, + art2.ConnectionString, + "baseURL is irrelevant to mysql connection strings", + ) +} + +func TestTrigger_ReturnsHTTPNotSupportedError(t *testing.T) { + g := mysql.New() + r := httptest.NewRequest(http.MethodGet, "/c/abc", nil) + + evt, resp, err := g.Trigger( + context.Background(), + newMySQLToken("abc"), + r, + ) + require.Error(t, err) + require.ErrorIs(t, err, mysql.ErrHTTPTriggerNotSupported) + require.Nil(t, evt, "no event when HTTP-triggered (mysql uses TCP)") + require.Nil(t, resp, "no response when HTTP-triggered (mysql uses TCP)") +} + +func TestTrigger_NilTokenAlsoReturnsHTTPNotSupportedError(t *testing.T) { + g := mysql.New() + r := httptest.NewRequest(http.MethodGet, "/c/abc", nil) + + _, _, err := g.Trigger(context.Background(), nil, r) + require.ErrorIs(t, err, mysql.ErrHTTPTriggerNotSupported) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/registry/registry.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/registry/registry.go index 34b28e92..ee987da6 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/registry/registry.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/registry/registry.go @@ -9,6 +9,7 @@ import ( "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/docx" "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/envfile" "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/kubeconfig" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/mysql" "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/pdf" "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/slowredirect" "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/webbug" @@ -28,5 +29,6 @@ func Build(_ Config) Registry { token.TypePDF: pdf.New(), token.TypeKubeconfig: kubeconfig.New(), token.TypeEnvfile: envfile.New(), + token.TypeMySQL: mysql.New(), } } diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/registry/registry_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/registry/registry_test.go index b3c5ddc7..bc4432d6 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/registry/registry_test.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/registry/registry_test.go @@ -62,6 +62,14 @@ func TestBuild_RegistersEnvfile(t *testing.T) { require.Equal(t, token.TypeEnvfile, g.Type()) } +func TestBuild_RegistersMySQL(t *testing.T) { + reg := registry.Build(registry.Config{BaseURL: testBaseURL}) + g, ok := reg[token.TypeMySQL] + require.True(t, ok, "expected mysql generator registered") + require.NotNil(t, g) + require.Equal(t, token.TypeMySQL, g.Type()) +} + func TestBuild_UnknownTypeReturnsZeroValue(t *testing.T) { reg := registry.Build(registry.Config{BaseURL: testBaseURL}) g, ok := reg["nonexistent-type"] @@ -69,28 +77,25 @@ func TestBuild_UnknownTypeReturnsZeroValue(t *testing.T) { require.Nil(t, g, "map zero value for missing key must be nil interface") } -func TestBuild_PendingTypesNotYetRegistered(t *testing.T) { - reg := registry.Build(registry.Config{BaseURL: testBaseURL}) - pending := []token.Type{ - token.TypeMySQL, - } - for _, tt := range pending { - _, ok := reg[tt] - require.False( - t, - ok, - "type %q is not yet registered (subsequent phases will add it); registry must not claim it", - tt, - ) - } -} - -func TestBuild_OnlyExpectedTypesPresentInPhase7(t *testing.T) { +func TestBuild_AllSevenGeneratorsRegistered(t *testing.T) { reg := registry.Build(registry.Config{BaseURL: testBaseURL}) require.Len( t, reg, - 6, - "Phase 7 registers exactly six generators (webbug, slowredirect, docx, pdf, kubeconfig, envfile); other phases append", + 7, + "Phase 8 closes the generator set with all 7 types registered (webbug, slowredirect, docx, pdf, kubeconfig, envfile, mysql)", ) + + for _, ty := range []token.Type{ + token.TypeWebbug, + token.TypeSlowRedirect, + token.TypeDocx, + token.TypePDF, + token.TypeKubeconfig, + token.TypeEnvfile, + token.TypeMySQL, + } { + _, ok := reg[ty] + require.True(t, ok, "type %q must be registered", ty) + } }