· 9 min read
A penetration test that proves impact still leaves you the hard half of the work: find the root cause in your own code, write the smallest correct fix, and prove it closes the hole without breaking anything. Darkmoon's Pro remediation agent does that half and hands you the result as a pull request. This is the engineering walk-through: the SAST audit that locates the root cause, the sandbox that validates the fix against the real exploit, and the pull-request and credential model, all as implemented in the platform code.
Pro, human-reviewed, add-on to the pentest
Remediation is a Pro-edition capability and it is off unless you ask for it. It runs after the pentest, alongside the server-side report, and it never finalizes the campaign or touches the report body. Its single hard rule: it opens a pull request for a human to review and merge, and never merges on its own. For the narrative overview of the loop, see from finding to fix; this piece goes a level deeper into the audit and validation.
Where it runs
The pentest orchestrator dispatches the remediation agent in PHASE 7, after the specialists have pushed their findings and before the campaign is finalized, but only when three conditions all hold: the operator set REMEDIATE=1, an opaque CREDENTIAL_REF is present, and either a source REPO is given or CREATE_REPO=1 authorizes a fresh one. Otherwise the phase is skipped. The pull requests are produced next to the deterministic server report, so a campaign ends with both a report and a set of reviewable fixes.
for each confirmed finding:
codemap ─▶ sandbox reproduce ─▶ patch (LLM) ─▶ rebuild ─▶ gates ─▶ open PR ─▶ link
▲__________________________________________________| (iterate until confident)Step 1 — the SAST audit that finds the root cause
The agent starts from something a static scanner never has: a finding that was already exploited or confirmed, with the exact request and raw response. codemap.py ranks candidate source locations for that finding, using category-specific vulnerable-sink patterns plus tokens taken from the finding itself, and it deliberately emits several candidates per file so a decoy line cannot hide the real sink.
The audit prompt itself reuses openly published practice: the three-phase method from Anthropic's claude-code-security-review (understand the code's secure pattern, trace the tainted data flow from sink back to source, then write the minimal root-cause fix) and its false-positive discipline. This is SAST in the strict sense: it reports and fixes security issues only, not style, smells or lint.
Step 2 — the patch, and a judge against scope creep
patcher.py generates the fix through an injected LLM. The model returns structured search/replace edits rather than a raw diff; the edits are applied deterministically and a unified diff is synthesised from the result, so the change that lands is exactly what the tooling applied. A second pass, an overreach judge, reviews that diff for scope creep before it is trusted.
Which model runs the patcher is not hard-wired: default_llm() picks Anthropic when an API key is set, an explicit OpenAI-compatible endpoint if configured, otherwise the platform's own provider, that is, the same provider the pentest ran on. The remediation loop does not require a second, different model vendor.
Step 3 — proving the fix in an ephemeral sandbox
This is the step that separates a suggested fix from a validated one. sandbox.py builds an ephemeral, self-destructing Docker container from the repository's own Dockerfile, publishes it only on 127.0.0.1, and removes it on exit. validators.py then runs two gates and blends a confidence score:
- Exploit gate. The original exploit and its variants must now fail against the patched build.
- Regression gate. Known-good behaviour and the repository's own tests must still pass.
A repository teaches the sandbox how to build itself and how to tell "exploited" from "safe" through an optional .darkmoon/repro.json, keyed by finding id or category:
{
"sql_injection": {
"spec": { "mode": "http", "container_port": 8000, "ready_path": "/health", "ready_timeout": 40 },
"exploit": { "kind": "http", "method": "POST", "path": "/login",
"json": { "username": "admin' -- ", "password": "x" }, "marker": "WELCOME" },
"variants": [ { "kind": "http", "method": "POST", "path": "/login",
"json": { "username": "zzz' OR '1'='1' -- ", "password": "y" }, "marker": "WELCOME" } ],
"normal": [ { "kind": "http", "method": "POST", "path": "/login",
"json": { "username": "admin", "password": "s3cr3t" },
"marker": "LOGIN_FAILED", "exploit_status": [401,403,500] } ]
}
}A probe counts as exploited when its marker appears in the response or the status is in the exploit set; a regression probe passes when the good path still works. Absent a repro plan, the agent still generates and judges a fix, but marks it unreproduced, lowers its confidence, and opens a draft.
Four honest dispositions
Every finding ends in one of four states, and none of them is a silent or fabricated fix:
| Disposition | When | What lands |
|---|---|---|
| patched | Gates passed, judge approved, confidence at or above threshold | A pull request opened for review |
| proposed | No push target/credential, or a dry run | A branch and commit prepared locally, recorded for the operator, no external push |
| draft | A fix was produced but not fully validated (e.g. no sandbox) | A draft PR at lower confidence, with the evidence attached |
| failed / skipped | No usable patch, or no code location mapped | Nothing — an honest record, never a fabricated fix |
The pull request, and the dashboard link
scm.py pushes the fix branch (the token is injected locally and never logged) and opens the pull or merge request. GitHub, GitLab, Gitea, Gogs, Bitbucket and Azure DevOps are supported, and it can create a repository for a no-repo engagement. pr_store.py records each PR and maintains the many-to-many link, one PR may fix several findings and one finding may be addressed by several PRs, stamping linked findings with a compact reference so the dashboard shows a PR column without a join. In the UI, that column links each finding to a /pull-request/:id detail page: forge URL, state, linked findings, diff stat, and the before/after validation. Those records are written only by the agent through the dashboard_link_pr MCP tool, the same trust boundary as a finding push, never by an HTTP caller.
The credential the model never sees
Opening a PR needs a token with write access, which is exactly the kind of secret you keep out of a model's context. Push credentials are sealed at rest with a Fernet-based SecretBox; the JSON store never holds a cleartext token, and the credentials API is JWT-gated on every route, returning a secret only as a { set, hint } pair (the last four characters, masked), never its value. The run carries only an opaque CREDENTIAL_REF such as cred_ab12cd; the pipeline resolves the real secret locally at push time, and egress is allow-listed to the operator's chosen git host.
What has been validated
The backend units and integration (the secret box, the credentials store with masking and the auth gate, the PR store, the provider selection) are green, as are the toolkit units (codemap, deterministic patch apply, SCM payloads, validators). End to end on a deliberately SQL-injectable app, the exploit was reproduced against a fresh build, the model produced a parameterized-query fix, the exploit and its variants failed after the patch while valid login still worked, and the run scored a confidence of 0.99 with a real fix branch prepared.
Honest limits
Full sandbox validation needs a buildable Dockerfile and, ideally, a .darkmoon/repro.json; without them the agent still proposes a fix but opens a draft at lower confidence and says so. GitLab exposes merge requests rather than pull requests, treated identically as proposals for review, never auto-merged. The credentials router is JWT-gated and encrypted at rest, but the platform's other data routers are unauthenticated today (a tracked gap), so deploy the dashboard behind its SSO as documented. Bitbucket and Azure DevOps PR creation is implemented but exercised less than the GitHub, GitLab and Gitea paths. And the fix is written by a model, which is the whole reason a human reviews and merges it.
Where this fits
Remediation acts on the findings a pentest proved and the deterministic report it produced, and it is visible on the same live dashboard. Enabled on a scheduled run, it turns a recurring test into a recurring stream of reviewable fixes.
Darkmoon is our open source project (GPL-3.0): github.com/ASCIT31/Dark-Moon, docs.