From e4a2b69410349d072ab22ddca8d3d523358ff4d7 Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Mon, 27 Jul 2026 21:08:07 -0400 Subject: [PATCH 1/2] fix(frontend): the remediation modal claimed applying a fix is a paid feature The Request Remediation modal told the operator "Applying the fix on the host is an OpenWatch Enterprise feature: the free tier governs the request and approval only." That is false, and has been since single-rule remediation shipped free in v0.2.0-rc.11. Verified against the registry rather than the copy: audit_export is the ONLY license_gated permission in auth/permissions.yaml. remediation:execute and remediation:rollback carry no license gate, and api-remediation C-06 states it outright ("Execute/rollback are FREE core (Tier A), NOT license-gated ... No remediation act endpoint returns 402"). frontend-remediation-tab AC-07 already required this upsell copy to be gone; it was removed from the tab but not from this modal. The practical effect is a Community user being told to buy something they already have, on the screen where they decide whether to act. Also reformats the paragraph: PR #755's "OpenWatch+" -> "OpenWatch Enterprise" rename pushed the line past the print width and merged unformatted, because CI does not run prettier (only the local pre-commit hook does). Worth noting as a gap rather than a one-off. --- frontend/src/components/hosts/RequestRemediationModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/hosts/RequestRemediationModal.tsx b/frontend/src/components/hosts/RequestRemediationModal.tsx index 93ead49a..4f25d1f9 100644 --- a/frontend/src/components/hosts/RequestRemediationModal.tsx +++ b/frontend/src/components/hosts/RequestRemediationModal.tsx @@ -109,8 +109,8 @@ export function RequestRemediationModal({

This files a remediation request for review. An approver decides whether the fix is - applied. Applying the fix on the host is an OpenWatch Enterprise feature: the free tier governs the - request and approval only. + applied. Applying a single rule on the host, and rolling it back, are included in + OpenWatch Community. Bulk and automated remediation are OpenWatch Enterprise features.

{mutation.error && ( From 62ae02801c172bc143db417c5fe63f0c12ab27ed Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Mon, 27 Jul 2026 21:08:33 -0400 Subject: [PATCH 2/2] feat(remediation): outcome vocabulary so a changed host is never reported as unchanged Remediation reported two terminal outcomes, executed and failed, and "failed" meant five different things carrying one message: "Remediation did not complete. No host change was committed." That message is false for Kensa v0.8.0's new `staged` status. On a host whose audit config is immutable (auditctl -s reports `enabled 2`) an audit_rule_set apply writes the reboot-deferred persist layer and terminates staged. The host IS mutated. OpenWatch absorbed the unknown status through a default branch, marked the request failed, told the operator nothing had changed, journalled it as 'skipped', then refused rollback because rollback required 'executed'. The change sat on the host, invisible, with no route back through the UI. Verified reachable on 2 of 6 reachable dev-fleet hosts (owas-tst01 RHEL 8.10, owas-tst02 RHEL 9.6, both enabled 2), across 103 audit_rule_set rules. Outcomes now map one-to-one onto operator actions: executed runtime converged, host protected staged persist written, NOT converged, host changed, needs reboot reverted validation failed, Kensa restored pre-state, host clean not_applied engine declined, host untouched partially_applied stranded steps, host state unknown failed errored or unreachable Three behaviours follow, and they are the point of the change: - staged does NOT flip host_rule_state to pass. The kernel has not loaded the change, a re-scan correctly still fails the rule, and claiming a pass would assert a protection the host does not have. - staged IS rollback-eligible. Kensa captured pre-state and reverses a staged drop-in byte-perfect; the precondition and the rollback-handle lookup both widened, since either alone still stranded the change. - reverted is not red and does not page. Validation failing and the host being restored is the atomic model working. Alerting on it teaches operators to ignore the alert. remediation.OutcomeOf is now the single place a Kensa status is interpreted, returning (Status, known bool). An unknown status logs at WARN naming the value and fails closed to failed, never to a success-shaped outcome. That guard is AC-11, and its absence is what caused this bug: a default branch silently swallowed a new terminal state. Deliberately NOT done: detecting Kensa's "engine refused without mutating" case (the v0.8.0 duplicate-audit-action guard). Kensa surfaces it as StepResult.Success=false plus human-readable Detail with no distinguishable TransactionStatus, so telling it from a real failure would mean matching step text on a path that runs as root. Refusals map by status to reverted, which is truthful if less specific. StatusNotApplied and the DB enum already accept the value, so wiring it is one line once Kensa exposes a signal. Same reasoning for already-compliant runs, which Kensa reports as committed with a synthetic check step. Both filed upstream. Migration 0054 widens both CHECK constraints. The one-open-request partial unique index is deliberately untouched: every new value is terminal, so a staged request must not block a fresh request for the same host and rule. .secrets.baseline is refreshed for two reasons, neither a new finding: line drift in the generated server.gen.go from the enum addition, and one pre-existing false positive at api/openapi.yaml:127 (the word "password" in prose about which profile fields are editable). That finding fires on unmodified main too; the baseline predates it. Specs: api-remediation 1.1.0 -> 1.2.0 (C-09, AC-09/10/11), frontend-remediation-tab 1.1.0 -> 1.2.0 (AC-08). 116 specs, 116 passing. Verified: gofmt, go vet, go build, full go test ./internal/..., tsc, prettier, vitest 361/361, OpenAPI drift gate green after regeneration. --- .secrets.baseline | 37 ++-- api/openapi.yaml | 4 +- frontend/src/api/schema.d.ts | 4 +- frontend/src/pages/HostDetailPage.tsx | 60 ++++++- frontend/tests/pages/remediation-tab.test.ts | 32 +++- .../0054_remediation_outcome_vocabulary.sql | 117 ++++++++++++ internal/kensa/remediatefunc.go | 25 ++- internal/remediation/execution.go | 170 +++++++++++++----- internal/remediation/outcome_test.go | 169 +++++++++++++++++ internal/remediation/types.go | 115 +++++++++++- internal/server/api/server.gen.go | 56 ++++-- internal/worker/remediation_worker.go | 34 +++- specs/api/remediation.spec.yaml | 56 +++++- specs/frontend/remediation-tab.spec.yaml | 17 +- 14 files changed, 797 insertions(+), 99 deletions(-) create mode 100644 internal/db/migrations/0054_remediation_outcome_vocabulary.sql create mode 100644 internal/remediation/outcome_test.go diff --git a/.secrets.baseline b/.secrets.baseline index 533b3fc4..63351755 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -143,13 +143,22 @@ "line_number": 53 } ], + "api/openapi.yaml": [ + { + "type": "Secret Keyword", + "filename": "api/openapi.yaml", + "hashed_secret": "6b1fe243c0b63c8e43d2545520492f2f5bc19869", + "is_verified": false, + "line_number": 127 + } + ], "docs/guides/API_GUIDE.md": [ { "type": "Secret Keyword", "filename": "docs/guides/API_GUIDE.md", "hashed_secret": "b48cf0140bea12734db05ebcdb012f1d265bed84", "is_verified": false, - "line_number": 63 + "line_number": 65 } ], "docs/guides/DATABASE_MIGRATIONS.md": [ @@ -158,7 +167,7 @@ "filename": "docs/guides/DATABASE_MIGRATIONS.md", "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 217 + "line_number": 223 } ], "docs/guides/ENVIRONMENT_REFERENCE.md": [ @@ -167,7 +176,7 @@ "filename": "docs/guides/ENVIRONMENT_REFERENCE.md", "hashed_secret": "7205a0abf00d1daec13c63ece029057c974795a9", "is_verified": false, - "line_number": 111 + "line_number": 116 } ], "docs/guides/INSTALLATION.md": [ @@ -176,21 +185,21 @@ "filename": "docs/guides/INSTALLATION.md", "hashed_secret": "c99a970222c9f5d73283b8be8021086dd666620b", "is_verified": false, - "line_number": 82 + "line_number": 84 }, { "type": "Basic Auth Credentials", "filename": "docs/guides/INSTALLATION.md", "hashed_secret": "c99a970222c9f5d73283b8be8021086dd666620b", "is_verified": false, - "line_number": 148 + "line_number": 150 }, { "type": "Basic Auth Credentials", "filename": "docs/guides/INSTALLATION.md", "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 381 + "line_number": 383 } ], "docs/guides/PRODUCTION_DEPLOYMENT.md": [ @@ -199,7 +208,7 @@ "filename": "docs/guides/PRODUCTION_DEPLOYMENT.md", "hashed_secret": "1a1d18a6cd33ec395692c25b8e26d90b4de8b5b4", "is_verified": false, - "line_number": 120 + "line_number": 121 } ], "docs/guides/SECRET_ROTATION.md": [ @@ -415,7 +424,7 @@ "filename": "internal/notification/store.go", "hashed_secret": "98292288b8e2ce2aec8675215186a0a33a0c8f7a", "is_verified": false, - "line_number": 258 + "line_number": 264 } ], "internal/notification/store_test.go": [ @@ -460,14 +469,14 @@ "filename": "internal/server/api/server.gen.go", "hashed_secret": "9fd0aaae1a3d0bc789d081307161ea9a821f9dee", "is_verified": false, - "line_number": 3761 + "line_number": 3999 }, { "type": "Secret Keyword", "filename": "internal/server/api/server.gen.go", "hashed_secret": "eca525ee60b3564d9633eb140726685271d52341", "is_verified": false, - "line_number": 3890 + "line_number": 4137 } ], "internal/server/api_scans_test.go": [ @@ -501,7 +510,7 @@ "filename": "internal/server/notifications_handlers.go", "hashed_secret": "44e17306b837162269a410204daaa5ecee4ec22c", "is_verified": false, - "line_number": 242 + "line_number": 292 } ], "internal/ssh/sudo.go": [ @@ -613,7 +622,7 @@ "filename": "specs/frontend/settings.spec.yaml", "hashed_secret": "119c2d04c23cb8e69e813d2185e42c4e105b0d30", "is_verified": false, - "line_number": 263 + "line_number": 278 } ], "specs/system/config.spec.yaml": [ @@ -631,7 +640,7 @@ "filename": "specs/system/os-intelligence.spec.yaml", "hashed_secret": "d2feddab9f573c2d8c1f4c0f92ca8990ebe60b50", "is_verified": false, - "line_number": 178 + "line_number": 186 } ], "specs/system/ssh-connectivity.spec.yaml": [ @@ -665,5 +674,5 @@ } ] }, - "generated_at": "2026-07-07T03:30:20Z" + "generated_at": "2026-07-28T01:05:36Z" } diff --git a/api/openapi.yaml b/api/openapi.yaml index e78b4f7a..1d8ffe11 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2060,7 +2060,7 @@ paths: required: false schema: type: string - enum: [pending_approval, approved, rejected, dry_run_complete, executing, executed, rolled_back, failed] + enum: [pending_approval, approved, rejected, dry_run_complete, executing, executed, rolled_back, failed, staged, reverted, not_applied, partially_applied] - name: host_id in: query required: false @@ -5744,7 +5744,7 @@ components: rule_id: {type: string} status: type: string - enum: [pending_approval, approved, rejected, dry_run_complete, executing, executed, rolled_back, failed] + enum: [pending_approval, approved, rejected, dry_run_complete, executing, executed, rolled_back, failed, staged, reverted, not_applied, partially_applied] requested_by: {type: string, format: uuid} reviewed_by: {type: string, format: uuid, nullable: true} review_note: {type: string} diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index ddf0d207..33069ce6 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -3743,7 +3743,7 @@ export interface components { host_name?: string; rule_id: string; /** @enum {string} */ - status: "pending_approval" | "approved" | "rejected" | "dry_run_complete" | "executing" | "executed" | "rolled_back" | "failed"; + status: "pending_approval" | "approved" | "rejected" | "dry_run_complete" | "executing" | "executed" | "rolled_back" | "failed" | "staged" | "reverted" | "not_applied" | "partially_applied"; /** Format: uuid */ requested_by: string; /** Format: uuid */ @@ -7436,7 +7436,7 @@ export interface operations { listRemediationRequests: { parameters: { query?: { - status?: "pending_approval" | "approved" | "rejected" | "dry_run_complete" | "executing" | "executed" | "rolled_back" | "failed"; + status?: "pending_approval" | "approved" | "rejected" | "dry_run_complete" | "executing" | "executed" | "rolled_back" | "failed" | "staged" | "reverted" | "not_applied" | "partially_applied"; host_id?: string; rule_id?: string; limit?: number; diff --git a/frontend/src/pages/HostDetailPage.tsx b/frontend/src/pages/HostDetailPage.tsx index 5967f005..8ac3baa4 100644 --- a/frontend/src/pages/HostDetailPage.tsx +++ b/frontend/src/pages/HostDetailPage.tsx @@ -1233,8 +1233,33 @@ const REM_STATUS_STYLE: Record - Fixed + {isStaged ? 'Staged, reboot required' : 'Fixed'} {canRollback && (