Departments / security / vulnerability-remediation

vulnerability-remediation

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.

Department

Security

Safety

writes-shared
Writes shared state

Supported stacks

Stack-agnostic — no detection required.

Produces

security/remediation/vulnerabilities-<date>.md

Consumes

  • security/findings/audit.json

When to use

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

Outputs

Tool dependencies

Procedure

  1. Triage by category. Bucket each finding into one of the canonical classes:

    CategoryOWASP refExample rules
    InjectionA03sql-injection, command-injection, ldap-injection, ssti
    Broken access controlA01missing-authz-check, idor, path-traversal, unrestricted-file-upload
    XSSA03reflected-xss, stored-xss, dom-xss
    Cryptographic / authA02weak-hash, predictable-randomness, insecure-jwt, missing-csrf
    Security misconfigurationA05missing-security-headers, verbose-errors, cors-wildcard-credentials
    Sensitive data exposureA02password-in-logs, stack-trace-in-response, pii-in-url
    SSRFA10ssrf-user-controlled-url
    Insecure deserializationA08pickle-load, readObject-without-filter
  2. 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}. 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:

    # 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 = True

    CORS — 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);
    });
  3. 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 survived

    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       # NOT 403 — leaks existence; return 404
  4. 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  # DAST

    Expected output: rule returns no matches for the affected path.

  5. 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

Quality checks

Customise for your organisation

vulnerability-remediation

The LLM will rewrite this skill for your environment. Your API key and form inputs stay in your browser — only the skill and your environment go to OpenRouter.

One line. Be specific — cloud, language, framework, orchestrator.

Free text that steers the rewrite. Leave blank if nothing specific.

cost estimate: