Skip to content
Merged
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
37 changes: 23 additions & 14 deletions .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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": [
Expand All @@ -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": [
Expand All @@ -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": [
Expand All @@ -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": [
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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": [
Expand All @@ -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": [
Expand Down Expand Up @@ -665,5 +674,5 @@
}
]
},
"generated_at": "2026-07-07T03:30:20Z"
"generated_at": "2026-07-28T01:05:36Z"
}
4 changes: 2 additions & 2 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/api/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/hosts/RequestRemediationModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ export function RequestRemediationModal({

<p style={{ color: 'var(--ow-fg-2)', fontSize: 12, lineHeight: 1.5, margin: '0 0 14px' }}>
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.
</p>

{mutation.error && (
Expand Down
60 changes: 56 additions & 4 deletions frontend/src/pages/HostDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1233,8 +1233,33 @@ const REM_STATUS_STYLE: Record<string, { fg: string; bg: string; label: string }
executed: { fg: 'var(--ow-ok)', bg: 'var(--ow-ok-bg)', label: 'Executed' },
rolled_back: { fg: 'var(--ow-fg-2)', bg: 'var(--ow-bg-2)', label: 'Rolled back' },
failed: { fg: 'var(--ow-crit)', bg: 'var(--ow-crit-bg)', label: 'Failed' },

// Terminal outcomes added with the outcome vocabulary (api-remediation
// v1.2.0). Each colour is chosen from what the operator has to DO, not from
// whether the word sounds good:
//
// staged amber. It worked, but the host is not protected yet and
// someone has to reboot. Green would claim a protection the
// host does not have.
// reverted neutral, deliberately NOT red. Validation failed and the
// host was restored. That is the atomic model doing its job,
// and colouring it as a failure teaches operators to distrust
// the thing protecting them.
// not_applied neutral. The engine declined and the host is untouched.
// partially_applied red. Some steps cannot be reversed automatically and
// the host needs a human.
staged: { fg: 'var(--ow-warn)', bg: 'var(--ow-warn-bg)', label: 'Staged, reboot required' },
reverted: { fg: 'var(--ow-fg-2)', bg: 'var(--ow-bg-2)', label: 'Reverted, host unchanged' },
not_applied: { fg: 'var(--ow-fg-2)', bg: 'var(--ow-bg-2)', label: 'Not applied' },
partially_applied: { fg: 'var(--ow-crit)', bg: 'var(--ow-crit-bg)', label: 'Partially applied' },
};

// Statuses a rollback can be started from. Mirrors Status.RollbackEligible in
// internal/remediation: 'staged' is included because a staged change is a real
// host mutation with captured pre-state, so the operator must be able to undo
// it. Omitting it here was half of why a staged change was unreversible.
const ROLLBACK_ELIGIBLE = new Set(['executed', 'staged']);

function RemStatusChip({ status }: { status: string }) {
const s = REM_STATUS_STYLE[status] ?? {
fg: 'var(--ow-fg-2)',
Expand Down Expand Up @@ -1635,24 +1660,39 @@ function RemediationRowAction({
);
}

if (request.status === 'executed') {
if (ROLLBACK_ELIGIBLE.has(request.status)) {
// 'executed' means the runtime converged: the host is protected now.
// 'staged' means the change is written but takes effect at reboot, so the
// host is NOT protected yet. Both keep the Roll back action; only the
// wording and colour differ, because they call for different next steps.
const isStaged = request.status === 'staged';
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span
title={
isStaged
? 'The change is written to the host but the running system has not picked it up. A re-scan still reports this rule as failing until the host reboots.'
: undefined
}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
fontSize: 11,
fontWeight: 600,
color: 'var(--ow-ok)',
color: isStaged ? 'var(--ow-warn)' : 'var(--ow-ok)',
}}
>
<span
aria-hidden
style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--ow-ok)' }}
style={{
width: 6,
height: 6,
borderRadius: '50%',
background: isStaged ? 'var(--ow-warn)' : 'var(--ow-ok)',
}}
/>
Fixed
{isStaged ? 'Staged, reboot required' : 'Fixed'}
</span>
{canRollback && (
<button
Expand Down Expand Up @@ -1684,6 +1724,18 @@ function RemediationRowAction({
return <span style={{ color: 'var(--ow-fg-2)', fontSize: 11 }}>Rolled back</span>;
}

// Host untouched. Neutral, not red: the engine restored the host itself, or
// declined to act. Showing these as failures trains operators to ignore the
// colour that is supposed to mean "something needs you".
if (request.status === 'reverted' || request.status === 'not_applied') {
return (
<span style={{ color: 'var(--ow-fg-2)', fontSize: 11 }}>
{request.status === 'reverted' ? 'Reverted, host unchanged' : 'Not applied'}
{inlineNote}
</span>
);
}

if (request.status === 'failed') {
return (
<span
Expand Down
32 changes: 31 additions & 1 deletion frontend/tests/pages/remediation-tab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,11 @@ describe('frontend-remediation-tab — source inspection', () => {
expect(PAGE).toContain(
"useAuthStore((s) => s.hasPermission('remediation:rollback')) || isAdmin",
);
expect(PAGE).toContain("request.status === 'executed'");
// Rollback eligibility is a SET, not a single status: 'executed' and
// 'staged' both leave a real change on the host, so both keep Roll back.
expect(PAGE).toContain('ROLLBACK_ELIGIBLE');
expect(PAGE).toContain('ROLLBACK_ELIGIBLE.has(request.status)');
expect(PAGE).toContain("new Set(['executed', 'staged'])");
expect(TAB_REGION).toContain('Fixed');
expect(TAB_REGION).toContain('Roll back');
expect(PAGE).toContain('canRollback');
Expand Down Expand Up @@ -154,4 +158,30 @@ describe('frontend-remediation-tab — source inspection', () => {
expect(EXPLAINER).not.toContain('—');
expect(UPSELL).not.toContain('—');
});

// @ac AC-08
test('frontend-remediation-tab/AC-08 — outcome vocabulary: colour follows required action', () => {
// Staged: warning, never success. The host is NOT protected until reboot,
// so a green "Fixed" chip would claim a protection it does not have.
expect(TAB_REGION).toContain('Staged, reboot required');
expect(PAGE).toContain("isStaged ? 'var(--ow-warn)' : 'var(--ow-ok)'");
expect(PAGE).toContain("staged: { fg: 'var(--ow-warn)'");

// Staged keeps Roll back: it is a real host mutation with captured
// pre-state, so the operator must be able to undo it.
expect(PAGE).toContain("new Set(['executed', 'staged'])");

// Reverted: NEUTRAL, deliberately not critical. Kensa restored the host
// itself; colouring that red teaches operators to ignore red.
expect(TAB_REGION).toContain('Reverted, host unchanged');
expect(PAGE).toContain("reverted: { fg: 'var(--ow-fg-2)'");
expect(PAGE).not.toContain("reverted: { fg: 'var(--ow-crit)'");

// Not applied is neutral too; partially applied is the one that needs a human.
expect(PAGE).toContain("not_applied: { fg: 'var(--ow-fg-2)'");
expect(PAGE).toContain("partially_applied: { fg: 'var(--ow-crit)'");

// The old lie must not come back anywhere in the tab copy.
expect(TAB_REGION).not.toContain('No host change was committed');
});
});
Loading
Loading