Back to catalog

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