PluginProbe
Stream – Activity Log & Audit Trail / trunk
Stream – Activity Log & Audit Trail vtrunk
4.4.0 4.3.0 4.2.2 4.2.1 trunk 2.0.1 2.0.2 2.0.3 2.0.4 2.0.5 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.1 3.1.1 3.10.0 3.2.0 3.2.1 3.2.2 3.2.3 All 50 releases
stream / classes / class-connector.php

class-connector.php in Stream – Activity Log & Audit Trail trunk, at classes/class-connector.php

460 lines 12.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Abstract class serving as the parent for all logger classes AKA "Connectors".
4 * Common functionality for registering log events are defined here.
5 *
6 * @package WP_Stream;
7 */
8
9 namespace WP_Stream;
10
11 /**
12 * Class - Connector
13 */
14 abstract class Connector {
15 /**
16 * Connector slug
17 *
18 * @var string
19 */
20 public $name = null;
21
22 /**
23 * Actions registered for this connector
24 *
25 * @var array
26 */
27 public $actions = array();
28
29 /**
30 * Store delayed logs
31 *
32 * @var array
33 */
34 public $delayed = array();
35
36 /**
37 * Previous Stream entry in same request
38 *
39 * @var int
40 */
41 public $prev_stream = null;
42
43 /**
44 * Register connector in the WP Admin
45 *
46 * @var bool
47 */
48 public $register_admin = true;
49
50 /**
51 * Register connector in the WP Frontend
52 *
53 * @var bool
54 */
55 public $register_frontend = true;
56
57 /**
58 * Holds connector registration status flag.
59 *
60 * @var bool
61 */
62 private $is_registered = false;
63
64 /**
65 * Is the connector currently registered?
66 *
67 * @return boolean
68 */
69 public function is_registered() {
70 return $this->is_registered;
71 }
72
73 /**
74 * Register all context hooks
75 */
76 public function register() {
77 if ( $this->is_registered ) {
78 return;
79 }
80
81 foreach ( $this->actions as $action ) {
82 add_action( $action, array( $this, 'callback' ), 10, 99 );
83 }
84
85 add_filter( 'wp_stream_action_links_' . $this->name, array( $this, 'action_links' ), 10, 2 );
86
87 $this->is_registered = true;
88 }
89
90 /**
91 * Unregister all context hooks
92 */
93 public function unregister() {
94 if ( ! $this->is_registered ) {
95 return;
96 }
97
98 foreach ( $this->actions as $action ) {
99 remove_action( $action, array( $this, 'callback' ), 10, 99 );
100 }
101
102 remove_filter( 'wp_stream_action_links_' . $this->name, array( $this, 'action_links' ), 10, 2 );
103
104 $this->is_registered = false;
105 }
106
107 /**
108 * Callback for all registered hooks throughout Stream
109 * Looks for a class method with the convention: "callback_{action name}"
110 */
111 public function callback() {
112 $action = current_filter();
113 $callback = array( $this, 'callback_' . preg_replace( '/[^a-z0-9_]/', '_', $action ) );
114
115 // For the sake of testing, trigger an action with the name of the callback.
116 if ( defined( 'WP_STREAM_TESTS' ) && WP_STREAM_TESTS ) {
117 /**
118 * Action fires during testing to test the current callback
119 *
120 * @param array $callback Callback name
121 */
122 do_action( 'wp_stream_test_' . $callback[1] );
123 }
124
125 // Call the real function.
126 if ( is_callable( $callback ) ) {
127 return call_user_func_array( $callback, func_get_args() );
128 }
129 }
130
131 /**
132 * Add action links to Stream drop row in admin list screen
133 *
134 * @param array $links Previous links registered.
135 * @param object $record Stream record.
136 *
137 * @filter wp_stream_action_links_{connector}
138 *
139 * @return array Action links
140 */
141 public function action_links( $links, $record ) {
142 unset( $record );
143 return $links;
144 }
145
146 /**
147 * Log handler
148 *
149 * @param string $message sprintf-ready error message string.
150 * @param array $args sprintf (and extra) arguments to use.
151 * @param int|null $object_id Target object id (if any).
152 * @param string $context Context of the event.
153 * @param string $action Action of the event.
154 * @param int $user_id User responsible for the event.
155 *
156 * @return bool
157 */
158 public function log( $message, $args, $object_id, $context, $action, $user_id = null ) {
159 $connector = $this->name;
160
161 /**
162 * Override the data logged. Returning false to this filter will stop the data from being logged.
163 * Examples of this filter in use can be found in some of the custom connectors.
164 *
165 * @see Connector_ACF::log_override()
166 *
167 * @return array|false An array of the data to be logged or false if it should not be logged.
168 */
169 $data = apply_filters(
170 'wp_stream_log_data',
171 compact( 'connector', 'message', 'args', 'object_id', 'context', 'action', 'user_id' )
172 );
173
174 if ( ! $data ) {
175 return false;
176 } else {
177 $connector = $data['connector'];
178 $message = $data['message'];
179 $args = $data['args'];
180 $object_id = $data['object_id'];
181 $context = $data['context'];
182 $action = $data['action'];
183 $user_id = $data['user_id'];
184 }
185
186 return call_user_func_array( array( wp_stream_get_instance()->log, 'log' ), compact( 'connector', 'message', 'args', 'object_id', 'context', 'action', 'user_id' ) );
187 }
188
189 /**
190 * Substrings that mark a setting name as holding a credential.
191 *
192 * Deliberately matched as substrings so unknown third-party settings are
193 * covered by default: connectors log arbitrary option arrays from other
194 * plugins, and an allowlist cannot anticipate every field a payment gateway
195 * or integration might add. Over-redacting a harmless field only costs a
196 * little detail in the audit log; under-redacting persists a live credential
197 * in a table that lower-privileged Stream viewers can read.
198 *
199 * @const array
200 */
201 const SECRET_KEY_PATTERNS = array(
202 'pass',
203 'secret',
204 'private_key',
205 'apikey',
206 'token',
207 'webhook',
208 'license',
209 'salt',
210 'credential',
211 'oauth',
212 // PayPal NVP `api_signature`; over-matches e.g. `email_signature`.
213 'signature',
214 );
215
216 /**
217 * Setting names, or suffixes of them, that hold a credential but do not
218 * contain any of the substrings above.
219 *
220 * Matched against the end of the name so option prefixes used by individual
221 * plugins (rg_gforms_key, woocommerce_..._key) are covered without treating
222 * every name that merely contains "key" as sensitive.
223 *
224 * @const array
225 */
226 const SECRET_KEY_SUFFIXES = array(
227 '_key',
228 );
229
230 /**
231 * Names that end in a secret-looking suffix but are not credentials.
232 *
233 * Public halves of key pairs are meant to be published, and redacting them
234 * removes useful audit detail for no benefit. Matched as substrings so the
235 * various plugin prefixes are covered.
236 *
237 * Only consulted after SECRET_KEY_PATTERNS, so a name containing an
238 * explicit secret marker is never exempted by appearing "public" too.
239 *
240 * @const array
241 */
242 const PUBLIC_KEY_PATTERNS = array(
243 'public_key',
244 'publishable_key',
245 'site_key',
246 );
247
248 /**
249 * Placeholder stored in place of a redacted value.
250 *
251 * A distinct marker rather than an empty string, so a reader of the audit
252 * trail can tell "this credential changed, value withheld" apart from "this
253 * field was cleared" -- an empty string would conflate the two.
254 *
255 * @const string
256 */
257 const REDACTED_PLACEHOLDER = '[redacted]';
258
259 /**
260 * Whether a setting/field name looks like it holds a credential.
261 *
262 * @param string $key Setting or field name.
263 * @return bool
264 */
265 public function is_secret_key( $key ) {
266 if ( ! is_string( $key ) || '' === $key ) {
267 return false;
268 }
269
270 $needle = strtolower( $key );
271
272 // An explicit secret marker always wins. The public-name exemption
273 // below is only there to stop the broad "_key" suffix rule from
274 // swallowing published key halves, so it must not be able to rescue a
275 // name that also says "secret", "private_key" or "webhook" -- that
276 // would invert the over-redact-rather-than-under-redact preference.
277 foreach ( self::SECRET_KEY_PATTERNS as $pattern ) {
278 if ( false !== strpos( $needle, $pattern ) ) {
279 return true;
280 }
281 }
282
283 foreach ( self::PUBLIC_KEY_PATTERNS as $pattern ) {
284 if ( false !== strpos( $needle, $pattern ) ) {
285 return false;
286 }
287 }
288
289 foreach ( self::SECRET_KEY_SUFFIXES as $suffix ) {
290 if ( substr( $needle, -strlen( $suffix ) ) === $suffix ) {
291 return true;
292 }
293 }
294
295 return false;
296 }
297
298 /**
299 * Redact credential values before they are persisted as record metadata.
300 *
301 * Accepts either a scalar (redacted when $key itself looks secret) or an
302 * array of settings (each secret-looking member redacted, recursively).
303 * Values are replaced rather than removed so the audit trail still shows
304 * that the field changed, without retaining the credential.
305 *
306 * @param mixed $value Value about to be logged.
307 * @param string $key Setting or field name the value belongs to.
308 * @return mixed
309 */
310 public function redact_secret_values( $value, $key = '' ) {
311 if ( is_array( $value ) ) {
312 // A secret parent may key credentials by opaque IDs (e.g. Jetpack
313 // `user_tokens` by user ID), so pass it down over child names.
314 $inherited_key = $this->is_secret_key( $key ) ? $key : null;
315
316 foreach ( $value as $child_key => $child_value ) {
317 $value[ $child_key ] = $this->redact_secret_values(
318 $child_value,
319 null !== $inherited_key ? $inherited_key : (string) $child_key
320 );
321 }
322
323 return $value;
324 }
325
326 if ( $this->is_secret_key( $key ) && ! empty( $value ) ) {
327 return self::REDACTED_PLACEHOLDER;
328 }
329
330 return $value;
331 }
332
333 /**
334 * Save log data till shutdown, so other callbacks would be able to override
335 *
336 * @param string $handle Special slug to be shared with other actions.
337 * @note param mixed $arg1 Extra arguments to sent to log()
338 * @note param param mixed $arg2, etc..
339 */
340 public function delayed_log( $handle ) {
341 $args = func_get_args();
342
343 array_shift( $args );
344
345 $this->delayed[ $handle ] = $args;
346
347 add_action( 'shutdown', array( $this, 'delayed_log_commit' ) );
348 }
349
350 /**
351 * Commit delayed logs saved by @delayed_log
352 */
353 public function delayed_log_commit() {
354 foreach ( $this->delayed as $handle => $args ) {
355 call_user_func_array( array( $this, 'log' ), $args );
356 }
357 }
358
359 /**
360 * Compare two values and return changed keys if they are arrays
361 *
362 * @param mixed $old_value Value before change.
363 * @param mixed $new_value Value after change.
364 * @param bool|int $deep Get array children changes keys as well, not just parents.
365 *
366 * @return array
367 */
368 public function get_changed_keys( $old_value, $new_value, $deep = false ) {
369 if ( ! is_array( $old_value ) && ! is_array( $new_value ) ) {
370 return array();
371 }
372
373 if ( ! is_array( $old_value ) ) {
374 return array_keys( $new_value );
375 }
376
377 if ( ! is_array( $new_value ) ) {
378 return array_keys( $old_value );
379 }
380
381 $diff = array_udiff_assoc(
382 $old_value,
383 $new_value,
384 function ( $value1, $value2 ) {
385 // Compare potentially complex nested arrays.
386 return wp_json_encode( $value1 ) !== wp_json_encode( $value2 );
387 }
388 );
389
390 $result = array_keys( $diff );
391
392 // Find unexisting keys in old or new value.
393 $common_keys = array_keys( array_intersect_key( $old_value, $new_value ) );
394 $unique_keys_old = array_values( array_diff( array_keys( $old_value ), $common_keys ) );
395 $unique_keys_new = array_values( array_diff( array_keys( $new_value ), $common_keys ) );
396
397 $result = array_merge( $result, $unique_keys_old, $unique_keys_new );
398
399 // Remove numeric indexes.
400 $result = array_filter(
401 $result,
402 function ( $value ) {
403 // @codingStandardsIgnoreStart
404 // check if is not valid number (is_int, is_numeric and ctype_digit are not enough)
405 return (string) (int) $value !== (string) $value;
406 // @codingStandardsIgnoreEnd
407 }
408 );
409
410 $result = array_values( array_unique( $result ) );
411
412 if ( false === $deep ) {
413 return $result; // Return an numerical based array with changed TOP PARENT keys only.
414 }
415
416 $result = array_fill_keys( $result, null );
417
418 foreach ( $result as $key => $val ) {
419 if ( in_array( $key, $unique_keys_old, true ) ) {
420 $result[ $key ] = false; // Removed.
421 } elseif ( in_array( $key, $unique_keys_new, true ) ) {
422 $result[ $key ] = true; // Added.
423 } elseif ( $deep ) { // Changed, find what changed, only if we're allowed to explore a new level.
424 if ( is_array( $old_value[ $key ] ) && is_array( $new_value[ $key ] ) ) {
425 $inner = array();
426 $parent = $key;
427 --$deep;
428 $changed = $this->get_changed_keys( $old_value[ $key ], $new_value[ $key ], $deep );
429 foreach ( $changed as $child => $change ) {
430 $inner[ $parent . '::' . $child ] = $change;
431 }
432 $result[ $key ] = 0; // Changed parent which has a changed children.
433 $result = array_merge( $result, $inner );
434 }
435 }
436 }
437
438 return $result;
439 }
440
441 /**
442 * Allow connectors to determine if their dependencies is satisfied or not
443 *
444 * @return bool
445 */
446 public function is_dependency_satisfied() {
447 return true;
448 }
449
450 /**
451 * Escape % characters in a string to avoid Uncaught ValueErrors in $this->log().
452 *
453 * @param string $value The string value to be escaped.
454 * @return string The escaped string.
455 */
456 public function escape_percentages( $value ) {
457 return str_replace( '%', '%%', $value );
458 }
459 }
460