Skip to content

Commit 31c8946

Browse files
committed
fix(audit): enforce passed-event logging via code policy
1 parent 4da33bd commit 31c8946

10 files changed

Lines changed: 261 additions & 74 deletions

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121

2222
- **Active sessions: identity context** — sessions panel now shows gravatars, username, role badge, display name, and time remaining for each active session. Responsive layout hides gravatars and names on small screens.
2323
- **Recent events: client-side filtering** — dropdown filters for Time (1h / 24h / 7d), Event type, and Surface, applied client-side against 50 stored events. Filters laid out horizontally in a single row.
24-
- **Passthrough logging**new `log_passthrough` toggle in Settings → Sudo → Session Settings. When enabled, gated actions that pass through an active sudo session are recorded as "Passed" events in the widget via the new `wp_sudo_action_passed` audit hook (fires on admin, REST, and WPGraphQL surfaces).
25-
- **Widget placement and layout** — widget renders in the side column at high priority, active session cards use CSS Grid (`repeat(auto-fit, minmax(180px, 1fr))`) with scrollable container, usernames link to user-edit.php, Settings link in passthrough notice.
24+
- **Passed-event audit visibility defaults**`wp_sudo_action_passed` events (admin, REST, WPGraphQL) are now recorded by default so active-session actions stay visible in the audit timeline. Disabling passed-event logging now requires an explicit code override (constant/filter), and WP Sudo shows a warning notice when that override is active.
25+
- **Widget placement and layout** — widget renders in the side column at high priority, active session cards use CSS Grid (`repeat(auto-fit, minmax(180px, 1fr))`) with scrollable container, usernames link to user-edit.php, and the empty-state panel now uses a clearer Site Health–style status layout.
2626
- **Users list "Sudo Active" filter** — the Users → All Users screen gains a "Sudo Active (N)" view link that filters the list to users with an active sudo session via `_wp_sudo_expires` meta query.
2727

2828
### Accessibility

docs/ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,7 @@ Use this default order after the v3.0.0 release unless a real user need override
461461

462462
- **Do next:** Network Dashboard Widget + Super Admin Visibility Controls
463463
- **Plan next:** Gutenberg Block Editor Integration
464-
- **Do later if demand exists:** Network Policy Hierarchy for Multisite, Cross-Site Session Revocation
464+
- **Do later if demand exists:** Network Policy Hierarchy for Multisite, Cross-Site Session Revocation, network-enforced Passed-event logging policy (super admins can require immutable Passed-event audit visibility across subsites)
465465
- **Keep as design backlog:** client-side modal challenge, per-session sudo isolation, REST sudo grant endpoint, SSO/SAML/OIDC framework
466466

467467
---

docs/current-metrics.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ Verification environment: primary local repo checkout at `/Users/danknauss/Devel
99

1010
| Metric | Value | Verification |
1111
|---|---:|---|
12-
| Unit tests | 627 tests | `composer test:unit` |
13-
| Unit assertions | 1781 assertions | `composer test:unit` |
12+
| Unit tests | 632 tests | `composer test:unit` |
13+
| Unit assertions | 1796 assertions | `composer test:unit` |
1414
| Integration tests in suite | 160 test methods | `rg -c "function test" tests/Integration/*.php | awk -F: '{sum+=$2} END{print sum}'` |
1515
| Unit test files | 23 | `ls tests/Unit/*.php | wc -l` |
1616
| Integration test files | 22 | `ls tests/Integration/*.php | wc -l` |
@@ -19,11 +19,11 @@ Verification environment: primary local repo checkout at `/Users/danknauss/Devel
1919

2020
| Metric | Value | Verification |
2121
|---|---:|---|
22-
| Production PHP lines (`includes/`, `wp-sudo.php`, `uninstall.php`, `mu-plugin/`, `bridges/`) | 12,003 | `find ./includes ./wp-sudo.php ./uninstall.php ./mu-plugin ./bridges -type f -name "*.php" -print0 | xargs -0 wc -l | tail -1 | awk '{print $1}'` |
23-
| Tests PHP lines (`tests/`) | 22,147 | `find ./tests -type f -name "*.php" -print0 | xargs -0 wc -l | tail -1 | awk '{print $1}'` |
24-
| Production + tests PHP lines | 34,150 | sum of the two rows above |
25-
| Test-to-production ratio | 1.85:1 | `22147 / 12003` |
26-
| Total repo PHP lines (excluding `vendor/`, `vendor_test/`, `.tmp/`, `.git/`) | 34,413 | `find . -type f -name "*.php" ! -path "*/vendor/*" ! -path "*/vendor_test/*" ! -path "*/.tmp/*" ! -path "*/.git/*" -print0 | xargs -0 wc -l | tail -1 | awk '{print $1}'` |
22+
| Production PHP lines (`includes/`, `wp-sudo.php`, `uninstall.php`, `mu-plugin/`, `bridges/`) | 12,076 | `find ./includes ./wp-sudo.php ./uninstall.php ./mu-plugin ./bridges -type f -name "*.php" -print0 | xargs -0 wc -l | tail -1 | awk '{print $1}'` |
23+
| Tests PHP lines (`tests/`) | 22,260 | `find ./tests -type f -name "*.php" -print0 | xargs -0 wc -l | tail -1 | awk '{print $1}'` |
24+
| Production + tests PHP lines | 34,336 | sum of the two rows above |
25+
| Test-to-production ratio | 1.84:1 | `22260 / 12076` |
26+
| Total repo PHP lines (excluding `vendor/`, `vendor_test/`, `.tmp/`, `.git/`) | 34,599 | `find . -type f -name "*.php" ! -path "*/vendor/*" ! -path "*/vendor_test/*" ! -path "*/.tmp/*" ! -path "*/.git/*" -print0 | xargs -0 wc -l | tail -1 | awk '{print $1}'` |
2727

2828
## Architectural Facts
2929

@@ -39,8 +39,8 @@ the count in prose without a verification command.
3939
| Gated rules (total) | 34 | `grep "'id'" includes/class-action-registry.php \| grep -v "rule\[" \| wc -l` | unreleased |
4040
| Help tabs | 12 | `grep -c -- "->add_help_tab(" includes/class-admin.php` | unreleased |
4141
| Audit hooks | 11 | `python3 - <<'PY'\nimport pathlib, re\nhooks = set()\nfor path in pathlib.Path('includes').glob('class-*.php'):\n hooks.update(re.findall(r\"do_action\\(\\s*'([^']+)'\", path.read_text()))\nhooks.discard('wp_sudo_render_two_factor_fields')\nprint(len(hooks))\nPY` | unreleased (v3.0.0) |
42-
| Settings fields (base) | 7 | 1 numeric (duration) + 1 toggle (passthrough) + 1 preset chooser + 4 policy dropdowns (REST, CLI, Cron, XML-RPC) | unreleased (v3.0.0) |
43-
| Settings fields (with WPGraphQL) | 8 | +1 conditional WPGraphQL policy dropdown | unreleased (v3.0.0) |
42+
| Settings fields (base) | 6 | 1 numeric (duration) + 1 preset chooser + 4 policy dropdowns (REST, CLI, Cron, XML-RPC) | unreleased (v3.0.0) |
43+
| Settings fields (with WPGraphQL) | 7 | +1 conditional WPGraphQL policy dropdown | unreleased (v3.0.0) |
4444
| E2E tests | 60 | `npx playwright test --config tests/e2e/playwright.config.ts --list` | unreleased |
4545

4646
### Files that reference these counts
@@ -66,7 +66,7 @@ Source: `.github/workflows/phpunit.yml`, `.github/workflows/e2e.yml`, `.github/w
6666

6767
## Verification Notes
6868

69-
- `composer test:unit` passed on 2026-04-20 (`627 tests`, `1781 assertions`).
69+
- `composer test:unit` passed on 2026-04-20 (`632 tests`, `1796 assertions`).
7070
- `composer test:integration` passed on 2026-04-20 (`165 tests`, `538 assertions`, `9 skipped`) using the repo wrapper's `wp-env` `tests-cli` fallback against the containerized `wordpress_test` database.
7171
- `WP_MULTISITE=1 composer test:integration` passed on 2026-04-20 (`165 tests`, `552 assertions`, `2 skipped`) using the same `wp-env` `tests-cli` fallback and database.
7272
- `composer analyse:phpstan`, `composer analyse:psalm`, and `composer lint` passed on 2026-04-19.

docs/developer-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@ needed. It remains inert when Stream APIs are unavailable.
315315
| `wp_sudo_requires_two_factor` | Whether a user needs 2FA for sudo (for third-party 2FA plugins). |
316316
| `wp_sudo_validate_two_factor` | Validate a 2FA code (for third-party 2FA plugins). |
317317
| `wp_sudo_render_two_factor_fields` | Render 2FA input fields (for third-party 2FA plugins). |
318+
| `wp_sudo_log_passed_events_enabled` | Toggle recording of `action_passed` dashboard events. Default `true`; intended for explicit code-level overrides only. |
318319
| `wp_sudo_wpgraphql_classification` | Classify WPGraphQL body as `mutation` or `query` (persisted-query support). |
319320
| `wp_sudo_wpgraphql_bypass` | Bypass WPGraphQL Limited-mode gating for specific requests. |
320321

includes/class-admin.php

Lines changed: 77 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,20 @@ class Admin {
118118
*/
119119
public const POLICY_PRESET_CUSTOM = 'custom';
120120

121+
/**
122+
* Constant name for disabling Passed-event logging via code.
123+
*
124+
* @var string
125+
*/
126+
public const DISABLE_PASSED_EVENT_LOGGING_CONSTANT = 'WP_SUDO_DISABLE_PASSED_EVENT_LOGGING';
127+
128+
/**
129+
* Filter name for enabling/disabling Passed-event logging via code.
130+
*
131+
* @var string
132+
*/
133+
public const PASSED_EVENT_LOGGING_FILTER = 'wp_sudo_log_passed_events_enabled';
134+
121135
/**
122136
* Transient prefix for one-shot preset summary notices.
123137
*
@@ -580,15 +594,6 @@ public function register_sections(): void {
580594
array( 'label_for' => 'session_duration' )
581595
);
582596

583-
add_settings_field(
584-
'log_passthrough',
585-
__( 'Log Session Pass-Throughs', 'wp-sudo' ),
586-
array( $this, 'render_field_log_passthrough' ),
587-
self::PAGE_SLUG,
588-
'wp_sudo_session',
589-
array( 'label_for' => 'log_passthrough' )
590-
);
591-
592597
// Entry point policies section.
593598
add_settings_section(
594599
'wp_sudo_policies',
@@ -832,6 +837,41 @@ public static function reset_cache(): void {
832837
self::$cached_settings = null;
833838
}
834839

840+
/**
841+
* Return whether Passed-event logging is enabled.
842+
*
843+
* Passed-event logging is enabled by default and intentionally not
844+
* configurable from the UI. It may only be disabled via a code-level
845+
* override (constant/filter) for exceptional environments.
846+
*
847+
* @since 3.0.0
848+
*
849+
* @return bool
850+
*/
851+
public static function is_passed_event_logging_enabled(): bool {
852+
$enabled = true;
853+
854+
if ( defined( self::DISABLE_PASSED_EVENT_LOGGING_CONSTANT ) && constant( self::DISABLE_PASSED_EVENT_LOGGING_CONSTANT ) ) {
855+
$enabled = false;
856+
}
857+
858+
if ( function_exists( 'apply_filters' ) ) {
859+
/**
860+
* Filter whether WP Sudo records action_passed events.
861+
*
862+
* Return false only when a deployment intentionally accepts reduced
863+
* audit visibility for actions performed during active sudo sessions.
864+
*
865+
* @since 3.0.0
866+
*
867+
* @param bool $enabled Default true unless disabled by constant.
868+
*/
869+
$enabled = (bool) apply_filters( self::PASSED_EVENT_LOGGING_FILTER, $enabled );
870+
}
871+
872+
return $enabled;
873+
}
874+
835875
/**
836876
* Sanitize settings input.
837877
*
@@ -848,9 +888,6 @@ public function sanitize_settings( array $input ): array {
848888
$sanitized['session_duration'] = 15;
849889
}
850890

851-
// Log passthrough: boolean toggle.
852-
$sanitized['log_passthrough'] = ! empty( $input['log_passthrough'] );
853-
854891
// Entry point policies: disabled, limited, or unrestricted.
855892
$policy_keys = self::policy_setting_keys();
856893

@@ -1176,6 +1213,7 @@ public function render_settings_page(): void {
11761213
<div class="wrap">
11771214
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
11781215
<?php $this->render_policy_preset_notice(); ?>
1216+
<?php $this->render_passed_event_logging_override_notice(); ?>
11791217
<?php if ( $is_network && isset( $_GET['updated'] ) ) : // phpcs:ignore WordPress.Security.NonceVerification.Recommended ?>
11801218
<div class="notice notice-success is-dismissible wp-sudo-notice">
11811219
<p><?php esc_html_e( 'Settings saved.', 'wp-sudo' ); ?></p>
@@ -1668,21 +1706,6 @@ public function render_field_session_duration(): void {
16681706
echo '<p class="description">' . esc_html__( 'How long a sudo session lasts before automatically expiring. Range: 1–15 minutes. Default: 15 minutes.', 'wp-sudo' ) . '</p>';
16691707
}
16701708

1671-
/**
1672-
* Render the log_passthrough toggle field.
1673-
*
1674-
* @return void
1675-
*/
1676-
public function render_field_log_passthrough(): void {
1677-
$value = self::get( 'log_passthrough', false );
1678-
printf(
1679-
'<input type="checkbox" id="log_passthrough" name="%s[log_passthrough]" value="1" %s />',
1680-
esc_attr( self::OPTION_KEY ),
1681-
checked( $value, true, false )
1682-
);
1683-
echo '<p class="description">' . esc_html__( 'Log each gated action that succeeds during an active sudo session. Disable to see only gate friction (challenges, blocks, replays) in the dashboard widget. Enable to see a complete audit trail including actions that passed through due to an active session. Default: off.', 'wp-sudo' ) . '</p>';
1684-
}
1685-
16861709
/**
16871710
* Render the policy preset chooser.
16881711
*
@@ -2073,6 +2096,33 @@ private function render_policy_preset_notice(): void {
20732096
);
20742097
}
20752098

2099+
/**
2100+
* Render an explicit warning when Passed-event logging is code-disabled.
2101+
*
2102+
* @return void
2103+
*/
2104+
private function render_passed_event_logging_override_notice(): void {
2105+
if ( self::is_passed_event_logging_enabled() ) {
2106+
return;
2107+
}
2108+
2109+
$message = __( 'Passed event logging is disabled by code override (constant/filter). Actions performed during active sudo sessions will not appear in dashboard event history.', 'wp-sudo' );
2110+
2111+
if ( function_exists( 'wp_get_admin_notice' ) ) {
2112+
$notice_html = wp_get_admin_notice(
2113+
$message,
2114+
array(
2115+
'type' => 'warning',
2116+
'additional_classes' => array( 'wp-sudo-notice' ),
2117+
)
2118+
);
2119+
echo wp_kses_post( $notice_html );
2120+
return;
2121+
}
2122+
2123+
echo '<div class="notice notice-warning wp-sudo-notice"><p>' . esc_html( $message ) . '</p></div>';
2124+
}
2125+
20762126
/**
20772127
* Convert a preset key to a display label.
20782128
*

includes/class-dashboard-widget.php

Lines changed: 50 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,6 @@ public static function render(): void {
8989
*/
9090
private const DEFAULTS = array(
9191
'session_duration' => 15,
92-
'log_passthrough' => false,
9392
'rest_app_password_policy' => 'limited',
9493
'cli_policy' => 'limited',
9594
'cron_policy' => 'disabled',
@@ -131,12 +130,15 @@ private static function render_active_sessions(): void {
131130

132131
if ( 0 === $count ) {
133132
echo '<div class="wp-sudo-empty-container">';
134-
echo '<div class="wp-sudo-empty-status"><strong>' . esc_html__( 'No active sessions now...', 'wp-sudo' ) . '</strong></div>';
133+
echo '<div class="wp-sudo-empty-status">';
134+
echo '<span class="wp-sudo-empty-status-icon" aria-hidden="true"><span class="dashicons dashicons-lock"></span></span>';
135+
echo '<strong>' . esc_html__( 'No active sessions now...', 'wp-sudo' ) . '</strong>';
136+
echo '</div>';
135137
echo '<div class="wp-sudo-empty-desc">';
136138
echo '<p>' . esc_html__( 'Sudo monitors gated actions to ensure high-privilege operations are authorized. Events are logged here to provide visibility into who is performing sensitive tasks.', 'wp-sudo' ) . '</p>';
137139
echo '<p>' . sprintf(
138140
/* translators: %1$s: opening link tag, %2$s: closing link tag */
139-
esc_html__( 'You can also %1$svisit the Sudo Settings page%2$s to configure session durations and logging.', 'wp-sudo' ),
141+
esc_html__( 'You can also %1$svisit the Sudo Settings page%2$s to configure session durations and policies.', 'wp-sudo' ),
140142
'<a href="' . esc_url( admin_url( 'options-general.php?page=wp-sudo-settings' ) ) . '">',
141143
'</a>'
142144
) . '</p>';
@@ -236,31 +238,18 @@ private static function render_active_sessions(): void {
236238
* @return void
237239
*/
238240
private static function render_recent_events(): void {
239-
$settings = get_option( 'wp_sudo_settings', array() );
240-
if ( ! is_array( $settings ) ) {
241-
$settings = array();
242-
}
243-
$log_passthrough = ! empty( $settings['log_passthrough'] );
241+
$passed_event_logging_enabled = Admin::is_passed_event_logging_enabled();
244242

245243
// Ensure the events table exists before querying.
246244
Event_Store::maybe_create_table();
247245
$events = Event_Store::recent_for_dashboard( 50 ); // Get more for client-side filtering.
248246

249247
echo '<h3>' . esc_html__( 'Recent Events', 'wp-sudo' ) . '</h3>';
250248

251-
// Pass-through notice.
252-
if ( ! $log_passthrough ) {
253-
$settings_url = esc_url( admin_url( 'options-general.php?page=wp-sudo-settings#log_passthrough' ) );
249+
// Code-override notice.
250+
if ( ! $passed_event_logging_enabled ) {
254251
echo '<p class="wp-sudo-filter-notice">';
255-
echo wp_kses(
256-
sprintf(
257-
/* translators: %1$s: opening link tag, %2$s: closing link tag */
258-
__( 'Enable "Log Passed Sessions" in %1$sSettings%2$s to track Passed events.', 'wp-sudo' ),
259-
'<a href="' . $settings_url . '">',
260-
'</a>'
261-
),
262-
array( 'a' => array( 'href' => array() ) )
263-
);
252+
echo esc_html__( 'Passed events are currently hidden by a code-level policy override.', 'wp-sudo' );
264253
echo '</p>';
265254
}
266255

@@ -281,7 +270,7 @@ private static function render_recent_events(): void {
281270
echo '<option value="action_gated">' . esc_html__( 'Gated', 'wp-sudo' ) . '</option>';
282271
echo '<option value="action_blocked">' . esc_html__( 'Blocked', 'wp-sudo' ) . '</option>';
283272
echo '<option value="action_allowed">' . esc_html__( 'Allowed', 'wp-sudo' ) . '</option>';
284-
echo '<option value="action_passed"' . ( $log_passthrough ? '' : ' disabled' ) . '>';
273+
echo '<option value="action_passed"' . ( $passed_event_logging_enabled ? '' : ' disabled' ) . '>';
285274
echo esc_html__( 'Passed', 'wp-sudo' );
286275
echo '</option>';
287276
echo '<option value="action_replayed">' . esc_html__( 'Replayed', 'wp-sudo' ) . '</option>';
@@ -562,15 +551,43 @@ private static function render_inline_styles(): void {
562551
}
563552

564553
#wp_sudo_activity .wp-sudo-empty-container {
565-
display: flex;
566-
gap: 1.5em;
554+
display: grid;
555+
grid-template-columns: minmax(140px, 30%) 1fr;
556+
gap: 1.25em;
567557
align-items: flex-start;
568558
margin-bottom: 1em;
569559
}
570560
#wp_sudo_activity .wp-sudo-empty-status {
571-
font-weight: 600;
572-
min-width: 140px;
573-
flex-shrink: 0;
561+
display: flex;
562+
flex-direction: column;
563+
align-items: center;
564+
justify-content: center;
565+
gap: 0.4em;
566+
padding: 0.75em 0.5em;
567+
border: 1px solid #e0e0e0;
568+
border-radius: 4px;
569+
background: #f8f9fa;
570+
min-height: 120px;
571+
text-align: center;
572+
}
573+
#wp_sudo_activity .wp-sudo-empty-status strong {
574+
font-size: 1.05em;
575+
line-height: 1.25;
576+
}
577+
#wp_sudo_activity .wp-sudo-empty-status-icon {
578+
display: inline-flex;
579+
align-items: center;
580+
justify-content: center;
581+
width: 32px;
582+
height: 32px;
583+
border-radius: 999px;
584+
background: #f0f6fc;
585+
color: #2271b1;
586+
}
587+
#wp_sudo_activity .wp-sudo-empty-status-icon .dashicons {
588+
width: 18px;
589+
height: 18px;
590+
font-size: 18px;
574591
}
575592
#wp_sudo_activity .wp-sudo-empty-desc {
576593
font-size: 0.85em;
@@ -639,6 +656,13 @@ private static function render_inline_styles(): void {
639656
#wp_sudo_activity .wp-sudo-event-filters {
640657
gap: 3px;
641658
}
659+
#wp_sudo_activity .wp-sudo-empty-container {
660+
grid-template-columns: 1fr;
661+
gap: 0.75em;
662+
}
663+
#wp_sudo_activity .wp-sudo-empty-status {
664+
min-height: 0;
665+
}
642666
}
643667
</style>
644668
<script>

includes/class-event-recorder.php

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,8 @@ public static function on_action_replayed( int $user_id, string $rule_id ): void
178178
* Handle wp_sudo_action_passed event.
179179
*
180180
* Fired when a gated action passes through due to an active sudo session.
181-
* Only logs when the log_passthrough setting is enabled.
181+
* Logging is enabled by default and may only be disabled by explicit
182+
* code-level override (constant/filter).
182183
*
183184
* @since 3.0.0
184185
*
@@ -188,10 +189,8 @@ public static function on_action_replayed( int $user_id, string $rule_id ): void
188189
* @return void
189190
*/
190191
public static function on_action_passed( int $user_id, string $rule_id, string $surface ): void {
191-
// Check the log_passthrough setting.
192-
$log_passthrough = Admin::get( 'log_passthrough', false );
193-
if ( ! $log_passthrough ) {
194-
return; // User opted out of passthrough logging.
192+
if ( ! Admin::is_passed_event_logging_enabled() ) {
193+
return;
195194
}
196195

197196
Event_Store::insert(

0 commit comments

Comments
 (0)