tzurot-security
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.
- Category: discovered
- Tags: github, community
- Version: 1.0.0
- Author: walid
- Downloads: 0
- GitHub stars: 6
- Updated: 2025-11-21
Download the latest version (v1.0.0) from this page.
SKILL.md
---
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-11-19'
---
# Security Skill - Tzurot v3
> **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.
## 🚨 Tier 1: Core Security (MUST FOLLOW)
### 1. Never Commit Secrets
**CRITICAL**: These incidents have happened TWICE in this project. Always verify before committing.
#### ❌ NEVER Commit These:
**Database Connection Strings:**
```bash
# ❌ 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:**
```typescript
// ❌ 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:**
- Private keys
- Session secrets
- Webhook URLs with tokens
- Real user data in test files
- `.env` files (use `.env.example` instead)
#### Pre-Commit Verification Checklist:
Before EVERY commit, verify:
1. **Run git diff and visually scan** for:
- `postgresql://` or `postgres://` URLs
- `redis://` URLs
- Long alphanumeric strings (potential tokens)
- `API_KEY`, `TOKEN`, `SECRET` variable assignments
2. **Check staged files**:
```bash
git diff --cached | grep -iE '(password|secret|token|api.?key|postgresql://|redis://)'
```
3. **Run npm audit**:
```bash
npm audit --audit-level=moderate
```
#### What to Do If You Commit a Secret:
**Immediate Actions:**
1. **DO NOT just delete and commit** - Secret is in git history
2. **Rotate the secret immediately**:
- Database: Generate new password in Railway
- API Keys: Regenerate in provider dashboard
- Discord Token: Regenerate in Discord Developer Portal
3. **Update environment variables** in Railway:
```bash
railway variables set KEY=new-value --service service-name
```
4. **Consider git history rewrite** (if recent and not pushed to shared branch):
```bash
# ⚠️ DANGEROUS - Only if commit not shared
git rebase -i HEAD~3 # Edit commits
git push --force-with-lease
```
### 2. Environment Variable Management
**Pattern: Always use Railway secrets for production**
```typescript
// ✅ 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:**
```bash
# 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
```
### 3. Security Logging (No PII, No Tokens)
**CRITICAL**: Logs are stored indefinitely. NEVER log sensitive data.
```typescript
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:**
- Email addresses
- Phone numbers
- IP addresses (usually)
- Discord usernames (use IDs instead)
- Message content (unless explicitly needed for debugging)
- API keys, tokens, secrets
**Safe to Log:**
- User IDs (snowflakes)
- Guild IDs
- Channel IDs
- Message IDs
- Timestamps
- Error types/codes
### 4. Rate Limiting / Token Budgeting (Economic DoS Prevention)
**Problem**: AI APIs cost money. Spam = wallet drain.
**Solution**: Implement token budgeting per user/guild in Redis.
```typescript
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:**
```typescript
// 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;
}
```
### 5. Discord Permission Verification
**Problem**: Discord API has race conditions. Don't trust real-time permissions.
**Solution**: Cache permissions and verify server-side.
```typescript
// ❌ 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:
```typescript
// 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' });
});
```
## 🛡️ Tier 2: Important Security (SHOULD IMPLEMENT)
### 6. PII Scrubbing Before Storage/Embedding
**Problem**: Once PII is embedded in pgvector, it's nearly impossible to selectively delete.
**Solution**: Scrub PII before storage using regex/NLP libraries.
```typescript
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](https://github.com/microsoft/presidio) for more comprehensive PII detection.
### 7. Prompt Injection Awareness
**Problem**: Users can try "jailbreak" prompts to bypass system instructions.
**Solution**: Implement pre-flight checks and output sanitization.
```typescript
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 };
}
```
### 8. Signed Internal Payloads (BullMQ Job Verification)
**Problem**: If Redis is compromised, attacker can inject malicious jobs.
**Solution**: Sign BullMQ jobs with HMAC.
```typescript
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
// ...
}
```
### 9. Content Validation for Attachments
**Problem**: Users can upload `.exe` files renamed as `.png`.
**Solution**: Validate using "magic numbers" (file headers), not extensions.
```typescript
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
// ...
}
}
});
```
### 10. Admin Endpoint Security
**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:**
1. **Owner ID as Authentication** - Is it a secret?
- Owner ID is stored in `DATABASE_URL` (via Prisma query)
- NOT rotatable like API keys
- NOT truly secret (visible in logs, database)
- ⚠️ Weak authentication for production
2. **No Rate Limiting** - Admin endpoints can be spammed
3. **No Additional Verification** - Single factor authentication
#### ✅ Recommended Production Security:
**Option A: API Key + HMAC Signature (Recommended)**
```typescript
import crypto from 'crypto';
class AdminAuthService {
private readonly ADMIN_API_KEY = process.env.ADMIN_API_KEY!; // Rotate-able secret
generateHMACSignature(payload: object, timestamp: number): string {
const message = JSON.stringify(payload) + timestamp;
return crypto.createHmac('sha256', this.ADMIN_API_KEY).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):**
```bash
#!/bin/bash
ADMIN_API_KEY="your-admin-api-key"
TIMESTAMP=$(date +%s)000 # Milliseconds
PAYLOAD='{"type":"all"}'
SIGNATURE=$(echo -n "${PAYLOAD}${TIMESTAMP}" | openssl dgst -sha256 -hmac "$ADMIN_API_KEY" | 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:
```typescript
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);
```
#### Current Status (v3.0.0-alpha.44):
**Development Environment:**
- ✅ Using X-Owner-Id header validation
- ❌ No rate limiting on admin endpoints
- ❌ No HMAC signatures
- ❌ No additional auth factors
**Before Public Production Launch:**
- [ ] Implement Option A (HMAC signatures) OR strengthen Option B (rate limiting)
- [ ] Add `ADMIN_API_KEY` environment variable to Railway
- [ ] Document key rotation procedure
- [ ] Add admin endpoint monitoring/alerting
#### Owner ID Rotation (Emergency Procedure):
If Owner ID is compromised:
1. **Generate new owner** in database:
```sql
INSERT INTO owners (id, ema