feat(K5): security-audit read surface — four bounded read-only tools - #58
Conversation
Implements the approved spec (Aura docs/plans/siteagent-security-audit-spec.md, PR #365): the competitive answer to Respira 8.1.0's security lane. The governance delta was zero — this ships the audit CONTENT. New tools (all read_only, explicit annotations, bounded-coverage contract — total_seen/returned/truncated + tripped cap in every result): - check_core_checksums — core-file integrity vs the official wp.org checksum manifest, fetched over HTTPS ONLY with sslverify (core's own get_core_checksums() is deliberately not used: it retries plaintext HTTP, letting an on-path attacker forge a clean audit). Reports modified/ missing/unexpected files under wp-admin, wp-includes, AND the WP root (allowlisted wp-config.php etc. reported separately); lstat discipline — symlinks/special files at core paths are findings, never hashed; per-file byte cap; fail-closed manifest_unavailable (never an empty 'clean'). - scan_executable_files — bounded uploads walk: PHP/phar/executables, .htaccess overrides, symlinks reported with target and never followed. Observations only; no malware verdicts. - audit_admin_accounts — admins with registration recency, users holding admin caps outside the administrator role, per-admin application-password counts (raw-LENGTH pre-check before unserializing the single usermeta blob), and multisite super admins read directly from the pre-size-checked site_admins option (get_super_admins() never called — it unserializes the whole option before any cap could run). - audit_cron — bounded event inventory with interval_lt_60s and unresolved_in_this_context fact-flags (explicitly not orphan verdicts); raw option LENGTH() pre-check with an honest autoload scope note. Honesty fixes (spec §3): README/readme.txt no longer claim a 'WordPress.org vulnerability database' — check_vulnerabilities is documented as the update-currency check it is (wp.org plugins only); tool tables gain the four new rows (21→25); verb-classifier line corrected to annotations-first. Tests: tests/unit/SecurityAuditToolsTest.php — fixture-based, no network (manifest via filter seam, temp trees, wpdb LENGTH queue): acceptance (b) incl. root implant + symlink-at-core-path + manifest-unavailable + HTTPS-only assertions, (c) incl. symlink no-follow, (f) single-site + multisite + oversized site_admins, (g) cap-hit per tool. Suite 573 green (was 511). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1d90688bf1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| $meta_table = isset( $wpdb->sitemeta ) ? $wpdb->sitemeta : $wpdb->prefix . 'sitemeta'; | ||
| $size = (int) $wpdb->get_var( | ||
| $wpdb->prepare( | ||
| "SELECT LENGTH(meta_value) FROM {$meta_table} WHERE meta_key = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
There was a problem hiding this comment.
Scope the size check to the current network
On a multi-network installation, wp_sitemeta contains a separate site_admins row for each site_id, but this query selects an arbitrary matching row. If another network's value is small while the current network's value exceeds the cap, the subsequent get_site_option() still retrieves and unserializes the oversized current-network value; the reverse ordering incorrectly skips a safe value. Filter the query by the current network ID so the pre-check covers the value that is actually read.
Useful? React with 👍 / 👎.
| continue; | ||
| } | ||
|
|
||
| $md5 = md5_file( $path ); |
There was a problem hiding this comment.
Reject symlinked ancestors before hashing core files
When wp-admin, wp-includes, or any intermediate directory is itself a symlink, special_kind() only checks the final file path, so this md5_file() follows the ancestor symlink and hashes files outside the intended WordPress tree. A top-level symlinked core directory is also silently skipped by walk_unexpected(), leaving no corresponding finding. Detect symlinks in the relative path before hashing so the audit cannot report externally resolved files as verified core files.
Useful? React with 👍 / 👎.
| 'file' => $rel, | ||
| 'kind' => 'symlink', | ||
| 'size' => 0, | ||
| 'mtime' => (int) @filemtime( $path ), |
There was a problem hiding this comment.
Use lstat metadata for reported upload symlinks
When an uploads symlink points outside the tree, filemtime($path) resolves the target even though this branch promises never to follow symlinks. Targets on slow or unavailable network mounts can therefore stall the audit, and metadata is read outside the bounded scan root. Obtain the link's timestamp from lstat() instead of calling a target-following filesystem function.
Useful? React with 👍 / 👎.
…adata (Codex round 1) - site_admins LENGTH() pre-check scoped to the current network's site_id — on multi-network installs an arbitrary row's size says nothing about the row get_site_option() reads. - check_core_checksums: a symlinked ancestor directory (wp-includes itself, or any intermediate dir) is now the reported finding and nothing beneath it is hashed — md5_file would otherwise follow it outside the tree. Per-run cache keeps the chain checks O(unique dirs). - scan_executable_files symlink findings use lstat metadata — filemtime() resolves the target, and a slow/unavailable mount would stall the scan. Tests: symlinked-ancestor fixture + network-scope query assertion. Suite 575 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
Pull request overview
Adds the K5 v1 “security-audit read surface” to the SiteAgent WordPress plugin by introducing four new bounded, read-only MCP tools (core checksum audit, uploads executable scan, admin account audit, and cron audit), plus fixture-based unit tests and documentation updates to reflect the expanded toolset and clarified “vulnerability DB” scope.
Changes:
- Introduces 4 new read-only security-audit MCP tools with explicit risk annotations and bounded-coverage contracts.
- Adds a new unit test suite and expands the test bootstrap with stubs needed to run the tools without network access.
- Updates README/readme.txt tool tables and clarifies the “check_vulnerabilities” behavior/claims.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/SecurityAuditToolsTest.php | Adds fixture-based unit tests covering the 4 new security-audit tools and their bounded-coverage / fail-closed behaviors. |
| tests/bootstrap.php | Extends the test harness with stubs for multisite, cron, uploads, and HTTP calls to support the new tools in isolated tests. |
| README.md | Updates the MCP tool list/count and clarifies risk-annotation behavior and the “check_vulnerabilities” description. |
| digitizer-site-worker/readme.txt | Updates WordPress.org readme tool list/count and clarifies “check_vulnerabilities” scope; documents the 4 new read tools. |
| digitizer-site-worker/includes/tools/class-tool-check-core-checksums.php | Implements HTTPS-only core checksum manifest fetch + core integrity audit with modified/missing/unexpected reporting. |
| digitizer-site-worker/includes/tools/class-tool-scan-executable-files.php | Implements bounded uploads-tree scan for executables/.htaccess/symlinks with lstat discipline (never follow symlinks). |
| digitizer-site-worker/includes/tools/class-tool-audit-admin-accounts.php | Implements bounded privileged-account facts (admin recency, caps outside role, app-password counts, multisite super admins with size pre-check). |
| digitizer-site-worker/includes/tools/class-tool-audit-cron.php | Implements bounded WP-Cron inventory with size pre-check and fact-flags for <60s schedules and unresolved callbacks-in-context. |
Suppressed comments (1)
digitizer-site-worker/includes/tools/class-tool-check-core-checksums.php:343
scan_root()overwrites$capwithmax_entrieseven if the scan was already truncated for another reason (e.g.max_files). That can make the returnedcoverage.capmisleading.
Only set $cap if it hasn’t already been set.
$rel = $rel_dir . '/' . $entry;
$path = $base . $rel;
if ( is_link( $path ) ) {
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if ( ! function_exists( 'add_query_arg' ) ) { | ||
| function add_query_arg( array $args, string $url ): string { | ||
| return $url . ( str_contains( $url, '?' ) ? '&' : '?' ) . http_build_query( $args ); | ||
| } |
| $url = add_query_arg( | ||
| array( | ||
| 'version' => rawurlencode( $version ), | ||
| 'locale' => rawurlencode( $locale ), | ||
| ), | ||
| 'https://api.wordpress.org/core/checksums/1.0/' | ||
| ); |
| $this->scan_root( $base, $manifest, $unexpected, $root_extra, $entries, $truncated, $cap ); | ||
|
|
||
| return array( | ||
| 'modified' => $modified, | ||
| 'missing' => $missing, | ||
| 'unexpected' => $unexpected, | ||
| 'special' => $special, | ||
| 'root_extra' => $root_extra, | ||
| 'coverage' => array( | ||
| 'files_expected' => count( $manifest ), | ||
| 'files_checked' => $checked, | ||
| 'truncated' => $truncated, | ||
| 'cap' => $truncated ? $cap : '', | ||
| ), | ||
| ); |
| if ( ++$entries > static::MAX_ENTRIES ) { | ||
| $truncated = true; | ||
| $cap = 'max_entries'; | ||
| closedir( $handle ); | ||
| return; | ||
| } |
| if ( is_link( $path ) ) { | ||
| $unexpected[] = array( | ||
| 'file' => $entry, | ||
| 'size' => 0, | ||
| 'mtime' => 0, | ||
| ); | ||
| continue; |
| $options_table = isset( $wpdb->options ) ? $wpdb->options : $wpdb->prefix . 'options'; | ||
|
|
||
| return (int) $wpdb->get_var( | ||
| $wpdb->prepare( | ||
| "SELECT LENGTH(option_value) FROM {$options_table} WHERE option_name = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared | ||
| 'cron' | ||
| ) | ||
| ); |
| $size = 0; | ||
| if ( isset( $wpdb ) && method_exists( $wpdb, 'get_var' ) ) { | ||
| $meta_table = isset( $wpdb->sitemeta ) ? $wpdb->sitemeta : $wpdb->prefix . 'sitemeta'; | ||
| $size = (int) $wpdb->get_var( | ||
| $wpdb->prepare( | ||
| "SELECT LENGTH(meta_value) FROM {$meta_table} WHERE meta_key = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared | ||
| 'site_admins' | ||
| ) | ||
| ); | ||
| } |
| $size = 0; | ||
| if ( isset( $wpdb ) && method_exists( $wpdb, 'get_var' ) ) { | ||
| $usermeta_table = isset( $wpdb->usermeta ) ? $wpdb->usermeta : $wpdb->prefix . 'usermeta'; | ||
| $size = (int) $wpdb->get_var( | ||
| $wpdb->prepare( | ||
| "SELECT LENGTH(meta_value) FROM {$usermeta_table} WHERE user_id = %d AND meta_key = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared | ||
| $user_id, | ||
| '_application_passwords' | ||
| ) | ||
| ); | ||
| } |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (4)
digitizer-site-worker/includes/tools/class-tool-check-core-checksums.php:300
filesize()inspecial_kind()is unsuppressed, but this path is explicitly handling potentially hostile/unusual filesystem objects. Iffilesize()warns (permissions, transient IO errors), it can produce noisy output; use@filesize()like the rest of the tool.
$size = filesize( $path );
if ( false !== $size && $size > static::MAX_FILE_BYTES ) {
return 'oversized';
digitizer-site-worker/includes/tools/class-tool-check-core-checksums.php:402
- Root-level symlinks are currently reported with
size/mtimeforced to 0. This loses useful facts and doesn’t follow the stated lstat discipline; you can capture link metadata vialstat()without following the target.
$unexpected[] = array(
'file' => $entry,
'size' => 0,
'mtime' => 0,
);
tests/bootstrap.php:915
add_query_arg()test stub usesstr_contains(), which is PHP 8+ only. The plugin targets PHP 7.4+, so the test suite will fatal on PHP 7.4 runners.
if ( ! function_exists( 'add_query_arg' ) ) {
function add_query_arg( array $args, string $url ): string {
return $url . ( str_contains( $url, '?' ) ? '&' : '?' ) . http_build_query( $args );
}
digitizer-site-worker/includes/tools/class-tool-check-core-checksums.php:156
md5_file()can emit warnings (e.g., unreadable core files due to permissions). Other file ops in this tool are already suppressed with@, so this should be consistent to avoid leaking warnings into REST output/logs.
This issue also appears on line 298 of the same file.
$md5 = md5_file( $path );
if ( false === $md5 || strtolower( $md5 ) !== strtolower( (string) $expected_md5 ) ) {
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d7a3f493da
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| $handle = @opendir( $abs ); | ||
| if ( false === $handle ) { | ||
| continue; |
There was a problem hiding this comment.
Report directories that cannot be scanned
When the uploads root or any discovered subdirectory cannot be opened due to permissions or an I/O error, this branch silently skips it, and execute() can return empty findings with truncated=false. That makes an inaccessible tree indistinguishable from a successfully scanned empty tree, so mark coverage incomplete or return an explicit scan error when opendir() fails.
Useful? React with 👍 / 👎.
| 'files_expected' => count( $manifest ), | ||
| 'files_checked' => $checked, |
There was a problem hiding this comment.
Count only in-scope files in checksum coverage
On manifests containing the bundled wp-content entries explicitly skipped above, files_expected counts those entries while files_checked does not, even when the scan completes with truncated=false. This makes the coverage contract internally inconsistent on normal manifests; compute the expected count from the filtered manifest or expose the skipped count separately.
Useful? React with 👍 / 👎.
… round 2) - scan_executable_files coverage carries unreadable_dirs: a directory that cannot be opened (permissions/IO) is UNKNOWN, not clean — silence made an unreadable uploads root look like a clean scan. - check_core_checksums files_expected counts only in-scope manifest entries (wp-content entries are skipped by design), computed up front so it stays the full in-scope total when the scan truncates — a complete scan no longer looks partial, and a truncated one shows what was left unverified. Suite 575 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (6)
tests/unit/SecurityAuditToolsTest.php:218
- This filter callback is declared with zero parameters, but apply_filters() passes the current value as the first argument. On PHP 8+, that can raise an ArgumentCountError. Make the callback variadic (or accept the $dirs value) and return the desired directory list.
private function exec_tool( string $dir ) {
$GLOBALS['_filters']['aura_worker_scan_executable_dirs'][] = static fn() => array( $dir );
return new Aura_Tool_Scan_Executable_Files();
tests/bootstrap.php:915
- The add_query_arg() test stub uses str_contains(), which is only available in PHP 8+. The plugin targets PHP 7.4+, and the unit test suite should remain runnable on 7.4 as well. Use strpos() instead.
if ( ! function_exists( 'add_query_arg' ) ) {
function add_query_arg( array $args, string $url ): string {
return $url . ( str_contains( $url, '?' ) ? '&' : '?' ) . http_build_query( $args );
}
digitizer-site-worker/includes/tools/class-tool-check-core-checksums.php:415
- Root-level symlinks are reported with size/mtime set to 0. Since the tool already follows a strict no-follow policy, you can still use lstat() (which does not follow the target) to capture the symlink’s own metadata for a more accurate observation.
if ( is_link( $path ) ) {
$unexpected[] = array(
'file' => $entry,
'size' => 0,
'mtime' => 0,
tests/unit/SecurityAuditToolsTest.php:91
- The filter callbacks in this helper are declared with zero parameters, but the test bootstrap’s apply_filters() stub calls callbacks with the filter value and any extra args. On PHP 8+, passing extra args to a zero-arg closure can throw an ArgumentCountError, which would make these tests brittle across PHP versions. Make the callbacks variadic (or accept the expected parameters) and ignore them.
This issue also appears on line 216 of the same file.
private function checksums_tool( string $base, ?array $manifest ) {
$GLOBALS['_filters']['aura_worker_core_checksums_manifest'][] = static fn() => $manifest;
$GLOBALS['_filters']['aura_worker_core_checksums_base'][] = static fn() => $base;
return new Aura_Tool_Check_Core_Checksums();
tests/bootstrap.php:920
- This guard block does not actually declare a rawurlencode_deep() stub, so it currently has no effect and could confuse future test additions (and would still fatal if code starts calling rawurlencode_deep). Either remove it or provide a minimal stub implementation.
if ( ! function_exists( 'rawurlencode_deep' ) ) {
// no-op helper space reserved
}
digitizer-site-worker/includes/tools/class-tool-scan-executable-files.php:45
- get_returns() documents coverage as only { total_seen, returned, truncated, cap }, but execute() also returns coverage.unreadable_dirs. Update the return contract so API consumers (and tests) can rely on the documented shape.
public function get_returns() {
return array(
'findings' => 'array — { file, kind: executable|htaccess|symlink, size, mtime, target? }',
'coverage' => 'object — { total_seen, returned, truncated, cap } bounded-coverage contract; an empty findings list with truncated=true means "nothing found before the cap", never "clean"',
);
}
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c14958c6fb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| if ( ! function_exists( 'add_query_arg' ) ) { | ||
| function add_query_arg( array $args, string $url ): string { | ||
| return $url . ( str_contains( $url, '?' ) ? '&' : '?' ) . http_build_query( $args ); |
There was a problem hiding this comment.
Replace the PHP 8-only str_contains call
The PHPUnit workflow explicitly runs on PHP 7.4 (.github/workflows/tests.yml:23), but str_contains() was introduced in PHP 8.0. The checksum live-fetch test invokes this stub, so the PHP 7.4 job terminates with an undefined-function fatal instead of running the suite; use a PHP 7.4-compatible check such as false !== strpos(...).
Useful? React with 👍 / 👎.
| $handle = @opendir( $base . $rel_dir ); | ||
| if ( false === $handle ) { | ||
| continue; |
There was a problem hiding this comment.
Mark checksum directory enumeration failures as incomplete
When wp-admin, wp-includes, or a nested directory cannot be opened, this branch silently skips its contents and the result can still return truncated=false. For example, a directory may allow traversal of known manifest paths while denying directory listing, so expected files hash successfully but unexpected implants remain invisible and the reported coverage appears complete; record unreadable directories or otherwise mark coverage incomplete.
Useful? React with 👍 / 👎.
…Codex round 3) - str_contains (PHP 8.0+) in the add_query_arg test stub replaced with strpos — the PHPUnit workflow runs on 7.4 and the live-fetch test hits this stub. - check_core_checksums coverage now carries unreadable_dirs like the executable scan: a directory that cannot be enumerated is UNVERIFIED, not clean (traversable-but-unreadable dirs no longer return truncated=false with silently missing contents). Suite 575 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b67479bc64
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // admin capabilities outside the administrator role. | ||
| $users = get_users( | ||
| array( | ||
| 'capability' => 'manage_options', |
There was a problem hiding this comment.
Include administrator-role users in the audit
When a site customizes the administrator role so that it no longer has manage_options but retains other privileged capabilities such as edit_users or activate_plugins, this query omits those administrator-role accounts entirely. Their recency and application-password counts are therefore absent even though the tool promises to list administrators, and the coverage result can still claim a complete scan; query administrator-role users separately and union them with capability-based matches.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 42005d9: union of role=administrator and capability=manage_options, deduped by user ID.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
digitizer-site-worker/includes/tools/class-tool-check-core-checksums.php:420
- In
scan_root(), the allowlist check runs before the symlink check. That means an allowlisted path (e.g.wp-config.phpor.htaccess) could be a symlink and would only appear inroot_extra, with no indication it’s a symlink, which undermines the “lstat discipline” guarantee. Also, root-level symlinks are currently reported underunexpectedwithmtime=0, losing useful metadata and mixing “unexpected regular files” with “special kinds”.
if ( in_array( $entry, self::ROOT_ALLOWLIST, true ) ) {
$root_extra[] = $entry;
continue;
}
if ( is_link( $path ) ) {
digitizer-site-worker/includes/tools/class-tool-scan-executable-files.php:129
- When the scan can’t open the base directory,
unreadable_dirscurrently records the absolute filesystem path ($base), which can leak host path details to the caller. Since findings are otherwise relative, record.(or another relative marker) instead.
$unreadable[] = '' === $dir ? $base : $dir;
digitizer-site-worker/includes/tools/class-tool-check-core-checksums.php:172
- If a core file exists but is unreadable,
md5_file()can emit warnings and the tool will record it asmodified(even though the hash couldn’t be computed). It’s more accurate (and quieter) to treat unreadable files as aspecialkind and skip hashing.
$md5 = md5_file( $path );
if ( false === $md5 || strtolower( $md5 ) !== strtolower( (string) $expected_md5 ) ) {
$modified[] = array(
'file' => $rel_file,
'expected_md5' => (string) $expected_md5,
| if ( ! function_exists( 'rawurlencode_deep' ) ) { | ||
| // no-op helper space reserved | ||
| } |
…rs (Codex round 4) A customized administrator role stripped of manage_options (but keeping other privileged caps) escaped the capability-only query; a non-admin role granted manage_options escapes a role-only query. The audit now unions both queries, deduped by user ID. Suite 575 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 42005d92d1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| $by_cap = get_users( | ||
| array( | ||
| 'capability' => 'manage_options', | ||
| 'number' => static::MAX_ACCOUNTS + 1, | ||
| 'fields' => 'all', |
There was a problem hiding this comment.
Include users with other privileged capabilities
On sites with custom roles, a non-administrator can hold capabilities such as update_plugins, update_core, or update_themes without manage_options, so both queries omit an account that can perform privileged mutations. Fresh evidence beyond the earlier administrator-role issue is that class-aura-worker-security.php explicitly authorizes the corresponding update routes using those capabilities; query and union all capabilities the audit considers privileged rather than only manage_options.
Useful? React with 👍 / 👎.
| * `check_core_checksums` — Core-file integrity against the official WordPress.org checksum manifest (modified/missing/unexpected files, fetched over HTTPS only) | ||
| * `scan_executable_files` — Uploads-directory observations: PHP/executable files, .htaccess overrides, and symlinks (reported, never followed) | ||
| * `audit_admin_accounts` — Privileged-account facts: administrators with recency, admin capabilities outside the role, application-password counts, multisite super admins | ||
| * `audit_cron` — Bounded WP-Cron inventory with sub-60-second-schedule and unresolved-callback fact-flags |
There was a problem hiding this comment.
Update the remaining built-in tool count
Adding these four tools makes the WordPress.org readme internally inconsistent: its feature summary says 25 tools, but the AI Agent Tools section immediately above this new list still says the plugin ships 21. Users reading that distribution-facing documentation therefore receive conflicting inventory counts.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (6)
digitizer-site-worker/includes/tools/class-tool-check-core-checksums.php:426
- For root-level symlinks, the tool records
size/mtimeas 0. Since the tool already promises lstat discipline, it should uselstat()here too so the observation isn't silently lossy/misleading.
if ( is_link( $path ) ) {
$unexpected[] = array(
'file' => $entry,
'size' => 0,
'mtime' => 0,
);
continue;
digitizer-site-worker/includes/tools/class-tool-audit-admin-accounts.php:100
get_users()/WP_User_Querydoesn't support a nativecapabilityquery arg, so this call will be ignored and may return arbitrary users (up to thenumberlimit), potentially missing the actualmanage_optionsholders you want to flag.
$by_cap = get_users(
array(
'capability' => 'manage_options',
'number' => static::MAX_ACCOUNTS + 1,
'fields' => 'all',
)
);
digitizer-site-worker/includes/tools/class-tool-scan-executable-files.php:163
- Symlink findings always report
sizeas 0 even though you alreadylstat()the link. This makes the observation misleading;lstat()['size']is available without following the symlink.
$lstat = @lstat( $path );
$findings[] = array(
'file' => $rel,
'kind' => 'symlink',
'size' => 0,
'mtime' => is_array( $lstat ) && isset( $lstat['mtime'] ) ? (int) $lstat['mtime'] : 0,
'target' => (string) @readlink( $path ),
);
digitizer-site-worker/includes/tools/class-tool-scan-executable-files.php:131
- When the base uploads directory is unreadable,
unreadable_dirscurrently includes the absolute filesystem path ($base). That leaks server path information and is inconsistent with the rest of the tool output (which is relative).
$handle = @opendir( $abs );
if ( false === $handle ) {
$unreadable[] = '' === $dir ? $base : $dir;
continue;
digitizer-site-worker/includes/tools/class-tool-scan-executable-files.php:45
- The tool returns
coverage['unreadable_dirs'], butget_returns()doesn't document that field. This makes the published tool contract inaccurate for consumers.
public function get_returns() {
return array(
'findings' => 'array — { file, kind: executable|htaccess|symlink, size, mtime, target? }',
'coverage' => 'object — { total_seen, returned, truncated, cap } bounded-coverage contract; an empty findings list with truncated=true means "nothing found before the cap", never "clean"',
);
digitizer-site-worker/includes/tools/class-tool-check-core-checksums.php:62
- The tool returns
coverage['unreadable_dirs'], butget_returns()doesn’t include it in the documentedcoverageshape. That makes the tool contract inaccurate.
'unexpected' => 'array — files not in the manifest under wp-admin/, wp-includes/, or the WP root { file, size, mtime }',
'special' => 'array — non-regular files (symlink/FIFO/device) or oversized files at core paths { file, kind }',
'root_extra' => 'array — allowlisted host-added root files present (reported, not flagged)',
'coverage' => 'object — { files_expected, files_checked, truncated, cap } scan-coverage contract',
'error' => 'string — "manifest_unavailable" when the checksum manifest could not be fetched (no findings are reported in that case)',
);
…adme count (Codex round 5) - The privileged-account union now covers update_core/update_plugins/ update_themes/install_plugins/edit_users/edit_plugins as well — custom roles can hold mutation-grade capabilities without manage_options. - readme.txt's remaining '21 built-in tools' line updated to 25 (the distribution-facing doc said both 21 and 25). Suite 575 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9e03cbe357
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 'fields' => 'all', | ||
| ) | ||
| ); | ||
| foreach ( array( 'manage_options', 'update_core', 'update_plugins', 'update_themes', 'install_plugins', 'edit_users', 'edit_plugins' ) as $capability ) { |
There was a problem hiding this comment.
Include activation-capable custom-role users
On sites where a non-administrator custom role grants activate_plugins but none of the seven queried capabilities, this audit omits an account that can activate arbitrary installed plugin code. Fresh evidence after 9e03cbe is that the new capability-union array still excludes activate_plugins; include it and any other intended mutation-grade administrator primitives in the union.
Useful? React with 👍 / 👎.
| 'schedule' => $schedule ? $schedule : 'single', | ||
| 'interval' => $interval, | ||
| 'next_run' => (int) $timestamp, | ||
| 'args_digest' => md5( (string) wp_json_encode( $args ) ), |
There was a problem hiding this comment.
Derive the digest from serialized cron arguments
When cron arguments contain objects with non-public state or strings that cannot be JSON-encoded, wp_json_encode() produces indistinguishable output such as {} or false, so distinct scheduled events receive the same args_digest. This makes the inventory conflate jobs precisely when their raw arguments are intentionally hidden; derive the digest from the serialized arguments (or retain the existing instance key, which WordPress computes from them) instead.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (4)
digitizer-site-worker/includes/tools/class-tool-check-core-checksums.php:172
- md5_file() failures (e.g., unreadable core file) are currently reported as "modified" because false triggers the mismatch branch. That conflates IO/permissions issues with actual checksum drift and can produce false-positive "modified" findings.
$md5 = md5_file( $path );
if ( false === $md5 || strtolower( $md5 ) !== strtolower( (string) $expected_md5 ) ) {
$modified[] = array(
'file' => $rel_file,
'expected_md5' => (string) $expected_md5,
);
}
digitizer-site-worker/includes/tools/class-tool-scan-executable-files.php:130
- coverage.unreadable_dirs mixes absolute and relative values: the base directory is recorded as an absolute path, but nested unreadables are recorded as relative paths. This makes the API output inconsistent and harder to interpret.
$abs = '' === $dir ? $base : $base . '/' . $dir;
$handle = @opendir( $abs );
if ( false === $handle ) {
$unreadable[] = '' === $dir ? $base : $dir;
continue;
digitizer-site-worker/includes/tools/class-tool-check-core-checksums.php:62
- get_returns() says the coverage object only includes { files_expected, files_checked, truncated, cap }, but execute() also returns coverage.unreadable_dirs. This makes the schema/contract inaccurate for consumers relying on tool metadata.
This issue also appears on line 166 of the same file.
public function get_returns() {
return array(
'modified' => 'array — core files whose hash differs from the manifest { file, expected_md5 }',
'missing' => 'array — manifest files absent on disk',
'unexpected' => 'array — files not in the manifest under wp-admin/, wp-includes/, or the WP root { file, size, mtime }',
'special' => 'array — non-regular files (symlink/FIFO/device) or oversized files at core paths { file, kind }',
'root_extra' => 'array — allowlisted host-added root files present (reported, not flagged)',
'coverage' => 'object — { files_expected, files_checked, truncated, cap } scan-coverage contract',
'error' => 'string — "manifest_unavailable" when the checksum manifest could not be fetched (no findings are reported in that case)',
);
digitizer-site-worker/includes/tools/class-tool-scan-executable-files.php:45
- get_returns() for scan_executable_files describes coverage as { total_seen, returned, truncated, cap }, but execute() also returns coverage.unreadable_dirs. The tool metadata should reflect the actual response shape so clients can handle unreadable paths explicitly.
This issue also appears on line 126 of the same file.
public function get_returns() {
return array(
'findings' => 'array — { file, kind: executable|htaccess|symlink, size, mtime, target? }',
'coverage' => 'object — { total_seen, returned, truncated, cap } bounded-coverage contract; an empty findings list with truncated=true means "nothing found before the cap", never "clean"',
);
…igest (Codex round 6) - Capability union extended to the full mutation-grade set (activate_plugins, switch_themes, install_themes, delete_users, promote_users, edit_themes, edit_files, unfiltered_html) — closing the family rather than chasing one capability per round. - audit_cron args digest uses serialize(): JSON collapses unencodable strings/objects to indistinguishable output, letting distinct arg sets share a digest; PHP serialization is total over cron-storable values. Suite 575 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d0d6ae0dad
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 'schedule' => $schedule ? $schedule : 'single', | ||
| 'interval' => $interval, | ||
| 'next_run' => (int) $timestamp, | ||
| 'args_digest' => md5( serialize( $args ) ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize |
There was a problem hiding this comment.
Reuse the stored cron instance digest
When a scheduled argument contains an object implementing __serialize() or __sleep(), this supposedly read-only audit invokes that userland hook again; the hook can mutate state, throw, or produce a digest different from the one used when the event was scheduled. The fresh problem introduced by the serialization-based collision fix is avoidable because each $instances array key is already WordPress's md5( serialize( $args ) ) identity, so capture and return that key instead of reserializing the arguments.
Useful? React with 👍 / 👎.
| $response = wp_remote_get( | ||
| $url, | ||
| array( | ||
| 'timeout' => 15, | ||
| 'sslverify' => true, | ||
| ) |
There was a problem hiding this comment.
Disable redirects for the HTTPS-only checksum fetch
If the checksum endpoint responds with a redirect to an http:// URL, wp_remote_get() follows redirects by default and accepts the plaintext destination's 200 response; sslverify => true only governs HTTPS connections and does not prohibit this downgrade. That lets a forged manifest green-light modified files in exactly the scenario this method claims to fail closed on, so disable redirects or validate every redirect target as HTTPS.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Suppressed comments (10)
tests/unit/SecurityAuditToolsTest.php:183
- This filter callback is invoked via apply_filters('aura_worker_core_checksums_base', ABSPATH), so it must accept the filtered value argument to avoid PHP 8+ ArgumentCountError.
$GLOBALS['_filters']['aura_worker_core_checksums_base'][] = static fn() => $base;
tests/unit/SecurityAuditToolsTest.php:203
- These filter callbacks are invoked via apply_filters() with at least one argument. As written they take 0 parameters and will fatally error on PHP 8+ (too many arguments).
$GLOBALS['_filters']['aura_worker_core_checksums_manifest'][] = static fn() => $manifest;
$GLOBALS['_filters']['aura_worker_core_checksums_base'][] = static fn() => $base;
tests/unit/SecurityAuditToolsTest.php:219
- This filter callback is invoked via apply_filters('aura_worker_scan_executable_dirs', $dirs), so it must accept the filtered value argument to avoid PHP 8+ ArgumentCountError.
private function exec_tool( string $dir ) {
$GLOBALS['_filters']['aura_worker_scan_executable_dirs'][] = static fn() => array( $dir );
return new Aura_Tool_Scan_Executable_Files();
}
tests/unit/SecurityAuditToolsTest.php:255
- This filter callback is invoked via apply_filters('aura_worker_scan_executable_dirs', $dirs); with 0 parameters it can fatally error on PHP 8+ due to extra arguments.
$GLOBALS['_filters']['aura_worker_scan_executable_dirs'][] = static fn() => array( $dir );
digitizer-site-worker/includes/tools/class-tool-check-core-checksums.php:59
- execute() returns coverage.unreadable_dirs, but get_returns() omits it. This makes the return-shape documentation inaccurate.
'coverage' => 'object — { files_expected, files_checked, truncated, cap } scan-coverage contract',
digitizer-site-worker/includes/tools/class-tool-check-core-checksums.php:424
- Root-level symlinks are reported as unexpected with size/mtime=0 but without indicating they are symlinks, despite the tool’s lstat/no-follow discipline. Including a kind (and lstat mtime) makes the observation explicit and avoids consumers misclassifying it as a regular file.
if ( is_link( $path ) ) {
$unexpected[] = array(
'file' => $entry,
'size' => 0,
'mtime' => 0,
tests/bootstrap.php:999
- sa_reset_state() resets HTTP response/error globals but does not reset the recorded HTTP call log. This can leak state between tests and make assertions that read $GLOBALS['_wp_http_calls'][0] order-dependent.
$GLOBALS['_cron_array'] = array();
$GLOBALS['_cron_schedules'] = null;
$GLOBALS['_http_response'] = null;
$GLOBALS['_http_error'] = false;
digitizer-site-worker/includes/tools/class-tool-scan-executable-files.php:171
- walk() treats any non-symlink, non-directory entry as a regular file and may call filesize()/filemtime() on it. An attacker can plant special files (FIFO/device) in uploads; metadata calls on these can block or error. Guard with is_file() before applying extension rules/metadata reads.
if ( is_dir( $path ) ) {
$stack[] = $rel;
continue;
}
digitizer-site-worker/includes/tools/class-tool-scan-executable-files.php:43
- execute() includes coverage.unreadable_dirs, but get_returns() doesn't document it. This makes the tool contract misleading for API consumers relying on get_returns().
public function get_returns() {
return array(
'findings' => 'array — { file, kind: executable|htaccess|symlink, size, mtime, target? }',
'coverage' => 'object — { total_seen, returned, truncated, cap } bounded-coverage contract; an empty findings list with truncated=true means "nothing found before the cap", never "clean"',
);
digitizer-site-worker/includes/tools/class-tool-check-core-checksums.php:56
- If unexpected entries can include a kind for symlinks (as in scan_root()), get_returns() should document that optional field so consumers can rely on it.
This issue also appears on line 59 of the same file.
'unexpected' => 'array — files not in the manifest under wp-admin/, wp-includes/, or the WP root { file, size, mtime }',
| $GLOBALS['_filters']['aura_worker_core_checksums_manifest'][] = static fn() => $manifest; | ||
| $GLOBALS['_filters']['aura_worker_core_checksums_base'][] = static fn() => $base; |
… (Codex round 7) - args_digest is now the cron instance's own array key — WordPress already stores each instance under md5(serialize(args)), and re-serializing an object argument would invoke its __serialize()/__sleep() userland hooks, which a read-only audit must never execute. - The checksum fetch sets redirection=0: a redirect to an http:// URL would silently reopen the plaintext-downgrade hole the HTTPS-only fetch closes; api.wordpress.org serves the endpoint directly, anything else is manifest_unavailable. Suite 575 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 910d856c47
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 'fields' => 'all', | ||
| ) | ||
| ); | ||
| foreach ( array( 'manage_options', 'update_core', 'update_plugins', 'update_themes', 'install_plugins', 'install_themes', 'activate_plugins', 'switch_themes', 'edit_users', 'delete_users', 'promote_users', 'edit_plugins', 'edit_themes', 'edit_files', 'unfiltered_html' ) as $capability ) { |
There was a problem hiding this comment.
Include code-deletion capabilities in the privileged union
On a single-site installation, a custom-role user granted delete_plugins or delete_themes but none of the capabilities in this array is omitted, even though that account can remove executable site code and disrupt production. Fresh evidence beyond the earlier capability findings is that the final “full mutation-grade” union still excludes both deletion capabilities; include them in the queried union.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 821205a. Per the agreed stop rule (marginal-only round — capability enumeration + an edge-case flag), closing the loop and merging once CI is green.
| 'is_admin' => $is_admin, | ||
| 'capability_outside_role' => ! $is_admin, | ||
| 'user_registered' => $registered, | ||
| 'recently_created' => ( false !== $reg_ts ) && ( ( $now - $reg_ts ) < static::RECENT_DAYS * DAY_IN_SECONDS ), |
There was a problem hiding this comment.
Exclude future timestamps from the recently-created flag
When an imported, migrated, or tampered account has a future user_registered value, $now - $reg_ts is negative and therefore always below the 30-day threshold, so the account is reported as recently created until 30 days after that future date. Require the computed age to be nonnegative before setting this flag.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 821205a. Per the agreed stop rule (marginal-only round — capability enumeration + an edge-case flag), closing the loop and merging once CI is green.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (9)
tests/unit/SecurityAuditToolsTest.php:90
- The test registers
aura_worker_core_checksums_*filter callbacks that accept zero arguments, butapply_filters()passes at least the current$value(and for the manifest filter also$version/$locale). In PHP 7.4 this will throw an ArgumentCountError and fail the test run. Make the callbacks accept the arguments WordPress passes (or use variadics).
private function checksums_tool( string $base, ?array $manifest ) {
$GLOBALS['_filters']['aura_worker_core_checksums_manifest'][] = static fn() => $manifest;
$GLOBALS['_filters']['aura_worker_core_checksums_base'][] = static fn() => $base;
return new Aura_Tool_Check_Core_Checksums();
tests/unit/SecurityAuditToolsTest.php:219
- The test registers an
aura_worker_scan_executable_dirsfilter callback that accepts zero arguments, butapply_filters()will pass the current$dirsvalue. This will raise an ArgumentCountError in PHP 7.4.
private function exec_tool( string $dir ) {
$GLOBALS['_filters']['aura_worker_scan_executable_dirs'][] = static fn() => array( $dir );
return new Aura_Tool_Scan_Executable_Files();
}
tests/unit/SecurityAuditToolsTest.php:184
aura_worker_core_checksums_baseis registered with a zero-arg callback, but the filter receives at least the current$basevalue. This will throw an ArgumentCountError during the HTTPS-only fetch test.
public function test_checksums_live_fetch_is_https_only(): void {
[ $base ] = $this->core_fixture();
$GLOBALS['_filters']['aura_worker_core_checksums_base'][] = static fn() => $base;
$GLOBALS['_http_error'] = true;
tests/bootstrap.php:920
rawurlencode_deepis guarded withfunction_exists()but not actually defined. This no-op block is misleading and will not prevent a fatal error if any code path starts callingrawurlencode_deep()in tests.
if ( ! function_exists( 'rawurlencode_deep' ) ) {
// no-op helper space reserved
}
digitizer-site-worker/includes/tools/class-tool-check-core-checksums.php:432
- Root-level unexpected symlinks are currently reported with
size=0andmtime=0but without any indication they are symlinks or what they point to. This loses the key security-relevant fact and makes the result look like an empty/unknown stat. Includekind+target(and an lstat-based mtime) to match the tool’s “symlinks reported, never followed” contract.
if ( is_link( $path ) ) {
$unexpected[] = array(
'file' => $entry,
'size' => 0,
'mtime' => 0,
);
continue;
}
tests/bootstrap.php:1000
sa_reset_state()resets the HTTP stub inputs (_http_response,_http_error) but does not reset the recorded call log (_wp_http_calls). This can cause cross-test leakage and order-dependent assertions when multiple tests exercisewp_remote_get().
$GLOBALS['_is_multisite'] = false;
$GLOBALS['_site_options'] = array();
$GLOBALS['_user_meta'] = array();
$GLOBALS['_cron_array'] = array();
$GLOBALS['_cron_schedules'] = null;
$GLOBALS['_http_response'] = null;
$GLOBALS['_http_error'] = false;
if ( isset( $GLOBALS['wpdb'] ) ) {
digitizer-site-worker/includes/tools/class-tool-audit-admin-accounts.php:50
get_returns()documentsapp_passwordsas a simple count, butapp_password_count()can return the string'oversized_skipped'when the raw usermeta value exceeds the parse-safe threshold. This mismatch makes the tool contract ambiguous for consumers.
public function get_returns() {
return array(
'accounts' => 'array — { user_login, user_id, roles, is_admin, capability_outside_role, user_registered, recently_created, network_super_admin, app_passwords }',
'super_admins' => 'array|string — multisite network super-admin logins, or "oversized_skipped" when the raw site_admins option exceeds the parse-safe size (itself a red flag); "not_multisite" on single site',
'coverage' => 'object — { total_seen, returned, truncated, cap } bounded-coverage contract',
);
digitizer-site-worker/includes/tools/class-tool-scan-executable-files.php:131
unreadable_dirsmixes absolute paths for the scan root with relative paths for nested directories. This makes the output inconsistent and harder to interpret (especially if multiple scan roots are configured). Prefer a single representation (e.g., always absolute paths).
$dir = array_pop( $stack );
$abs = '' === $dir ? $base : $base . '/' . $dir;
$handle = @opendir( $abs );
if ( false === $handle ) {
$unreadable[] = '' === $dir ? $base : $dir;
continue;
digitizer-site-worker/includes/tools/class-tool-check-core-checksums.php:324
special_kind()callsfilesize()without suppression. If a core file exists but is unreadable (permissions/IO),filesize()can emit warnings during a REST request. Other filesystem reads in this PR already use@to avoid noisy warnings; this should be consistent.
$size = filesize( $path );
if ( false !== $size && $size > static::MAX_FILE_BYTES ) {
return 'oversized';
}
… handling (Codex round 8) recently_created now requires a non-negative age; a future user_registered value gets its own registered_in_future fact — an imported/tampered account is an anomaly, not a 'recent' one. delete_plugins/delete_themes join the mutation-grade capability union. Suite 575 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (5)
tests/bootstrap.php:920
- The
rawurlencode_deepstub block is empty, so the function still doesn’t exist in the test runtime. If any code path ends up callingrawurlencode_deep()(directly or indirectly), tests will fatal. Either remove this block or define a minimal no-op stub.
if ( ! function_exists( 'rawurlencode_deep' ) ) {
// no-op helper space reserved
}
digitizer-site-worker/includes/tools/class-tool-scan-executable-files.php:45
get_returns()doesn’t document thecoverage.unreadable_dirsfield thatexecute()actually returns. Sinceget_returns()is surfaced via list_tools metadata, this becomes an API/schema mismatch for tool consumers.
public function get_returns() {
return array(
'findings' => 'array — { file, kind: executable|htaccess|symlink, size, mtime, target? }',
'coverage' => 'object — { total_seen, returned, truncated, cap } bounded-coverage contract; an empty findings list with truncated=true means "nothing found before the cap", never "clean"',
);
digitizer-site-worker/includes/tools/class-tool-check-core-checksums.php:62
get_returns()doesn’t match the actual response shape:execute()includescoverage.unreadable_dirsand, onmanifest_unavailable, also returnsversionandlocale. Since tool metadata is used by/tools/list, this should be reflected in the declared schema.
'unexpected' => 'array — files not in the manifest under wp-admin/, wp-includes/, or the WP root { file, size, mtime }',
'special' => 'array — non-regular files (symlink/FIFO/device) or oversized files at core paths { file, kind }',
'root_extra' => 'array — allowlisted host-added root files present (reported, not flagged)',
'coverage' => 'object — { files_expected, files_checked, truncated, cap } scan-coverage contract',
'error' => 'string — "manifest_unavailable" when the checksum manifest could not be fetched (no findings are reported in that case)',
);
digitizer-site-worker/includes/tools/class-tool-audit-admin-accounts.php:50
execute()returnsregistered_in_futureand can return'oversized_skipped'forapp_passwords, but theaccountsschema inget_returns()doesn’t document either. This is an API/schema mismatch for tool consumers relying on/tools/listmetadata.
public function get_returns() {
return array(
'accounts' => 'array — { user_login, user_id, roles, is_admin, capability_outside_role, user_registered, recently_created, network_super_admin, app_passwords }',
'super_admins' => 'array|string — multisite network super-admin logins, or "oversized_skipped" when the raw site_admins option exceeds the parse-safe size (itself a red flag); "not_multisite" on single site',
'coverage' => 'object — { total_seen, returned, truncated, cap } bounded-coverage contract',
);
digitizer-site-worker/includes/tools/class-tool-scan-executable-files.php:131
- When the scan root directory can’t be opened,
unreadable_dirsincludes the absolute$basepath ($unreadable[] = $base). This can leak server filesystem paths in the tool response, while subdirectories are returned as relative paths. Consider keepingunreadable_dirsconsistently relative (like thefilefields) to avoid info disclosure and ambiguity.
$abs = '' === $dir ? $base : $base . '/' . $dir;
$handle = @opendir( $abs );
if ( false === $handle ) {
$unreadable[] = '' === $dir ? $base : $dir;
continue;
Implements the K5 v1 spec approved today (Digitizers/Aura#365, 13 Codex rounds; owner decisions §7: no paid advisory feed, Phase B data-first, tools free / fleet aggregation paid).
check_core_checksumsscan_executable_filesaudit_admin_accountssite_adminsreadaudit_cronEvery result carries the bounded-coverage contract (
total_seen/returned/truncated+cap). Facts, not verdicts — no 'malware detected' claims anywhere.Also the spec's honesty fixes: README/readme.txt vulnerability-DB overclaim corrected (update-currency check, wp.org plugins only), tool tables 21→25, annotations-first classifier line.
Tests: 62 new assertions across 21 fixture-based tests (no network); suite 573 green (was 511).
🤖 Generated with Claude Code