Skip to content

feat(api): add approval, idempotency, audit and rescan gate for remediation (#266) - #364

Merged
ritiksah141 merged 3 commits into
OWASP:devfrom
parthrohit22:feat/266-remediation-approval-gate
Oct 3, 2026
Merged

ritiksah141 merged 3 commits into
OWASP:devfrom
parthrohit22:feat/266-remediation-approval-gate

Conversation

@parthrohit22

Copy link
Copy Markdown
Collaborator

What does this PR do?

Adds the execution gate for any future agent-driven remediation: human approval, an empty allowlist, at-most-once execution, an append-only audit trail, and rescan-based verification. It is proposal-only, and nothing in the repo runs a playbook.

Type of change

  • New scan rule
  • Remediation playbook
  • Bug fix
  • Dashboard/front-end work
  • API endpoint
  • Documentation
  • Compliance mapping
  • Other: core persistence and safety gate (new feature, api/models and Alembic)

Design

Full write-up in docs/remediation-gate.md. Lifecycle:

PROPOSED -> APPROVED -> EXECUTING -> PENDING_VERIFICATION -> VERIFIED

with REJECTED, EXECUTION_FAILED and VERIFICATION_FAILED branches. Every transition is a single conditional UPDATE ... WHERE status = ANY(...) plus an audit row in the same transaction, so concurrency is decided by the database rather than by check-then-write in Python.

Mapping to the issue:

Requirement Implementation
Typed allowlist, starts empty EXECUTION_ALLOWLIST = frozenset(). A refused attempt is audited.
Mandatory human approval begin_execution only matches APPROVED rows.
A retry never executes twice APPROVED -> EXECUTING is won by exactly one caller. Everyone else gets InvalidTransition, never a grant. A crashed runner leaves the row EXECUTING and is not retried automatically.
Full audit log remediation_audit_log, append-only via trigger.
Rescan verification VERIFIED needs a later completed rescan of the same subscription whose rule evaluation (#263) for that resource is PASS. FAIL stays open as VERIFICATION_FAILED. Missing, UNKNOWN or ERROR is inconclusive and never closes the action.

It also covers the concern @babyblueviper1 raised on the issue: approval is a compare-and-swap, so two concurrent approve calls on one PROPOSED row cannot both win.

Rule details (if applicable)

N/A, no scan rule is added or changed.

Testing

  • Tested against a real Azure free trial subscription (N/A: database-only change, no Azure calls)
  • tests/test_remediation_gate_postgres.py: 29 tests against PostgreSQL covering every exit criterion, including 8-way concurrent proposal, approval, execution and verification
  • Mutation-checked: removing the approval CAS guard, or treating missing evidence as cleared, each make a test fail
  • Alembic upgrade -> downgrade -> upgrade round-trip, single head (b6d2f8a4c1e7)
  • Full suite pytest tests/ passes locally (1509 passed, 3 skipped) against PostgreSQL 15; ruff check and ruff format --check are clean
  • No hardcoded credentials or secrets

The existing migration test pins the head revision, so _HEAD in tests/test_scan_admission_migration_postgres.py is bumped.

Not in scope (follow-ups)

HTTP routes and UI for propose/approve, generating proposals from findings, separation of duties (approver must differ from proposer), and authenticating the actor identity. Listed in the doc.

Related issue

Closes #266

Checklist

  • Every commit includes a DCO Signed-off-by trailer (git commit -s; see docs/dco.md)
  • My code follows the rule template in CONTRIBUTING.md (N/A for rules)
  • I added or updated the matching CLI playbook (N/A)
  • I added or updated all four compliance framework mappings (N/A)
  • I have not committed any real Azure credentials
  • My branch name follows the convention: feat/description

Comment thread alembic/versions/b6d2f8a4c1e7_remediation_action_gate.py Fixed
@babyblueviper1

Copy link
Copy Markdown

Thanks for picking up the approve-side race. I read through _transition: the FOR UPDATE CTE plus WHERE a.status = ANY(...) does make the second concurrent approve see zero rows, so that concern is closed.

Two gaps are left in the same area. The first one I reproduced.

1. TRUNCATE gets past the append-only trigger. Row-level BEFORE UPDATE OR DELETE triggers don't fire on TRUNCATE. I ran the migration's trigger DDL on Postgres 16:

DELETE FROM remediation_audit_log;   -> ERROR: remediation_audit_log is append-only
TRUNCATE remediation_audit_log;      -> TRUNCATE TABLE   (0 rows left)

One extra statement in the migration closes it (after I added this, the same TRUNCATE raised and the row survived):

CREATE TRIGGER trg_remediation_audit_log_no_truncate
BEFORE TRUNCATE ON remediation_audit_log
FOR EACH STATEMENT EXECUTE FUNCTION remediation_audit_log_append_only();

It would also help to REVOKE UPDATE, DELETE, TRUNCATE from the app role. The table owner can still DROP TRIGGER, so if the trail needs to be evidence against the operator too, it needs a prev_hash/row_hash chain whose head is published outside the database.

2. Approval binds to the playbook path, not to what will run. remediation_actions.playbook holds playbooks/cli/fix_<rule>.sh, and the approved audit row carries no detail. If that script changes between approve and begin_execution, the grant still covers it, and the audit trail can't show what the approver actually saw.

A small fix:

  • store playbook_sha256 (the script bytes) and the target (resource_id, subscription_id) at propose;
  • put both in the approved audit detail;
  • have begin_execution recompute the hash and refuse on mismatch.

This is the same "approve X, execute exactly X" check we use for deploy approvals here: the approval is bound to the plan's sha256 and re-verified before dispatch.

Glad to send (1) as a follow-up PR against this branch if that's useful.

parthrohit22 added a commit to parthrohit22/openshield that referenced this pull request Sep 30, 2026
…proval to the script

Two gaps found in review of OWASP#364:

- Row-level triggers do not fire for TRUNCATE, so the whole audit trail could
  be emptied in one statement. Add a statement-level BEFORE TRUNCATE trigger
  using the same append-only function. Test teardown no longer relies on
  TRUNCATE and switches the triggers off explicitly instead.
- Approval covered the playbook path, not its contents. propose now stores the
  script's SHA-256 and the target (resource_id, subscription_id); the approved
  audit row records them, and begin_execution recomputes the hash and refuses,
  with an audited playbook_changed refusal, if the script changed or vanished.
  The stored path is never trusted: it must resolve to a regular file under
  playbooks/.

Reported by babyblueviper1 on OWASP#364.

Signed-off-by: parthrohit22 <parthrohit60@gmail.com>
@parthrohit22
parthrohit22 force-pushed the feat/266-remediation-approval-gate branch from 8280251 to ac38744 Compare September 30, 2026 23:03
…iation (OWASP#266)

Agent-driven remediation can change customer infrastructure, so the gate
that protects it has to exist before any execution path does. This adds
it as proposal-only: nothing runs a playbook.

- remediation_actions and an append-only remediation_audit_log, with a
  partial unique index allowing one live action per finding
- RemediationGate: propose, approve, reject, begin_execution,
  complete_execution and verify, each a single conditional UPDATE plus an
  audit row in the same transaction
- execution requires an approved action and an allowlisted playbook; the
  allowlist ships empty, and begin_execution grants at most once, so a
  retry or a concurrent caller never executes twice
- a remediation is pending verification, not done, until a later
  completed rescan reports PASS for the same rule and resource; FAIL
  stays open and a missing, UNKNOWN or ERROR evaluation is inconclusive
- PostgreSQL tests for concurrent proposal, approval, execution and
  verification, and for the audit trail
- update the pinned Alembic head in the migration test

Closes OWASP#266

Signed-off-by: parthrohit22 <parthrohit60@gmail.com>
Build the partial unique index with op.execute instead of passing an
f-string to sa.text, matching the other migrations, and annotate the
single fixed-fragment UPDATE in RemediationGate._transition the same way
api/routes/resources.py does.

Signed-off-by: parthrohit22 <parthrohit60@gmail.com>
…proval to the script

Two gaps found in review of OWASP#364:

- Row-level triggers do not fire for TRUNCATE, so the whole audit trail could
  be emptied in one statement. Add a statement-level BEFORE TRUNCATE trigger
  using the same append-only function. Test teardown no longer relies on
  TRUNCATE and switches the triggers off explicitly instead.
- Approval covered the playbook path, not its contents. propose now stores the
  script's SHA-256 and the target (resource_id, subscription_id); the approved
  audit row records them, and begin_execution recomputes the hash and refuses,
  with an audited playbook_changed refusal, if the script changed or vanished.
  The stored path is never trusted: it must resolve to a regular file under
  playbooks/.

Reported by babyblueviper1 on OWASP#364.

Signed-off-by: parthrohit22 <parthrohit60@gmail.com>
@parthrohit22
parthrohit22 force-pushed the feat/266-remediation-approval-gate branch from ac38744 to 670a751 Compare September 30, 2026 23:20
@babyblueviper1

Copy link
Copy Markdown

Checked 670a751. Both fixes are right:

  • The BEFORE TRUNCATE statement trigger closes the gap I reproduced.
  • playbook_sha256 is stored at propose time, carried in the approved audit row, and recomputed by begin_execution, which refuses on a mismatch.

One note for when the execution side lands (nothing runs a playbook in this PR, so it's not a blocker here). The hash is checked when the grant is issued, but the executor will read the script from disk again when it runs. To make the binding hold end to end, the executor should either read the bytes once, check them against the grant's playbook_sha256, and run those exact bytes, or recompute the hash right before exec and refuse on a mismatch. Otherwise a change between the grant and exec slips through the same way the approval-to-execution gap did.

@ritiksah141
ritiksah141 merged commit ea5044f into OWASP:dev Oct 3, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: define approval/idempotency/audit/rescan gate for agent-driven remediation

5 participants