prefix . 'guardforge_trusted_baseline'; } /** * Record (or replace) the approved state of one file. Called only from * GUARDFORGE_Case_Actions::handle_expected_change — the one decision * (§6.4 K1-b) that closes a file_change case as an operator-reviewed * expected change — with the hash/version that case's OWN last * integrity-scan evidence already carries, so recording an approval * never re-reads the file from disk or re-fetches a reference. * * @param string $scope 'core' or 'plugin' (class-integrity.php's own scopes for this case type). * @param string $path Relative path, the same shape class-integrity.php flags. * @param string $file_hash sha256 of the file at approval time. * @param string $package_version Installed core/plugin version at approval time. * @param int $case_id The case this approval was recorded from. * @param string $reason The operator's own "why expected" text (mandatory at K1-b). * @return int The baseline row id. */ public function record( $scope, $path, $file_hash, $package_version, $case_id, $reason ) { global $wpdb; $key = GUARDFORGE_Case_Model::case_key( 'file_change', [ 'scope' => (string) $scope, 'path' => (string) $path ] ); $now = function_exists( 'current_time' ) ? current_time( 'mysql' ) : gmdate( 'Y-m-d H:i:s' ); $user = function_exists( 'wp_get_current_user' ) ? wp_get_current_user() : null; $fields = [ 'scope' => substr( (string) $scope, 0, 20 ), 'path' => substr( (string) $path, 0, 300 ), 'file_hash' => substr( (string) $file_hash, 0, 64 ), 'package_version' => substr( (string) $package_version, 0, 40 ), 'case_id' => (int) $case_id, 'approved_by' => $user && $user->ID ? (int) $user->ID : 0, 'approved_by_login' => $user && $user->user_login ? substr( (string) $user->user_login, 0, 200 ) : '', 'reason' => (string) $reason, 'approved_at' => $now, 'updated_at' => $now, ]; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix + a literal; the key is prepared. $existing_id = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$this->table()} WHERE baseline_key = %s", $key ) ); if ( $existing_id ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin-owned table; $wpdb escapes insert/update/replace/delete values itself, and there is nothing to cache on a write. $wpdb->update( $this->table(), $fields, [ 'id' => (int) $existing_id ] ); return (int) $existing_id; } $fields['baseline_key'] = $key; $fields['created_at'] = $now; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- plugin-owned table; $wpdb escapes insert/update/replace/delete values itself, and there is nothing to cache on a write. $wpdb->insert( $this->table(), $fields ); return (int) $wpdb->insert_id; } /** * Every stored baseline, keyed "scope|path" — the same shape * GUARDFORGE_Cases::sync_file_changes builds for GUARDFORGE_Case_ * Model::candidates_from_integrity's own $context['baselines']. A * plain, unfiltered read: this table holds one row per file an operator * has EXPLICITLY approved, which on any real site is a short, hand-built * list — never a per-scan or per-login volume the way guardforge_cases/ * guardforge_case_events are (see the class docblock). * * @return array> */ public function all() { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix + a literal; no user input. $rows = (array) $wpdb->get_results( "SELECT * FROM {$this->table()}", ARRAY_A ); $out = []; foreach ( $rows as $row ) { if ( ! is_array( $row ) ) { continue; } $out[ (string) ( $row['scope'] ?? '' ) . '|' . (string) ( $row['path'] ?? '' ) ] = $row; } return $out; } /** * @param string $scope * @param string $path * @return array|null */ public function get( $scope, $path ) { $all = $this->all(); return $all[ (string) $scope . '|' . (string) $path ] ?? null; } }