diff --git a/README.md b/README.md index 80e869f..98979ae 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,31 @@ All routes require `manage_options` (filter: `asa_capability`): - `GET /wp-json/asa/v1/servers` — MCP server inventory + adapter state - `GET /wp-json/asa/v1/export` — report as a download attachment +### Fleet summary + +The report carries a compact `fleet_summary` — a one-glance digest of this +site's agent-surface posture, meant for fleet tooling that needs "how exposed is +this one site?" without the full per-ability report: + +```json +"fleet_summary": { + "sev": "crit", + "highest_severity": "critical", + "agent_reachable": 19, + "agent_reachable_write": 3, + "findings_by_severity": { "critical": 3, "high": 7, "medium": 9, "low": 6, "info": 1 }, + "analysis_errors": 0 +} +``` + +`sev` (`ok`/`warn`/`crit`) is derived conservatively from the highest finding +severity present (critical/high → `crit`, medium/low → `warn`, info or none → +`ok`) and, like the rest of the report, means "no issues detected", never +"safe". It is a pure projection of the audit — no extra analysis — and is +consumed as a fleet-health signal (e.g. by a MainWP Fleet Health Monitor via its +`fhm_extra_signals` filter). Treat the field as optional: a report cached before +an upgrade may briefly omit it. + ## WP-CLI & CI The plugin registers `wp asa audit` (read-only, like everything else): diff --git a/src/Report/class-audit-runner.php b/src/Report/class-audit-runner.php index 1505821..aab8d0d 100644 --- a/src/Report/class-audit-runner.php +++ b/src/Report/class-audit-runner.php @@ -140,21 +140,108 @@ private function compute() { } return [ - 'generated_at' => gmdate( 'c' ), - 'from_cache' => false, - 'environment' => [ + 'generated_at' => gmdate( 'c' ), + 'from_cache' => false, + 'environment' => [ 'plugin_version' => ASA_VERSION, 'wp_version' => is_string( $wp_version ) ? $wp_version : '', 'php_version' => PHP_VERSION, 'mcp_adapter' => $adapter_state, ], - 'summary' => $summary, - 'coverage' => $this->coverage_notes(), - 'servers' => $public_servers, - 'abilities' => $abilities, + 'summary' => $summary, + 'fleet_summary' => $this->fleet_summary( $summary ), + 'coverage' => $this->coverage_notes(), + 'servers' => $public_servers, + 'abilities' => $abilities, ]; } + /** + * A compact, transport-agnostic digest of this site's agent-surface posture. + * + * This is the stable contract that fleet consumers read — a MainWP child + * reporter, a CI gate, or anything that needs "how bad is this one site?" + * without the full per-ability report. It is a pure projection of the + * already-computed $summary: no new analysis, no I/O, no state. Keeping it + * small is deliberate — a fleet payload should carry counts and a grade, not + * ability descriptors (which would enlarge what leaves the site and duplicate + * the full report). + * + * `sev` speaks the Fleet Health Monitor vocabulary (`ok|warn|crit`) so an + * `fhm_extra_signals` consumer can hand it straight to the grade. Note this + * grade reflects the *highest finding severity present*, which is a smell + * signal, not a safety proof — consistent with §5 (a green tile is "no issues + * detected", never "safe"). + * + * @param array $summary The computed report summary. + * @return array The fleet digest. + */ + private function fleet_summary( array $summary ) { + $by_severity = isset( $summary['findings_by_severity'] ) && is_array( $summary['findings_by_severity'] ) + ? $summary['findings_by_severity'] + : []; + + // Highest severity that actually has ≥1 finding (most severe first). + $highest = null; + foreach ( Finding::SEVERITY_ORDER as $severity ) { + if ( ! empty( $by_severity[ $severity ] ) ) { + $highest = $severity; + break; + } + } + + $by_severity_ints = []; + foreach ( $by_severity as $severity => $count ) { + $by_severity_ints[ (string) $severity ] = $this->int_of( $count ); + } + + return [ + 'sev' => $this->derive_sev( $highest ), + 'highest_severity' => $highest, // null when no findings at all. + 'agent_reachable' => $this->int_of( isset( $summary['agent_reachable'] ) ? $summary['agent_reachable'] : 0 ), + 'agent_reachable_write' => $this->int_of( isset( $summary['agent_reachable_write'] ) ? $summary['agent_reachable_write'] : 0 ), + 'findings_by_severity' => $by_severity_ints, + 'analysis_errors' => $this->int_of( isset( $summary['analysis_errors'] ) ? $summary['analysis_errors'] : 0 ), + ]; + } + + /** + * Narrow a mixed value to a non-negative int for the digest. + * + * The summary is typed `array`, so its counts are `mixed` at + * the type level; this proves the int for both the reader and the analyzer. + * + * @param mixed $value Any value; only numerics contribute a count. + * @return int The value as an int, or 0 when not numeric. + */ + private function int_of( $value ) { + return is_numeric( $value ) ? (int) $value : 0; + } + + /** + * Map the highest finding severity to the Fleet Health `ok|warn|crit` grade. + * + * Critical/high map to crit (an agent-reachable write or missing gate is a + * fleet alarm); medium/low map to warn (a smell worth surfacing, not an + * alarm); info or no findings map to ok. Deliberately conservative: info-only + * sites (e.g. a bare ASA011 meta-tool awareness note) do not raise a grade. + * + * @param string|null $highest Highest present severity, or null. + * @return string One of 'ok'|'warn'|'crit'. + */ + private function derive_sev( $highest ) { + switch ( $highest ) { + case Finding::SEVERITY_CRITICAL: + case Finding::SEVERITY_HIGH: + return 'crit'; + case Finding::SEVERITY_MEDIUM: + case Finding::SEVERITY_LOW: + return 'warn'; + default: + return 'ok'; + } + } + /** * Standing statements about what this audit does NOT cover. * diff --git a/tests/Integration/report-integration-test.php b/tests/Integration/report-integration-test.php index 40981a6..a296741 100644 --- a/tests/Integration/report-integration-test.php +++ b/tests/Integration/report-integration-test.php @@ -139,4 +139,25 @@ public function test_summary_counts_are_consistent() { $this->assertGreaterThanOrEqual( 1, $summary['findings_by_severity']['critical'] ); $this->assertGreaterThanOrEqual( 1, $summary['agent_reachable_write'] ); } + + public function test_fleet_summary_is_a_faithful_projection_of_the_summary() { + $this->assertArrayHasKey( 'fleet_summary', self::$report ); + $fleet = self::$report['fleet_summary']; + $summary = self::$report['summary']; + + // Shape: the compact digest a fleet consumer reads. + foreach ( [ 'sev', 'highest_severity', 'agent_reachable', 'agent_reachable_write', 'findings_by_severity', 'analysis_errors' ] as $key ) { + $this->assertArrayHasKey( $key, $fleet, "fleet_summary missing $key" ); + } + + // It is a pure projection — counts equal the source summary's. + $this->assertSame( (int) $summary['agent_reachable'], $fleet['agent_reachable'] ); + $this->assertSame( (int) $summary['agent_reachable_write'], $fleet['agent_reachable_write'] ); + $this->assertSame( array_map( 'intval', $summary['findings_by_severity'] ), $fleet['findings_by_severity'] ); + + // The fixtures produce ≥1 critical finding, so the grade must be 'crit' + // and the highest severity 'critical'. + $this->assertSame( 'critical', $fleet['highest_severity'] ); + $this->assertSame( 'crit', $fleet['sev'] ); + } } diff --git a/tests/Unit/fleet-summary-test.php b/tests/Unit/fleet-summary-test.php new file mode 100644 index 0000000..d8a5966 --- /dev/null +++ b/tests/Unit/fleet-summary-test.php @@ -0,0 +1,112 @@ +setAccessible( true ); + return $ref->invokeArgs( $runner, $args ); + } + + /** + * A summary array with the given findings-by-severity counts. + * + * @param array $by_severity Severity => count. + * @param int $reachable_write Agent-reachable writes. + * @return array + */ + private function summary( array $by_severity, $reachable_write = 0 ) { + return [ + 'agent_reachable' => 3, + 'agent_reachable_write' => $reachable_write, + 'analysis_errors' => 0, + 'findings_by_severity' => array_merge( + array_fill_keys( Finding::SEVERITY_ORDER, 0 ), + $by_severity + ), + ]; + } + + public function test_critical_finding_grades_crit() { + $fleet = $this->invoke( 'fleet_summary', [ $this->summary( [ 'critical' => 1 ] ) ] ); + + $this->assertSame( 'crit', $fleet['sev'] ); + $this->assertSame( 'critical', $fleet['highest_severity'] ); + } + + public function test_high_finding_grades_crit() { + $fleet = $this->invoke( 'fleet_summary', [ $this->summary( [ 'high' => 2 ] ) ] ); + + $this->assertSame( 'crit', $fleet['sev'] ); + $this->assertSame( 'high', $fleet['highest_severity'] ); + } + + public function test_medium_and_low_grade_warn() { + $this->assertSame( 'warn', $this->invoke( 'fleet_summary', [ $this->summary( [ 'medium' => 1 ] ) ] )['sev'] ); + $this->assertSame( 'warn', $this->invoke( 'fleet_summary', [ $this->summary( [ 'low' => 1 ] ) ] )['sev'] ); + } + + public function test_info_only_grades_ok() { + // An info-only site (e.g. a bare ASA011 awareness note) is NOT a fleet alarm. + $fleet = $this->invoke( 'fleet_summary', [ $this->summary( [ 'info' => 3 ] ) ] ); + + $this->assertSame( 'ok', $fleet['sev'] ); + $this->assertSame( 'info', $fleet['highest_severity'] ); + } + + public function test_no_findings_grades_ok_with_null_highest() { + $fleet = $this->invoke( 'fleet_summary', [ $this->summary( [] ) ] ); + + $this->assertSame( 'ok', $fleet['sev'] ); + $this->assertNull( $fleet['highest_severity'] ); + } + + public function test_highest_severity_wins_when_multiple_present() { + // low + critical together → the grade reflects the worst present. + $fleet = $this->invoke( 'fleet_summary', [ $this->summary( [ 'low' => 5, 'critical' => 1 ] ) ] ); + + $this->assertSame( 'critical', $fleet['highest_severity'] ); + $this->assertSame( 'crit', $fleet['sev'] ); + } + + public function test_projection_carries_counts_verbatim() { + $fleet = $this->invoke( 'fleet_summary', [ $this->summary( [ 'high' => 1 ], 4 ) ] ); + + $this->assertSame( 3, $fleet['agent_reachable'] ); + $this->assertSame( 4, $fleet['agent_reachable_write'] ); + $this->assertSame( 0, $fleet['analysis_errors'] ); + $this->assertSame( 1, $fleet['findings_by_severity']['high'] ); + } + + public function test_tolerates_a_summary_missing_findings_by_severity() { + // Defensive: a malformed summary must not fatal — grade defaults to ok. + $fleet = $this->invoke( 'fleet_summary', [ [ 'agent_reachable' => 0 ] ] ); + + $this->assertSame( 'ok', $fleet['sev'] ); + $this->assertNull( $fleet['highest_severity'] ); + $this->assertSame( [], $fleet['findings_by_severity'] ); + } +}