When to use
security-audithas producedsecurity/findings/audit.jsonwith 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.jsonwith 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,sstiBroken access control A01 missing-authz-check,idor,path-traversal,unrestricted-file-uploadXSS A03 reflected-xss,stored-xss,dom-xssCryptographic / auth A02 weak-hash,predictable-randomness,insecure-jwt,missing-csrfSecurity misconfiguration A05 missing-security-headers,verbose-errors,cors-wildcard-credentialsSensitive data exposure A02 password-in-logs,stack-trace-in-response,pii-in-urlSSRF A10 ssrf-user-controlled-urlInsecure deserialization A08 pickle-load,readObject-without-filter -
Apply category-specific fix patterns.
Injection — parameterised queries, not string concatenation:
Python (psycopg2):
# Before cur.execute(f"SELECT * FROM users WHERE email = '{email}'") # After cur.execute("SELECT * FROM users WHERE email = %s", (email,))TypeScript (node-postgres):
// Before await db.query(`SELECT * FROM users WHERE email = '${email}'`); // After await db.query("SELECT * FROM users WHERE email = $1", [email]);Go (sqlx):
// Before db.Query("SELECT * FROM users WHERE email = '" + email + "'") // After db.Query("SELECT * FROM users WHERE email = $1", email)XSS — context-aware escaping:
React is default-safe with
{value}.dangerouslySetInnerHTMLrequires 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, nevertext/templatefor browser output.Broken access control — deny-by-default + owner scoping:
Django:
# Before order = Order.objects.get(id=request.GET["id"]) # After order = get_object_or_404(Order, id=request.GET["id"], owner=request.user)Express:
// Policy middleware 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:
# During login, after verifying old hash 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 = TrueCORS — explicit allowlist, never
*+ credentials:app.use(cors({ origin: ["https://app.acme.example", "https://staging.acme.example"], credentials: true, // valid ONLY with explicit origin maxAge: 86400, }));Verbose errors — env-aware error handler:
app.use((err, req, res, _next) => { logger.error({ err, reqId: req.id }); // full detail to logs 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 # table survivedSecurity-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 # NOT 403 — leaks existence; return 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 # SAST nuclei -u https://staging.acme.example \ -t http/misconfiguration/http-missing-security-headers.yaml # DASTExpected 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() == [] # no match, no escape
assert Order.objects.count() == len(seed_orders) # table intact
Re-verify:
semgrep scan --config=p/sql-injection orders/views.py
# Results: 0 findings
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
# [INF] No results found
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 enablingcredentials: 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.