From 79475eacbac404d894f2fb1b46e81dfb7a8c0ff3 Mon Sep 17 00:00:00 2001 From: David Gaitan Date: Thu, 16 Jul 2026 21:11:32 -0600 Subject: [PATCH] improving evidences --- assets/app/components/FindingItem.jsx | 118 +++++- assets/app/styles/_server.scss | 71 ++++ src/Domain/Evidence.php | 378 ++++++++++++++++++ src/Domain/Finding.php | 9 +- .../CoreIntegrity/Checks/CoreFilesCheck.php | 3 +- .../Checks/SuspiciousFilesCheck.php | 27 +- .../Checks/VulnerabilityAdvisoryCheck.php | 31 +- .../CoreIntegrity/Checks/WpConfigCheck.php | 8 +- .../Checks/WpContentStructureCheck.php | 3 +- .../Checks/AutoloadedOptionsCheck.php | 3 +- .../Checks/SuspiciousContentCheck.php | 8 +- .../Headers/Checks/CookieSecurityCheck.php | 10 +- src/Modules/Headers/Checks/HstsCheck.php | 5 +- .../Headers/Checks/SecurityHeadersCheck.php | 8 +- src/Modules/Headers/Checks/SriCheck.php | 3 +- .../Performance/Checks/CompressionCheck.php | 3 +- src/Modules/Performance/Checks/TtfbCheck.php | 5 +- .../Checks/InactivePluginsCheck.php | 3 +- .../Checks/OutdatedJsLibraryCheck.php | 10 +- .../Checks/PluginUpdatesCheck.php | 3 +- .../Seo/Checks/MetaDescriptionCheck.php | 3 +- src/Modules/Seo/Checks/PageTitleCheck.php | 8 +- src/Modules/Seo/Checks/RobotsTxtCheck.php | 3 +- src/Modules/Server/Checks/DiskSpaceCheck.php | 11 +- src/Modules/Server/Checks/HttpsCheck.php | 3 +- .../Server/Checks/HttpsRedirectCheck.php | 5 +- .../Server/Checks/MemoryLimitCheck.php | 8 +- .../Server/Checks/PhpExtensionsCheck.php | 3 +- src/Modules/Server/Checks/PhpVersionCheck.php | 8 +- .../Checks/TlsCertificateExpiryCheck.php | 14 +- src/Modules/Users/Checks/AdminCountCheck.php | 3 +- .../Users/Checks/DormantUsersCheck.php | 3 +- src/Persistence/FindingRepository.php | 3 +- tests/Unit/Domain/EvidenceTest.php | 203 ++++++++++ tests/Unit/Domain/FindingFromArrayTest.php | 7 +- tests/Unit/Domain/FindingTest.php | 12 +- .../Checks/CoreFilesCheckTest.php | 4 +- .../Checks/SuspiciousFilesCheckTest.php | 14 +- .../Checks/VulnerabilityAdvisoryCheckTest.php | 6 +- .../Checks/WpConfigCheckTest.php | 4 +- .../Checks/WpContentStructureCheckTest.php | 4 +- .../Checks/AutoloadedOptionsCheckTest.php | 2 +- .../Checks/SuspiciousContentCheckTest.php | 4 +- .../Checks/CookieSecurityCheckTest.php | 4 +- .../Modules/Headers/Checks/HstsCheckTest.php | 2 +- .../Checks/SecurityHeadersCheckTest.php | 10 +- .../Modules/Headers/Checks/SriCheckTest.php | 2 +- .../Performance/Checks/TtfbCheckTest.php | 2 +- .../Checks/InactivePluginsCheckTest.php | 4 +- .../Checks/OutdatedJsLibraryCheckTest.php | 8 +- .../Checks/PluginUpdatesCheckTest.php | 4 +- .../Modules/Seo/Checks/PageTitleCheckTest.php | 8 +- .../Modules/Seo/Checks/RobotsTxtCheckTest.php | 2 +- .../Server/Checks/DiskSpaceCheckTest.php | 2 +- .../Modules/Server/Checks/HttpsCheckTest.php | 4 +- .../Server/Checks/MemoryLimitCheckTest.php | 4 +- .../Server/Checks/PhpExtensionsCheckTest.php | 4 +- .../Server/Checks/PhpVersionCheckTest.php | 6 +- .../Users/Checks/AdminCountCheckTest.php | 4 +- .../Users/Checks/DormantUsersCheckTest.php | 4 +- .../Persistence/FindingRepositoryTest.php | 22 +- 61 files changed, 957 insertions(+), 183 deletions(-) create mode 100644 src/Domain/Evidence.php create mode 100644 tests/Unit/Domain/EvidenceTest.php diff --git a/assets/app/components/FindingItem.jsx b/assets/app/components/FindingItem.jsx index b1c0397..4f00a06 100644 --- a/assets/app/components/FindingItem.jsx +++ b/assets/app/components/FindingItem.jsx @@ -8,19 +8,111 @@ const STATUS_ICONS = { skipped: '–', }; -function renderEvidenceValue( value ) { - if ( Array.isArray( value ) ) { - return value.join( ', ' ) || __( '(empty)', 'wp-security' ); +function cellText( cell ) { + if ( cell === null || cell === undefined || cell === '' ) { + return '—'; } - if ( value !== null && typeof value === 'object' ) { - return JSON.stringify( value ); + if ( typeof cell === 'object' ) { + return JSON.stringify( cell ); + } + return String( cell ); +} + +function EvidenceValue( { type, value } ) { + switch ( type ) { + case 'empty': + return ( + + { __( 'None', 'wp-security' ) } + + ); + + case 'boolean': + return ( + + { value ? __( 'Yes', 'wp-security' ) : __( 'No', 'wp-security' ) } + + ); + + case 'list': + if ( ! Array.isArray( value ) || value.length === 0 ) { + return ( + + { __( 'None', 'wp-security' ) } + + ); + } + return ( + + ); + + case 'table': + if ( ! value || ! Array.isArray( value.rows ) || value.rows.length === 0 ) { + return ( + + { __( 'None', 'wp-security' ) } + + ); + } + return ( + + + + { value.columns.map( ( column ) => ( + + ) ) } + + + + { value.rows.map( ( row, index ) => ( + // eslint-disable-next-line react/no-array-index-key + + { value.columns.map( ( column ) => ( + + ) ) } + + ) ) } + +
{ column.label }
{ cellText( row[ column.key ] ) }
+ ); + + case 'group': + if ( ! Array.isArray( value ) || value.length === 0 ) { + return ( + + { __( 'None', 'wp-security' ) } + + ); + } + return ( +
+ { value.map( ( item ) => ( +
+
{ item.label }
+
+ +
+
+ ) ) } +
+ ); + + case 'scalar': + default: + return { cellText( value ) }; } - return String( value ?? '' ); } function EvidenceTable( { evidence } ) { - const entries = Object.entries( evidence ); - if ( entries.length === 0 ) { + if ( ! Array.isArray( evidence ) || evidence.length === 0 ) { return null; } @@ -31,11 +123,11 @@ function EvidenceTable( { evidence } ) { - { entries.map( ( [ key, value ] ) => ( - - + { evidence.map( ( item ) => ( + + ) ) } @@ -47,7 +139,7 @@ function EvidenceTable( { evidence } ) { export function FindingItem( { finding } ) { const hasEvidence = - finding.evidence && Object.keys( finding.evidence ).length > 0; + Array.isArray( finding.evidence ) && finding.evidence.length > 0; return (
  • diff --git a/assets/app/styles/_server.scss b/assets/app/styles/_server.scss index fb3d2df..443066e 100644 --- a/assets/app/styles/_server.scss +++ b/assets/app/styles/_server.scss @@ -143,6 +143,77 @@ word-break: break-all; } +.wpsec-finding__evidence-empty { + color: $wpsec-color-muted; + font-style: italic; +} + +.wpsec-finding__evidence-boolean { + display: inline-flex; + align-items: center; + padding: 1px $wpsec-spacing-sm; + border-radius: $wpsec-radius-sm; + font-size: $wpsec-font-sm; + font-weight: 600; + + &[data-value='yes'] { background: $wpsec-color-primary-light; color: $wpsec-color-primary; } + &[data-value='no'] { background: $wpsec-color-bg; color: $wpsec-color-muted; } +} + +.wpsec-finding__evidence-list { + margin: 0; + padding-left: $wpsec-spacing-md; + list-style: disc; + + li { + padding: 1px 0; + } +} + +.wpsec-finding__evidence-subtable { + width: 100%; + border-collapse: collapse; + font-size: $wpsec-font-sm; + + th, + td { + padding: 3px $wpsec-spacing-sm 3px 0; + text-align: left; + vertical-align: top; + } + + th { + color: $wpsec-color-text; + font-weight: 600; + border-bottom: 1px solid $wpsec-color-border; + } + + td { + color: $wpsec-color-muted; + } +} + +.wpsec-finding__evidence-group { + margin: 0; +} + +.wpsec-finding__evidence-group-row { + display: flex; + gap: $wpsec-spacing-sm; + padding: 1px 0; + + dt { + font-weight: 600; + color: $wpsec-color-text; + white-space: nowrap; + } + + dd { + margin: 0; + color: $wpsec-color-muted; + } +} + // Empty / error states. .wpsec-server__empty { color: $wpsec-color-muted; diff --git a/src/Domain/Evidence.php b/src/Domain/Evidence.php new file mode 100644 index 0000000..f4418d6 --- /dev/null +++ b/src/Domain/Evidence.php @@ -0,0 +1,378 @@ +add( 'max_age', $maxAge ); + * + * Each call to add() inspects the given value and standardizes it into one + * of a small set of display types (empty/boolean/scalar/list/table/group) + * plus a humanized label, so the React admin UI can render by type instead + * of guessing a raw PHP array's shape at display time. This is the single + * place that decides how evidence looks — Finding::toArray(), + * FindingRepository::mapRow(), and FindingItem.jsx all consume its output + * rather than re-deriving it. + */ +final class Evidence implements \JsonSerializable { + + private const ACRONYMS = [ + 'php', + 'url', + 'sql', + 'id', + 'cve', + 'csp', + 'sri', + 'hsts', + 'tls', + 'ssl', + 'ttfb', + 'wp', + 'http', + 'https', + 'cdn', + 'xml', + 'rss', + ]; + + private const SMALL_WORDS = [ 'in', 'of', 'and', 'or', 'the', 'to', 'a', 'an' ]; + + private const UNIT_DISPLAY = [ + 'ms' => 'ms', + 'mb' => 'MB', + 'kb' => 'KB', + 'bytes' => 'bytes', + 'count' => 'count', + ]; + + /** @var array */ + private array $items = []; + + /** + * The original, pre-normalization value passed to add() for each key — + * kept separate from $items because normalization is lossy by design + * (e.g. a table's rows are reshaped into {columns, rows} for display, + * and an empty array becomes null). get() returns this raw form so + * Check/test code can read back exactly what was written. + * + * @var array + */ + private array $rawValues = []; + + /** + * Adds one evidence entry, inspecting $value to decide its display type. + */ + public function add( string $key, mixed $value, ?string $label = null ): self { + $this->items[] = self::buildItem( $key, $value, $label ); + $this->rawValues[ $key ] = $value; + return $this; + } + + public function isEmpty(): bool { + return [] === $this->items; + } + + public function has( string $key ): bool { + return array_key_exists( $key, $this->rawValues ); + } + + /** + * Raw value lookup by key — mainly useful for tests and internal Check logic. + */ + public function get( string $key ): mixed { + return $this->rawValues[ $key ] ?? null; + } + + /** + * @return array + */ + public function toArray(): array { + return $this->items; + } + + /** + * @return array + */ + public function jsonSerialize(): array { + return $this->items; + } + + /** + * Single normalization entry point. Accepts an Evidence instance + * (returned as-is), an already-normalized item list (round-tripped from + * storage or from another Evidence's toArray()), a raw associative array + * (legacy shape, or a REST-submitted evidence payload), or a bare + * scalar — and always returns a valid Evidence. + */ + public static function from( mixed $raw ): self { + if ( $raw instanceof self ) { + return $raw; + } + + if ( is_array( $raw ) && self::looksNormalized( $raw ) ) { + $evidence = new self(); + foreach ( $raw as $item ) { + if ( ! is_array( $item ) || ! isset( $item['key'] ) ) { + continue; + } + $key = (string) $item['key']; + $value = $item['value'] ?? null; + $evidence->items[] = [ + 'key' => $key, + 'label' => isset( $item['label'] ) ? (string) $item['label'] : self::humanize( $key ), + 'type' => isset( $item['type'] ) ? (string) $item['type'] : self::detectType( $value ), + 'value' => $value, + ]; + // The true pre-normalization value isn't recoverable from an + // already-normalized item, so get() falls back to the + // normalized value here — acceptable since this path is only + // hit when reconstructing evidence for display, not by Check + // authors calling add() directly. + $evidence->rawValues[ $key ] = $value; + } + return $evidence; + } + + if ( is_array( $raw ) ) { + $evidence = new self(); + foreach ( $raw as $key => $value ) { + $evidence->add( (string) $key, $value ); + } + return $evidence; + } + + if ( null === $raw ) { + return new self(); + } + + return ( new self() )->add( 'value', $raw ); + } + + /** + * A raw evidence array is "normalized" when it's a list of arrays that + * each already carry a 'key' entry — the shape produced by toArray()/ + * jsonSerialize(). A Check-authored raw array is keyed by string names + * (e.g. 'missing_sri'), so array_is_list() is false for it. + * + * @param array $raw + */ + private static function looksNormalized( array $raw ): bool { + if ( ! array_is_list( $raw ) ) { + return false; + } + + if ( [] === $raw ) { + return false; + } + + foreach ( $raw as $item ) { + if ( ! is_array( $item ) || ! isset( $item['key'] ) ) { + return false; + } + } + + return true; + } + + /** + * @return array{key:string,label:string,type:string,value:mixed} + */ + private static function buildItem( string $key, mixed $value, ?string $label ): array { + if ( is_object( $value ) ) { + $decoded = json_decode( (string) wp_json_encode( $value ), true ); + $value = is_array( $decoded ) ? $decoded : (array) $value; + } + + $type = self::detectType( $value ); + + return [ + 'key' => $key, + 'label' => $label ?? self::humanize( $key ), + 'type' => $type, + 'value' => self::normalizeValue( $value, $type ), + ]; + } + + private static function detectType( mixed $value ): string { + if ( null === $value ) { + return 'empty'; + } + + if ( is_bool( $value ) ) { + return 'boolean'; + } + + if ( is_scalar( $value ) ) { + return 'scalar'; + } + + if ( ! is_array( $value ) ) { + return 'empty'; + } + + if ( [] === $value ) { + return 'empty'; + } + + if ( array_is_list( $value ) ) { + $allArrays = true; + $allScalars = true; + + foreach ( $value as $element ) { + if ( is_array( $element ) ) { + $allScalars = false; + } else { + $allArrays = false; + } + } + + if ( $allArrays ) { + return 'table'; + } + + if ( $allScalars ) { + return 'list'; + } + + // Mixed list of scalars and arrays — still displayable as a list, + // with any array elements stringified defensively at render time. + return 'list'; + } + + return 'group'; + } + + private static function normalizeValue( mixed $value, string $type ): mixed { + return match ( $type ) { + 'empty' => null, + 'boolean' => (bool) $value, + 'scalar' => $value, + 'list' => self::normalizeList( is_array( $value ) ? $value : [] ), + 'table' => self::normalizeTable( is_array( $value ) ? $value : [] ), + 'group' => self::normalizeGroup( is_array( $value ) ? $value : [] ), + default => $value, + }; + } + + /** + * @param array $value + * @return array + */ + private static function normalizeList( array $value ): array { + return array_values( + array_map( + static function ( mixed $element ): mixed { + if ( is_scalar( $element ) || null === $element ) { + return $element; + } + return wp_json_encode( $element ); + }, + $value + ) + ); + } + + /** + * @param array> $rows + * @return array{columns: array, rows: array>} + */ + private static function normalizeTable( array $rows ): array { + $columnKeys = []; + foreach ( $rows as $row ) { + foreach ( array_keys( $row ) as $columnKey ) { + $columnKey = (string) $columnKey; + if ( ! in_array( $columnKey, $columnKeys, true ) ) { + $columnKeys[] = $columnKey; + } + } + } + + $columns = array_map( + static fn ( string $columnKey ): array => [ + 'key' => $columnKey, + 'label' => self::humanize( $columnKey ), + ], + $columnKeys + ); + + $normalizedRows = array_map( + static function ( array $row ) use ( $columnKeys ): array { + $out = []; + foreach ( $columnKeys as $columnKey ) { + $cell = $row[ $columnKey ] ?? null; + $out[ $columnKey ] = is_scalar( $cell ) || null === $cell ? $cell : wp_json_encode( $cell ); + } + return $out; + }, + $rows + ); + + return [ + 'columns' => $columns, + 'rows' => array_values( $normalizedRows ), + ]; + } + + /** + * @param array $value + * @return array + */ + private static function normalizeGroup( array $value ): array { + $items = []; + foreach ( $value as $key => $nestedValue ) { + $items[] = self::buildItem( (string) $key, $nestedValue, null ); + } + return $items; + } + + /** + * Converts a snake_case key into an acronym-aware, unit-suffix-aware label. + * + * Examples: php_in_uploads -> "PHP in Uploads", ttfb_ms -> "TTFB (ms)", + * autoloaded_size_bytes -> "Autoloaded Size (bytes)". + */ + private static function humanize( string $key ): string { + $words = array_values( array_filter( explode( '_', $key ), static fn ( string $word ): bool => '' !== $word ) ); + + if ( [] === $words ) { + return $key; + } + + $unitSuffix = null; + if ( count( $words ) > 1 ) { + $lastLower = strtolower( (string) end( $words ) ); + if ( isset( self::UNIT_DISPLAY[ $lastLower ] ) ) { + $unitSuffix = self::UNIT_DISPLAY[ $lastLower ]; + array_pop( $words ); + } + } + + $formatted = implode( + ' ', + array_map( + static function ( string $word ): string { + $lower = strtolower( $word ); + if ( in_array( $lower, self::ACRONYMS, true ) ) { + return strtoupper( $word ); + } + if ( in_array( $lower, self::SMALL_WORDS, true ) ) { + return $lower; + } + return ucfirst( $lower ); + }, + $words + ) + ); + + $formatted = ucfirst( $formatted ); + + return null !== $unitSuffix ? sprintf( '%s (%s)', $formatted, $unitSuffix ) : $formatted; + } +} diff --git a/src/Domain/Finding.php b/src/Domain/Finding.php index 4945745..076d767 100644 --- a/src/Domain/Finding.php +++ b/src/Domain/Finding.php @@ -23,7 +23,7 @@ final class Finding { * @param string $title Short headline shown in the UI. * @param string $description Plain-language explanation of what was found. * @param string $recommendation Concrete action the site owner should take. - * @param array $evidence Structured detail surfaced in the evidence table. + * @param Evidence $evidence Structured detail surfaced in the evidence table. * @param string|null $docsUrl Link to further documentation. */ public function __construct( @@ -33,7 +33,7 @@ public function __construct( public readonly string $title, public readonly string $description, public readonly string $recommendation, - public readonly array $evidence = [], + public readonly Evidence $evidence = new Evidence(), public readonly ?string $docsUrl = null, ) {} @@ -64,7 +64,7 @@ public function toArray(): array { 'title' => $this->title, 'description' => $this->description, 'recommendation' => $this->recommendation, - 'evidence' => $this->evidence, + 'evidence' => $this->evidence->toArray(), 'docs_url' => $this->docsUrl, ]; } @@ -93,8 +93,7 @@ public static function pass( string $checkId, string $title, string $description * @param array $data */ public static function fromArray( array $data ): self { - /** @var array $evidence */ - $evidence = is_array( $data['evidence'] ?? null ) ? $data['evidence'] : []; + $evidence = Evidence::from( $data['evidence'] ?? null ); $docsUrl = $data['docs_url'] ?? null; return new self( diff --git a/src/Modules/CoreIntegrity/Checks/CoreFilesCheck.php b/src/Modules/CoreIntegrity/Checks/CoreFilesCheck.php index 9363a2c..2e06401 100644 --- a/src/Modules/CoreIntegrity/Checks/CoreFilesCheck.php +++ b/src/Modules/CoreIntegrity/Checks/CoreFilesCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -96,7 +97,7 @@ public function run( Context $context ): Finding { $count ), recommendation: __( 'Reinstall WordPress core via Dashboard → Updates or WP-CLI ("wp core download --force"). Investigate the changes — they may indicate malware or tampering.', 'wp-security' ), - evidence: [ 'modified_files' => $modified ], + evidence: ( new Evidence() )->add( 'modified_files', $modified ), ); } } diff --git a/src/Modules/CoreIntegrity/Checks/SuspiciousFilesCheck.php b/src/Modules/CoreIntegrity/Checks/SuspiciousFilesCheck.php index fb604eb..3b57cca 100644 --- a/src/Modules/CoreIntegrity/Checks/SuspiciousFilesCheck.php +++ b/src/Modules/CoreIntegrity/Checks/SuspiciousFilesCheck.php @@ -9,6 +9,7 @@ use UnexpectedValueException; use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -278,28 +279,18 @@ private function resolveSeverity( array $found ): Severity { } /** - * Build the evidence array, omitting empty categories. + * Build the evidence, omitting empty categories. * * @param array{php_in_uploads: list, blacklisted: list, double_extension: list, theme_violations: list} $found - * @return array> */ - private function buildEvidence( array $found ): array { - $evidence = []; - - if ( [] !== $found['php_in_uploads'] ) { - $evidence['php_in_uploads'] = $found['php_in_uploads']; - } - - if ( [] !== $found['blacklisted'] ) { - $evidence['blacklisted'] = $found['blacklisted']; - } - - if ( [] !== $found['double_extension'] ) { - $evidence['double_extension'] = $found['double_extension']; - } + private function buildEvidence( array $found ): Evidence { + $evidence = new Evidence(); + foreach ( array_keys( $found ) as $category ) { + if ( [] === $found[ $category ] ) { + continue; + } - if ( [] !== $found['theme_violations'] ) { - $evidence['theme_violations'] = $found['theme_violations']; + $evidence->add( $category, $found[ $category ] ); } return $evidence; diff --git a/src/Modules/CoreIntegrity/Checks/VulnerabilityAdvisoryCheck.php b/src/Modules/CoreIntegrity/Checks/VulnerabilityAdvisoryCheck.php index 7e91c3f..d3f377c 100644 --- a/src/Modules/CoreIntegrity/Checks/VulnerabilityAdvisoryCheck.php +++ b/src/Modules/CoreIntegrity/Checks/VulnerabilityAdvisoryCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -108,21 +109,23 @@ public function run( Context $context ): Finding { title: $this->label(), description: $message, recommendation: __( 'Update or remove the affected plugins, themes, or WordPress core version immediately to eliminate known CVEs.', 'wp-security' ), - evidence: [ - 'vulnerability_count' => $count, - 'advisories' => array_map( - static fn( $a ) => [ - 'id' => $a->id, - 'title' => $a->title, - 'severity' => $a->severity, - 'type' => $a->type, - 'slug' => $a->slug, - 'fixed_in' => $a->fixedIn, - 'cve_id' => $a->cveId, - ], - $advisories + evidence: ( new Evidence() ) + ->add( 'vulnerability_count', $count ) + ->add( + 'advisories', + array_map( + static fn( $a ) => [ + 'id' => $a->id, + 'title' => $a->title, + 'severity' => $a->severity, + 'type' => $a->type, + 'slug' => $a->slug, + 'fixed_in' => $a->fixedIn, + 'cve_id' => $a->cveId, + ], + $advisories + ) ), - ], ); } } diff --git a/src/Modules/CoreIntegrity/Checks/WpConfigCheck.php b/src/Modules/CoreIntegrity/Checks/WpConfigCheck.php index 76837e1..a57e08a 100644 --- a/src/Modules/CoreIntegrity/Checks/WpConfigCheck.php +++ b/src/Modules/CoreIntegrity/Checks/WpConfigCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -55,10 +56,9 @@ public function run( Context $context ): Finding { title: $this->label(), description: implode( '; ', $issues ) . '.', recommendation: __( 'Add "define( \'DISALLOW_FILE_EDIT\', true );" and confirm "define( \'WP_DEBUG\', false );" in wp-config.php for production.', 'wp-security' ), - evidence: [ - 'disallow_file_edit' => $disallowFileEdit, - 'wp_debug' => $wpDebug, - ], + evidence: ( new Evidence() ) + ->add( 'disallow_file_edit', $disallowFileEdit ) + ->add( 'wp_debug', $wpDebug ), ); } } diff --git a/src/Modules/CoreIntegrity/Checks/WpContentStructureCheck.php b/src/Modules/CoreIntegrity/Checks/WpContentStructureCheck.php index 39dec38..f77bfc2 100644 --- a/src/Modules/CoreIntegrity/Checks/WpContentStructureCheck.php +++ b/src/Modules/CoreIntegrity/Checks/WpContentStructureCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -73,7 +74,7 @@ public function run( Context $context ): Finding { implode( ', ', $missing ) ), recommendation: __( 'Ensure your WordPress installation has the standard wp-content/plugins, wp-content/themes, and wp-content/uploads directories. Their absence may indicate a misconfiguration or a directory that has been renamed or removed.', 'wp-security' ), - evidence: [ 'missing_directories' => $missing ], + evidence: ( new Evidence() )->add( 'missing_directories', $missing ), ); } } diff --git a/src/Modules/Database/Checks/AutoloadedOptionsCheck.php b/src/Modules/Database/Checks/AutoloadedOptionsCheck.php index f038f32..7f25b33 100644 --- a/src/Modules/Database/Checks/AutoloadedOptionsCheck.php +++ b/src/Modules/Database/Checks/AutoloadedOptionsCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -64,7 +65,7 @@ public function run( Context $context ): Finding { $this->formatBytes( $sizeBytes ) ), recommendation: __( 'Review and remove unnecessary autoloaded options. Run SELECT option_name, LENGTH(option_value) FROM wp_options WHERE autoload="yes" ORDER BY 2 DESC to identify the largest contributors.', 'wp-security' ), - evidence: [ 'autoloaded_size_bytes' => $sizeBytes ], + evidence: ( new Evidence() )->add( 'autoloaded_size_bytes', $sizeBytes ), ); } diff --git a/src/Modules/Database/Checks/SuspiciousContentCheck.php b/src/Modules/Database/Checks/SuspiciousContentCheck.php index 0542369..abb5c89 100644 --- a/src/Modules/Database/Checks/SuspiciousContentCheck.php +++ b/src/Modules/Database/Checks/SuspiciousContentCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -62,10 +63,9 @@ public function run( Context $context ): Finding { $postCount ), recommendation: __( 'Investigate these database entries immediately — they may indicate a malware injection. Compare against a known-good backup and restore from a clean snapshot if malware is confirmed.', 'wp-security' ), - evidence: [ - 'suspicious_option_count' => $optionCount, - 'suspicious_post_count' => $postCount, - ], + evidence: ( new Evidence() ) + ->add( 'suspicious_option_count', $optionCount ) + ->add( 'suspicious_post_count', $postCount ), ); } } diff --git a/src/Modules/Headers/Checks/CookieSecurityCheck.php b/src/Modules/Headers/Checks/CookieSecurityCheck.php index ec109d6..dd3734e 100644 --- a/src/Modules/Headers/Checks/CookieSecurityCheck.php +++ b/src/Modules/Headers/Checks/CookieSecurityCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -79,10 +80,9 @@ public function run( Context $context ): Finding { implode( ', ', $insecureCookies ) ), recommendation: __( 'Set the Secure and HttpOnly attributes on all cookies so they cannot be transmitted over plain HTTP or read by JavaScript.', 'wp-security' ), - evidence: [ - 'insecure_cookies' => $insecureCookies, - 'weak_samesite' => $weakSameSite, - ], + evidence: ( new Evidence() ) + ->add( 'insecure_cookies', $insecureCookies ) + ->add( 'weak_samesite', $weakSameSite ), ); } @@ -98,7 +98,7 @@ public function run( Context $context ): Finding { implode( ', ', $weakSameSite ) ), recommendation: __( 'Set SameSite=Strict or SameSite=Lax on cookies that do not need cross-site delivery.', 'wp-security' ), - evidence: [ 'weak_samesite' => $weakSameSite ], + evidence: ( new Evidence() )->add( 'weak_samesite', $weakSameSite ), ); } diff --git a/src/Modules/Headers/Checks/HstsCheck.php b/src/Modules/Headers/Checks/HstsCheck.php index 7ae5c20..089f00f 100644 --- a/src/Modules/Headers/Checks/HstsCheck.php +++ b/src/Modules/Headers/Checks/HstsCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -62,7 +63,7 @@ public function run( Context $context ): Finding { title: $this->label(), description: __( 'The Strict-Transport-Security header is present but its max-age is missing or zero, which instructs browsers to stop enforcing HTTPS.', 'wp-security' ), recommendation: __( 'Set "Strict-Transport-Security: max-age=31536000; includeSubDomains" with a max-age of at least 6 months.', 'wp-security' ), - evidence: [ 'max_age' => $maxAge ], + evidence: ( new Evidence() )->add( 'max_age', $maxAge ), ); } @@ -78,7 +79,7 @@ public function run( Context $context ): Finding { $maxAge ), recommendation: __( 'Increase the HSTS max-age to at least 15552000 seconds (6 months).', 'wp-security' ), - evidence: [ 'max_age' => $maxAge ], + evidence: ( new Evidence() )->add( 'max_age', $maxAge ), ); } diff --git a/src/Modules/Headers/Checks/SecurityHeadersCheck.php b/src/Modules/Headers/Checks/SecurityHeadersCheck.php index 30791fe..db03df4 100644 --- a/src/Modules/Headers/Checks/SecurityHeadersCheck.php +++ b/src/Modules/Headers/Checks/SecurityHeadersCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -112,10 +113,9 @@ public function run( Context $context ): Finding { title: $this->label(), description: implode( ' ', $descriptionParts ), recommendation: __( 'Configure your web server or CDN to include the missing security headers, and tighten the Content-Security-Policy to remove unsafe-inline, unsafe-eval, and wildcard sources.', 'wp-security' ), - evidence: [ - 'missing' => $missing, - 'csp_weaknesses' => $cspWeaknesses, - ], + evidence: ( new Evidence() ) + ->add( 'missing', $missing ) + ->add( 'csp_weaknesses', $cspWeaknesses ), ); } diff --git a/src/Modules/Headers/Checks/SriCheck.php b/src/Modules/Headers/Checks/SriCheck.php index f48cee5..87cb847 100644 --- a/src/Modules/Headers/Checks/SriCheck.php +++ b/src/Modules/Headers/Checks/SriCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -76,7 +77,7 @@ public function run( Context $context ): Finding { count( $missingSri ) ), recommendation: __( 'Add an integrity attribute (and crossorigin="anonymous") to every externally-hosted script tag and stylesheet link tag.', 'wp-security' ), - evidence: [ 'missing_sri' => $missingSri ], + evidence: ( new Evidence() )->add( 'missing_sri', $missingSri ), ); } diff --git a/src/Modules/Performance/Checks/CompressionCheck.php b/src/Modules/Performance/Checks/CompressionCheck.php index 2b32f04..37aec95 100644 --- a/src/Modules/Performance/Checks/CompressionCheck.php +++ b/src/Modules/Performance/Checks/CompressionCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -56,7 +57,7 @@ public function run( Context $context ): Finding { title: $this->label(), description: __( 'HTTP compression (GZIP or Brotli) is not enabled.', 'wp-security' ), recommendation: __( 'Enable GZIP or Brotli compression in your server or via a caching plugin to reduce page weight and improve load time.', 'wp-security' ), - evidence: [ 'content_encoding' => '' === $encoding ? 'none' : $encoding ], + evidence: ( new Evidence() )->add( 'content_encoding', '' === $encoding ? 'none' : $encoding ), ); } } diff --git a/src/Modules/Performance/Checks/TtfbCheck.php b/src/Modules/Performance/Checks/TtfbCheck.php index 2196051..186c7bd 100644 --- a/src/Modules/Performance/Checks/TtfbCheck.php +++ b/src/Modules/Performance/Checks/TtfbCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -49,7 +50,7 @@ public function run( Context $context ): Finding { /* translators: %s: TTFB in milliseconds */ description: sprintf( __( 'TTFB is %.0f ms — well above the 800 ms threshold. Users experience slow initial load.', 'wp-security' ), $ms ), recommendation: __( 'Investigate slow response times: check for slow database queries, unoptimised PHP, or missing page/object caching.', 'wp-security' ), - evidence: [ 'ttfb_ms' => $ms ], + evidence: ( new Evidence() )->add( 'ttfb_ms', $ms ), ); } @@ -62,7 +63,7 @@ public function run( Context $context ): Finding { /* translators: %s: TTFB in milliseconds */ description: sprintf( __( 'TTFB is %.0f ms, above the recommended 200 ms target.', 'wp-security' ), $ms ), recommendation: __( 'Enable page caching, object caching (Redis/Memcached), or a CDN to reduce TTFB.', 'wp-security' ), - evidence: [ 'ttfb_ms' => $ms ], + evidence: ( new Evidence() )->add( 'ttfb_ms', $ms ), ); } diff --git a/src/Modules/PluginsThemes/Checks/InactivePluginsCheck.php b/src/Modules/PluginsThemes/Checks/InactivePluginsCheck.php index c8863cb..09ed9eb 100644 --- a/src/Modules/PluginsThemes/Checks/InactivePluginsCheck.php +++ b/src/Modules/PluginsThemes/Checks/InactivePluginsCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -71,7 +72,7 @@ public function run( Context $context ): Finding { title: $this->label(), description: $message, recommendation: __( 'Remove inactive plugins to reduce your attack surface. Even deactivated plugins can be exploited if they contain vulnerabilities, because their files are still accessible on disk.', 'wp-security' ), - evidence: [ 'inactive_plugins' => $inactive ], + evidence: ( new Evidence() )->add( 'inactive_plugins', $inactive ), ); } } diff --git a/src/Modules/PluginsThemes/Checks/OutdatedJsLibraryCheck.php b/src/Modules/PluginsThemes/Checks/OutdatedJsLibraryCheck.php index 34ca4c4..3a046a5 100644 --- a/src/Modules/PluginsThemes/Checks/OutdatedJsLibraryCheck.php +++ b/src/Modules/PluginsThemes/Checks/OutdatedJsLibraryCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -130,10 +131,9 @@ public function run( Context $context ): Finding { $names ), recommendation: __( 'Update the flagged JavaScript libraries to a patched version.', 'wp-security' ), - evidence: [ - 'outdated' => $outdated, - 'version_unknown' => $versionUnknown, - ], + evidence: ( new Evidence() ) + ->add( 'outdated', $outdated ) + ->add( 'version_unknown', $versionUnknown ), ); } @@ -145,7 +145,7 @@ public function run( Context $context ): Finding { title: $this->label(), description: __( 'No known-vulnerable JavaScript library versions were detected. Some recognized libraries had no parseable version string and could not be fully verified.', 'wp-security' ), recommendation: '', - evidence: [ 'version_unknown' => $versionUnknown ], + evidence: ( new Evidence() )->add( 'version_unknown', $versionUnknown ), ); } diff --git a/src/Modules/PluginsThemes/Checks/PluginUpdatesCheck.php b/src/Modules/PluginsThemes/Checks/PluginUpdatesCheck.php index d5d2037..c040995 100644 --- a/src/Modules/PluginsThemes/Checks/PluginUpdatesCheck.php +++ b/src/Modules/PluginsThemes/Checks/PluginUpdatesCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -62,7 +63,7 @@ public function run( Context $context ): Finding { title: $this->label(), description: $message, recommendation: __( 'Update all plugins promptly to patch known security vulnerabilities. Attackers scan for unpatched versions shortly after CVEs are published.', 'wp-security' ), - evidence: [ 'plugins_needing_update' => $slugs ], + evidence: ( new Evidence() )->add( 'plugins_needing_update', $slugs ), ); } } diff --git a/src/Modules/Seo/Checks/MetaDescriptionCheck.php b/src/Modules/Seo/Checks/MetaDescriptionCheck.php index 292ecd0..c25acca 100644 --- a/src/Modules/Seo/Checks/MetaDescriptionCheck.php +++ b/src/Modules/Seo/Checks/MetaDescriptionCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -64,7 +65,7 @@ public function run( Context $context ): Finding { /* translators: %d: character count */ description: sprintf( __( 'Meta description is %d characters. Recommended: 50–160 characters.', 'wp-security' ), $length ), recommendation: __( 'Adjust the meta description to 50–160 characters for best search-engine display.', 'wp-security' ), - evidence: [ 'length' => $length ], + evidence: ( new Evidence() )->add( 'length', $length ), ); } diff --git a/src/Modules/Seo/Checks/PageTitleCheck.php b/src/Modules/Seo/Checks/PageTitleCheck.php index f181d6c..c7105ae 100644 --- a/src/Modules/Seo/Checks/PageTitleCheck.php +++ b/src/Modules/Seo/Checks/PageTitleCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -59,10 +60,9 @@ public function run( Context $context ): Finding { /* translators: %d: character count */ description: sprintf( __( 'Page title is %d characters long. Recommended length is 10–70 characters.', 'wp-security' ), $length ), recommendation: __( 'Adjust your page title to 10–70 characters for best search-engine visibility.', 'wp-security' ), - evidence: [ - 'title' => $titleText, - 'length' => $length, - ], + evidence: ( new Evidence() ) + ->add( 'title', $titleText ) + ->add( 'length', $length ), ); } diff --git a/src/Modules/Seo/Checks/RobotsTxtCheck.php b/src/Modules/Seo/Checks/RobotsTxtCheck.php index 3a4576f..66ca356 100644 --- a/src/Modules/Seo/Checks/RobotsTxtCheck.php +++ b/src/Modules/Seo/Checks/RobotsTxtCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -48,7 +49,7 @@ public function run( Context $context ): Finding { /* translators: %d: HTTP status code */ description: sprintf( __( 'robots.txt returned HTTP %d. Search engines expect a 200 response.', 'wp-security' ), $status ), recommendation: __( 'Ensure a valid robots.txt file is accessible at the root of your domain.', 'wp-security' ), - evidence: [ 'http_status' => $status ], + evidence: ( new Evidence() )->add( 'http_status', $status ), ); } } diff --git a/src/Modules/Server/Checks/DiskSpaceCheck.php b/src/Modules/Server/Checks/DiskSpaceCheck.php index cd35fbe..69c6798 100644 --- a/src/Modules/Server/Checks/DiskSpaceCheck.php +++ b/src/Modules/Server/Checks/DiskSpaceCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -45,10 +46,10 @@ public function run( Context $context ): Finding { $freeBytes = (int) $free; $totalBytes = null !== $total && false !== $total ? (int) $total : 0; - $evidence = [ - 'free_mb' => round( $freeBytes / ( 1024 * 1024 ), 1 ), - 'total_mb' => $totalBytes > 0 ? round( $totalBytes / ( 1024 * 1024 ), 1 ) : null, - ]; + $freeMb = round( $freeBytes / ( 1024 * 1024 ), 1 ); + $evidence = ( new Evidence() ) + ->add( 'free_mb', $freeMb ) + ->add( 'total_mb', $totalBytes > 0 ? round( $totalBytes / ( 1024 * 1024 ), 1 ) : null ); if ( $freeBytes < self::CRITICAL_BYTES ) { return new Finding( @@ -80,7 +81,7 @@ public function run( Context $context ): Finding { sprintf( /* translators: %s: free disk space in MB */ __( '%s MB of free disk space available.', 'wp-security' ), - number_format( $evidence['free_mb'] ) + number_format( $freeMb ) ) ); } diff --git a/src/Modules/Server/Checks/HttpsCheck.php b/src/Modules/Server/Checks/HttpsCheck.php index 4ba1e03..92e1ad1 100644 --- a/src/Modules/Server/Checks/HttpsCheck.php +++ b/src/Modules/Server/Checks/HttpsCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -49,7 +50,7 @@ public function run( Context $context ): Finding { title: $this->label(), description: __( 'The site home URL is not using HTTPS. Traffic is transmitted in plain text.', 'wp-security' ), recommendation: __( 'Install a TLS certificate (e.g. via Let\'s Encrypt) and update the WordPress home URL and site URL to https://.', 'wp-security' ), - evidence: [ 'home_url' => $url ], + evidence: ( new Evidence() )->add( 'home_url', $url ), ); } } diff --git a/src/Modules/Server/Checks/HttpsRedirectCheck.php b/src/Modules/Server/Checks/HttpsRedirectCheck.php index e5a6597..b61c10f 100644 --- a/src/Modules/Server/Checks/HttpsRedirectCheck.php +++ b/src/Modules/Server/Checks/HttpsRedirectCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -64,7 +65,7 @@ public function run( Context $context ): Finding { title: $this->label(), description: __( 'The site does not fully redirect HTTP traffic to HTTPS.', 'wp-security' ), recommendation: __( 'Configure your web server or CDN to redirect all HTTP requests to HTTPS with a 301 response.', 'wp-security' ), - evidence: [ 'chain' => $chain ], + evidence: ( new Evidence() )->add( 'chain', $chain ), ); } @@ -83,7 +84,7 @@ public function run( Context $context ): Finding { title: $this->label(), description: __( 'The redirect chain to HTTPS passes through more than one unencrypted hop.', 'wp-security' ), recommendation: __( 'Ensure the first redirect from HTTP goes directly to HTTPS, avoiding intermediate plain-HTTP hops.', 'wp-security' ), - evidence: [ 'chain' => $chain ], + evidence: ( new Evidence() )->add( 'chain', $chain ), ); } diff --git a/src/Modules/Server/Checks/MemoryLimitCheck.php b/src/Modules/Server/Checks/MemoryLimitCheck.php index 34ba0af..7ee9b2d 100644 --- a/src/Modules/Server/Checks/MemoryLimitCheck.php +++ b/src/Modules/Server/Checks/MemoryLimitCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -56,10 +57,9 @@ public function run( Context $context ): Finding { /* translators: %s: memory limit string such as "32M" */ description: sprintf( __( 'PHP memory limit is %s, which is below the recommended 64 MB.', 'wp-security' ), $raw ), recommendation: __( 'Set memory_limit to at least 64M in php.ini or wp-config.php (define WP_MEMORY_LIMIT).', 'wp-security' ), - evidence: [ - 'current' => $raw, - 'recommended_minimum' => '64M', - ], + evidence: ( new Evidence() ) + ->add( 'current', $raw ) + ->add( 'recommended_minimum', '64M' ), ); } diff --git a/src/Modules/Server/Checks/PhpExtensionsCheck.php b/src/Modules/Server/Checks/PhpExtensionsCheck.php index 632aff9..a9f3efb 100644 --- a/src/Modules/Server/Checks/PhpExtensionsCheck.php +++ b/src/Modules/Server/Checks/PhpExtensionsCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -83,7 +84,7 @@ public function run( Context $context ): Finding { implode( ', ', array_keys( $missing ) ) ), recommendation: __( 'Contact your hosting provider and ask them to enable the missing extensions.', 'wp-security' ), - evidence: [ 'missing' => $missing ], + evidence: ( new Evidence() )->add( 'missing', $missing ), ); } } diff --git a/src/Modules/Server/Checks/PhpVersionCheck.php b/src/Modules/Server/Checks/PhpVersionCheck.php index 2f0b7bd..5f96002 100644 --- a/src/Modules/Server/Checks/PhpVersionCheck.php +++ b/src/Modules/Server/Checks/PhpVersionCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -52,10 +53,9 @@ public function run( Context $context ): Finding { /* translators: %s: PHP version number */ description: sprintf( __( 'PHP %s is no longer receiving security updates.', 'wp-security' ), $version ), recommendation: __( 'Upgrade to PHP 8.1 or later. Contact your hosting provider if you cannot upgrade independently.', 'wp-security' ), - evidence: [ - 'current' => $version, - 'minimum_secure' => self::MIN_SECURE, - ], + evidence: ( new Evidence() ) + ->add( 'current', $version ) + ->add( 'minimum_secure', self::MIN_SECURE ), ); } } diff --git a/src/Modules/Server/Checks/TlsCertificateExpiryCheck.php b/src/Modules/Server/Checks/TlsCertificateExpiryCheck.php index 8c3b9ee..a34c4c2 100644 --- a/src/Modules/Server/Checks/TlsCertificateExpiryCheck.php +++ b/src/Modules/Server/Checks/TlsCertificateExpiryCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -48,13 +49,12 @@ public function run( Context $context ): Finding { } $daysUntilExpiry = (int) ( $certificate['days_until_expiry'] ?? 0 ); - $evidence = [ - 'valid_to' => $certificate['valid_to'] ?? null, - 'days_until_expiry' => $daysUntilExpiry, - 'subject_cn' => $certificate['subject_cn'] ?? null, - 'issuer_cn' => $certificate['issuer_cn'] ?? null, - 'self_signed' => $certificate['self_signed'] ?? null, - ]; + $evidence = ( new Evidence() ) + ->add( 'valid_to', $certificate['valid_to'] ?? null ) + ->add( 'days_until_expiry', $daysUntilExpiry ) + ->add( 'subject_cn', $certificate['subject_cn'] ?? null ) + ->add( 'issuer_cn', $certificate['issuer_cn'] ?? null ) + ->add( 'self_signed', $certificate['self_signed'] ?? null ); if ( $daysUntilExpiry < 0 ) { return new Finding( diff --git a/src/Modules/Users/Checks/AdminCountCheck.php b/src/Modules/Users/Checks/AdminCountCheck.php index 02e3e04..e09a236 100644 --- a/src/Modules/Users/Checks/AdminCountCheck.php +++ b/src/Modules/Users/Checks/AdminCountCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -60,7 +61,7 @@ public function run( Context $context ): Finding { $adminCount ), recommendation: __( 'Review administrator accounts and downgrade any that do not require full administrator access to a lower capability role (Editor, Author, or Contributor).', 'wp-security' ), - evidence: [ 'admin_user_count' => $adminCount ], + evidence: ( new Evidence() )->add( 'admin_user_count', $adminCount ), ); } } diff --git a/src/Modules/Users/Checks/DormantUsersCheck.php b/src/Modules/Users/Checks/DormantUsersCheck.php index e9117db..e8cb2cb 100644 --- a/src/Modules/Users/Checks/DormantUsersCheck.php +++ b/src/Modules/Users/Checks/DormantUsersCheck.php @@ -6,6 +6,7 @@ use WPSecurity\Contracts\Check; use WPSecurity\Contracts\Context; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -62,7 +63,7 @@ public function run( Context $context ): Finding { title: $this->label(), description: $message, recommendation: __( 'Review dormant accounts and disable or delete any that are no longer needed. Dormant accounts are a common vector for unauthorized access if credentials have been compromised.', 'wp-security' ), - evidence: [ 'dormant_user_count' => $dormantCount ], + evidence: ( new Evidence() )->add( 'dormant_user_count', $dormantCount ), ); } } diff --git a/src/Persistence/FindingRepository.php b/src/Persistence/FindingRepository.php index 6431144..70c7418 100644 --- a/src/Persistence/FindingRepository.php +++ b/src/Persistence/FindingRepository.php @@ -4,6 +4,7 @@ namespace WPSecurity\Persistence; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use wpdb; @@ -131,7 +132,7 @@ private function mapRow( array $row ): array { $evidence = []; if ( ! empty( $row['evidence'] ) && is_string( $row['evidence'] ) ) { $decoded = json_decode( $row['evidence'], true ); - $evidence = is_array( $decoded ) ? $decoded : []; + $evidence = Evidence::from( is_array( $decoded ) ? $decoded : [] )->toArray(); } return [ diff --git a/tests/Unit/Domain/EvidenceTest.php b/tests/Unit/Domain/EvidenceTest.php new file mode 100644 index 0000000..2f3d881 --- /dev/null +++ b/tests/Unit/Domain/EvidenceTest.php @@ -0,0 +1,203 @@ + 100] + * When Evidence::from() is called + * Then it returns an Evidence with one inferred entry + * + * Scenario: Evidence::from() accepts an already-normalized item list + * Given the array form produced by toArray() + * When Evidence::from() is called + * Then it reconstructs the same entries without re-inferring types + * + * Scenario: Evidence::from() passes an Evidence instance through unchanged + * Given an existing Evidence instance + * When Evidence::from() is called with it + * Then the exact same instance is returned + * + * @package WPSecurity\Tests + */ + +declare( strict_types=1 ); + +namespace WPSecurity\Tests\Unit\Domain; + +use PHPUnit\Framework\TestCase; +use WPSecurity\Domain\Evidence; + +final class EvidenceTest extends TestCase { + + public function test_humanizes_acronym_key(): void { + $evidence = ( new Evidence() )->add( 'php_in_uploads', [ 'a.php' ] ); + + $this->assertSame( 'PHP in Uploads', $evidence->toArray()[0]['label'] ); + } + + public function test_humanizes_unit_suffixed_key(): void { + $evidence = ( new Evidence() )->add( 'ttfb_ms', 187 ); + $item = $evidence->toArray()[0]; + + $this->assertSame( 'TTFB (ms)', $item['label'] ); + $this->assertSame( 'scalar', $item['type'] ); + $this->assertSame( 187, $item['value'] ); + } + + public function test_null_and_empty_array_are_typed_empty(): void { + $evidence = ( new Evidence() ) + ->add( 'null_value', null ) + ->add( 'empty_value', [] ); + + $items = $evidence->toArray(); + + $this->assertSame( 'empty', $items[0]['type'] ); + $this->assertNull( $items[0]['value'] ); + $this->assertSame( 'empty', $items[1]['type'] ); + $this->assertNull( $items[1]['value'] ); + } + + public function test_boolean_value_is_typed_boolean(): void { + $evidence = ( new Evidence() )->add( 'wp_debug', true ); + $item = $evidence->toArray()[0]; + + $this->assertSame( 'boolean', $item['type'] ); + $this->assertTrue( $item['value'] ); + } + + public function test_list_of_scalars_is_typed_list(): void { + $slugs = [ 'woocommerce/woocommerce.php', 'wp-all-export/wp-all-export.php' ]; + $evidence = ( new Evidence() )->add( 'plugins_needing_update', $slugs ); + $item = $evidence->toArray()[0]; + + $this->assertSame( 'list', $item['type'] ); + $this->assertSame( $slugs, $item['value'] ); + $this->assertSame( 'Plugins Needing Update', $item['label'] ); + } + + public function test_list_of_records_is_typed_table_with_humanized_columns(): void { + $rows = [ + [ + 'id' => 1, + 'fixed_in' => '6.4.1', + ], + [ + 'id' => 2, + 'fixed_in' => '2.1.0', + ], + ]; + + $evidence = ( new Evidence() )->add( 'advisories', $rows ); + $item = $evidence->toArray()[0]; + + $this->assertSame( 'table', $item['type'] ); + $this->assertSame( + [ + [ + 'key' => 'id', + 'label' => 'ID', + ], + [ + 'key' => 'fixed_in', + 'label' => 'Fixed in', + ], + ], + $item['value']['columns'] + ); + $this->assertSame( $rows, $item['value']['rows'] ); + } + + public function test_nested_assoc_array_is_typed_group(): void { + $evidence = ( new Evidence() )->add( + 'certificate', + [ + 'subject_cn' => 'example.test', + 'self_signed' => false, + ] + ); + $item = $evidence->toArray()[0]; + + $this->assertSame( 'group', $item['type'] ); + $this->assertCount( 2, $item['value'] ); + $this->assertSame( 'subject_cn', $item['value'][0]['key'] ); + $this->assertSame( 'scalar', $item['value'][0]['type'] ); + $this->assertSame( 'self_signed', $item['value'][1]['key'] ); + $this->assertSame( 'boolean', $item['value'][1]['type'] ); + } + + public function test_from_infers_types_from_raw_legacy_array(): void { + $evidence = Evidence::from( [ 'max_age' => 100 ] ); + $item = $evidence->toArray()[0]; + + $this->assertSame( 'max_age', $item['key'] ); + $this->assertSame( 'scalar', $item['type'] ); + $this->assertSame( 100, $item['value'] ); + } + + public function test_from_reconstructs_already_normalized_item_list(): void { + $original = ( new Evidence() )->add( 'max_age', 100 ); + + $rebuilt = Evidence::from( $original->toArray() ); + + $this->assertSame( $original->toArray(), $rebuilt->toArray() ); + } + + public function test_from_passes_through_an_evidence_instance(): void { + $original = ( new Evidence() )->add( 'max_age', 100 ); + + $this->assertSame( $original, Evidence::from( $original ) ); + } + + public function test_from_null_returns_empty_evidence(): void { + $this->assertTrue( Evidence::from( null )->isEmpty() ); + } + + public function test_get_and_has_look_up_raw_values(): void { + $evidence = ( new Evidence() )->add( 'max_age', 100 ); + + $this->assertTrue( $evidence->has( 'max_age' ) ); + $this->assertFalse( $evidence->has( 'missing' ) ); + $this->assertSame( 100, $evidence->get( 'max_age' ) ); + $this->assertNull( $evidence->get( 'missing' ) ); + } +} diff --git a/tests/Unit/Domain/FindingFromArrayTest.php b/tests/Unit/Domain/FindingFromArrayTest.php index 489a1e6..290e477 100644 --- a/tests/Unit/Domain/FindingFromArrayTest.php +++ b/tests/Unit/Domain/FindingFromArrayTest.php @@ -24,6 +24,7 @@ namespace WPSecurity\Tests\Unit\Domain; use PHPUnit\Framework\TestCase; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -38,7 +39,7 @@ public function test_round_trips_through_to_array(): void { 'Outdated PHP', 'PHP 7.4 is end-of-life.', 'Upgrade to PHP 8.1+.', - [ 'current' => '7.4.33' ], + ( new Evidence() )->add( 'current', '7.4.33' ), 'https://example.test/docs' ); @@ -48,7 +49,7 @@ public function test_round_trips_through_to_array(): void { $this->assertSame( $original->status, $rebuilt->status ); $this->assertSame( $original->severity, $rebuilt->severity ); $this->assertSame( $original->title, $rebuilt->title ); - $this->assertSame( $original->evidence, $rebuilt->evidence ); + $this->assertSame( $original->evidence->toArray(), $rebuilt->evidence->toArray() ); $this->assertSame( $original->docsUrl, $rebuilt->docsUrl ); $this->assertSame( $original->penalty(), $rebuilt->penalty() ); } @@ -70,6 +71,6 @@ public function test_rehydrates_persisted_row_shape(): void { $this->assertSame( Status::WARN, $rebuilt->status ); $this->assertSame( Severity::MEDIUM, $rebuilt->severity ); $this->assertNull( $rebuilt->docsUrl ); - $this->assertSame( [], $rebuilt->evidence ); + $this->assertTrue( $rebuilt->evidence->isEmpty() ); } } diff --git a/tests/Unit/Domain/FindingTest.php b/tests/Unit/Domain/FindingTest.php index cf60138..7846640 100644 --- a/tests/Unit/Domain/FindingTest.php +++ b/tests/Unit/Domain/FindingTest.php @@ -37,6 +37,7 @@ namespace WPSecurity\Tests\Unit\Domain; use PHPUnit\Framework\TestCase; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -92,7 +93,7 @@ public function test_to_array_uses_snake_case_keys(): void { 'PHP version', 'PHP is end of life.', 'Upgrade PHP.', - [ 'current' => '7.4' ], + ( new Evidence() )->add( 'current', '7.4' ), 'https://example.test/docs' ); @@ -104,7 +105,14 @@ public function test_to_array_uses_snake_case_keys(): void { 'title' => 'PHP version', 'description' => 'PHP is end of life.', 'recommendation' => 'Upgrade PHP.', - 'evidence' => [ 'current' => '7.4' ], + 'evidence' => [ + [ + 'key' => 'current', + 'label' => 'Current', + 'type' => 'scalar', + 'value' => '7.4', + ], + ], 'docs_url' => 'https://example.test/docs', ], $finding->toArray() diff --git a/tests/Unit/Modules/CoreIntegrity/Checks/CoreFilesCheckTest.php b/tests/Unit/Modules/CoreIntegrity/Checks/CoreFilesCheckTest.php index 9ea57e2..b97335e 100644 --- a/tests/Unit/Modules/CoreIntegrity/Checks/CoreFilesCheckTest.php +++ b/tests/Unit/Modules/CoreIntegrity/Checks/CoreFilesCheckTest.php @@ -119,8 +119,8 @@ public function test_modified_file_recorded_in_evidence(): void { unlink( $tmpFile ); - $this->assertArrayHasKey( 'modified_files', $finding->evidence ); - $this->assertContains( $filename, $finding->evidence['modified_files'] ); + $this->assertTrue( $finding->evidence->has( 'modified_files' ) ); + $this->assertContains( $filename, $finding->evidence->get( 'modified_files' ) ); } public function test_missing_core_file_is_not_flagged(): void { diff --git a/tests/Unit/Modules/CoreIntegrity/Checks/SuspiciousFilesCheckTest.php b/tests/Unit/Modules/CoreIntegrity/Checks/SuspiciousFilesCheckTest.php index f12a657..6b5b4aa 100644 --- a/tests/Unit/Modules/CoreIntegrity/Checks/SuspiciousFilesCheckTest.php +++ b/tests/Unit/Modules/CoreIntegrity/Checks/SuspiciousFilesCheckTest.php @@ -110,8 +110,8 @@ public function test_php_in_uploads_returns_fail_critical(): void { $this->assertSame( Status::FAIL, $finding->status ); $this->assertSame( Severity::CRITICAL, $finding->severity ); - $this->assertArrayHasKey( 'php_in_uploads', $finding->evidence ); - $this->assertStringContainsString( 'shell.php', implode( ' ', $finding->evidence['php_in_uploads'] ) ); + $this->assertTrue( $finding->evidence->has( 'php_in_uploads' ) ); + $this->assertStringContainsString( 'shell.php', implode( ' ', $finding->evidence->get( 'php_in_uploads' ) ) ); } public function test_blacklisted_filename_returns_fail_critical(): void { @@ -121,7 +121,7 @@ public function test_blacklisted_filename_returns_fail_critical(): void { $this->assertSame( Status::FAIL, $finding->status ); $this->assertSame( Severity::CRITICAL, $finding->severity ); - $this->assertArrayHasKey( 'blacklisted', $finding->evidence ); + $this->assertTrue( $finding->evidence->has( 'blacklisted' ) ); } public function test_blacklisted_extension_returns_fail_critical(): void { @@ -131,7 +131,7 @@ public function test_blacklisted_extension_returns_fail_critical(): void { $this->assertSame( Status::FAIL, $finding->status ); $this->assertSame( Severity::CRITICAL, $finding->severity ); - $this->assertArrayHasKey( 'blacklisted', $finding->evidence ); + $this->assertTrue( $finding->evidence->has( 'blacklisted' ) ); } public function test_double_extension_returns_fail_high(): void { @@ -142,7 +142,7 @@ public function test_double_extension_returns_fail_high(): void { $this->assertSame( Status::FAIL, $finding->status ); $this->assertSame( Severity::HIGH, $finding->severity ); - $this->assertArrayHasKey( 'double_extension', $finding->evidence ); + $this->assertTrue( $finding->evidence->has( 'double_extension' ) ); } public function test_unexpected_extension_in_theme_returns_fail_medium(): void { @@ -152,8 +152,8 @@ public function test_unexpected_extension_in_theme_returns_fail_medium(): void { $this->assertSame( Status::FAIL, $finding->status ); $this->assertSame( Severity::MEDIUM, $finding->severity ); - $this->assertArrayHasKey( 'theme_violations', $finding->evidence ); - $this->assertStringContainsString( 'binary.exe', implode( ' ', $finding->evidence['theme_violations'] ) ); + $this->assertTrue( $finding->evidence->has( 'theme_violations' ) ); + $this->assertStringContainsString( 'binary.exe', implode( ' ', $finding->evidence->get( 'theme_violations' ) ) ); } public function test_expected_theme_extensions_are_not_flagged(): void { diff --git a/tests/Unit/Modules/CoreIntegrity/Checks/VulnerabilityAdvisoryCheckTest.php b/tests/Unit/Modules/CoreIntegrity/Checks/VulnerabilityAdvisoryCheckTest.php index 8f3c03d..fe43eb8 100644 --- a/tests/Unit/Modules/CoreIntegrity/Checks/VulnerabilityAdvisoryCheckTest.php +++ b/tests/Unit/Modules/CoreIntegrity/Checks/VulnerabilityAdvisoryCheckTest.php @@ -86,7 +86,7 @@ public function test_one_vulnerability_returns_fail_critical(): void { $this->assertSame( Status::FAIL, $finding->status ); $this->assertSame( Severity::CRITICAL, $finding->severity ); - $this->assertSame( 1, $finding->evidence['vulnerability_count'] ); + $this->assertSame( 1, $finding->evidence->get( 'vulnerability_count' ) ); } public function test_multiple_vulnerabilities_count_is_correct(): void { @@ -108,8 +108,8 @@ public function test_multiple_vulnerabilities_count_is_correct(): void { $finding = $this->check->run( $ctx ); - $this->assertSame( 3, $finding->evidence['vulnerability_count'] ); - $this->assertCount( 3, $finding->evidence['advisories'] ); + $this->assertSame( 3, $finding->evidence->get( 'vulnerability_count' ) ); + $this->assertCount( 3, $finding->evidence->get( 'advisories' ) ); } public function test_plugins_are_checked(): void { diff --git a/tests/Unit/Modules/CoreIntegrity/Checks/WpConfigCheckTest.php b/tests/Unit/Modules/CoreIntegrity/Checks/WpConfigCheckTest.php index 83105b1..4777ff2 100644 --- a/tests/Unit/Modules/CoreIntegrity/Checks/WpConfigCheckTest.php +++ b/tests/Unit/Modules/CoreIntegrity/Checks/WpConfigCheckTest.php @@ -120,7 +120,7 @@ public function test_evidence_contains_both_constant_values(): void { ); $finding = $this->check->run( $context ); - $this->assertArrayHasKey( 'disallow_file_edit', $finding->evidence ); - $this->assertArrayHasKey( 'wp_debug', $finding->evidence ); + $this->assertTrue( $finding->evidence->has( 'disallow_file_edit' ) ); + $this->assertTrue( $finding->evidence->has( 'wp_debug' ) ); } } diff --git a/tests/Unit/Modules/CoreIntegrity/Checks/WpContentStructureCheckTest.php b/tests/Unit/Modules/CoreIntegrity/Checks/WpContentStructureCheckTest.php index ab5a918..f8f175b 100644 --- a/tests/Unit/Modules/CoreIntegrity/Checks/WpContentStructureCheckTest.php +++ b/tests/Unit/Modules/CoreIntegrity/Checks/WpContentStructureCheckTest.php @@ -114,8 +114,8 @@ public function test_evidence_lists_missing_directories(): void { $finding = $this->check->run( $this->makeContext() ); - $this->assertArrayHasKey( 'missing_directories', $finding->evidence ); - $missing = $finding->evidence['missing_directories']; + $this->assertTrue( $finding->evidence->has( 'missing_directories' ) ); + $missing = $finding->evidence->get( 'missing_directories' ); $this->assertContains( 'plugins', $missing ); $this->assertContains( 'uploads', $missing ); $this->assertNotContains( 'themes', $missing ); diff --git a/tests/Unit/Modules/Database/Checks/AutoloadedOptionsCheckTest.php b/tests/Unit/Modules/Database/Checks/AutoloadedOptionsCheckTest.php index 4e60f48..fb76adf 100644 --- a/tests/Unit/Modules/Database/Checks/AutoloadedOptionsCheckTest.php +++ b/tests/Unit/Modules/Database/Checks/AutoloadedOptionsCheckTest.php @@ -70,7 +70,7 @@ public function test_size_exceeds_threshold_returns_warn_medium(): void { $this->assertSame( Status::WARN, $finding->status ); $this->assertSame( Severity::MEDIUM, $finding->severity ); - $this->assertSame( 1100000, $finding->evidence['autoloaded_size_bytes'] ); + $this->assertSame( 1100000, $finding->evidence->get( 'autoloaded_size_bytes' ) ); } public function test_zero_bytes_returns_pass(): void { diff --git a/tests/Unit/Modules/Database/Checks/SuspiciousContentCheckTest.php b/tests/Unit/Modules/Database/Checks/SuspiciousContentCheckTest.php index f522323..07374c9 100644 --- a/tests/Unit/Modules/Database/Checks/SuspiciousContentCheckTest.php +++ b/tests/Unit/Modules/Database/Checks/SuspiciousContentCheckTest.php @@ -122,7 +122,7 @@ public function test_both_suspicious_evidence_contains_both_counts(): void { $finding = $this->check->run( $ctx ); $this->assertSame( Status::FAIL, $finding->status ); - $this->assertSame( 3, $finding->evidence['suspicious_option_count'] ); - $this->assertSame( 2, $finding->evidence['suspicious_post_count'] ); + $this->assertSame( 3, $finding->evidence->get( 'suspicious_option_count' ) ); + $this->assertSame( 2, $finding->evidence->get( 'suspicious_post_count' ) ); } } diff --git a/tests/Unit/Modules/Headers/Checks/CookieSecurityCheckTest.php b/tests/Unit/Modules/Headers/Checks/CookieSecurityCheckTest.php index 9210dd0..435193c 100644 --- a/tests/Unit/Modules/Headers/Checks/CookieSecurityCheckTest.php +++ b/tests/Unit/Modules/Headers/Checks/CookieSecurityCheckTest.php @@ -96,7 +96,7 @@ public function test_missing_secure_or_httponly_returns_warn_high(): void { $this->assertSame( Status::WARN, $finding->status ); $this->assertSame( Severity::HIGH, $finding->severity ); - $this->assertContains( 'insecure_cookie', $finding->evidence['insecure_cookies'] ); + $this->assertContains( 'insecure_cookie', $finding->evidence->get( 'insecure_cookies' ) ); } public function test_weak_samesite_only_returns_warn_medium(): void { @@ -116,7 +116,7 @@ public function test_weak_samesite_only_returns_warn_medium(): void { $this->assertSame( Status::WARN, $finding->status ); $this->assertSame( Severity::MEDIUM, $finding->severity ); - $this->assertContains( 'partial_cookie', $finding->evidence['weak_samesite'] ); + $this->assertContains( 'partial_cookie', $finding->evidence->get( 'weak_samesite' ) ); } public function test_no_cookies_observed_returns_pass_with_caveat(): void { diff --git a/tests/Unit/Modules/Headers/Checks/HstsCheckTest.php b/tests/Unit/Modules/Headers/Checks/HstsCheckTest.php index e780623..48a1e4d 100644 --- a/tests/Unit/Modules/Headers/Checks/HstsCheckTest.php +++ b/tests/Unit/Modules/Headers/Checks/HstsCheckTest.php @@ -120,7 +120,7 @@ public function test_max_age_below_threshold_returns_warn_medium(): void { $this->assertSame( Status::WARN, $finding->status ); $this->assertSame( Severity::MEDIUM, $finding->severity ); - $this->assertSame( 3600, $finding->evidence['max_age'] ); + $this->assertSame( 3600, $finding->evidence->get( 'max_age' ) ); } public function test_max_age_zero_returns_warn_high(): void { diff --git a/tests/Unit/Modules/Headers/Checks/SecurityHeadersCheckTest.php b/tests/Unit/Modules/Headers/Checks/SecurityHeadersCheckTest.php index cf01fc3..5c5def9 100644 --- a/tests/Unit/Modules/Headers/Checks/SecurityHeadersCheckTest.php +++ b/tests/Unit/Modules/Headers/Checks/SecurityHeadersCheckTest.php @@ -138,8 +138,8 @@ public function test_missing_csp_recorded_in_evidence(): void { $context = new MockContext( values: [ 'response_headers' => $headers ] ); $finding = $this->check->run( $context ); - $this->assertArrayHasKey( 'missing', $finding->evidence ); - $this->assertArrayHasKey( 'content-security-policy', $finding->evidence['missing'] ); + $this->assertTrue( $finding->evidence->has( 'missing' ) ); + $this->assertArrayHasKey( 'content-security-policy', $finding->evidence->get( 'missing' ) ); } public function test_weak_csp_with_headers_present_returns_warn_medium(): void { @@ -150,8 +150,8 @@ public function test_weak_csp_with_headers_present_returns_warn_medium(): void { $this->assertSame( Status::WARN, $finding->status ); $this->assertSame( Severity::MEDIUM, $finding->severity ); - $this->assertSame( [], $finding->evidence['missing'] ); - $this->assertContains( 'unsafe-inline', $finding->evidence['csp_weaknesses'] ); - $this->assertContains( 'wildcard-default-src', $finding->evidence['csp_weaknesses'] ); + $this->assertSame( [], $finding->evidence->get( 'missing' ) ); + $this->assertContains( 'unsafe-inline', $finding->evidence->get( 'csp_weaknesses' ) ); + $this->assertContains( 'wildcard-default-src', $finding->evidence->get( 'csp_weaknesses' ) ); } } diff --git a/tests/Unit/Modules/Headers/Checks/SriCheckTest.php b/tests/Unit/Modules/Headers/Checks/SriCheckTest.php index fc513ae..1e75bb4 100644 --- a/tests/Unit/Modules/Headers/Checks/SriCheckTest.php +++ b/tests/Unit/Modules/Headers/Checks/SriCheckTest.php @@ -110,7 +110,7 @@ public function test_external_asset_missing_integrity_returns_warn_high(): void $this->assertSame( Status::WARN, $finding->status ); $this->assertSame( Severity::HIGH, $finding->severity ); - $this->assertContains( 'https://cdn.example.com/unprotected.js', $finding->evidence['missing_sri'] ); + $this->assertContains( 'https://cdn.example.com/unprotected.js', $finding->evidence->get( 'missing_sri' ) ); } public function test_null_page_asset_tags_returns_skipped(): void { diff --git a/tests/Unit/Modules/Performance/Checks/TtfbCheckTest.php b/tests/Unit/Modules/Performance/Checks/TtfbCheckTest.php index 26767e7..6b291e0 100644 --- a/tests/Unit/Modules/Performance/Checks/TtfbCheckTest.php +++ b/tests/Unit/Modules/Performance/Checks/TtfbCheckTest.php @@ -100,6 +100,6 @@ public function test_evidence_contains_ttfb_ms_on_fail(): void { $ctx = new MockContext( values: [ 'ttfb_ms' => 900.0 ] ); $finding = $this->check->run( $ctx ); - $this->assertSame( 900.0, $finding->evidence['ttfb_ms'] ); + $this->assertSame( 900.0, $finding->evidence->get( 'ttfb_ms' ) ); } } diff --git a/tests/Unit/Modules/PluginsThemes/Checks/InactivePluginsCheckTest.php b/tests/Unit/Modules/PluginsThemes/Checks/InactivePluginsCheckTest.php index af47afa..92a3fe0 100644 --- a/tests/Unit/Modules/PluginsThemes/Checks/InactivePluginsCheckTest.php +++ b/tests/Unit/Modules/PluginsThemes/Checks/InactivePluginsCheckTest.php @@ -102,7 +102,7 @@ public function test_one_inactive_plugin_returns_warn_medium(): void { $this->assertSame( Status::WARN, $finding->status ); $this->assertSame( Severity::MEDIUM, $finding->severity ); - $this->assertContains( 'plugin-b/plugin-b.php', $finding->evidence['inactive_plugins'] ); + $this->assertContains( 'plugin-b/plugin-b.php', $finding->evidence->get( 'inactive_plugins' ) ); } public function test_multiple_inactive_plugins_returns_warn_with_all_slugs(): void { @@ -120,7 +120,7 @@ public function test_multiple_inactive_plugins_returns_warn_with_all_slugs(): vo $finding = $this->check->run( $ctx ); $this->assertSame( Status::WARN, $finding->status ); - $this->assertCount( 2, $finding->evidence['inactive_plugins'] ); + $this->assertCount( 2, $finding->evidence->get( 'inactive_plugins' ) ); } public function test_no_plugins_installed_returns_pass(): void { diff --git a/tests/Unit/Modules/PluginsThemes/Checks/OutdatedJsLibraryCheckTest.php b/tests/Unit/Modules/PluginsThemes/Checks/OutdatedJsLibraryCheckTest.php index f08eb2f..b77565b 100644 --- a/tests/Unit/Modules/PluginsThemes/Checks/OutdatedJsLibraryCheckTest.php +++ b/tests/Unit/Modules/PluginsThemes/Checks/OutdatedJsLibraryCheckTest.php @@ -98,8 +98,8 @@ public function test_outdated_jquery_returns_warn_high(): void { $this->assertSame( Status::WARN, $finding->status ); $this->assertSame( Severity::HIGH, $finding->severity ); - $this->assertSame( 'jQuery', $finding->evidence['outdated'][0]['library'] ); - $this->assertSame( 'CVE-2020-11022', $finding->evidence['outdated'][0]['reference'] ); + $this->assertSame( 'jQuery', $finding->evidence->get( 'outdated' )[0]['library'] ); + $this->assertSame( 'CVE-2020-11022', $finding->evidence->get( 'outdated' )[0]['reference'] ); } public function test_outdated_jquery_ui_is_attributed_to_jquery_ui_not_jquery(): void { @@ -119,7 +119,7 @@ public function test_outdated_jquery_ui_is_attributed_to_jquery_ui_not_jquery(): $finding = $this->check->run( $context ); $this->assertSame( Status::WARN, $finding->status ); - $this->assertSame( 'jQuery UI', $finding->evidence['outdated'][0]['library'] ); + $this->assertSame( 'jQuery UI', $finding->evidence->get( 'outdated' )[0]['library'] ); } public function test_unparseable_version_does_not_fail(): void { @@ -139,7 +139,7 @@ public function test_unparseable_version_does_not_fail(): void { $finding = $this->check->run( $context ); $this->assertSame( Status::PASS, $finding->status ); - $this->assertArrayHasKey( 'version_unknown', $finding->evidence ); + $this->assertTrue( $finding->evidence->has( 'version_unknown' ) ); } public function test_null_page_asset_tags_returns_skipped(): void { diff --git a/tests/Unit/Modules/PluginsThemes/Checks/PluginUpdatesCheckTest.php b/tests/Unit/Modules/PluginsThemes/Checks/PluginUpdatesCheckTest.php index 36b32cf..9e4d0de 100644 --- a/tests/Unit/Modules/PluginsThemes/Checks/PluginUpdatesCheckTest.php +++ b/tests/Unit/Modules/PluginsThemes/Checks/PluginUpdatesCheckTest.php @@ -68,7 +68,7 @@ public function test_one_plugin_needs_update_returns_warn_high(): void { $this->assertSame( Status::WARN, $finding->status ); $this->assertSame( Severity::HIGH, $finding->severity ); - $this->assertSame( [ 'plugin-a/plugin-a.php' ], $finding->evidence['plugins_needing_update'] ); + $this->assertSame( [ 'plugin-a/plugin-a.php' ], $finding->evidence->get( 'plugins_needing_update' ) ); } public function test_multiple_plugins_need_updates_returns_warn_high(): void { @@ -78,7 +78,7 @@ public function test_multiple_plugins_need_updates_returns_warn_high(): void { $this->assertSame( Status::WARN, $finding->status ); $this->assertSame( Severity::HIGH, $finding->severity ); - $this->assertCount( 2, $finding->evidence['plugins_needing_update'] ); + $this->assertCount( 2, $finding->evidence->get( 'plugins_needing_update' ) ); } public function test_check_id_matches_finding(): void { diff --git a/tests/Unit/Modules/Seo/Checks/PageTitleCheckTest.php b/tests/Unit/Modules/Seo/Checks/PageTitleCheckTest.php index 067eab0..c0812ce 100644 --- a/tests/Unit/Modules/Seo/Checks/PageTitleCheckTest.php +++ b/tests/Unit/Modules/Seo/Checks/PageTitleCheckTest.php @@ -101,9 +101,9 @@ public function test_evidence_includes_title_and_length_on_warn(): void { $ctx = new MockContext( values: [ 'homepage_html' => $html ] ); $finding = $this->check->run( $ctx ); - $this->assertArrayHasKey( 'title', $finding->evidence ); - $this->assertArrayHasKey( 'length', $finding->evidence ); - $this->assertSame( 'Hi', $finding->evidence['title'] ); - $this->assertSame( 2, $finding->evidence['length'] ); + $this->assertTrue( $finding->evidence->has( 'title' ) ); + $this->assertTrue( $finding->evidence->has( 'length' ) ); + $this->assertSame( 'Hi', $finding->evidence->get( 'title' ) ); + $this->assertSame( 2, $finding->evidence->get( 'length' ) ); } } diff --git a/tests/Unit/Modules/Seo/Checks/RobotsTxtCheckTest.php b/tests/Unit/Modules/Seo/Checks/RobotsTxtCheckTest.php index 34d159b..963ea69 100644 --- a/tests/Unit/Modules/Seo/Checks/RobotsTxtCheckTest.php +++ b/tests/Unit/Modules/Seo/Checks/RobotsTxtCheckTest.php @@ -72,6 +72,6 @@ public function test_evidence_contains_http_status(): void { $ctx = new MockContext( values: [ 'robots_txt_status' => 403 ] ); $finding = $this->check->run( $ctx ); - $this->assertSame( 403, $finding->evidence['http_status'] ); + $this->assertSame( 403, $finding->evidence->get( 'http_status' ) ); } } diff --git a/tests/Unit/Modules/Server/Checks/DiskSpaceCheckTest.php b/tests/Unit/Modules/Server/Checks/DiskSpaceCheckTest.php index 6641788..fd408ce 100644 --- a/tests/Unit/Modules/Server/Checks/DiskSpaceCheckTest.php +++ b/tests/Unit/Modules/Server/Checks/DiskSpaceCheckTest.php @@ -104,7 +104,7 @@ public function test_warn_finding_includes_free_mb_evidence(): void { $context = new MockContext( values: [ 'disk_free_bytes' => 300 * self::MB ] ); $finding = $this->check->run( $context ); - $this->assertArrayHasKey( 'free_mb', $finding->evidence ); + $this->assertTrue( $finding->evidence->has( 'free_mb' ) ); } public function test_boundary_at_500mb_is_pass(): void { diff --git a/tests/Unit/Modules/Server/Checks/HttpsCheckTest.php b/tests/Unit/Modules/Server/Checks/HttpsCheckTest.php index 15e47f6..3a5860d 100644 --- a/tests/Unit/Modules/Server/Checks/HttpsCheckTest.php +++ b/tests/Unit/Modules/Server/Checks/HttpsCheckTest.php @@ -74,8 +74,8 @@ public function test_http_finding_includes_home_url_evidence(): void { $context = new MockContext( homeUrl: 'http://example.test' ); $finding = $this->check->run( $context ); - $this->assertArrayHasKey( 'home_url', $finding->evidence ); - $this->assertSame( 'http://example.test', $finding->evidence['home_url'] ); + $this->assertTrue( $finding->evidence->has( 'home_url' ) ); + $this->assertSame( 'http://example.test', $finding->evidence->get( 'home_url' ) ); } public function test_empty_url_returns_skipped(): void { diff --git a/tests/Unit/Modules/Server/Checks/MemoryLimitCheckTest.php b/tests/Unit/Modules/Server/Checks/MemoryLimitCheckTest.php index 9e9fd41..5e4f813 100644 --- a/tests/Unit/Modules/Server/Checks/MemoryLimitCheckTest.php +++ b/tests/Unit/Modules/Server/Checks/MemoryLimitCheckTest.php @@ -121,7 +121,7 @@ public function test_warn_finding_includes_evidence(): void { $context = new MockContext( values: [ 'memory_limit' => '32M' ] ); $finding = $this->check->run( $context ); - $this->assertArrayHasKey( 'current', $finding->evidence ); - $this->assertArrayHasKey( 'recommended_minimum', $finding->evidence ); + $this->assertTrue( $finding->evidence->has( 'current' ) ); + $this->assertTrue( $finding->evidence->has( 'recommended_minimum' ) ); } } diff --git a/tests/Unit/Modules/Server/Checks/PhpExtensionsCheckTest.php b/tests/Unit/Modules/Server/Checks/PhpExtensionsCheckTest.php index 4ea31a1..4932f45 100644 --- a/tests/Unit/Modules/Server/Checks/PhpExtensionsCheckTest.php +++ b/tests/Unit/Modules/Server/Checks/PhpExtensionsCheckTest.php @@ -113,8 +113,8 @@ public function test_missing_curl_evidence_contains_missing_map(): void { $context = new MockContext( values: [ 'php_extensions' => array_values( $extensions ) ] ); $finding = $this->check->run( $context ); - $this->assertArrayHasKey( 'missing', $finding->evidence ); - $this->assertArrayHasKey( 'curl', $finding->evidence['missing'] ); + $this->assertTrue( $finding->evidence->has( 'missing' ) ); + $this->assertArrayHasKey( 'curl', $finding->evidence->get( 'missing' ) ); } public function test_extension_check_is_case_insensitive(): void { diff --git a/tests/Unit/Modules/Server/Checks/PhpVersionCheckTest.php b/tests/Unit/Modules/Server/Checks/PhpVersionCheckTest.php index 13f2953..1a3bb6e 100644 --- a/tests/Unit/Modules/Server/Checks/PhpVersionCheckTest.php +++ b/tests/Unit/Modules/Server/Checks/PhpVersionCheckTest.php @@ -102,9 +102,9 @@ public function test_fail_finding_includes_version_evidence(): void { $context = new MockContext( phpVersion: '7.4.0' ); $finding = $this->check->run( $context ); - $this->assertArrayHasKey( 'current', $finding->evidence ); - $this->assertArrayHasKey( 'minimum_secure', $finding->evidence ); - $this->assertSame( '7.4.0', $finding->evidence['current'] ); + $this->assertTrue( $finding->evidence->has( 'current' ) ); + $this->assertTrue( $finding->evidence->has( 'minimum_secure' ) ); + $this->assertSame( '7.4.0', $finding->evidence->get( 'current' ) ); } public function test_pass_finding_has_no_score_penalty(): void { diff --git a/tests/Unit/Modules/Users/Checks/AdminCountCheckTest.php b/tests/Unit/Modules/Users/Checks/AdminCountCheckTest.php index ccd476e..222f5ae 100644 --- a/tests/Unit/Modules/Users/Checks/AdminCountCheckTest.php +++ b/tests/Unit/Modules/Users/Checks/AdminCountCheckTest.php @@ -74,7 +74,7 @@ public function test_two_admins_returns_warn_low(): void { $this->assertSame( Status::WARN, $finding->status ); $this->assertSame( Severity::LOW, $finding->severity ); - $this->assertSame( 2, $finding->evidence['admin_user_count'] ); + $this->assertSame( 2, $finding->evidence->get( 'admin_user_count' ) ); } public function test_three_admins_returns_warn_low(): void { @@ -97,7 +97,7 @@ public function test_many_admins_returns_warn_medium(): void { $finding = $this->check->run( $ctx ); $this->assertSame( Severity::MEDIUM, $finding->severity ); - $this->assertSame( 10, $finding->evidence['admin_user_count'] ); + $this->assertSame( 10, $finding->evidence->get( 'admin_user_count' ) ); } public function test_zero_admins_returns_warn(): void { diff --git a/tests/Unit/Modules/Users/Checks/DormantUsersCheckTest.php b/tests/Unit/Modules/Users/Checks/DormantUsersCheckTest.php index 1fedd25..99acc03 100644 --- a/tests/Unit/Modules/Users/Checks/DormantUsersCheckTest.php +++ b/tests/Unit/Modules/Users/Checks/DormantUsersCheckTest.php @@ -64,7 +64,7 @@ public function test_one_dormant_user_returns_warn_medium(): void { $this->assertSame( Status::WARN, $finding->status ); $this->assertSame( Severity::MEDIUM, $finding->severity ); - $this->assertSame( 1, $finding->evidence['dormant_user_count'] ); + $this->assertSame( 1, $finding->evidence->get( 'dormant_user_count' ) ); } public function test_multiple_dormant_users_returns_warn_medium(): void { @@ -73,7 +73,7 @@ public function test_multiple_dormant_users_returns_warn_medium(): void { $this->assertSame( Status::WARN, $finding->status ); $this->assertSame( Severity::MEDIUM, $finding->severity ); - $this->assertSame( 5, $finding->evidence['dormant_user_count'] ); + $this->assertSame( 5, $finding->evidence->get( 'dormant_user_count' ) ); } public function test_check_id_matches_finding(): void { diff --git a/tests/Unit/Persistence/FindingRepositoryTest.php b/tests/Unit/Persistence/FindingRepositoryTest.php index a69a5bd..efeaea8 100644 --- a/tests/Unit/Persistence/FindingRepositoryTest.php +++ b/tests/Unit/Persistence/FindingRepositoryTest.php @@ -37,6 +37,7 @@ namespace WPSecurity\Tests\Unit\Persistence; use PHPUnit\Framework\TestCase; +use WPSecurity\Domain\Evidence; use WPSecurity\Domain\Finding; use WPSecurity\Domain\Severity; use WPSecurity\Domain\Status; @@ -82,10 +83,9 @@ public function test_evidence_round_trips_as_array(): void { 'Low memory', 'Memory limit is low.', 'Increase the limit.', - [ - 'limit' => '64M', - 'recommended' => '256M', - ] + ( new Evidence() ) + ->add( 'limit', '64M' ) + ->add( 'recommended', '256M' ) ); $this->repo->save( 7, 'server', $finding ); @@ -93,8 +93,18 @@ public function test_evidence_round_trips_as_array(): void { $this->assertSame( [ - 'limit' => '64M', - 'recommended' => '256M', + [ + 'key' => 'limit', + 'label' => 'Limit', + 'type' => 'scalar', + 'value' => '64M', + ], + [ + 'key' => 'recommended', + 'label' => 'Recommended', + 'type' => 'scalar', + 'value' => '256M', + ], ], $found[0]['evidence'] );
  • { key }
    { item.label } - { renderEvidenceValue( value ) } +