Skip to content

feat(K5): security-audit read surface — four bounded read-only tools - #58

Merged
BenKalsky merged 9 commits into
mainfrom
feat/k5-security-audit-v1
Aug 4, 2026
Merged

feat(K5): security-audit read surface — four bounded read-only tools#58
BenKalsky merged 9 commits into
mainfrom
feat/k5-security-audit-v1

Conversation

@BenKalsky

Copy link
Copy Markdown
Member

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).

Tool What it reports
check_core_checksums modified/missing/unexpected core files vs the official wp.org manifest (HTTPS-only fetch, fail-closed), incl. ABSPATH-root implants; lstat discipline
scan_executable_files PHP/executables, .htaccess overrides, symlinks (with target, never followed) in uploads
audit_admin_accounts admins + recency, caps outside role, app-password counts (pre-size-checked), multisite super admins via direct site_admins read
audit_cron bounded event inventory + sub-60s / unresolved-callback fact-flags

Every 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

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>
Copilot AI lite review requested due to automatic review settings August 4, 2026 15:54
@BenKalsky

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d7a3f49.

continue;
}

$md5 = md5_file( $path );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d7a3f49.

'file' => $rel,
'kind' => 'symlink',
'size' => 0,
'mtime' => (int) @filemtime( $path ),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d7a3f49.

…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>
@BenKalsky

Copy link
Copy Markdown
Member Author

@codex review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 $cap with max_entries even if the scan was already truncated for another reason (e.g. max_files). That can make the returned coverage.cap misleading.

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.

Comment thread tests/bootstrap.php
Comment on lines +912 to +915
if ( ! function_exists( 'add_query_arg' ) ) {
function add_query_arg( array $args, string $url ): string {
return $url . ( str_contains( $url, '?' ) ? '&' : '?' ) . http_build_query( $args );
}
Comment on lines +197 to +203
$url = add_query_arg(
array(
'version' => rawurlencode( $version ),
'locale' => rawurlencode( $locale ),
),
'https://api.wordpress.org/core/checksums/1.0/'
);
Comment on lines +154 to +168
$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 : '',
),
);
Comment on lines +288 to +293
if ( ++$entries > static::MAX_ENTRIES ) {
$truncated = true;
$cap = 'max_entries';
closedir( $handle );
return;
}
Comment on lines +351 to +357
if ( is_link( $path ) ) {
$unexpected[] = array(
'file' => $entry,
'size' => 0,
'mtime' => 0,
);
continue;
Comment on lines +141 to +148
$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'
)
);
Comment on lines +143 to +152
$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'
)
);
}
Comment on lines +178 to +188
$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'
)
);
}
Copilot AI review requested due to automatic review settings August 4, 2026 16:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() in special_kind() is unsuppressed, but this path is explicitly handling potentially hostile/unusual filesystem objects. If filesize() 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/mtime forced to 0. This loses useful facts and doesn’t follow the stated lstat discipline; you can capture link metadata via lstat() without following the target.
				$unexpected[] = array(
					'file'  => $entry,
					'size'  => 0,
					'mtime' => 0,
				);

tests/bootstrap.php:915

  • add_query_arg() test stub uses str_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 ) ) {

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +122 to +124
$handle = @opendir( $abs );
if ( false === $handle ) {
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c14958c.

Comment on lines +182 to +183
'files_expected' => count( $manifest ),
'files_checked' => $checked,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c14958c.

… 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>
Copilot AI review requested due to automatic review settings August 4, 2026 16:05
@BenKalsky

Copy link
Copy Markdown
Member Author

@codex review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"',
		);
	}

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread tests/bootstrap.php Outdated

if ( ! function_exists( 'add_query_arg' ) ) {
function add_query_arg( array $args, string $url ): string {
return $url . ( str_contains( $url, '?' ) ? '&' : '?' ) . http_build_query( $args );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b67479b.

Comment on lines +340 to +342
$handle = @opendir( $base . $rel_dir );
if ( false === $handle ) {
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b67479b.

…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>
Copilot AI review requested due to automatic review settings August 4, 2026 16:13
@BenKalsky

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 42005d9: union of role=administrator and capability=manage_options, deduped by user ID.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.php or .htaccess) could be a symlink and would only appear in root_extra, with no indication it’s a symlink, which undermines the “lstat discipline” guarantee. Also, root-level symlinks are currently reported under unexpected with mtime=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_dirs currently 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 as modified (even though the hash couldn’t be computed). It’s more accurate (and quieter) to treat unreadable files as a special kind 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,

Comment thread tests/bootstrap.php
Comment on lines +918 to +920
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>
Copilot AI review requested due to automatic review settings August 4, 2026 16:18
@BenKalsky

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +94 to +98
$by_cap = get_users(
array(
'capability' => 'manage_options',
'number' => static::MAX_ACCOUNTS + 1,
'fields' => 'all',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9e03cbe.

Comment on lines +86 to +89
* `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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9e03cbe.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/mtime as 0. Since the tool already promises lstat discipline, it should use lstat() 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_Query doesn't support a native capability query arg, so this call will be ignored and may return arbitrary users (up to the number limit), potentially missing the actual manage_options holders 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 size as 0 even though you already lstat() 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_dirs currently 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'], but get_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'], but get_returns() doesn’t include it in the documented coverage shape. 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>
Copilot AI review requested due to automatic review settings August 4, 2026 16:25
@BenKalsky

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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 ) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d0d6ae0.

'schedule' => $schedule ? $schedule : 'single',
'interval' => $interval,
'next_run' => (int) $timestamp,
'args_digest' => md5( (string) wp_json_encode( $args ) ),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d0d6ae0.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Copilot AI review requested due to automatic review settings August 4, 2026 16:32
@BenKalsky

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 910d856.

Comment on lines +242 to +247
$response = wp_remote_get(
$url,
array(
'timeout' => 15,
'sslverify' => true,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 910d856.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 }',

Comment on lines +88 to +89
$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>
Copilot AI review requested due to automatic review settings August 4, 2026 16:38
@BenKalsky

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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 ) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, but apply_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_dirs filter callback that accepts zero arguments, but apply_filters() will pass the current $dirs value. 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_base is registered with a zero-arg callback, but the filter receives at least the current $base value. 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_deep is guarded with function_exists() but not actually defined. This no-op block is misleading and will not prevent a fatal error if any code path starts calling rawurlencode_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=0 and mtime=0 but 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. Include kind + 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 exercise wp_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() documents app_passwords as a simple count, but app_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_dirs mixes 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() calls filesize() 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>
Copilot AI review requested due to automatic review settings August 4, 2026 16:44
@BenKalsky
BenKalsky merged commit 2cc8f0e into main Aug 4, 2026
8 checks passed
@BenKalsky
BenKalsky deleted the feat/k5-security-audit-v1 branch August 4, 2026 16:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_deep stub block is empty, so the function still doesn’t exist in the test runtime. If any code path ends up calling rawurlencode_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 the coverage.unreadable_dirs field that execute() actually returns. Since get_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() includes coverage.unreadable_dirs and, on manifest_unavailable, also returns version and locale. 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() returns registered_in_future and can return 'oversized_skipped' for app_passwords, but the accounts schema in get_returns() doesn’t document either. This is an API/schema mismatch for tool consumers relying on /tools/list metadata.
	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_dirs includes the absolute $base path ($unreadable[] = $base). This can leak server filesystem paths in the tool response, while subdirectories are returned as relative paths. Consider keeping unreadable_dirs consistently relative (like the file fields) to avoid info disclosure and ambiguity.
			$abs    = '' === $dir ? $base : $base . '/' . $dir;
			$handle = @opendir( $abs );
			if ( false === $handle ) {
				$unreadable[] = '' === $dir ? $base : $dir;
				continue;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants