security-and-hardening — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited security-and-hardening (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Security-first development. Three standing constraints: treat every external input as hostile until validated, keep every secret out of code and logs, and check authorization on every protected action. Security is a property of each line that touches user data, authentication, or external systems, built in during construction rather than retrofitted afterward.
A control added without a threat model is a guess. Before hardening, spend five minutes as the attacker:
| Threat | Ask | Typical mitigation |
|---|---|---|
| Spoofing | Can someone impersonate a user/service? | Authentication, signature verification |
| Tampering | Can data be altered in transit or at rest? | Integrity checks, parameterized queries, HTTPS |
| Repudiation | Can an action be denied later? | Audit logging of security events |
| Information disclosure | Can data leak? | Encryption, field allowlists, generic errors |
| Denial of service | Can it be overwhelmed? | Rate limiting, input size caps, timeouts |
| Elevation of privilege | Can a user gain rights they shouldn't? | Authorization checks, least privilege |
If you cannot name a feature's trust boundaries, you cannot secure it. This is OWASP A04: Insecure Design; design flaws, not code typos, drive most breaches.
npm audit, pip-audit, cargo audit, govulncheck, or equivalent) before every releaseeval, innerHTML, exec, template injection)These are prevention patterns, not a ranking. For the 2021 ordering, see the quick-reference table in references/security-checklist.md. Examples appear in two language families; the mitigation is the same across stacks.
// BAD: SQL injection via string concatenation
const query = `SELECT * FROM users WHERE id = '${userId}'`;
// GOOD: parameterized query
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
// GOOD: ORM with parameterized input
const user = await prisma.user.findUnique({ where: { id: userId } });# BAD: SQL injection via string formatting
cur.execute(f"SELECT * FROM users WHERE id = '{user_id}'")
# GOOD: parameterized query (driver binds the value)
cur.execute("SELECT * FROM users WHERE id = %s", (user_id,))
# GOOD: ORM with parameterized input
user = session.query(User).filter(User.id == user_id).one_or_none()The same rule holds for OS commands: pass an argument vector (execFile, subprocess.run([...])), never a shell string built from input.
import { hash, compare } from 'bcrypt';
const SALT_ROUNDS = 12;
const hashedPassword = await hash(plaintext, SALT_ROUNDS);
const isValid = await compare(plaintext, hashedPassword);
// Session cookie flags
app.use(session({
secret: process.env.SESSION_SECRET, // from environment, not code
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // not readable by JavaScript
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
}));from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher()
hashed_password = ph.hash(plaintext)
try:
ph.verify(hashed_password, plaintext)
is_valid = True
except VerifyMismatchError:
is_valid = False
# Session cookie flags (framework-agnostic)
SESSION_COOKIE = dict(
httponly=True, # not readable by JavaScript
secure=True, # HTTPS only
samesite="Lax", # CSRF protection
max_age=24 * 60 * 60,
)The secret comes from the environment in both cases; never hard-code it.
// BAD: rendering user input as HTML
element.innerHTML = userInput;
// GOOD: framework auto-escaping (React escapes by default)
return <div>{userInput}</div>;
// If you MUST render HTML, sanitize first
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userInput);# BAD: concatenating input into markup (autoescape bypassed)
return render_template_string("<div>" + user_input + "</div>")
# GOOD: template autoescaping (Jinja2 escapes {{ ... }} by default)
return render_template("page.html", body=user_input)
# If you MUST allow HTML, sanitize first
import bleach
clean = bleach.clean(user_input)The bypass paths (innerHTML, |safe, mark_safe, render_template_string with concatenation) are where XSS enters.
Check authorization, not just authentication. Verify the caller owns the resource.
app.patch('/api/tasks/:id', authenticate, async (req, res) => {
const task = await taskService.findById(req.params.id);
if (task.ownerId !== req.user.id) {
return res.status(403).json({
error: { code: 'FORBIDDEN', message: 'Not authorized to modify this task' }
});
}
const updated = await taskService.update(req.params.id, req.body);
return res.json(updated);
});@app.patch("/api/tasks/{task_id}")
async def update_task(task_id: str, body: TaskUpdate, user=Depends(authenticate)):
task = await task_service.find_by_id(task_id)
if task.owner_id != user.id:
raise HTTPException(status_code=403, detail="Not authorized to modify this task")
return await task_service.update(task_id, body)Missing ownership checks are IDOR: authenticated, but acting on another user's row.
import helmet from 'helmet';
app.use(helmet());
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // tighten if possible
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'"],
},
}));
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || 'http://localhost:3000',
credentials: true,
}));from fastapi.middleware.cors import CORSMiddleware
SECURITY_HEADERS = {
"Content-Security-Policy": "default-src 'self'; script-src 'self'",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
}
@app.middleware("http")
async def set_security_headers(request, call_next):
response = await call_next(request)
response.headers.update(SECURITY_HEADERS)
return response
app.add_middleware(
CORSMiddleware,
allow_origins=os.environ.get("ALLOWED_ORIGINS", "http://localhost:3000").split(","),
allow_credentials=True,
)CORS is an allowlist of known origins. Never reflect arbitrary origins and never pair * with credentials.
// Strip sensitive fields from API responses
function sanitizeUser(user: UserRecord): PublicUser {
const { passwordHash, resetToken, ...publicFields } = user;
return publicFields;
}
const API_KEY = process.env.STRIPE_API_KEY;
if (!API_KEY) throw new Error('STRIPE_API_KEY not configured');from pydantic import BaseModel
# Response model defines exactly which fields leave the boundary
class PublicUser(BaseModel):
id: str
email: str
# passwordHash / reset_token are absent, so they cannot be serialized
API_KEY = os.environ.get("STRIPE_API_KEY")
if not API_KEY:
raise RuntimeError("STRIPE_API_KEY not configured")Default to an explicit allowlist of returned fields rather than denylisting secrets one by one.
Any time the server fetches a URL the user influenced — webhooks, "import from URL", image proxies, link previews — an attacker can aim it at internal services (cloud metadata, localhost, private IPs).
// BAD: fetch whatever the user gives you
await fetch(req.body.webhookUrl);
// GOOD: allowlist scheme + host, reject if ANY resolved IP is private, forbid redirects
import { lookup } from 'node:dns/promises';
import ipaddr from 'ipaddr.js';
const ALLOWED_HOSTS = new Set(['hooks.example.com']);
async function assertSafeUrl(raw: string): Promise<URL> {
const url = new URL(raw);
if (url.protocol !== 'https:') throw new Error('https only');
if (!ALLOWED_HOSTS.has(url.hostname)) throw new Error('host not allowed');
const addrs = await lookup(url.hostname, { all: true });
if (addrs.some((a) => ipaddr.parse(a.address).range() !== 'unicast')) {
throw new Error('private/reserved IP');
}
return url;
}
await fetch(await assertSafeUrl(req.body.webhookUrl), { redirect: 'error' });# GOOD: allowlist scheme + host, reject if ANY resolved IP is non-global
import ipaddress, socket
from urllib.parse import urlparse
ALLOWED_HOSTS = {"hooks.example.com"}
def assert_safe_url(raw: str) -> str:
url = urlparse(raw)
if url.scheme != "https":
raise ValueError("https only")
if url.hostname not in ALLOWED_HOSTS:
raise ValueError("host not allowed")
for *_, sockaddr in socket.getaddrinfo(url.hostname, 443):
if not ipaddress.ip_address(sockaddr[0]).is_global:
raise ValueError("private/reserved IP")
return rawThe non-unicast / non-global check covers loopback, link-local 169.254.169.254 (cloud metadata, the #1 SSRF target), private, and unique-local ranges across IPv4 and IPv6.
Caveat — this still has a TOCTOU gap. The HTTP client resolves DNS again after the check, so an attacker using a short-TTL record can rebind to an internal IP between validation and connection. For high-risk surfaces, resolve once and connect to the pinned IP, or put a request-filtering proxy in front of all outbound fetches.
import { z } from 'zod';
const CreateTaskSchema = z.object({
title: z.string().min(1).max(200).trim(),
description: z.string().max(2000).optional(),
priority: z.enum(['low', 'medium', 'high']).default('medium'),
dueDate: z.string().datetime().optional(),
});
app.post('/api/tasks', async (req, res) => {
const result = CreateTaskSchema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({
error: { code: 'VALIDATION_ERROR', message: 'Invalid input', details: result.error.flatten() },
});
}
const task = await taskService.create(result.data); // typed and validated
return res.status(201).json(task);
});from datetime import datetime
from typing import Literal, Optional
from pydantic import BaseModel, Field, ValidationError
class CreateTask(BaseModel):
title: str = Field(min_length=1, max_length=200)
description: Optional[str] = Field(default=None, max_length=2000)
priority: Literal["low", "medium", "high"] = "medium"
due_date: Optional[datetime] = None
@app.post("/api/tasks", status_code=201)
async def create_task(raw: dict):
try:
data = CreateTask.model_validate(raw) # typed and validated
except ValidationError as e:
raise HTTPException(422, detail={"code": "VALIDATION_ERROR", "details": e.errors()})
return await task_service.create(data)Validate at the boundary with an allowlist schema; reject anything that does not parse.
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
function validateUpload(file: UploadedFile) {
if (!ALLOWED_TYPES.includes(file.mimetype)) {
throw new ValidationError('File type not allowed');
}
if (file.size > MAX_SIZE) {
throw new ValidationError('File too large (max 5MB)');
}
// Don't trust the file extension — check magic bytes if critical
}ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
MAX_SIZE = 5 * 1024 * 1024 # 5MB
def validate_upload(file):
if file.content_type not in ALLOWED_TYPES:
raise ValidationError("File type not allowed")
if file.size > MAX_SIZE:
raise ValidationError("File too large (max 5MB)")
# Don't trust the file extension — check magic bytes if criticalNot every audit finding needs immediate action. Use this decision tree:
the dependency audit reports a vulnerability
├── Severity: critical or high
│ ├── Is the vulnerable code reachable in your app?
│ │ ├── YES --> Fix immediately (update, patch, or replace the dependency)
│ │ └── NO (dev-only dep, unused code path) --> Fix soon, but not a blocker
│ └── Is a fix available?
│ ├── YES --> Update to the patched version
│ └── NO --> Find a workaround, replace the dependency, or allowlist with a review date
├── Severity: moderate
│ ├── Reachable in production? --> Fix in the next release cycle
│ └── Dev-only? --> Fix when convenient, track in backlog
└── Severity: low
└── Track and fix during regular dependency updatesKey questions:
When you defer a fix, document the reason and set a review date.
A vulnerability audit catches known CVEs; it will not catch a malicious or typosquatted package. Also:
npm ci, pip install --require-hashes, cargo build --locked, or the equivalent for your toolchain. No silent version drift.postinstall, setup.py, build.rs) — they run arbitrary code at install time.cross-env vs crossenv, requests vs request, python-dateutil vs dateutil.import rateLimit from 'express-rate-limit';
// General API rate limit
app.use('/api/', rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
standardHeaders: true,
legacyHeaders: false,
}));
// Stricter limit for auth endpoints
app.use('/api/auth/', rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // 10 attempts per 15 minutes
}));from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
# General API rate limit
@app.get("/api/resource")
@limiter.limit("100/15minutes")
async def resource(request: Request):
...
# Stricter limit for auth endpoints
@app.post("/api/auth/login")
@limiter.limit("10/15minutes")
async def login(request: Request):
...Always apply a tighter limit to authentication endpoints than to general traffic.
.env files:
├── .env.example → committed (template with placeholder values)
├── .env → NOT committed (contains real secrets)
└── .env.local → NOT committed (local overrides)
.gitignore must include:
.env
.env.local
.env.*.local
*.pem
*.keyAlways check before committing:
# Catch accidentally staged secrets
git diff --cached | grep -i "password\|secret\|api_key\|token"If a secret is ever committed, rotate it. Deleting the line or rewriting history is not enough — assume it is compromised the moment it reaches a remote. Revoke and reissue the key first, then purge it from history.
If the app calls an LLM — chatbots, summarizers, agents, RAG — it inherits a new attack surface. Map it to the OWASP Top 10 for LLM Applications (2025):
eval, SQL, a shell, raw markup, or a file path. Validate and encode it exactly as you would raw user input.// BAD: trusting model output as a command or as markup
const sql = await llm.generate(`Write SQL for: ${userQuestion}`);
await db.query(sql); // arbitrary query execution
container.innerHTML = await llm.reply(userMessage); // stored XSS, via the model
// GOOD: model output is data — parse defensively, then validate, then encode
let intent;
try {
intent = CommandSchema.parse(JSON.parse(await llm.replyJson(userMessage)));
} catch {
throw new ValidationError('unexpected model output');
}
await runAllowlistedAction(intent.action, intent.params);
container.textContent = await llm.reply(userMessage);# BAD: trusting model output as a command or as markup
sql = llm.generate(f"Write SQL for: {user_question}")
db.execute(sql) # arbitrary query execution
# GOOD: model output is data — parse defensively, then validate, then encode
try:
intent = CommandSchema.model_validate_json(llm.reply_json(user_message))
except (ValueError, ValidationError):
raise ValidationError("unexpected model output")
run_allowlisted_action(intent.action, intent.params)### Authentication
- [ ] Passwords hashed with bcrypt/scrypt/argon2 (salt rounds ≥ 12)
- [ ] Session tokens are httpOnly, secure, sameSite
- [ ] Login has rate limiting
- [ ] Password reset tokens expire
### Authorization
- [ ] Every endpoint checks user permissions
- [ ] Users can only access their own resources
- [ ] Admin actions require admin role verification
### Input
- [ ] All user input validated at the boundary
- [ ] SQL queries are parameterized
- [ ] HTML output is encoded/escaped
- [ ] Server-side URL fetches are allowlisted (no SSRF to internal services)
### Data
- [ ] No secrets in code or version control
- [ ] Sensitive fields excluded from API responses
- [ ] PII encrypted at rest (if applicable)
### Infrastructure
- [ ] Security headers configured (CSP, HSTS, etc.)
- [ ] CORS restricted to known origins
- [ ] Dependencies audited for vulnerabilities
- [ ] Error messages don't expose internals
### Supply Chain
- [ ] Lockfile committed; CI uses a reproducible, locked install
- [ ] New dependencies reviewed (maintenance, downloads, install-time scripts)
### AI / LLM (if used)
- [ ] Model output treated as untrusted (no eval/SQL/raw-markup/shell)
- [ ] Secrets and other users' data kept out of prompts
- [ ] Tool/agent permissions scoped; destructive actions require confirmationDetailed security checklists and pre-commit verification steps: references/security-checklist.md.
| Rationalization | Reality |
|---|---|
| "This is an internal tool, security doesn't matter" | Internal tools get compromised. Attackers target the weakest link. |
| "We'll add security later" | Retrofitting security costs far more than building it in. Add it now. |
| "No one would try to exploit this" | Automated scanners will find it. Security by obscurity is not security. |
| "The framework handles security" | Frameworks provide tools, not guarantees. You still have to use them correctly. |
| "It's just a prototype" | Prototypes become production. Security habits from day one. |
| "Threat modeling is overkill here" | Five minutes of "how would I attack this?" prevents the design flaws no control can patch later. |
| "It's just LLM output, it's only text" | That "text" can be a SQL statement, a script tag, or a shell command. Treat it like any untrusted input. |
*) originsevalAfter implementing security-relevant code:
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.