Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 44 additions & 23 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,29 +382,50 @@ holds only while a human keeps checking.
still accumulate state and pollute the malicious-user telemetry the demo points at, so a
minimum interval between replays of the same finding belongs in the design.

- [ ] **I2** Drift and conflict detection. (M, P1)
`snapshots/` already captures the pre-change LB state. Turn that into a comparison run
before apply: what is on the LB now versus what the last run left versus what is about to
be pushed.
- Acceptance: re-applying an unchanged control reports `no_change` and writes nothing; a
hand edit made in the XC console since the last apply reports as a field-level diff; an
earlier ALLOW rule shadowing the new DENY blocks apply as a conflict; drift runs
read-only.
- Surfaces: `src/vpcopilot/drift.py`, `vpcopilot drift --lb <name>`, `GET /api/drift`,
a drift check before `engine.ApplyContext.load()` takes the snapshot.
- **Reconciled:** `engine.SafeApply` is not a symbol — "SafeApply spine" is prose for
`engine.py`, which exports `RollbackError`, `protected_lbs`, `guard_lb`, `ApplyContext`,
`poll_until`, `safe_rollback`. The snapshot is taken in `ApplyContext.load()`.
- **`no_change` must return before `ApplyContext.load()`**, not merely before the mutation:
`load()` writes `out/lb_snapshot.json` *and* a fresh `out/snapshots/<lb>-<ts>.json` on every
call (`engine.py:63-72`), and `self_test()` always issues an idempotent PUT to XC
(`engine.py:75-83`). A drift check therefore does its own `xc.get_lb()`.
- **Partly built:** `ApplyContext.load()` already writes per-LB timestamped snapshots to
`out/snapshots/` and `export.py` already bundles them, so the raw material exists. Note also
that `lint_service_policy` already implements an *exploit-relative* FIRST_MATCH shadow check
(`apply.py:116-152`) — it returns early without an exploit request (`apply.py:130-131`), so
the reusable piece for a drift comparison is the `_matches` + FIRST_MATCH walk, not the
function as-is.
- [x] **I2** Drift and conflict detection. (M, P1) — **DONE:** `drift.py` compares live LB vs last
snapshot vs proposed, read-only throughout. `drift.preflight()` is the pre-apply gate, called by
**both** apply paths — `apply.apply_from_scan` and `refiner.refine_apply_service_policy` (the
latter is the default for `--from-scan` and the console's Mitigate button, so gating only the
former would have gated nothing anyone uses). Surfaces: `vpcopilot drift --lb <name>` (exit 1 on
conflict), `GET /api/drift`, `--force` / console **apply anyway**. Six new audit actions, all
category `gate`. Verified live against `banknimbus-dev`.
- **Acceptance, as met:**
- `no_change` — met. Reports and writes nothing: no LB PUT, no `snapshots/`, no
`lb_snapshot.json`, and no stray policy object, because the gate runs before the XC *create*,
not just before `ApplyContext.load()`.
- field-level diff of a hand edit — met. Verified against a real 2026-07-24 snapshot of
`banknimbus-dev`: 12 dotted-path changes, correctly attributed.
- drift runs read-only — met, and pinned by tests on both the CLI and the endpoint (the
endpoint is polled from the browser; a version that wrote a snapshot would corrupt the run
dir just by someone opening a page).
- **"an earlier ALLOW rule shadowing the new DENY blocks apply as a conflict" — the criterion
as written was wrong, and running it live is what proved it.** It assumes the new policy is
appended after the attached ones. Both attach paths do the opposite — they replace the oneof
with exactly one policy (`apply.py`, `refiner.py`: `active_service_policies = {"policies":
[{ns, name}]}`) — so this tool never leaves two service policies attached and there is no
earlier policy to be shadowed by. The first live run produced a confident false positive on
`banknimbus-dev`. Replaced by the two real behaviours underneath it:
- **shadowing, in its only true scope** — an ALLOW *inside the policy being applied* that
matches the exploit before its DENY. This **refuses** (`--force` overrides), and reuses
`lint_service_policy` rather than re-deriving FIRST_MATCH so the two can never disagree.
On the refine path it degrades to a warning: reordering rules is what the refine loop
exists to do, and refusing there would break the default flow to protect it from a problem
it already fixes.
- **displacement** — that same wholesale replacement silently *detaches* whatever was
attached, which is a live loss of protection nothing was reporting. Warned and audited
(`policy_displaced`, carrying whether the displaced policy is what currently blocks this
exploit), never refused: replacing the previous band-aid is the normal flow and refusing
would break every second apply. Follows the G2 precedent — warn with an audited override,
not a machine veto.
- **Deliberate partial:** `no_change` is determined for `service_policy` only. There the proposed
end state is exact (that policy name in `active_service_policies`), so "unchanged" is a fact.
For the six LB-wide toggles, presence is detectable by inverting `detach_control` but presence
is **not** parameter equality — a `rate_limit` already on at 100/MINUTE would read as unchanged
while you push 5/MINUTE. Silently skipping a real change is worse than re-applying an identical
one, so those report `already_attached` with a note and are never auto-skipped.
- **No regressions:** an LB already carrying a foreign policy still mitigates (warn + proceed);
dry runs are never gated; `create_only` is never gated; behaviour with no snapshot and no
conflict is byte-identical to before. Each pinned by a test.

---

Expand Down
21 changes: 20 additions & 1 deletion docs/AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ Source of truth: `src/vpcopilot/audit.py` (the sink), `runmeta.py` (identity + p
attaching/enabling a band-aid on an LB, a refine-and-retry loop, opening a code-fix PR, retiring a
band-aid, and a rollback that failed. One JSON object per line in `<out>/audit.log`, never rewritten.

Also recorded: every **gate decision** — the pre-apply drift check refusing, being overridden,
skipping an unchanged apply, or reporting that an apply detached another policy. Nothing changed on
the LB in those cases, which is exactly why they belong here. "We looked and refused" and "we looked
and overrode it anyway" are questions an auditor asks, and only the log can answer them.

That scope is the whole band-aid path, but it is not literally every XC write the tool can make: the
lab/teardown utilities `vpcopilot lab-create` (`lab.py`) and `vpcopilot xc-rm` (`cli.py`) mutate XC
outside the finding lifecycle and write **no** audit record. They are setup/cleanup helpers, not
Expand Down Expand Up @@ -86,7 +91,7 @@ detail = {k: v for k, v in detail.items() if k not in _STAMPED}

Everything else is per-action detail.

### The 15 actions
### The 21 actions

| action | category | what it means | key detail fields |
|---|---|---|---|
Expand All @@ -105,6 +110,12 @@ Everything else is per-action detail.
| `open_pr` | cure | The code-fix PR (the cure) was opened on GitHub | `finding_id` `finding` `repo` `url` `number` |
| `rollback_failed` | rollback | The LB could **not** be confirmed restored to its pre-apply snapshot after N retries. The one entry that must never be anonymous — the LB may be left in a changed state | `finding_id` `lb` `namespace` `reason` |
| `apply_timing` | timing | Wall-clock + outcome for one console Mitigate click. Feeds MTTM on the hero panel and policy quality in the model benchmark | `control` `finding_id` `passed` `elapsed_s` `attempts` `before_after` `unfixable` `reason` `kept` |
| `drift_detected` | gate | The live LB differs from the snapshot the last apply left — someone edited it outside this tool. Carries the full field-level diff | `lb` `policy` `snapshot` `changes` |
| `policy_displaced` | gate | Attaching replaces `active_service_policies` wholesale, so this apply **detached** another service policy. `protects_exploit` is true when the displaced policy is what currently blocks this finding's exploit | `lb` `policy` `displaced` `rules` `denies` `protects_exploit` |
| `apply_skipped_no_change` | gate | The policy asked for was already the attached one. Nothing was pushed — no LB PUT, no snapshot, no run artifact | `lb` `policy` |
| `drift_block` | gate | The apply was **refused**: an ALLOW inside the policy matched the exploit before its DENY, so under FIRST_MATCH the band-aid would have attached cleanly and blocked nothing | `lb` `policy` `conflicts` |
| `drift_override` | gate | The same conflict, applied anyway via `--force` / the console's **apply anyway**. The override is the point of the entry | `lb` `policy` `conflicts` |
| `simulate_override` | gate | A policy that shadow simulation flagged as over-broad was promoted anyway | `finding_id` `policy` `lb` `block_rate` `threshold` `reason` |

Notes read from the source:

Expand All @@ -122,6 +133,14 @@ Notes read from the source:
- `apply_timing` is written by the console only, and only when `dry_run` is false
(`console/app.py::_run_action`). A CLI-driven run has none — MTTM and `elapsed_s` are simply
absent, not zero.
- The six **gate** actions are the only ones that record a moment when the LB did **not** change.
Their `outcome` in the normalized export is the decision itself — `refused`, `overridden`,
`no_change`, `displaced`, `drift_detected` — never `passed`/`failed`, because there was no apply
to score. Dry runs remain unlogged: a gate decision is a real decision about a real apply, a dry
run is a preview.
- `policy_displaced` is a **warning, not a veto**. Every apply replaces whatever was attached, so
refusing would break every second apply; it is said out loud and written down instead. If you are
auditing what protection an LB lost over time, this is the entry to read.

### Reading it raw

Expand Down
6 changes: 4 additions & 2 deletions docs/DEMO.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ To regenerate them: rebuild the dataset with `python3 demo/build_demo_out.py`, r
`#simulate`, `#mitigate` and `#retire` steps plus `demo/out/report.html` at 1200px wide / 2× device pixel ratio.

Point the console at a **credential-free** `.env` when you do (`VPCOPILOT_ENV=…`): with XC creds
loaded, the hero band renders a deep link carrying your tenant hostname and namespace, and that
would ship in the image. `build_demo_out.py` curates `actor`/`host`/`out_dir` in the fixture for the
loaded, the hero band renders a deep link carrying your tenant hostname and namespace, and the
Mitigate step's drift strip renders the target LB's live control set — both would ship in the image.
Without creds the drift strip renders nothing at all, which is why it does not appear in
`4-mitigate.png`. `build_demo_out.py` curates `actor`/`host`/`out_dir` in the fixture for the
same reason — no real machine identity in a shared dataset.
31 changes: 31 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,34 @@ agent to correct the spec (using the exact exploit + legit requests), and retrie
required". The working refined spec is written back to the artifact. `--no-refine` for single-shot.
Configurable in the console's action bar (**refine** + **attempts**).

### Pre-apply drift check (I2)

Before anything is created or attached, a **read-only** comparison runs against the live LB. It
answers three questions and short-circuits or warns accordingly:

```sh
vpcopilot drift --lb <lb> # what changed since the last apply
vpcopilot drift --lb <lb> --control service_policy --policy <name> --finding <id>
```

| finding | what happens | override |
|---|---|---|
| the policy is **already the attached one** | reports `no_change` and **writes nothing** — no LB PUT, no snapshot, no run artifact | `--force` |
| a field on the LB **changed since the last snapshot** (someone edited it in the XC console) | field-level diff printed and audited as `drift_detected`; the apply continues | — |
| applying will **detach** another service policy | warned and audited as `policy_displaced`, including whether the displaced policy is what currently blocks this exploit; the apply continues | — |
| an **ALLOW inside the policy being applied** matches the exploit before its DENY | refused — under FIRST_MATCH the band-aid would attach cleanly and block nothing | `--force`, or `--refine` (default), which reorders it instead |

`drift` exits `1` when it finds a conflict, so it composes in a script. The check never PUTs, never
writes a snapshot, and never touches the run directory — it is safe to run or poll at any time.

**Why displacement is a warning and not a refusal.** Attaching replaces `active_service_policies`
wholesale, so every apply detaches whatever was there. Replacing the previous band-aid is the
normal flow; refusing would break every second apply. It is said out loud and written down instead.

In the console the same check runs inside the Mitigate job, so its warnings appear in the live log.
A refusal renders an **apply anyway** button, and `no_change` renders as its own outcome rather
than a pass or a fail.

## 5. Open the code-fix PR (the cure)
```sh
vpcopilot pr --repo owner/name --base <branch> --path-prefix <repo-relative-dir> [--finding <id>] [--dry-run]
Expand Down Expand Up @@ -206,6 +234,9 @@ VPCOPILOT_DEFAULT_PREFIX= # usually empty
- **Guardrails:** `PROTECTED_POLICIES` (the `nimbus-*` demo policies) can't be created/deleted;
protected LBs (`VPCOPILOT_PROTECTED_LBS`, default `nimbus-www`) can't be mutated without
`--allow-protected-lb`.
- **Pre-apply drift check:** before any create or attach, the live LB is compared against the last
snapshot and against what is about to be pushed — see [Pre-apply drift check](#pre-apply-drift-check-i2).
Re-applying an unchanged policy writes nothing; a policy that could never fire is refused.
- **Reversible:** every apply snapshots the LB and rolls back on validation failure (or by
default). Every change is written to the append-only audit log — the finding that justified it,
the control and the XC object, the load balancer and its namespace, whether it was kept or rolled
Expand Down
61 changes: 44 additions & 17 deletions src/vpcopilot/apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,35 @@ def artifact_spec(spec: dict) -> dict:
return inner if isinstance(inner, dict) and inner else spec


def rule_matches(rule_spec: dict, exploit: dict) -> bool:
"""Does one rule's path+method matcher cover this request?

Extracted from `lint_service_policy` unchanged so `drift.py` can run the same FIRST_MATCH walk
ACROSS the policies already attached to an LB, not just within one spec. Behaviour is
deliberately identical, including that `exact_values` is treated as a prefix — loosening or
tightening it here would silently change what the linter reports."""
import re
path = exploit.get("path", "") or ""
method = (exploit.get("method") or "GET").upper()
p = rule_spec.get("path") or {}
ok = any(path == v or path.startswith(v)
for v in (p.get("prefix_values") or []) + (p.get("exact_values") or []))
for rx in (p.get("regex_values") or []):
try:
if re.search(rx, path):
ok = True
except re.error:
pass
methods = [m.upper() for m in ((rule_spec.get("http_method") or {}).get("methods") or [])]
return ok and (not methods or method in methods)


def lint_service_policy(spec: dict, exploit: dict | None) -> list[str]:
"""Deterministic pre-apply lint — catch a service_policy that won't actually block the exploit,
BEFORE any live LB round-trip. Under FIRST_MATCH the FIRST rule whose path+method match the
exploit decides its fate; if that's an ALLOW (a bad rule order, or a DENY whose path is a wrong
guess so an allow-all catches the exploit first), the exploit sails through. Returns issue
strings ([] = looks correct)."""
import re
spec = artifact_spec(spec) # accept a {metadata, spec} artifact, exactly as apply_from_scan does
rules = (spec.get("rule_list") or {}).get("rules") or []
if not any((r.get("spec") or {}).get("action") == "DENY" for r in rules):
Expand All @@ -141,22 +163,9 @@ def lint_service_policy(spec: dict, exploit: dict | None) -> list[str]:
path = exploit.get("path", "") or ""
method = (exploit.get("method") or "GET").upper()

def _matches(rs: dict) -> bool:
p = rs.get("path") or {}
ok = any(path == v or path.startswith(v)
for v in (p.get("prefix_values") or []) + (p.get("exact_values") or []))
for rx in (p.get("regex_values") or []):
try:
if re.search(rx, path):
ok = True
except re.error:
pass
methods = [m.upper() for m in ((rs.get("http_method") or {}).get("methods") or [])]
return ok and (not methods or method in methods)

for r in rules: # FIRST_MATCH: the first path+method match decides
rs = r.get("spec") or {}
if _matches(rs):
if rule_matches(rs, exploit):
if rs.get("action") == "ALLOW":
return [f"an ALLOW rule matches the exploit {method} {path} before any DENY — "
"FIRST_MATCH lets it through (fix the rule order or the DENY path)"]
Expand Down Expand Up @@ -356,11 +365,16 @@ def rollback(): # B3: verified, retried rollback (LB restored to snapshot or Ro
def apply_from_scan(artifact_path: str, lb: str, target_url: str, *, name: str | None = None,
create_only: bool = False, dry_run: bool = False, keep: bool = False,
allow_protected: bool = False, probe: bool = False, retries: int = 8,
wait_seconds: int = 8, finding_id: str | None = None,
wait_seconds: int = 8, finding_id: str | None = None, force: bool = False,
out_dir: str = "out", log: Callable = print) -> dict:
"""End-to-end from a generated artifact: create the policy in XC (if missing), then
attach -> validate -> rollback via apply_service_policy. Guarded against clobbering a
protected policy."""
protected policy.

I2 — a read-only drift check runs FIRST, before `ApplyContext.load()`: `load()` writes both
`lb_snapshot.json` and a fresh `snapshots/<lb>-<ts>.json` on every call and `self_test()` always
PUTs, so "reports no_change and writes nothing" has to short-circuit earlier than the mutation.
`force=True` re-applies anyway (to re-validate a policy that is already attached)."""
xc = XC()
art = json.loads(Path(artifact_path).read_text())
# Normalize to an XC create body: {metadata:{name,namespace,...}, spec:{...}}.
Expand All @@ -382,6 +396,19 @@ def apply_from_scan(artifact_path: str, lb: str, target_url: str, *, name: str |
if src_meta.get(k) is not None:
body["metadata"][k] = src_meta[k]

# I2 — the pre-apply gate runs before the CREATE, not just before the attach: a run that is
# about to be refused should not leave a stray policy object behind in the tenant. It also has
# to precede `ApplyContext.load()`, which writes a snapshot on every call, so "no_change writes
# nothing" is true of the run directory as well as the LB. `create_only` makes no attachment,
# so there is nothing for it to gate.
if not dry_run and not create_only:
from .drift import preflight
d = preflight(lb, policy_name, out_dir=out_dir, force=force, xc=xc, log=log, spec=spec,
exploit=(_load_probe(out_dir, finding_id) or {}).get("exploit"))
if d is not None:
return {"mode": "no_change", "control": "service_policy", "policy": policy_name,
"passed": None, "drift": d}

if xc.service_policy_exists(policy_name):
log(f"policy '{policy_name}' already exists — not overwriting")
elif dry_run:
Expand Down
Loading
Loading