feat(canary): envfile bait recipes (aws/stripe/github/db)
Phase 7 first commit. Four recipes for generating realistic-looking
credentials that pass syntactic validation by tools like gitleaks but
are NOT real credentials — they're decoration that makes the env file
look like a legitimate prod config dump. The detection mechanism is
the canary URL embedded by the generator (next commit); the bait keys
themselves don't trigger anything.
Package layout (sub-package under envfile/):
- recipes.go: EnvLine, Recipe interface, internal registry, shared
crypto/rand helpers (RandomAlnumUpper / RandomAlnumMixed /
RandomHexLower / RandomBase64 / RandomChoice). All randomness
uses crypto/rand — no math/rand or gosec G404 fight, matches the
Phase 0 supplement's discipline.
- aws.go: AKIA + 16 [A-Z0-9] body (gitleaks regex
`AKIA[0-9A-Z]{16}`) + 40+ base64-encoded secret + canonical
region (us-east-1 etc.) + stable S3 bucket name
- stripe.go: sk_live_/pk_live_/whsec_ prefixes with 24/24/32-char
[A-Za-z0-9] bodies (matches Stripe's documented live-key format)
- github.go: ghp_ + 36 base62 body + 6 base62 "checksum" = 42 chars
after the prefix (matches gitleaks
`ghp_[0-9a-zA-Z]{36,255}`); plus base64 deploy-key, stable owner +
repo names (acme-corp / internal-platform)
- db.go: postgres://app_writer:<24-char pass>@db.internal:5432/
app_prod?sslmode=require + redis://default:<32-char pass>@
cache-prod.internal:6379/0 — realistic internal-DNS-style
hostnames per spec §9.6 "Bait realism principles"
Public API:
- recipes.Get(key) (Recipe, bool) — registry lookup
- recipes.AvailableKeys() []string — sorted snapshot
- Recipe types (AWS, Stripe, GitHub, DB) exported so callers can
construct them directly if needed (registry is the normal path)
Tests (~30 cases): one test file per recipe plus shared helpers.
- Format-regex assertions: AKIA + base32-upper, sk_live_/pk_live_/
whsec_ + base62, ghp_ + base62, region xx-name-n
- URL parsing for postgres + redis (real net/url validation, not
string matching)
- Distinct-invocations checks (20 calls → near-20 unique secrets)
- Helper coverage: RandomAlnumUpper/Mixed/HexLower/Base64/Choice
length + alphabet + empty-input handling
Recipes package compiles + tests pass in isolation. Generator and
registry wire-up land in next commits.
go test -race -timeout=60s ./internal/token/generators/envfile/recipes/...
passes with 0 lint issues.
This commit is contained in:
parent
a36292b786
commit
003aa9970c
|
|
@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue