@ "), not `confidence_reason`, not * `resolution_basis`, not a recheck reason, not an audit `details` field, not * a plugin option. `resolution_basis` is reported as the yes/no fact that a * basis was recorded, which is what the acceptance line needs ("closed with a * basis") without shipping the operator's own words to the client. * * @package GuardForge */ if ( ! defined( 'ABSPATH' ) ) { exit; } class GUARDFORGE_Guard_Report { /** The four honest categories, in the order the plan states them. */ const CATEGORIES = [ 'detected', 'checked', 'fixed', 'unknown' ]; /** * The fifth line: mitigated / fix-applied cases. Not a category — it is * the number that stops `fixed` from being read as "everything we did". */ const MITIGATED_KEY = 'mitigated_not_fixed'; /** Statuses counted on the MITIGATED_KEY line. */ const MITIGATED_STATUSES = [ 'mitigated', 'fix_applied' ]; /** Recheck verdicts that count as "checked" — a verdict was reached. */ const VERDICT_REACHED = [ 'still_affected', 'fixed' ]; /** Recheck verdicts that count as "left unknown" — no verdict was reached. */ const VERDICT_UNDECIDED = [ 'unavailable' ]; /** Every recheck verdict this class will print, plus the "no recheck" case. */ const VERDICTS = [ 'fixed', 'still_affected', 'unavailable', 'none' ]; /** * Where each number comes from, as the table and column a reader can go * and count themselves. Kept in English on purpose: these strings ride * into the client-facing CSV, which is not translated (the same choice * GUARDFORGE_Audit_Export makes for its own column headers). */ const SOURCES = [ 'detected' => 'guardforge_cases.first_seen', 'checked' => 'guardforge_case_events.observed_at (source=recheck, result=fixed|still_affected)', 'fixed' => 'guardforge_cases.updated_at (status=fixed)', 'unknown' => 'guardforge_case_events.observed_at (source=recheck, result=unavailable)', self::MITIGATED_KEY => 'guardforge_case_events.observed_at (source=decision, to mitigated|fix_applied)', ]; /** What each number does not cover. Printed next to it, never omitted. */ const COMPLETENESS = [ 'detected' => 'Cases whose first observation falls inside the window. Only what the integrity scan, the advisory feed and the login log observed and recorded on this site.', 'checked' => 'Cases whose recheck reached a verdict inside the window. A recheck that could not run is counted under "left unknown", never here.', 'fixed' => 'Cases a recheck confirmed and closed with a recorded basis inside the window. Mitigated cases are on their own line below; cases closed as an expected change are not counted as fixed.', 'unknown' => 'Cases whose recheck inside the window could not decide: the reference package, the advisory feed or the log window was unavailable. Counted from that recheck itself, so a case in any open status is here once its recheck could not run — and a case nobody rechecked this month never is.', self::MITIGATED_KEY => 'Cases where a decision inside the window blocked access or installed an update. Counted from that decision, not from the case status, so re-observing an older case does not move it into this month. Blocking access or installing an update is not a confirmation that the condition is gone. Not counted as fixed.', ]; /** * First and last instant of a calendar month, as the half-open window * [from, to) the SQL below binds. Site-local naive strings, because that * is what current_time('mysql') writes into every column being counted. * * @param string $month 'YYYY-MM'. * @return array{0:string,1:string}|null Null when $month is not a month. */ public static function month_bounds( $month ) { if ( ! preg_match( '/^(\d{4})-(0[1-9]|1[0-2])$/', (string) $month, $m ) ) { return null; } $from = $m[1] . '-' . $m[2] . '-01 00:00:00'; $next = ( new DateTimeImmutable( $from, new DateTimeZone( 'UTC' ) ) )->modify( '+1 month' ); return [ $from, $next->format( 'Y-m-d H:i:s' ) ]; } /** * The months a report may be asked for: this one back through $count-1 * earlier ones. Newest first. * * @param string $now_mysql Site-local 'Y-m-d H:i:s' (current_time('mysql')). * @param int $count * @return string 'YYYY-MM' values. */ public static function month_choices( $now_mysql, $count = 12 ) { $month = self::month_of( $now_mysql ); if ( $month === '' ) { return []; } $cursor = new DateTimeImmutable( $month . '-01 00:00:00', new DateTimeZone( 'UTC' ) ); $out = []; for ( $i = 0; $i < max( 1, (int) $count ); $i++ ) { $out[] = $cursor->format( 'Y-m' ); $cursor = $cursor->modify( '-1 month' ); } return $out; } /** @return string 'YYYY-MM' of a mysql datetime, or '' if it is not one. */ public static function month_of( $mysql ) { return preg_match( '/^(\d{4}-(?:0[1-9]|1[0-2]))-\d{2}/', (string) $mysql, $m ) ? $m[1] : ''; } /** * The whole report for one month. * * @param string $month 'YYYY-MM'. * @param array $cases Case rows touching the window: id, type, status, * first_seen, updated_at, resolution_basis. * @param array $rechecks Recheck verdicts inside the window: * ['case_id' => int, 'result' => string]. * @param array $meta ['site' => string, 'generated_at' => string, 'data_as_of' => string]. * @param array $decisions Decisions recorded inside the window that moved * a case to a status: * ['case_id' => int, 'to_status' => string]. * Last of the optional arguments on purpose: it * arrived after $meta, and the four-argument call * shape is the one every caller already uses. * @return array{month:string,from:string,to:string,site:string,generated_at:string,data_as_of:string,figures:array,cases:array>}|null */ public static function build( $month, array $cases, array $rechecks, array $meta = [], array $decisions = [] ) { $bounds = self::month_bounds( $month ); if ( $bounds === null ) { return null; } [ $from, $to ] = $bounds; $window = $from . ' .. ' . $to; // case_id => the verdict its last recheck inside the window reached. // $rechecks arrives oldest-first from the repository, so a later row // for the same case overwrites an earlier one on purpose. $verdicts = []; foreach ( $rechecks as $row ) { $result = (string) ( $row['result'] ?? '' ); if ( ! in_array( $result, self::VERDICTS, true ) || $result === 'none' ) { continue; } $verdicts[ (int) ( $row['case_id'] ?? 0 ) ] = $result; } // case_id => the mitigating status its last decision inside the // window moved it to. Only the two statuses the fifth line reports // are kept, so nothing else a decision recorded can reach a figure. $decided = []; foreach ( $decisions as $row ) { $to_status = (string) ( $row['to_status'] ?? '' ); if ( ! in_array( $to_status, self::MITIGATED_STATUSES, true ) ) { continue; } $decided[ (int) ( $row['case_id'] ?? 0 ) ] = $to_status; } $counts = array_fill_keys( array_merge( self::CATEGORIES, [ self::MITIGATED_KEY ] ), 0 ); $rows = []; foreach ( $cases as $case ) { $id = (int) ( $case['id'] ?? 0 ); $status = (string) ( $case['status'] ?? '' ); $opened = self::in_window( (string) ( $case['first_seen'] ?? '' ), $from, $to ); $changed = self::in_window( (string) ( $case['updated_at'] ?? '' ), $from, $to ); $verdict = $verdicts[ $id ] ?? 'none'; $has_base = trim( (string) ( $case['resolution_basis'] ?? '' ) ) !== ''; $counted = []; if ( $opened ) { $counted[] = 'detected'; } if ( in_array( $verdict, self::VERDICT_REACHED, true ) ) { $counted[] = 'checked'; } // A 'fixed' case without a recorded basis is a bug elsewhere // (GUARDFORGE_Case_Recheck always passes the verdict's reason as // the basis) — but a report must count what is on file, so it is // left out of 'fixed' rather than counted as one and the row // still ships with basis_recorded=no for a reader to see. if ( $status === 'fixed' && $changed && $has_base ) { $counted[] = 'fixed'; } // Read off the recheck, not off the status: see the class // docblock. A case sitting in acknowledged/assigned/needs_ // verification whose recheck could not run belongs here, and a // case in 'unverified' that nothing rechecked this month does // not — its 'updated_at' says only that it was seen again. if ( in_array( $verdict, self::VERDICT_UNDECIDED, true ) ) { $counted[] = 'unknown'; } // Likewise by the moment of the decision, not by 'updated_at': // a case mitigated in June and re-observed in August is a June // mitigation in every month's report. if ( isset( $decided[ $id ] ) ) { $counted[] = self::MITIGATED_KEY; } if ( ! $counted ) { continue; } foreach ( $counted as $key ) { $counts[ $key ]++; } $rows[] = self::redact_case_row( $case, $verdict, $counted ); } $figures = []; foreach ( array_merge( self::CATEGORIES, [ self::MITIGATED_KEY ] ) as $key ) { $figures[ $key ] = [ 'count' => (int) $counts[ $key ], 'source' => self::SOURCES[ $key ], 'window' => $window, 'completeness' => self::COMPLETENESS[ $key ], ]; } return [ 'month' => self::month_of( $from ), 'from' => $from, 'to' => $to, 'site' => self::plain_site( (string) ( $meta['site'] ?? '' ) ), 'generated_at' => self::date_time_or_empty( (string) ( $meta['generated_at'] ?? '' ) ), 'data_as_of' => self::date_time_or_empty( (string) ( $meta['data_as_of'] ?? '' ) ), 'figures' => $figures, 'cases' => $rows, ]; } /** * One case, reduced to values that cannot carry a secret: an id, two * members of closed vocabularies, two dates, a yes/no and a verdict. * Anything not recognised becomes 'other'/'' rather than being passed * through — a redactor that forwards what it does not understand is not * a redactor. * * @param array $case * @param string $verdict Last recheck verdict inside the window. * @param string $counted Which figures this row contributed to. * @return array */ public static function redact_case_row( array $case, $verdict = 'none', array $counted = [] ) { $types = class_exists( 'GUARDFORGE_Case_Model' ) ? GUARDFORGE_Case_Model::TYPES : []; $statuses = class_exists( 'GUARDFORGE_Case_Model' ) ? GUARDFORGE_Case_Model::STATUSES : []; $keys = array_merge( self::CATEGORIES, [ self::MITIGATED_KEY ] ); return [ 'case_id' => (int) ( $case['id'] ?? 0 ), 'type' => in_array( (string) ( $case['type'] ?? '' ), $types, true ) ? (string) $case['type'] : 'other', 'status' => in_array( (string) ( $case['status'] ?? '' ), $statuses, true ) ? (string) $case['status'] : 'other', 'opened' => self::date_or_empty( (string) ( $case['first_seen'] ?? '' ) ), 'last_change' => self::date_or_empty( (string) ( $case['updated_at'] ?? '' ) ), 'basis_recorded' => trim( (string) ( $case['resolution_basis'] ?? '' ) ) !== '' ? 'yes' : 'no', 'last_recheck' => in_array( (string) $verdict, self::VERDICTS, true ) ? (string) $verdict : 'none', 'counted_as' => implode( ' ', array_values( array_intersect( $keys, $counted ) ) ), ]; } /** * The report as CSV lines (arrays of scalars). Header block first, then * the figures, then one row per case so the client can add the numbers * up themselves — an unauditable number is worth nothing. * * @param array $report build's return value. * @return array> */ public static function csv_matrix( array $report ) { $out = [ [ 'GuardForge monthly guard report' ], [ 'site', (string) $report['site'] ], [ 'month', (string) $report['month'] ], [ 'window_from', (string) $report['from'] ], [ 'window_to', (string) $report['to'] ], [ 'generated_at', (string) $report['generated_at'] ], [ 'data_as_of', (string) $report['data_as_of'] ], [], [ 'figure', 'count', 'source', 'window', 'completeness' ], ]; foreach ( (array) $report['figures'] as $key => $figure ) { $out[] = [ $key, (int) $figure['count'], $figure['source'], $figure['window'], $figure['completeness'] ]; } $out[] = []; $out[] = [ 'case_id', 'type', 'status', 'opened', 'last_change', 'basis_recorded', 'last_recheck', 'counted_as' ]; foreach ( (array) $report['cases'] as $row ) { $out[] = array_values( $row ); } return $out; } /** @return bool Whether a mysql datetime falls in [$from, $to). Unparseable → false. */ private static function in_window( $mysql, $from, $to ) { if ( ! preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/', (string) $mysql ) ) { return false; } $at = substr( (string) $mysql, 0, 19 ); return $at >= $from && $at < $to; } /** @return string 'Y-m-d' prefix of a mysql date, or '' — never the input itself. */ private static function date_or_empty( $mysql ) { return preg_match( '/^(\d{4}-\d{2}-\d{2})/', (string) $mysql, $m ) ? $m[1] : ''; } /** @return string 'Y-m-d H:i:s' prefix of a mysql datetime, or ''. */ private static function date_time_or_empty( $mysql ) { return preg_match( '/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/', (string) $mysql, $m ) ? $m[1] : ''; } /** * The one identifying string in the report: which site it is about. * Host only, and only the characters a host name may contain — a report * handed to a client has to name the client's site, but it has no reason * to carry a URL's path, query or credentials, and it must not become a * hole through which an option value leaves the site. */ private static function plain_site( $url ) { $host = (string) wp_parse_url( trim( (string) $url ), PHP_URL_HOST ); if ( $host === '' ) { return ''; } return preg_match( '/^[A-Za-z0-9.\-]{1,253}$/', $host ) ? strtolower( $host ) : ''; } }