// Security best practices for Tzurot v3 - Secret management, AI-specific security (prompt injection, PII scrubbing), Economic DoS prevention, Discord permission verification, microservices security, and supply chain integrity. Use when handling secrets, user input, or security-critical code.
| name | tzurot-security |
| description | Security best practices for Tzurot v3 - Secret management, AI-specific security (prompt injection, PII scrubbing), Economic DoS prevention, Discord permission verification, microservices security, and supply chain integrity. Use when handling secrets, user input, or security-critical code. |
| lastUpdated | 2025-12-08 |
Critical Context: Solo developer + AI assistance = single point of failure for security. This skill codifies security patterns learned from production incidents and AI-specific vulnerabilities.
CRITICAL: These incidents have happened TWICE in this project. Always verify before committing.
Database Connection Strings:
# ❌ WRONG - Contains password
DATABASE_URL="postgresql://user:PASSWORD@host:5432/db"
REDIS_URL="redis://:PASSWORD@host:6379"
# ✅ CORRECT - Use environment variable
DATABASE_URL="your-database-url-here" # In docs/examples
API Keys and Tokens:
// ❌ WRONG - Hardcoded tokens (NEVER do this!)
const DISCORD_TOKEN = 'your-actual-discord-token-here';
const OPENROUTER_KEY = 'your-actual-openrouter-key-here';
// ✅ CORRECT - Use environment variables
const DISCORD_TOKEN = process.env.DISCORD_TOKEN;
const OPENROUTER_KEY = process.env.OPENROUTER_API_KEY;
Other Sensitive Data:
.env files (use .env.example instead)Before EVERY commit, verify:
Run git diff and visually scan for:
postgresql:// or postgres:// URLsredis:// URLsAPI_KEY, TOKEN, SECRET variable assignmentsCheck staged files:
git diff --cached | grep -iE '(password|secret|token|api.?key|postgresql://|redis://)'
Run npm audit:
npm audit --audit-level=moderate
Immediate Actions:
railway variables set KEY=new-value --service service-name
# ⚠️ DANGEROUS - Only if commit not shared
git rebase -i HEAD~3 # Edit commits
git push --force-with-lease
Pattern: Always use Railway secrets for production
// ✅ CORRECT - Fail fast if missing
const requiredEnvVars = ['DISCORD_TOKEN', 'DATABASE_URL', 'REDIS_URL', 'AI_PROVIDER'] as const;
for (const varName of requiredEnvVars) {
if (!process.env[varName]) {
throw new Error(`Missing required environment variable: ${varName}`);
}
}
// ✅ CORRECT - Type-safe access
const config = {
discordToken: process.env.DISCORD_TOKEN!,
databaseUrl: process.env.DATABASE_URL!,
redisUrl: process.env.REDIS_URL!,
aiProvider: process.env.AI_PROVIDER! as 'openrouter' | 'gemini',
};
Railway CLI Usage:
# Set secret
railway variables set OPENROUTER_API_KEY=sk-or-v1-... --service ai-worker
# List secrets (values are hidden)
railway variables --service ai-worker
# NEVER use 'echo' to verify - Railway dashboard shows them
CRITICAL: Logs are stored indefinitely. NEVER log sensitive data.
import { logger } from '@tzurot/common-types';
// ❌ WRONG - Logs entire user object (may contain PII)
logger.info({ user }, 'User authenticated');
// ❌ WRONG - Logs token
logger.debug({ token: discordToken }, 'Initializing Discord client');
// ✅ CORRECT - Log only non-sensitive identifiers
logger.info({ userId: user.id, guildId: interaction.guildId }, 'User authenticated');
// ✅ CORRECT - Log masked token (first 10 chars only)
logger.debug({ tokenPrefix: discordToken.slice(0, 10) }, 'Initializing Discord client');
PII to NEVER Log:
Safe to Log:
Problem: AI APIs cost money. Spam = wallet drain.
Solution: Implement token budgeting per user/guild in Redis.
import { TIMEOUTS } from '@tzurot/common-types';
interface TokenBudget {
tokensUsed: number;
windowStart: number;
}
class TokenBudgetService {
private readonly BUDGET_PER_HOUR = 50_000; // 50k tokens/hour per user
private readonly WINDOW_MS = 60 * 60 * 1000; // 1 hour
async checkBudget(userId: string, estimatedTokens: number): Promise<boolean> {
const key = `token_budget:${userId}`;
const budgetData = await redis.get(key);
const budget: TokenBudget = budgetData
? JSON.parse(budgetData)
: { tokensUsed: 0, windowStart: Date.now() };
// Reset window if expired
if (Date.now() - budget.windowStart > this.WINDOW_MS) {
budget.tokensUsed = 0;
budget.windowStart = Date.now();
}
// Check if budget would be exceeded
if (budget.tokensUsed + estimatedTokens > this.BUDGET_PER_HOUR) {
return false; // Budget exceeded
}
// Update budget
budget.tokensUsed += estimatedTokens;
await redis.set(key, JSON.stringify(budget), 'PX', this.WINDOW_MS);
return true; // Budget available
}
}
// Usage in API Gateway
app.post('/ai/generate', async (req, res) => {
const estimatedTokens = estimateTokens(req.body.prompt);
const hasBudget = await tokenBudgetService.checkBudget(req.body.userId, estimatedTokens);
if (!hasBudget) {
return res.status(429).json({
error: 'Token budget exceeded. Try again in an hour.',
});
}
// Continue processing...
});
Discord Bot Integration:
// In bot-client, inform users of limits
if (!hasBudget) {
await interaction.reply({
content: "⚠️ You've reached your hourly token limit (50k tokens). Try again in an hour.",
ephemeral: true,
});
return;
}
Problem: Discord API has race conditions. Don't trust real-time permissions.
Solution: Cache permissions and verify server-side.
// ❌ WRONG - Trusts client-side permissions
if (interaction.member.permissions.has('Administrator')) {
// Execute admin command
}
// ✅ CORRECT - Verify on server-side with caching
class PermissionService {
private permissionCache = new Map<string, { permissions: bigint; expires: number }>();
async hasPermission(
guildId: string,
userId: string,
permission: PermissionFlagsBits
): Promise<boolean> {
const cacheKey = `${guildId}:${userId}`;
const cached = this.permissionCache.get(cacheKey);
// Use cache if valid (5 minute TTL)
if (cached && Date.now() < cached.expires) {
return (cached.permissions & BigInt(permission)) === BigInt(permission);
}
// Fetch fresh permissions from Discord API
const guild = await client.guilds.fetch(guildId);
const member = await guild.members.fetch(userId);
const permissions = member.permissions.bitfield;
// Cache for 5 minutes
this.permissionCache.set(cacheKey, {
permissions,
expires: Date.now() + 5 * 60 * 1000,
});
return (permissions & BigInt(permission)) === BigInt(permission);
}
}
// Usage
const hasAdmin = await permissionService.hasPermission(
interaction.guildId!,
interaction.user.id,
PermissionFlagsBits.Administrator
);
if (!hasAdmin) {
await interaction.reply({
content: '❌ You need Administrator permission to use this command.',
ephemeral: true,
});
return;
}
Destructive Command Verification:
For dangerous commands (wiping memory, changing configs), require confirmation:
// Two-step confirmation for destructive commands
app.post('/admin/wipe-memory', async (req, res) => {
const { userId, guildId, confirmationToken } = req.body;
// Step 1: Generate confirmation token (first request)
if (!confirmationToken) {
const token = randomUUID();
await redis.set(
`confirm:${token}`,
JSON.stringify({ userId, guildId, action: 'wipe-memory' }),
'EX',
300 // 5 minute expiry
);
return res.json({
message: 'Confirmation required',
confirmationToken: token,
});
}
// Step 2: Verify confirmation token (second request)
const confirmation = await redis.get(`confirm:${confirmationToken}`);
if (!confirmation) {
return res.status(400).json({ error: 'Invalid or expired confirmation' });
}
// Execute destructive action
await wipeMemory(guildId);
await redis.del(`confirm:${confirmationToken}`);
return res.json({ message: 'Memory wiped successfully' });
});
Problem: Once PII is embedded in pgvector, it's nearly impossible to selectively delete.
Solution: Scrub PII before storage using regex/NLP libraries.
import { logger } from '@tzurot/common-types';
class PIIScrubber {
private readonly EMAIL_REGEX = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g;
private readonly PHONE_REGEX = /\b(\+\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g;
private readonly SSN_REGEX = /\b\d{3}-\d{2}-\d{4}\b/g;
scrubText(text: string): { scrubbedText: string; hadPII: boolean } {
let scrubbedText = text;
let hadPII = false;
// Scrub emails
if (this.EMAIL_REGEX.test(scrubbedText)) {
scrubbedText = scrubbedText.replace(this.EMAIL_REGEX, '<EMAIL_REDACTED>');
hadPII = true;
}
// Scrub phone numbers
if (this.PHONE_REGEX.test(scrubbedText)) {
scrubbedText = scrubbedText.replace(this.PHONE_REGEX, '<PHONE_REDACTED>');
hadPII = true;
}
// Scrub SSNs
if (this.SSN_REGEX.test(scrubbedText)) {
scrubbedText = scrubbedText.replace(this.SSN_REGEX, '<SSN_REDACTED>');
hadPII = true;
}
if (hadPII) {
logger.warn({ originalLength: text.length }, 'PII detected and scrubbed from user input');
}
return { scrubbedText, hadPII };
}
}
// Usage in API Gateway (before embedding)
app.post('/memory/add', async (req, res) => {
const { content, personalityId } = req.body;
// Scrub PII BEFORE embedding
const { scrubbedText, hadPII } = piiScrubber.scrubText(content);
if (hadPII) {
// Optionally warn user
logger.info({ personalityId }, 'PII detected and removed from memory');
}
// Embed scrubbed text
const embedding = await generateEmbedding(scrubbedText);
await memoryService.addMemory(personalityId, scrubbedText, embedding);
res.json({ success: true });
});
For Advanced PII Detection: Consider Microsoft Presidio for more comprehensive PII detection.
Problem: Users can try "jailbreak" prompts to bypass system instructions.
Solution: Implement pre-flight checks and output sanitization.
class PromptSecurityService {
private readonly JAILBREAK_PATTERNS = [
/ignore (previous|all|earlier|above) (instructions|directions|rules|prompts)/i,
/dan mode/i,
/developer mode/i,
/forget (everything|all|your|previous)/i,
/new instructions:/i,
/system:\s*\[/i, // Attempts to inject system messages
];
detectJailbreakAttempt(prompt: string): boolean {
return this.JAILBREAK_PATTERNS.some(pattern => pattern.test(prompt));
}
sanitizeOutput(output: string): string {
// Remove any attempts to leak system prompts
const sanitized = output
.replace(/\[SYSTEM\].*?\[\/SYSTEM\]/gs, '<REDACTED>')
.replace(/\[INST\].*?\[\/INST\]/gs, '<REDACTED>');
return sanitized;
}
}
// Usage in AI Worker
async function processLLMGeneration(job: Job) {
const { prompt, personalityId } = job.data;
// Pre-flight check
if (promptSecurity.detectJailbreakAttempt(prompt)) {
logger.warn({ personalityId, jobId: job.id }, 'Jailbreak attempt detected');
return {
content:
'⚠️ Your prompt appears to contain instructions that violate bot policies. Please rephrase.',
flagged: true,
};
}
// Generate response
const response = await llmProvider.generate(prompt);
// Sanitize output before returning
const sanitized = promptSecurity.sanitizeOutput(response.content);
return { content: sanitized, flagged: false };
}
Problem: If Redis is compromised, attacker can inject malicious jobs.
Solution: Sign BullMQ jobs with HMAC.
import crypto from 'crypto';
class JobSigningService {
private readonly SECRET = process.env.JOB_SIGNING_SECRET!;
signPayload(payload: object): string {
const payloadString = JSON.stringify(payload);
return crypto.createHmac('sha256', this.SECRET).update(payloadString).digest('hex');
}
verifySignature(payload: object, signature: string): boolean {
const expectedSignature = this.signPayload(payload);
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature));
}
}
// API Gateway (job creation)
const jobData = {
prompt: userPrompt,
personalityId,
userId,
timestamp: Date.now(),
};
const signature = jobSigning.signPayload(jobData);
await aiQueue.add('llm-generation', {
...jobData,
signature, // Include signature in job data
});
// AI Worker (job processing)
async function processJob(job: Job) {
const { signature, ...payload } = job.data;
// Verify signature BEFORE processing
if (!jobSigning.verifySignature(payload, signature)) {
logger.error({ jobId: job.id }, 'Job signature verification failed - possible tampering');
throw new Error('Invalid job signature');
}
// Process job normally
// ...
}
Problem: Users can upload .exe files renamed as .png.
Solution: Validate using "magic numbers" (file headers), not extensions.
import fileType from 'file-type'; // npm install file-type
class AttachmentValidator {
private readonly ALLOWED_TYPES = [
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'audio/mpeg', // For voice transcription
'audio/wav',
'audio/ogg',
];
async validateAttachment(buffer: Buffer): Promise<{ valid: boolean; detectedType?: string }> {
// Detect actual file type from magic numbers
const detected = await fileType.fromBuffer(buffer);
if (!detected) {
return { valid: false };
}
const valid = this.ALLOWED_TYPES.includes(detected.mime);
return { valid, detectedType: detected.mime };
}
}
// Usage in bot-client
client.on('messageCreate', async message => {
if (message.attachments.size > 0) {
for (const attachment of message.attachments.values()) {
// Download attachment
const response = await fetch(attachment.url);
const buffer = Buffer.from(await response.arrayBuffer());
// Validate using magic numbers
const { valid, detectedType } = await attachmentValidator.validateAttachment(buffer);
if (!valid) {
await message.reply({
content: `⚠️ Invalid file type detected: ${detectedType}. Only images and audio files are allowed.`,
});
return;
}
// Process valid attachment
// ...
}
}
});
Problem: Admin endpoints (cache invalidation, configuration, etc.) need stronger auth than user endpoints.
Current Implementation: /admin/invalidate-cache uses X-Owner-Id header validation.
Security Concerns:
Owner ID as Authentication - Is it a secret?
DATABASE_URL (via Prisma query)No Rate Limiting - Admin endpoints can be spammed
No Additional Verification - Single factor authentication
Option A: API Key + HMAC Signature (Recommended)
import crypto from 'crypto';
class AdminAuthService {
private readonly INTERNAL_SERVICE_SECRET = process.env.INTERNAL_SERVICE_SECRET!; // Rotate-able secret
generateHMACSignature(payload: object, timestamp: number): string {
const message = JSON.stringify(payload) + timestamp;
return crypto.createHmac('sha256', this.INTERNAL_SERVICE_SECRET).update(message).digest('hex');
}
verifyRequest(req: Request): boolean {
const { 'x-signature': signature, 'x-timestamp': timestamp } = req.headers;
const payload = req.body;
// Reject if timestamp is >5 minutes old (replay attack prevention)
if (Date.now() - Number(timestamp) > 5 * 60 * 1000) {
return false;
}
// Verify HMAC signature
const expectedSignature = this.generateHMACSignature(payload, Number(timestamp));
return crypto.timingSafeEqual(Buffer.from(signature as string), Buffer.from(expectedSignature));
}
}
// Usage in admin routes
app.post('/admin/invalidate-cache', async (req, res) => {
// Verify HMAC signature
if (!adminAuth.verifyRequest(req)) {
return res.status(403).json({ error: 'Invalid authentication' });
}
// Process admin action
// ...
});
Client-side (curl example):
#!/bin/bash
INTERNAL_SERVICE_SECRET="your-admin-api-key"
TIMESTAMP=$(date +%s)000 # Milliseconds
PAYLOAD='{"type":"all"}'
SIGNATURE=$(echo -n "${PAYLOAD}${TIMESTAMP}" | openssl dgst -sha256 -hmac "$INTERNAL_SERVICE_SECRET" | awk '{print $2}')
curl -X POST https://api-gateway.railway.app/admin/invalidate-cache \
-H "Content-Type: application/json" \
-H "X-Signature: $SIGNATURE" \
-H "X-Timestamp: $TIMESTAMP" \
-d "$PAYLOAD"
Option B: Owner ID + Rate Limiting (Current Dev Approach)
Keep X-Owner-Id for dev, but add rate limiting:
import rateLimit from 'express-rate-limit';
// Aggressive rate limiting for admin endpoints
const adminRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 requests per window
message: 'Too many admin requests, try again later',
standardHeaders: true,
legacyHeaders: false,
});
// Apply to all admin routes
app.use('/admin', adminRateLimiter);
// Owner ID verification middleware
const verifyOwner = async (req: Request, res: Response, next: NextFunction) => {
const ownerId = req.headers['x-owner-id'];
if (!ownerId) {
return res.status(401).json({ error: 'Missing X-Owner-Id header' });
}
// Verify owner exists in database
const owner = await prisma.owner.findUnique({
where: { id: ownerId as string },
});
if (!owner) {
return res.status(403).json({ error: 'Invalid owner ID' });
}
next();
};
app.use('/admin', verifyOwner);
Development Environment:
Before Public Production Launch:
INTERNAL_SERVICE_SECRET environment variable to RailwayIf Owner ID is compromised:
Generate new owner in database:
INSERT INTO owners (id, email)
VALUES (gen_random_uuid(), 'your-email@example.com');
Update Railway environment variable:
railway variables set OWNER_ID=<new-uuid> --service api-gateway
Verify old owner can't access:
curl -X POST https://api-gateway.railway.app/admin/invalidate-cache \
-H "X-Owner-Id: <old-uuid>" \
-d '{"type":"all"}'
# Should return 403
Delete old owner from database:
DELETE FROM owners WHERE id = '<old-uuid>';
Problem: AI assistants can hallucinate fake npm packages. Compromised packages can steal secrets.
Solution: Audit dependencies before installing, pin exact versions.
Before installing ANY package suggested by AI:
Verify it exists:
npm view <package-name>
Check weekly downloads (on npmjs.com):
Check last publish date:
npm view <package-name> time.modified
Check for known vulnerabilities:
npm audit
Always pin exact versions in package.json:
{
"dependencies": {
"discord.js": "14.14.1", // ✅ Exact version
"bullmq": "5.1.0", // ✅ Exact version
"pino": "8.17.2" // ✅ Exact version
},
"devDependencies": {
"vitest": "4.0.3" // ✅ Exact version
}
}
NOT:
{
"dependencies": {
"discord.js": "^14.14.1", // ❌ Allows minor/patch updates
"bullmq": "~5.1.0" // ❌ Allows patch updates
}
}
Why: Prevents rogue updates to sub-dependencies from breaking security overnight.
Add npm audit to pre-commit checks:
# .husky/pre-commit or package.json script
#!/bin/sh
npm audit --audit-level=moderate || {
echo "❌ npm audit found vulnerabilities. Fix them before committing."
exit 1
}
Configuration: Tzurot v3 uses Dependabot for automated dependency updates and security patches.
See .github/dependabot.yml for full configuration.
Key Features:
develop branch - follows project workflowBenefits:
✅ Automatic security vulnerability patches
✅ Keeps dependencies current
✅ Reduces manual dependency management
✅ Solo dev friendly: manageable PR volume with grouping
✅ Conventional commit format for changelog integration
When Dependabot Creates a PR:
git fetch origin
git checkout dependabot/npm_and_yarn/services/bot-client/discord.js-14.15.0
pnpm install
pnpm test
Security Vulnerabilities:
Dependabot will create immediate PRs for security vulnerabilities (not just weekly updates).
Priority: Security PRs should be reviewed and merged ASAP.
Workflow:
# 1. Dependabot creates PR: "chore(deps/ai-worker): bump openai from 4.20.0 to 4.20.1 [security]"
# 2. Review the security advisory linked in the PR
# 3. Check tests pass
# 4. Merge to develop
# 5. Deploy to Railway (auto-deploys from develop)