| name | vulnerability-remediation |
| description | Use when security-audit has produced SAST or DAST findings and they need to be fixed in code or config. Applies category-specific remediation patterns (injection, XSS, broken access control, crypto, misconfig, SSRF, deserialization), adds a regression test per finding, and re-runs the originating scanner rule to verify closure. |
| safety | writes-shared |
| produces | security/remediation/vulnerabilities-<date>.md |
| consumes | ["security/findings/audit.json"] |
When to use
security-audit has produced security/findings/audit.json with SAST and/or DAST findings.
- A Semgrep / SonarQube / ZAP / Nuclei scan from CI has failed the pipeline on a Medium+ finding.
- A pen-test report has logged a code-level vulnerability that needs engineering-owned remediation.
- A threat-modeling exercise identified an unmitigated class of risk and you need to patch its current instances.
Do not use this skill for dependency CVEs (use dependency-remediation) or container-image issues (use container-remediation). Do not use it for architectural redesign — that's a design-level decision, not a remediation.
Inputs
security/findings/audit.json with category-labelled findings.
- Source repository checkout with test infrastructure runnable locally.
- The exact scanner rule ID per finding (needed to re-verify closure).
Outputs
security/remediation/vulnerabilities-<date>.md — per-finding audit trail.
- Source-code patches with clear before/after.
- One regression test per finding (unit, integration, or API contract, depending on category).
- A PR with CVE-style findings closed + test evidence + re-scan result.
Tool dependencies
- SAST:
semgrep (p/owasp-top-ten, p/sql-injection, custom rule packs), sonar-scanner.
- DAST:
zap-baseline.py, nuclei, wapiti.
- Language-specific lint:
bandit (Python), gosec (Go), eslint-plugin-security (JS), brakeman (Ruby).
- Test runners:
pytest, jest/vitest, go test, rspec.
Procedure
-
Triage by category. Bucket each finding into one of the canonical classes:
| Category | OWASP ref | Example rules |
|---|
| Injection | A03 | sql-injection, command-injection, ldap-injection, ssti |
| Broken access control | A01 | missing-authz-check, idor, path-traversal, unrestricted-file-upload |
| XSS | A03 | reflected-xss, stored-xss, dom-xss |
| Cryptographic / auth | A02 | weak-hash, predictable-randomness, insecure-jwt, missing-csrf |
| Security misconfiguration | A05 | missing-security-headers, verbose-errors, cors-wildcard-credentials |
| Sensitive data exposure | A02 | password-in-logs, stack-trace-in-response, pii-in-url |
| SSRF | A10 | ssrf-user-controlled-url |
| Insecure deserialization | A08 | pickle-load, readObject-without-filter |
-
Apply category-specific fix patterns.
Injection — parameterised queries, not string concatenation:
Python (psycopg2):
cur.execute(f"SELECT * FROM users WHERE email = '{email}'")
cur.execute("SELECT * FROM users WHERE email = %s", (email,))
TypeScript (node-postgres):
await db.query(`SELECT * FROM users WHERE email = '${email}'`);
await db.query("SELECT * FROM users WHERE email = $1", [email]);
Go (sqlx):
db.Query("SELECT * FROM users WHERE email = '" + email + "'")
db.Query("SELECT * FROM users WHERE email = $1", email)
XSS — context-aware escaping:
React is default-safe with {value}. dangerouslySetInnerHTML requires a DOMPurify pass:
import DOMPurify from "isomorphic-dompurify";
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />
Handlebars: prefer {{var}} (escaped) over {{{var}}} (raw). Grep for {{{ and audit each.
Go templates: use html/template, never text/template for browser output.
Broken access control — deny-by-default + owner scoping:
Django:
order = Order.objects.get(id=request.GET["id"])
order = get_object_or_404(Order, id=request.GET["id"], owner=request.user)
Express:
async function requireOwner(req, res, next) {
const resource = await Resource.findById(req.params.id);
if (!resource || resource.ownerId !== req.user.id) return res.sendStatus(404);
req.resource = resource;
next();
}
router.get("/resource/:id", requireOwner, (req, res) => res.json(req.resource));
Weak crypto — migrate with a per-login rehash:
if password_needs_rehash(user.password_hash):
user.password_hash = argon2.hash(password)
user.save()
Missing security headers — one-line fix per framework:
Express + helmet:
import helmet from "helmet";
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
}));
Django settings.py:
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"
CSP_DEFAULT_SRC = ("'self'",)
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
CORS — explicit allowlist, never * + credentials:
app.use(cors({
origin: ["https://app.acme.example", "https://staging.acme.example"],
credentials: true,
maxAge: 86400,
}));
Verbose errors — env-aware error handler:
app.use((err, req, res, _next) => {
logger.error({ err, reqId: req.id });
const body = process.env.NODE_ENV === "production"
? { error: "internal_error", reqId: req.id }
: { error: err.message, stack: err.stack };
res.status(err.status ?? 500).json(body);
});
-
Add a regression test per finding. Never ship a fix without a test that would fail against the old code:
SQL-injection regression (pytest):
def test_login_rejects_sql_injection(client, db):
payload = "'; DROP TABLE users;--"
resp = client.post("/api/login", json={"email": payload, "password": "x"})
assert resp.status_code in (400, 401)
assert db.execute("SELECT COUNT(*) FROM users").scalar() > 0
Security-headers regression (Playwright):
test("home page has strict security headers", async ({ request }) => {
const r = await request.get("/");
expect(r.headers()["strict-transport-security"]).toMatch(/max-age=\d{7,}/);
expect(r.headers()["content-security-policy"]).toContain("default-src 'self'");
expect(r.headers()["x-frame-options"]).toBe("DENY");
expect(r.headers()["referrer-policy"]).toBeTruthy();
});
IDOR regression (pytest + httpx):
def test_order_read_scoped_to_owner(client, user_a, user_b, order_of_a):
resp = client.get(f"/api/orders/{order_of_a.id}", headers=auth(user_b))
assert resp.status_code == 404
-
Verify with the originating scanner. Re-run the specific rule that flagged the finding:
semgrep scan --config=p/sql-injection path/to/file.py
nuclei -u https://staging.acme.example \
-t http/misconfiguration/http-missing-security-headers.yaml
Expected output: rule returns no matches for the affected path.
-
Write the remediation report. security/remediation/vulnerabilities-<date>.md:
- Summary counts by category and severity.
- Per finding: category, CWE, file+line, fix commit SHA, regression test path, re-scan result.
- Residuals table (findings with accepted exceptions).
Examples
Example 1 — SQL injection in Django view
audit.json excerpt:
{
"findings": [
{
"id": "sast-042",
"rule": "python.django.security.audit.raw-sql",
"category": "injection",
"severity": "HIGH",
"cwe": "CWE-89",
"file": "orders/views.py",
"line": 84
}
]
}
Before (orders/views.py):
def search_orders(request):
q = request.GET.get("q", "")
sql = f"SELECT * FROM orders WHERE customer_name LIKE '%{q}%'"
return JsonResponse(list(Order.objects.raw(sql)))
After:
def search_orders(request):
q = request.GET.get("q", "")
qs = Order.objects.raw(
"SELECT * FROM orders WHERE customer_name LIKE %s",
["%" + q + "%"],
)
return JsonResponse(list(qs))
Regression test (tests/test_orders.py):
@pytest.mark.django_db
def test_search_orders_rejects_sql_injection(client, seed_orders):
resp = client.get("/api/orders/search?q=%27%3B+DROP+TABLE+orders%3B--")
assert resp.status_code == 200
assert resp.json() == []
assert Order.objects.count() == len(seed_orders)
Re-verify:
semgrep scan --config=p/sql-injection orders/views.py
Report excerpt:
### sast-042 — SQL injection in orders.views.search_orders
- Category: injection · CWE-89 · HIGH
- Fix: commit c4f8a31 — replaced f-string with parameterised `raw(sql, params)`.
- Test: tests/test_orders.py::test_search_orders_rejects_sql_injection (passes; asserts table survives injection payload).
- Re-scan: semgrep p/sql-injection → 0 findings.
Example 2 — missing security headers on Node/Express API
audit.json excerpt (DAST):
{
"findings": [
{
"id": "dast-011",
"rule": "http-missing-security-headers",
"category": "misconfiguration",
"severity": "MEDIUM",
"cwe": "CWE-693",
"url": "https://staging.acme.example/",
"missing": ["strict-transport-security", "content-security-policy", "x-frame-options"]
}
]
}
Before (server.ts) — no helmet:
const app = express();
app.use(express.json());
app.use("/api", apiRouter);
After:
import helmet from "helmet";
import crypto from "node:crypto";
const app = express();
app.use((req, res, next) => {
res.locals.nonce = crypto.randomBytes(16).toString("base64");
next();
});
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", (_req, res: any) => `'nonce-${res.locals.nonce}'`],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
frameAncestors: ["'none'"],
},
},
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
}));
app.use(express.json());
app.use("/api", apiRouter);
Regression test (Playwright):
test("API responses carry strict security headers", async ({ request }) => {
const r = await request.get("/api/health");
expect(r.headers()["strict-transport-security"]).toMatch(/max-age=31536000/);
expect(r.headers()["content-security-policy"]).toContain("default-src 'self'");
expect(r.headers()["x-frame-options"]).toBe("SAMEORIGIN");
expect(r.headers()["referrer-policy"]).toBe("strict-origin-when-cross-origin");
});
Re-verify:
nuclei -u https://staging.acme.example \
-t http/misconfiguration/http-missing-security-headers.yaml
Report excerpt:
### dast-011 — missing security headers (CWE-693)
- Category: misconfiguration · MEDIUM
- Fix: commit f912d04 — added `helmet` middleware with nonce-based CSP, HSTS 1y, strict referrer, frameAncestors none.
- Test: tests/e2e/headers.spec.ts (4 assertions).
- Re-scan: nuclei http-missing-security-headers → 0 findings.
Constraints
- Never ship a fix without a regression test. The test must reliably fail on the pre-fix code.
- Never suppress a scanner finding in-code (
# nosec, // semgrep-ignore) without writing an exception entry in the remediation report with justification + review date.
- Never widen CORS to
* while enabling credentials: true — the browser rejects it, but even a partial wildcard is a red flag during audit.
- Do not return 403 on an unauthorised access when the resource existence itself is sensitive — return 404. Avoid leaking whether the object exists.
- Do not rehash credentials retroactively at rest without user-initiated flow; plan for per-login rehash instead.
Quality checks
- Re-run the originating scanner rule against the changed path → 0 findings.
- Every finding in the report has: fix commit, test path, re-scan result.
- Full test suite green on the remediation PR, including the new regression tests.
- For each exception (finding not fixed): compensating control + review date + owner captured in the report.
- The PR description lists the CWE IDs closed so the pentest-report can incorporate closure into the next report.