Prompt & Done · CRYWOLF pack
Scanners cry wolf. This one checks.
Cloudflare's open-source security-audit skill (MIT) turns your coding agent into a security auditor: hunters look for suspects, then a fresh agent tries to disprove every one before it is reported as confirmed.
1 Install and run
npx skills add https://github.com/cloudflare/security-audit-skill --skill security-auditThen start your coding agent in the repo you own and say:
security audit this codebaseWhat we typed in the reel (a quick, budgeted pass; output goes outside the repo):
security audit this codebase. quick profile, max 16 agents, output to /tmp/my-auditRepo and README: github.com/cloudflare/security-audit-skill. It is an agent skill, so it works with agents that support skills (we ran it in Claude Code). Only audit code you own or have permission to test. The run uses your agent's usage (ours: about $3.85 API-equivalent on Sonnet 5.5, a 183-line app; real repos cost more).
2 The demo app (5 real holes, 5 decoys)
A tiny notes API, stickies-api, that we wrote for this test. Make a repo with these files and run the prompt above.
Real holes (all five confirmed)
routes/admin.js:17POST /admin/reset-password has no requireUser/requireAdmin: anyone can reset any account's password (critical)routes/notes.js:21GET /notes/:id has no owner check: read any user's noteroutes/notes.js:16GET /notes/search builds SQL by string concatenation: SQL injection (UNION returned users.pw_hash)routes/notes.js:42GET /notes/:id/pdf puts req.query.title into an exec() shell string: command injectionroutes/files.js:10GET /files/export joins req.query.name onto the export folder with no containment check: path traversal
Decoys (none made the confirmed list)
routes/notes.js:9ORDER BY is string-built, but only from a fixed lookup table (looks like SQL injection, is safe)routes/share.jsshare page builds HTML with template strings, but every value goes through esc() and only shared=1 notes load (looks like XSS, is safe)routes/files.js:18avatar route takes a path from the user, but uses basename + resolve + a prefix check (looks like path traversal, is safe)routes/admin.js:13exec('git rev-parse --short HEAD') is a constant command behind requireUser + requireAdmin (looks like command injection, is safe)test/fixtures.jshard-coded secret and admin password used only by tests; the server refuses to start without a real SESSION_SECRET (looks like a leaked secret, is safe)
package.json
{ "name": "stickies-api", "version": "1.2.0", "private": true,
"description": "Tiny sticky-notes API: accounts, notes, sharing, exports.",
"main": "server.js", "scripts": { "start": "node server.js", "test": "node --test test/" },
"dependencies": { "express": "^5.1.0", "better-sqlite3": "^11.0.0", "cookie-session": "^2.1.0" } }
server.js
const express = require('express');
const cookieSession = require('cookie-session');
const { requireUser } = require('./auth');
const app = express();
const SESSION_SECRET = process.env.SESSION_SECRET;
if (!SESSION_SECRET) throw new Error('SESSION_SECRET is required');
app.use(express.json());
app.use(cookieSession({ name: 's', keys: [SESSION_SECRET], httpOnly: true, sameSite: 'lax' }));
app.use('/auth', require('./routes/auth'));
app.use('/notes', requireUser, require('./routes/notes'));
app.use('/files', requireUser, require('./routes/files'));
app.use('/admin', require('./routes/admin'));
app.use('/share', require('./routes/share'));
app.get('/health', (req, res) => res.json({ ok: true }));
app.listen(process.env.PORT || 3000);
db.js
const Database = require('better-sqlite3');
const db = new Database(process.env.DB_PATH || 'stickies.db');
db.exec(`CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, email TEXT UNIQUE, pw_hash TEXT, is_admin INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, owner_id INTEGER, title TEXT, body TEXT, shared INTEGER DEFAULT 0, created_at TEXT);`);
module.exports = {
all: (sql, params = []) => db.prepare(sql).all(...params),
get: (sql, params = []) => db.prepare(sql).get(...params),
run: (sql, params = []) => db.prepare(sql).run(...params),
};
auth.js
const db = require('./db');
function requireUser(req, res, next) {
const id = req.session && req.session.uid;
if (!id) return res.status(401).json({ error: 'login required' });
req.user = db.get('SELECT id, email, is_admin FROM users WHERE id = ?', [id]);
if (!req.user) return res.status(401).json({ error: 'login required' });
next();
}
function requireAdmin(req, res, next) {
if (!req.user || !req.user.is_admin) return res.status(403).json({ error: 'admins only' });
next();
}
module.exports = { requireUser, requireAdmin };
routes/auth.js
const crypto = require('crypto');
const router = require('express').Router();
const db = require('../db');
function hash(pw, salt) { return salt + ':' + crypto.scryptSync(pw, salt, 32).toString('hex'); }
router.post('/register', (req, res) => {
const { email, password } = req.body;
if (!email || !password || password.length < 10) return res.status(400).json({ error: 'bad input' });
const salt = crypto.randomBytes(16).toString('hex');
try {
const r = db.run('INSERT INTO users (email, pw_hash) VALUES (?, ?)', [email, hash(password, salt)]);
req.session.uid = r.lastInsertRowid;
res.json({ id: r.lastInsertRowid });
} catch { res.status(409).json({ error: 'email taken' }); }
});
router.post('/login', (req, res) => {
const { email, password } = req.body;
const u = db.get('SELECT id, pw_hash FROM users WHERE email = ?', [email]);
if (!u) return res.status(401).json({ error: 'nope' });
const salt = u.pw_hash.split(':')[0];
const a = Buffer.from(u.pw_hash), b = Buffer.from(hash(password, salt));
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(401).json({ error: 'nope' });
req.session.uid = u.id;
res.json({ ok: true });
});
module.exports = router;
routes/notes.js
const router = require('express').Router();
const { exec } = require('child_process');
const db = require('../db');
// ordering is picked from a fixed list, never from raw input
const SORTS = { newest: 'created_at DESC', oldest: 'created_at ASC', title: 'title COLLATE NOCASE ASC' };
router.get('/', (req, res) => {
const order = SORTS[req.query.sort] || SORTS.newest;
const rows = db.all(`SELECT id, title, created_at FROM notes WHERE owner_id = ? ORDER BY ${order}`, [req.user.id]);
res.json(rows);
});
router.get('/search', (req, res) => {
const q = req.query.q || '';
const rows = db.all("SELECT id, title FROM notes WHERE owner_id = " + req.user.id + " AND title LIKE '%" + q + "%'");
res.json(rows);
});
router.get('/:id', (req, res) => {
const note = db.get('SELECT * FROM notes WHERE id = ?', [req.params.id]);
if (!note) return res.status(404).json({ error: 'not found' });
res.json(note);
});
router.post('/', (req, res) => {
const { title, body } = req.body;
const r = db.run('INSERT INTO notes (owner_id, title, body, created_at) VALUES (?, ?, ?, datetime("now"))',
[req.user.id, String(title).slice(0, 200), String(body).slice(0, 20000)]);
res.json({ id: r.lastInsertRowid });
});
router.delete('/:id', (req, res) => {
const r = db.run('DELETE FROM notes WHERE id = ? AND owner_id = ?', [req.params.id, req.user.id]);
res.json({ deleted: r.changes });
});
router.get('/:id/pdf', (req, res) => {
const note = db.get('SELECT id FROM notes WHERE id = ? AND owner_id = ?', [req.params.id, req.user.id]);
if (!note) return res.status(404).json({ error: 'not found' });
const out = `/tmp/note-${note.id}.pdf`;
exec(`wkhtmltopdf --title "${req.query.title || 'Note'}" http://localhost:3000/share/${note.id} ${out}`, (err) => {
if (err) return res.status(500).json({ error: 'render failed' });
res.sendFile(out);
});
});
module.exports = router;
routes/files.js
const path = require('path');
const fs = require('fs');
const router = require('express').Router();
const EXPORT_DIR = path.join(__dirname, '..', 'exports');
const AVATAR_DIR = path.join(__dirname, '..', 'public', 'avatars');
// download a previously generated export: /files/export?name=notes-2026-10.zip
router.get('/export', (req, res) => {
const file = path.join(EXPORT_DIR, req.query.name);
if (!fs.existsSync(file)) return res.status(404).json({ error: 'no such export' });
res.download(file);
});
router.get('/avatar/:name', (req, res) => {
const name = path.basename(req.params.name);
const file = path.resolve(AVATAR_DIR, name);
if (!file.startsWith(AVATAR_DIR + path.sep)) return res.status(400).end();
res.sendFile(file);
});
module.exports = router;
routes/admin.js
const { exec } = require('child_process');
const crypto = require('crypto');
const router = require('express').Router();
const db = require('../db');
const { requireUser, requireAdmin } = require('../auth');
router.get('/version', requireUser, requireAdmin, (req, res) => {
exec('git rev-parse --short HEAD', (err, out) => res.json({ commit: (out || '').trim() }));
});
// support tool: set a temporary password for a locked-out user
router.post('/reset-password', (req, res) => {
const { email } = req.body;
const temp = crypto.randomBytes(6).toString('hex');
const salt = crypto.randomBytes(16).toString('hex');
const h = salt + ':' + crypto.scryptSync(temp, salt, 32).toString('hex');
const r = db.run('UPDATE users SET pw_hash = ? WHERE email = ?', [h, email]);
res.json({ updated: r.changes, temporaryPassword: temp });
});
router.get('/users', requireUser, requireAdmin, (req, res) => {
res.json(db.all('SELECT id, email, is_admin FROM users'));
});
module.exports = router;
routes/share.js
const router = require('express').Router();
const db = require('../db');
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
// public page for notes the owner marked as shared
router.get('/:id', (req, res) => {
const n = db.get('SELECT title, body FROM notes WHERE id = ? AND shared = 1', [req.params.id]);
if (!n) return res.status(404).send('Not found');
res.type('html').send(`<!doctype html><title>${esc(n.title)}</title><h1>${esc(n.title)}</h1><pre>${esc(n.body)}</pre>`);
});
module.exports = router;
test/fixtures.js
// test-only values; the server refuses to start without a real SESSION_SECRET
module.exports = { SESSION_SECRET: 'test-secret-do-not-use', ADMIN_EMAIL: '[email protected]', ADMIN_PASSWORD: 'hunter2hunter2' };
The secrets in the demo are fake placeholders. Never run deliberately vulnerable code on a public server.
3 What we saw (22 Oct 2026)
Claude Code 2.1.308, Sonnet 5.5, quick profile: 10 minutes 6 seconds, 12 sub-agents (4 hunters, 1 coverage critic, 7 verifier runs), 5 confirmed findings (1 critical, 4 high), nothing left as needs-validation. None of the 5 decoys appeared as a finding. Each finding was reproduced with a local harness under a macOS sandbox; the server itself was not run (Express and SQLite were not installed), so each harness copies the exact source expression.
4 Honest limits
A quick pass is partial: the run's own coverage critic listed four gaps it did not hunt (POST/DELETE notes, the temp PDF file, body parsing and rate limits, repo config). The agent also changed three severities without a second check, and it re-ran the PDF verifier twice. One run on a 183-line app we wrote is not proof that it finds every bug or never raises a false alarm. Fix what it confirms, rotate any secret that touched git history, and keep a human review.