PluginProbe
Activity Logs, User Activity Tracking, Multisite Activity Log from Logtivity / 3.3.10
Activity Logs, User Activity Tracking, Multisite Activity Log from Logtivity v3.3.10
3.3.10 3.3.9 3.3.8 trunk 1.0 1.1.0 1.10.0 1.11.0 1.11.1 1.12.0 1.13.0 1.14.0 1.15.0 1.16.0 1.17.0 1.17.1 1.18.0 1.19.0 1.2.0 1.20.0 1.20.1 1.3.0 1.3.1 1.4.0 1.5.0 All 68 releases
← All changes | Loggers/Core/Logtivity_Plugin.php +216 -27 3.3.8 → 3.3.10 View file →
@@ -24,11 +24,15 @@
24 24
25 25 class Logtivity_Plugin extends Logtivity_Abstract_Logger
26 26 {
27 27 /**
28 + * Plugin data captured before a destructive file operation — a deletion, or
29 + * an update that overwrites the files — keyed by plugin slug. Read back once
30 + * the operation completes, when the on-disk copy is gone or already replaced.
31 + *
28 32 * @var array
29 33 */
30 - protected array $savedPluginData = [];
34 + protected array $capturedPluginData = [];
31 35
32 36 /**
33 37 * @inheritDoc
34 38 */
@@ -33,13 +37,24 @@
33 37 * @inheritDoc
34 38 */
35 39 protected function registerHooks(): void
36 40 {
37 - add_action('activated_plugin', [$this, 'pluginActivated'], 10, 2);
38 - add_action('deactivated_plugin', [$this, 'pluginDeactivated'], 10, 2);
41 + /*
42 + * Track the active plugin lists directly rather than the activated_plugin/
43 + * deactivated_plugin actions. Those actions are skipped when a plugin is
44 + * (de)activated silently (the $silent argument to activate_plugin() /
45 + * deactivate_plugins()), whereas the underlying option is always written.
46 + * Diffing the option therefore catches every status change, silent or not.
47 + */
48 + add_action('update_option_active_plugins', [$this, 'activePluginsUpdated'], 10, 2);
49 + add_action('add_option_active_plugins', [$this, 'activePluginsAdded'], 10, 2);
50 + add_action('update_site_option_active_sitewide_plugins', [$this, 'networkPluginsUpdated'], 10, 3);
51 + add_action('add_site_option_active_sitewide_plugins', [$this, 'networkPluginsAdded'], 10, 2);
52 +
39 53 add_action('delete_plugin', [$this, 'savePluginData'], 10, 1);
40 54 add_action('deleted_plugin', [$this, 'pluginDeleted'], 10, 2);
41 55
56 + add_filter('upgrader_pre_install', [$this, 'savePreUpdateVersion'], 10, 2);
42 57 add_action('upgrader_process_complete', [$this, 'upgradeProcessComplete'], 10, 2);
43 58
44 59 add_filter('editable_extensions', [$this, 'pluginFileModified'], 10, 2);
45 60 }
@@ -52,13 +67,13 @@
52 67 protected function getPluginData($plugin): ?array
53 68 {
54 69 if (is_string($plugin)) {
55 70 $slug = $plugin;
56 - $pluginData = $this->savedPluginData[$plugin]
57 - ?? get_plugin_data(WP_PLUGIN_DIR . '/' . $plugin, false, false);
71 + $pluginData = get_plugin_data(WP_PLUGIN_DIR . '/' . $plugin, false, false);
58 72
59 73 } elseif ($plugin instanceof Plugin_Upgrader) {
60 - if ($slug = $plugin->plugin_info()) {
74 + $slug = $plugin->plugin_info();
75 + if ($slug) {
61 76 $pluginData = get_plugin_data($plugin->result['local_destination'] . '/' . $slug, false, false);
62 77 }
63 78 }
64 79
@@ -84,9 +99,10 @@
84 99 * @return void
85 100 */
86 101 public function pluginStateChanged(string $state, string $slug, bool $networkWide): void
87 102 {
88 - if ($plugin = $this->getPluginData($slug)) {
103 + $plugin = $this->getPluginData($slug);
104 + if ($plugin) {
89 105 Logtivity::log()
90 106 ->setAction('Plugin ' . $state)
91 107 ->setContext($plugin['Name'])
92 108 ->addMeta('Version', $plugin['Version'])
@@ -91,32 +107,165 @@
91 107 ->setContext($plugin['Name'])
92 108 ->addMeta('Version', $plugin['Version'])
93 109 ->addMeta('Slug', $plugin['Slug'])
94 110 ->addMeta('Network Wide', $networkWide ? 'Yes' : 'No')
111 + ->addInventory()
95 112 ->send();
96 113 }
97 114 }
98 115
99 116 /**
117 + * The active_plugins option was updated (single site).
118 + *
119 + * @param mixed $oldValue
120 + * @param mixed $value
121 + *
122 + * @return void
123 + */
124 + public function activePluginsUpdated($oldValue, $value): void
125 + {
126 + $this->logPluginListChanges((array)$oldValue, (array)$value, false);
127 + }
128 +
129 + /**
130 + * The active_plugins option was created (single site, first activation).
131 + *
132 + * @param string $option
133 + * @param mixed $value
134 + *
135 + * @return void
136 + */
137 + public function activePluginsAdded(string $option, $value): void
138 + {
139 + $this->logPluginListChanges([], (array)$value, false);
140 + }
141 +
142 + /**
143 + * The active_sitewide_plugins network option was updated (multisite).
144 + *
145 + * The value is keyed by plugin slug, so compare against the keys.
146 + *
147 + * @param string $option
148 + * @param mixed $value
149 + * @param mixed $oldValue
150 + *
151 + * @return void
152 + */
153 + public function networkPluginsUpdated(string $option, $value, $oldValue): void
154 + {
155 + $this->logPluginListChanges(
156 + array_keys((array)$oldValue),
157 + array_keys((array)$value),
158 + true
159 + );
160 + }
161 +
162 + /**
163 + * The active_sitewide_plugins network option was created (multisite).
164 + *
165 + * @param string $option
166 + * @param mixed $value
167 + *
168 + * @return void
169 + */
170 + public function networkPluginsAdded(string $option, $value): void
171 + {
172 + $this->logPluginListChanges([], array_keys((array)$value), true);
173 + }
174 +
175 + /**
176 + * Diff a previous and current list of active plugin slugs and log each
177 + * activation/deactivation. A set-based diff means a pure reordering of the
178 + * list (e.g. Logtivity moving itself to load first) produces no events.
179 + *
180 + * @param string[] $previous
181 + * @param string[] $current
182 + * @param bool $networkWide
183 + *
184 + * @return void
185 + */
186 + protected function logPluginListChanges(array $previous, array $current, bool $networkWide): void
187 + {
188 + foreach (array_diff($current, $previous) as $slug) {
189 + $this->pluginStateChanged('Activated', $slug, $networkWide);
190 + }
191 +
192 + foreach (array_diff($previous, $current) as $slug) {
193 + /*
194 + * A plugin whose files no longer exist was deleted, not merely
195 + * deactivated. Deleting an active plugin drops it from the active
196 + * list too, but that deletion is already reported via the
197 + * deleted_plugin hook, so skip the redundant deactivation event.
198 + */
199 + if ($this->pluginFileExists($slug) == false) {
200 + continue;
201 + }
202 +
203 + $this->pluginStateChanged('Deactivated', $slug, $networkWide);
204 + }
205 + }
206 +
207 + /**
208 + * Whether the plugin's main file still exists on disk.
209 + *
100 210 * @param string $slug
101 - * @param bool $networkWide
102 211 *
212 + * @return bool
213 + */
214 + protected function pluginFileExists(string $slug): bool
215 + {
216 + return is_readable(WP_PLUGIN_DIR . '/' . $slug);
217 + }
218 +
219 + /**
220 + * Capture a plugin's data before its files are removed or overwritten, so it
221 + * can still be reported once the on-disk copy is gone or replaced. Keyed by
222 + * slug and captured at most once per plugin per request.
223 + *
224 + * @param string $slug
225 + *
103 226 * @return void
104 227 */
105 - public function pluginActivated(string $slug, bool $networkWide): void
228 + protected function capturePluginData(string $slug): void
106 229 {
107 - $this->pluginStateChanged('Activated', $slug, $networkWide);
230 + if (
231 + isset($this->capturedPluginData[$slug]) == false
232 + && $this->pluginFileExists($slug)
233 + ) {
234 + $pluginData = $this->getPluginData($slug);
235 + if ($pluginData) {
236 + $this->capturedPluginData[$slug] = $pluginData;
237 + }
238 + }
108 239 }
109 240
110 241 /**
111 - * @param string $slug
112 - * @param bool $networkDeactivating
242 + * Snapshot the version of every installed plugin. Used when an install does
243 + * not name the plugin being written (an upload, including one overwriting an
244 + * existing plugin), so the prior version can still be resolved by slug once
245 + * the install completes. Runs while the old files are still on disk, and
246 + * never overwrites data already captured for a specific plugin.
113 247 *
114 248 * @return void
115 249 */
116 - public function pluginDeactivated(string $slug, bool $networkDeactivating): void
250 + protected function captureAllPluginData(): void
117 251 {
118 - $this->pluginStateChanged('Deactivated', $slug, $networkDeactivating);
252 + if (function_exists('get_plugins') == false) {
253 + require_once ABSPATH . 'wp-admin/includes/plugin.php';
254 + }
255 +
256 + foreach (get_plugins() as $slug => $pluginData) {
257 + if (isset($this->capturedPluginData[$slug]) == false) {
258 + $this->capturedPluginData[$slug] = array_merge(
259 + [
260 + 'Name' => 'Not Set',
261 + 'Slug' => $slug,
262 + 'Version' => 'Not Set',
263 + ],
264 + $pluginData
265 + );
266 + }
267 + }
119 268 }
120 269
121 270 /**
122 271 * @param string $plugin
@@ -124,13 +273,9 @@
124 273 * @return void
125 274 */
126 275 public function savePluginData(string $plugin): void
127 276 {
128 - if (empty($this->savedPluginData)) {
129 - if ($pluginData = $this->getPluginData($plugin)) {
130 - $this->savedPluginData[$plugin] = $pluginData;
131 - }
132 - }
277 + $this->capturePluginData($plugin);
133 278 }
134 279
135 280 /**
136 281 * @param string $pluginFile
@@ -139,9 +284,11 @@
139 284 * @return void
140 285 */
141 286 public function pluginDeleted(string $pluginFile, bool $deleted): void
142 287 {
143 - if ($plugin = $this->getPluginData($pluginFile)) {
288 + $plugin = $this->capturedPluginData[$pluginFile] ?? $this->getPluginData($pluginFile);
289 +
290 + if ($plugin) {
144 291 Logtivity::log()
145 292 ->setAction('Plugin Deleted')
146 293 ->setContext($plugin['Name'])
147 294 ->addMeta('Slug', $plugin['Slug'])
@@ -146,13 +293,39 @@
146 293 ->setContext($plugin['Name'])
147 294 ->addMeta('Slug', $plugin['Slug'])
148 295 ->addMeta('Version', $plugin['Version'])
149 296 ->addMeta('Deletion Successful', $deleted ? 'Yes' : 'No')
297 + ->addInventory()
150 298 ->send();
151 299 }
152 300 }
153 301
154 302 /**
303 + * Capture plugin data before an install/update overwrites the files, so the
304 + * old version can be logged once it completes. Fires while the old files are
305 + * still on disk. Updates name the plugin in $hookExtra, so only that one is
306 + * captured; uploads (including one overwriting an existing plugin) do not,
307 + * so every installed plugin is snapshotted and the replaced one resolved by
308 + * slug at completion. Registered as a filter, so the value is returned as-is.
309 + *
310 + * @param bool|WP_Error $response
311 + * @param array $hookExtra
312 + *
313 + * @return bool|WP_Error
314 + */
315 + public function savePreUpdateVersion($response, array $hookExtra)
316 + {
317 + $slug = $hookExtra['plugin'] ?? null;
318 + if ($slug) {
319 + $this->capturePluginData($slug);
320 + } else {
321 + $this->captureAllPluginData();
322 + }
323 +
324 + return $response;
325 + }
326 +
327 + /**
155 328 * @param WP_Upgrader $upgrader
156 329 * @param array $options
157 330 *
158 331 * @return void
@@ -200,15 +373,23 @@
200 373 $bulk = $options['bulk'] ?? false;
201 374 $plugins = $this->getPluginList($upgrader, $options);
202 375
203 376 foreach ($plugins as $plugin) {
204 - if ($pluginData = $this->getPluginData($plugin)) {
205 - Logtivity::log()
377 + $pluginData = $this->getPluginData($plugin);
378 + if ($pluginData) {
379 + $log = Logtivity::log()
206 380 ->setAction('Plugin ' . $action)
207 381 ->setContext($pluginData['Name'])
208 382 ->addMeta('Slug', $pluginData['Slug'])
209 - ->addMeta('Version', $pluginData['Version'])
210 - ->addMeta('Bulk', $bulk ? 'Yes' : 'No')
383 + ->addMeta('Version', $pluginData['Version']);
384 +
385 + $oldVersion = $this->capturedPluginData[$pluginData['Slug']]['Version'] ?? null;
386 + if ($oldVersion) {
387 + $log->addMeta('Old Version', $oldVersion);
388 + }
389 +
390 + $log->addMeta('Bulk', $bulk ? 'Yes' : 'No')
391 + ->addInventory()
211 392 ->send();
212 393 }
213 394 }
214 395 }
@@ -219,11 +400,19 @@
219 400 * @return array
220 401 */
221 402 public function pluginFileModified(array $defaultTypes): array
222 403 {
223 - $action = sanitize_text_field($_POST['action'] ?? null);
224 - $plugin = sanitize_text_field($_POST['plugin'] ?? null);
225 - $file = sanitize_text_field($_REQUEST['file'] ?? null);
404 + // phpcs:disable WordPress.Security.NonceVerification.Missing -- Passive logger reads core plugin-editor request after WordPress validates it.
405 + $action = isset($_POST['action'])
406 + ? sanitize_text_field(wp_unslash($_POST['action']))
407 + : null;
408 + $plugin = isset($_POST['plugin'])
409 + ? sanitize_text_field(wp_unslash($_POST['plugin']))
410 + : null;
411 + $file = isset($_REQUEST['file'])
412 + ? sanitize_text_field(wp_unslash($_REQUEST['file']))
413 + : null;
414 + // phpcs:enable WordPress.Security.NonceVerification.Missing
226 415
227 416 if ($file && $plugin && $action == 'edit-theme-plugin-file') {
228 417 $pluginData = $this->getPluginData($plugin);
229 418