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 (
+
+ { value.map( ( item, index ) => (
+ // eslint-disable-next-line react/no-array-index-key
+ - { cellText( item ) }
+ ) ) }
+
+ );
+
+ case 'table':
+ if ( ! value || ! Array.isArray( value.rows ) || value.rows.length === 0 ) {
+ return (
+
+ { __( 'None', 'wp-security' ) }
+
+ );
+ }
+ return (
+
+
+
+ { value.columns.map( ( column ) => (
+ | { column.label } |
+ ) ) }
+
+
+
+ { value.rows.map( ( row, index ) => (
+ // eslint-disable-next-line react/no-array-index-key
+
+ { value.columns.map( ( column ) => (
+ | { 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 ] ) => (
-
- | { key } |
+ { evidence.map( ( item ) => (
+
+ | { item.label } |
- { renderEvidenceValue( value ) }
+
|
) ) }
@@ -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']
);