skills/security-review/SKILL.md
Comprehensive security audit for code changes. Use this skill when implementing authentication, authorization, user input handling, API endpoints, secrets/credentials, payment features, or file uploads. Provides security checklists, vulnerability patterns, and remediation guidance. Integrates with implement-phase as a security quality gate.
npx skillsauth add mhylle/claude-skills-collection security-reviewInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
This skill ensures all code follows security best practices and identifies potential vulnerabilities before they reach production.
Security is not optional. This skill acts as a security quality gate that validates code against common vulnerability patterns (OWASP Top 10) and project-specific security requirements. One vulnerability can compromise the entire platform.
Trigger this skill when code involves:
const apiKey = "sk-proj-xxxxx" // Hardcoded secret
const dbPassword = "password123" // In source code
const config = {
stripe_key: "sk_live_xxxxx" // Committed to repo
}
const apiKey = process.env.OPENAI_API_KEY
const dbUrl = process.env.DATABASE_URL
// Verify secrets exist at startup
if (!apiKey) {
throw new Error('OPENAI_API_KEY not configured')
}
// Use secret management services for production
// AWS Secrets Manager, HashiCorp Vault, etc.
.env, .env.local, .env.production in .gitignoregit log -p | grep -i "api_key\|secret\|password")// DANGEROUS: No validation
async function createUser(req: Request) {
const { email, name, role } = req.body
return db.users.create({ email, name, role }) // role injection!
}
// DANGEROUS: Client-side only validation
if (formData.email.includes('@')) { /* good enough? NO */ }
import { z } from 'zod'
const CreateUserSchema = z.object({
email: z.string().email().max(255),
name: z.string().min(1).max(100).regex(/^[a-zA-Z\s]+$/),
age: z.number().int().min(0).max(150).optional()
// Note: role is NOT accepted from user input
})
export async function createUser(input: unknown) {
const validated = CreateUserSchema.parse(input)
return await db.users.create({
...validated,
role: 'user' // Role set server-side, never from input
})
}
function validateFileUpload(file: File) {
// Size check (5MB max)
const maxSize = 5 * 1024 * 1024
if (file.size > maxSize) {
throw new Error('File too large (max 5MB)')
}
// MIME type check (verify actual content, not just header)
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']
if (!allowedTypes.includes(file.type)) {
throw new Error('Invalid file type')
}
// Extension check (defense in depth)
const allowedExtensions = ['.jpg', '.jpeg', '.png', '.gif']
const extension = file.name.toLowerCase().match(/\.[^.]+$/)?.[0]
if (!extension || !allowedExtensions.includes(extension)) {
throw new Error('Invalid file extension')
}
// Sanitize filename
const sanitizedName = file.name.replace(/[^a-zA-Z0-9.-]/g, '_')
return { ...file, name: sanitizedName }
}
// CRITICAL VULNERABILITY - SQL Injection
const query = `SELECT * FROM users WHERE email = '${userEmail}'`
await db.query(query)
// Also dangerous with template literals
const search = `SELECT * FROM products WHERE name LIKE '%${term}%'`
// Safe - parameterized query with Supabase
const { data } = await supabase
.from('users')
.select('*')
.eq('email', userEmail)
// Safe - parameterized raw SQL
await db.query(
'SELECT * FROM users WHERE email = $1',
[userEmail]
)
// Safe - ORM with proper escaping
const user = await prisma.user.findUnique({
where: { email: userEmail }
})
// Safe - LIKE queries with parameterization
await db.query(
'SELECT * FROM products WHERE name LIKE $1',
[`%${term}%`]
)
// WRONG: localStorage vulnerable to XSS
localStorage.setItem('token', token)
localStorage.setItem('user', JSON.stringify(user))
// WRONG: No authorization check
async function deleteUser(userId: string) {
await db.users.delete({ where: { id: userId } }) // Anyone can delete!
}
// CORRECT: httpOnly cookies (server-side)
res.setHeader('Set-Cookie', [
`token=${token}; HttpOnly; Secure; SameSite=Strict; Max-Age=3600; Path=/`
])
// CORRECT: Authorization check before action
export async function deleteUser(userId: string, requesterId: string) {
const requester = await db.users.findUnique({
where: { id: requesterId },
select: { id: true, role: true }
})
// Check ownership OR admin role
if (requester.id !== userId && requester.role !== 'admin') {
throw new ForbiddenError('Not authorized to delete this user')
}
await db.users.delete({ where: { id: userId } })
}
-- Enable RLS on all tables with user data
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Users can only view their own data
CREATE POLICY "Users view own data"
ON users FOR SELECT
USING (auth.uid() = id);
-- Users can only update their own data
CREATE POLICY "Users update own data"
ON users FOR UPDATE
USING (auth.uid() = id);
-- Admin policy (if needed)
CREATE POLICY "Admins can view all"
ON users FOR SELECT
USING (
EXISTS (
SELECT 1 FROM users WHERE id = auth.uid() AND role = 'admin'
)
);
// DANGEROUS: Direct HTML injection
function Comment({ html }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />
}
// DANGEROUS: Eval-like functions
const userCode = getUserInput()
eval(userCode)
new Function(userCode)()
import DOMPurify from 'isomorphic-dompurify'
function SafeComment({ html }: { html: string }) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br'],
ALLOWED_ATTR: [] // No attributes allowed
})
return <div dangerouslySetInnerHTML={{ __html: clean }} />
}
// Better: Use markdown instead of HTML
import { marked } from 'marked'
import DOMPurify from 'isomorphic-dompurify'
function MarkdownContent({ markdown }: { markdown: string }) {
const html = marked(markdown)
const clean = DOMPurify.sanitize(html)
return <div dangerouslySetInnerHTML={{ __html: clean }} />
}
// next.config.js
const securityHeaders = [
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self'", // Remove 'unsafe-inline' 'unsafe-eval' if possible
"style-src 'self' 'unsafe-inline'", // Needed for most CSS-in-JS
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' https://api.example.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'"
].join('; ')
},
{
key: 'X-Content-Type-Options',
value: 'nosniff'
},
{
key: 'X-Frame-Options',
value: 'DENY'
}
]
// VULNERABLE: State-changing GET request
app.get('/api/delete-account', async (req, res) => {
await deleteAccount(req.user.id)
})
// VULNERABLE: No CSRF token verification
app.post('/api/transfer', async (req, res) => {
await transferMoney(req.body.amount, req.body.to)
})
import { csrf } from '@/lib/csrf'
export async function POST(request: Request) {
// Verify CSRF token from header
const token = request.headers.get('X-CSRF-Token')
if (!csrf.verify(token)) {
return NextResponse.json(
{ error: 'Invalid CSRF token' },
{ status: 403 }
)
}
// Process request
}
// Client-side: Include CSRF token in requests
fetch('/api/action', {
method: 'POST',
headers: {
'X-CSRF-Token': csrfToken, // From meta tag or cookie
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
// Modern browsers: SameSite provides CSRF protection
res.setHeader('Set-Cookie', [
`session=${sessionId}; HttpOnly; Secure; SameSite=Strict; Path=/`
])
// VULNERABLE: No limits on expensive operation
app.post('/api/search', async (req, res) => {
const results = await expensiveSearch(req.body.query)
return res.json(results)
})
// VULNERABLE: No limits on auth endpoints
app.post('/api/login', async (req, res) => {
// Brute force attack possible
})
import rateLimit from 'express-rate-limit'
// General API rate limit
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: { error: 'Too many requests, please try again later' },
standardHeaders: true,
legacyHeaders: false
})
// Strict rate limit for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // 5 attempts per 15 minutes
message: { error: 'Too many login attempts' },
skipSuccessfulRequests: true
})
// Very strict for expensive operations
const searchLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 10,
message: { error: 'Too many search requests' }
})
app.use('/api/', apiLimiter)
app.use('/api/auth/', authLimiter)
app.use('/api/search', searchLimiter)
// WRONG: Logging sensitive data
console.log('User login:', { email, password })
console.log('Payment processed:', { cardNumber, cvv, amount })
console.log('Request body:', req.body) // May contain passwords
// WRONG: Exposing internal errors
catch (error) {
return res.json({
error: error.message,
stack: error.stack, // Stack trace exposed!
query: sqlQuery // Query exposed!
})
}
// WRONG: Returning full user object
return res.json({ user }) // Includes password hash, internal IDs, etc.
// CORRECT: Redact sensitive data in logs
console.log('User login:', { email, userId: user.id })
console.log('Payment processed:', {
last4: card.number.slice(-4),
amount,
userId
})
// CORRECT: Generic error messages to clients
catch (error) {
// Log full error server-side
logger.error('Payment failed', {
error: error.message,
userId,
// Never log card numbers, even partial
})
// Return generic message to client
return res.status(500).json({
error: 'Payment processing failed. Please try again.'
})
}
// CORRECT: Select specific fields
const user = await db.users.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
// Explicitly exclude: passwordHash, internalNotes, etc.
}
})
return res.json({ user })
Only applicable when project involves blockchain/crypto functionality.
// WRONG: Not verifying wallet ownership
async function claimReward(walletAddress: string) {
await sendReward(walletAddress) // Anyone can claim!
}
// WRONG: Blind transaction signing
async function processTransaction(tx: any) {
await wallet.signAndSend(tx) // No validation!
}
import { verify } from '@solana/web3.js'
import nacl from 'tweetnacl'
// Verify wallet ownership with signed message
async function verifyWalletOwnership(
publicKey: string,
signature: string,
message: string
): Promise<boolean> {
try {
const messageBytes = new TextEncoder().encode(message)
const signatureBytes = Buffer.from(signature, 'base64')
const publicKeyBytes = Buffer.from(publicKey, 'base64')
return nacl.sign.detached.verify(
messageBytes,
signatureBytes,
publicKeyBytes
)
} catch {
return false
}
}
// Validate transaction before signing
async function processTransaction(transaction: Transaction) {
// Verify recipient is expected
if (transaction.to !== KNOWN_RECIPIENT) {
throw new Error('Invalid recipient address')
}
// Verify amount is within limits
if (transaction.amount > MAX_TRANSACTION_AMOUNT) {
throw new Error('Amount exceeds limit')
}
// Verify user has sufficient balance
const balance = await getBalance(transaction.from)
if (balance < transaction.amount + GAS_BUFFER) {
throw new Error('Insufficient balance')
}
// Log transaction for audit
await auditLog.record({
type: 'transaction',
from: transaction.from,
to: transaction.to,
amount: transaction.amount,
timestamp: new Date()
})
return await signAndSend(transaction)
}
# Check for known vulnerabilities
npm audit
# Automatically fix what's possible
npm audit fix
# Check for outdated packages
npm outdated
# Update dependencies
npm update
# For major updates, use interactive mode carefully
npx npm-check-updates -i
# ALWAYS commit lock files
git add package-lock.json # or yarn.lock, pnpm-lock.yaml
# In CI/CD, use clean install for reproducible builds
npm ci # Not npm install
# Verify integrity
npm ci --ignore-scripts # Then run scripts separately if needed
npm audit clean or exceptions documented)When invoked as part of the implement-phase pipeline (Step 3), this skill provides structured output for orchestration.
Security Review for Phase [N]
Context:
- Plan: [path to plan file]
- Phase: [phase number and name]
- Changed files: [list of modified/added files]
- Security-relevant changes: [auth, input, api, secrets, payment, uploads]
STATUS: PASS | PASS_WITH_ISSUES | FAIL
CATEGORIES_CHECKED: [count of applicable categories reviewed]
ISSUES_FOUND:
- [CRITICAL] [Category]: [Description]
- [HIGH] [Category]: [Description]
- [MEDIUM] [Category]: [Description]
- [LOW] [Category]: [Description]
SEVERITY: CRITICAL | HIGH | MEDIUM | LOW | NONE
RECOMMENDATIONS:
- [Non-blocking improvement suggestions]
REPORT: [Path to detailed report if written]
| Level | Meaning | Action Required | |-------|---------|-----------------| | CRITICAL | Active vulnerability, exploitable now | Block deployment, fix immediately | | HIGH | Serious vulnerability, likely exploitable | Block deployment, fix before merge | | MEDIUM | Potential vulnerability, context-dependent | Should fix, may proceed with documented exception | | LOW | Best practice violation, minimal risk | Note for improvement |
implement-phase Pipeline:
Step 1: Implementation
Step 2: Functional verification (tests pass)
Step 3: Code review
Step 4: SECURITY REVIEW (this skill) <--
Step 5: Plan synchronization
Step 6: Completion report
On FAIL:
- Block phase completion
- Report blocking issues
- Require fixes before retry
Before ANY production deployment, verify:
For detailed checklists and examples:
Security is everyone's responsibility. When in doubt, ask for a security review.
tools
--- name: tt-workflow-build description: Tasktracker-native trigger for a PARALLEL build via the Claude Code Workflow tool. Thin by design — it does two things, then drives to done: (1) ensure a tasktracker project exists (use the existing one, or create one), then (2) start a dynamic `Workflow` that builds it, tracking the work in tasktracker and using the build + verify skills. It does NOT analyze parallelism up front, ask the user to choose a mode, hand back, or fall back to a sequential skil
tools
--- name: grumpy-reviewer description: A single grumpy, nitpicky structural code reviewer that runs as an isolated subagent and treats the code as third-party work submitted by a junior programmer for validation. It cares about exactly one thing — maintainability — judged through separation of concerns, service-oriented design, helper-method extraction, small files, and the rule of 7 (as any grouping nears 7 members, it pushes for sub-groupings). It is deliberately kept OUT of the implementation
development
--- name: tt-workflow-run description: Tasktracker-native autonomous build-loop orchestrator. Drives a first-class `workflow_run` end-to-end — create the run (Gate 1 lifecycle completeness + Gate 2 zero-defects-in), then loop while `getNextReadyTask(projectId)` returns a slice — `setActiveTask` → record a pre-slice `scanArchitectureDrift` baseline → delegate the slice to `/tt-implement-phase` (which does the code work, registers the architecture delta in-slice, and auto-logs defects/learnings/fr
tools
Tasktracker-native project-wide parallel audit using the Claude Code Workflow tool (dynamic workflows). Partitions a repo / backlog / architecture and fans out read-only agents (one per partition) that return schema-checked findings, aggregates them into a deduplicated, ranked risk register, and OPTIONALLY writes fixes back as tasks under a Bug Fix phase — with all tasktracker writes done by the PARENT, never the parallel agents (single global active-task pointer). Journaled and resumable, so a rate-limit or crash mid-audit resumes without re-running completed partitions. Use for large, embarrassingly-parallel, read/analyze-heavy jobs where each unit is self-contained and the output aggregates — audit every file/component for risk, find all architecture drift (scanArchitectureDrift) or duplicate tasks (detectDuplicates/auditDuplicates), per-file tech-debt sweep, test-coverage or security-surface scan across a whole project. Triggers on "/tt-workflow-audit", "audit the whole repo", "parallel audit", "scan every file/component", "find all drift/duplicates", "tech-debt sweep (tasktracker)", or any whole-project analyze-at-scale request inside a session with a tasktracker project. Prefer this over /codebase-audit or /code-quality-audit when the project is tracked in tasktracker AND you want the findings written back as tasks; prefer it over team-* modes when the units don't need to negotiate live (they just report).