Skip to content

Commit 1ef01ca

Browse files
committed
fix: never let a detected credential leave the process
Addresses the secret-handling half of the review on #22. A secret finding is the one type where the alert hands us the thing we must not repeat, and where the pull request is not the remediation: the credential was committed, so it is in git history and live whether or not the PR merges. Only rotation fixes it. The PR is still opened — that is what makes the exposure visible and gets it actioned — but it is no longer allowed to imply it is the fix. The body now opens with a warning saying so in terms, and rotation is injected as the first manual step deterministically rather than left to the impact model to think of, so it reaches the webhook too. The credential itself is scrubbed at every point where it would leave the repository's access boundary (redact.py, one chokepoint per path): both model prompts, the impact model's prose on the way back, the PR title, body and commit message, and NotificationPayload — which covers the run log and the Slack webhook, and means a backend added later inherits the scrubbing instead of having to remember it. The commit diff is the deliberate exception: a commit that removes a line renders that line, and no wording changes what `git show` prints. That residue is exactly what the PR warning exists to declare. Candidates come from the alert's code_snippet, longest first — the assignment's quoted value, other quoted literals, and the whole line as a diff contains it. Keys are never candidates: `API_KEY = "sk-live-…"` yields the value, never `API_KEY`, because the variable name is what the PR has to tell an operator to set. Only secret findings build a live redactor; blanking a SAST or CVE snippet would blind gate 2 to the lines it has to judge. Also from the same comment: the sanity gate's secret-pattern check ran only for `secret` findings, so a CVE bump that pasted a token into a lockfile was never checked — the module docstring's "no new secrets" was true for one type in four. It now runs for every type. And secret.md promised "never commit the actual secret value, even to remove it", which is not achievable by construction. It now says what actually happens and what the agent must never author. 32 new tests; 403 total.
1 parent edd0a91 commit 1ef01ca

8 files changed

Lines changed: 614 additions & 33 deletions

File tree

‎plugins/security-engineer/HARNESS.md‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ the commit will contain.
118118

119119
| # | Gate | Checks | On failure |
120120
|---|---|---|---|
121-
| 1 | **sanity** (`validator.sanity_check`) | Diff is non-empty; within the type's line budget (cve 200, sast 100, iac/secret 50); for `secret` findings, no added line matches a secret pattern; the agent's own `diff_summary` does not name a version its diff never added | `FAILED`, no PR |
121+
| 1 | **sanity** (`validator.sanity_check`) | Diff is non-empty; within the type's line budget (cve 200, sast 100, iac/secret 50); no added line matches a secret pattern, **whatever the finding type** — a CVE bump that pastes a token into a lockfile fails here too; the agent's own `diff_summary` does not name a version its diff never added | `FAILED`, no PR |
122122
| 2 | **llm** (`validator.llm_validate`) | A single-shot model call: does this diff address this alert? `pass` / `fail` / `uncertain` | `fail` → `FAILED`. `uncertain` → passes, PR labelled `needs-review` |
123123
| 3 | **verify** (`pipeline.verify`) | **CVE:** the manifest still declares the package; it is no longer on the version the alert was raised against; if the agent chose a different version, that version carries no known advisory; a lockfile beside the manifest agrees; then `go build ./...` (Go) or `cargo metadata --locked` (Cargo). **Other types:** language build check (below) | `FAILED`, no PR |
124124
| 4 | **orca** (`validator.orca_check_gate`) | Polls the Orca GitHub App check on the PR head SHA. On failure, pulls the check **annotations** — file, line, message | Reverts the worktree to the branch head, re-invokes the fix agent *with the annotations as feedback*, re-runs gates 1 and 3, and pushes a follow-up commit to the same PR branch. After `max_retries`, follows `on_failure` |
@@ -205,6 +205,46 @@ fix agents.
205205

206206
---
207207

208+
## Secret findings
209+
210+
A secret finding is the one type where the alert hands the harness the thing it
211+
must not repeat, and where the pull request is **not** the remediation. The
212+
credential was committed: it is in git history and live regardless of whether the
213+
PR merges. Only rotation at the provider fixes it.
214+
215+
The PR is still opened, because it is what makes the exposure visible and gets it
216+
actioned — but it is not allowed to imply it is the fix:
217+
218+
- The body opens with a warning saying in terms that this does not remediate,
219+
that the credential remains in history and live, and that rotation comes first.
220+
- Rotation is injected as the **first** manual step (`_with_rotation_step`),
221+
deterministically rather than left to the impact model to think of — so it
222+
reaches the webhook payload as well as the PR body.
223+
224+
**What is redacted, and what cannot be.** The credential is already in the repo,
225+
so the PR diff adds no audience the repository did not already have. What a run
226+
adds is the paths that leave the repository's access boundary, and those are
227+
closed at the point of egress (`redact.py`):
228+
229+
| Path | Redacted |
230+
|---|---|
231+
| `llm_validate` prompt → model | Yes — whole prompt, including `alert_json` |
232+
| `analyze_impact` prompt → model | Yes — whole prompt |
233+
| Impact `description` / `concerns` / `manual_steps` → PR body | Yes, on parse |
234+
| PR title and body → GitHub, watch notifications | Yes, immediately before `open-pr` |
235+
| `NotificationPayload` → console, run log, webhook | Yes, in `_notify_payload` — one chokepoint, so a backend added later inherits it |
236+
| The commit diff itself | **No — not possible.** A commit that removes a line renders that line. This is what the PR warning exists for |
237+
238+
Candidates come from the alert's `code_snippet`, longest first: the quoted value
239+
on the right of an assignment, any other quoted literal, and the whole stripped
240+
line (which is the form a unified diff contains). Keys are never candidates —
241+
`API_KEY = "sk-live-…"` yields the value, never `API_KEY`, because the variable
242+
name is exactly what the PR has to tell an operator to set.
243+
244+
Only secret findings build a live redactor. For a SAST or CVE alert the
245+
`code_snippet` is ordinary vulnerable code, and scrubbing it from the diff would
246+
blind gate 2 to the lines it has to judge.
247+
208248
## Degradation policy
209249

210250
A harness that fails closed on every unknown never finishes; one that fails open

‎plugins/security-engineer/Makefile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ test:
1515
python3 skills/run/tests/test_version_data.py
1616
python3 skills/run/tests/test_package_identity.py
1717
python3 skills/run/tests/test_pipelines.py
18+
python3 skills/run/tests/test_redact.py
1819
python3 tools/tests/test_check_manifests.py
1920

2021
# Static checks. CI (.github/workflows/ci.yml at the repository root) runs this

‎plugins/security-engineer/skills/run/fix-agents/secret.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ ENV API_KEY=""
8787

8888
## Important Notes
8989

90-
- **Never** commit the actual secret value, even to remove it — the PR body should only describe the type (e.g. "API key", "database password"), not the value itself.
90+
- **Removing the secret is not remediation.** The credential was committed, so it stays in git history and stays live until somebody rotates it at the provider. The pull request is cleanup and a visible prompt to rotate — never describe it as the fix.
91+
- **Never write the value into anything you author.** Not the PR body, not the commit message, not the branch name — describe the type only (e.g. "API key", "database password"). The removal diff is the one place the value unavoidably still appears: a deleted line is rendered by `git`, and no wording changes that. The orchestrator redacts known values from the PR body, the run log, the webhook payload and both model prompts, but treat that as a backstop, not a licence.
9192
- If the secret appears in multiple files, fix all occurrences in the same branch/PR.
9293
- The PR body must list every env var name that needs to be set by operators after merge.
9394

‎plugins/security-engineer/skills/run/impact_agent.py‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from dataclasses import dataclass, field
1111

1212
from _json_util import find_last_json_with_key
13+
from redact import build_redactor
1314
from validator import (
1415
_SINGLE_SHOT_CONTRACT,
1516
_SINGLE_SHOT_MAX_TURNS,
@@ -133,12 +134,16 @@ def analyze_impact(
133134
fix_context: a specialist's FixPlan.metadata, when the type produced one.
134135
Facts the model would otherwise have to infer from the diff.
135136
"""
137+
redactor = build_redactor(alert_json)
136138
prompt = _PROMPT.format(
137139
alert_json=json.dumps(alert_json, indent=2),
138-
diff_text=diff_text[:6000],
140+
diff_text=redactor(diff_text)[:6000],
139141
fix_context=_render_fix_context(fix_context),
140142
contract=_SINGLE_SHOT_CONTRACT,
141143
)
144+
# alert_json and fix_context are in here too, and for a secret finding the
145+
# alert's code_snippet is the credential. Scrub the assembled prompt.
146+
prompt = redactor(prompt)
142147
# No tools at all — see _SINGLE_SHOT_TOOL_FLAGS for why denying them was
143148
# not the same thing, and cost 6x more.
144149
cmd = [
@@ -163,7 +168,7 @@ def analyze_impact(
163168
if result.returncode != 0:
164169
# claude reports its own failures on stdout, not stderr — see
165170
# _subprocess_error_detail. Logging stderr alone printed an empty string.
166-
detail = _subprocess_error_detail(result)
171+
detail = redactor(_subprocess_error_detail(result))
167172
print(f"[WARN] impact analysis failed (exit={result.returncode}): {detail}")
168173
return ImpactResult(
169174
level="medium",
@@ -172,10 +177,18 @@ def analyze_impact(
172177
error=f"exit_code={result.returncode}: {detail}",
173178
)
174179

175-
return _parse(result.stdout)
180+
return _parse(result.stdout, redactor)
176181

177182

178-
def _parse(raw: str) -> ImpactResult:
183+
def _parse(raw: str, redactor=None) -> ImpactResult:
184+
"""Parse the impact verdict.
185+
186+
redactor: description, concerns and manual_steps are pasted verbatim into the
187+
PR body and the webhook payload, so the model's prose is an egress
188+
path like any other. A model explaining why removing a hardcoded
189+
credential is risky can name the credential.
190+
"""
191+
redactor = redactor or build_redactor(None)
179192
try:
180193
envelope = json.loads(raw)
181194
text = envelope.get("result", "") or raw
@@ -184,7 +197,7 @@ def _parse(raw: str) -> ImpactResult:
184197

185198
data = find_last_json_with_key(text, "level")
186199
if not data:
187-
snippet = text[:200] if text else "(empty)"
200+
snippet = redactor(text[:200]) if text else "(empty)"
188201
print(f"[WARN] could not parse impact analysis output: {snippet}")
189202
return ImpactResult(
190203
level="medium",
@@ -195,9 +208,9 @@ def _parse(raw: str) -> ImpactResult:
195208

196209
return ImpactResult(
197210
level=data.get("level", "medium"),
198-
description=data.get("description", ""),
211+
description=redactor(data.get("description", "")),
199212
downtime_risk=bool(data.get("downtime_risk", False)),
200213
requires_deploy=bool(data.get("requires_deploy", True)),
201-
manual_steps=data.get("manual_steps") or [],
202-
concerns=data.get("concerns") or [],
214+
manual_steps=redactor.scrub(data.get("manual_steps") or []),
215+
concerns=redactor.scrub(data.get("concerns") or []),
203216
)

‎plugins/security-engineer/skills/run/orchestrator.py‎

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
repos_with_cve,
4040
)
4141
from pipelines import FixPlan, get_pipeline
42+
from redact import build_redactor
4243
from validator import ci_gate, llm_validate, orca_check_gate, sanity_check, worktree_diff
4344

4445
_RUN_AGENT = str(_THIS_DIR / "run_agent.py")
@@ -503,13 +504,49 @@ def _what_changed(task: AlertTask) -> str:
503504
return "\n".join(lines)
504505

505506

507+
_SECRET_WARNING = """\
508+
> [!WARNING]
509+
> **This pull request does not remediate the exposure.**
510+
>
511+
> The credential was committed, so it is still in this repository's git history
512+
> and still live whether or not this merges. Removing it from the working tree is
513+
> cleanup, not remediation — and the diff below necessarily shows the value being
514+
> removed.
515+
>
516+
> **Rotate the credential first.** Revoke the old value at its provider, confirm
517+
> the replacement works, and only then merge this. Until rotation happens, treat
518+
> the secret as compromised.
519+
520+
"""
521+
522+
# The one step that actually fixes a secret finding. Generated deterministically
523+
# rather than left to the impact model, which may or may not think of it — and it
524+
# rides along to the webhook, so the Slack message carries it too.
525+
_ROTATE_STEP = "Rotate the exposed credential at its provider, then verify the old value is revoked"
526+
527+
528+
def _with_rotation_step(impact: ImpactResult | None, feature_type: str) -> ImpactResult | None:
529+
"""Put rotation at the top of the manual steps for a secret finding."""
530+
if impact is None or feature_type != "secret":
531+
return impact
532+
steps = list(impact.manual_steps or [])
533+
if not any("rotat" in step.lower() for step in steps):
534+
impact.manual_steps = [_ROTATE_STEP, *steps]
535+
return impact
536+
537+
506538
def _commit_and_pr(task: AlertTask, impact: ImpactResult | None, dry_run: bool) -> str | None:
507539
"""Stage, commit, and open PR. Returns PR URL or None (dry-run)."""
508540
if dry_run:
509541
print(f" [dry-run] would commit and open PR for {task.alert_id}")
510542
return None
511543

512-
commit_msg = f"fix(security): {task.title} ({task.alert_id})"
544+
# Built once here and applied to everything this function authors. The diff
545+
# itself cannot be scrubbed — a commit that removes a line renders that line —
546+
# which is what _SECRET_WARNING exists to say out loud.
547+
redactor = build_redactor(task.alert_json)
548+
549+
commit_msg = redactor(f"fix(security): {task.title} ({task.alert_id})")
513550

514551
impact_level = impact.level if impact else "unknown"
515552
impact_desc = impact.description if impact else ""
@@ -525,7 +562,10 @@ def _commit_and_pr(task: AlertTask, impact: ImpactResult | None, dry_run: bool)
525562
f"- {c}" for c in impact.concerns
526563
)
527564

565+
warning = _SECRET_WARNING if task.feature_type == "secret" else ""
566+
528567
pr_body = (
568+
f"{warning}"
529569
f"## Security Fix: {task.title}\n\n"
530570
f"**Alert:** `{task.alert_id}` | "
531571
f"**Risk:** {task.risk_level} | "
@@ -539,6 +579,11 @@ def _commit_and_pr(task: AlertTask, impact: ImpactResult | None, dry_run: bool)
539579
)
540580
pr_title = f"fix(security): {task.title[:60]} [{task.alert_id}]"
541581

582+
# Covers the impact model's prose and the fix agent's own diff_summary in one
583+
# pass, before the body reaches GitHub and the watch notifications it sends.
584+
pr_body = redactor(pr_body)
585+
pr_title = redactor(pr_title)
586+
542587
_run(["python3", _RUN_AGENT, "git-commit", task.alert_id, commit_msg],
543588
cwd=task.worktree_path)
544589

@@ -581,18 +626,26 @@ def _revert(worktree_path: Path) -> None:
581626

582627

583628
def _notify_payload(task: AlertTask) -> NotificationPayload:
629+
"""Build the payload for one alert, scrubbed.
630+
631+
Every notification in the run is built here, which makes this the single
632+
place a credential could reach LogFileNotifier's file on disk or
633+
WebhookNotifier's POST to Slack. Scrubbing at the source means a backend
634+
added later inherits it instead of having to remember.
635+
"""
636+
redactor = build_redactor(task.alert_json)
584637
return NotificationPayload(
585638
event="",
586639
alert_id=task.alert_id,
587640
feature_type=task.feature_type,
588641
risk_level=task.risk_level,
589642
repo="",
590643
pr_url=task.pr_url,
591-
reason=task.failure_reason,
644+
reason=redactor(task.failure_reason),
592645
impact_level=task.impact.level if task.impact else None,
593-
manual_steps=task.impact.manual_steps if task.impact else [],
594-
concerns=task.impact.concerns if task.impact else [],
595-
error_detail=task.impact.error if task.impact else None,
646+
manual_steps=redactor.scrub(task.impact.manual_steps if task.impact else []),
647+
concerns=redactor.scrub(task.impact.concerns if task.impact else []),
648+
error_detail=redactor(task.impact.error if task.impact else None),
596649
)
597650

598651

@@ -720,7 +773,9 @@ def _run_pipeline(task: AlertTask, dry_run: bool, notifier, repo: Repository) ->
720773
task.state = "DONE"
721774
p = _notify_payload(task)
722775
p.repo = repo.name
723-
p.detail = fix_result.diff_summary or ""
776+
# The fix agent's own account of what it would do. For a secret finding
777+
# that prose can quote the credential, and dry-run still notifies.
778+
p.detail = build_redactor(task.alert_json)(fix_result.diff_summary or "")
724779
notifier.notify("fix_planned", p)
725780
return task
726781

@@ -771,8 +826,10 @@ def _run_pipeline(task: AlertTask, dry_run: bool, notifier, repo: Repository) ->
771826
task.state = "IMPACT_ANALYSIS"
772827
print(f"[PHASE] {task.alert_id} production impact analysis", flush=True)
773828
diff_text = _get_diff(task.worktree_path)
774-
task.impact = analyze_impact(task.alert_json, diff_text,
775-
fix_context=plan.metadata or None)
829+
task.impact = _with_rotation_step(
830+
analyze_impact(task.alert_json, diff_text,
831+
fix_context=plan.metadata or None),
832+
task.feature_type)
776833
if task.impact.error:
777834
print(f"[WARN] {task.alert_id} impact analysis error: {task.impact.error}")
778835

0 commit comments

Comments
 (0)